Небольшие доработки по трейсу

This commit is contained in:
2026-04-26 20:47:13 +03:00
parent 314e6f3c46
commit ed33f6e9cd
16 changed files with 725 additions and 27 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
from app_runtime.control.base import ControlActionSet, ControlChannel
from app_runtime.control.base import ControlActionRequest, ControlActionSet, ControlChannel
from app_runtime.control.http_channel import HttpControlChannel
from app_runtime.control.service import ControlPlaneService
__all__ = ["ControlActionSet", "ControlChannel", "ControlPlaneService", "HttpControlChannel"]
__all__ = ["ControlActionRequest", "ControlActionSet", "ControlChannel", "ControlPlaneService", "HttpControlChannel"]
+10 -1
View File
@@ -6,7 +6,16 @@ from dataclasses import dataclass
from app_runtime.core.types import HealthPayload
ActionHandler = Callable[[], Awaitable[str]]
@dataclass(slots=True)
class ControlActionRequest:
wait: bool | None = None
timeout: float | None = None
force: bool | None = None
ActionResult = str | dict[str, object]
ActionHandler = Callable[[ControlActionRequest], Awaitable[ActionResult]]
HealthHandler = Callable[[], Awaitable[HealthPayload]]
+53 -3
View File
@@ -1,19 +1,23 @@
from __future__ import annotations
import logging
import time
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app_runtime.control.base import ControlActionRequest
from app_runtime.core.types import HealthPayload
LOGGER = logging.getLogger(__name__)
class HttpControlAppFactory:
def create(
self,
health_provider: Callable[[], Awaitable[HealthPayload]],
action_provider: Callable[[str], Awaitable[JSONResponse]],
action_provider: Callable[[str, str, ControlActionRequest], Awaitable[JSONResponse]],
) -> FastAPI:
app = FastAPI(title="PLBA Control API")
@@ -32,7 +36,53 @@ class HttpControlAppFactory:
@app.get("/actions/{action}")
@app.post("/actions/{action}")
async def action(action: str) -> JSONResponse:
return await action_provider(action)
async def action(action: str, request: Request) -> JSONResponse:
client_source = self._client_source(request)
if action in {"start", "stop"}:
LOGGER.warning("Control action requested: /actions/%s client=%s", action, client_source)
try:
action_request = self._action_request(request)
except ValueError as exc:
return JSONResponse(content={"status": "error", "detail": str(exc)}, status_code=400)
return await action_provider(action, client_source, action_request)
return app
def _action_request(self, request: Request) -> ControlActionRequest:
return ControlActionRequest(
wait=self._bool_param(request, "wait"),
timeout=self._float_param(request, "timeout"),
force=self._bool_param(request, "force"),
)
def _client_source(self, request: Request) -> str:
explicit_header = request.headers.get("X-Client-Source", "").strip()
if explicit_header:
return explicit_header
user_agent = request.headers.get("User-Agent", "").strip()
if user_agent:
return f"user-agent:{user_agent}"
return "unknown"
def _bool_param(self, request: Request, name: str) -> bool | None:
raw_value = request.query_params.get(name)
if raw_value is None:
return None
normalized = raw_value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ValueError(f"invalid boolean query parameter: {name}={raw_value}")
def _float_param(self, request: Request, name: str) -> float | None:
raw_value = request.query_params.get(name)
if raw_value is None:
return None
try:
value = float(raw_value)
except ValueError as exc:
raise ValueError(f"invalid numeric query parameter: {name}={raw_value}") from exc
if value < 0:
raise ValueError(f"query parameter must be >= 0: {name}={raw_value}")
return value
+16 -4
View File
@@ -4,7 +4,7 @@ import asyncio
from fastapi.responses import JSONResponse
from app_runtime.control.base import ControlActionSet, ControlChannel
from app_runtime.control.base import ControlActionRequest, ControlActionSet, ControlChannel
from app_runtime.control.http_app import HttpControlAppFactory
from app_runtime.control.http_runner import UvicornThreadRunner
@@ -33,7 +33,12 @@ class HttpControlChannel(ControlChannel):
return {"status": "unhealthy", "detail": "control actions are not configured"}
return await asyncio.wait_for(self._actions.health(), timeout=float(self._timeout))
async def _action_response(self, action: str) -> JSONResponse:
async def _action_response(
self,
action: str,
_client_source: str = "unknown",
request: ControlActionRequest | None = None,
) -> JSONResponse:
if self._actions is None:
return JSONResponse(content={"status": "error", "detail": f"{action} handler is not configured"}, status_code=404)
callbacks = {
@@ -44,9 +49,10 @@ class HttpControlChannel(ControlChannel):
callback = callbacks.get(action)
if callback is None:
return JSONResponse(content={"status": "error", "detail": f"unsupported action: {action}"}, status_code=404)
action_timeout = max(float(self._timeout), 10.0) if action in {"start", "stop"} else float(self._timeout)
action_request = request or ControlActionRequest()
action_timeout = self._action_timeout(action, action_request)
try:
detail = await asyncio.wait_for(callback(), timeout=action_timeout)
detail = await asyncio.wait_for(callback(action_request), timeout=action_timeout)
except asyncio.TimeoutError:
return JSONResponse(
content={"status": "accepted", "detail": f"{action} operation is still in progress"},
@@ -55,3 +61,9 @@ class HttpControlChannel(ControlChannel):
except Exception as exc:
return JSONResponse(content={"status": "error", "detail": str(exc)}, status_code=500)
return JSONResponse(content={"status": "ok", "detail": detail or f"{action} action accepted"}, status_code=200)
def _action_timeout(self, action: str, request: ControlActionRequest) -> float:
base_timeout = max(float(self._timeout), 10.0) if action in {"start", "stop"} else float(self._timeout)
if action != "stop" or request.wait is False or request.timeout is None:
return base_timeout
return max(base_timeout, float(request.timeout) + 1.0)
+13 -7
View File
@@ -4,6 +4,7 @@ from time import monotonic, sleep
from app_runtime.config.providers import FileConfigProvider
from app_runtime.contracts.application import ApplicationModule
from app_runtime.control.base import ControlActionRequest
from app_runtime.control.service import ControlPlaneService
from app_runtime.core.configuration import ConfigurationManager
from app_runtime.core.registration import ModuleRegistry
@@ -87,7 +88,7 @@ class RuntimeManager:
async def health_status(self) -> HealthPayload:
return self.current_health()
async def start_runtime(self) -> dict[str, object] | str:
async def start_runtime(self, _request: ControlActionRequest) -> dict[str, object] | str:
self._refresh_state()
if self._started:
return "runtime already running"
@@ -100,24 +101,29 @@ class RuntimeManager:
return self._action_detail("runtime started", timed_out=False)
return self._action_detail("runtime start is still in progress", timed_out=True)
async def stop_runtime(self) -> dict[str, object] | str:
async def stop_runtime(self, request: ControlActionRequest) -> dict[str, object] | str:
self._refresh_state()
if not self._started:
if self._state == LifecycleState.STOPPING:
return self._action_detail("runtime stop is still in progress", timed_out=True)
return "runtime already stopped"
wait = True if request.wait is None else request.wait
timeout = self.ACTION_TIMEOUT_SECONDS if request.timeout is None else float(request.timeout)
force = False if request.force is None else request.force
self._state = LifecycleState.STOPPING
try:
self.workers.stop(timeout=self.ACTION_TIMEOUT_SECONDS, force=False)
self.workers.stop(timeout=timeout, force=force, wait=wait)
except TimeoutError:
return self._action_detail("runtime stop is still in progress", timed_out=True)
self._started = False
self._state = LifecycleState.STOPPED
return self._action_detail("runtime stopped", timed_out=False)
self._refresh_state()
if self._state == LifecycleState.STOPPED:
self._started = False
return self._action_detail("runtime stopped", timed_out=False)
return self._action_detail("runtime stop requested", timed_out=False)
async def runtime_status(self) -> str:
async def runtime_status(self, _request: ControlActionRequest) -> str:
self._refresh_state()
return self._state.value
+2 -2
View File
@@ -18,10 +18,10 @@ class WorkerSupervisor:
for worker in self._workers:
worker.start()
def stop(self, timeout: float = 30.0, force: bool = False) -> None:
def stop(self, timeout: float = 30.0, force: bool = False, wait: bool = True) -> None:
for worker in self._workers:
worker.stop(force=force)
if force:
if not wait:
return
deadline = monotonic() + timeout
while True:
@@ -133,7 +133,17 @@ class WorkflowRepository:
None,
"running",
self._connection_factory.dumps(snapshot),
runtime.get("email_trace_id"),
self._resolve_trace_id(runtime),
)
@staticmethod
def _resolve_trace_id(runtime: dict[str, Any]) -> str | None:
return (
runtime.get("trace_id")
or runtime.get("task_trace_id")
or runtime.get("order_trace_id")
or runtime.get("attachment_trace_id")
or runtime.get("email_trace_id")
)
def _use_memory(self) -> bool:
+2 -1
View File
@@ -1,6 +1,6 @@
from plba.bootstrap import create_runtime
from plba.config import ConfigFileLoader, FileConfigProvider
from plba.control import ControlActionSet, ControlChannel, ControlPlaneService, HttpControlChannel
from plba.control import ControlActionRequest, ControlActionSet, ControlChannel, ControlPlaneService, HttpControlChannel
from plba.contracts import (
ApplicationModule,
ConfigProvider,
@@ -35,6 +35,7 @@ __all__ = [
"ConfigFileLoader",
"ConfigProvider",
"ConfigurationManager",
"ControlActionRequest",
"ControlActionSet",
"ControlChannel",
"ControlPlaneService",
+2 -1
View File
@@ -1,8 +1,9 @@
from app_runtime.control.base import ControlActionSet, ControlChannel
from app_runtime.control.base import ControlActionRequest, ControlActionSet, ControlChannel
from app_runtime.control.http_channel import HttpControlChannel
from app_runtime.control.service import ControlPlaneService
__all__ = [
"ControlActionRequest",
"ControlActionSet",
"ControlChannel",
"ControlPlaneService",