Repository navigation
Conversation
…d a process lease An append is now XADD plus one HMGET of status and cancel in a plain pipeline: 2 server commands instead of 8 in MULTI. open() writes a stream.start entry and gives every key a stream_max_run TTL, so nothing slides and a live producer's keys never lapse during an idle wait. An append past half the lifetime extends it. The per-stream heartbeat, keepalive(), attach(), writer fencing, StreamFenced and the started and alive meta fields are gone. Each process keeps one lease: a set of the runs it still has a writer for, rewritten every stream_lease_interval, plus a cancel set other processes write to. Readers end a running run missing from its owner's lease as producer_lost; open() takes over a slot held by such a run. suspend() and resume() hand a run to another process; a resume of a run its owner still vouches for raises StreamBusy. append() and finish() raise StreamFinished when a reader already ended the run, cancel() ends a suspended run, readers stop on a run whose entries expired, and each lifespan gets its own process ID so forked workers hold separate leases. Docs still describe the old API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On redis-py 8.0.1, an async RedisCluster whose transactional pipeline was executed outside async with can fail to close afterwards with 'Event loop is closed'. Every pipeline the backend runs now releases its connection on exit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port the stream_case fixture and gated_exec from #98. stream_case runs a test against standalone Redis, and against an OSS Cluster or Redis Enterprise when STREAMS_TEST_CLUSTER_URL or STREAMS_TEST_ENTERPRISE_URL is set, with its own key prefix and cleanup. test_stream_topologies covers appends that write no TTLs, a crashed process reported lost and its scope freed, a dropped writer reported lost, a cancel reaching another process during a tool call, suspend and a once-only resume, cancelling a suspended run, a reader whose end aborts when the run is resumed before its EXEC, and concurrent starts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## feature/resumable-streams #99 +/- ##
=============================================================
+ Coverage 96.21% 96.35% +0.14%
=============================================================
Files 17 17
Lines 3748 3536 -212
=============================================================
- Hits 3606 3407 -199
+ Misses 142 129 -13
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
One _retry() helper replaces the three retry loops in _pipeline, _guarded and _create. _final_xadd() builds the final entry for finish, a cancel of a suspended run and a reader ending a lost run. _vouching() holds a run in the lease while open() or resume() decides, and _check_live() is the one guard for append, extend and suspend. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the standalone-only copies of eight scenarios that test_stream_topologies runs on every topology, after moving their extra assertions there: suspended status, TTL and sequence numbers, a second cancel, and no stray meta after concurrent starts. collect(), crash() and configure() move to tests/stream_support.py, replacing per-file copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jruaux
marked this pull request as ready for review
October 29, 2026 12:43
Remove the hand-written client and pipeline fakes and the MagicMock/AsyncMock clients from the stream unit tests. What they checked now runs on real Redis, on every topology stream_case is given: the support probe on RESP2 and RESP3 and against an unreachable server, detection caching, the commands one append runs (read from INFO commandstats: one XADD, one HMGET), lease renewal and cancel collection, a dropped writer leaving the lease, every producer state a quiet reader can find, and resume() and reads through RESP2 and RESP3 clients. The unit tests left make no Redis call; a backend there gets a client factory that fails the test if called. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run is now one Redis Stream, <prefix>:stream:<scope>:<id>, and nothing else: the meta hash, the scope's active slot, the process lease and the cancel sets are gone, and so are every WATCH transaction and the per-stream heartbeat. open() writes the start entry and sets the run's lifetime in one MULTI; created is the reply of EXPIRE ... NX, and a creating open widens the stream's IDMP window to the run's life. append() is one XADD. finish(), cancel() and a reader's producer_lost end share one IDMP pair, so a run has one final entry, and EXPIRE ... LT lets ending only shorten it. A reader that sees nothing for stream_silence_timeout ends the run; keepalive(), which spawn() runs, pings a quiet producer. Removed: active(), exclusive, StreamBusy, suspend(), resume(), and the settings stream_gone_ttl, stream_lease_interval and stream_lease_ttl. Added: stream_silence_timeout. A cancel from another process ends the run for readers but no longer reaches its producer. The incident triage example keeps its own current-run pointer per engineer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test app no longer has an active route, so TestUnsupportedServer hit a 404 instead of the 503 it asserts; it runs only against a server without stream support, the redis 7.4 CI job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No write trims a run any more: XADD MAXLEN could remove the final entry once a producer that missed a cancel wrote past it. stream_maxlen now caps each writer's events instead, and append() raises past it. Appends from one writer go out one at a time, so a retry is never overtaken by enough appends to push its IDMP pair out of Redis's 100-pair history. open() sets the stream's IDMP window (a constant day) inside its creating transaction, so no cancel can land before it and lose its pair; a retried start sends the same value, which clears nothing. extend() is gone: it could lengthen a run another process had already ended. stream_done_ttl must be shorter than stream_max_run. The incident triage example tells an ended run by its TTL rather than its last 20 entries, and uses the renamed settings; its claim has tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8.6.2 is the oldest server resumable streams support, and the IDMP behaviour the stream tests rely on was only checked on 8.8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guide, the API reference and the configuration page still described the meta hash, the scope's active slot, the producer heartbeat, active(), exclusive, StreamBusy, attach(), StreamFenced, extend(), trimming by stream_maxlen and the removed settings. They now describe the run as one stream, the fixed run lifetime, the per-writer event cap, silence detection with keepalive(), a cancel that does not reach a producer in another process, and how an application keeps its own current-run pointer for one run at a time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Requiring stream_done_ttl shorter than stream_max_run only served the incident triage example, which tells an ended run by its TTL; it rejected settings that work, such as REDIS_STREAM_DONE_TTL=3600 with the default lifetime. Ending only ever shortens a run, so a longer stream_done_ttl means a finished run keeps what lifetime it has left. The example now checks the assumption itself at startup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On
feature/resumable-streams, every run is three keys (stream, meta hash, the scope's active slot), and everyappend()is aMULTIof 8 commands. Most of that is bookkeeping: each append doubles as the producer heartbeat, refreshing four TTLs and reading a cancel flag. The design also has two bugs:keepalive()refreshes the heartbeat but not the stream or meta TTLs, so a tool call longer thanstream_active_ttlmakes the next append fail. fix(streams): retain active history during heartbeats #98 fixes this inside the current design.stream_alive_ttlis ended asproducer_lost, but its nextappend()andfinish()silently succeed.What changes
One key per run: a Redis Stream,
<prefix>:stream:<scope>:<id>. Everything about a run is an entry in it, and readers stop at the final entry.open()MULTIXADD s * t stream.start·XCFGSET s IDMP-DURATION 86400·EXPIRE s <stream_max_run> NXEXEC, one round trip.createdis theEXPIRE NXreply: only a stream with no TTL yet, one this call just created, gets one. Setting theIDMPwindow inside the transaction means nothing can write a pair that it then clears; a retried start sends the same value, which clears nothingappend()XADD s NOMKSTREAM IDMP <writer> <seq> * …: one command, no reads, no TTL writes. Appends from one writer go out one at a time, so a retry is never overtaken by enough others to push its pair out of Redis's 100-pairIDMPhistory. A writer can append at moststream_maxleneventsfinish(),cancel(), a reader'sproducer_lostMULTIXADD s NOMKSTREAM IDMP end final … f 1·EXPIRE s <stream_done_ttl> LTEXEC. The sharedIDMPpair keeps one final entry however they race;LTmeans ending only ever shortens the run's lifetimekeepalive()(run byspawn())stream.ping, only after halfstream_silence_timeoutwithout a writeXREAD BLOCK. Paststream_silence_timeoutwith nothing new, it writesstream.end {producer_lost}Removed:
active(),exclusiveandStreamBusy;WATCHtransaction;alivefield;stream_active_ttl,stream_alive_ttlandstream_gone_ttl.Added: the setting
stream_silence_timeout, andstream_max_runas the run's hard lifetime (at most 86,400 s). Nothing extends a run:extend()is gone, because it could lengthen a run another process had already ended.No trimming: no write uses
MAXLEN. A producer that missed a cancel could otherwise trim the final entry away by writing past it.stream_maxlennow caps each writer's events, andappend()raises past it.The example app (
examples/incident_triage_app) keeps its own per-user current-run pointer, for "one run per engineer" and "the phone finds the run". It claims the pointer withSET … NX, frees it withDELEX … IFEQ, and takes it over once its run has ended.Behaviour:
keepalive();spawn()does that.cancel()returns nothing, andfinish()after someone else ended the run returns that final entry's ID.open()whose first reply was lost reportscreated = False; the run then ends asproducer_lostafter the silence timeout.Size:
stream_backend.pygoes from 1,112 code lines onfeature/resumable-streamsto 815, excluding comments and docstrings.Validation
Run locally with redis-py 8.0.1 on Redis 8.8, standalone and a 3-node OSS cluster:
test_stream_topologies.pychecks the command count of each operation throughINFO commandstats:open()is oneXADD, oneEXPIREand, only when it creates the run, oneXCFGSET;append()is oneXADD. It also covers lost producers,keepalive(), cancels from the producer's own process and from another, and a cancel racing afinish()(held atEXECwithgated_exec), on both topologies.EXPIRE … LTon ends; theIDMPwindow widened to the run's life; a Redis outage no longer counted as producer silence; earlier pings; and three fixes in the example.stream.gapevents.open()and itsXCFGSETlost itsIDMPpair;extend()could lengthen a finished run; the example missed ends behind 20 later entries; and the 7.4 test failure. All 6 are fixed, each with a test that fails on the previous code and passes now.stream_done_ttlisn't shorter thanstream_max_run: the example now refuses that at startup, and the SDK accepts anystream_done_ttl(a finished run keeps what lifetime it has left). It also found three tests that didn't prove their names (fixed).Limitations
open()whose reply is lost reportscreated = False: no producer starts, and the run ends asproducer_lostafter the silence timeout.createdmeans "no live stream with this ID", not "first time ever".stream_done_ttlis a maximum: a run that ends with less of its lifetime left keeps that shorter time.XCFGSETkeepingIDMPhistory was verified on Redis 8.8 only. This PR adds Redis 8.6.2, the supported minimum, to the CI integration matrix, so the stream tests check it there; I couldn't confirm that theredis:8.6.2image tag exists.spawn()'s docstring now says.🤖 Generated with Claude Code