Перенес workflow

This commit is contained in:
2026-03-05 11:46:05 +03:00
parent 4a0646bb14
commit 89c0d21e88
65 changed files with 1271 additions and 1640 deletions

View File

@@ -2,35 +2,36 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from threading import Event, Thread
from threading import Event, Lock, Thread
from time import sleep
from app_runtime.contracts.application import ApplicationModule
from app_runtime.contracts.health import HealthContributor
from app_runtime.contracts.tasks import Task, TaskHandler
from app_runtime.contracts.worker import WorkerHealth
from app_runtime.contracts.worker import Worker, WorkerHealth, WorkerStatus
from app_runtime.core.registration import ModuleRegistry
from app_runtime.core.runtime import RuntimeManager
from app_runtime.queue.in_memory import InMemoryTaskQueue
from app_runtime.tracing.transport import NoOpTraceTransport
from app_runtime.workers.queue_worker import QueueWorker
@dataclass
class CollectingHandler(TaskHandler):
class CollectingRoutine:
processed: list[dict[str, object]] = field(default_factory=list)
_done: bool = False
def handle(self, task: Task) -> None:
self.processed.append(task.payload)
def run(self) -> None:
if self._done:
return
self.processed.append({"id": 1})
self._done = True
class BlockingHandler(TaskHandler):
class BlockingRoutine:
def __init__(self, started: Event, release: Event) -> None:
self._started = started
self._release = release
def handle(self, task: Task) -> None:
del task
def run(self) -> None:
self._started.set()
self._release.wait(timeout=2.0)
@@ -40,37 +41,139 @@ class StaticHealthContributor(HealthContributor):
return WorkerHealth(name="example-module", status="ok", critical=False, meta={"kind": "test"})
class RoutineWorker(Worker):
def __init__(
self,
name: str,
routine: object,
*,
interval: float = 0.01,
concurrency: int = 1,
critical: bool = True,
) -> None:
self._name = name
self._routine = routine
self._interval = interval
self._concurrency = concurrency
self._critical = critical
self._threads: list[Thread] = []
self._stop_requested = Event()
self._force_stop = Event()
self._lock = Lock()
self._started = False
self._in_flight = 0
self._runs = 0
self._failures = 0
self._last_error: str | None = None
@property
def name(self) -> str:
return self._name
@property
def critical(self) -> bool:
return self._critical
def start(self) -> None:
if any(thread.is_alive() for thread in self._threads):
return
self._threads.clear()
self._stop_requested.clear()
self._force_stop.clear()
self._started = True
for index in range(self._concurrency):
thread = Thread(target=self._run_loop, name=f"{self._name}-{index + 1}", daemon=True)
self._threads.append(thread)
thread.start()
def stop(self, force: bool = False) -> None:
self._stop_requested.set()
if force:
self._force_stop.set()
def health(self) -> WorkerHealth:
status = self.status()
if self._started and not self._stop_requested.is_set() and self._alive_threads() == 0:
return WorkerHealth(self.name, "unhealthy", self.critical, "worker threads are not running", status.meta)
if self._failures > 0:
return WorkerHealth(self.name, "degraded", self.critical, self._last_error, status.meta)
return WorkerHealth(self.name, "ok", self.critical, meta=status.meta)
def status(self) -> WorkerStatus:
alive_threads = self._alive_threads()
with self._lock:
in_flight = self._in_flight
runs = self._runs
failures = self._failures
detail = self._last_error
if self._started and alive_threads == 0:
state = "stopped"
elif self._stop_requested.is_set():
state = "stopping" if alive_threads > 0 else "stopped"
elif not self._started:
state = "stopped"
elif in_flight > 0:
state = "busy"
else:
state = "idle"
return WorkerStatus(
name=self.name,
state=state,
in_flight=in_flight,
detail=detail,
meta={"alive_threads": alive_threads, "concurrency": self._concurrency, "runs": runs, "failures": failures},
)
def _run_loop(self) -> None:
while True:
if self._force_stop.is_set() or self._stop_requested.is_set():
return
with self._lock:
self._in_flight += 1
try:
self._routine.run()
except Exception as exc:
with self._lock:
self._failures += 1
self._last_error = str(exc)
else:
with self._lock:
self._runs += 1
self._last_error = None
finally:
with self._lock:
self._in_flight -= 1
if self._stop_requested.is_set():
return
sleep(self._interval)
def _alive_threads(self) -> int:
return sum(1 for thread in self._threads if thread.is_alive())
class ExampleModule(ApplicationModule):
def __init__(self) -> None:
self.handler = CollectingHandler()
self.queue = InMemoryTaskQueue()
self.routine = CollectingRoutine()
@property
def name(self) -> str:
return "example"
def register(self, registry: ModuleRegistry) -> None:
traces = registry.services.get("traces")
registry.add_queue("incoming", self.queue)
registry.add_handler("collect", self.handler)
self.queue.publish(Task(name="incoming", payload={"id": 1}, metadata={}))
registry.add_worker(QueueWorker("collector", self.queue, self.handler, traces, concurrency=1))
registry.add_worker(RoutineWorker("collector", self.routine))
registry.add_health_contributor(StaticHealthContributor())
class BlockingModule(ApplicationModule):
def __init__(self, started: Event, release: Event) -> None:
self.queue = InMemoryTaskQueue()
self.handler = BlockingHandler(started, release)
self.routine = BlockingRoutine(started, release)
@property
def name(self) -> str:
return "blocking"
def register(self, registry: ModuleRegistry) -> None:
traces = registry.services.get("traces")
self.queue.publish(Task(name="incoming", payload={"id": 1}, metadata={}))
registry.add_worker(QueueWorker("blocking-worker", self.queue, self.handler, traces, concurrency=1))
registry.add_worker(RoutineWorker("blocking-worker", self.routine))
class RecordingTransport(NoOpTraceTransport):
@@ -85,7 +188,7 @@ class RecordingTransport(NoOpTraceTransport):
self.messages.append(record)
def test_runtime_processes_tasks_and_exposes_status(tmp_path) -> None:
def test_runtime_runs_worker_routine_and_exposes_status(tmp_path) -> None:
config_path = tmp_path / "config.yml"
config_path.write_text(
"""
@@ -113,7 +216,7 @@ log:
status = runtime.status()
runtime.stop()
assert module.handler.processed == [{"id": 1}]
assert module.routine.processed == [{"id": 1}]
assert status["modules"] == ["example"]
assert status["runtime"]["state"] == "idle"
assert status["health"]["status"] == "ok"
@@ -146,7 +249,7 @@ def test_trace_service_writes_contexts_and_messages() -> None:
transport = RecordingTransport()
manager = TraceService(transport=transport)
with manager.open_context(alias="worker", kind="task", attrs={"task": "incoming"}):
with manager.open_context(alias="worker", kind="worker", attrs={"routine": "incoming"}):
manager.step("parse")
manager.info("started", status="ok", attrs={"attempt": 1})
@@ -202,29 +305,67 @@ def test_http_control_channel_exposes_health_and_actions() -> None:
asyncio.run(scenario())
def test_public_plba_package_exports_runtime_builder(tmp_path) -> None:
def test_public_plba_package_exports_runtime_builder_and_worker_contract(tmp_path) -> None:
import plba
from plba import ApplicationModule as PublicApplicationModule
from plba import QueueWorker as PublicQueueWorker
from plba import InMemoryTaskQueue
from plba import Worker as PublicWorker
from plba import WorkerHealth as PublicWorkerHealth
from plba import WorkerStatus as PublicWorkerStatus
from plba import create_runtime
config_path = tmp_path / "config.yml"
config_path.write_text("platform: {}\n", encoding="utf-8")
queue = InMemoryTaskQueue[int]()
queue.put(2)
assert queue.get(timeout=0.01) == 2
queue.task_done()
class PublicRoutine:
def __init__(self) -> None:
self.runs = 0
def run(self) -> None:
if self.runs == 0:
self.runs += 1
class PublicWorkerImpl(PublicWorker):
def __init__(self, routine: PublicRoutine) -> None:
self._inner = RoutineWorker("public-worker", routine)
@property
def name(self) -> str:
return self._inner.name
@property
def critical(self) -> bool:
return self._inner.critical
def start(self) -> None:
self._inner.start()
def stop(self, force: bool = False) -> None:
self._inner.stop(force=force)
def health(self) -> PublicWorkerHealth:
return self._inner.health()
def status(self) -> PublicWorkerStatus:
return self._inner.status()
class PublicExampleModule(PublicApplicationModule):
@property
def name(self) -> str:
return "public-example"
def register(self, registry: ModuleRegistry) -> None:
queue = InMemoryTaskQueue()
traces = registry.services.get("traces")
handler = CollectingHandler()
queue.publish(Task(name="incoming", payload={"id": 2}, metadata={}))
registry.add_worker(PublicQueueWorker("public-worker", queue, handler, traces))
registry.add_worker(PublicWorkerImpl(PublicRoutine()))
runtime = create_runtime(PublicExampleModule(), config_path=str(config_path))
runtime.start()
sleep(0.2)
assert runtime.configuration.get() == {"platform": {}}
assert runtime.status()["workers"]["registered"] == 1
assert hasattr(plba, "QueueWorker") is False
runtime.stop()