feat(observatory): add durable recorded compute queue
This commit is contained in:
@@ -42,6 +42,15 @@ from k1link.observatory import (
|
||||
ObservatoryRunPreparationLedger,
|
||||
load_observatory_run_preparation_ledger,
|
||||
)
|
||||
from k1link.observatory.m49_queue_binding import (
|
||||
M49QueueBindingConfig,
|
||||
M49QueueBindingError,
|
||||
M49RecordedQueueBindingService,
|
||||
)
|
||||
from k1link.observatory.recorded_jobs import (
|
||||
ObservatoryRecordedJobQueue,
|
||||
ObservatoryRecordedQueueError,
|
||||
)
|
||||
from k1link.sessions import (
|
||||
MaterializedRecording,
|
||||
RecordedCameraFrameService,
|
||||
@@ -242,6 +251,34 @@ OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR: str | None
|
||||
OBSERVATORY_RUN_PREPARATION_LEDGER,
|
||||
OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR,
|
||||
) = load_observatory_run_preparation_ledger(session_store.data_dir)
|
||||
OBSERVATORY_RECORDED_BINDING_SERVICE: M49RecordedQueueBindingService | None
|
||||
OBSERVATORY_RECORDED_JOB_QUEUE: ObservatoryRecordedJobQueue | None
|
||||
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR: str | None
|
||||
try:
|
||||
if OBSERVATORY_LABORATORY_SETUP_REGISTRY is None:
|
||||
raise M49QueueBindingError("observatory setup registry is unavailable")
|
||||
OBSERVATORY_RECORDED_BINDING_SERVICE = M49RecordedQueueBindingService(
|
||||
data_dir=session_store.data_dir,
|
||||
session_store=session_store,
|
||||
setup_registry=OBSERVATORY_LABORATORY_SETUP_REGISTRY,
|
||||
config=M49QueueBindingConfig.from_file(
|
||||
REPOSITORY_ROOT
|
||||
/ "config"
|
||||
/ "observatory-m49-recorded-queue-binding.json"
|
||||
),
|
||||
)
|
||||
OBSERVATORY_RECORDED_JOB_QUEUE = ObservatoryRecordedJobQueue(
|
||||
session_store.data_dir,
|
||||
definitions=OBSERVATORY_RECORDED_BINDING_SERVICE.definitions,
|
||||
)
|
||||
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR = None
|
||||
except (M49QueueBindingError, ObservatoryRecordedQueueError, OSError, ValueError) as exc:
|
||||
# Recorded execution remains an optional observation-only slice. A drifted
|
||||
# seal or queue must fail closed without preventing K1, Simulation or legacy
|
||||
# LAB from starting.
|
||||
OBSERVATORY_RECORDED_BINDING_SERVICE = None
|
||||
OBSERVATORY_RECORDED_JOB_QUEUE = None
|
||||
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR = str(exc)
|
||||
simulation_project_store = SimulationProjectStore(session_store.data_dir)
|
||||
simulation_project_service = SimulationProjectService(simulation_project_store)
|
||||
session_artifact_gateway = configured_artifact_gateway(session_store.data_dir)
|
||||
@@ -725,6 +762,9 @@ app.include_router(
|
||||
setup_registry_error=OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR,
|
||||
run_preparation_ledger=OBSERVATORY_RUN_PREPARATION_LEDGER,
|
||||
run_preparation_ledger_error=OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR,
|
||||
recorded_binding_service=OBSERVATORY_RECORDED_BINDING_SERVICE,
|
||||
recorded_job_queue=OBSERVATORY_RECORDED_JOB_QUEUE,
|
||||
recorded_job_queue_error=OBSERVATORY_RECORDED_JOB_QUEUE_ERROR,
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
|
||||
@@ -17,6 +17,18 @@ from k1link.observatory import (
|
||||
is_admitted_observatory_recorded_result,
|
||||
observatory_run_preparation_request_sha256,
|
||||
)
|
||||
from k1link.observatory.m49_queue_binding import (
|
||||
M49QueueBindingError,
|
||||
M49QueueBindingIntegrityError,
|
||||
M49RecordedQueueBindingService,
|
||||
)
|
||||
from k1link.observatory.recorded_jobs import (
|
||||
ObservatoryRecordedJobQueue,
|
||||
ObservatoryRecordedQueueCapacityError,
|
||||
ObservatoryRecordedQueueConflictError,
|
||||
ObservatoryRecordedQueueError,
|
||||
ObservatoryRecordedQueueNotFoundError,
|
||||
)
|
||||
from k1link.sessions import SessionIntegrityError, SessionNotFoundError, SessionStore
|
||||
from k1link.sessions.models import SessionSummary
|
||||
|
||||
@@ -32,6 +44,19 @@ OBSERVATORY_RUN_PREFLIGHT_REQUEST_SCHEMA: Literal[
|
||||
OBSERVATORY_RUN_PREFLIGHT_SCHEMA: Literal[
|
||||
"missioncore.observatory-run-preflight/v1"
|
||||
] = "missioncore.observatory-run-preflight/v1"
|
||||
OBSERVATORY_RECORDED_RUN_SUBMIT_SCHEMA: Literal[
|
||||
"missioncore.observatory-recorded-run-submit/v1"
|
||||
] = "missioncore.observatory-recorded-run-submit/v1"
|
||||
OBSERVATORY_RECORDED_JOB_LIST_SCHEMA: Literal[
|
||||
"missioncore.observatory-recorded-job-list/v1"
|
||||
] = "missioncore.observatory-recorded-job-list/v1"
|
||||
|
||||
_OBSERVATION_ONLY_AUTHORITY: dict[str, bool] = {
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"production_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
class _StrictApiModel(BaseModel):
|
||||
@@ -88,6 +113,25 @@ class ObservatoryRunPreparationRequest(_StrictApiModel):
|
||||
definition_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
class ObservatoryRecordedRunSubmitRequest(_StrictApiModel):
|
||||
schema_version: Literal["missioncore.observatory-recorded-run-submit/v1"]
|
||||
idempotency_key: str = Field(
|
||||
min_length=1,
|
||||
max_length=160,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$",
|
||||
)
|
||||
source_session_id: str = Field(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
|
||||
)
|
||||
setup_id: str = Field(
|
||||
min_length=3,
|
||||
max_length=96,
|
||||
pattern=r"^[a-z][a-z0-9-]{2,95}$",
|
||||
)
|
||||
|
||||
|
||||
def build_observatory_router(
|
||||
store: SessionStore,
|
||||
*,
|
||||
@@ -95,6 +139,9 @@ def build_observatory_router(
|
||||
setup_registry_error: str | None = None,
|
||||
run_preparation_ledger: ObservatoryRunPreparationLedger | None = None,
|
||||
run_preparation_ledger_error: str | None = None,
|
||||
recorded_binding_service: M49RecordedQueueBindingService | None = None,
|
||||
recorded_job_queue: ObservatoryRecordedJobQueue | None = None,
|
||||
recorded_job_queue_error: str | None = None,
|
||||
) -> APIRouter:
|
||||
"""Build bounded catalog-only mutations for typed Observatory projections."""
|
||||
|
||||
@@ -224,6 +271,34 @@ def build_observatory_router(
|
||||
assert isinstance(executor, dict)
|
||||
compatible = compatibility.get("compatible") is True
|
||||
existing = preflight.get("outcome") == "existing"
|
||||
queue_binding_ready = False
|
||||
queue_binding_reason: str | None = None
|
||||
binding_service = recorded_binding_service
|
||||
exact_queue_setup = (
|
||||
binding_service is not None
|
||||
and request.setup_id
|
||||
== binding_service.config.setup.setup_id
|
||||
)
|
||||
if (
|
||||
compatible
|
||||
and not existing
|
||||
and binding_service is not None
|
||||
and exact_queue_setup
|
||||
and isinstance(expected_digest, str)
|
||||
):
|
||||
try:
|
||||
binding_service.check(
|
||||
source_session_id=request.source_session_id,
|
||||
setup_id=request.setup_id,
|
||||
definition_sha256=expected_digest,
|
||||
)
|
||||
queue_binding_ready = True
|
||||
except M49QueueBindingError:
|
||||
queue_binding_reason = (
|
||||
"Точная привязка источника и исполняемого релиза не прошла "
|
||||
"проверку целостности."
|
||||
)
|
||||
queueable = queue_binding_ready and recorded_job_queue is not None
|
||||
checks: list[dict[str, Any]] = [
|
||||
{
|
||||
"check_id": "source-compatibility",
|
||||
@@ -247,26 +322,66 @@ def build_observatory_router(
|
||||
},
|
||||
{
|
||||
"check_id": "executor",
|
||||
"outcome": "not-applicable" if existing else "fail",
|
||||
"outcome": (
|
||||
"not-applicable"
|
||||
if existing
|
||||
else "pass"
|
||||
if queue_binding_ready
|
||||
else "fail"
|
||||
),
|
||||
"reason_code": (
|
||||
"existing-result-does-not-require-executor"
|
||||
if existing
|
||||
else "executor-release-sealed"
|
||||
if queue_binding_ready
|
||||
else str(executor.get("reason_code"))
|
||||
),
|
||||
"message": (
|
||||
"Готовый результат открывается без повторного запуска Worker."
|
||||
if existing
|
||||
else (
|
||||
"Исполняемый релиз и его ресурсы запечатаны; отдельный "
|
||||
"Worker service заберёт расчёт из очереди после установки."
|
||||
)
|
||||
if queue_binding_ready
|
||||
else str(executor.get("reason"))
|
||||
),
|
||||
},
|
||||
{
|
||||
"check_id": "durable-queue",
|
||||
"outcome": (
|
||||
"not-applicable"
|
||||
if existing
|
||||
else "pass"
|
||||
if queueable
|
||||
else "fail"
|
||||
),
|
||||
"reason_code": (
|
||||
"existing-result-does-not-require-queue"
|
||||
if existing
|
||||
else "durable-queue-ready"
|
||||
if queueable
|
||||
else "durable-queue-unavailable"
|
||||
),
|
||||
"message": (
|
||||
"Готовый результат не требует постановки в очередь."
|
||||
if existing
|
||||
else "Durable-очередь готова принять расчёт."
|
||||
if queueable
|
||||
else queue_binding_reason
|
||||
or "Этот источник и сетап пока нельзя поставить в очередь."
|
||||
),
|
||||
},
|
||||
]
|
||||
return {
|
||||
"schema_version": OBSERVATORY_RUN_PREFLIGHT_SCHEMA,
|
||||
"source_session_id": request.source_session_id,
|
||||
"setup_id": request.setup_id,
|
||||
"definition_sha256": expected_digest,
|
||||
"outcome": "existing" if existing else "blocked",
|
||||
"submission_allowed": False,
|
||||
"outcome": (
|
||||
"existing" if existing else "queueable" if queueable else "blocked"
|
||||
),
|
||||
"submission_allowed": queueable,
|
||||
"checks": checks,
|
||||
"existing_result_ids": preflight.get("existing_result_ids", []),
|
||||
"executor": executor,
|
||||
@@ -508,6 +623,236 @@ def build_observatory_router(
|
||||
detail="Подготовка расчётов Обсерватории недоступна.",
|
||||
)
|
||||
|
||||
if (
|
||||
setup_registry is not None
|
||||
and recorded_binding_service is not None
|
||||
and recorded_job_queue is not None
|
||||
):
|
||||
|
||||
@router.post("/api/v1/observatory/runs", status_code=202)
|
||||
def submit_observatory_recorded_run(
|
||||
request: ObservatoryRecordedRunSubmitRequest,
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
existing_job = recorded_job_queue.get_by_idempotency_key(
|
||||
request.idempotency_key
|
||||
)
|
||||
except ObservatoryRecordedQueueNotFoundError:
|
||||
existing_job = None
|
||||
except (ObservatoryRecordedQueueError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Durable-очередь расчётов недоступна.",
|
||||
) from exc
|
||||
if existing_job is not None:
|
||||
if (
|
||||
existing_job.source_session_id != request.source_session_id
|
||||
or existing_job.setup_id != request.setup_id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Ключ идемпотентности уже связан с другим расчётом.",
|
||||
)
|
||||
return existing_job.as_dict()
|
||||
|
||||
source = source_summary(request.source_session_id)
|
||||
try:
|
||||
setup_registry.setup(request.setup_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Сетап лаборатории не найден.",
|
||||
) from exc
|
||||
catalog = setup_registry.catalog(
|
||||
source,
|
||||
available_observatory_result_ids=available_observatory_results(
|
||||
request.source_session_id
|
||||
),
|
||||
)
|
||||
setups = catalog.get("setups")
|
||||
if not isinstance(setups, list):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Каталог сетапов Обсерватории нарушил контракт.",
|
||||
)
|
||||
projected = next(
|
||||
(
|
||||
item
|
||||
for item in setups
|
||||
if isinstance(item, dict) and item.get("setup_id") == request.setup_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if projected is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Каталог сетапов Обсерватории нарушил контракт.",
|
||||
)
|
||||
compatibility = projected.get("compatibility")
|
||||
preflight = projected.get("preflight")
|
||||
definition = projected.get("run_definition")
|
||||
if not isinstance(compatibility, dict) or not isinstance(preflight, dict):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Каталог сетапов Обсерватории нарушил контракт.",
|
||||
)
|
||||
if compatibility.get("compatible") is not True:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Исходная сессия несовместима с выбранным сетапом.",
|
||||
)
|
||||
if preflight.get("outcome") == "existing":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Точный результат уже существует; новый расчёт не создан.",
|
||||
)
|
||||
if not isinstance(definition, dict):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Для сетапа нет воспроизводимой RunDefinition.",
|
||||
)
|
||||
definition_sha256 = definition.get("definition_sha256")
|
||||
if not isinstance(definition_sha256, str):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Каталог сетапов Обсерватории нарушил контракт.",
|
||||
)
|
||||
try:
|
||||
admission = recorded_binding_service.admit(
|
||||
source_session_id=request.source_session_id,
|
||||
setup_id=request.setup_id,
|
||||
definition_sha256=definition_sha256,
|
||||
)
|
||||
job, _created = recorded_job_queue.submit(
|
||||
admission.intent(idempotency_key=request.idempotency_key),
|
||||
enqueue=True,
|
||||
)
|
||||
return job.as_dict()
|
||||
except M49QueueBindingIntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Точная привязка источника и исполняемого релиза не прошла "
|
||||
"проверку целостности."
|
||||
),
|
||||
) from exc
|
||||
except ObservatoryRecordedQueueConflictError as exc:
|
||||
try:
|
||||
existing_job = recorded_job_queue.get_by_idempotency_key(
|
||||
request.idempotency_key
|
||||
)
|
||||
except ObservatoryRecordedQueueNotFoundError:
|
||||
existing_job = None
|
||||
except (ObservatoryRecordedQueueError, ValueError) as lookup_exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Durable-очередь расчётов недоступна.",
|
||||
) from lookup_exc
|
||||
if (
|
||||
existing_job is not None
|
||||
and existing_job.source_session_id == request.source_session_id
|
||||
and existing_job.setup_id == request.setup_id
|
||||
):
|
||||
return existing_job.as_dict()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Ключ идемпотентности уже связан с другим расчётом.",
|
||||
) from exc
|
||||
except ObservatoryRecordedQueueCapacityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Квота durable-очереди расчётов исчерпана.",
|
||||
) from exc
|
||||
except (M49QueueBindingError, ObservatoryRecordedQueueError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Durable-очередь расчётов недоступна.",
|
||||
) from exc
|
||||
|
||||
@router.get("/api/v1/observatory/runs")
|
||||
def list_observatory_recorded_runs(
|
||||
source_session_id: str = Query(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
|
||||
),
|
||||
setup_id: str = Query(
|
||||
min_length=3,
|
||||
max_length=96,
|
||||
pattern=r"^[a-z][a-z0-9-]{2,95}$",
|
||||
),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
jobs = recorded_job_queue.list_jobs(
|
||||
source_session_id=source_session_id,
|
||||
setup_id=setup_id,
|
||||
limit=limit,
|
||||
)
|
||||
except (ObservatoryRecordedQueueError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Durable-очередь расчётов недоступна.",
|
||||
) from exc
|
||||
return {
|
||||
"schema_version": OBSERVATORY_RECORDED_JOB_LIST_SCHEMA,
|
||||
"items": [job.as_dict() for job in jobs],
|
||||
"authority": dict(_OBSERVATION_ONLY_AUTHORITY),
|
||||
}
|
||||
|
||||
@router.get("/api/v1/observatory/runs/{job_id}")
|
||||
def get_observatory_recorded_run(
|
||||
job_id: str = ApiPath(
|
||||
min_length=48,
|
||||
max_length=48,
|
||||
pattern=r"^observatory-run-[a-f0-9]{32}$",
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
return recorded_job_queue.get(job_id).as_dict()
|
||||
except ObservatoryRecordedQueueNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Расчёт Обсерватории не найден.",
|
||||
) from exc
|
||||
except (ObservatoryRecordedQueueError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Durable-очередь расчётов недоступна.",
|
||||
) from exc
|
||||
|
||||
elif recorded_job_queue_error is not None:
|
||||
|
||||
@router.post("/api/v1/observatory/runs")
|
||||
def unavailable_observatory_recorded_run(
|
||||
request: ObservatoryRecordedRunSubmitRequest,
|
||||
) -> None:
|
||||
del request
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Durable-очередь расчётов недоступна.",
|
||||
)
|
||||
|
||||
@router.get("/api/v1/observatory/runs")
|
||||
def unavailable_observatory_recorded_runs(
|
||||
source_session_id: str = Query(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
|
||||
),
|
||||
setup_id: str = Query(
|
||||
min_length=3,
|
||||
max_length=96,
|
||||
pattern=r"^[a-z][a-z0-9-]{2,95}$",
|
||||
),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
) -> None:
|
||||
del source_session_id, setup_id, limit
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Durable-очередь расчётов недоступна.",
|
||||
)
|
||||
|
||||
@router.patch(
|
||||
"/api/v1/observatory/lab-projections/{session_id}",
|
||||
response_model=ObservatoryProjectionDocument,
|
||||
|
||||
Reference in New Issue
Block a user