Skip to content

feat(replication): R1 real WAIT/ACK + consistency/durability fixes (silent replica drop, SELECT leak, AOF db attribution)#282

Merged
pilotspacex-byte merged 4 commits into
mainfrom
feat/v0.7-r1-wait-ack
Jul 11, 2026
Merged

feat(replication): R1 real WAIT/ACK + consistency/durability fixes (silent replica drop, SELECT leak, AOF db attribution)#282
pilotspacex-byte merged 4 commits into
mainfrom
feat/v0.7-r1-wait-ack

Conversation

@pilotspacex-byte

@pilotspacex-byte pilotspacex-byte commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Two commits completing v0.7 R1 (task #19) plus the three pre-existing consistency/durability P0s the new gates caught (task #35):

Commit 1 — R1: real WAIT/ACK plumbing

WAIT was dead in production (returned :0 unconditionally on monoio). Three legs:

  1. Replica ACK sender: replication socket split (monoio::io::Splitable / tokio into_split); a 1s ticker task owns the write half and sends REPLCONF ACK <offset> (replicationCron cadence + idle keepalive). A timeout-wrapped read was rejected by design: cancelling an in-flight io_uring read whose CQE completed discards bytes → silent stream corruption.
  2. Master ACK reader: the inline PSYNC drain splits the hijacked socket; a same-thread ack_read_loop parses ACKs via a dedicated drain_ack_offsets parser (the shared replication drainer deliberately drops REPLCONF as chatter — reusing it swallowed every ACK, caught by the red e2e). fetch_max so reordered/duplicate ACKs never regress.
  3. WAIT wire: connection-layer try_handle_wait intercept (monoio + tokio sharded) awaits wait_for_replicas; timeout 0 = block (capped 1y). Per-shard ack floors ride R2.

Commit 2 — consistency/durability fixes (all A/B-reproduced on main; none are R1 regressions)

  1. Silent replica record drop: per-replica fan-out try_send skipped records on a full channel — one pipelined burst left a replica with ~2k of 40k keys, master_link_status:up, diverged forever. Now overflow kicks the replica (shared kicked flag → drain loop closes the socket → PSYNC resync) — Redis's output-buffer-limit policy. Channel 1024→16384.
  2. Client SELECT persisted as a command (it's W-flagged for routing): poisoned the db context of BOTH persistence planes for interleaved connections. New metadata::is_persisted_write excludes it at all four handler persist gates.
  3. AOF had no db attribution: kill-9 recovery of a 20k-db0 + 20k-db2 load restored 0/40000 (everything into db2). The writer now threads the executing db through AofMessage, tracks the stream's current db, and injects SELECT <db> records exactly on change (Redis aof_selected_db), across all four writer loci + BGREWRITEAOF fold drains.

Verification (user-mandated consistency/durability/bench gates)

  • Gate script 11/11: WAIT-under-load, 20000/20000 multi-db replica parity, kill-9 AOF recovery 20000/20000 per db, post-restart reconvergence.
  • e2e matrix 17/17 (monoio): streaming 7 (incl. new replica_converges_under_interleaved_multidb_load + wait_returns_acked_replica_count), graph 3, hardening 5, new aof_multidb_kill9 2. Tokio: lib 3315, replication_test 5/5, aof_multidb_kill9 2/2.
  • Crash-matrix canaries green: crash_matrix_per_shard_aof 3/3, crash_matrix_per_shard_bgrewriteaof 2/2.
  • VM A/B bench (moon-dev, branch vs main): flat-to-better everywhere — noaof SET p1/p16 +17%/+2%, aof SET p16 within 1% on warm reps, replica-attached SET p1/p16 +7%/+3%. During the bench itself, main's replica silently diverged (7664/10000); the branch converged exactly.

Known follow-ups

Summary by CodeRabbit

  • New Features

    • Added production support for WAIT, returning the number of replicas that acknowledge a requested replication offset.
    • Replicas now periodically report replication progress to the primary.
    • AOF records preserve database context across writes, transactions, rewrites, and recovery.
  • Bug Fixes

    • Client-issued SELECT commands are no longer incorrectly persisted or replicated.
    • Lagging replicas are disconnected and resynchronized instead of silently losing updates.
    • Improved multi-database recovery and replication accuracy.

…reader, WAIT wire

Task #19, the v0.7 R1 milestone item: WAIT/ACK was dead in production.
Three gaps closed (the fourth, per-shard ack floors, keeps the existing
Vec<AtomicU64> shape and rides the R2 multi-shard redesign):

1. Replica ACK send (replica.rs, both runtimes): the replication socket
   is split (monoio::io::Splitable / tokio into_split); a dedicated 1s
   ticker task owns the write half and sends REPLCONF ACK
   <master_repl_offset> — Redis's replicationCron cadence, which also
   serves as an idle keepalive for master-side lag detection. A
   timeout-wrapped read was rejected by design: cancelling an in-flight
   io_uring read whose CQE already completed DISCARDS those bytes —
   silent replication-stream corruption. The read/apply loop is
   unchanged, extracted as stream_commands_read_loop; the ticker stops
   via a per-connection flag when the read loop exits, and the write
   half drops with it.

2. Master ACK read (master.rs): drain_replica_inline_single_shard
   splits the hijacked PSYNC socket; a same-thread reader task
   (ack_read_loop) parses REPLCONF ACK frames and records them into the
   replica's ack_offsets/last_ack_time. fetch_max — a reordered or
   duplicate ACK can never regress the offset. Parsing is a dedicated
   drain_ack_offsets over protocol::parse: the shared replication
   drainer (drain_replicated_commands) deliberately DROPS REPLCONF as
   chatter, which is exactly the frame this loop exists to read (first
   implementation reused it and swallowed every ACK — caught by the red
   e2e staying red). Malformed bytes close the connection loudly; the
   replica then reconnects with a fresh resync.

3. WAIT wire (handler_monoio + handler_sharded dispatch): WAIT answered
   ':0' unconditionally from the synchronous generic dispatch table on
   the monoio runtime (only handler_single ever called
   wait_for_replicas). New connection-layer try_handle_wait intercept
   awaits wait_for_replicas (10ms poll, early exit). Redis parity:
   timeout 0 = block until satisfied (capped at one year — the poll
   loop is cooperative so the shard thread is never starved). The
   dispatch-table arm remains as a truthful fallback for paths without
   repl_state (no replicas can be attached there).

Red/green: new e2e wait_returns_acked_replica_count — WAIT with no
replicas returns 0 fast; WAIT 1 after a write returns 1 within the ACK
cadence; WAIT 2 with one replica times out reporting 1. RED before
(returned :0), GREEN after; the intermediate drainer-reuse bug kept it
red until the dedicated parser landed.

Verification: lib 4111 green, replication_streaming 6/6 (incl. new
test), fmt clean; clippy + tokio matrix + consistency/durability/bench
gates run before PR (see PR description).

author: Tin Dang
Task #35. The user-mandated consistency/durability gates for the R1
WAIT/ACK work (multi-db load parity + kill-9 recovery, A/B'd against
main) exposed three PRE-EXISTING data-integrity bugs. All three
reproduced on main with the same harness; none are R1 regressions.

1. Replication silently dropped records for lagging replicas.
   wal_append_and_fanout / ReplicaLiveFanout used try_send and SKIPPED
   the record on a full channel — one pipelined burst left the replica
   with ~2k of 40k keys, master_link_status:up, offsets diverged
   forever (WAIT correctly reported 0, which is how R1 exposed it).
   Fix: fanout_send_or_kick — overflow sets a shared `kicked` flag
   (ShardMessage::RegisterReplica now carries it) and drops the shard's
   sender; the inline drain loop polls the flag (monoio::select! with a
   250ms timer — the kick cannot arrive in-band on a full channel, and
   ReplicaInfo.shard_txs holds a sender clone so channel closure cannot
   signal it either) and closes the socket. The replica reconnects and
   resyncs from the backlog — Redis's output-buffer-limit policy:
   divergence becomes a loud, self-healing resync. Channel capacity
   1024 -> 16384 records so ordinary bursts kick rarely.

2. Client SELECT commands leaked into BOTH persistence planes.
   SELECT is W-flagged for routing, so every handler AOF'd/replicated
   the literal client SELECT while bare writes carried no db context.
   Interleaved connections then corrupted db attribution downstream —
   conn A (db0) SET after conn B's SELECT 2 handshake replayed into
   db2. New metadata::is_persisted_write (is_write minus SELECT) gates
   all four handler persist legs; SELECT is connection-state only.

3. The AOF had no per-record db attribution at all.
   Kill-9 recovery of a 20k-db0 + 20k-db2 load restored 0/40000 (every
   key into db2 — the last literal client SELECT won). Fix mirrors
   Redis's aof_selected_db: AofMessage::{Append,AppendSync} carry the
   executing db; each writer task tracks the stream's current db and
   injects a `SELECT <db>` record exactly when it changes, in the same
   on-disk format (raw RESP for TopLevel, [lsn=0][len] framed for
   PerShard). Context resets per fresh incr segment (replay starts each
   segment at db0) and threads through every BGREWRITEAOF fold drain;
   drains that leave the context ambiguous set a usize::MAX force-emit
   sentinel — a redundant SELECT is harmless, a missing one is
   corruption. Zero-length fsync-barrier payloads never trigger nor
   observe a db switch. Replay engines already executed SELECT records;
   only the writer was blind.

Red/green:
- NEW tests/aof_multidb_kill9.rs (TopLevel shards=1 + PerShard
  shards=2): interleaved two-conn multi-db writes, kill -9, restart,
  exact per-db placement. RED before (db0=0, all keys in db2), GREEN
  after, on BOTH runtimes.
- NEW replica_converges_under_interleaved_multidb_load
  (replication_streaming): 2x10k interleaved pipelined SETs across
  db0/db2 with a replica attached; exact DBSIZE parity + no cross-db
  leaks after settle. RED before (5116/10001, 5115/10000), GREEN after.
- Writer-level unit test: Append db0 -> db2 -> db0 produces framed
  SELECT records at exactly the two switches.

Verification: gate script 11/11 PASS (WAIT-under-load 1, 20000/20000
parity both dbs, AOF recovery 20000/20000, post-restart reconvergence);
replication e2e matrix 17/17 (streaming 7, graph 3, hardening 5,
aof_multidb 2); crash_matrix_per_shard_aof 3/3 +
crash_matrix_per_shard_bgrewriteaof 2/2 (rewrite-fold canary); lib
4114 monoio / 3315 tokio; clippy -D warnings both matrices; fmt clean.

author: Tin Dang
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@TinDang97, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ce59d686-0507-424e-84a7-4b61682d1a96

📥 Commits

Reviewing files that changed from the base of the PR and between 03efd28 and f927489.

📒 Files selected for processing (1)
  • tests/aof_multidb_kill9.rs
📝 Walkthrough

Walkthrough

The change adds database-aware AOF persistence with synthetic SELECT records, excludes client SELECT from persisted writes, preserves database attribution through transactions and rewrites, adds replica ACK/WAIT handling, and disconnects replicas whose fan-out queues overflow.

Changes

AOF database attribution

Layer / File(s) Summary
AOF contracts and append plumbing
src/persistence/aof/mod.rs, src/persistence/aof/pool.rs
AOF messages and enqueue APIs now carry database context and inject SELECT records when the context changes.
Writer batching and rewrite continuity
src/persistence/aof/rewrite.rs, src/persistence/aof/writer_task.rs
Writer and rewrite paths track last_db across batches, drains, rotations, and rollback handling.
Command and transaction attribution
src/command/*, src/server/conn/*, src/shard/*
Persisted-write classification excludes SELECT, while transaction, coordinator, local, remote, and connection paths attach the executing database to each AOF entry.
AOF regression and compatibility coverage
tests/aof_multidb_kill9.rs, tests/wal_group_commit.rs, src/persistence/aof/*
Tests cover database-aware frames, barriers, routing, transaction attribution, and crash recovery across writer configurations.

Replication R1 ACK and fan-out behavior

Layer / File(s) Summary
Replica fan-out overflow handling
src/shard/dispatch.rs, src/shard/event_loop.rs, src/shard/spsc_handler.rs
Fan-out records carry a shared kick flag; full or disconnected replica channels remove replicas from active fan-out.
Replica ACK streaming
src/replication/replica.rs
Tokio and Monoio replicas periodically send REPLCONF ACK offsets while streaming.
Master ACK parsing and WAIT dispatch
src/replication/master.rs, src/server/conn/handler_*/
Masters parse replica ACK frames and connection handlers implement WAIT acknowledgement and timeout behavior.
Release and integration coverage
CHANGELOG.md, tests/replication_streaming.rs
Release notes and ignored integration tests document and exercise ACK counts, timeout behavior, fan-out stress, and multi-database convergence.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: WAIT/ACK support plus replication/AOF consistency fixes.
Description check ✅ Passed The description is detailed and covers the main work and verification, though the template's Checklist, Performance Impact, and Notes sections are not filled.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/v0.7-r1-wait-ack

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.

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server/conn/shared.rs (1)

238-249: 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

MULTI/EXEC still persists queued SELECT and mis-tags later writes.

  • aof_bytes is still gated on metadata::is_write(cmd), so a queued SELECT lands in aof_entries and reaches AOF/replication.
  • persist_txn_aof(..., db) takes one db for the whole body, but SELECT mutates the local selected, so later writes can be replayed under the wrong db.

Switch this path to is_persisted_write and either forbid db changes inside MULTI or thread the db per entry.

🤖 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/shared.rs` around lines 238 - 249, Update the sharded
MULTI/EXEC AOF handling around aof_bytes and persist_txn_aof: gate serialization
with is_persisted_write instead of metadata::is_write so queued SELECT is
excluded, then prevent database changes within MULTI or propagate the selected
database per transaction entry so later writes persist under the correct
database.
🤖 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/handler_single.rs`:
- Around line 1391-1399: Update execute_transaction and its txn_aof_entries
representation to retain the active database for each queued command’s AOF
entry, including changes around SELECT handling and write recording. When
merging entries in the EXEC path, use each entry’s captured database rather than
the final conn.selected_db, while preserving the existing exec_resp_idx
association.

---

Outside diff comments:
In `@src/server/conn/shared.rs`:
- Around line 238-249: Update the sharded MULTI/EXEC AOF handling around
aof_bytes and persist_txn_aof: gate serialization with is_persisted_write
instead of metadata::is_write so queued SELECT is excluded, then prevent
database changes within MULTI or propagate the selected database per transaction
entry so later writes persist under the correct database.
🪄 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

Run ID: 5d9e9260-c608-47b9-921d-15550ad39a3e

📥 Commits

Reviewing files that changed from the base of the PR and between 25657c5 and 911faf4.

📒 Files selected for processing (26)
  • CHANGELOG.md
  • src/command/metadata.rs
  • src/command/mod.rs
  • src/persistence/aof/mod.rs
  • src/persistence/aof/pool.rs
  • src/persistence/aof/rewrite.rs
  • src/persistence/aof/writer_task.rs
  • src/persistence/aof_manifest/shard_replay.rs
  • src/replication/master.rs
  • src/replication/replica.rs
  • src/server/conn/blocking.rs
  • src/server/conn/handler_monoio/dispatch.rs
  • src/server/conn/handler_monoio/mod.rs
  • src/server/conn/handler_monoio/write.rs
  • src/server/conn/handler_sharded/dispatch.rs
  • src/server/conn/handler_sharded/mod.rs
  • src/server/conn/handler_sharded/write.rs
  • src/server/conn/handler_single.rs
  • src/server/conn/shared.rs
  • src/shard/coordinator.rs
  • src/shard/dispatch.rs
  • src/shard/event_loop.rs
  • src/shard/spsc_handler.rs
  • tests/aof_multidb_kill9.rs
  • tests/replication_streaming.rs
  • tests/wal_group_commit.rs

Comment thread src/server/conn/handler_single.rs Outdated
PR #282 review (CodeRabbit, Major): the txn executors collapsed every
body entry to ONE db at EXEC time and still persisted the literal
queued SELECT (they gated on is_write — missed by the top-level
is_persisted_write sweep). Deterministic corruption:
`MULTI; SELECT 2; SET t2; EXEC` then a plain db0 write — the literal
SELECT 2 shifts the replay/replica stream to db2, but the next db0
record is tagged 0 == the writer's tracker 0, so no corrective SELECT
is injected: every post-EXEC db0 write recovers into db2 (and applies
into the replica's db2). RED-proven by the new e2e before the fix.

Per-executor semantics (they genuinely differ):
- execute_transaction_sharded re-dispatches each body command against
  the CURRENT `selected`, so a queued SELECT really redirects the
  commands after it. aof_entries is now Vec<(usize, Bytes)> with the
  db captured per entry BEFORE dispatch; persist_txn_aof and the
  monoio EXEC replication leg (record_local_write_db) consume it per
  entry, as does the TxnExecute owner-routing arm's
  wal_append_and_fanout loop.
- execute_transaction (handler_single) holds ONE guard on the db
  selected at EXEC time — every body write physically lands there even
  if a queued SELECT mutates conn.selected_db mid-body. The caller now
  captures `txn_db` BEFORE the call and attributes all entries to it
  (the post-EXEC conn.selected_db is the wrong answer there).
- Both executors now use is_persisted_write: a queued literal SELECT
  is never persisted on any path.

Red/green: new e2e aof_txn_select_inside_multi_kill9_{toplevel,
per_shard} — MULTI body with a queued SELECT, post-EXEC db0 probe,
kill -9, restart; recovery placement must equal memory placement. RED
before (post lands in db2), GREEN after, on BOTH runtimes.

Regression: aof_multidb_kill9 4/4 both runtimes, replication_streaming
7/7, replication_hardening 5/5, crash_matrix_per_shard_aof 3/3,
crash_matrix_per_shard_bgrewriteaof 2/2, lib 4114, clippy -D warnings
both matrices, fmt clean.

Known residue (unchanged semantics, noted for R2): in-txn SELECT does
not persist to the connection after EXEC on the sharded paths
(`selected` mutations are local to the executor), and OrderedAcrossShards
merged entries still replay per-entry at db0.

author: Tin Dang

@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: 1

🤖 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 `@tests/aof_multidb_kill9.rs`:
- Around line 217-233: Update the memory-baseline checks around mem_t2_db0 and
mem_t2_db2 to require t2 exists in exactly one database. Add an assertion that
exactly one GET result is non-missing, while preserving the existing
per-database reads and later recovery comparisons.
🪄 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

Run ID: 4d2d4e18-845a-4e1d-8cd5-0af6c3c51040

📥 Commits

Reviewing files that changed from the base of the PR and between 911faf4 and 03efd28.

📒 Files selected for processing (5)
  • src/server/conn/handler_monoio/write.rs
  • src/server/conn/handler_single.rs
  • src/server/conn/shared.rs
  • src/shard/spsc_handler.rs
  • tests/aof_multidb_kill9.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/server/conn/handler_single.rs
  • src/shard/spsc_handler.rs

Comment thread tests/aof_multidb_kill9.rs
PR #282 review (Minor): the memory baseline for t2 could be `$-1` in
BOTH dbs if the queued `SET t2` silently failed, letting every
recovery assertion pass vacuously. Assert exactly-one-db placement
before the kill so the test can never green on a no-op transaction.

author: Tin Dang
@pilotspacex-byte pilotspacex-byte merged commit 4ef0b21 into main Jul 11, 2026
10 checks passed
@TinDang97 TinDang97 deleted the feat/v0.7-r1-wait-ack branch July 11, 2026 08:18
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.

2 participants