refactor: запись хода занятия — один commit SessionStore на операцию вместо журнала и checkpoint

Правка балла с пульта и из отчёта идёт одной доменной операцией: раньше WS-путь не обновлял full_report живой сессии.
This commit is contained in:
gglamer 2026-09-26 22:48:24 +00:00
commit 47cb85ee02
30 changed files with 1406 additions and 1161 deletions

View file

@ -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))

View file

@ -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()))

View file

@ -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)

View 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

View 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 []