Skip to content

fix(blocking): multi-key blocking pops serve exactly once; refuse spanning BLMPOP/BZMPOP (moon#989) - #1021

Merged
TinDang97 merged 2 commits into
mainfrom
fix/989-blmpop-bzmpop-extra-pop
Sep 19, 2026
Merged

TinDang97 merged 2 commits into
mainfrom
fix/989-blmpop-bzmpop-extra-pop

Conversation

@TinDang97

@TinDang97 TinDang97 commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes #989. Follow-up for the part left on purpose: #1019.

Root cause

This confirms the triage hypothesis, from the code and a live reproduction against redis 8.6.1.

When a client's connection is on a different shard than the keys, blocking::immediate_scan can see none of them. That is correct (moon#557). The multi-key coordinator in handle_blocking_command{,_monoio} then sent one BlockRegister per key to their owner. The owner handled each message on its own: register the key, see data, serve the waiter (spsc_handler.rs). So the same waiter was served once per non-empty key. The client kept the first reply from its FuturesUnordered and dropped the rest, and the elements in those replies had already left the keyspace. A2's undo does not help, because the send into the still-live receiver succeeds.

  • It depends on which shard the connection landed on, not on where the keys are. That is why it showed up with {hash}-tagged keys, varied between server instances, stayed stable within one instance, and never appeared at --shards 1. (On macOS moon's SO_REUSEPORT listeners send every connection to one shard.)
  • It affects the whole multi-key blocking-pop family: BLPOP, BRPOP, BZPOPMIN and BZPOPMAX lose data exactly like BLMPOP/BZMPOP.
  • The same mechanism popped a key named twice twice (BLMPOP 0 2 k k LEFT). It also skipped Redis's -WRONGTYPE for an earlier wrong-typed key, because per-key registrations had sole_key: false.
  • With keys on several shards, the local scan also skipped an earlier remote non-empty key and popped a later local one (the wrong key).

Options considered

option exactly-once co-located / --shards 1 vs redis cost, blast radius
A refuse ALL cross-shard multi-key blocking pops with CROSSSLOT spanning yes; co-located still broken (the bug is not about routing) unchanged, still broken does not fix #989 on its own
B single-winner claim token across shards at most once, but argument order not honoured on the immediate path closer new cross-shard state machine plus loom; an extra hop per wake; element restore on a lost claim
C (chosen) one registration per OWNER shard with every key it owns; owner registers, type-checks and serves in one synchronous stretch. Plus CROSSSLOT for spanning BLMPOP/BZMPOP (the moon#962 rule) co-located: yes, by construction (remove_wait unregisters siblings before the next key is looked at). Spanning MPOP: refused before anything is touched identical to redis and --shards 1, including -WRONGTYPE and duplicate keys 2 new small modules; blocking.rs shrinks; one message per owner instead of one per key

Behaviour for co-located keys changes only toward redis parity (no more element loss; -WRONGTYPE where redis gives it). The only new refusal is spanning BLMPOP/BZMPOP, consistent with LMPOP/ZMPOP since #990. It has a BEHAVIOUR CHANGE CHANGELOG entry.

Deliberately not done: spanning BLPOP/BRPOP/BZPOPMIN/BZPOPMAX can still be served by two owners, or pop the wrong key. Refusing them breaks untagged BLPOP q1 q2 q3 0 worker loops at --shards > 1, which is a product decision. That is filed as #1019 with options and a recommendation. An interleaved A/B shows this PR leaves that placement unchanged within noise: extra pops 35 (merge-base) vs 40 (branch) out of 120, over 10 fresh instances each. When every key is on a different shard the protocol sends the same messages as before.

Changes

  • src/server/conn/blocking_multikey.rs (new): stage_multikey_wait, shared by both runtimes. Local keys are registered directly; remote keys are grouped into one BlockRegisterGroup per owner, in argument order, with whole_command set when the owner holds every key.
  • src/blocking/group.rs (new): register_group, the owner side. A whole-command group runs Redis's type ladder first (-WRONGTYPE for the first existing wrong-typed key, stopping at the first servable one). Then it registers every member and serves in argument order, stopping once the waiter is gone. A wrong-typed key is never offered to the wakers: they would pop the waiter, fail, and remove_wait all of its siblings.
  • ShardMessage::BlockRegisterGroup (boxed; the 64-byte enum cap still holds). BlockRegister now carries single-key waiters only, so sole_key is removed and the owner's type check is unconditional.
  • immediate_scan refuses spanning BLMPOP/BZMPOP through cross_shard_multikey_rejection. They are added to its family list ((6, b'b'), a new arm with no collision), so there is one list, not two.
  • BlockingRegistry::is_waiting.

Dispatch paths

  • command::dispatch / dispatch_read: not reached. Blocking commands are intercepted by the connection handlers before dispatch. Inside MULTI they are queued as their non-blocking twin (queued_blocking_frame), and EXEC's locality check covers them (bmk7).
  • try_inline_dispatch: GET/SET only; not reachable.
  • Both runtimes' handle_blocking_command* now call the same staging function.
  • Lua: redis.call('BLMPOP', ...) answers unknown command on moon, both before and after this PR (redis runs it non-blocking). Pre-existing and out of scope.

Evidence

Measured before rebasing onto 16c4213 (the two commits it adds touch ACL SAVE and CI sharding only; the new test and clippy were re-run after the rebase). All numbers are from a native macOS host against redis 8.6.1 (redis-server on PATH); moon is a release-fast build.

Repro, clean (one listener per port verified with lsof for every leg), 3 fresh instances per binary, {tag} keys × 16 per command, commands BLMPOP/BZMPOP/BLPOP/BZPOPMIN:

                  destroyed an element   spanning BLMPOP/BZMPOP
merge-base 43ad387b    139 / 192          answered; wrong key or extra pop
this branch              0 / 192          72/72 CROSSSLOT, keyspace untouched
redis 8.6.1              0 / 192          (answers; standalone)

tests/blocking_multikey_cross_shard.rs (7 tests; the co-located rows use tags constructed to be owned by every shard, so they cannot pass by the connection landing on the owner):

  • RED on the unmodified tree, for the real reason: bmk1 58/80 rows "ELEMENT DESTROYED" (including the duplicate-key rows), bmk3 every remote-owner row pops instead of -WRONGTYPE, bmk6 20/32 spanning probes pop an unanswered key. Controls bmk2 (--shards 1), bmk4 (block then wake), bmk5 (timeout leaves no ghost waiter) and bmk7 (MULTI/EXEC) are green on both trees.
  • GREEN with the fix, monoio and tokio.
  • The guard can fail: forcing one group per key turns bmk1/bmk3 red on both runtimes (60/80 and 59/80); removing the refusal arm turns bmk6 red (25 placements).

scripts/test-consistency.sh --shards 4: new rows red on the merge-base binary (blmpop/bzmpop span+colo, blpop/brpop/bzpopmin/bzpopmax colo) and green on this branch. The same 6 unrelated failures appear on both binaries (5 tracking: controls, see the known 4-shard tracking flake; and ROLE on a master), so they are not from this change. --shards 1 (branch only): only ROLE on a master fails, the same row that fails on the merge-base binary at --shards 4. Answered route_probe_multi rows now also compare the keyspace with redis, not just the reply; the reply alone is exactly what hid this bug.

scripts/test-commands.sh: 4 new rows in blocking. I could not run the script itself in my sandbox. I replayed the exact rows against redis: 12/12 pass on this branch over 3 instances, and the merge-base binary fails the "later key untouched" rows on every instance.

Unit tests: blocking::group (9), blocking_multikey (3). cargo clippy --all-targets -D warnings is clean on both runtimes and cargo fmt --check is clean. The lib test gate_is_skipped_with_spill_sender_when_no_limit_is_configured fails under a full parallel --lib run on this branch and on the merge-base source alike. It is #856, unrelated; it passes in isolation.

No unsafe, no unwrap outside tests, no atomics. Not measured on Linux: io_uring and SO_REUSEPORT hashing change which connection lands where, but not the protocol. ci-local.sh was not run from this sandboxed session (it needs the moon-dev VM); the hosted matrix is dispatched.

…nning BLMPOP/BZMPOP (moon#989)

At --shards >= 2 a multi-key blocking pop (BLMPOP, BZMPOP, BLPOP, BRPOP,
BZPOPMIN, BZPOPMAX) over keys co-located under one {hash} tag replied
correctly while a SECOND non-empty key lost its head element, delivered to
nobody:

  BLMPOP 0.3 3 {t}a {t}b {t}c LEFT   ({t}a empty, {t}b=[B1 B2], {t}c=[C1 C2])
  redis 8.6.1     -> {t}b [B1]   {t}c=[C1 C2]
  moon --shards 4 -> {t}b [B1]   {t}c=[C2]      <-- C1 destroyed

Root cause (the triage hypothesis, confirmed from code and reproduction):
when the client's connection lives on a different shard than the keys,
immediate_scan can see none of them, so the multi-key coordinator sent one
BlockRegister per key to their owner. The owner handled each message on its
own -- register, see data, serve -- and so served the SAME waiter once per
non-empty key. The client kept the first reply and dropped the rest. The
same fan-out popped a key named twice twice, and skipped Redis's
-WRONGTYPE for an earlier wrong-typed key. It only happens when the
connection is off the owner shard, which is why it varied between server
instances and never showed at --shards 1.

Fix:
- The coordinator now sends ONE BlockRegisterGroup per owner shard carrying
  every key that owner holds, in argument order (blocking_multikey.rs, shared
  by both runtimes). The owner registers every member under one wait_id and
  then serves in argument order in one synchronous stretch
  (blocking::group::register_group); serving runs remove_wait, which
  unregisters the siblings before the next key is looked at, so the waiter
  is served at most once. When the group is the whole command it also
  answers the -WRONGTYPE Redis owes; a partial group skips a wrong-typed key
  instead of tearing down its sibling registrations.
- Spanning BLMPOP/BZMPOP are refused with CROSSSLOT in immediate_scan,
  before anything is popped or registered, via the same family list moon#962
  uses for LMPOP/ZMPOP. That placement also popped the WRONG key (a later
  local key over an earlier remote one) and could be served by two owners.
- BlockRegister now carries single-key waiters only, so its sole_key flag
  is gone and the owner's type check is unconditional.

Not changed: spanning BLPOP/BRPOP/BZPOPMIN/BZPOPMAX can still be served by
two owner shards (measured unchanged within noise, 35 vs 40 extra pops of
120 over 10 instances each). Refusing them is a client-visible decision and
is moon#1019.

Tests:
- tests/blocking_multikey_cross_shard.rs: 7 tests. Co-located rows run for
  tags owned by EVERY shard, so they cannot pass by the connection happening
  to land on the owner. RED on 43ad387 (bmk1 58/80, bmk3, bmk6 20/32),
  GREEN with the fix on monoio and tokio; mutating the grouping or the
  refusal turns them red again.
- unit tests for register_group and stage_multikey_wait.
- test-consistency.sh: blmpop/bzmpop span+colo rows, colo-only rows for the
  other four, and answered rows now compare the keyspace with redis, not
  just the reply. test-commands.sh: co-located BLMPOP/BZMPOP rows with a
  read of the key they must not touch.

Refs: moon#989, moon#962, moon#1019

author: Tin Dang
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 51 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 923a0a9f-1ac3-45cd-91d4-20fb85aae368

📥 Commits

Reviewing files that changed from the base of the PR and between efc5bce and 085aeed.

📒 Files selected for processing (12)
  • CHANGELOG.md
  • scripts/test-commands.sh
  • scripts/test-consistency.sh
  • src/blocking/group.rs
  • src/blocking/mod.rs
  • src/server/conn/blocking.rs
  • src/server/conn/blocking_multikey.rs
  • src/server/conn/mod.rs
  • src/server/conn/shared.rs
  • src/shard/dispatch.rs
  • src/shard/spsc_handler.rs
  • tests/blocking_multikey_cross_shard.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

CHANGELOG.md was the only conflict: both sides added entries at the same
place in [Unreleased]. Both are kept. No code file changed, so the tree
outside CHANGELOG.md is exactly the tested branch plus main.

author: Tin Dang
@TinDang97

Copy link
Copy Markdown
Collaborator Author

Merged main into this branch to clear a CHANGELOG.md-only conflict; both sides' entries are kept. Every non-CHANGELOG file is byte-identical to the tested head f3fce7d5, whose full dispatch matrix passed, plus main. This will merge once this head's PR-event checks pass. The self-hosted dispatch is not re-run for a CHANGELOG-only change.

@TinDang97
TinDang97 merged commit 39e784e into main Sep 19, 2026
10 checks passed
TinDang97 added a commit that referenced this pull request Sep 19, 2026
…r; keep a serve that races the end of a wait

A multi-key BLPOP/BRPOP/BZPOPMIN/BZPOPMAX whose keys span shards could pop
the wrong key (the client's shard served a later local key over an earlier
non-empty remote one), or pop on two or three shards and deliver one reply,
destroying the rest (moon#1019). These commands keep working across shards,
as decided for untagged BLPOP q1 q2 q3 0 worker loops, and now answer as
standalone redis does, including the -WRONGTYPE ladder. Separately, a wait
that ended by timeout, disconnect or shutdown sent BlockCancel and dropped its
receivers, and a serve landing in between was dropped with them, element
included, because the owner undoes only a FAILED send (moon#1023).

One mechanism fixes both: a claim token (src/blocking/claim.rs), shared by
every registration of a waiter that lives on more than one thread.

  WAITING --try_claim (a shard, element already popped)--> CLAIMED
  WAITING --settle (the waiter gives up)-----------------> DEAD

Each terminal state is entered by exactly one CAS from WAITING, so exactly
one of "a shard serves" and "the waiter gives up" happens.

- Owners pop first and claim second, with the element in hand. On a lost
  claim the element goes back in the same synchronous stretch (WakeUndo), and
  the waker moves on to the next waiter. There is no "claimed but nothing to
  send" state to release. Errors (-WRONGTYPE at registration) are claimed
  too, so exactly one answer of any kind can exist.
- Wakers skip a settled waiter before touching the datastore
  (WaitEntry::is_settled), and keep serving a key's waiters while it still
  holds data. One push of several elements used to answer exactly one waiter
  (the review P3 on #989 in register_group). Redis 8.6.1 answers both
  waiters of RPUSH k a b.
- Argument order: immediate_scan now STOPS at the first remote key instead
  of skipping it. The remaining keys are registered one run of same-shard
  keys at a time, in argument order, and every run but the last is
  acknowledged before the next is sent. So each run is decided only after
  every earlier key's owner found it empty and of the right type, and that
  owner decides its run exactly as --shards 1 would. BlockRegisterGroup's
  whole_command flag is gone: every run is decisive. Later local runs go
  through the same register_group on the client's thread. Cost: one extra
  round trip per remote run that is not last. Co-located keys and single-key
  waits send exactly the messages they sent before.
- Settle (blocking_multikey::settle): when a wait ends without a reply, the
  waiter settles the token. DEAD means no shard served and none can, so the
  receivers are dropped as they are, with no drain needed. CLAIMED means the
  winner's reply is in flight on exactly one receiver. It is taken and then
  delivered on timeout or shutdown, or on peer-gone its element is restored
  to the key's owner (new ShardMessage::BlockRestore, re-woken for the next
  waiter). A waiter whose keys are all local carries no token. It drains
  after its synchronous remove_wait, which is exact on one thread, and its
  fast path is otherwise unchanged.

BLMPOP/BZMPOP spanning shards are still refused with CROSSSLOT, like
LMPOP/ZMPOP.

Evidence (macOS, release-fast, --shards 4). RED was measured on #1021's tip
plus the test hook; its blocking files are byte-identical to main's.
- tests/blocking_spanning_claim.rs, before -> after, monoio / tokio:
  immediate pops differing from redis  91/192, 95/192 -> 0/192 on both
  -WRONGTYPE ladder skipped            14/16,  10/16  -> 0/16
  concurrent pushes lost elements      39/80,  64/80  -> 0/80
  push racing a timeout lost it        13/16,  13/16  -> 0/16
  push racing a disconnect lost it     14/16,  14/16  -> 0/16
  GREEN 3/3 consecutive runs per runtime. bsc4 also asserts that at least
  2*(SHARDS-1) serves landed inside the window, so it cannot pass
  vacuously.
- Mutations, each turning its test RED: ignoring the claim (bsc1 83/192,
  bsc3 48/80); no run acknowledgement (bsc1 87/192, bsc2; bsc3 stays green
  because order and exactly-once are guarded separately); a no-op restore
  (bsc5 10/16); one waiter per push (bsc6 4/4).
- tests/loom_blocking_claim.rs: exhaustive under cfg(loom), and it fails
  when the model's claim is bypassed.
- The blocking suites are green on both runtimes. test-consistency.sh
  --shards 4 has 0 blocking failures. Spanning blpop/brpop/bzpopmin/bzpopmax
  rows moved from colo-only to span+colo, and a CROSSSLOT for them now fails
  the row.

Test hook: MOON_TEST_BLOCK_SETTLE_DELAY_MS, read once through a OnceLock,
the same pattern as MOON_TEST_AOF_FSYNC_STALL_MS.

Closes #1019
Closes #1023

author: Tin Dang
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BLMPOP/BZMPOP silently pop an element from a key they do not answer with at --shards >= 2 (co-located keys too, so not routing)

1 participant