fix(blocking): spanning multi-key pops serve exactly once in key order; a serve racing the end of a wait is never lost (moon#1019, moon#1023) - #1045
Conversation
|
Warning Review limit reachedNext included review available in 14 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (18)
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. Comment |
…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
…he client deadline, keep the put-back TTL Follow-up to the adversarial review of PR #1045. A committed serve whose client vanished is no longer put back. The pop itself is never logged when it happens: its AOF/replication record is written by the connection, from the reply. So a restore lands after whatever other clients wrote and logged in the meantime, on top of a pop the log never saw. For example, `RPUSH k b; LPOP k` left the master holding [a] while the AOF replayed [b], and a `DEL k` was undone. The serve now stands, as in redis. The connection gets BlockingOutcome::ServedPeerGone, records the effect and the tracking invalidation exactly as for a delivered reply, and closes. restore_and_rewake, BlockRestore and WakeUndo::from_reply are gone. The acknowledgement each cross-shard run waits for now races the client's deadline and shutdown, not only the 30 s internal bound. A stalled owner no longer stretches `BLPOP q1 q2 q3 1` past its timeout or answers MOONERR. Hitting the deadline is a normal timeout; shutdown gets the normal shutdown reply. A lost-claim or failed-send put-back re-applies the key's TTL when the pop had emptied (removed) the key. Previously the key came back persistent on the master while replicas expired it. The encoding is not preserved; the key is recreated in its natural encoding. A wake on a key that holds nothing answers nobody, instead of popping a parked waiter and answering it nil. The BlockRegister handler drops a registration whose claim is already settled. The local no-token drain is exact for any key count: it stops only after two consecutive idle polls, not after 1024 items. tests/loom_blocking_claim.rs now compiles the real src/blocking/claim.rs through #[path] (claim.rs takes loom atomics under cfg(loom), declared in build.rs). It adds a won-claim-but-send-fails model. The waker-side claim tests moved to src/blocking/claim_wake_tests.rs, so claim.rs stays self-contained. tests/blocking_spanning_claim.rs resolves the server through common::find_moon_binary, so MOON_BIN is honoured. RED -> GREEN (macOS, --shards 4, pinned binaries): - bsc8, a disconnected serve undone over later writes: 6/6 -> 0/6. - bsc7, `BLPOP k1 k2 k3 0.5` behind a 3 s owner stall: 3.0 s / 6.0 s -> nil in < 1.5 s, both runtimes. - A lost-claim put-back dropped the TTL -> kept (unit, list and zset). - A wake on an absent key answered a parked waiter nil -> answers nobody (unit). - Loom with a check-then-set try_claim: all 3 models fail. With the failed-send put-back removed: the send-fails model fails. Not fixed here, pre-existing on origin/main: the effect record of any cross-shard blocking serve, delivered or not, is appended to the connection's shard AOF, and replay drops it. A delivered cross-shard BLPOP diverges from its AOF on main (3/4 keys). author: Tin Dang
ffacf2e to
37a4def
Compare
…ration of a timed-out waiter A list or zset waker that pops a waiter and then finds nothing to pop no longer answers it nil. This happens when the key now holds another type or is empty. For example, `BZPOPMIN k 0` parks, then `RPUSH k x y`, then a remote `BLPOP k 0` registers and runs every waker on `k`. The zset waker answered the BZPOPMIN client nil, a reply redis never sends a timeout-0 waiter. The waker now puts the waiter back at the front of its queue, unanswered, and stops. BlockingRegistry::requeue_front undoes the pop_front_of_family; wait_keys and the deadline heap were never touched. deliver() now takes the frame by value: nothing reaches it without one. expire_timed_out now takes a timed-out waiter off every key it sits on, through remove_wait. Before, it removed only the entries it swept and then forgot the id. A spanning wait's later local run is registered through register_group without the client's deadline, so that entry could never be swept. Once wait_keys lost the id, neither remove_wait nor the connection's cancel fan-out could find it, and it stayed parked until its key was pushed again. Review finding codes in comments and tests are replaced by the invariants they stood for. The docs on a gone client's serve now say what its record does NOT guarantee: - for a key another shard owns, replay drops the record (moon#1056); - the tokio handler appends it to the AOF only, never to replication. RED -> GREEN (unit, release-fast): - expiry_removes_undeadlined_sibling_registrations: the undeadlined sibling outlived its timed-out waiter -> removed. - a_waiter_of_another_family_is_left_parked_on_a_wrong_typed_key and every_waiter_of_another_family_stays_parked_in_order: the waiter was answered from a key of another type -> left parked, FIFO intact. Refs #1019 Refs #1023 Refs #1056 author: Tin Dang
|
Marked ready. Both review rounds are addressed: the first round's findings were fixed earlier, and the second round's P2 (timed-out siblings left registered), wrong-family nil, finding-code scrub and CHANGELOG wording were fixed at 8088557, with red→green unit tests. Full dispatch run 35421740138 is green. Follow-ups tracked elsewhere: #1056 (cross-shard pop logged on the waiter's shard) and #1059 (BLMOVE wake doesn't reach destination waiters). |
|
ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing |
Closes #1019. Closes #1023.
Decision implemented (from the owner): spanning
BLPOP/BRPOP/BZPOPMIN/BZPOPMAXkeep working across shards and are not refused with CROSSSLOT. An untaggedBLPOP q1 q2 q3 0worker loop at--shards > 1answers the way standalone redis does. SpanningBLMPOP/BZMPOPare still refused with CROSSSLOT (from #1021, matchingLMPOP/ZMPOP).The claim protocol
One token per waiter that is registered on more than one thread:
src/blocking/claim.rs,Arc<AtomicU8>.Who does a CAS, and when
register_group) first skip a settled waiter without touching the datastore (WaitEntry::is_settled). Otherwise they pop first and claim second, with the element in hand. If the owner wins, it sends the reply in the same synchronous stretch. If it loses,WakeUndo::restoreputs the element back in that same stretch, where no other client can observe the round trip, and the waker moves on to the next waiter. Errors (-WRONGTYPE, and XREADGROUP's errors at registration) are answered only on a won claim too. So the invariant is: nobody sendsSome(..)without winning, and at most one answer of any kind exists."A winner that finds the key empty" cannot happen here. The claim is attempted only after a successful pop, so there is no claimed-but-empty state to release and no retry protocol. That is what keeps the machine at three states with one CAS per side. The alternative, claim-then-pop, needs a
CLAIMED → WAITINGrelease, and a waiter settling during that window would have to spin. I rejected it.The waker's one-waiter-per-push policy, and a lost claim. A lost claim is treated like a dead waiter:
continueto the next waiter, notreturn. The pushed element is never spent on a waiter another shard already served. In addition, both destructive wakers now keep serving the key's waiters while it still has data. This also closes the review P3 inregister_group, where each key was offered to the waker once. Separately from the claim, oneRPUSH k a bused to answer one of two parked waiters and leave the other next tobuntil its timeout. Redis 8.6.1 answers both; this was verified with tworedis-cliwaiters on a liveredis-server.Argument order across shards (#1019, immediate path)
immediate_scannow stops at the first key this shard does not own, instead of skipping it. The remaining keys are cut into runs of consecutive same-owner keys and registered one run at a time, in argument order. Every run except the last is acknowledged (a newackinBlockRegisterGroupPayload) before the next is sent, and registration stops early once the token is claimed. So a run is decided only after every earlier key's owner found that key empty and of the right type, and the owner can apply redis's full ladder,-WRONGTYPEincluded. Thewhole_commandflag is gone because every run is now decisive. A later local run goes through the sameregister_groupon the client's own thread.The ack is also what keeps later arrivals linearizable. If an earlier key receives data after its owner looked, that owner serves the waiter itself, since the waiter is registered there. Because a push and its wake run in one synchronous stretch, it claims at the instant of the push, so a later owner's claim fails and that owner puts its element back.
Cost: one extra round trip per remote run that is not the last. Co-located keys (one run) and single-key waits send exactly the messages they sent before. A waiter whose keys are all local carries no token and keeps its fast path.
Alternative considered: a coordinator shard that probes the owners sequentially. It needs the same N round trips plus a second hop for every wake, and it still needs something like the token to arbitrate concurrent wakes. The shared claim needs neither.
Settling a wait that ends without a reply (#1023)
blocking_multikey::settle, used by both runtimes, the single-key and multi-key paths:DEAD: no shard served and none ever can. Every receiver is dropped as is. This is why the drain the issue asked for is unnecessary with a token: afterDEAD, nothing of value can be buffered or arrive, because every send ofSomefollows a won claim.CLAIMED: the winner's reply is in flight on exactly one receiver. It is taken (without a timer if it is already buffered, otherwise bounded byXSHARD_REPLY_TIMEOUT), and then:BlockingOutcome::ServedPeerGone, runs the tracking invalidation and theblocking_effectAOF/replication record, and closes. This is redis's behaviour: redis pops and propagates when it serves, and a client that disconnects with the reply in its output buffer loses it. The first cut restored the element instead (WakeUndo::from_reply+ShardMessage::BlockRestore); both are removed.remove_waitruns in the same synchronous stretch that ended the wait, then an exactnow_or_neverdrain. This also covers the local case the issue did not name:select!can pick the timer over a receiver that already holds a reply.Second-round follow-up (head
80885575)The fixes are in
c2b78584, then origin/main was merged in (a merge commit, no force-push). Each code fix was RED on the unfixed code and GREEN after (unit tests,release-fast):expiry_removes_undeadlined_sibling_registrations: "the undeadlined sibling registration outlived its timed-out waiter"expire_timed_outnow callsremove_waitfor every timed-out id, so every registration of that waiter goes, including those with no deadlinea_waiter_of_another_family_is_left_parked_on_a_wrong_typed_key: "zset waiter on a list: the waiter was answered from a key of another type";every_waiter_of_another_family_stays_parked_in_orderalso REDBlockingRegistry::requeue_front) unanswered, then stops. FIFO is intact.delivernow takes theFrameby value, so the path that answeredNoneis gone. This covers a wrong-typed key, an absent key and a cold miss alike, with no type probe on the push path. The stream waker already decides before it pops, so it needed no change.finish_unservedandBlockingOutcome::ServedPeerGonesaid it "keeps one history"runtime-tokioit is appended to the AOF only, never to replication. Verified inhandler_sharded/mod.rs(onlyaof_pool, norecord_local_write).Out of scope, as instructed: the unbounded ack wait for timeout-0, the env-var stall hook, loom being run by hand, and the BLMOVE destination wake.
Gates at
80885575(after the merge):cargo fmt --checkpasses.cargo clippy --all-targets -D warningspasses on monoio and on tokio.--libblocking unit tests pass: 133 on monoio (filterblocking bsc claim std_), 93 on tokio (filterblocking).blocking_*suite,blocking_spanning_claim(8/8) andloom_blocking_claimpass on both runtimes, against pinnedrelease-fastbinaries (MOON_BIN).Review follow-up (head
37a4def5)Fixes for the adversarial review of
ffacf2ef. Measured on macOS,--shards 4, with pinned binaries.tests/blocking_spanning_claim.rsused to hard-codeCARGO_BIN_EXE_moonand ignoreMOON_BIN; it now goes throughcommon::find_moon_binary.BLPOP k 0with nila_wake_on_an_absent_key_answers_nobody: "list: a parked waiter was answered from an absent key"!db.exists(key)BLPOP k1 k2 k3 0.5, owner stalled 3 s byMOON_TEST_BLOCK_ACK_STALL_MS): answered after 3.009 s / 6.025 s (monoio), 3.003 s / 6.022 s (tokio)await_run_ackraces the client deadline (→WaitEnd::Timeout) and shutdown (→ the normal shutdown reply).register_runsreturnsResult<(), WaitEnd>and the caller settles it like any other endffacf2efbinary: 6/6 served-then-disconnected waiters hadaput back over later writes. WithRPUSH b; LPOPthe master held[a](the AOF replays[b]); aDELwas undonea_lost_claim_put_back_keeps_the_ttl: "the put-back dropped the TTL" (leftNone)deliver. Also covered: a won claim whose send fails puts back with the TTL; a surviving key's TTL is left alone. Encoding is not preserved: the key is recreated in its natural encodingclaim.rsClaimTokenvia#[path], plus the won-claim-send-fails model; mutations above fail itBlockRegisterfor a settled claimreturnif!claim.is_open()F3 — which semantics, and why. I chose redis semantics: the serve stands and is logged like a delivered one. I did not make the restore a logged write, because that cannot give one history. The owner's pop is never logged when it happens; its record is written later by the connection, from the reply. A restore lands after whatever other clients wrote and logged meanwhile, on top of a pop the log never saw. Logging the restore only adds a record to a history that is already missing the pop.
Not fixed here — pre-existing on origin/main, filed as moon#1056. While measuring F3 against the AOF I found that the
blocking_effectrecord (moon#827) of any cross-shard blocking serve is appended to the connection's shard AOF, not the key owner's, and per-shard replay drops it.Repro on origin/main
75375155, pinned binary:--shards 4 --appendonly yes --appendfsync always.BLPOP k 0, delivered byRPUSH k a; thenRPUSH k b; LPOP k.kill -9and restart.The master held
[]; the replay gave[b]for the 3 keys on other shards. So every delivered cross-shardBLPOPcomes back after a restart. There is a related ordering gap even on one shard: the record is written after the connection receives the reply, so a third party's write in between can be logged first. For example, afterLPUSH cthe master holds[c,b]and the replay[a,b]. For the same reason, bsc8 asserts the master, not the AOF. Fixing this means logging the pop on the owner, in the stretch that pops. That touches every wake site and the moon#827 record path, which is out of scope for this PR.Other limits:
Resultplumbing, not by an integration test.BLPOP … 0), the ack wait is no longer capped at 30 s. It ends on the ack, on a dropped ack sender (→RegisterFailed), or on shutdown.Gates at
37a4def5:cargo fmt --checkandcargo clippy --all-targets -D warningspass on both runtimes.blocking_*suite andloom_blocking_claimpass on both runtimes; the spanning suite passes 8/8.cfg(loom): 3/3.scripts/test-consistency.sh --shards 4(monoio): 1185/1191. The same 6 non-blocking rows fail on origin/main (5tracking:controls andROLE on a master). Main also fails the 4moon#962 … spanrows that this PR fixes.Evidence
All on native macOS (aarch64),
release-fast. Nothing here was measured on Linux. io_uring andSO_REUSEPORTplacement change which connection lands where, but not the protocol.RED → GREEN,
tests/blocking_spanning_claim.rs,--shards 4. RED was measured on #1021's tip plus the test hook only; its blocking files are byte-identical to main's.-WRONGTYPEladder skippedRPUSH k a b, two waitersGREEN held on 3 consecutive runs per runtime (parallel test threads). The window in bsc4/bsc5 comes from
MOON_TEST_BLOCK_SETTLE_DELAY_MS, read once through aOnceLock, following theMOON_TEST_AOF_FSYNC_STALL_MSpattern.Every new guard fails when removed:
deliver: bsc1 83/192, bsc3 48/80.restore_undeliveredas a no-op (first cut, since removed): bsc5 10/16.Loom:
tests/loom_blocking_claim.rscompiles the REALsrc/blocking/claim.rsthrough#[path]; undercfg(loom)that file takes loom'sArc/AtomicU8, andbuild.rsdeclares the cfg. Three models: two owners (pop, claim, send-or-undo) racing a waiter that settles; two owners serving a live waiter; and a won claim whose send fails because the receiver closed without a settle, which must put its element back. Checked: at most one send;DEAD⇒ no send ever, including afterwards;CLAIMED⇒ exactly one reply taken; conservation. Exhaustive undercfg(loom)(3/3 pass). Iftry_claimin claim.rs is changed from a CAS to load-then-store, all 3 models fail. If the failed-send put-back is removed, the send-fails model fails. Run it withcargo rustc --profile release-fast --test loom_blocking_claim -- --cfg loom, then run the built binary.RUSTFLAGS=--cfg loomleaks intohyper-utiland does not build.Gates, both runtimes:
cargo clippy --all-targets -D warningsandcargo fmt --checkare clean.cargo clippy --all-targets -D warningspasses after rebasing onto75375155.blocking_{exec_wakeup,ghost_waiter,multikey_cross_shard,peer_eof,pop_propagation_827,spanning_claim,stream_read,waiter_cannibalisation,wrongtype_immediate}andloom_blocking_claim.--libblocking unit tests (filterblocking): 108 on monoio, 122 on tokio.--lib: 1 failure on each runtime,gate_is_skipped_with_spill_sender_when_no_limit_is_configured(lib testgate_is_skipped_with_spill_sender_when_no_limit_is_configuredfails on main under parallel execution #856). It fails the same way on fix(blocking): multi-key blocking pops serve exactly once; refuse spanning BLMPOP/BZMPOP (moon#989) #1021 and passes in isolation.blocking_list_timeout -- --ignored(it needs a server on :16479): 5/5 at--shards 1. At--shards 4,brpoplpush_legacy_aliasgets moon#570'sCROSSSLOT(untagged src and dst on different shards; I confirmed the reply with redis-cli). That refusal runs before anything this PR touches.scripts/test-consistency.sh --shards 4against redis 8.6.1: 0 blocking failures on either binary. The spanningblpop/brpop/bzpopmin/bzpopmaxrows moved from colo-only tospan+colo, and aCROSSSLOTfor them now fails the row (mk_must_answer). The failures that remain are the same 6 fix(blocking): multi-key blocking pops serve exactly once; refuse spanning BLMPOP/BZMPOP (moon#989) #1021 recorded (5tracking:controls andROLE on a master). The tokio binary also fails 6 graph/text rows, which is expected because that leg builds withoutgraph/text-index.Dispatch paths
command::dispatch/dispatch_read: not reached, because blocking commands are intercepted by the connection handlers.blocking_txn, which does not useimmediate_scan. It is unchanged.try_inline_dispatch: GET/SET only.handle_blocking_command*share the staging, registration (register_runs) and settle code inblocking_multikey.rs.Hygiene
unsafe.unwrap/expectin library code.src/command,src/protocol,src/shard/event_loop.rsorsrc/io.Arc<Mutex>.src/server/conn/blocking.rs(already over the cap on main) grows by 26 lines net. The new helpers live inblocking_multikey.rs(676 lines). The waker-side claim tests live insrc/blocking/claim_wake_tests.rs, soclaim.rsstays self-contained for loom.ci-local.shwas not run: this sandboxed session has no moon-dev VM. The hosted matrix is dispatched.