Большой рефакторинг с кодексом
This commit is contained in:
@@ -2,12 +2,12 @@
|
||||
runtime: 5
|
||||
|
||||
# === HTTP-канал управления (ConfigManagerV2): /health, /actions/start, /actions/stop ===
|
||||
# management:
|
||||
# enabled: true
|
||||
# host: "0.0.0.0"
|
||||
# port: 8000
|
||||
# timeout: 3
|
||||
# health_timeout: 30
|
||||
management:
|
||||
enabled: true
|
||||
host: "0.0.0.0"
|
||||
port: 8000
|
||||
timeout: 3
|
||||
health_timeout: 30
|
||||
|
||||
# === Логирование ===
|
||||
log:
|
||||
|
||||
@@ -6,14 +6,10 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from config_manager import ConfigManager
|
||||
from config_manager.v2.core import LogManager
|
||||
from config_manager.v2 import ManagementServerSettings
|
||||
from config_manager.v2.control import HttpControlChannel
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
# Таймаут health: без успешного execute() дольше этого времени — unhealthy.
|
||||
HEALTH_TIMEOUT = 3.0
|
||||
|
||||
|
||||
class MyApp(ConfigManager):
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -31,21 +27,20 @@ class MyApp(ConfigManager):
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
log_manager = LogManager()
|
||||
# Один объект: и HTTP management-сервер (enabled, port), и health (health_timeout).
|
||||
management_settings = ManagementServerSettings(
|
||||
enabled=True,
|
||||
port=8000,
|
||||
health_timeout=HEALTH_TIMEOUT,
|
||||
)
|
||||
config_path = Path(__file__).parent / "config.yaml"
|
||||
print(config_path)
|
||||
app = MyApp(
|
||||
str(config_path),
|
||||
log_manager=log_manager,
|
||||
management_settings=management_settings,
|
||||
control_channels=lambda m: [
|
||||
HttpControlChannel(
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
timeout=3,
|
||||
health_provider=m.get_health_provider(),
|
||||
)
|
||||
],
|
||||
)
|
||||
logger.info("App starting (health_timeout=%s)", HEALTH_TIMEOUT)
|
||||
logger.info("App starting")
|
||||
# Менеджер запускаем в фоне (start() не возвращает управление до stop).
|
||||
asyncio.create_task(app.start())
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def test_control_channel_can_stop_manager(tmp_path):
|
||||
cfg.write_text("log: {}\nmanagement: { enabled: false }\n", encoding="utf-8")
|
||||
|
||||
channel = DummyControlChannel()
|
||||
app = ControlledApp(str(cfg), control_channel=channel)
|
||||
app = ControlledApp(str(cfg), control_channels=[channel])
|
||||
|
||||
runner = asyncio.create_task(app.start())
|
||||
await asyncio.sleep(0.12)
|
||||
@@ -47,11 +47,14 @@ def test_control_channel_can_stop_manager(tmp_path):
|
||||
|
||||
status_text = await channel.on_status()
|
||||
assert "state=running" in status_text
|
||||
assert "worker_inflight=" in status_text
|
||||
assert "worker_timed_out_inflight=" in status_text
|
||||
|
||||
stop_text = await channel.on_stop()
|
||||
assert "stop signal accepted" in stop_text
|
||||
|
||||
await runner
|
||||
# Менеджер при остановке не вызывает control_channel.stop() (канал остаётся доступным)
|
||||
await app.stop()
|
||||
assert channel.stopped is True
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
153
tests/v2/test_runtime_resilience.py
Normal file
153
tests/v2/test_runtime_resilience.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
||||
from config_manager.v2 import ConfigManagerV2
|
||||
from config_manager.v2.control.base import ControlChannel, StartHandler, StatusHandler, StopHandler
|
||||
|
||||
|
||||
class DummyControlChannel(ControlChannel):
|
||||
def __init__(self):
|
||||
self.on_start: StartHandler | None = None
|
||||
self.on_stop: StopHandler | None = None
|
||||
self.on_status: StatusHandler | None = None
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
|
||||
async def start(self, on_start: StartHandler, on_stop: StopHandler, on_status: StatusHandler) -> None:
|
||||
self.on_start = on_start
|
||||
self.on_stop = on_stop
|
||||
self.on_status = on_status
|
||||
self.started = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.stopped = True
|
||||
|
||||
|
||||
class RestartableApp(ConfigManagerV2):
|
||||
DEFAULT_UPDATE_INTERVAL = 0.05
|
||||
DEFAULT_WORK_INTERVAL = 0.05
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.calls = 0
|
||||
|
||||
def execute(self) -> None:
|
||||
self.calls += 1
|
||||
|
||||
|
||||
class TimeoutAwareApp(ConfigManagerV2):
|
||||
DEFAULT_UPDATE_INTERVAL = 0.05
|
||||
DEFAULT_WORK_INTERVAL = 0.02
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.calls = 0
|
||||
self.active = 0
|
||||
self.max_active = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def execute(self) -> None:
|
||||
with self._lock:
|
||||
self.calls += 1
|
||||
self.active += 1
|
||||
self.max_active = max(self.max_active, self.active)
|
||||
try:
|
||||
time.sleep(0.2)
|
||||
finally:
|
||||
with self._lock:
|
||||
self.active -= 1
|
||||
|
||||
|
||||
class NormalSingleThreadApp(ConfigManagerV2):
|
||||
DEFAULT_UPDATE_INTERVAL = 0.05
|
||||
DEFAULT_WORK_INTERVAL = 0.02
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.calls = 0
|
||||
self.active = 0
|
||||
self.max_active = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def execute(self) -> None:
|
||||
with self._lock:
|
||||
self.calls += 1
|
||||
self.active += 1
|
||||
self.max_active = max(self.max_active, self.active)
|
||||
try:
|
||||
time.sleep(0.03)
|
||||
finally:
|
||||
with self._lock:
|
||||
self.active -= 1
|
||||
|
||||
|
||||
def test_control_channel_stop_and_start_resumes_execute(tmp_path):
|
||||
async def scenario() -> None:
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("log: {}\nmanagement: { enabled: false }\n", encoding="utf-8")
|
||||
|
||||
channel = DummyControlChannel()
|
||||
app = RestartableApp(str(cfg), control_channels=[channel])
|
||||
await app.start()
|
||||
await asyncio.sleep(0.2)
|
||||
before_stop = app.calls
|
||||
assert before_stop > 0
|
||||
|
||||
assert channel.on_stop is not None
|
||||
assert channel.on_start is not None
|
||||
stop_text = await channel.on_stop()
|
||||
assert "stop signal accepted" in stop_text
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
after_stop = app.calls
|
||||
assert after_stop == before_stop
|
||||
|
||||
start_text = await channel.on_start()
|
||||
assert "start signal accepted" in start_text
|
||||
await asyncio.sleep(0.2)
|
||||
assert app.calls > after_stop
|
||||
|
||||
await app.stop()
|
||||
assert channel.stopped is True
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_normal_mode_uses_single_inflight_execute(tmp_path):
|
||||
async def scenario() -> None:
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("log: {}\nmanagement: { enabled: false }\n", encoding="utf-8")
|
||||
|
||||
app = NormalSingleThreadApp(str(cfg))
|
||||
await app.start()
|
||||
await asyncio.sleep(0.25)
|
||||
health = await app.get_health_provider()()
|
||||
await app.stop()
|
||||
|
||||
assert app.calls >= 2
|
||||
assert app.max_active == 1
|
||||
assert health["status"] == "ok"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_execute_timeout_does_not_start_parallel_runs(tmp_path, monkeypatch):
|
||||
async def scenario() -> None:
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("log: {}\nmanagement: { enabled: false }\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setenv("EXECUTE_TIMEOUT", "0.05")
|
||||
app = TimeoutAwareApp(str(cfg))
|
||||
await app.start()
|
||||
await asyncio.sleep(0.35)
|
||||
degraded_health = await app.get_health_provider()()
|
||||
await app.stop()
|
||||
|
||||
assert app.calls >= 1
|
||||
assert app._last_execute_error is not None
|
||||
assert "did not finish within" in app._last_execute_error
|
||||
assert app.max_active == 2
|
||||
assert degraded_health["status"] == "degraded"
|
||||
|
||||
asyncio.run(scenario())
|
||||
Reference in New Issue
Block a user