chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
View File
@@ -0,0 +1,119 @@
"""Async Redis pub/sub Topic for the native-async SSE reader.
Event-loop twin of :class:`application.streaming.broadcast_channel.Topic`.
Same contract — ``subscribe`` yields ``None`` on poll timeout (so the
caller can emit keepalives / run the watchdog) and ``bytes`` per delivered
message, fires ``on_subscribe`` once after Redis acks SUBSCRIBE, and tears
the pubsub down cleanly on client disconnect — but awaitable so an idle
stream costs a coroutine instead of a WSGI thread.
Publishing stays on the sync side (the producer writes via
``broadcast_channel.Topic.publish``); this is read-only fan-out.
"""
from __future__ import annotations
import inspect
import logging
from typing import AsyncIterator, Awaitable, Callable, Optional, Union
import anyio
from application.streaming.async_redis import get_async_redis_instance
logger = logging.getLogger(__name__)
OnSubscribe = Callable[[], Union[None, Awaitable[None]]]
class AsyncTopic:
"""An async pub/sub channel identified by a string name."""
def __init__(self, name: str) -> None:
self.name = name
async def subscribe(
self,
on_subscribe: Optional[OnSubscribe] = None,
poll_timeout: float = 1.0,
) -> AsyncIterator[Optional[bytes]]:
"""Subscribe to the topic; yield raw payloads or ``None`` on tick.
``on_subscribe`` runs (and is awaited if it returns a coroutine)
after Redis acks SUBSCRIBE — use it to seed snapshot state that
must be ordered after the subscriber is live but before the first
live message is processed. If Redis is unavailable, returns
immediately without yielding so the caller can fall back to a
direct snapshot read. Cleanly unsubscribes on close / disconnect.
"""
redis = await get_async_redis_instance()
if redis is None:
logger.debug(
"Async Redis unavailable; subscribe to %s yielded nothing",
self.name,
)
return
pubsub = redis.pubsub()
on_subscribe_fired = False
try:
try:
await pubsub.subscribe(self.name)
except Exception:
# Transient subscribe failure is treated like "Redis
# unavailable": yield nothing, let the caller fall back to
# its own snapshot read. The finally block still tears the
# pubsub down cleanly.
logger.exception("async pubsub.subscribe failed for %s", self.name)
return
while True:
try:
msg = await pubsub.get_message(timeout=poll_timeout)
except Exception:
logger.exception(
"async pubsub.get_message failed for %s", self.name
)
return
if msg is None:
yield None
continue
msg_type = msg.get("type")
if msg_type == "subscribe":
if not on_subscribe_fired and on_subscribe is not None:
try:
result = on_subscribe()
if inspect.isawaitable(result):
await result
except Exception:
logger.exception(
"on_subscribe callback failed for %s", self.name
)
on_subscribe_fired = True
continue
if msg_type != "message":
continue
data = msg.get("data")
if data is None:
continue
yield data if isinstance(data, bytes) else str(data).encode("utf-8")
finally:
# Client disconnect cancels this generator at the ``await
# get_message`` above; without shielding, the cancellation could
# re-fire mid-teardown and skip ``aclose()``, leaking the pooled
# connection back to nothing. Shield so unsubscribe + aclose
# always complete and the connection returns to the pool.
with anyio.CancelScope(shield=True):
if on_subscribe_fired:
try:
await pubsub.unsubscribe(self.name)
except Exception:
logger.debug(
"async pubsub unsubscribe error for %s",
self.name,
exc_info=True,
)
try:
await pubsub.aclose()
except Exception:
logger.debug(
"async pubsub close error for %s", self.name, exc_info=True
)
+232
View File
@@ -0,0 +1,232 @@
"""Native-async snapshot+tail iterator for chat-stream reconnect.
The sole reconnect reader: a ``: connected`` prelude, a snapshot flush
inside the SUBSCRIBE-ack callback, a dedup'd live tail, keepalive +
producer-liveness watchdog, and close-on-terminal — all as an async
generator driven off the event loop instead of a WSGI thread.
Wire format, dedup floor, and terminal detection come from
``event_replay`` (``read_snapshot_lines``, ``format_sse_event``,
``_decode_pubsub_message``, ``_payload_is_terminal``,
``_check_producer_liveness``), the same primitives the producer's journal
writes through — so the reader and writer cannot drift on wire shape. The
only sync I/O (snapshot read, watchdog DB probe) is pushed to a worker
thread via ``anyio.to_thread`` so it never blocks the loop.
"""
from __future__ import annotations
import logging
import time
from typing import AsyncIterator, Optional
import anyio
from application.streaming.async_broadcast_channel import AsyncTopic
from application.streaming.event_replay import (
DEFAULT_KEEPALIVE_SECONDS,
DEFAULT_POLL_TIMEOUT_SECONDS,
DEFAULT_PRODUCER_IDLE_SECONDS,
DEFAULT_WATCHDOG_INTERVAL_SECONDS,
_check_producer_liveness,
_decode_pubsub_message,
_payload_is_terminal,
format_sse_event,
read_snapshot_lines,
)
from application.streaming.keys import message_topic_name
logger = logging.getLogger(__name__)
# The snapshot read and watchdog probe are the reader's only DB I/O; they run
# in worker threads and each borrows a connection from the app-wide SQLAlchemy
# pool (pool_size=10 + max_overflow=20 = 30). The reader scales to many
# concurrent event-loop streams, so without a bound a burst of aligned
# watchdog/snapshot ticks could exhaust the pool and starve every other route.
# Cap the reader's concurrent DB-thread usage well below the pool so it can
# never monopolise it; excess ticks queue briefly (watchdog cadence is 5s, so
# a short queue delay is harmless).
_MAX_CONCURRENT_DB_READS = 8
_db_read_limiter: Optional[anyio.CapacityLimiter] = None
def _get_db_read_limiter() -> anyio.CapacityLimiter:
"""Lazily build the shared limiter on the (single) event loop.
Created on first use rather than at import so it binds to the running
loop; creation is synchronous, so the single-worker loop has no race.
"""
global _db_read_limiter
if _db_read_limiter is None:
_db_read_limiter = anyio.CapacityLimiter(_MAX_CONCURRENT_DB_READS)
return _db_read_limiter
async def build_message_event_stream_async(
message_id: str,
last_event_id: Optional[int] = None,
*,
user_id: Optional[str] = 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,
) -> AsyncIterator[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 or a terminal event is delivered.
"""
yield ": connected\n\n"
replay_buffer: list[str] = []
max_replayed_seq: Optional[int] = last_event_id
replay_done = False
replay_failed = False
terminal_in_snapshot = False
async def _load_snapshot() -> None:
nonlocal max_replayed_seq, replay_failed, terminal_in_snapshot
try:
lines, max_seq, terminal = await anyio.to_thread.run_sync(
read_snapshot_lines,
message_id,
last_event_id,
user_id,
limiter=_get_db_read_limiter(),
)
except Exception:
logger.exception(
"Snapshot read failed for message_id=%s last_event_id=%s",
message_id,
last_event_id,
)
replay_failed = True
return
replay_buffer.extend(lines)
max_replayed_seq = max_seq
terminal_in_snapshot = terminal
async def _on_subscribe() -> None:
# SUBSCRIBE acked — Postgres reads from this point capture every
# committed row; pub/sub messages published after this are queued
# at the connection level until the loop polls again.
nonlocal replay_done
try:
await _load_snapshot()
finally:
replay_done = True
topic = AsyncTopic(message_topic_name(message_id))
last_keepalive = time.monotonic()
last_watchdog_check = float("-inf")
watchdog_synthetic_seq = -1
try:
async for payload in topic.subscribe(
on_subscribe=_on_subscribe,
poll_timeout=poll_timeout_seconds,
):
# Flush snapshot exactly once after the SUBSCRIBE callback ran.
if replay_done and replay_buffer:
for line in replay_buffer:
yield line
replay_buffer.clear()
if terminal_in_snapshot:
# Original stream already finished; tailing would just
# emit keepalives forever.
return
if replay_failed:
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 — gate the watchdog on ``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 = await anyio.to_thread.run_sync(
_check_producer_liveness,
message_id,
user_id,
producer_idle_seconds,
limiter=_get_db_read_limiter(),
)
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)
max_replayed_seq = seq
last_keepalive = now
if _payload_is_terminal(inner, envelope.get("event_type")):
return
# Subscribe exited without yielding (Redis unavailable / subscribe
# raised). The snapshot half is still in Postgres — read it
# directly so a Redis-only outage doesn't cost the client their
# backlog. Gate on ``replay_done`` so we don't double-read.
if not replay_done:
await _load_snapshot()
replay_done = True
for line in replay_buffer:
yield line
replay_buffer.clear()
if replay_failed:
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
if terminal_in_snapshot:
return
except Exception:
# GeneratorExit / CancelledError are BaseException subclasses, so a
# client disconnect bypasses this handler and propagates to close
# the inner AsyncTopic generator (tearing its pubsub down in that
# generator's finally). Only genuine bugs land here.
logger.exception(
"Async reconnect stream crashed for message_id=%s", message_id
)
return
+47
View File
@@ -0,0 +1,47 @@
"""Lazy async Redis client for the native-async SSE reader.
Async twin of :func:`application.cache.get_redis_instance`. The
Starlette-mounted reader (``application.api.async_sse``) tails pub/sub on
the event loop, so it needs a ``redis.asyncio`` client rather than the
sync one used by the producer side. The app runs a single ASGI worker /
event loop, so a module-level singleton is sufficient and avoids
reconnecting per request.
"""
from __future__ import annotations
import logging
from typing import Optional
import redis.asyncio as aioredis
from application.core.settings import settings
logger = logging.getLogger(__name__)
_async_redis: Optional[aioredis.Redis] = None
_creation_failed = False
async def get_async_redis_instance() -> Optional[aioredis.Redis]:
"""Return a process-wide async Redis client, or ``None`` if unavailable.
``from_url`` builds the client without opening a socket (connection is
lazy), so a transient broker outage surfaces later on the first command
rather than here. Mirrors the sync client's ``socket_connect_timeout``
and ``health_check_interval`` so a half-open TCP can't wedge the tail
loop past its keepalive cadence.
"""
global _async_redis, _creation_failed
if _async_redis is None and not _creation_failed:
try:
_async_redis = aioredis.Redis.from_url(
settings.CACHE_REDIS_URL,
socket_connect_timeout=2,
health_check_interval=10,
)
except ValueError as e:
logger.error("Invalid Redis URL for async client: %s", e)
_creation_failed = True
_async_redis = None
return _async_redis
+144
View File
@@ -0,0 +1,144 @@
"""Redis pub/sub Topic abstraction for SSE fan-out.
A Topic is a named channel for one-shot live event delivery. Canonical uses:
- ``user:{user_id}`` for per-user notifications
- ``channel:{message_id}`` for per-chat-message streams
Subscription is race-free via ``on_subscribe``: the callback fires only
after Redis acknowledges ``SUBSCRIBE``, so a publisher dispatched inside
the callback cannot lose its first event to a not-yet-registered
subscriber.
The subscribe iterator yields ``None`` on poll timeout so the caller can
emit SSE keepalive comments without spawning a separate timer thread.
"""
from __future__ import annotations
import logging
from typing import Callable, Iterator, Optional
import redis as redis_lib
from application.cache import get_pubsub_redis_instance, get_redis_instance
logger = logging.getLogger(__name__)
class Topic:
"""A pub/sub channel identified by a string name."""
def __init__(self, name: str) -> None:
self.name = name
def publish(self, payload: str | bytes) -> int:
"""Fan out a payload to currently subscribed clients.
Returns the number Redis reports as receiving the message (limited
to subscribers connected to *this* Redis instance), or 0 if Redis
is unavailable. Never raises.
"""
redis = get_redis_instance()
if redis is None:
logger.debug("Redis unavailable; dropping publish to %s", self.name)
return 0
try:
return int(redis.publish(self.name, payload))
except Exception:
logger.exception("Topic.publish failed for %s", self.name)
return 0
def subscribe(
self,
on_subscribe: Optional[Callable[[], None]] = None,
poll_timeout: float = 1.0,
) -> Iterator[Optional[bytes]]:
"""Subscribe to the topic; yield raw payloads or ``None`` on tick.
Yields ``None`` every ``poll_timeout`` seconds while idle so the
caller can emit keepalive frames or check cancellation. Yields
``bytes`` for each delivered message.
``on_subscribe`` runs synchronously after Redis acknowledges the
SUBSCRIBE — use it to seed any state (e.g. read backlog) that
must be ordered after the subscriber is live but before the
first pub/sub message is processed.
If Redis is unavailable, returns immediately without yielding.
Cleanly unsubscribes on ``GeneratorExit`` (client disconnect).
Uses the pub/sub-dedicated client (bounded ``socket_timeout``): a
subscriber whose connection went half-open must fail within seconds
and release its WSGI thread, not block in ``get_message`` until the
worker restarts.
"""
redis = get_pubsub_redis_instance()
if redis is None:
logger.debug("Redis unavailable; subscribe to %s yielded nothing", self.name)
return
pubsub = None
on_subscribe_fired = False
try:
pubsub = redis.pubsub()
try:
pubsub.subscribe(self.name)
except Exception:
# Subscribe failure (transient Redis hiccup, conn reset, etc.)
# is treated like "Redis unavailable": yield nothing, let the
# caller fall back to its own resilience strategy. The finally
# block will still tear down the pubsub object cleanly.
logger.exception("pubsub.subscribe failed for %s", self.name)
return
while True:
try:
msg = pubsub.get_message(timeout=poll_timeout)
except redis_lib.exceptions.TimeoutError:
# A bounded read timed out mid-message/health-check: the
# connection is half-open (NAT/IPVS dropped the flow).
# End the subscription so the SSE client reconnects on a
# fresh connection; expected during network churn, so no
# stack trace.
logger.info(
"pubsub read timed out for %s; closing subscriber",
self.name,
)
return
except Exception:
logger.exception("pubsub.get_message failed for %s", self.name)
return
if msg is None:
yield None
continue
msg_type = msg.get("type")
if msg_type == "subscribe":
if not on_subscribe_fired and on_subscribe is not None:
try:
on_subscribe()
except Exception:
logger.exception(
"on_subscribe callback failed for %s", self.name
)
on_subscribe_fired = True
continue
if msg_type != "message":
continue
data = msg.get("data")
if data is None:
continue
yield data if isinstance(data, bytes) else str(data).encode("utf-8")
finally:
if pubsub is not None:
if on_subscribe_fired:
try:
pubsub.unsubscribe(self.name)
except Exception:
logger.debug(
"pubsub unsubscribe error for %s",
self.name,
exc_info=True,
)
try:
pubsub.close()
except Exception:
logger.debug("pubsub close error for %s", self.name, exc_info=True)
+260
View File
@@ -0,0 +1,260 @@
"""Shared snapshot/replay primitives for chat-stream reconnect.
The reconnect reader itself is the native-async generator in
``async_event_replay.build_message_event_stream_async``; this module holds
the pieces both it and the producer's journal depend on: the SSE wire
format (``format_sse_event``), the ``message_events`` snapshot read
(``read_snapshot_lines``), the producer-liveness watchdog probe
(``_check_producer_liveness``), and the pub/sub envelope encode/decode.
Keeping them here lets the async reader and the sync journal agree on the
exact wire shape and dedup/terminal rules. See
``docs/runbooks/sse-notifications.md``.
"""
from __future__ import annotations
import json
import logging
import re
from typing import 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
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, user_id: Optional[str], idle_seconds: float
) -> Optional[dict]:
"""Inspect ``conversation_messages`` and return a terminal SSE
payload when the producer is no longer alive, else ``None``.
When ``user_id`` is given the lookup is scoped to ``AND user_id = :u``
(defence in depth: this long-lived re-read re-asserts the ownership the
route gated on, so a stream cannot keep tailing a row it no longer
owns). A non-matching row reads as missing → a terminal ``error``.
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.
"""
owner_clause = " AND user_id = :u" if user_id is not None else ""
params = {"id": message_id, "idle_secs": float(idle_seconds)}
if user_id is not None:
params["u"] = user_id
try:
with db_readonly() as conn:
row = conn.execute(
sql_text(
# ``owner_clause`` is a fixed literal (no user input in the
# SQL string); ``user_id`` is bound via ``:u``.
f"""
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){owner_clause}
"""
),
params,
).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 read_snapshot_lines(
message_id: str, last_event_id: Optional[int], user_id: Optional[str] = None
) -> tuple[list[str], Optional[int], bool]:
"""Read journal rows after ``last_event_id`` as SSE-formatted lines.
Returns ``(lines, max_sequence_no, terminal)``: ``max_sequence_no`` is
seeded with ``last_event_id`` and advanced past every row read,
``terminal`` is True if any row carried a terminal ``end``/``error``.
Raises on DB error so the caller can drive its replay-failed path.
Used by ``async_event_replay.build_message_event_stream_async`` (the
reconnect reader); it shares ``format_sse_event`` / ``_payload_is_terminal``
with the producer's journal writer so reader and writer never drift on
wire shape or terminal semantics.
"""
lines: list[str] = []
max_seq = last_event_id
terminal = False
with db_readonly() as conn:
rows = MessageEventsRepository(conn).read_after(
message_id, last_sequence_no=last_event_id, user_id=user_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 is a legacy/direct-SQL row — drop it rather than ship
# a malformed envelope that would 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
lines.append(format_sse_event(payload, seq))
if max_seq is None or seq > max_seq:
max_seq = seq
if _payload_is_terminal(payload, row.get("event_type")):
terminal = True
return lines, max_seq, terminal
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,
}
)
+19
View File
@@ -0,0 +1,19 @@
"""Per-chat-message stream key derivations.
Single source of truth for the Redis pub/sub topic name and any
auxiliary keys that the chat-stream snapshot+tail reconnect path
shares between the writer (``complete_stream`` + journal) and the
reader (``/api/messages/<id>/events`` reconnect endpoint).
"""
from __future__ import annotations
def message_topic_name(message_id: str) -> str:
"""Redis pub/sub channel for live fan-out of one chat message.
Subscribers tail this topic for every event that ``complete_stream``
yielded after the SUBSCRIBE-ack arrived; older events are recovered
from the ``message_events`` snapshot half of the pattern.
"""
return f"channel:{message_id}"
+451
View File
@@ -0,0 +1,451 @@
"""Per-yield journal write for the chat-stream snapshot+tail pattern.
``record_event`` inserts into ``message_events`` and publishes to
``channel:{message_id}``. Both are best-effort; the INSERT commits
before the publish so a fast reconnect sees the row. See
``docs/runbooks/sse-notifications.md``.
"""
from __future__ import annotations
import logging
import time
from typing import Any, Optional
from sqlalchemy.exc import IntegrityError
from application.storage.db.repositories.message_events import (
MessageEventsRepository,
)
from application.storage.db.session import db_readonly, db_session
from application.streaming.broadcast_channel import Topic
from application.streaming.event_replay import encode_pubsub_message
from application.streaming.keys import message_topic_name
logger = logging.getLogger(__name__)
# Tunables for ``BatchedJournalWriter``. A streaming answer emits ~100s
# of ``answer`` chunks per response; without batching, that's one PG
# transaction per yield in the WSGI thread. With these defaults, ~10x
# fewer commits at the cost of a ≤100ms reconnect-visibility lag for
# any event still sitting in the buffer.
DEFAULT_BATCH_SIZE = 16
DEFAULT_BATCH_INTERVAL_MS = 100
def _strip_null_bytes(value: Any) -> Any:
"""Recursively strip ``\\x00`` from string keys/values in ``value``.
Postgres JSONB rejects the NUL escape; an LLM emitting a stray NUL
in a chunk would otherwise raise ``DataError`` at INSERT and the row
would be lost from the journal (live stream proceeds, reconnect
snapshot misses the chunk). Mirrors the strip already done in
``parser/embedding_pipeline.py`` and
``api/user/attachments/routes.py``.
"""
if isinstance(value, str):
return value.replace("\x00", "") if "\x00" in value else value
if isinstance(value, dict):
return {
(k.replace("\x00", "") if isinstance(k, str) and "\x00" in k else k):
_strip_null_bytes(v)
for k, v in value.items()
}
if isinstance(value, list):
return [_strip_null_bytes(item) for item in value]
if isinstance(value, tuple):
return tuple(_strip_null_bytes(item) for item in value)
return value
# Postgres SQLSTATE for a foreign-key violation. ``message_events`` has
# an FK to ``conversation_messages(id)``, so a 23503 means that parent
# row was never committed — the event can't be journaled and a
# ``sequence_no`` retry is pointless churn. A 23505 (PK collision), by
# contrast, is recoverable by rewriting at a fresh seq.
_FK_VIOLATION_SQLSTATE = "23503"
def _is_foreign_key_violation(exc: IntegrityError) -> bool:
"""Return ``True`` when ``exc`` wraps a Postgres FK violation (23503).
``IntegrityError.orig`` is the underlying driver error, which carries
the SQLSTATE on ``.sqlstate`` (psycopg3) or ``.pgcode`` (psycopg2).
When neither is present (an error we can't classify) this returns
``False`` so the caller keeps its existing seq-retry behavior.
"""
orig = getattr(exc, "orig", None)
sqlstate = getattr(orig, "sqlstate", None) or getattr(orig, "pgcode", None)
return sqlstate == _FK_VIOLATION_SQLSTATE
def record_event(
message_id: str,
sequence_no: int,
event_type: str,
payload: Optional[dict[str, Any]] = None,
) -> bool:
"""Journal one SSE event and publish it live. Best-effort.
``payload`` must be a ``dict`` or ``None`` (non-dicts are dropped so
live and replay envelopes stay byte-identical). Returns ``True`` when
the journal INSERT committed. Never raises.
"""
if not message_id or not event_type:
logger.warning(
"record_event called without message_id/event_type "
"(message_id=%r, event_type=%r)",
message_id,
event_type,
)
return False
if payload is None:
materialised_payload: dict[str, Any] = {}
elif isinstance(payload, dict):
materialised_payload = _strip_null_bytes(payload)
else:
logger.warning(
"record_event called with non-dict payload "
"(message_id=%s seq=%s type=%s payload_type=%s) — dropping",
message_id,
sequence_no,
event_type,
type(payload).__name__,
)
return False
journal_committed = False
# The seq we actually managed to write. Diverges from
# ``sequence_no`` only on the IntegrityError-retry path below.
materialised_seq = sequence_no
try:
# Short-lived per-event transaction. Critical for visibility:
# the reconnect endpoint reads the journal from a separate
# connection and only sees committed rows.
with db_session() as conn:
MessageEventsRepository(conn).record(
message_id, sequence_no, event_type, materialised_payload
)
journal_committed = True
except IntegrityError as exc:
if _is_foreign_key_violation(exc):
# No ``conversation_messages`` parent row — the event
# references a message that was never committed. A
# ``sequence_no`` retry can't fix that, so log the real
# cause and drop instead of churning the readonly probe +
# retry (which would just FK-fail again).
logger.warning(
"record_event: no conversation_messages row for "
"message_id=%s (foreign-key violation); dropping "
"(seq=%s type=%s). The parent message row must be "
"committed before its events are journaled.",
message_id,
sequence_no,
event_type,
)
else:
# Composite-PK collision on (message_id, sequence_no). Most
# likely cause is a stale ``latest_sequence_no`` seed on a
# continuation retry — the route read MAX(seq) from a
# separate connection before another writer committed past
# it. Look up the live latest and retry once with latest+1
# so the event is not silently lost. Bounded to a single
# retry — if two writers keep racing in lockstep the
# route-level retry will converge them across attempts.
try:
with db_readonly() as conn:
latest = MessageEventsRepository(conn).latest_sequence_no(
message_id
)
materialised_seq = (latest if latest is not None else -1) + 1
with db_session() as conn:
MessageEventsRepository(conn).record(
message_id,
materialised_seq,
event_type,
materialised_payload,
)
journal_committed = True
logger.info(
"record_event: collision at seq=%s recovered → wrote at "
"seq=%s message_id=%s type=%s",
sequence_no,
materialised_seq,
message_id,
event_type,
)
except IntegrityError:
# Second collision under the same retry — give up and
# log. The route's nonlocal counter will continue at
# ``sequence_no+1`` on the next emit; the next call may
# land cleanly past the contended window.
logger.warning(
"record_event: IntegrityError persists after seq+1 "
"retry; dropping. message_id=%s original_seq=%s "
"retry_seq=%s type=%s",
message_id,
sequence_no,
materialised_seq,
event_type,
)
except Exception:
logger.exception(
"record_event: retry path failed unexpectedly "
"(message_id=%s seq=%s type=%s)",
message_id,
sequence_no,
event_type,
)
except Exception:
logger.exception(
"message_events INSERT failed: message_id=%s seq=%s type=%s",
message_id,
sequence_no,
event_type,
)
try:
# Publish using ``materialised_seq`` so the live pubsub frame
# matches the journal row that other clients will snapshot on
# reconnect. The original POST stream's SSE ``id:`` still
# carries the caller's ``sequence_no`` — a reconnect from that
# client will receive the same event at ``materialised_seq``
# on the snapshot, which is a benign duplicate (the slice's
# ``max_replayed_seq`` advances past it). No-collision case:
# ``materialised_seq == sequence_no`` and this is identical to
# the prior behaviour.
wire = encode_pubsub_message(
message_id, materialised_seq, event_type, materialised_payload
)
Topic(message_topic_name(message_id)).publish(wire)
except Exception:
logger.exception(
"channel:%s publish failed: seq=%s type=%s",
message_id,
materialised_seq,
event_type,
)
return journal_committed
class BatchedJournalWriter:
"""Per-stream journal writer that batches PG INSERTs.
One writer per ``message_id``; ``record()`` buffers events and flushes
on size/time/``close()`` triggers. Pubsub publishes fire only after the
INSERT commits. On ``IntegrityError`` falls back to per-row writes.
"""
def __init__(
self,
message_id: str,
*,
batch_size: int = DEFAULT_BATCH_SIZE,
batch_interval_ms: int = DEFAULT_BATCH_INTERVAL_MS,
) -> None:
self._message_id = message_id
self._batch_size = batch_size
self._batch_interval_ms = batch_interval_ms
self._buffer: list[tuple[int, str, dict[str, Any]]] = []
self._last_flush_mono_ms = time.monotonic() * 1000.0
self._closed = False
def record(
self,
sequence_no: int,
event_type: str,
payload: Optional[dict[str, Any]] = None,
) -> bool:
"""Buffer one event; maybe flush. Publish happens after journal commit."""
if self._closed:
logger.warning(
"BatchedJournalWriter.record after close: "
"message_id=%s seq=%s type=%s",
self._message_id,
sequence_no,
event_type,
)
return False
if not event_type:
logger.warning(
"BatchedJournalWriter.record without event_type: "
"message_id=%s seq=%s",
self._message_id,
sequence_no,
)
return False
if payload is None:
materialised: dict[str, Any] = {}
elif isinstance(payload, dict):
materialised = _strip_null_bytes(payload)
else:
# Same contract as ``record_event`` — non-dict payloads
# are rejected so the live and replay paths can't diverge
# on envelope reconstruction.
logger.warning(
"BatchedJournalWriter.record with non-dict payload: "
"message_id=%s seq=%s type=%s payload_type=%s — dropping",
self._message_id,
sequence_no,
event_type,
type(payload).__name__,
)
return False
self._buffer.append((sequence_no, event_type, materialised))
if self._should_flush():
self.flush()
return True
def _should_flush(self) -> bool:
if len(self._buffer) >= self._batch_size:
return True
elapsed_ms = (time.monotonic() * 1000.0) - self._last_flush_mono_ms
return elapsed_ms >= self._batch_interval_ms and len(self._buffer) > 0
def flush(self) -> None:
"""Commit buffered events to PG. Best-effort.
Tries one bulk INSERT first; on ``IntegrityError`` (composite
PK collision — typically a stale continuation seed) falls back
to per-row ``record_event`` so one bad seq doesn't drop the
rest of the batch. Always clears the buffer to bound memory,
even on failure — a journaled event missing from a snapshot
is degraded UX, but a runaway buffer is corruption.
"""
if not self._buffer:
self._last_flush_mono_ms = time.monotonic() * 1000.0
return
# Snapshot and clear before the I/O so a concurrent record()
# call would land in a fresh buffer rather than racing the
# flush. ``complete_stream`` is single-threaded per stream, so
# this is belt-and-suspenders for any future change.
pending = self._buffer
self._buffer = []
self._last_flush_mono_ms = time.monotonic() * 1000.0
try:
with db_session() as conn:
MessageEventsRepository(conn).bulk_record(
self._message_id, pending
)
except IntegrityError as exc:
if _is_foreign_key_violation(exc):
# The whole batch references a conversation_messages
# parent that doesn't exist — every per-row retry would
# FK-fail too, so drop the batch once instead of N
# pointless re-attempts. The buffer is already cleared.
logger.warning(
"BatchedJournalWriter: no conversation_messages row "
"for message_id=%s (foreign-key violation); dropping "
"%d event(s). The parent message row must be committed "
"before its events are journaled.",
self._message_id,
len(pending),
)
return
logger.info(
"BatchedJournalWriter: bulk INSERT collided for "
"message_id=%s n=%d; falling back to per-row writes",
self._message_id,
len(pending),
)
self._flush_per_row(pending)
return
except Exception:
logger.exception(
"BatchedJournalWriter: bulk INSERT failed for "
"message_id=%s n=%d; events dropped from journal",
self._message_id,
len(pending),
)
return
# Bulk INSERT committed — publish each frame in order. Best-effort:
# one failed publish must not poison the rest of the batch.
for seq, event_type, payload in pending:
self._publish(seq, event_type, payload)
def _flush_per_row(
self, pending: list[tuple[int, str, dict[str, Any]]]
) -> None:
"""Per-row fallback after a bulk collision. Publishes after each commit."""
for seq, event_type, payload in pending:
committed_seq: Optional[int] = None
try:
with db_session() as conn:
MessageEventsRepository(conn).record(
self._message_id, seq, event_type, payload
)
committed_seq = seq
except IntegrityError:
try:
with db_readonly() as conn:
latest = MessageEventsRepository(
conn
).latest_sequence_no(self._message_id)
retry_seq = (latest if latest is not None else -1) + 1
with db_session() as conn:
MessageEventsRepository(conn).record(
self._message_id, retry_seq, event_type, payload
)
committed_seq = retry_seq
except IntegrityError:
logger.warning(
"BatchedJournalWriter: IntegrityError persists "
"after seq+1 retry; dropping. message_id=%s "
"original_seq=%s type=%s",
self._message_id,
seq,
event_type,
)
except Exception:
logger.exception(
"BatchedJournalWriter: per-row retry failed "
"(message_id=%s seq=%s type=%s)",
self._message_id,
seq,
event_type,
)
except Exception:
logger.exception(
"BatchedJournalWriter: per-row INSERT failed "
"(message_id=%s seq=%s type=%s)",
self._message_id,
seq,
event_type,
)
if committed_seq is not None:
self._publish(committed_seq, event_type, payload)
def _publish(
self, sequence_no: int, event_type: str, payload: dict[str, Any]
) -> None:
"""Publish one frame to the per-message pubsub channel. Best-effort."""
try:
wire = encode_pubsub_message(
self._message_id, sequence_no, event_type, payload
)
Topic(message_topic_name(self._message_id)).publish(wire)
except Exception:
logger.exception(
"channel:%s publish failed: seq=%s type=%s",
self._message_id,
sequence_no,
event_type,
)
def close(self) -> None:
"""Final flush. Idempotent — safe to call from multiple
finally clauses.
"""
if self._closed:
return
self.flush()
self._closed = True