Skip to content

fix(blocking): stop cross-shard BLPOP from destroying the next push (c10k A2/A3/A4/A5/A6) - #426

Open
TinDang97 wants to merge 2 commits into
mainfrom
fix/c10k-hardening-blocking-registry
Open

fix(blocking): stop cross-shard BLPOP from destroying the next push (c10k A2/A3/A4/A5/A6)#426
TinDang97 wants to merge 2 commits into
mainfrom
fix/c10k-hardening-blocking-registry

Conversation

@TinDang97

@TinDang97 TinDang97 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

Cluster A (wave 1) of the c10k hardening review. A timed-out cross-shard
blocking command was silently eating the next push to that key at
--shards > 1, and the cross-shard registration plane could drop control
messages or retry them forever.

The bugs

A3 — inverted cleanup condition (data loss root cause)

src/server/conn/blocking.rs, tokio single-key path:

if is_remote && !matches!(result, Frame::Null) || matches!(result, Frame::Error(_))

Rust parses this as (is_remote && !Null) || Error. Two consequences:

  • On a timeout the result is Frame::Null, so the BlockCancel was
    never sent — the owning shard kept the WaitEntry forever. Remote
    registrations carry deadline: None, so the W6 deadline sweep never reaps
    them either. Permanent ghost waiter.
  • On a local waiter ending in shutdown (Frame::Error) it took the remote
    branch anyway and computed target_index(shard, shard) — which underflows
    to a panic on shard 0, and misroutes the cancel to an unrelated shard
    otherwise.

The monoio twin always had the correct if is_remote, so this reproduces
only under runtime-tokio.

A2 — the wake path then destroyed the element

Every wake path popped first and did let _ = reply_tx.send(...) second. For
a ghost waiter the receiver is long gone, so the popped value was neither
delivered, nor requeued, nor offered to the next FIFO waiter. Redis
guarantees delivered-or-stays.

Fixed with two defences, because neither alone is sufficient:

  1. waiters whose client has disconnected are skipped before the datastore
    is touched (OneshotSender::is_disconnected), so the common case causes no
    mutation at all;
  2. anything popped in the remaining race window is restored — including
    BLMOVE's destination push (both halves reversed) and BLMPOP's
    multi-element pops, in original order.

XRead needs no undo (non-destructive range read). XReadGroup leaves the
entries in the stream and only records them in the PEL for a consumer that
vanished — which is exactly Redis's dead-consumer semantics, recoverable via
XAUTOCLAIM — so it takes the liveness pre-check only.

A4 / A5 / A6 — dispatch integrity

BlockRegister / BlockCancel were pushed with a bare try_push (tokio) or
an uncapped loop { try_push; sleep(10us) } with no shutdown arm (monoio):

  • a dropped BlockRegister drops the reply sender with it, so BLPOP key 0
    returned nil at t=0 — a protocol violation;
  • a dropped BlockCancel leaked the owner-side waiter — the very ghost A2
    then had to defend against;
  • the uncapped retry turned a saturated owner shard into a zombie connection
    that also blocked graceful shutdown.

All four call sites now share one bounded, shutdown-aware helper using the
plane-wide CROSS_SHARD_PUSH_MAX_RETRIES / CROSS_SHARD_PUSH_BACKOFF
budget (not a tighter blocking-specific one — the old monoio path retried
forever, so the only safe direction is toward the existing bound), notify the
owning shard on success, and fail loudly (MOONERR blocking registration failed) rather than blocking on a key nobody is watching.

Red/green evidence

Both test layers were verified failing first, not just passing after.

  • 5 unit tests in src/blocking/wakeup.rs (BLPOP / BLMOVE / BLMPOP /
    BZPOPMIN + dead-head-yields-to-live-waiter). All 5 failed pre-fix, e.g.
    dead_list_waiter_does_not_consume_element ... a dead waiter is not a wakeup.

  • tests/blocking_ghost_waiter.rs drives a real 4-shard server over raw
    RESP. With the three defences temporarily reverted:

    ghost:1: element was swallowed by a ghost waiter — LLEN ":0\r\n"
    

    Its positive control (woken_cross_shard_blpop_consumes_exactly_one_element)
    stayed green throughout that run, so the suite is not failing
    incidentally — and it proves the fix wasn't implemented by simply never
    delivering.

    16 distinct keys are used because a connection is pinned to one shard by
    SO_REUSEPORT; with 4 shards most keys hash elsewhere, which is the only
    configuration that reproduces A3.

Gates

  • cargo fmt --check
  • cargo clippy --all-targets — monoio and runtime-tokio,jemalloc, zero warnings
  • lib tests: 4449 monoio / 3614 tokio
  • --test integration: 108 tokio
  • new e2e: green on both runtimes

Not in this PR

A1 — immortal blocked clients, the unauthenticated permanent-maxclients
DoS in the same cluster (BLPOP k 0 + RST leaks the handler task, the
WaitEntry, the registry entry and the slot, unreapable by disconnect,
CLIENT KILL or timeout). The agreed fix adds a read-readiness select arm
that performs a real read and carries any pipelined bytes back to the
handler's read_buf — that threads through both handlers and is its own
change. Tracked separately.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed cross-shard blocking list operations so timeouts no longer swallow the next pushed element.
    • When a blocked client disconnects, waking no longer consumes or strands list/sorted-set entries.
    • Blocking operations are now shutdown- and disconnect-aware, preventing silent drops or infinite retries.
    • Multi-key blocking registration/cancellation now reports undeliverable cases more reliably to avoid leaked waiters.
  • Documentation
    • Updated the unreleased changelog to reflect these c10k hardening improvements.

…c10k A2/A3/A4/A5/A6)

Cluster A of the c10k hardening review (tmp/C10K-HARDENING-REVIEW.md). Two
compounding defects made a timed-out cross-shard blocking command silently
eat the next push to that key at --shards > 1.

A3 — inverted cleanup condition. The tokio single-key path read

    if is_remote && !matches!(result, Frame::Null) || matches!(result, Frame::Error(_))

which Rust parses as `(is_remote && !Null) || Error`. On a TIMEOUT the result
IS Frame::Null, so the BlockCancel was never sent and the owning shard kept
the WaitEntry forever — remote registrations carry `deadline: None`, so the
W6 deadline sweep never reaps them either. The same precedence bug drove a
LOCAL waiter's shutdown cleanup into the remote branch, computing
`target_index(shard, shard)`, which underflows to a panic on shard 0 and
misroutes the cancel to an unrelated shard otherwise. The monoio twin always
had the correct `if is_remote`.

A2 — the wake path then destroyed the element. Every wake popped first and
did `let _ = reply_tx.send(...)` second; for a ghost waiter the receiver is
gone, so the value was neither delivered, nor requeued, nor offered to the
next FIFO waiter. Redis guarantees delivered-or-stays. Fixed with two
defences: waiters whose client has disconnected are skipped BEFORE the
datastore is touched, and anything popped in the remaining race window is
restored — including BLMOVE's destination push and BLMPOP's multi-element
pops, in original order. XRead needs no undo (non-destructive) and
XReadGroup's PEL entry matches Redis's dead-consumer semantics.

A4/A5/A6 — dispatch integrity. BlockRegister/BlockCancel were pushed with a
bare try_push (tokio) or an uncapped `loop { try_push; sleep(10us) }` with no
shutdown arm (monoio). A dropped BlockRegister drops the reply sender with
it, so `BLPOP key 0` returned nil at t=0 (protocol violation); a dropped
BlockCancel leaked the owner-side waiter — the very ghost A2 then had to
defend against; and the uncapped retry turned a saturated owner shard into a
zombie connection that also blocked graceful shutdown. All four call sites
now share one bounded, shutdown-aware helper on the plane-wide
CROSS_SHARD_PUSH budget, notify the owning shard on success, and fail the
command loudly rather than blocking on a key nobody is watching.

Tests (red/green, both verified failing first):
  - 5 unit tests in src/blocking/wakeup.rs covering BLPOP/BLMOVE/BLMPOP/
    BZPOPMIN and dead-head-yields-to-live-waiter. All 5 failed pre-fix.
  - tests/blocking_ghost_waiter.rs drives a real 4-shard server. With the
    three defences temporarily reverted it fails with
    "ghost:1: element was swallowed by a ghost waiter — LLEN :0";
    its positive control (a woken waiter consumes exactly one element)
    stays green throughout, so the suite is not failing incidentally.

Gates: cargo fmt --check; clippy --all-targets on monoio and on
runtime-tokio,jemalloc (zero warnings); lib tests 4449 monoio / 3614 tokio;
the new e2e green on both runtimes.

A1 (immortal blocked clients — the unauthenticated permanent-maxclients DoS
in the same cluster) is deliberately NOT in this commit: the only safe fix
threads a read-readiness arm and a byte-carry through both handlers, which
is its own change.

author: Tin Dang
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8db2f37b-73d1-46dd-94b1-3423afebd392

📥 Commits

Reviewing files that changed from the base of the PR and between 3bf66a4 and 26c71d4.

📒 Files selected for processing (2)
  • src/server/conn/blocking.rs
  • tests/blocking_ghost_waiter.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/blocking_ghost_waiter.rs
  • src/server/conn/blocking.rs

📝 Walkthrough

Walkthrough

The PR hardens blocked-client wakeups by preserving popped data for disconnected receivers and adds shutdown-aware cross-shard blocking registration and cancellation for tokio and monoio paths. Unit and integration tests cover dead waiters and cross-shard BLPOP behavior.

Changes

Blocking waiter hardening

Layer / File(s) Summary
Disconnected waiter wakeup restoration
src/runtime/channel.rs, src/storage/db/accessors.rs, src/blocking/wakeup.rs
Adds receiver-disconnection detection, restores list and sorted-set data after failed wake delivery, preserves stream behavior, and tests dead waiter scenarios.
Shutdown-aware cross-shard registration
src/server/conn/blocking.rs
Adds shutdown-aware BlockRegister and BlockCancel delivery, staged multi-key registration, explicit failure responses, and centralized cleanup for tokio and monoio.
Cross-shard regression coverage and release metadata
tests/blocking_ghost_waiter.rs, CHANGELOG.md, .gitignore
Adds real-server tests for timed-out and live cross-shard BLPOP, records the fixes, and ignores target-tokio2 build output.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant BlockingCommand
  participant RemoteShard
  participant Wakeup
  participant Database

  Client->>BlockingCommand: issue cross-shard blocking command
  BlockingCommand->>RemoteShard: deliver BlockRegister
  RemoteShard-->>BlockingCommand: register waiter
  RemoteShard->>Wakeup: notify matching waiter
  Wakeup->>Database: remove list or sorted-set data
  Wakeup-->>Client: send wake response
  Wakeup->>Database: restore data if receiver is disconnected
  BlockingCommand->>RemoteShard: deliver BlockCancel on timeout or shutdown
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: preventing cross-shard BLPOP from dropping the next push.
Description check ✅ Passed The description covers the bug, fix scope, testing evidence, and exclusions, even if its headings differ from the template.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/c10k-hardening-blocking-registry

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.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix cross-shard blocking waiter leaks and wakeup data loss (c10k A2–A6)

🐞 Bug fix 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Prevent timed-out cross-shard blocking waiters from persisting and corrupting later wakeups.
• Make wakeups skip dead clients and restore popped data on send-races to avoid silent loss.
• Harden cross-shard BlockRegister/BlockCancel delivery with bounded backpressure and loud failure.
Diagram

graph TD
  C["Client"] --> H["Blocking cmd handler"] --> D["Cross-shard dispatch"] --> O["Owning shard loop"] --> R["Blocking registry"] --> W["Wakeup + undo"] --> DB[("Datastore")]
  H -- "bounded push" --> D
  W -- "restore on dead waiter" --> DB
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Always assign deadlines to remote WaitEntry registrations
  • ➕ Prevents permanent remote waiter leaks even if cancels are dropped
  • ➕ Provides a second safety net beyond cancel delivery
  • ➖ Requires choosing a correct deadline semantics for 'infinite' blocking commands
  • ➖ Still does not address the 'pop then failed send' data-loss race by itself
2. Owner-shard delivery ack before mutating datastore
  • ➕ Eliminates the need for undo by ensuring the client is definitely live before pop
  • ➕ Makes wake semantics closer to a transactional 'claim'
  • ➖ Requires new cross-shard handshake/ack protocol (latency + complexity)
  • ➖ More moving parts in the hottest path (wakeups) than the current local-only undo
3. Use a dedicated async channel per shard for blocking control messages
  • ➕ Simplifies backpressure semantics (awaiting send) and makes delivery explicit
  • ➕ Avoids manual retry loops/try_push failure handling
  • ➖ Bigger architectural change to the shard messaging plane
  • ➖ Potentially higher overhead than the existing SPSC ring approach

Recommendation: The PR’s approach (bounded, shutdown-aware pushes for control messages + wake-side liveness precheck + undo on residual races) is the best minimal fix: it directly enforces Redis’s delivered-or-stays guarantee without introducing a new cross-shard protocol. Consider adding remote deadlines later as a belt-and-suspenders measure, but it should complement—not replace—the undo/liveness protections.

Files changed (7) +983 / -191

Enhancement (2) +29 / -0
channel.rsExpose oneshot receiver liveness via is_disconnected +11/-0

Expose oneshot receiver liveness via is_disconnected

• Adds OneshotSender::is_disconnected to allow callers to detect abandoned receivers and avoid performing destructive work that would inevitably be dropped.

src/runtime/channel.rs

accessors.rsAdd zset_restore to support wakeup undo semantics +18/-0

Add zset_restore to support wakeup undo semantics

• Implements Database::zset_restore as the inverse of zset_pop_min/max so the wakeup path can reinsert popped (member, score) pairs without distorting memory accounting across a pop/restore cycle.

src/storage/db/accessors.rs

Bug fix (2) +700 / -191
wakeup.rsSkip dead waiters and restore popped data on failed wake delivery +411/-77

Skip dead waiters and restore popped data on failed wake delivery

• Introduces a WakeUndo mechanism to restore list/zset mutations (including BLMOVE and multi-pop variants) when a wake cannot be delivered. Adds pre-pop liveness checks via oneshot disconnection detection and extensive unit tests covering dead-waiter scenarios and FIFO correctness.

src/blocking/wakeup.rs

blocking.rsFix remote cleanup condition and harden cross-shard BlockRegister/BlockCancel pushes +289/-114

Fix remote cleanup condition and harden cross-shard BlockRegister/BlockCancel pushes

• Fixes a boolean-precedence bug in the tokio single-key cleanup path that prevented remote cancel on timeout and could misroute/panic on local shutdown cleanup. Centralizes blocking control-plane sending into a bounded, shutdown-aware push helper, stages multikey remote registrations to avoid borrow/await conflicts, and fails the command with a clear error if remote registration cannot be delivered.

src/server/conn/blocking.rs

Tests (1) +222 / -0
blocking_ghost_waiter.rsEnd-to-end regression tests for cross-shard ghost waiters and BLPOP invariants +222/-0

End-to-end regression tests for cross-shard ghost waiters and BLPOP invariants

• Adds an integration test that reproduces the timed-out cross-shard BLPOP ghost-waiter scenario and asserts subsequent RPUSH does not lose data, plus a complementary test that a correctly woken BLPOP consumes exactly one element.

tests/blocking_ghost_waiter.rs

Documentation (1) +31 / -0
CHANGELOG.mdDocument fixes for cross-shard blocking data loss and dispatch integrity +31/-0

Document fixes for cross-shard blocking data loss and dispatch integrity

• Adds detailed Unreleased 'Fixed' entries describing the root causes and user-visible impact of A2/A3 (data loss) and A4/A5/A6 (registration/cancel integrity) and the new failure behavior on registration failure.

CHANGELOG.md

Other (1) +1 / -0
.gitignoreIgnore additional tokio build output directory +1/-0

Ignore additional tokio build output directory

• Adds /target-tokio2/ to gitignore to avoid committing build artifacts from an additional tokio target output path.

.gitignore

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
src/server/conn/blocking.rs (2)

106-127: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Cleanup fans out cancels serially, each with the full ~0.5 s retry budget.

cancel_multikey_registrations runs on the client's response path (both the end-of-command path and the registration-failure path). With N distinct remote owners wedged, the reply is delayed by up to N × CROSS_SHARD_PUSH_MAX_RETRIES × CROSS_SHARD_PUSH_BACKOFF. Consider issuing the cancels concurrently (e.g. futures::future::join_all) so the worst case stays one budget rather than N.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/conn/blocking.rs` around lines 106 - 127, Update
cancel_multikey_registrations to dispatch BlockCancel messages to all
registered_remote_shards concurrently rather than awaiting each push_msg
sequentially. Preserve the existing arguments, notifier selection, and retry
behavior for each push, while ensuring the overall cleanup waits for the
concurrent operations as one batch.

400-405: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Failure path cancels shards that were never registered.

registered_remote_shards is populated during staging, so after break it also names owners whose BlockRegister was never delivered. Those cancels are no-ops on the owner but each still consumes a full push budget. Track successful pushes instead.

♻️ Track delivered targets
-    let mut registration_failed = false;
-    for (target, msg) in pending_remote {
-        if !push_block_msg(shutdown, dispatch_tx, shard_id, target, msg, None).await {
-            registration_failed = true;
-            break;
-        }
-    }
+    let mut registration_failed = false;
+    let mut delivered_remote_shards: Vec<usize> = Vec::new();
+    for (target, msg) in pending_remote {
+        if !push_block_msg(shutdown, dispatch_tx, shard_id, target, msg, None).await {
+            registration_failed = true;
+            break;
+        }
+        if !delivered_remote_shards.contains(&target) {
+            delivered_remote_shards.push(target);
+        }
+    }
Note the same pattern exists in the monoio twin at Lines 679-693.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/conn/blocking.rs` around lines 400 - 405, Update the failure
cleanup around the pending_remote push loop in the blocking connection flow to
track only targets for which push_block_msg returned true, rather than using the
staging-derived registered_remote_shards set. Use this successfully
delivered-target collection when issuing cancellation pushes, and apply the same
correction in the monoio twin.
tests/blocking_ghost_waiter.rs (1)

132-137: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Tighten integer-reply assertions.

starts_with(":1") also matches :10/:12, so an over-count would pass. Assert ":1\r\n".

Also applies to: 208-213

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/blocking_ghost_waiter.rs` around lines 132 - 137, Update the
integer-reply assertions in the LLEN checks near the shown assertion and the
corresponding occurrence to require an exact ":1\r\n" reply instead of using
starts_with(":1"), preventing over-counts such as :10 or :12 from passing.
src/blocking/wakeup.rs (1)

596-614: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Test seeds the zset with the very API it validates.

dead_zset_waiter_does_not_consume_member uses zset_restore as the fixture, so a broken zset_restore could make both the seed and the assertion agree. Prefer a normal ZADD-equivalent insertion path (e.g. get_or_create_sorted_set + insert) for setup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/blocking/wakeup.rs` around lines 596 - 614, The test fixture in
dead_zset_waiter_does_not_consume_member should avoid using zset_restore, since
that is the behavior being indirectly validated. Seed the sorted set through the
normal ZADD-equivalent path using get_or_create_sorted_set and its insertion
API, then keep the existing wake and pop assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/conn/blocking.rs`:
- Line 268: Align the tokio `push_block_msg` handling in both the normal path
and the cancel path around lines 334–341 with its actual wake behavior: either
add a task-local `Notify`/`ParkIdle` receive path to the tokio shard loop and
pass it through, or revise the A5 note to state that tokio uses the non-eventfd
drain/timer wake instead of `notify_one()`. Ensure both call sites document and
implement the same chosen behavior.

In `@tests/blocking_ghost_waiter.rs`:
- Around line 51-58: Update read_reply to accumulate data across multiple
TcpStream reads instead of assuming one read contains the complete response. Add
a small resp_is_complete helper that recognizes completion of the expected RESP
reply, including the required bulk elements, and stop reading only once complete
while preserving the socket timeout behavior.

---

Nitpick comments:
In `@src/blocking/wakeup.rs`:
- Around line 596-614: The test fixture in
dead_zset_waiter_does_not_consume_member should avoid using zset_restore, since
that is the behavior being indirectly validated. Seed the sorted set through the
normal ZADD-equivalent path using get_or_create_sorted_set and its insertion
API, then keep the existing wake and pop assertions unchanged.

In `@src/server/conn/blocking.rs`:
- Around line 106-127: Update cancel_multikey_registrations to dispatch
BlockCancel messages to all registered_remote_shards concurrently rather than
awaiting each push_msg sequentially. Preserve the existing arguments, notifier
selection, and retry behavior for each push, while ensuring the overall cleanup
waits for the concurrent operations as one batch.
- Around line 400-405: Update the failure cleanup around the pending_remote push
loop in the blocking connection flow to track only targets for which
push_block_msg returned true, rather than using the staging-derived
registered_remote_shards set. Use this successfully delivered-target collection
when issuing cancellation pushes, and apply the same correction in the monoio
twin.

In `@tests/blocking_ghost_waiter.rs`:
- Around line 132-137: Update the integer-reply assertions in the LLEN checks
near the shown assertion and the corresponding occurrence to require an exact
":1\r\n" reply instead of using starts_with(":1"), preventing over-counts such
as :10 or :12 from passing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7dd83cd9-48e4-4a56-b414-ee16f39be20c

📥 Commits

Reviewing files that changed from the base of the PR and between a7b6174 and 3bf66a4.

📒 Files selected for processing (7)
  • .gitignore
  • CHANGELOG.md
  • src/blocking/wakeup.rs
  • src/runtime/channel.rs
  • src/server/conn/blocking.rs
  • src/storage/db/accessors.rs
  • tests/blocking_ghost_waiter.rs

Comment thread src/server/conn/blocking.rs
Comment thread tests/blocking_ghost_waiter.rs Outdated
@qodo-code-review

qodo-code-review Bot commented Jul 30, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 55 rules

Grey Divider


Action required

1. Cancel drop leaks waiters 🐞 Bug ☼ Reliability
Description
cancel_multikey_registrations() ignores push_block_msg() failure, so a BlockCancel can still
be dropped under sustained backpressure/shutdown and the owner shard will retain a remote
WaitEntry with deadline: None indefinitely (until a future wake on that key, or forever if no
push occurs). This undermines the PR’s “no more leaked cross-shard waiters” guarantee and can
accumulate registry memory/CPU pressure over time.
Code

src/server/conn/blocking.rs[R115-126]

+    blocking_registry.borrow_mut().remove_wait(wait_id);
+    for &remote_shard in registered_remote_shards {
+        let _ = push_block_msg(
+            shutdown,
+            dispatch_tx,
+            shard_id,
+            remote_shard,
+            ShardMessage::BlockCancel { wait_id },
+            notifiers.map(|n| &*n[remote_shard]),
+        )
+        .await;
+    }
Relevance

●●● Strong

Team previously accepted fixes to stop ignoring enqueue/fsync failures in reliability-critical paths
(PR291, PR154).

PR-#291
PR-#154

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The PR introduces a bounded push API that can fail, but the cancel/cleanup path still ignores that
failure. Because remote registrations are stored with deadline: None, the deadline sweep cannot
reap them; without a delivered cancel, they can persist indefinitely unless/until a future wake
removes them as disconnected.

src/server/conn/blocking.rs[55-97]
src/server/conn/blocking.rs[99-127]
src/shard/dispatch.rs[1013-1089]
src/shard/spsc_handler.rs[1997-2026]
src/blocking/mod.rs[69-79]
src/blocking/mod.rs[169-226]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`cancel_multikey_registrations()` (and other BlockCancel call sites) discard the boolean result from `push_block_msg()`. When `push_with_backpressure` returns `Backpressure`/`Cancelled`, the cancel is not delivered, but the remote owner stores registrations with `deadline: None`, so they are not eligible for `BlockingRegistry::expire_timed_out` reaping.

## Issue Context
- `push_block_msg()` intentionally has a bounded retry budget and can fail.
- Remote `BlockRegister` handling explicitly sets `deadline: None`.
- Therefore, cancel delivery is the *only* cleanup mechanism for remote timeouts/disconnects unless a later wake happens to prune via `is_disconnected()`.

## Fix Focus Areas
- src/server/conn/blocking.rs[99-127]
- src/server/conn/blocking.rs[315-342]
- src/server/conn/blocking.rs[613-626]
- src/shard/spsc_handler.rs[1997-2026]
- src/shard/dispatch.rs[1013-1089]

## Suggested fix
1. Make `cancel_multikey_registrations()` observe failures:
  - If `push_block_msg(...BlockCancel...)` returns `false`, emit a `tracing::warn!` (include `wait_id`, `shard_id`, `remote_shard`, and `outcome` if you thread it through).
2. Add a safety-net so remote waiters can be reaped even if cancel delivery fails:
  - Extend `BlockRegisterPayload` to carry the origin-side `deadline: Option<Instant>`.
  - In `ShardMessage::BlockRegister` handling, set `WaitEntry.deadline = payload.deadline` instead of hardcoding `None`.
  - This allows the existing deadline heap sweep (`expire_timed_out`) to reclaim remote entries even when `BlockCancel` cannot be delivered.
3. Keep block-forever (`deadline: None`) behavior as-is, but ensure cancel failures are at least observable (warn/metric), since they otherwise become permanent unless a wake happens.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Flaky TCP reply reads ✓ Resolved 🐞 Bug ☼ Reliability
Description
The new integration test assumes a single TcpStream::read() returns exactly one full RESP reply
and uses a fixed sleep to ensure the waiter is registered; both assumptions can break under normal
TCP framing and slow/loaded CI scheduling. This can make the regression test nondeterministic and
fail for reasons unrelated to the blocking fix.
Code

tests/blocking_ghost_waiter.rs[R51-58]

+/// Read one reply. Every reply this suite issues fits in a single small
+/// response, so one read is sufficient; the socket carries a read timeout so
+/// a wedged server fails the test instead of hanging it.
+fn read_reply(stream: &mut TcpStream) -> String {
+    let mut buf = [0u8; 4096];
+    let n = stream.read(&mut buf).expect("read reply");
+    String::from_utf8_lossy(&buf[..n]).into_owned()
+}
Relevance

●●● Strong

Repo has accepted replacing single read() assumptions with exact-read/parsing to avoid flaky TCP
framing (PR250, PR65).

PR-#250
PR-#65

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The test’s read_reply() does one read() and returns whatever bytes arrived, but TCP does not
preserve application message boundaries. The test also uses a fixed sleep to assume registration is
complete; this can race under CI load.

tests/blocking_ghost_waiter.rs[51-58]
tests/blocking_ghost_waiter.rs[190-196]
PR-#250

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`tests/blocking_ghost_waiter.rs` reads server replies with a single `read()` call and assumes that corresponds to exactly one RESP frame. TCP can split or coalesce data arbitrarily, so this can cause flaky assertions and reply desynchronization.

The test also uses `sleep(150ms)` to "wait for registration", which is timing-dependent and can be insufficient on slow CI.

## Issue Context
This is an end-to-end regression test for a subtle race; it should not itself introduce additional nondeterministic races.

## Fix Focus Areas
- tests/blocking_ghost_waiter.rs[42-58]
- tests/blocking_ghost_waiter.rs[190-214]

## Suggested fix
1. Replace `read_reply()` with a small RESP-aware reader that reads *exactly one* reply:
  - Maintain a growable buffer and loop `read()` until a full RESP frame can be parsed.
  - Parse at least: `+/-/:/$/*` and bulk/array lengths, so you know how many bytes to read.
2. Remove or harden the fixed sleep:
  - Prefer a bounded retry loop to establish that the BLPOP has actually entered the blocked state before pushing (e.g., wait until a short non-blocking read yields no data for N consecutive checks, or add a test-only server hook/command if the repo already has one).
  - If you must keep a sleep, increase it and add a retry fallback so the test adapts to slow runners.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

3. blocking_ghost_waiter uses find_moon_binary 📘 Rule violation ▣ Testability
Description
The new integration test spawns the server using common::find_moon_binary() and even skips when no
binary is found, rather than requiring MOON_BIN to be explicitly set. This violates the rule
intended to make integration tests deterministic and avoid falling back to
target/{release,debug}/moon paths.
Code

tests/blocking_ghost_waiter.rs[R71-75]

+    let bin = common::find_moon_binary();
+    if !bin.exists() {
+        eprintln!("skipping: no moon binary; build with `cargo build --release`");
+        return;
+    }
Relevance

● Weak

Similar “require MOON_BIN explicitly” review suggestions were rejected repeatedly (PR421, PR216).

PR-#421
PR-#216

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 992389 requires integration tests that spawn a moon server to set and require
MOON_BIN explicitly and not rely on find_moon_binary() fallbacks. The added test calls
common::find_moon_binary() and skips when missing, and the shared helper shows it falls back to
target/release/moon and target/debug/moon when MOON_BIN is unset.

Rule 992389: Integration tests must set MOON_BIN explicitly for server binaries
tests/blocking_ghost_waiter.rs[71-75]
tests/common/mod.rs[133-165]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new integration test `tests/blocking_ghost_waiter.rs` spawns the server via `common::find_moon_binary()`, which can fall back to local `target/...` paths when `MOON_BIN` is unset. Compliance requires integration tests that spawn the server to require `MOON_BIN` explicitly.

## Issue Context
`common::find_moon_binary()` checks `MOON_BIN` but then falls back to other locations if it is missing. The test currently also skips if the resolved binary doesn't exist.

## Fix Focus Areas
- tests/blocking_ghost_waiter.rs[71-75]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/server/conn/blocking.rs
Comment thread tests/blocking_ghost_waiter.rs Outdated
…cel loud (#426 review)

Three review findings, none of them behaviour bugs in the shipped fix but all
worth correcting.

1. `read_reply` in the ghost-waiter suite took ONE `read()` per reply. TCP
   gives no framing guarantee, so a multi-bulk BLPOP answer can arrive split
   and the `contains("first")` / `starts_with(":1")` assertions would fail
   spuriously. It now accumulates until the buffer parses as a complete RESP
   value (simple/error/integer, bulk incl. null, and nested arrays). The
   socket read timeout still bounds a wedged server, so a real hang fails the
   test rather than blocking it.

2. `cancel_multikey_registrations` discarded `push_block_msg`'s result. The
   push budget is bounded by design — A4 fixed the unbounded retry, so the
   only safe direction is to keep the bound — which means a shard wedged for
   the whole ~0.5 s budget can still drop a BlockCancel and leave the owner
   with a `deadline: None` entry its sweep will not reap.

   This is a memory leak, not data loss: since A2 every wake path checks
   `is_disconnected()` before touching the datastore and restores anything it
   popped, so a stale entry is skipped and pruned rather than swallowing an
   element. Now logged at warn with wait_id and both shard ids, so the leak is
   observable instead of silent.

3. The A5 note claimed a `notify_one()` kick that tokio never performs —
   tokio has no task-local Notify receive path and passes `notify: None`,
   discovering ring items through its own periodic drain. The comment now
   says what the code does rather than what monoio does.

Gates (Linux VM): fmt; clippy -D warnings on default and
runtime-tokio,jemalloc; lib 4471; blocking_ghost_waiter 2/2.

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.

1 participant