refactor: запись хода занятия — один commit SessionStore на операцию вместо журнала и checkpoint
Правка балла с пульта и из отчёта идёт одной доменной операцией: раньше WS-путь не обновлял full_report живой сессии.
This commit is contained in:
parent
72c6a7cfa3
commit
47cb85ee02
30 changed files with 1406 additions and 1161 deletions
|
|
@ -12,7 +12,7 @@ import logging
|
|||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import ErrorKind, ErrorEvent, Exercise, Metric, ScoreReady
|
||||
from app.domain.events import Exercise, Metric, ScoreReady
|
||||
from app.domain.statuses import ServiceStatus, current
|
||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||
from app.domain.timers import TimerCode
|
||||
|
|
@ -29,6 +29,7 @@ from app.scoring.timing import time_credit
|
|||
from app.scoring.weights import apply_weights
|
||||
from app.session.hub import hub
|
||||
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
|
||||
from app.session.store import ScoreArchived, ScoreCalculated, ScoreOverridden, apply_score_override
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -351,13 +352,9 @@ async def finish(session_id: UUID, state) -> None:
|
|||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||||
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
|
||||
|
||||
if hub.journal and not await hub.journal.score(session_id, result.score, state.score):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось сохранить оценку и аудит; итог не выдан. Обратитесь к преподавателю.",
|
||||
))
|
||||
return
|
||||
|
||||
# Оценка и аудит уходят в commit операции, завершившей занятие; ScoreReady
|
||||
# ждёт того же commit — без записи итог не выдаётся.
|
||||
hub.record(session_id, ScoreCalculated(result.score, state.score))
|
||||
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||
await release_score(session_id, state)
|
||||
|
||||
|
|
@ -370,11 +367,21 @@ async def refresh_archived_report(session_id: UUID, state) -> None:
|
|||
if scenario is None:
|
||||
return
|
||||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||||
if hub.journal:
|
||||
await hub.journal.score_snapshot(session_id, state.score)
|
||||
hub.record(session_id, ScoreArchived(state.score))
|
||||
|
||||
|
||||
async def release_score(session_id: UUID, state) -> None:
|
||||
"""Отдать оценку курсанту, когда самооценка сдана."""
|
||||
if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD):
|
||||
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|
||||
|
||||
|
||||
def override_score(state, override: ScoreOverridden) -> None:
|
||||
"""Правка балла преподавателем — одна операция для HTTP и WS.
|
||||
|
||||
Вызывается внутри `hub.operation`: строка `Score`, архивный разбор и аудит
|
||||
пишутся тем же commit, что и снимок с исправленной оценкой.
|
||||
"""
|
||||
hub.record(state.session_id, override)
|
||||
state.score = apply_score_override(state.score, override)
|
||||
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ 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
|
||||
|
|
@ -24,6 +23,7 @@ from app.domain.events import (
|
|||
TimerTick,
|
||||
)
|
||||
from app.session.state import SessionState
|
||||
from app.session.store import MemorySessionStore, Record, SessionStore
|
||||
|
||||
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
|
||||
#: очередь ограничена, переполнение роняет соединение, а не сессию.
|
||||
|
|
@ -34,51 +34,30 @@ 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 _Operation:
|
||||
"""Отложенная публикация одной операции: события ждут её commit."""
|
||||
|
||||
|
||||
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, 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
|
||||
) -> 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,
|
||||
) -> 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]: ...
|
||||
def __init__(self, session_id: UUID) -> None:
|
||||
self.session_id = session_id
|
||||
self.records: list[Record] = []
|
||||
self.events: list[tuple[dict[UUID, set[asyncio.Queue]], BaseModel]] = []
|
||||
self.open = True
|
||||
#: Такт, которому нечего сохранять, не пишет снимок каждую секунду.
|
||||
self.persist = True
|
||||
|
||||
|
||||
class SessionHub:
|
||||
def __init__(self, journal: Journal | None = None) -> None:
|
||||
self.journal = journal
|
||||
def __init__(self, store: SessionStore | None = None) -> None:
|
||||
self.store: SessionStore = store if store is not None else MemorySessionStore()
|
||||
self._sessions: dict[UUID, SessionState] = {}
|
||||
self._observers: dict[UUID, set[asyncio.Queue]] = {}
|
||||
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
|
||||
# Задачи, порождённые внутри операции, наследуют контекст; после
|
||||
# закрытия операции их события идут напрямую (`_Operation.open`).
|
||||
self._operation: ContextVar[_Operation | None] = ContextVar(
|
||||
f"session-operation-{id(self)}", default=None
|
||||
)
|
||||
|
||||
# ── реестр ──
|
||||
|
|
@ -139,84 +118,67 @@ class SessionHub:
|
|||
self._sessions.pop(session_id, None)
|
||||
self.stop_ticker(session_id)
|
||||
|
||||
async def checkpoint(self, session_id: UUID) -> None:
|
||||
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
|
||||
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:
|
||||
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)
|
||||
# ── операция: один commit, потом события ──
|
||||
|
||||
@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)
|
||||
async def operation(self, session_id: UUID) -> AsyncIterator[_Operation]:
|
||||
"""Изменение занятия фиксируется одним `store.commit` на выходе.
|
||||
|
||||
События копятся до коммита. Исключение внутри операции или сбой
|
||||
коммита отбрасывают их и закрывают занятие на узле (fail closed).
|
||||
"""
|
||||
op = _Operation(session_id)
|
||||
token: Token = self._operation.set(op)
|
||||
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)
|
||||
yield op
|
||||
state = self._sessions.get(session_id)
|
||||
if self.journal is not None and state is not None and not state.lease_fenced:
|
||||
if state is not None and op.persist:
|
||||
if state.lease_fenced:
|
||||
raise RuntimeError(LEASE_FENCED_MESSAGE)
|
||||
op.persist = False # второй commit из обработчика отмены не нужен
|
||||
await asyncio.shield(self.store.commit(state, op.records))
|
||||
except asyncio.CancelledError:
|
||||
# Задачу сокета отменили посреди операции — это не сбой хранилища.
|
||||
# Сделанное фиксируется, события слать уже некому.
|
||||
op.events.clear()
|
||||
state = self._sessions.get(session_id)
|
||||
if state is not None and op.persist and not state.lease_fenced:
|
||||
try:
|
||||
await asyncio.shield(self.store.commit(state, op.records))
|
||||
except Exception:
|
||||
await self.fence(state)
|
||||
raise
|
||||
except Exception:
|
||||
op.events.clear()
|
||||
state = self._sessions.get(session_id)
|
||||
if state is not None and not state.lease_fenced:
|
||||
await self.fence(state)
|
||||
raise
|
||||
finally:
|
||||
self._event_batch.reset(token)
|
||||
op.open = False
|
||||
self._operation.reset(token)
|
||||
for registry, event in op.events:
|
||||
self._put(registry.get(session_id, set()), event)
|
||||
|
||||
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 record(self, session_id: UUID, record: Record) -> None:
|
||||
"""Строка журнала уходит в commit текущей операции, не отдельной транзакцией."""
|
||||
op = self._operation.get()
|
||||
if op is None or not op.open or op.session_id != session_id:
|
||||
raise RuntimeError(f"запись занятия {session_id} вне операции")
|
||||
op.records.append(record)
|
||||
|
||||
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)
|
||||
async def commit(self, session_id: UUID, *records: Record) -> None:
|
||||
"""Операция из одних строк — реплика голосового контура."""
|
||||
async with self.operation(session_id):
|
||||
for record in records:
|
||||
self.record(session_id, record)
|
||||
|
||||
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))
|
||||
op = self._operation.get()
|
||||
if (op is not None and op.open and op.session_id == session_id
|
||||
and not isinstance(event, ErrorEvent)):
|
||||
op.events.append((registry, event))
|
||||
else:
|
||||
self._put(registry.get(session_id, set()), event)
|
||||
|
||||
|
|
@ -316,12 +278,11 @@ class SessionHub:
|
|||
if state is None or state.ended:
|
||||
return
|
||||
if state.dds_phase:
|
||||
active_before = state.desk.active_id
|
||||
delivered = state.desk.deliver_due()
|
||||
if state.desk.active_id != active_before and state.desk.active_id:
|
||||
self.to_station(session_id, state.card_received_event())
|
||||
if delivered:
|
||||
await self.checkpoint(session_id)
|
||||
async with self.operation(session_id) as op:
|
||||
active_before = state.desk.active_id
|
||||
op.persist = bool(state.desk.deliver_due())
|
||||
if state.desk.active_id != active_before and state.desk.active_id:
|
||||
self.to_station(session_id, state.card_received_event())
|
||||
# Keep the pending count and countdown live even while
|
||||
# the active dispatcher card is being handled.
|
||||
self.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||
|
|
|
|||
|
|
@ -1,423 +0,0 @@
|
|||
"""Запись хода занятия в БД.
|
||||
|
||||
Профиль курсанта, дельта попыток и аналитика группы строятся по журналу,
|
||||
а не по памяти процесса: всё, что здесь не записано, для оценки не существует.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.db import repo
|
||||
from app.db.models import AuditLog, Score, SelfAssessment, Session, User, Utterance
|
||||
from app.domain.events import Mood, Speaker, TranscriptEntry
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.state import SessionState, now_utc
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
LEASE_SECONDS = 15
|
||||
|
||||
|
||||
class SessionLeaseLost(RuntimeError):
|
||||
"""This process no longer owns the durable session generation."""
|
||||
|
||||
|
||||
class DbJournal:
|
||||
def __init__(self, sessionmaker: async_sessionmaker, node_id: str | None = None) -> None:
|
||||
self._sessionmaker = sessionmaker
|
||||
self._node_id = node_id
|
||||
self._epochs: dict[UUID, int] = {}
|
||||
|
||||
async def _fence(self, db, session_id: UUID, expected_epoch: int | None = None) -> None:
|
||||
"""Renew and fence this write in the same transaction as its mutation."""
|
||||
if self._node_id is None:
|
||||
return
|
||||
epoch = expected_epoch if expected_epoch is not None else self._epochs.get(session_id)
|
||||
if epoch is None:
|
||||
raise SessionLeaseLost(f"session {session_id} has no local fencing epoch")
|
||||
now = now_utc()
|
||||
result = await db.execute(
|
||||
update(Session)
|
||||
.where(
|
||||
Session.id == session_id,
|
||||
Session.backend_node_id == self._node_id,
|
||||
Session.backend_fencing_epoch == epoch,
|
||||
)
|
||||
.values(backend_lease_until=now + timedelta(seconds=LEASE_SECONDS))
|
||||
.returning(Session.id)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise SessionLeaseLost(f"session {session_id} owner epoch {epoch} was fenced")
|
||||
|
||||
async def _write(
|
||||
self, action, *args, _fence_session_id: UUID | None = None,
|
||||
_fence_epoch: int | None = None, _raise_errors: bool = False, **kwargs
|
||||
) -> None:
|
||||
"""Ошибка записи не роняет занятие, но и не проглатывается молча:
|
||||
занятие идёт дальше, в логе остаётся след."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
if _fence_session_id is not None:
|
||||
await self._fence(db, _fence_session_id, _fence_epoch)
|
||||
await action(db, *args, **kwargs)
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
session_id = _fence_session_id or kwargs.get("session_id")
|
||||
log.error("журнал: запись не удалась для сессии %s (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
if _raise_errors:
|
||||
raise
|
||||
|
||||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: 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:
|
||||
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
|
||||
|
||||
Строка сессии и событие аудита фиксируются вместе. При сбое транзакции
|
||||
занятие не запускается без долговечной истории.
|
||||
"""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
node_id = backend_node_id or self._node_id
|
||||
|
||||
def audit_start(transaction, row):
|
||||
if row.backend_fencing_epoch <= 0:
|
||||
row.backend_fencing_epoch = 1
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
transaction.add(AuditLog(
|
||||
actor=owner_login or "system",
|
||||
role="instructor" if owner_login else "system",
|
||||
action="lesson.start",
|
||||
object_id=str(row.id),
|
||||
detail=f"{scenario_id}, mode {mode}",
|
||||
))
|
||||
|
||||
row = await repo.ensure_session(
|
||||
db,
|
||||
session_id=session_id,
|
||||
scenario_id=scenario_id,
|
||||
mode=mode,
|
||||
trainee_name=trainee_name,
|
||||
trainee_id=trainee_id,
|
||||
owner_login=owner_login,
|
||||
backend_node_id=node_id,
|
||||
before_commit=audit_start,
|
||||
)
|
||||
epoch = getattr(row, "backend_fencing_epoch", 0) or 1
|
||||
self._epochs[session_id] = epoch
|
||||
service = None
|
||||
if row.trainee_id is not None:
|
||||
service = await db.scalar(
|
||||
select(User.service)
|
||||
.where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
|
||||
.limit(1)
|
||||
)
|
||||
return row.attempt, row.trainee_id, service, epoch
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.error("журнал: не удалось завести сессию %s (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return None
|
||||
|
||||
async def checkpoint(self, state: SessionState) -> None:
|
||||
"""Сохранить снимок после подтверждённого действия пользователя."""
|
||||
async def action(db):
|
||||
values = (
|
||||
{"live_state": None, "checkpoint_at": None}
|
||||
if state.ended
|
||||
else {"live_state": dump_state(state), "checkpoint_at": now_utc()}
|
||||
)
|
||||
await db.execute(update(Session).where(Session.id == state.session_id).values(**values))
|
||||
await db.commit()
|
||||
|
||||
if state.backend_fencing_epoch > 0:
|
||||
self._epochs.setdefault(state.session_id, state.backend_fencing_epoch)
|
||||
await self._write(
|
||||
lambda db: action(db), _fence_session_id=state.session_id,
|
||||
_fence_epoch=state.backend_fencing_epoch or None,
|
||||
_raise_errors=True,
|
||||
)
|
||||
|
||||
async def renew(self, session_id: UUID) -> None:
|
||||
"""Refresh an owned session lease; concurrent takeover is row-serialized."""
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
await db.commit()
|
||||
|
||||
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]:
|
||||
"""Atomically fence and restore expired owners on this backend node."""
|
||||
if self._node_id is None:
|
||||
return []
|
||||
now = now_utc()
|
||||
conditions = [
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
(Session.backend_node_id.is_(None) | (Session.backend_node_id != self._node_id)),
|
||||
(Session.backend_lease_until.is_(None) | (Session.backend_lease_until <= now)),
|
||||
]
|
||||
if session_id is not None:
|
||||
conditions.append(Session.id == session_id)
|
||||
async with self._sessionmaker() as db:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(*conditions).with_for_update(skip_locked=True).limit(100)
|
||||
)).all()
|
||||
for row in rows:
|
||||
row.backend_node_id = self._node_id
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now + timedelta(seconds=LEASE_SECONDS)
|
||||
if rows:
|
||||
await db.commit()
|
||||
if not rows:
|
||||
return []
|
||||
return await self.restore_active(bump_owned_epoch=False)
|
||||
|
||||
async def restore_active(self, *, bump_owned_epoch: bool = True) -> list[SessionState]:
|
||||
"""Восстановить только незавершённые сессии с валидным снимком."""
|
||||
restored: list[SessionState] = []
|
||||
async with self._sessionmaker() as db:
|
||||
active_with_snapshot = (
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
)
|
||||
if self._node_id is not None:
|
||||
# Adopt legacy unassigned snapshots exactly once. Concurrent
|
||||
# nodes lock disjoint rows; subsequent restores are owner-only.
|
||||
unassigned = (await db.scalars(
|
||||
select(Session)
|
||||
.where(*active_with_snapshot, Session.backend_node_id.is_(None))
|
||||
.with_for_update(skip_locked=True)
|
||||
)).all()
|
||||
for row in unassigned:
|
||||
row.backend_node_id = self._node_id
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
if unassigned:
|
||||
await db.commit()
|
||||
# A restarted process with the same stable node ID is a new
|
||||
# owner generation. Bump before exposing any restored state.
|
||||
owned = (await db.scalars(
|
||||
select(Session)
|
||||
.where(*active_with_snapshot, Session.backend_node_id == self._node_id)
|
||||
.with_for_update(skip_locked=True)
|
||||
)).all()
|
||||
if bump_owned_epoch:
|
||||
for row in owned:
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
if owned:
|
||||
await db.commit()
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(
|
||||
*active_with_snapshot,
|
||||
Session.backend_node_id == self._node_id,
|
||||
)
|
||||
)).all()
|
||||
else:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(*active_with_snapshot)
|
||||
)).all()
|
||||
for row in rows:
|
||||
try:
|
||||
state = load_state(row.live_state, row.checkpoint_at)
|
||||
if state.session_id != row.id:
|
||||
raise ValueError("ID снимка не совпадает с записью занятия")
|
||||
state.owner_login = row.owner_login
|
||||
state.backend_fencing_epoch = row.backend_fencing_epoch
|
||||
self._epochs[row.id] = row.backend_fencing_epoch
|
||||
# Реплики пишутся отдельно сразу после появления. Если
|
||||
# процесс умер между репликой и общим снимком, отдельный
|
||||
# журнал не даёт потерять последний фрагмент диалога.
|
||||
utterances = (await db.scalars(
|
||||
select(Utterance)
|
||||
.where(Utterance.session_id == row.id)
|
||||
.order_by(Utterance.at, Utterance.ref)
|
||||
)).all()
|
||||
if utterances:
|
||||
state.transcript = [
|
||||
TranscriptEntry(
|
||||
ref=item.ref,
|
||||
speaker=Speaker(item.speaker),
|
||||
text=item.text,
|
||||
at=item.at,
|
||||
mood=Mood(item.mood) if item.mood else None,
|
||||
)
|
||||
for item in utterances
|
||||
]
|
||||
restored.append(state)
|
||||
except Exception as exc: # noqa: BLE001 — один снимок не блокирует весь стенд
|
||||
log.error("журнал: снимок занятия %s повреждён (%s)",
|
||||
row.id, type(exc).__name__)
|
||||
return restored
|
||||
|
||||
async def utterance(self, session_id: UUID, entry) -> None:
|
||||
await self._write(
|
||||
repo.append_utterance,
|
||||
session_id=session_id,
|
||||
ref=entry.ref,
|
||||
speaker=entry.speaker.value,
|
||||
text=entry.text,
|
||||
at=entry.at,
|
||||
mood=entry.mood.value if entry.mood else None,
|
||||
_fence_session_id=session_id,
|
||||
)
|
||||
|
||||
async def hint(self, session_id: UUID, checklist_id: str, question: str, at: datetime) -> None:
|
||||
await self._write(
|
||||
repo.record_hint, _fence_session_id=session_id, session_id=session_id,
|
||||
checklist_id=checklist_id, question=question, at=at
|
||||
)
|
||||
|
||||
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None:
|
||||
await self._write(
|
||||
repo.add_note, _fence_session_id=session_id, session_id=session_id,
|
||||
transcript_ref=ref, text=text, author=author
|
||||
)
|
||||
|
||||
async def self_assessment(
|
||||
self, session_id: UUID, missed: list[str], comment: str, at: datetime
|
||||
) -> bool:
|
||||
"""Persist trainee reflection and its security audit together."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None:
|
||||
return False
|
||||
actor = "system"
|
||||
role = "system"
|
||||
if session.trainee_id is not None:
|
||||
login = await db.scalar(
|
||||
select(User.login).where(User.trainee_id == session.trainee_id)
|
||||
)
|
||||
if login:
|
||||
actor, role = login, "trainee"
|
||||
else:
|
||||
actor, role = f"trainee:{session.trainee_id}", "trainee"
|
||||
db.add(SelfAssessment(
|
||||
session_id=session_id, missed=missed, comment=comment, submitted_at=at
|
||||
))
|
||||
db.add(AuditLog(
|
||||
actor=actor,
|
||||
role=role,
|
||||
action="self_assessment.submit",
|
||||
object_id=str(session_id),
|
||||
detail=f"missed_count={len(missed)}; comment_chars={len(comment)}",
|
||||
))
|
||||
await self._fence(db, session_id)
|
||||
await db.commit()
|
||||
return True
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — do not accept an unaudited reflection
|
||||
log.error("самооценка и аудит сессии %s не сохранены (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> bool:
|
||||
"""Persist the initial result and its audit event atomically."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
db.add(Score(
|
||||
session_id=session_id, score_auto=score_auto,
|
||||
score_final=score_auto, report=report,
|
||||
))
|
||||
db.add(AuditLog(
|
||||
actor="system", role="system", action="score.calculate",
|
||||
object_id=str(session_id), detail=f"score_auto={score_auto}",
|
||||
))
|
||||
await db.commit()
|
||||
return True
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — result is not complete until durable
|
||||
log.error("итоговая оценка и аудит сессии %s не сохранены (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
async def score_override(
|
||||
self, session_id: UUID, score_final: float, author: str, comment: str
|
||||
) -> bool:
|
||||
"""Persist a live correction and its security audit as one transaction."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
score = await db.scalar(
|
||||
select(Score)
|
||||
.where(Score.session_id == session_id)
|
||||
.with_for_update()
|
||||
)
|
||||
if score is None:
|
||||
return False
|
||||
score.score_final = score_final
|
||||
score.overridden_by = author
|
||||
score.override_comment = comment
|
||||
report = dict(score.report or {})
|
||||
archived = report.get("full_report")
|
||||
if isinstance(archived, dict):
|
||||
archived = dict(archived)
|
||||
archived.update({
|
||||
"score_auto": score.score_auto,
|
||||
"score_final": score_final,
|
||||
"overridden_by": author,
|
||||
"override_comment": comment,
|
||||
})
|
||||
report["full_report"] = archived
|
||||
score.report = report
|
||||
db.add(AuditLog(
|
||||
actor=author,
|
||||
role="instructor",
|
||||
action="score.override",
|
||||
object_id=str(session_id),
|
||||
detail=(f"{score.score_auto} → {score_final}; "
|
||||
f"comment_chars={len(comment)}"),
|
||||
))
|
||||
await db.commit()
|
||||
return True
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — do not confirm a correction without its audit
|
||||
log.error("корректировка оценки и аудит сессии %s не сохранены (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
async def score_snapshot(self, session_id: UUID, report: dict) -> None:
|
||||
"""Обновить полный архивный разбор после самооценки курсанта."""
|
||||
async def action(db):
|
||||
await db.execute(
|
||||
update(Score).where(Score.session_id == session_id).values(report=report)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db), _fence_session_id=session_id)
|
||||
|
||||
async def session_started(self, session_id: UUID, at: datetime) -> None:
|
||||
async def action(db):
|
||||
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db), _fence_session_id=session_id)
|
||||
|
||||
async def session_ended(self, session_id: UUID, at: datetime, reason: str) -> None:
|
||||
async def action(db):
|
||||
await db.execute(
|
||||
update(Session)
|
||||
.where(Session.id == session_id)
|
||||
.values(
|
||||
ended_at=at,
|
||||
end_reason=reason,
|
||||
live_state=None,
|
||||
checkpoint_at=None,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db), _fence_session_id=session_id)
|
||||
362
backend/app/session/pg_store.py
Normal file
362
backend/app/session/pg_store.py
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
"""PostgreSQL adapter хранилища занятия.
|
||||
|
||||
Профиль курсанта, дельта попыток и аналитика группы строятся по этим строкам,
|
||||
а не по памяти процесса: всё, что здесь не записано, для оценки не существует.
|
||||
Каждый `commit` — одна транзакция: продление lease с проверкой `(node_id, epoch)`,
|
||||
append-only строки операции и снимок `live_state`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db import repo
|
||||
from app.db.models import (
|
||||
AuditLog,
|
||||
HintUse,
|
||||
InstructorNote,
|
||||
Score,
|
||||
SelfAssessment,
|
||||
Session,
|
||||
User,
|
||||
Utterance,
|
||||
)
|
||||
from app.domain.events import Mood, Speaker, TranscriptEntry
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.session.store import (
|
||||
HintRecorded,
|
||||
LessonEnded,
|
||||
LessonIdentity,
|
||||
LessonRequest,
|
||||
LessonStarted,
|
||||
NoteAdded,
|
||||
Record,
|
||||
ScoreArchived,
|
||||
ScoreCalculated,
|
||||
ScoreOverridden,
|
||||
SelfAssessed,
|
||||
SessionLeaseLost,
|
||||
UtteranceAppended,
|
||||
apply_score_override,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
LEASE_SECONDS = 15
|
||||
|
||||
__all__ = ["LEASE_SECONDS", "PostgresSessionStore", "SessionLeaseLost"]
|
||||
|
||||
|
||||
class PostgresSessionStore:
|
||||
persistent = True
|
||||
|
||||
def __init__(self, sessionmaker: async_sessionmaker, node_id: str | None = None) -> None:
|
||||
self._sessionmaker = sessionmaker
|
||||
self._node_id = node_id
|
||||
self._epochs: dict[UUID, int] = {}
|
||||
|
||||
async def _fence(self, db, session_id: UUID, expected_epoch: int | None = None) -> None:
|
||||
"""Renew and fence this write in the same transaction as its mutation."""
|
||||
if self._node_id is None:
|
||||
return
|
||||
epoch = expected_epoch if expected_epoch is not None else self._epochs.get(session_id)
|
||||
if epoch is None:
|
||||
raise SessionLeaseLost(f"session {session_id} has no local fencing epoch")
|
||||
now = now_utc()
|
||||
result = await db.execute(
|
||||
update(Session)
|
||||
.where(
|
||||
Session.id == session_id,
|
||||
Session.backend_node_id == self._node_id,
|
||||
Session.backend_fencing_epoch == epoch,
|
||||
)
|
||||
.values(backend_lease_until=now + timedelta(seconds=LEASE_SECONDS))
|
||||
.returning(Session.id)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise SessionLeaseLost(f"session {session_id} owner epoch {epoch} was fenced")
|
||||
|
||||
async def open(
|
||||
self, request: LessonRequest, build: Callable[[LessonIdentity], SessionState],
|
||||
) -> SessionState:
|
||||
"""Строка занятия, аудит запуска и первый снимок — одна транзакция.
|
||||
|
||||
При сбое занятие не запускается без долговечной истории.
|
||||
"""
|
||||
async with self._sessionmaker() as db:
|
||||
row = await repo.ensure_session(
|
||||
db,
|
||||
session_id=request.session_id,
|
||||
scenario_id=request.scenario_id,
|
||||
mode=request.mode,
|
||||
trainee_name=request.trainee_name,
|
||||
trainee_id=request.trainee_id,
|
||||
owner_login=request.owner_login,
|
||||
backend_node_id=request.backend_node_id or self._node_id,
|
||||
commit=False,
|
||||
)
|
||||
if row.backend_fencing_epoch <= 0:
|
||||
row.backend_fencing_epoch = 1
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
db.add(AuditLog(
|
||||
actor=request.owner_login or "system",
|
||||
role="instructor" if request.owner_login else "system",
|
||||
action="lesson.start",
|
||||
object_id=str(row.id),
|
||||
detail=f"{request.scenario_id}, mode {request.mode}",
|
||||
))
|
||||
service = None
|
||||
if row.trainee_id is not None:
|
||||
service = await db.scalar(
|
||||
select(User.service)
|
||||
.where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
|
||||
.limit(1)
|
||||
)
|
||||
epoch = row.backend_fencing_epoch
|
||||
state = build(LessonIdentity(
|
||||
attempt=row.attempt, trainee_id=row.trainee_id,
|
||||
service=service, fencing_epoch=epoch,
|
||||
))
|
||||
if state.started_at is not None:
|
||||
row.started_at = state.started_at
|
||||
row.live_state = dump_state(state)
|
||||
row.checkpoint_at = now_utc()
|
||||
await db.commit()
|
||||
self._epochs[request.session_id] = epoch
|
||||
return state
|
||||
|
||||
async def commit(self, state: SessionState, records: Sequence[Record] = ()) -> None:
|
||||
session_id = state.session_id
|
||||
if state.backend_fencing_epoch > 0:
|
||||
self._epochs.setdefault(session_id, state.backend_fencing_epoch)
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id, state.backend_fencing_epoch or None)
|
||||
for record in records:
|
||||
await self._apply(db, session_id, record)
|
||||
values = (
|
||||
{"live_state": None, "checkpoint_at": None}
|
||||
if state.ended
|
||||
else {"live_state": dump_state(state), "checkpoint_at": now_utc()}
|
||||
)
|
||||
await db.execute(
|
||||
update(Session).where(Session.id == session_id).values(**values)
|
||||
)
|
||||
await db.commit()
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# SQL-трасса несёт реплики и комментарии курсанта — в лог только тип.
|
||||
log.error("хранилище: commit занятия %s не удался (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
raise
|
||||
|
||||
async def commit_archived(self, session_id: UUID, records: Sequence[Record]) -> None:
|
||||
"""Занятия нет в памяти узла, владеть нечем: без lease и снимка."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
for record in records:
|
||||
await self._apply(db, session_id, record)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
log.error("хранилище: запись архивного занятия %s не удалась (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
raise
|
||||
|
||||
async def _apply(self, db: AsyncSession, session_id: UUID, record: Record) -> None:
|
||||
match record:
|
||||
case UtteranceAppended(entry=entry):
|
||||
db.add(Utterance(
|
||||
session_id=session_id, ref=entry.ref, speaker=entry.speaker.value,
|
||||
text=entry.text, at=entry.at,
|
||||
mood=entry.mood.value if entry.mood else None,
|
||||
))
|
||||
case HintRecorded():
|
||||
db.add(HintUse(
|
||||
session_id=session_id, checklist_id=record.checklist_id,
|
||||
question=record.question, at=record.at,
|
||||
))
|
||||
case NoteAdded():
|
||||
db.add(InstructorNote(
|
||||
session_id=session_id, transcript_ref=record.transcript_ref,
|
||||
text=record.text, author=record.author,
|
||||
))
|
||||
case SelfAssessed():
|
||||
actor, role = await self._trainee_actor(db, session_id)
|
||||
db.add(SelfAssessment(
|
||||
session_id=session_id, missed=record.missed,
|
||||
comment=record.comment, submitted_at=record.at,
|
||||
))
|
||||
db.add(AuditLog(
|
||||
actor=actor, role=role, action="self_assessment.submit",
|
||||
object_id=str(session_id),
|
||||
detail=f"missed_count={len(record.missed)}; comment_chars={len(record.comment)}",
|
||||
))
|
||||
case LessonStarted(at=at):
|
||||
await db.execute(
|
||||
update(Session).where(Session.id == session_id).values(started_at=at)
|
||||
)
|
||||
case LessonEnded(at=at, reason=reason):
|
||||
await db.execute(
|
||||
update(Session).where(Session.id == session_id)
|
||||
.values(ended_at=at, end_reason=reason)
|
||||
)
|
||||
case ScoreCalculated():
|
||||
db.add(Score(
|
||||
session_id=session_id, score_auto=record.score_auto,
|
||||
score_final=record.score_auto, report=record.report,
|
||||
))
|
||||
db.add(AuditLog(
|
||||
actor="system", role="system", action="score.calculate",
|
||||
object_id=str(session_id), detail=f"score_auto={record.score_auto}",
|
||||
))
|
||||
case ScoreArchived():
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
update(Score).where(Score.session_id == session_id)
|
||||
.values(report=record.report)
|
||||
)
|
||||
case ScoreOverridden():
|
||||
await db.flush()
|
||||
score = await db.scalar(
|
||||
select(Score).where(Score.session_id == session_id).with_for_update()
|
||||
)
|
||||
if score is None:
|
||||
raise LookupError(f"нет оценки занятия {session_id}")
|
||||
score.score_final = record.score_final
|
||||
score.overridden_by = record.author
|
||||
score.override_comment = record.comment
|
||||
score.report = apply_score_override(dict(score.report or {}), record)
|
||||
db.add(AuditLog(
|
||||
actor=record.author, role=record.role, action="score.override",
|
||||
object_id=str(session_id),
|
||||
# Обоснование остаётся в разборе; аудиту нужны изменение и автор,
|
||||
# а не вторая бессрочная копия свободного текста.
|
||||
detail=(f"{score.score_auto} → {record.score_final}; "
|
||||
f"comment_chars={len(record.comment)}"),
|
||||
))
|
||||
await db.flush()
|
||||
|
||||
@staticmethod
|
||||
async def _trainee_actor(db: AsyncSession, session_id: UUID) -> tuple[str, str]:
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None or session.trainee_id is None:
|
||||
return "system", "system"
|
||||
login = await db.scalar(select(User.login).where(User.trainee_id == session.trainee_id))
|
||||
return (login, "trainee") if login else (f"trainee:{session.trainee_id}", "trainee")
|
||||
|
||||
async def renew(self, session_id: UUID) -> None:
|
||||
"""Refresh an owned session lease; concurrent takeover is row-serialized."""
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
await db.commit()
|
||||
|
||||
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]:
|
||||
"""Atomically fence and restore expired owners on this backend node."""
|
||||
if self._node_id is None:
|
||||
return []
|
||||
now = now_utc()
|
||||
conditions = [
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
(Session.backend_node_id.is_(None) | (Session.backend_node_id != self._node_id)),
|
||||
(Session.backend_lease_until.is_(None) | (Session.backend_lease_until <= now)),
|
||||
]
|
||||
if session_id is not None:
|
||||
conditions.append(Session.id == session_id)
|
||||
async with self._sessionmaker() as db:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(*conditions).with_for_update(skip_locked=True).limit(100)
|
||||
)).all()
|
||||
for row in rows:
|
||||
row.backend_node_id = self._node_id
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now + timedelta(seconds=LEASE_SECONDS)
|
||||
if rows:
|
||||
await db.commit()
|
||||
if not rows:
|
||||
return []
|
||||
return await self.restore_active(bump_owned_epoch=False)
|
||||
|
||||
async def restore_active(self, *, bump_owned_epoch: bool = True) -> list[SessionState]:
|
||||
"""Восстановить только незавершённые сессии с валидным снимком."""
|
||||
restored: list[SessionState] = []
|
||||
async with self._sessionmaker() as db:
|
||||
active_with_snapshot = (
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
)
|
||||
if self._node_id is not None:
|
||||
# Adopt legacy unassigned snapshots exactly once. Concurrent
|
||||
# nodes lock disjoint rows; subsequent restores are owner-only.
|
||||
unassigned = (await db.scalars(
|
||||
select(Session)
|
||||
.where(*active_with_snapshot, Session.backend_node_id.is_(None))
|
||||
.with_for_update(skip_locked=True)
|
||||
)).all()
|
||||
for row in unassigned:
|
||||
row.backend_node_id = self._node_id
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
if unassigned:
|
||||
await db.commit()
|
||||
# A restarted process with the same stable node ID is a new
|
||||
# owner generation. Bump before exposing any restored state.
|
||||
owned = (await db.scalars(
|
||||
select(Session)
|
||||
.where(*active_with_snapshot, Session.backend_node_id == self._node_id)
|
||||
.with_for_update(skip_locked=True)
|
||||
)).all()
|
||||
if bump_owned_epoch:
|
||||
for row in owned:
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
if owned:
|
||||
await db.commit()
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(
|
||||
*active_with_snapshot,
|
||||
Session.backend_node_id == self._node_id,
|
||||
)
|
||||
)).all()
|
||||
else:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(*active_with_snapshot)
|
||||
)).all()
|
||||
for row in rows:
|
||||
try:
|
||||
state = load_state(row.live_state, row.checkpoint_at)
|
||||
if state.session_id != row.id:
|
||||
raise ValueError("ID снимка не совпадает с записью занятия")
|
||||
state.owner_login = row.owner_login
|
||||
state.backend_fencing_epoch = row.backend_fencing_epoch
|
||||
self._epochs[row.id] = row.backend_fencing_epoch
|
||||
# Стенограмма — по строкам реплик: снимок, записанный до
|
||||
# перехода на единый commit, мог отстать от них.
|
||||
utterances = (await db.scalars(
|
||||
select(Utterance)
|
||||
.where(Utterance.session_id == row.id)
|
||||
.order_by(Utterance.at, Utterance.ref)
|
||||
)).all()
|
||||
if utterances:
|
||||
state.transcript = [
|
||||
TranscriptEntry(
|
||||
ref=item.ref,
|
||||
speaker=Speaker(item.speaker),
|
||||
text=item.text,
|
||||
at=item.at,
|
||||
mood=Mood(item.mood) if item.mood else None,
|
||||
)
|
||||
for item in utterances
|
||||
]
|
||||
restored.append(state)
|
||||
except Exception as exc: # noqa: BLE001 — один снимок не блокирует весь стенд
|
||||
log.error("журнал: снимок занятия %s повреждён (%s)",
|
||||
row.id, type(exc).__name__)
|
||||
return restored
|
||||
215
backend/app/session/store.py
Normal file
215
backend/app/session/store.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""Port хранилища живого занятия.
|
||||
|
||||
Операция курсанта или преподавателя фиксируется одним `commit`: проверка
|
||||
владения `(node_id, epoch)`, снимок состояния и append-only строки — в одной
|
||||
транзакции. Хаб публикует события только после успешного `commit`; сбой
|
||||
отбрасывает их и закрывает занятие на этом узле (docs/arch/SCALE-OUT.md).
|
||||
|
||||
Adapter-ы: PostgreSQL (`app.session.pg_store`) и in-memory — для `DEMO_NO_DB`
|
||||
и юнит-тестов.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import TranscriptEntry
|
||||
from app.session.checkpoint import dump_state
|
||||
from app.session.state import SessionState
|
||||
|
||||
|
||||
class SessionLeaseLost(RuntimeError):
|
||||
"""This process no longer owns the durable session generation."""
|
||||
|
||||
|
||||
# ── append-only строки операции ──
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UtteranceAppended:
|
||||
entry: TranscriptEntry
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HintRecorded:
|
||||
checklist_id: str
|
||||
question: str
|
||||
at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoteAdded:
|
||||
transcript_ref: str
|
||||
text: str
|
||||
author: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelfAssessed:
|
||||
missed: list[str]
|
||||
comment: str
|
||||
at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LessonStarted:
|
||||
at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LessonEnded:
|
||||
at: datetime
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoreCalculated:
|
||||
score_auto: float
|
||||
report: dict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoreArchived:
|
||||
"""Полный разбор дополнен после оценки — например, самооценкой курсанта."""
|
||||
report: dict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoreOverridden:
|
||||
score_final: float
|
||||
author: str
|
||||
role: str
|
||||
comment: str
|
||||
|
||||
|
||||
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
|
||||
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden)
|
||||
|
||||
|
||||
def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
|
||||
"""Правка балла одинакова для живой сессии, строки `Score` и архива.
|
||||
|
||||
Автооценка остаётся рядом: видно, что скорректировано и кем. Архивный
|
||||
`full_report` правится тем же образом, иначе PDF и история покажут
|
||||
старую оценку.
|
||||
"""
|
||||
fields = {
|
||||
"score_final": override.score_final,
|
||||
"overridden_by": override.author,
|
||||
"override_comment": override.comment,
|
||||
}
|
||||
updated = {**report, **fields}
|
||||
archived = report.get("full_report")
|
||||
if isinstance(archived, dict):
|
||||
updated["full_report"] = {
|
||||
**archived,
|
||||
"score_auto": report.get("score_auto", archived.get("score_auto")),
|
||||
**fields,
|
||||
}
|
||||
return updated
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LessonIdentity:
|
||||
"""То, что о занятии знает только хранилище: номер попытки, курсант, эпоха."""
|
||||
attempt: int
|
||||
trainee_id: UUID | None
|
||||
service: str | None
|
||||
fencing_epoch: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LessonRequest:
|
||||
session_id: UUID
|
||||
scenario_id: str
|
||||
mode: str
|
||||
trainee_name: str | None
|
||||
trainee_id: UUID | None
|
||||
owner_login: str | None
|
||||
backend_node_id: str | None
|
||||
|
||||
|
||||
class SessionStore(Protocol):
|
||||
#: Переживает ли занятие перезапуск процесса. Без этого каталог сценариев
|
||||
#: и отчёты берутся только из памяти.
|
||||
persistent: bool
|
||||
|
||||
async def open(
|
||||
self, request: LessonRequest, build: Callable[[LessonIdentity], SessionState],
|
||||
) -> SessionState:
|
||||
"""Завести занятие, аудит запуска и первый снимок одной транзакцией."""
|
||||
...
|
||||
|
||||
async def commit(self, state: SessionState, records: Sequence[Record] = ()) -> None:
|
||||
"""Проверка владения, снимок и строки — одна транзакция. Ошибка — исключение."""
|
||||
...
|
||||
|
||||
async def commit_archived(self, session_id: UUID, records: Sequence[Record]) -> 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 MemorySessionStore:
|
||||
"""Занятие живёт до перезапуска процесса: `DEMO_NO_DB` и юнит-тесты.
|
||||
|
||||
Хранит то же, что PostgreSQL, в словарях — тесты читают снимок, строки
|
||||
и оценку тем же путём, что и сквозной сценарий.
|
||||
"""
|
||||
|
||||
persistent = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.commits: list[tuple[UUID, list[Record]]] = []
|
||||
self.records: dict[UUID, list[Record]] = {}
|
||||
self.scores: dict[UUID, dict] = {}
|
||||
self._snapshots: dict[UUID, dict | None] = {}
|
||||
|
||||
async def open(
|
||||
self, request: LessonRequest, build: Callable[[LessonIdentity], SessionState],
|
||||
) -> SessionState:
|
||||
state = build(LessonIdentity(
|
||||
attempt=1, trainee_id=request.trainee_id, service=None, fencing_epoch=0,
|
||||
))
|
||||
self.commits.append((request.session_id, []))
|
||||
self._snapshots[request.session_id] = dump_state(state)
|
||||
return state
|
||||
|
||||
async def commit(self, state: SessionState, records: Sequence[Record] = ()) -> None:
|
||||
records = list(records)
|
||||
self.commits.append((state.session_id, records))
|
||||
self._apply(state.session_id, records)
|
||||
self._snapshots[state.session_id] = None if state.ended else dump_state(state)
|
||||
|
||||
async def commit_archived(self, session_id: UUID, records: Sequence[Record]) -> None:
|
||||
records = list(records)
|
||||
self.commits.append((session_id, records))
|
||||
self._apply(session_id, records)
|
||||
|
||||
def _apply(self, session_id: UUID, records: list[Record]) -> None:
|
||||
for record in records:
|
||||
if isinstance(record, ScoreCalculated):
|
||||
self.scores[session_id] = dict(record.report)
|
||||
elif isinstance(record, ScoreArchived):
|
||||
self.scores[session_id] = dict(record.report)
|
||||
elif isinstance(record, ScoreOverridden):
|
||||
if session_id not in self.scores:
|
||||
raise LookupError(f"нет оценки занятия {session_id}")
|
||||
self.scores[session_id] = apply_score_override(self.scores[session_id], record)
|
||||
self.records.setdefault(session_id, []).extend(records)
|
||||
|
||||
def snapshot(self, session_id: UUID) -> dict | None:
|
||||
return self._snapshots.get(session_id)
|
||||
|
||||
async def restore_active(self) -> list[SessionState]:
|
||||
return []
|
||||
|
||||
async def renew(self, session_id: UUID) -> None:
|
||||
return None
|
||||
|
||||
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]:
|
||||
return []
|
||||
Loading…
Reference in a new issue