mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-26 00:16:53 +00:00
* feat: SSE notification system
Adds a per-user SSE pipe (GET /api/events) plus a per-message
chat-stream reconnect endpoint (GET /api/messages/<id>/events).
Backend substrate:
- application/events/ — durable journal (Redis Streams) + live
pub/sub for user-scoped events, with publish_user_event() as
the worker-side entrypoint.
- application/streaming/ — broadcast_channel for pub/sub fanout
and event_replay for the per-message snapshot+tail path.
- application/storage/db/repositories/message_events.py +
alembic 0007 — Postgres journal for chat-stream events.
- application/worker.py — ingest/reingest/remote/connector/
attachment/mcp_oauth tasks publish queued/progress/completed/
failed envelopes alongside their existing status updates.
Frontend client:
- frontend/src/events/ — connect/reconnect, Last-Event-ID cursor,
backoff with jitter. Each tab runs its own connection; no
cross-tab dedup (future work).
- frontend/src/notifications/ — recentEvents ring, cursor
tracking, tool-approval toast.
- frontend/src/upload/uploadSlice.ts — extraReducers for
source.ingest.* and attachment.* events.
Coverage: 132 SSE tests across events substrate, replay, journal,
routes, and worker publishes.
* refactor(attachments): remove polling, SSE-only
frontend/src/components/MessageInput.tsx no longer runs a 2s
setInterval against getTaskStatus for every processing
attachment. The attachment.* SSE reducers in uploadSlice.ts are
now the sole driver of attachment state transitions.
* feat(connector): consume source.ingest.* SSE, remove polling
frontend/src/components/ConnectorTree.tsx now mirrors FileTree's
slice-walking pattern: it watches notifications.recentEvents
for source.ingest.{completed,failed} envelopes matching the
sync's source id, and no longer polls /task_status every 2s.
* refactor(source-ingest): remove polling, SSE-only
frontend/src/upload/Upload.tsx and
frontend/src/components/FileTree.tsx no longer run getTaskStatus
polling fallbacks. The source.ingest.* SSE reducers in
uploadSlice.ts and FileTree's slice walk are now the sole
drivers of upload/reingest state transitions.
* refactor(mcp-oauth): carry authorization_url in SSE, remove polling
application/worker.py::mcp_oauth now publishes
authorization_url on the mcp.oauth.awaiting_redirect envelope.
frontend/src/modals/MCPServerModal.tsx consumes it from SSE
instead of polling /oauth_status/<task_id> every 1s.
The URL is generated inside DocsGPTOAuth.redirect_handler when
the FastMCP client triggers OAuth. The worker now plumbs a
publish callback through tool_config -> MCPTool -> DocsGPTOAuth
so the awaiting_redirect publish fires from inside the handler
at the exact point the URL becomes known. The legacy Redis
mcp_oauth_status setex writes and the GET
/api/mcp_server/oauth_status/<task_id> endpoint are kept as
belt-and-suspenders; nothing in the frontend reads them now.
* feat(source-ingest): plumb limited flag through SSE for token-cap UX
application/worker.py::ingest_worker and remote_worker now publish
``limited: bool`` on the source.ingest.completed envelope.
uploadSlice routes ``payload.limited === true`` to a failed status
with a ``tokenLimitReached`` flag, and UploadToast surfaces the
translated tokenLimit i18n string. No worker code path sets
limited=true today; this is a forward-looking contract so when
token-cap detection lands, the UX is already wired.
* refactor(mcp-oauth): read status from SSE journal, drop polling endpoint
MCPOAuthManager.get_oauth_status now walks the per-user SSE Streams
journal (user:{user_id}:stream) for the latest mcp.oauth.* envelope
matching the task id, returning the status string derived from the
event type suffix and the payload fields. The worker is the single
source of truth — its publish_user_event calls write the same
record the SSE client receives live.
Removed:
- /api/mcp_server/oauth_status/<task_id> route in
application/api/user/tools/mcp.py
- mcp_oauth_status worker function and mcp_oauth_status_task Celery
wrapper
- All mcp_oauth_status:{task_id} Redis setex writes (4 in mcp_oauth,
2 in DocsGPTOAuth.redirect_handler / callback_handler)
- The update_status closure in mcp_oauth that wrote the polling
payload
Tests updated:
- get_oauth_status now takes (task_id, user_id); new coverage walks
a fake xrevrange response for the completed envelope, the no-match
case, and a Redis-down case
- Removed TestMCPOAuthStatus route tests and TestMcpOauthStatusTask
celery-wrapper test
- Removed the two oauth_status methods from the integration runner
mcp_oauth:auth_url/state/code/error Redis keys remain — they are
the OAuth flow's own state (not the dropped polling payload).
* chore(mcp-oauth): delete orphaned getMCPOAuthStatus client
The /api/mcp_server/oauth_status/<task_id> endpoint was removed in
the prior commit; the corresponding userService method and the
MCP_OAUTH_STATUS endpoint constant had no remaining callers in the
frontend, so they're deleted along with it.
* fix(events): drop live publish when journal write fails
application/events/publisher.py returned an envelope to live
pubsub subscribers even when the XADD to the durable journal
failed. The envelope had no ``id`` field, which bypassed the SSE
route's dedup floor and broke ``Last-Event-ID`` semantics for any
reconnecting client.
Best-effort delivery means dropping consistently, not delivering
inconsistent state. Now: if the journal write fails the publisher
returns None and skips the live publish entirely.
* fix(notifications): dedupe sseEventReceived against immediate dupes
Snapshot replay + live tail can both deliver the same id when the
live pubsub frame and the replay XRANGE overlap. The route's own
dedup floor catches the common case, but consumers walking
``recentEvents`` (FileTree, ConnectorTree, MCPServerModal,
ToolApprovalToast) would otherwise act on the same envelope
twice when a duplicate slipped through.
Belt-and-suspenders: short-circuit when the most recent id in
the ring matches the incoming one.
* fix(events): skip replay budget INCR when no snapshot work possible
_allow_replay incremented the per-user counter on every
/api/events GET, including no-op connects from a fresh client
with no cursor against an empty backlog. React StrictMode dev
double-mounts plus a few tabs trivially tripped the default
30-per-60s budget on idle reconnects.
XLEN pre-check: when last_event_id is None and the user stream
is empty, the connect can't do snapshot work — return True
without INCR. Cursor-bearing connects still INCR unconditionally
(probing the cursor's relationship to stream contents would
require a redundant XRANGE).
* fix(streaming): tighten journal contract + recover from seq collisions
Two related fixes to application/streaming/message_journal.py.
1. record_event now rejects non-dict payloads at the gate. The
live path (base.py::_emit) wrapped non-dicts as
{"value": payload}; the replay path in event_replay synthesized
{"type": event_type}. A reconnecting client would receive a
different envelope than the one originally streamed. Now both
paths see byte-identical envelopes because non-dicts can't be
journaled at all. The corresponding event_replay fallback is
replaced with a warn-and-skip for any legacy rows.
2. record_event handles IntegrityError on (message_id, sequence_no)
collisions by reading latest_sequence_no and retrying once with
latest+1. The most likely cause is a stale seq seed on a
continuation retry where the route read MAX(seq) from a
separate connection before another writer committed past it.
Previously the error was swallowed and the event silently
dropped from the journal; now it lands at the next available
seq. The live pubsub publish uses the materialised seq so the
journal row and the live frame agree.
* perf(streaming): batch message_events INSERTs per stream
complete_stream previously opened a fresh db_session() per yielded
event, doing one Postgres INSERT + commit per chunk on the WSGI
thread. Streaming answers emit ~100s of answer chunks per response,
so the route was paying ~100 PG roundtrips per stream serialized on
commit latency.
New BatchedJournalWriter in application/streaming/message_journal.py
accumulates rows per stream and flushes on three triggers:
- size: buffer reaches 16 entries
- time: 100ms elapsed since the last flush
- lifecycle: close() at end-of-stream
Live pubsub publishes still fire synchronously per record(), so
subscribers see events in real time — only the durable journal write
is amortized. On bulk INSERT IntegrityError the writer falls back to
per-row record() with the existing seq+1 retry so a single colliding
seq doesn't drop the rest of the batch.
complete_stream wires journal_writer.close() into every exit path
(happy end, tool-approval-paused end, GeneratorExit, error handler)
so the terminal event is committed before the generator returns —
otherwise a reconnecting client could snapshot up to the last flush
boundary and live-tail waiting for an end that's still in memory.
Repository gets bulk_record() — one SQLAlchemy executemany INSERT
for the bulk path. All-or-nothing on collision (Postgres aborts the
whole batch); the writer's per-row fallback handles recovery.
* chore(upload): drop dead UploadTask.lastEventAt field
The lastEventAt field on UploadTask had no remaining consumers — the
matching Attachment.lastEventAt was cleaned up earlier. Remove the
field declaration and the slice write site.
* chore(frontend): drop orphaned getTaskStatus client
After the polling-removal sweep no caller in frontend/src/ references
userService.getTaskStatus or endpoints.USER.TASK_STATUS. The backend
route /api/task_status itself stays — agents, webhooks, e2e specs,
and the public docs still depend on it.
* docs(repo): remove stale planning docs from repo root
notification-channel-design.md, plan.md, and reminder-tool-design.md
were leftover Claude planning artifacts from the SSE substrate work
that landed accidentally. CLAUDE.md prohibits creating planning docs
unless asked — delete them.
* docs(message-events): clarify repo vs wrapper payload contract
MessageEventsRepository.record accepts any JSONB-compatible value; the
streaming wrapper record_event tightens this to dicts only because the
live and replay paths reconstruct non-dict payloads differently. Spell
the split out so the next reader of the repo method doesn't assume the
wrapper's contract applies here.
* refactor(events): raise on malformed stream id instead of lex fallback
stream_id_compare's lex-fallback branch was a footgun: a malformed id
that sorts lex-greater than a real one would pin live-tail dedup
forever, dropping every subsequent legitimate event silently. Both
current callers in application/api/events/routes.py pre-validate
inputs against _STREAM_ID_RE before calling, so changing the function
to raise ValueError is a no-op on the happy path and turns the future-
caller footgun into a loud failure.
* test(tasks): cover cleanup_message_events task body
Adds skipped-when-no-POSTGRES_URI and happy-path coverage for the
Celery janitor. The skipped path returns the documented short-circuit
shape without touching the repo. The happy path seeds a backdated
row, runs the task against the pg_conn fixture, and asserts the
retention window's row is deleted while in-window rows survive.
Mirrors the TestCleanupPendingToolState pattern.
* fix(notifications): treat /c/new as no current conversation
useMatch('/c/:conversationId') treats the literal URL /c/new as a
real conversation id, so the toast suppression check confused
'user is on /c/new' with 'user is on the conversation needing
approval'. Explicit guard: when the matched id is 'new', fall
through to the no-match case so approval toasts still surface.
* docs(events): enumerate publish_user_event None-return paths
The function returns Optional[str] today, with None conflating five
distinct outcomes (missing args / push disabled / unserialisable /
Redis down / XADD failed). Every current call site is fire-and-
forget and ignores the return, so the right move is to document the
five cases rather than promote to an enum return — keeps the API
small while making the diagnostic surface (logs) obvious. If a
future caller needs to react differently per reason, promote then.
* refactor(sources): move source-id derivation out of worker module
application/api/user/sources/upload.py imported _derive_source_id
from application.worker — pulling the entire Celery worker module
into the API process at import time just for a two-line helper.
Move DOCSGPT_INGEST_NAMESPACE and the derivation function to a
new application/storage/db/source_ids.py module that both layers
can import without that dependency edge. worker.py re-exports the
old names (_derive_source_id, DOCSGPT_INGEST_NAMESPACE) for
backward-compatible imports from tests and any other in-tree
callers; new code should import from the new module directly.
* fix(cache): enable Redis health_check_interval to surface half-open TCP
Without health_check_interval, a half-open TCP socket (NAT silently
dropped state, ELB idle-close) can leave pubsub.get_message hanging
past the SSE generator's keepalive cadence — the kernel never
surfaces the dead socket because no payload is in flight. Setting
health_check_interval=10 makes redis-py ping every 10s when
otherwise idle, so the next get_message after the dead window
raises and the SSE loop falls into its reconnect path instead of
silently freezing on the user.
* chore(events): rename attachment.processing.progress to attachment.progress
The event-type taxonomy was inconsistent: source ingest emits
source.ingest.progress (three segments) while attachments emitted
attachment.processing.progress (four segments). Drops the
.processing. infix for parity. Worker publish sites, the slice
reducer's match, and the worker tests all flip together.
No external consumers — the event type is purely internal between
the publisher and the in-tab slice; safe to rename in one commit.
* feat: events cleanup
* fix: better docs
* fix: e2e tests
435 lines
18 KiB
Python
435 lines
18 KiB
Python
"""Snapshot+tail iterator for chat-stream reconnect-after-disconnect.
|
||
|
||
Subscribe to ``channel:{message_id}``, snapshot ``message_events``
|
||
rows past ``last_event_id`` inside the SUBSCRIBE-ack callback, flush
|
||
snapshot, then tail live pub/sub (dedup'd by ``sequence_no``). See
|
||
``docs/runbooks/sse-notifications.md``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
import time
|
||
from typing import Iterator, Optional
|
||
|
||
from sqlalchemy import text as sql_text
|
||
|
||
from application.storage.db.repositories.message_events import (
|
||
MessageEventsRepository,
|
||
)
|
||
from application.storage.db.session import db_readonly
|
||
from application.streaming.broadcast_channel import Topic
|
||
from application.streaming.keys import message_topic_name
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
DEFAULT_KEEPALIVE_SECONDS = 15.0
|
||
DEFAULT_POLL_TIMEOUT_SECONDS = 1.0
|
||
# When the live tail has no events and no terminal in snapshot, fall
|
||
# back to checking ``conversation_messages`` directly. If the row has
|
||
# already gone terminal (worker journaled ``end``/``error`` to the DB
|
||
# but the matching pub/sub publish was lost, or the row was finalized
|
||
# without a journal write at all) we surface a terminal event so the
|
||
# client doesn't hang on keepalives. If the row is still non-terminal
|
||
# but the producer heartbeat is older than ``PRODUCER_IDLE_SECONDS``
|
||
# the producer is presumed dead (worker crash / recycle between chunks
|
||
# and finalize) and we emit a terminal ``error`` so the UI can recover.
|
||
DEFAULT_WATCHDOG_INTERVAL_SECONDS = 5.0
|
||
# 1.5× the route's 60s heartbeat interval — long enough that a normal
|
||
# heartbeat skew doesn't false-positive, short enough that a stuck
|
||
# stream surfaces before the 5-minute reconciler sweep escalates.
|
||
DEFAULT_PRODUCER_IDLE_SECONDS = 90.0
|
||
|
||
# WHATWG SSE accepts CRLF, CR, LF — split on any of them so a stray CR
|
||
# can't smuggle a record boundary into the wire format.
|
||
_SSE_LINE_SPLIT_PATTERN = re.compile(r"\r\n|\r|\n")
|
||
|
||
# Event types that mark the end of a chat answer. After delivering one
|
||
# we close the reconnect stream — keeping the connection open past a
|
||
# terminal event would leak both the client's reconnect promise and
|
||
# the server's WSGI thread waiting on keepalives that the user no
|
||
# longer cares about. The agent loop emits ``end`` for normal /
|
||
# tool-paused completion and ``error`` for the catch-all failure path
|
||
# (which doesn't get a trailing ``end``).
|
||
_TERMINAL_EVENT_TYPES = frozenset({"end", "error"})
|
||
|
||
|
||
def _payload_is_terminal(
|
||
payload: object, event_type: Optional[str] = None
|
||
) -> bool:
|
||
"""True if ``payload['type']`` or ``event_type`` is a terminal sentinel."""
|
||
if isinstance(payload, dict) and payload.get("type") in _TERMINAL_EVENT_TYPES:
|
||
return True
|
||
return event_type in _TERMINAL_EVENT_TYPES
|
||
|
||
|
||
def format_sse_event(payload: dict, sequence_no: int) -> str:
|
||
"""Encode a journal event as one ``id:``/``data:`` SSE record.
|
||
|
||
The body is the payload's JSON serialisation. ``complete_stream``
|
||
payloads are flat JSON dicts with no embedded newlines, so a
|
||
single ``data:`` line is sufficient — but we still split on any
|
||
line terminator in case a future caller passes a multi-line string.
|
||
"""
|
||
body = json.dumps(payload)
|
||
lines = [f"id: {sequence_no}"]
|
||
for line in _SSE_LINE_SPLIT_PATTERN.split(body):
|
||
lines.append(f"data: {line}")
|
||
return "\n".join(lines) + "\n\n"
|
||
|
||
|
||
def _check_producer_liveness(
|
||
message_id: str, idle_seconds: float
|
||
) -> Optional[dict]:
|
||
"""Inspect ``conversation_messages`` and return a terminal SSE
|
||
payload when the producer is no longer alive, else ``None``.
|
||
|
||
Three terminal cases collapse into a single DB round-trip:
|
||
|
||
- ``status='complete'`` — the live finalize ran but its journal
|
||
terminal write didn't reach us (or never happened). Synthesise
|
||
``end`` so the client closes cleanly on the row's user-visible
|
||
state.
|
||
- ``status='failed'`` — same, but for the failure path. Carry the
|
||
stashed ``error`` from ``message_metadata`` so the UI shows the
|
||
real reason.
|
||
- non-terminal status and ``last_heartbeat_at`` (or ``timestamp``)
|
||
older than ``idle_seconds`` — the producing worker is gone.
|
||
Synthesise ``error`` so the client doesn't hang on keepalives
|
||
until the proxy idle-timeout kicks in.
|
||
"""
|
||
try:
|
||
with db_readonly() as conn:
|
||
row = conn.execute(
|
||
sql_text(
|
||
"""
|
||
SELECT
|
||
status,
|
||
message_metadata->>'error' AS err,
|
||
GREATEST(
|
||
timestamp,
|
||
COALESCE(
|
||
(message_metadata->>'last_heartbeat_at')
|
||
::timestamptz,
|
||
timestamp
|
||
)
|
||
) < now() - make_interval(secs => :idle_secs)
|
||
AS is_stale
|
||
FROM conversation_messages
|
||
WHERE id = CAST(:id AS uuid)
|
||
"""
|
||
),
|
||
{"id": message_id, "idle_secs": float(idle_seconds)},
|
||
).first()
|
||
except Exception:
|
||
logger.exception(
|
||
"Watchdog liveness check failed for message_id=%s", message_id
|
||
)
|
||
return None
|
||
|
||
if row is None:
|
||
# Row deleted out from under us — treat as terminal so the
|
||
# client doesn't keep tailing a message that no longer exists.
|
||
return {
|
||
"type": "error",
|
||
"error": "Message no longer exists; please refresh.",
|
||
"code": "message_missing",
|
||
"message_id": message_id,
|
||
}
|
||
|
||
status, err, is_stale = row[0], row[1], bool(row[2])
|
||
if status == "complete":
|
||
return {"type": "end"}
|
||
if status == "failed":
|
||
return {
|
||
"type": "error",
|
||
"error": err or "Stream failed; please try again.",
|
||
"code": "producer_failed",
|
||
"message_id": message_id,
|
||
}
|
||
if is_stale:
|
||
return {
|
||
"type": "error",
|
||
"error": (
|
||
"Stream producer is no longer responding; please try again."
|
||
),
|
||
"code": "producer_stale",
|
||
"message_id": message_id,
|
||
}
|
||
return None
|
||
|
||
|
||
def build_message_event_stream(
|
||
message_id: str,
|
||
last_event_id: Optional[int] = None,
|
||
*,
|
||
keepalive_seconds: float = DEFAULT_KEEPALIVE_SECONDS,
|
||
poll_timeout_seconds: float = DEFAULT_POLL_TIMEOUT_SECONDS,
|
||
watchdog_interval_seconds: float = DEFAULT_WATCHDOG_INTERVAL_SECONDS,
|
||
producer_idle_seconds: float = DEFAULT_PRODUCER_IDLE_SECONDS,
|
||
) -> Iterator[str]:
|
||
"""Yield SSE-formatted lines for one ``message_id`` reconnect stream.
|
||
|
||
First frame is ``: connected``; subsequent frames are snapshot rows,
|
||
live-tail events, or ``: keepalive`` comments. Runs until the client
|
||
disconnects.
|
||
"""
|
||
yield ": connected\n\n"
|
||
|
||
# Replay buffer — populated inside ``_on_subscribe`` (or the
|
||
# Redis-unavailable fallback below), drained on the first iteration
|
||
# of the subscribe loop after the callback runs.
|
||
replay_buffer: list[str] = []
|
||
# Dedup floor: seeded with the client's cursor so an empty snapshot
|
||
# still rejects re-published live events with seq <= last_event_id.
|
||
# Advanced by snapshot rows AND by yielded live events, so any
|
||
# republish past the snapshot ceiling is also dropped.
|
||
max_replayed_seq: Optional[int] = last_event_id
|
||
replay_done = False
|
||
replay_failed = False
|
||
# Set when a snapshot row carries a terminal ``end`` / ``error``
|
||
# event. After flushing the buffer the generator returns; if we
|
||
# kept tailing we'd loop on keepalives forever for a stream that
|
||
# already finished.
|
||
terminal_in_snapshot = False
|
||
|
||
def _read_snapshot_into_buffer() -> None:
|
||
nonlocal max_replayed_seq, replay_failed, terminal_in_snapshot
|
||
try:
|
||
with db_readonly() as conn:
|
||
rows = MessageEventsRepository(conn).read_after(
|
||
message_id, last_sequence_no=last_event_id
|
||
)
|
||
for row in rows:
|
||
seq = int(row["sequence_no"])
|
||
payload = row.get("payload")
|
||
if not isinstance(payload, dict):
|
||
# ``record_event`` rejects non-dict payloads at the
|
||
# write gate, so this can only be a legacy row from
|
||
# before that contract or a direct SQL insert. The
|
||
# original synthetic fallback (``{"type": event_type}``)
|
||
# used to ship a malformed envelope here — drop the
|
||
# row instead so a corrupt journal entry doesn't
|
||
# poison a reconnect.
|
||
logger.warning(
|
||
"Skipping non-dict payload from message_events: "
|
||
"message_id=%s seq=%s type=%s",
|
||
message_id,
|
||
seq,
|
||
row.get("event_type"),
|
||
)
|
||
continue
|
||
replay_buffer.append(format_sse_event(payload, seq))
|
||
if max_replayed_seq is None or seq > max_replayed_seq:
|
||
max_replayed_seq = seq
|
||
if _payload_is_terminal(payload, row.get("event_type")):
|
||
terminal_in_snapshot = True
|
||
except Exception:
|
||
logger.exception(
|
||
"Snapshot read failed for message_id=%s last_event_id=%s",
|
||
message_id,
|
||
last_event_id,
|
||
)
|
||
replay_failed = True
|
||
|
||
def _on_subscribe() -> None:
|
||
# SUBSCRIBE has been acked — Postgres reads from this point
|
||
# capture every row that's been committed. Pub/sub messages
|
||
# published after this point are queued at the connection level
|
||
# until the outer loop calls ``get_message`` again.
|
||
nonlocal replay_done
|
||
try:
|
||
_read_snapshot_into_buffer()
|
||
finally:
|
||
# Flip even on failure so the outer loop continues to live
|
||
# tail and the client doesn't hang waiting for a snapshot
|
||
# flush that will never come.
|
||
replay_done = True
|
||
|
||
topic = Topic(message_topic_name(message_id))
|
||
last_keepalive = time.monotonic()
|
||
# Rate-limit the watchdog's DB hit. ``-inf`` makes the first idle
|
||
# tick after replay_done fire immediately so a snapshot-already-
|
||
# terminal-in-DB case is surfaced before any keepalive cadence.
|
||
# Subsequent checks are gated by ``watchdog_interval_seconds``.
|
||
last_watchdog_check = float("-inf")
|
||
# Synthetic terminal events emitted by the watchdog use the same
|
||
# ``sequence_no=-1`` convention as the snapshot-failure path so the
|
||
# frontend's strict ``\d+`` cursor regex rejects them as a
|
||
# ``Last-Event-ID`` for any future reconnect. The chosen
|
||
# discriminator ensures a manual page refresh after a watchdog-fired
|
||
# error doesn't loop on the same synthetic id.
|
||
watchdog_synthetic_seq = -1
|
||
|
||
try:
|
||
for payload in topic.subscribe(
|
||
on_subscribe=_on_subscribe,
|
||
poll_timeout=poll_timeout_seconds,
|
||
):
|
||
# Flush snapshot exactly once after the SUBSCRIBE callback
|
||
# has run and produced a buffer.
|
||
if replay_done and replay_buffer:
|
||
for line in replay_buffer:
|
||
yield line
|
||
replay_buffer.clear()
|
||
if terminal_in_snapshot:
|
||
# The original stream already finished; tailing
|
||
# would just emit keepalives forever and pin both a
|
||
# client reconnect promise and a server WSGI thread.
|
||
return
|
||
|
||
if replay_failed:
|
||
# Snapshot read failed (DB blip / transient timeout). Emit a
|
||
# terminal ``error`` event and return — the client only
|
||
# reconnects after the original stream has already moved on,
|
||
# so without a snapshot there's nothing live left to tail and
|
||
# holding the connection open would just emit keepalives
|
||
# until the proxy idle-timeout fires. ``code`` preserves the
|
||
# snapshot-vs-agent-loop distinction so a future client can
|
||
# opt into a refetch instead of a hard failure.
|
||
yield format_sse_event(
|
||
{
|
||
"type": "error",
|
||
"error": "Stream replay failed; please refresh to load the latest state.",
|
||
"code": "snapshot_failed",
|
||
"message_id": message_id,
|
||
},
|
||
sequence_no=-1,
|
||
)
|
||
return
|
||
|
||
now = time.monotonic()
|
||
if payload is None:
|
||
# Idle tick — check both keepalive and watchdog. The
|
||
# watchdog only kicks in once the snapshot half has been
|
||
# flushed (``replay_done``) so we don't race the
|
||
# snapshot read on the first iteration.
|
||
if (
|
||
replay_done
|
||
and watchdog_interval_seconds >= 0
|
||
and now - last_watchdog_check >= watchdog_interval_seconds
|
||
):
|
||
last_watchdog_check = now
|
||
terminal_payload = _check_producer_liveness(
|
||
message_id, producer_idle_seconds
|
||
)
|
||
if terminal_payload is not None:
|
||
yield format_sse_event(
|
||
terminal_payload,
|
||
sequence_no=watchdog_synthetic_seq,
|
||
)
|
||
return
|
||
if now - last_keepalive >= keepalive_seconds:
|
||
yield ": keepalive\n\n"
|
||
last_keepalive = now
|
||
continue
|
||
|
||
envelope = _decode_pubsub_message(payload)
|
||
if envelope is None:
|
||
continue
|
||
seq = envelope.get("sequence_no")
|
||
inner = envelope.get("payload")
|
||
if (
|
||
not isinstance(seq, int)
|
||
or isinstance(seq, bool)
|
||
or not isinstance(inner, dict)
|
||
):
|
||
continue
|
||
if max_replayed_seq is not None and seq <= max_replayed_seq:
|
||
# Snapshot already covered this id — drop the duplicate.
|
||
continue
|
||
yield format_sse_event(inner, seq)
|
||
# Advance the dedup floor on the live path too, so a stale
|
||
# republish of an already-yielded seq (process restart, retry
|
||
# tool, etc.) is dropped on a later iteration.
|
||
max_replayed_seq = seq
|
||
last_keepalive = now
|
||
if _payload_is_terminal(inner, envelope.get("event_type")):
|
||
# Live tail just delivered the terminal event — close
|
||
# out the reconnect stream so the client's drain
|
||
# promise resolves and the WSGI thread is freed.
|
||
return
|
||
|
||
# Subscribe exited without ever yielding (Redis unavailable,
|
||
# ``pubsub.subscribe`` raised, or the inner loop died between
|
||
# SUBSCRIBE-ack and the first poll). The snapshot half is in
|
||
# Postgres and is still serviceable — read it directly so a
|
||
# Redis-only outage doesn't cost the client their reconnect
|
||
# backlog. Gate the read on ``replay_done`` rather than
|
||
# ``subscribe_started``: if ``_on_subscribe`` already populated
|
||
# the buffer, re-reading would append the same rows twice and
|
||
# double the answer chunks on the client (the per-message
|
||
# reconnect dispatcher does not dedup by ``id``).
|
||
if not replay_done:
|
||
_read_snapshot_into_buffer()
|
||
replay_done = True
|
||
for line in replay_buffer:
|
||
yield line
|
||
replay_buffer.clear()
|
||
if replay_failed:
|
||
# Mirror the live-tail branch: emit a terminal ``error`` so
|
||
# the frontend's existing end/error handling drives the UI
|
||
# to a failed state instead of relying on the proxy timeout.
|
||
yield format_sse_event(
|
||
{
|
||
"type": "error",
|
||
"error": "Stream replay failed; please refresh to load the latest state.",
|
||
"code": "snapshot_failed",
|
||
"message_id": message_id,
|
||
},
|
||
sequence_no=-1,
|
||
)
|
||
return
|
||
# Same close-on-terminal contract as the live-tail branch.
|
||
# Without it a Redis-down + already-completed-stream client
|
||
# would also hang on a never-ending generator.
|
||
if terminal_in_snapshot:
|
||
return
|
||
except GeneratorExit:
|
||
# Client disconnect — let the underlying ``Topic.subscribe``
|
||
# ``finally`` block tear down its pubsub cleanly.
|
||
return
|
||
|
||
|
||
def _decode_pubsub_message(raw) -> Optional[dict]:
|
||
"""Parse a ``Topic.publish`` payload to ``{sequence_no, payload, ...}``.
|
||
|
||
Returns ``None`` for malformed messages (drop silently — the
|
||
journal is still authoritative on reconnect).
|
||
"""
|
||
try:
|
||
if isinstance(raw, (bytes, bytearray)):
|
||
text_value = raw.decode("utf-8")
|
||
else:
|
||
text_value = str(raw)
|
||
envelope = json.loads(text_value)
|
||
except Exception:
|
||
return None
|
||
if not isinstance(envelope, dict):
|
||
return None
|
||
return envelope
|
||
|
||
|
||
def encode_pubsub_message(
|
||
message_id: str,
|
||
sequence_no: int,
|
||
event_type: str,
|
||
payload: dict,
|
||
) -> str:
|
||
"""Build the JSON envelope used for ``channel:{message_id}`` publishes.
|
||
|
||
Kept here (not in ``message_journal.py``) so the encode/decode pair
|
||
stays in one file — replay's ``_decode_pubsub_message`` and the
|
||
journal's publish must agree on the shape exactly.
|
||
"""
|
||
return json.dumps(
|
||
{
|
||
"message_id": str(message_id),
|
||
"sequence_no": int(sequence_no),
|
||
"event_type": event_type,
|
||
"payload": payload,
|
||
}
|
||
)
|