fix(cache): scan every primary for streams, not only the one SCAN lands on - #193
robert-mihaiAdam wants to merge 5 commits into
Conversation
|
🔎 Maintainer heads-up: automated triage flagged this PR as potentially material, so it may need a signed CLA in addition to the DCO sign-off. Strong signals
This is advisory only — the bot does not decide. Please judge against the CLA criteria (material, product-critical, patent-sensitive, corporate contributor, broad commercial use). Note that thresholds can be gamed by splitting PRs, so use your judgement.
|
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The production primary-filtering implementation lacks direct test coverage.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
What changed in this PR
Fixes Redis stream maintenance so every connected primary is scanned in clustered deployments.
Changes:
- Exposes connected Redis primaries through
IRedisConnector. - Scans each primary independently, retaining the previous fallback behavior.
- Adds regression tests, public API metadata, and release notes.
| File | Description |
|---|---|
RedisStreamTopicMonitorTests.cs |
Tests multi-primary scanning and fallback. |
RedisConnector.cs |
Enumerates connected primary servers. |
IRedisConnector.cs |
Adds the primary enumeration API. |
PublicAPI.Unshipped.txt |
Records the new public API. |
RedisStreamHealthMaintainer.cs |
Scans streams across all primaries. |
CHANGELOG.md |
Documents the cluster maintenance fix. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
bec3493 to
7b98adc
Compare
…d cover the gaps Discovery changes from the review of #193: Keys from every primary landed in one list, so a slot caught mid-migration -- which answers on both its source and its target -- was processed twice in a pass: doubled stream and group metrics, and a second delete against a key the first visit had already removed. They merge into a set now. The shards share nothing but that merge, so their scans run concurrently. A pass costs the slowest shard rather than the sum, which is what keeps it inside the lock it holds for MaintainerCheckInterval as shard counts grow. QuarantineKey is built after the merge, on one thread, so a custom IRedisKeyStrategy is still only ever called sequentially. Tests: the maintainer fixture reported no primaries, so every behaviour test ran the routed-database fallback -- the one branch a real connector never takes. It now reports one, and the fallback keeps its own test. RedisConnector.GetPrimaries had no test at all; its replica filter, its connected filter and its not-yet-connected guard now have one each. Docs: broadcast.md described discovery as a single SCAN. It now explains the per-primary scan and the fallback, and troubleshooting carries the symptom this produced -- some streams trimmed and others not, on one cluster under one config. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9ahiMBR7wow2ok8AQgbFf Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
8e57869 to
852efef
Compare
|
Reviewed this and pushed one commit on top of yours ( The blocker
That throw propagates out of Probed directly against 3.1.31, 3.2.1 and 3.2.15:
Upstream fixed the three-argument form in 3.2.15 — two days ago — but the database-scoped overload is correct on every version, so that's what this uses. It also applies the multiplexer's configured default database, which means the scan reaches the same keyspace the routed The unit test passed because Also in the commit
Two things I left alone
The description is now slightly out of date — the "Notes for review" bullet reasons about Verification: 0 warnings; 1775 tests green without Redis; against a live server the only failures are the two pre-existing 🤖 Generated with Claude Code |
704b000 to
545a7f5
Compare
…ds on RedisStreamHealthMaintainer discovered streams with a keyless SCAN sent on IDatabase. A keyless command carries no slot for the client to route by, so it reaches whichever single server the multiplexer picks and its cursor walks that server's keyspace alone. On a cluster every broadcast stream whose slot lived on another primary was never discovered, and so never MINID-trimmed, never group-reaped and never deleted -- held only by the MAXLEN each XADD carries, which leaves the last MaxLength entries in place of the intended trim window, on a key that has no TTL and so is not reclaimable under a volatile-* policy. IRedisConnector gains GetPrimaries(), defaulted to empty so no existing implementer breaks, and RedisConnector filters GetServers() to connected primaries. The maintainer scans each one with its own cursor and falls back to the routed database when none can be enumerated, which is the single-server behavior it replaces. Every other maintainer command is keyed, so the client already routes those to the owning shard. Present since the maintainer was first added and invisible until now: a single-shard server behaves correctly, and the only coverage mocked IDatabase.ExecuteAsync, which cannot model shards. The regression test stands two primaries up with disjoint keyspaces and asserts both streams are checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: robert-mihaiAdam <robert.adam@uipath.com>
Keeps a one-line summary on the new public interface member; the rationale lives in the commit message and the CHANGELOG entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: robert-mihaiAdam <robert.adam@uipath.com>
… set Review follow-ups to the per-primary scan. The scan went out through IServer.ExecuteAsync(command, args, flags). That overload builds the message with db -1 and is documented for commands that are not database-specific. SCAN is database-specific, so StackExchange.Redis rejects it in the Message constructor -- "A target database is required for SCAN" -- and nothing ever reached the wire. Any deployment reporting at least one connected primary therefore found no streams at all: nothing trimmed, no group reaped, no stream deleted, which is a worse outcome than the single-shard scan it replaced. Passing a null database to the database-scoped overload applies the multiplexer's configured default, so the scan covers the keyspace the routed IDatabase used to. Checked against a live server on 3.1.31, 3.2.1 and 3.2.15; upstream taught the three-argument overload to supply the default database in 3.2.15, and passing it explicitly is what keeps this correct on the versions before that. Keys from every primary went into one list, so a slot caught mid-migration -- it answers on both its source and its target -- was handled twice in a single pass: stream and group metrics counted double, and the second visit issued a delete against a key the first had already removed. The keys merge into a set now. Nothing is shared between the shards but that merge, so they scan concurrently. A pass costs the slowest shard instead of the sum of all of them, which is what keeps it inside the lock it holds for MaintainerCheckInterval as shard counts grow. QuarantineKey is built after the merge, on one thread, so a custom IRedisKeyStrategy is still only ever called sequentially. A shard that cannot be scanned costs its own streams for that cycle rather than unwinding the whole pass, and both degraded modes -- a primary that fails, and a connector that reports none -- are logged, because silence is what let the original bug run undetected since June 2024. Tests: the maintainer fixture reported no primaries, so every behaviour test exercised the routed-database fallback, the one branch a real connector never reaches. It reports one now, and the fallback keeps a test of its own. RedisConnector.GetPrimaries had no test whatsoever; its replica filter, its connected filter and its not-yet-connected guard each have one. Message construction is where this failed and a mocked IServer cannot model it, so the maintainer is driven against a real server as well. Docs: broadcast.md presented discovery as a single SCAN. It now covers the per-primary scan and the fallback, and troubleshooting carries the symptom this produced -- some streams trimmed and others not, on one cluster under one configuration. Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
Three tests produced a connection failure by pointing at 127.0.0.1:6399 and trusting that nothing answers there. Nothing does on CI, so they passed. But 6399 is the obvious second port for a local Redis, and with one running the connection succeeds, no warning is logged, and StartAsync_RetriesAndWarns and StartAsync_ClampsNegativeRetryDelay fail on "Warnings.Count to be greater than or equal to 2, but found 0". None of the three carries the fixture's skip guard, so they run under a plain dotnet test too, and the failure reads as a broken retry loop rather than as the wrong port. StopAsync_HaltsRetryLoop rested on the same assumption and concealed it: it compares the warning count before and after StopAsync, and against a reachable port both counts are zero, so it passed while asserting nothing. Each test now binds a loopback port of its own. The listener completes the TCP handshake and then never speaks RESP, so the attempt still fails for real, on connectTimeout, down the same code path -- except that the port belongs to the test and no other process can be answering on it. StopAsync_HaltsRetryLoop asserts the loop was running before it stops it, so it can no longer pass vacuously. Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
Touches only the comments the two follow-up commits on this branch introduced, bringing them to the style 90c4551 set for the original fix. No behaviour change. Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
545a7f5 to
61472a0
Compare
|




Summary
RedisStreamHealthMaintainerdiscovered streams with a keylessSCANsent onIDatabase. A keyless command carries no slot for the client to route by, so it reaches whichever single server the multiplexer picks and its cursor walks that server's keyspace alone. On a cluster, every broadcast stream whose slot lives on another primary was never discovered — and so never MINID-trimmed, never group-reaped and never deleted.Found in production: two stream keys on the same Azure Managed Redis (OSS cluster policy, 2 primaries split at slot 8192), same process, same options — the one above the split was trimmed to the intended ~1h window, the one below it had retained two weeks of events.
Changes
IRedisConnectorgainsGetPrimaries(), with a default implementation returning empty so no existing implementer breaks.RedisConnector.GetPrimaries()filtersGetServers()to connected primaries, using the same "only if the connection is already established" guard asGetEndPoints.GetAllStreamsAsyncscans every primary, each with its own cursor, and falls back to the routed database when none can be enumerated — which is exactly the single-server behavior it replaces.StreamInfoAsync. Verified to fail onmainand pass with the fix.GetPrimaries()to empty explicitly, alongside its existingDatabasestub — left unconfigured, the auto-mocked connector hands back generated servers whoseSCANanswers with nothing.Every other maintainer command (
StreamInfoAsync,StreamGroupInfoAsync,StreamTrimByMinIdAsync, the quarantine hash ops,LockTakeAsync) is keyed, so the client already routes those to the owning shard. Only discovery was affected.Notes for review
0bf3de0, June 2024). It is invisible on a single-shard server, and the only prior coverage mockedIDatabase.ExecuteAsync, which cannot model shards — so it passed identically on 1 or 20 primaries.MAXLEN ~ MaxLengthcaps growth so nothing alerts, and resizing a cluster remaps slots — so which streams go unmaintained moves over time.MaxLengthentries" (32,768 by default) instead ofMaintainerTrimInterval. Bounded, but stream keys carry no TTL, so that memory is not reclaimable under avolatile-*eviction policy.IServer.ExecuteAsyncrather thanIServer.Keys()becauseKeys()has noTYPEfilter, and the maintainer relies onTYPE streamto avoid walking the whole keyspace.Test plan
main/ green with the fixdotnet test) — full suite 1770 passed, 0 failed, 12 skipped (Docker-gated); solution builds with 0 warningsLinked issues
Fixes #
Contributor declaration
git commit -s).🤖 Generated with Claude Code