Skip to content

DOC-7017 Assert cumulative SCAN totals instead of pinning per-call counts in cmds_generic - #3900

Merged
andy-stark-redis merged 1 commit into
mainfrom
DOC-7017-cmds-generic-scan-2-s-five-call-split-assumption-is-not-guaranteed-flaky-blocks-full-script-runs
Aug 28, 2026
Merged

DOC-7017 Assert cumulative SCAN totals instead of pinning per-call counts in cmds_generic#3900
andy-stark-redis merged 1 commit into
mainfrom
DOC-7017-cmds-generic-scan-2-s-five-call-split-assumption-is-not-guaranteed-flaky-blocks-full-script-runs

Conversation

@andy-stark-redis

@andy-stark-redis andy-stark-redis commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • cmds_generic's scan2 step asserted that the last of five SCAN calls returns exactly 18 matches — an unguaranteed per-call split. Every client (redis-py, node-redis, ioredis, predis, jedis, lettuce-async, lettuce-reactive, go-redis) now asserts the cumulative total across all five calls (19) instead. Rust already did this; NRedisStack's scan2 step is an empty stub, untouched.
  • Verifying that fix let a second, previously-masked defect run for the first time: scan3's TYPE-filtered SCAN made the identical single-call assumption. Fixed the same way (loop until the cursor is exhausted, accumulate, assert on the total) across the same clients (predis has no scan3 step; rust never uses SCAN there).
  • node-redis's non-throwing console.assert on this check is now a real, throwing assertion.

See the commit message for the full narrative, including a self-caught bug: the first pass at the lettuce-async/lettuce-reactive scan3 fix nested a blocking call inside an async callback and deadlocked the event loop for a full minute per run before being caught and rewritten.

Test plan

  • Ran each fixed client's cmds_generic example through build/example-test-harness at least 3x against a live Redis instance; scan2 and scan3 pass reliably on every run for every client.
  • Full end-to-end verification through scan4 wasn't possible in this environment — local Redis was 7.2.7 and scan4's HSCAN ... NOVALUES needs 7.4+. That's a separate, pre-existing, version-gated issue this PR doesn't touch; every client fails there identically regardless of this change.

Related: DOC-6968, DOC-7015.


Note

Low Risk
Changes are limited to documentation example tests and assertions; no production runtime or Redis server behavior is modified.

Overview
cmds_generic SCAN examples no longer assume fixed per-call result sizes from MATCH/TYPE-filtered SCAN, which Redis does not guarantee.

For scan2, examples across eight clients (go-redis, ioredis, jedis, lettuce-async, lettuce-reactive, node-redis, predis, redis-py) now sum keys from every iteration (including the high-COUNT pass) and assert 19 total matches for *11*, instead of expecting the final call alone to return 18. Docs/comments call out that only the cumulative total is stable.

For scan3, the same clients (except predis, which has no scan3 step) loop until the cursor is exhausted, accumulating TYPE zset keys, instead of relying on a single SCAN. Lettuce async/reactive scan3 is rewritten as sequential blocking scans rather than nested reactive chains, avoiding blocking inside callbacks.

node-redis replaces non-throwing console.assert on the scan2 check with assert.strictEqual.

Reviewed by Cursor Bugbot for commit 548c720. Bugbot is set up for automated code reviews on this repo. Configure here.

…unts in cmds_generic

scan2's five SCAN calls no longer assert that the last call alone returns 18
matches; each client now sums matches across all five calls and asserts the
cumulative total (19), which is what SCAN actually guarantees. Applied to
redis-py, node-redis, ioredis, predis, jedis, lettuce-async, lettuce-reactive,
and go-redis (rust already did this; NRedisStack's scan2 step is an empty
stub, untouched).

Verifying that fix surfaced a second copy of the same bug one step later:
scan3's `SCAN ... TYPE zset` also assumed a single default-COUNT call would
find both matches. It was invisible before because scan2 always crashed
first — the crash was hiding a second, independent defect. Reproduced the
scan3 failure 100% of the time on a fresh, empty Redis instance (not just
leftover state from repeated local runs), so it's a real defect and not
environmental noise. Fixed the same way: loop until the cursor is exhausted,
accumulate, assert on the accumulated set.

The lettuce-async/lettuce-reactive fix needed a second pass. The first
attempt nested the accumulate-until-done loop's blocking `.join()`/`.block()`
calls inside the existing `.thenCompose()`/`.flatMap()` async callback chain
those files use for every other step. That deadlocks: the blocking call sits
on the same Netty event-loop thread that needs to run to deliver the SCAN
response, so the command hangs until Lettuce's own 1-minute command timeout
kills it — passed locally as a hang, not a failure, so it needed a timing
read to catch. Rewrote scan3 to drop out of the async chain entirely and use
plain sequential blocking calls, matching the pattern scan2 already
established in these same two files.

Learned: a step masked by an earlier crash can hide its own independent bug — fixing the crash can unmask a different defect downstream, not just prove the fix works.
Constraint: in lettuce-async/lettuce-reactive cmds_generic, never nest a blocking .join()/.block() call inside a thenCompose/thenAccept/flatMap/doOnNext callback — it deadlocks the event-loop thread that would deliver the response.
Rejected: keeping scan3's async-chained thenCompose/thenAccept style and looping inside it | deadlocks Lettuce's event loop; use plain sequential blocking calls instead, as scan1/scan2 already do in both files.
Gaps: only verified against local Redis 7.2.7, 3x per client; Redis 8.8 was unavailable. scan4 remains unverified end-to-end — every client that reaches it fails on HSCAN NOVALUES, a separate pre-existing version-gated (Redis 7.4+) issue this branch doesn't touch.
Ticket: DOC-7017
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

DOC-7017

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history (5 new this commit):

Memory updated at 548c720

@dwdougherty dwdougherty left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skimmed. Approved.

@andy-stark-redis andy-stark-redis self-assigned this Aug 28, 2026
@andy-stark-redis andy-stark-redis added the clients Client library docs label Aug 28, 2026
@andy-stark-redis

Copy link
Copy Markdown
Contributor Author

Thanks for the (several) approvals, @dwdougherty :-)

@andy-stark-redis
andy-stark-redis merged commit e254481 into main Aug 28, 2026
55 of 56 checks passed
@andy-stark-redis
andy-stark-redis deleted the DOC-7017-cmds-generic-scan-2-s-five-call-split-assumption-is-not-guaranteed-flaky-blocks-full-script-runs branch August 28, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

clients Client library docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants