Repository navigation
fix(gateway): replay the transcript spool in drop order with full fidelity on restart (salvage #122718) #133932
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 15 commits
b438a89
9f77071
70488cc
be3f1c8
19e53d5
c918adf
a2c0e19
0bd036f
e2154ae
36f27f2
d4d5a8e
4d7d3c0
02938cb
7a54b7f
376d2e1
ab8d368
575b4dd
0b9659c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,36 @@ def _spool_dropped(session_id: str, message: Dict[str, Any]): | |
| ) | ||
|
|
||
|
|
||
| def transcript_append_kwargs(session_id: str, message: Dict[str, Any], *, fallback_ts: Any = None) -> Dict[str, Any]: | ||
| """``SessionDB.append_message`` kwargs for one transcript row. The live writer and the restart | ||
| replay of the transcript spool (gateway/shutdown_flush.py) both build rows here, so a replayed | ||
| message lands as the row the live drain would have written. Fields are listed rather than | ||
| splatted: a spooled message is arbitrary JSON from disk, and an unexpected key would raise and | ||
| abort the replay. *fallback_ts* applies only to a message with no timestamp at all, because a | ||
| truthiness test would rewrite epoch 0.""" | ||
| is_assistant = message.get("role") == "assistant" | ||
| timestamp = message.get("timestamp") | ||
| return { | ||
| "session_id": session_id, | ||
| "role": message.get("role", "unknown"), | ||
| "content": message.get("content"), | ||
| "tool_name": message.get("tool_name"), | ||
| "tool_calls": message.get("tool_calls"), | ||
| "tool_call_id": message.get("tool_call_id"), | ||
| **{k: message.get(k) if is_assistant else None for k in _ASSISTANT_ONLY_KEYS}, | ||
| "platform_message_id": message.get("platform_message_id") or message.get("message_id"), | ||
| "observed": bool(message.get("observed")), | ||
| "timestamp": fallback_ts if timestamp is None else timestamp, | ||
| # Exact bytes sent to the API (prompt-cache-stable replay); must survive every | ||
| # persistence path or the next replay diverges. | ||
| "api_content": extract_api_content_sidecar(message), | ||
| # Presentation typing ("internal_notification" for self-injected async-delegation/background | ||
| # notification turns, #82888). DB-only; stripped from provider-bound payloads. | ||
| "display_kind": message.get("display_kind"), | ||
| "display_metadata": message.get("display_metadata"), | ||
| } | ||
|
|
||
|
|
||
| class SessionTranscriptMixin: | ||
| """SessionStore transcript I/O: SQLite append with a per-session retry queue, | ||
| compression-reroute following, FTS corruption recovery, rewrite/rewind/load.""" | ||
|
|
@@ -244,10 +274,15 @@ def _ack_head() -> bool: | |
|
|
||
| # DB write outside the retry lock so other sessions can append. | ||
| while True: | ||
| # Spooled backlog (cap eviction or a stalled session) is older than ``msg``: replay it | ||
| # first so recovery keeps transcript order; a still-dead DB just fails both. | ||
| self._drain_spooled_drops(session_id) | ||
| spool_exc = None | ||
| try: | ||
| # Spooled backlog (cap eviction, a stalled session, a boot hold-back) is older than | ||
| # ``msg``: replay it first. While any of it stays on disk ``msg`` stays queued, since | ||
| # writing it now would give it a lower row id than that backlog for good. The replay's | ||
| # own error is raised so the repair/divert handling below still classifies it. | ||
| spool_exc = self._drain_spooled_drops(session_id) | ||
| if spool_exc is not None: | ||
| raise spool_exc | ||
| self._append_transcript_message(session_id, msg) | ||
| except Exception as exc: | ||
| from hermes_state import StateDbCorruptError, StateDbReplacedError | ||
|
|
@@ -289,6 +324,8 @@ def _ack_head() -> bool: | |
| "no unique live child; not retrying", session_id) | ||
| return | ||
| if self._is_fts_corruption_error(exc) and self._rebuild_fts_once(): | ||
| if spool_exc is not None: | ||
| continue # repaired: drain the older spool again before ``msg`` | ||
| try: | ||
| self._append_transcript_message(session_id, msg) | ||
| except Exception as retry_exc: | ||
|
|
@@ -341,26 +378,43 @@ def _spool_stalled_backlog(self, session_id: str, queue_session_id: str) -> int: | |
| self._dirty_transcripts.pop(queue_session_id, None) | ||
| return spooled | ||
|
|
||
| def _drain_spooled_drops(self, session_id: str) -> None: | ||
| """Replay cap-dropped spooled transcript messages after DB recovery. Best-effort: replay | ||
| failures keep the spool files for the next successful flush; nothing here may raise.""" | ||
| def _drain_spooled_drops(self, session_id: str) -> Exception | None: | ||
| """Replay cap-dropped spooled transcript messages after DB recovery; return the replay | ||
| failure while some of them are still on disk, else None. Best-effort: replay failures keep | ||
| the spool files for the next successful flush; nothing here may raise.""" | ||
| spooled_sessions = getattr(self, "_spooled_drop_sessions", None) | ||
| if not spooled_sessions or session_id not in spooled_sessions: | ||
| return | ||
| return None | ||
| failures: list[Exception] = [] | ||
|
|
||
| def replay(message: Dict[str, Any]) -> None: | ||
| try: | ||
| self._append_transcript_message(session_id, message) | ||
| except Exception as exc: | ||
| failures.append(exc) | ||
| raise | ||
|
|
||
| try: | ||
| from gateway.shutdown_flush import drain_transcript_spool | ||
| # Inside an outage the append that follows logs/escalates the same failure; the | ||
| # replay attempt is only the order-preserving probe, so its failure stays at DEBUG. | ||
| with self._transcript_retry_lock: | ||
| known_failing = bool(self._transcript_append_failures.get(session_id)) | ||
| _replayed, remaining = drain_transcript_spool( | ||
| session_id, lambda message: self._append_transcript_message(session_id, message), | ||
| db_known_failing=known_failing, | ||
| ) | ||
| session_id, replay, db_known_failing=known_failing) | ||
| if not remaining: | ||
| spooled_sessions.discard(session_id) | ||
| return None | ||
| return failures[-1] if failures else RuntimeError( | ||
| f"older spooled transcript rows for {session_id} still pending") | ||
| except Exception as exc: | ||
| logger.warning("Failed to drain transcript spool for %s: %s", session_id, exc) | ||
| return None | ||
|
Comment on lines
431
to
+433
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a spool row is saved but its file cannot be deleted, Prompt To Fix With AIThis is a comment left during a code review.
Path: gateway/session_transcript.py
Line: 431-433
Comment:
**Old spool row repeats**
If a spool row is saved but its file cannot be deleted, `_drain_spooled_drops` returns `None` after logging the error. The live writer then saves a newer row while the old file remains. On the next restart, the old row can be saved again, leaving a duplicate out of order. Keep the live row queued when file cleanup fails.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Partly fixed in The restart duplicate you describe remains: nothing on disk records that the row was written, so the next start writes it once more. We are keeping fail-open behaviour rather than holding newer messages back, and the error log names the file to delete by hand. Keeping the live row queued would not prevent the restart duplicate either. |
||
|
|
||
| def spooled_drop_sessions(self) -> set: | ||
| """The live set of sessions whose next transcript write drains the spool first. Boot recovery | ||
| adds the sessions it held back, so their next live row cannot land ahead of those files.""" | ||
| return self._lazy("_spooled_drop_sessions", set) | ||
|
|
||
| def _append_transcript_message(self, session_id: str, message: Dict[str, Any]) -> None: | ||
| """Write one transcript row. Caller handles retry queuing.""" | ||
|
|
@@ -370,27 +424,7 @@ def _append_transcript_message(self, session_id: str, message: Dict[str, Any]) - | |
| # into the ambient store. | ||
| raise RuntimeError( | ||
| f"no owning session store for {session_id}; deferring transcript write") | ||
| is_assistant = message.get("role") == "assistant" | ||
| _db.append_message( | ||
| session_id=session_id, | ||
| role=message.get("role", "unknown"), | ||
| content=message.get("content"), | ||
| tool_name=message.get("tool_name"), | ||
| tool_calls=message.get("tool_calls"), | ||
| tool_call_id=message.get("tool_call_id"), | ||
| **{k: message.get(k) if is_assistant else None for k in _ASSISTANT_ONLY_KEYS}, | ||
| platform_message_id=(message.get("platform_message_id") or message.get("message_id")), | ||
| observed=bool(message.get("observed")), | ||
| timestamp=message.get("timestamp"), | ||
| # Exact bytes sent to the API (prompt-cache-stable replay); must survive every | ||
| # persistence path or the next replay diverges. | ||
| api_content=extract_api_content_sidecar(message), | ||
| # Presentation typing (e.g. "internal_notification"); DB-only. | ||
| # "internal_notification" for self-injected async-delegation/background notification turns, | ||
| # #82888). DB-only; stripped from provider-bound payloads. | ||
| display_kind=message.get("display_kind"), | ||
| display_metadata=message.get("display_metadata"), | ||
| ) | ||
| _db.append_message(**transcript_append_kwargs(session_id, message)) | ||
|
|
||
| @staticmethod | ||
| def _is_fts_corruption_error(exc: Exception) -> bool: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.