Repository navigation
fix(gateway): live-tail malformed redis reconnect ids - #4012
WillemJiang merged 3 commits into
Conversation
zhfeng
left a comment
There was a problem hiding this comment.
Thanks for this - clean fix, and the xrevrange-to-concrete-id approach is nicer than a naive $ (uniform XREAD loop, clean empty-stream -> 0-0 fallback, smaller miss window). CI is green and the regression tests (fake + real-redis) look valid. Approving with two notes below.
Non-blocking: malformed Last-Event-ID on an already-terminal run now hangs until TTL.
The replaced test (test_redis_integration_invalid_last_event_id_falls_back) subscribed to a completed run with a malformed id and relied on the 0-0 fallback to replay the retained events + END. With the new resolver, a malformed id on a terminal run whose stream is still buffered (within stream_ttl_seconds, default 86400s) resolves to the END marker's id -> XREAD after it returns nothing -> the subscriber heartbeats until the TTL expires, because _terminal_record_stream_missing stays False while the stream still exists.
This isn't reachable from the frontend (shouldSkipReconnect skips terminal runs before joinStream, and the SDK only sends -1 on reconnect), so it only affects a direct API client sending a bogus Last-Event-ID to a finished run. If that's acceptable, a one-line note in the resolver docstring would help; if not, a small guard works: in _resolve_start_stream_id, if the xrevrange last entry's kind == _KIND_END, return "0-0" (bounded replay delivers END and returns - no hang, since no new events pile on a terminal run; the hang only bites active runs).
Trivial: dead $ branch in _FakeRedis.xread.
The if last_id == "$": … branch isn't reachable, since the resolver produces a concrete id via xrevrange and never "$". Harmless, but can be dropped for clarity.
willem-bd
left a comment
There was a problem hiding this comment.
Reviewed the malformed reconnect-id fix. The core change is sound: pre-validating the id before XREAD, live-tailing on a malformed token, and propagating ResponseError (now safe because validation happens up front) all check out, and the two regression tests are meaningful. None of the items below are blocking — they're accuracy / coverage / cleanup nits. Details inline.
| return "0-0" | ||
| if _REDIS_STREAM_ID_RE.fullmatch(last_event_id): | ||
| return last_event_id | ||
| entries = await self._redis.xrevrange(key, count=1) |
There was a problem hiding this comment.
The PR body says malformed ids "start from Redis $", but this resolves to the last retained entry's id via XREVRANGE instead. They're equivalent for live-tailing a non-empty stream, and the XREVRANGE approach is arguably the better choice here — it also covers the empty-stream case by returning 0-0, whereas $ on a non-existent stream has murkier BLOCK semantics.
One loose end: _FakeRedis.xread gained a last_id == "$" branch (test_stream_bridge.py:49) that is now dead — nothing in production or tests ever passes $ (confirmed by grep). It reads like an incomplete refactor. Suggest aligning the PR-body wording to "live-tail from the last retained id" and either dropping the fake's $ branch or leaving a comment that it's intentional future-proofing.
|
|
||
| _KIND_EVENT = "event" | ||
| _KIND_END = "end" | ||
| _REDIS_STREAM_ID_RE = re.compile(r"^\d+-\d+$") |
There was a problem hiding this comment.
Minor: this is stricter than Redis's own stream-id grammar. Redis accepts bare-ms partial ids like 1234 (== 1234-0), but this regex rejects them, so such an id now falls into the malformed path and live-tails instead of resuming — silently dropping any retained events with id >= that value. No known client emits partial ids (the SDK echoes the server's full <ms>-<seq>), so the risk is theoretical, but ^\d+(-\d+)?$ would preserve resume semantics if you want belt-and-suspenders.
Nit: with fullmatch, the ^…$ anchors are redundant.
| if _REDIS_STREAM_ID_RE.fullmatch(last_event_id): | ||
| return last_event_id | ||
| entries = await self._redis.xrevrange(key, count=1) | ||
| if not entries: |
There was a problem hiding this comment.
Coverage gap: this empty-stream fallback (XREVRANGE -> [] -> "0-0") isn't exercised — both new tests pre-populate the stream, so the malformed path always takes the XREVRANGE branch. A small test subscribing with a malformed id to an empty/expired stream would cover this branch and confirm it live-tails future events.
…y rebuilt 488ec17 -> 9097642. Migration-free (head stays 0003), no compose/Dockerfile/ frontend changes, zero conflicts, all carried patches intact. Notable: bytedance#3996 adds memory consolidation, which is LOSSY (source fact content is permanently replaced, only consolidatedFrom IDs survive). It defaults to false and we do not set the key, so we inherit false — verified against the new image, and the code gates it at both prompt-build and apply time. Keep it off. bytedance#4019 auto-promote is gated on tool_search.enabled (false here). bytedance#4012 fixes the redis stream bridge (we run in-memory). bytedance#4018 scrubs MYSQL_PWD/REDISCLI_AUTH from the skill env. Verified 3a/3b/3c incl. a live Aio bash-smoke, since bytedance#4009/bytedance#4017 touch the tool-output path the LGI pipeline depends on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(gateway): live-tail malformed redis reconnect ids * test: avoid redis live-tail heartbeat gaps * fix: handle terminal malformed redis reconnect ids
* fix(gateway): live-tail malformed redis reconnect ids * test: avoid redis live-tail heartbeat gaps * fix: handle terminal malformed redis reconnect ids
Fixes #4011
Why
When the Redis stream bridge is enabled, a reconnecting LangGraph SDK client can send
Last-Event-ID: -1after a page refresh. The bridge previously passed that value directly to RedisXREAD; Redis rejected it, and the error fallback reset the stream id to0-0.That fallback replayed the retained stream buffer from the beginning, so long active runs could re-stream the whole retained response on every reconnect and hang the chat UI.
What changed
Malformed Redis stream reconnect ids now live-tail new events instead of replaying retained history.
<ms>-<seq>) still resume after the providedLast-Event-ID.Last-Event-IDstill starts from0-0, preserving initial subscribe behavior.-1now start from Redis$, so active reconnects wait for new events only.ResponseErrorno longer falls back to0-0, avoiding accidental full-buffer replay.Surface area
frontend/langgraph.json, or prompt changedocker/or sandboxed executionskills/backend/pyproject.tomlorfrontend/package.jsonScreenshots / Recording
N/A. Backend Redis stream reconnect behavior only.
Bug fix verification
Test path that reproduces the bug:
backend/tests/test_stream_bridge.py::test_redis_invalid_last_event_id_tails_live_eventsbackend/tests/test_stream_bridge.py::test_redis_integration_invalid_last_event_id_tails_live_eventsDid it go red on
mainand green on this branch?mainin this checkout. The previous implementation passed malformed ids to Redis, handled the resultingResponseError, and reset to0-0; the updated tests assert that malformed ids skip retainedmetadataand receive only newly published events.If a red test wasn't cheap to write, explain why and what you did instead.
-1reconnect path without requiring Redis. The real Redis integration test is also updated for environments withDEER_FLOW_TEST_REDIS_URL.Validation