Большой рефакторинг с кодексом

This commit is contained in:
2026-02-26 21:58:21 +03:00
parent aa32c23dba
commit a1dd495d6d
15 changed files with 572 additions and 339 deletions

View File

@@ -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:

View File

@@ -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())

View File

@@ -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())

View 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())