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