Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -7,13 +7,23 @@
|
|||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from contextvars import ContextVar, Token
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domain.events import CardReceived, Exercise, StationState, TimerTick
|
||||
from app.domain.events import (
|
||||
CardReceived,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
Exercise,
|
||||
StationState,
|
||||
TimerTick,
|
||||
)
|
||||
from app.session.state import SessionState
|
||||
|
||||
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
|
||||
|
|
@ -21,6 +31,15 @@ from app.session.state import SessionState
|
|||
QUEUE_SIZE = 256
|
||||
|
||||
TICK_SECONDS = 1.0
|
||||
LEASE_FENCED_MESSAGE = "Занятие передано другому backend-узлу; переподключитесь."
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _current_task():
|
||||
try:
|
||||
return asyncio.current_task()
|
||||
except RuntimeError: # synchronous tests and tooling have no running loop
|
||||
return None
|
||||
|
||||
|
||||
class Journal(Protocol):
|
||||
|
|
@ -29,21 +48,26 @@ class Journal(Protocol):
|
|||
|
||||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||
trainee_id: UUID | None = None,
|
||||
) -> tuple[int, UUID | None, str | None]: ...
|
||||
trainee_id: UUID | None = None, owner_login: str | None = None,
|
||||
backend_node_id: str | None = None,
|
||||
) -> tuple[int, UUID | None, str | None, int] | None: ...
|
||||
async def utterance(self, session_id: UUID, entry) -> None: ...
|
||||
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
|
||||
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
|
||||
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ...
|
||||
async def self_assessment(
|
||||
self, session_id: UUID, missed: list[str], comment: str, at
|
||||
) -> bool: ...
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> bool: ...
|
||||
async def score_snapshot(self, session_id: UUID, report: dict) -> None: ...
|
||||
async def score_override(
|
||||
self, session_id: UUID, score_final: float, author: str, comment: str,
|
||||
) -> None: ...
|
||||
) -> bool: ...
|
||||
async def session_started(self, session_id: UUID, at) -> None: ...
|
||||
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
|
||||
async def checkpoint(self, state: SessionState) -> None: ...
|
||||
async def restore_active(self) -> list[SessionState]: ...
|
||||
async def renew(self, session_id: UUID) -> None: ...
|
||||
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]: ...
|
||||
|
||||
|
||||
class SessionHub:
|
||||
|
|
@ -54,6 +78,9 @@ class SessionHub:
|
|||
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._stations: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._tickers: dict[UUID, asyncio.Task] = {}
|
||||
self._event_batch: ContextVar[dict | None] = ContextVar(
|
||||
f"session-event-batch-{id(self)}", default=None
|
||||
)
|
||||
|
||||
# ── реестр ──
|
||||
|
||||
|
|
@ -62,12 +89,47 @@ class SessionHub:
|
|||
return state
|
||||
|
||||
def get(self, session_id: UUID) -> SessionState | None:
|
||||
return self._sessions.get(session_id)
|
||||
state = self._sessions.get(session_id)
|
||||
return None if state is not None and state.lease_fenced else state
|
||||
|
||||
def is_lease_fenced(self, session_id: UUID) -> bool:
|
||||
state = self._sessions.get(session_id)
|
||||
return state is not None and state.lease_fenced
|
||||
|
||||
def active_sessions(self, owner_login: str) -> list[SessionState]:
|
||||
"""Живые занятия только преподавателя-владельца для группового обзора."""
|
||||
return [
|
||||
state for state in self._sessions.values()
|
||||
if not state.ended and not state.lease_fenced and state.owner_login == owner_login
|
||||
]
|
||||
|
||||
def history(
|
||||
self, *, owner_login: str | None = None, trainee_id: UUID | None = None,
|
||||
mode: str | None = None, since: datetime | None = None, limit: int = 100,
|
||||
) -> list[SessionState]:
|
||||
"""Volatile session history for the explicit no-database demo mode."""
|
||||
if since is not None and since.tzinfo is None:
|
||||
since = since.replace(tzinfo=UTC)
|
||||
states = [
|
||||
state for state in self._sessions.values()
|
||||
if not state.lease_fenced
|
||||
and (owner_login is None or state.owner_login == owner_login)
|
||||
and (trainee_id is None or state.trainee_id == trainee_id)
|
||||
and (mode is None or state.mode.value == mode)
|
||||
and (since is None or (state.started_at is not None and state.started_at >= since))
|
||||
]
|
||||
# Hub insertion order is creation order; completed lessons sort by
|
||||
# their finish time, while unanswered calls retain their start time.
|
||||
states.sort(
|
||||
key=lambda state: state.ended_at or state.started_at or datetime.min.replace(tzinfo=UTC),
|
||||
reverse=True,
|
||||
)
|
||||
return states[:max(0, limit)]
|
||||
|
||||
def has_active_scenario(self, scenario_id: str) -> bool:
|
||||
"""Архивирование контента не должно менять уже идущее занятие."""
|
||||
return any(
|
||||
not state.ended and (
|
||||
not state.ended and not state.lease_fenced and (
|
||||
state.scenario_id == scenario_id
|
||||
or any(item.id == scenario_id for item in state.dds_scenarios)
|
||||
)
|
||||
|
|
@ -80,9 +142,100 @@ class SessionHub:
|
|||
|
||||
async def checkpoint(self, session_id: UUID) -> None:
|
||||
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
|
||||
state = self.get(session_id)
|
||||
state = self._sessions.get(session_id)
|
||||
if state is not None and state.lease_fenced:
|
||||
raise RuntimeError(LEASE_FENCED_MESSAGE)
|
||||
if state is not None and self.journal is not None:
|
||||
await self.journal.checkpoint(state)
|
||||
try:
|
||||
await self.journal.checkpoint(state)
|
||||
except Exception:
|
||||
self._discard_event_batch(session_id)
|
||||
await self.fence(state)
|
||||
raise
|
||||
self._flush_event_batch(session_id)
|
||||
|
||||
def begin_event_stream(self, session_id: UUID) -> Token:
|
||||
"""Stage controller output until each explicit checkpoint in its loop."""
|
||||
return self._event_batch.set({
|
||||
"session_id": session_id, "events": [], "committed": False,
|
||||
"persistent": True, "owner_task": _current_task(),
|
||||
})
|
||||
|
||||
async def end_event_stream(self, token: Token) -> None:
|
||||
batch = self._event_batch.get()
|
||||
try:
|
||||
if batch is not None and batch["events"]:
|
||||
session_id = batch["session_id"]
|
||||
batch["events"].clear()
|
||||
state = self._sessions.get(session_id)
|
||||
if self.journal is not None and state is not None and not state.lease_fenced:
|
||||
await self.fence(state)
|
||||
finally:
|
||||
self._event_batch.reset(token)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def durable_transition(self, session_id: UUID):
|
||||
"""Do not publish state-changing events until its checkpoint commits."""
|
||||
batch = {
|
||||
"session_id": session_id, "events": [], "committed": False,
|
||||
"persistent": False, "owner_task": _current_task(),
|
||||
}
|
||||
token: Token = self._event_batch.set(batch)
|
||||
try:
|
||||
yield
|
||||
if not batch["committed"]:
|
||||
await self.checkpoint(session_id)
|
||||
else:
|
||||
self._flush_event_batch(session_id)
|
||||
except Exception:
|
||||
self._discard_event_batch(session_id)
|
||||
state = self._sessions.get(session_id)
|
||||
if self.journal is not None and state is not None and not state.lease_fenced:
|
||||
await self.fence(state)
|
||||
raise
|
||||
finally:
|
||||
self._event_batch.reset(token)
|
||||
|
||||
def _discard_event_batch(self, session_id: UUID) -> None:
|
||||
batch = self._event_batch.get()
|
||||
if batch is not None and batch["session_id"] == session_id:
|
||||
batch["events"].clear()
|
||||
|
||||
def _flush_event_batch(self, session_id: UUID) -> None:
|
||||
batch = self._event_batch.get()
|
||||
if batch is None or batch["session_id"] != session_id:
|
||||
return
|
||||
pending, batch["events"] = batch["events"], []
|
||||
batch["committed"] = not batch.get("persistent", False)
|
||||
for registry, target_session_id, event in pending:
|
||||
self._put(registry.get(target_session_id, set()), event)
|
||||
|
||||
def _send(self, registry: dict[UUID, set[asyncio.Queue]], session_id: UUID,
|
||||
event: BaseModel) -> None:
|
||||
batch = self._event_batch.get()
|
||||
if isinstance(event, ErrorEvent):
|
||||
self._put(registry.get(session_id, set()), event)
|
||||
elif (batch is not None and batch["session_id"] == session_id
|
||||
and batch["owner_task"] is _current_task()):
|
||||
batch["events"].append((registry, session_id, event))
|
||||
else:
|
||||
self._put(registry.get(session_id, set()), event)
|
||||
|
||||
async def fence(self, state: SessionState) -> None:
|
||||
"""Fail closed when durable ownership is lost or cannot be confirmed."""
|
||||
if state.lease_fenced:
|
||||
return
|
||||
state.lease_fenced = True
|
||||
self.stop_ticker(state.session_id)
|
||||
if state.voice is not None:
|
||||
try:
|
||||
await state.voice.close()
|
||||
except Exception as exc: # noqa: BLE001 — fencing must still close data channels
|
||||
log.error("не удалось закрыть голос при fencing занятия %s (%s)",
|
||||
state.session_id, type(exc).__name__)
|
||||
event = ErrorEvent(code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE)
|
||||
self.broadcast(state.session_id, event)
|
||||
self.to_station(state.session_id, event)
|
||||
|
||||
# ── подписки ──
|
||||
|
||||
|
|
@ -115,13 +268,16 @@ class SessionHub:
|
|||
queues.discard(queue)
|
||||
|
||||
def to_observers(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self._put(self._observers.get(session_id, set()), event)
|
||||
self._send(self._observers, session_id, event)
|
||||
|
||||
def to_trainee(self, session_id: UUID, event: BaseModel | bytes) -> None:
|
||||
self._put(self._trainees.get(session_id, set()), event)
|
||||
if isinstance(event, BaseModel):
|
||||
self._send(self._trainees, session_id, event)
|
||||
else:
|
||||
self._put(self._trainees.get(session_id, set()), event)
|
||||
|
||||
def to_station(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self._put(self._stations.get(session_id, set()), event)
|
||||
self._send(self._stations, session_id, event)
|
||||
|
||||
def broadcast(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self.to_trainee(session_id, event)
|
||||
|
|
@ -160,7 +316,7 @@ class SessionHub:
|
|||
state = self.get(session_id)
|
||||
if state is None or state.ended:
|
||||
return
|
||||
if state.exercise is Exercise.DDS:
|
||||
if state.exercise is Exercise.DDS or (state.handoff_to_dds and state.dds_scenarios):
|
||||
from app.session.dds import deliver_due_cards
|
||||
|
||||
active_before = state.dds_active_card_id
|
||||
|
|
|
|||
Loading…
Reference in a new issue