Skip to content

fix: durability wave 1 — rewrite-window append overflow, degraded latch, WAL mid-chain tear policy (#452, #54) - #454

Merged
TinDang97 merged 6 commits into
mainfrom
fix/w1-durability-452
Aug 8, 2026
Merged

fix: durability wave 1 — rewrite-window append overflow, degraded latch, WAL mid-chain tear policy (#452, #54)#454
TinDang97 merged 6 commits into
mainfrom
fix/w1-durability-452

Conversation

@TinDang97

@TinDang97 TinDang97 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Wave 1 of the stable-release plan: the only open defect class that loses acked data. Closes the three durability items from #452 that became urgent when #433 made AOF rewrites automatic, plus the #54 recovery-matrix e2e leg that proves them.

Fixes

1. Rewrite-window append drops (#452.1) — RewriteOverflow

While a rewrite fold runs, the AOF writer thread is out of its recv loop, so the bounded (10k) append channel saturates under sustained pipelined writes and acked records were dropped — lost even on a clean restart. Since #433, rewrites fire automatically under exactly that load.

The fix is an aof_rewrite_buf equivalent: a per-writer RewriteOverflow spill buffer, armed by the writer around every fold (all six arms: monoio/tokio × TopLevel Rewrite / RewriteSharded / per-shard):

  • Producers spill channel-overflow appends into the buffer instead of dropping (256 MiB cap; beyond it the pre-existing fail-loud path is unchanged).
  • Ordering invariant (replay order = enqueue order): once spilling, keep spilling — a producer never bypasses older buffered items through a freed channel slot; at drain time the channel (older) is written before the buffer (newer), under the buffer lock end-to-end.
  • The writer drains channel-then-buffer into the committed incr immediately after the fold (phase-8 barrier aware: an aborted rewrite drains into the rolled-back OLD incr correctly). Spilled AppendSync acks park until the post-drain boundary fsync (AOF: AppendSync acks Synced before rewrite-boundary fsync (appendfsync=always durability gap) #140 discipline).
  • INFO persistence gains aof_rewrite_overflow_spilled.

2. Degraded-state latch + reason-DEL escalation (#452.4)

  • Every dropped acked append now routes through one accounting helper that bumps the counter and latches sticky aof_last_append_status:err in INFO persistence — operators can alert on a health bit instead of diffing counters. Deliberately never resets (the AOF stays short one acked record for this generation's lifetime; reset-on-clean-rewrite is tracked in Durability follow-ups from 2026-08 deep review: rewrite-window append drops, WAL mid-chain tear policy, checkpoint data-file fsync #452).
  • Eviction/expiry reason-DELs get a 100× escalated backpressure bound (500ms) and a dedicated aof_reason_del_dropped counter: a dropped reason-DEL makes restart replay resurrect data clients were told is gone. Not unbounded (a dead writer must not hang the shard) and not deferred-retry (would reorder DEL-after-SET on replay).

3. WAL v3 mid-chain tear policy (#452.2)

Replay stopped at the first corrupt record within a segment but kept applying later segments — silently replaying operations from after a hole. Rotation fsyncs a segment before opening the next, so a mid-chain tear can only mean on-disk corruption (the class InnoDB/RocksDB treat as fatal-by-default). Replay now refuses (InvalidData with an actionable message) when a torn segment has later segments; MOON_WAL_SALVAGE=1 is the explicit operator override; a torn FINAL segment stays the benign crash-tail it always was.

Verification

  • Merge-base A/B (tests/recovery_matrix_w1.rs, new): 600k-key preload, two connections flood 30k-command pipelined bursts against a live BGREWRITEAOF fold, SIGKILL + restart, exact-recovery asserts with a strict every-reply-must-be-+OK harness. main fails 1–2 of 3 runs (~5.6k drops per hit); this branch is stably green on macOS and the Linux VM. The race is probabilistic on fast disks — the deterministic drop/spill/ordering contract is pinned by unit tests in rewrite_overflow.
  • Harness notes baked into the test (each cost a debugging round): burst must exceed the 10k channel cap (a round-trip-gated ≤10k burst mathematically cannot overflow it); auto-rewrite must be disabled (a post-flood auto fold folds live memory into a fresh base and heals the loss before the kill); two connections because only the cross-shard SPSC leg drops silently (the inline leg fails loud with -MOONERR).
  • Gates: cargo fmt --check; cargo clippy --all-targets -- -D warnings on both feature sets (also fixes the pre-existing wait_for_compaction dead-code warning that blocked all-targets clippy); lib suites: 4514 (macOS monoio) / 3679 (tokio+jemalloc) / 4536 (Linux VM monoio); unit red/green for every behavioral change (spill ordering, cap, disarm, AppendSync acks, degraded latch, reason-DEL accounting, tear-policy × salvage × tail-tear).

Refs #452, #54, #433. Follow-ups deliberately out of scope: latch reset on clean rewrite, checkpoint data-file fsync (#452 item 3), tracking expiry invalidation (#452 item 5 — wave 2).

author: Tin Dang

Summary by CodeRabbit

  • Reliability

    • AOF rewrite overflow is now buffered, ordered, capacity-limited, and drained without silent loss.
    • AOF failures and dropped deletion records trigger persistent degraded-status tracking.
    • Reason-DEL handling uses shared backpressure limits and dedicated drop accounting.
  • Monitoring

    • Persistence information reports AOF health, dropped deletion records, and rewrite-overflow activity.
  • Recovery

    • WAL replay stops by default on corruption in earlier segments; salvage mode can continue with MOON_WAL_SALVAGE=1.
    • Final-segment crash tails remain tolerated.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

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

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

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 Plus

Run ID: 6bf90792-a40b-46fa-b318-557196a70502

📥 Commits

Reviewing files that changed from the base of the PR and between b5cf67e and 7a17104.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • src/command/connection.rs
  • src/persistence/aof/mod.rs
  • src/persistence/aof/pool.rs
  • src/persistence/aof/rewrite.rs
  • src/persistence/aof/rewrite_overflow.rs
  • src/persistence/aof/writer_task.rs
  • src/shard/persistence_tick.rs
📝 Walkthrough

Walkthrough

This change adds bounded AOF rewrite-overflow buffering, sticky append-drop health tracking, shared reason-DEL backpressure budgets, persistence metrics, and WAL v3 salvage handling. It also adds unit, integration, and recovery tests.

Changes

Durability and recovery hardening

Layer / File(s) Summary
AOF contracts and observability
src/persistence/aof/mod.rs, src/command/persistence.rs, src/command/connection.rs, src/persistence/aof/group_commit.rs
Rewrite messages carry overflow state. AOF append health and drop metrics are exposed through INFO.
Rewrite overflow buffering and draining
src/persistence/aof/rewrite_overflow.rs, src/persistence/aof/rewrite.rs
Bounded buffers preserve append order during rewrites. Drains apply snapshot cuts, persist AppendSync records, and clean up overflow state.
Pool and writer integration
src/persistence/aof/pool.rs, src/persistence/aof/writer_task.rs, src/shard/spsc_handler.rs
Append paths spill before blocking or dropping. Tokio and monoio rewrite paths arm and drain overflow state.
Reason-DEL backpressure and accounting
src/replication/reason_del.rs, src/shard/persistence_tick.rs, src/shard/spsc_handler.rs, src/shard/timers.rs
Eviction and expiry sweeps share bounded reason-DEL budgets. Failed emissions increment drop metrics and latch degraded AOF status.
WAL v3 tear policy
src/persistence/wal_v3/replay.rs, src/persistence/recovery.rs, src/shard/mod.rs, src/shard/shared_databases.rs
Replay rejects mid-chain tears by default, supports MOON_WAL_SALVAGE=1, tolerates final-segment tails, and aborts boot on fatal tears.
Validation and documentation
tests/recovery_matrix_w1.rs, src/persistence/aof/rewrite_overflow.rs, src/persistence/wal_v3/replay.rs, CHANGELOG.md
Tests cover overflow ordering, snapshot cuts, acknowledgements, recovery, salvage, and cleanup behavior.

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

Possibly related issues

Possibly related PRs

Suggested reviewers: pilotspacex-byte

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main durability fixes: rewrite overflow handling, degraded append status, and WAL tear policy.
Description check ✅ Passed The description gives a detailed summary, implementation notes, verification results, and follow-ups, with only minor template headings omitted.
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/w1-durability-452

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 durability wave 1: AOF rewrite overflow, degraded latch, WAL mid-chain tears

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Prevent AOF rewrites from dropping acked appends by spilling overflow during folds.
• Expose sticky degraded durability status and reason-DEL drop accounting in INFO persistence.
• Refuse WAL v3 replay past mid-chain tears by default; add salvage override and e2e recovery test.
Diagram

graph TD
  U["Producers"] --> P["AofWriterPool"] --> O["RewriteOverflow"] --> W["AOF writer task"] --> A[("AOF incr/base")]
  A --> I["INFO persistence"]
  S["Startup recovery"] --> R["WAL v3 replay"] --> L[("WAL segments")]
  L --> A
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Block producers for full fold duration
  • ➕ Simpler conceptual model (no extra buffer/ordering logic).
  • ➕ Naturally preserves ordering by backpressuring at the source.
  • ➖ Can stall shard event loops for hundreds of ms on large folds.
  • ➖ Harder to apply safely to fire-and-forget paths without risking deadlock or long tail latency.
2. Keep draining while folding (dedicated drain thread / split recv loop)
  • ➕ Avoids channel growth and reduces need for spill buffers.
  • ➕ Can preserve low-latency enqueuing under sustained load.
  • ➖ Much larger refactor of rewrite phases and writer lifecycle.
  • ➖ Higher concurrency complexity (coordination with phase barriers and abort/rollback semantics).
3. Unbounded channel + global memory cap
  • ➕ Eliminates try_send drops and reduces special-case producer logic.
  • ➕ Centralized memory pressure control.
  • ➖ Harder to guarantee bounded memory per shard/writer under skewed load.
  • ➖ Still needs careful ordering/ack semantics around rewrites and fsync boundaries.

Recommendation: The PR’s per-writer RewriteOverflow is the best scoped fix for wave-1: it preserves ordering via an explicit spill-first rule, stays memory-bounded (256 MiB cap), and integrates cleanly with the rewrite phase-8 committed-generation barrier so aborted folds drain into the correct incr. The alternatives either risk unacceptable tail latency (blocking) or require a much larger rewrite of the writer/fold architecture (continuous draining / unbounded channels).

Files changed (14) +1313 / -69

Enhancement (1) +11 / -0
connection.rsExpose new persistence health fields in INFO output +11/-0

Expose new persistence health fields in INFO output

• Extends 'INFO persistence' to include 'aof_last_append_status', 'aof_reason_del_dropped', and 'aof_rewrite_overflow_spilled'. Implements status formatting from process-global atomics.

src/command/connection.rs

Bug fix (8) +1019 / -65
persistence.rsPass rewrite overflow handle into BGREWRITEAOF control messages +5/-2

Pass rewrite overflow handle into BGREWRITEAOF control messages

• Updates BGREWRITEAOF entry points to send 'AofMessage::Rewrite' / 'RewriteSharded' including the writer’s 'RewriteOverflow' so the writer can arm spill behavior around folds.

src/command/persistence.rs

mod.rsAdd degraded durability latch, reason-DEL counters, and overflow-aware message types +56/-3

Add degraded durability latch, reason-DEL counters, and overflow-aware message types

• Introduces sticky 'AOF_LAST_APPEND_OK', 'AOF_REASON_DEL_DROPPED', and a single 'record_append_dropped()' helper to ensure all drop sites latch degraded status. Adds 'AOF_REASON_DEL_BACKPRESSURE_BOUND' and extends rewrite message variants to carry 'RewriteOverflow'; exports rewrite_overflow module.

src/persistence/aof/mod.rs

pool.rsSpill rewrite-window overflow instead of dropping on full writer channel +121/-16

Spill rewrite-window overflow instead of dropping on full writer channel

• Adds per-writer 'RewriteOverflow' slots, a 'spill_first' producer gate to preserve ordering, and spill-on-full handling for both 'try_send_append' and bounded-blocking send paths. Routes all loss accounting through 'record_append_dropped()' and adds unit tests for the degraded latch and overflow behavior.

src/persistence/aof/pool.rs

rewrite.rsWire rewrite overflow types and update fold documentation +24/-26

Wire rewrite overflow types and update fold documentation

• Re-exports 'RewriteOverflow' and the spilled counter, broadens cfg gating to both runtimes for bounded drains, and updates rewrite-per-shard docs to reflect the now-closed channel-saturation limitation.

src/persistence/aof/rewrite.rs

rewrite_overflow.rsImplement RewriteOverflow spill buffer with ordering + post-fold drain +473/-0

Implement RewriteOverflow spill buffer with ordering + post-fold drain

• Adds a new module implementing a bounded, per-writer spill buffer armed during rewrite folds. Enforces strict replay ordering (spill-first, channel-before-buffer drain, lock-held drain) and drains into the committed incr with correct 'AppendSync' ack parking; includes unit tests for cap behavior, ordering, spill-vs-drop, and ack fulfillment.

src/persistence/aof/rewrite_overflow.rs

writer_task.rsArm and drain rewrite overflow around all rewrite fold paths +78/-7

Arm and drain rewrite overflow around all rewrite fold paths

• Updates both monoio and tokio writer loops to arm 'RewriteOverflow' at rewrite start and drain channel backlog plus spilled appends into the committed incr immediately after the fold. Adds fatal-path disarm accounting on tokio reopen failure and wires overflow into per-shard rewrite control messages.

src/persistence/aof/writer_task.rs

replay.rsFail loudly on WAL v3 mid-chain tears; add MOON_WAL_SALVAGE override +185/-2

Fail loudly on WAL v3 mid-chain tears; add MOON_WAL_SALVAGE override

• Tracks whether a segment ended torn/corrupt and refuses to apply later segments after a mid-chain tear by default, returning 'InvalidData'. Adds 'MOON_WAL_SALVAGE=1' to explicitly continue with loud warnings, and expands tests to cover default abort, salvage continuation, and benign final-segment crash tails.

src/persistence/wal_v3/replay.rs

reason_del.rsEscalate reason-DEL backpressure and add fail-loud drop accounting +77/-9

Escalate reason-DEL backpressure and add fail-loud drop accounting

• Switches eviction/expiry reason-DEL persistence to use a longer backpressure bound (500ms) and explicitly counts + logs drops via 'AOF_REASON_DEL_DROPPED' while latching degraded durability status. Adds a unit test ensuring drops are observable and non-silent.

src/replication/reason_del.rs

Refactor (1) +2 / -2
group_commit.rsTreat rewritten message variants as control messages +2/-2

Treat rewritten message variants as control messages

• Adjusts control-message detection to match 'Rewrite(..)' and 'RewriteSharded(..)' after adding overflow parameters.

src/persistence/aof/group_commit.rs

Tests (3) +259 / -2
spsc_handler.rsUpdate WAL append tests for new rewrite message shapes +2/-2

Update WAL append tests for new rewrite message shapes

• Adjusts match arms in WAL append tests to handle 'Rewrite(..)' and 'RewriteSharded(..)' variants after message signature changes.

src/shard/spsc_handler.rs

aof_auto_rewrite.rsPreserve documented compaction wait helper +4/-0

Preserve documented compaction wait helper

• Adds documentation and '#[allow(dead_code)]' for a one-shot compaction wait helper to keep it as a supported entry point for future tests.

tests/aof_auto_rewrite.rs

recovery_matrix_w1.rsAdd end-to-end crash recovery matrix for rewrite-under-load durability +253/-0

Add end-to-end crash recovery matrix for rewrite-under-load durability

• Introduces an ignored e2e test that floods pipelined writes during BGREWRITEAOF, SIGKILLs, and asserts exact recovery (DBSIZE and spot-checked keys). Designed to reproduce the rewrite-window channel overflow class that unit tests can’t surface and to validate the fix under real process behavior.

tests/recovery_matrix_w1.rs

Documentation (1) +22 / -0
CHANGELOG.mdDocument wave-1 durability fixes and recovery guarantees +22/-0

Document wave-1 durability fixes and recovery guarantees

• Adds a detailed changelog entry describing the three durability fixes: rewrite-window overflow buffering, sticky degraded-state visibility + reason-DEL accounting, and WAL mid-chain tear policy with salvage override.

CHANGELOG.md

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

Caution

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

⚠️ Outside diff range comments (1)
src/persistence/aof/writer_task.rs (1)

1324-1332: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not discard overflow records while this writer remains active.

A producer can spill an append after overflow.arm() and before writer.flush() fails. disarm_dropping() then discards that append, but this branch returns to the writer loop. An EverySec client can already have received +OK.

If the old incremental file remains usable, drain the overflow into it. Otherwise latch the writer failure and stop accepting successful appends.

🤖 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/persistence/aof/writer_task.rs` around lines 1324 - 1332, Update the
flush-error branch in the writer loop around writer.flush() so overflow records
produced after overflow.arm() are never discarded while the writer remains
active. If the old incremental file is still usable, drain overflow into it;
otherwise latch the writer failure and stop accepting successful appends,
ensuring no +OK append can be lost before the loop exits.
🧹 Nitpick comments (1)
src/persistence/aof/rewrite_overflow.rs (1)

99-120: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Note the approximate nature of the byte cap.

try_spill accounts only payload bytes. The 12-byte framed header, the SELECT prefix records emitted at drain time, and the Vec<AofMessage> element overhead are not counted. Actual resident memory therefore exceeds max_bytes under many small appends. The overshoot is bounded and the design intent is a soft guard, so document the cap as approximate rather than changing the accounting.

🤖 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/persistence/aof/rewrite_overflow.rs` around lines 99 - 120, Document in
the overflow buffer implementation that the max_bytes limit enforced by
try_spill is an approximate soft cap, since accounting covers payload bytes but
excludes framing, SELECT prefixes, and Vec<AofMessage> overhead. Do not change
the existing byte accounting or spill behavior.
🤖 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 `@CHANGELOG.md`:
- Around line 36-38: The changelog claim about every dropped acked append is
inconsistent with try_send_append_no_spill, which increments
AOF_BACKPRESSURE_DROPPED without latching aof_last_append_status:err. Route this
drop through the shared accounting helper so it performs the required latch, or
narrow the changelog wording to exclude this path.

In `@src/persistence/aof/mod.rs`:
- Around line 115-122: Make the helper mandatory and align all documentation
with that contract: update record_reason_del_dropped in
src/replication/reason_del.rs to use record_append_dropped while preserving its
reason-DEL-specific counter, close the direct-counter bypass in
try_send_append_no_spill in src/persistence/aof/pool.rs, and retain the
universal helper wording in src/persistence/aof/mod.rs. Update CHANGELOG.md
lines 36-38 only after every dropped acked append routes through the helper.

In `@src/persistence/aof/pool.rs`:
- Line 689: Update send_append_backpressure and try_send_append_sync to match
try_send_append’s spill-first behavior: attempt
overflow_for(shard_id).try_spill(...) before enqueueing, and retry spilling when
the append channel is full. Preserve each method’s existing acknowledgement and
error behavior while enabling durable EverySec and Always appends to use delayed
overflow acknowledgements.
- Around line 508-517: Update the dropped-append handling near the AOF
backpressure warning to call record_append_dropped(1) instead of incrementing
AOF_BACKPRESSURE_DROPPED directly, so the append status latch is updated. Apply
the same record_append_dropped(1) accounting to the disconnected-channel path.
- Around line 475-480: Update both spill-first append paths around spill_first
and try_spill to distinguish a full overflow buffer from a disarmed overflow.
When try_spill rejects because capacity is exhausted, preserve the spill-first
ordering by returning the failure without calling try_send_append_no_spill; only
fall back to the channel when the overflow is disarmed.

In `@src/persistence/aof/rewrite_overflow.rs`:
- Around line 159-248: Track the number of spilled messages that remain
undrained when the closure in the rewrite overflow drain returns an error,
rather than allowing them to be dropped unaccounted. Expose that count to the
outer error path and call super::record_append_dropped for the undrained
remainder, preserving the existing logging and reset behavior while ensuring
failed drains update the dropped-write metrics.

In `@src/persistence/aof/writer_task.rs`:
- Around line 581-586: The overflow-drain error handlers around
overflow.finish_raw and the listed analogous sites must not continue processing
after a write failure. Transition the writer into its non-acknowledging failed
state, and either return the uncommitted spill records or call
record_append_dropped for every record that cannot be recovered before exiting
the rewrite flow.
- Around line 840-848: The overflow-drain blocks around finish_raw must not lose
newly queued Rewrite or RewriteSharded control messages. After
overflow.finish_raw completes, explicitly clear AOF_REWRITE_IN_PROGRESS as
needed and preserve/reprocess any rewrite request consumed during draining so a
subsequent rewrite is started instead of leaving the flag set without an active
rewrite; apply the same fix to both drain paths.

In `@src/persistence/wal_v3/replay.rs`:
- Around line 408-413: Update the WAL replay handling around the zero-byte
`data` check so an empty segment is accepted only when it is the final segment;
when later `.wal` segments exist, apply the existing default-fail or
explicit-salvage behavior instead of returning `torn: false`. Add coverage for
an empty `000000000002.wal` between valid first and third segments.
- Around line 342-366: Move the existing stop_at_lsn cutoff check in the WAL
replay flow before the mid-chain tear handling around result.torn, so replay
returns successfully once the valid prefix reaches the target and does not
inspect later corruption. Keep InvalidData rejection for tears only when records
beyond the tear are required, and add a regression test covering a target
record, a following corrupt record, and a later segment that verifies only the
target prefix is applied.

In `@src/replication/reason_del.rs`:
- Around line 253-259: Update the drop-accounting flow around
record_reason_del_dropped, record_bytes_conn, and record_effect_write so
reason-DEL drops log only the relevant key, not the serialized RESP payload. Add
or reuse a sibling accounting helper that shares the counter and latch but emits
a caller-neutral message with record length for generic/script-effect drops.
Ensure record_bytes_conn selects the appropriate helper based on its caller
context.
- Around line 139-147: Update record_reason_del_dropped to remove the truncated
raw-byte value from the tracing::error! call and log only a non-reversible
identifier, such as a stable hash of the provided key/serialized record.
Preserve the existing dropped counter and saturation error context, ensuring no
key or value bytes are emitted at error level.

In `@tests/recovery_matrix_w1.rs`:
- Around line 215-222: Update the recovery test’s flood coordination before
SIGKILL so it retries or continues until the persistence diagnostic
aof_rewrite_overflow_spilled reports a value greater than zero. Parse and assert
that spill count before killing the process, ensuring the test exercises the
overflow path rather than merely validating normal recovery.

---

Outside diff comments:
In `@src/persistence/aof/writer_task.rs`:
- Around line 1324-1332: Update the flush-error branch in the writer loop around
writer.flush() so overflow records produced after overflow.arm() are never
discarded while the writer remains active. If the old incremental file is still
usable, drain overflow into it; otherwise latch the writer failure and stop
accepting successful appends, ensuring no +OK append can be lost before the loop
exits.

---

Nitpick comments:
In `@src/persistence/aof/rewrite_overflow.rs`:
- Around line 99-120: Document in the overflow buffer implementation that the
max_bytes limit enforced by try_spill is an approximate soft cap, since
accounting covers payload bytes but excludes framing, SELECT prefixes, and
Vec<AofMessage> overhead. Do not change the existing byte accounting or spill
behavior.
🪄 Autofix

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: e4623a4d-0ff5-4e4c-9068-d2c048158ee7

📥 Commits

Reviewing files that changed from the base of the PR and between 34bcfe7 and e2eaef8.

📒 Files selected for processing (14)
  • CHANGELOG.md
  • src/command/connection.rs
  • src/command/persistence.rs
  • src/persistence/aof/group_commit.rs
  • src/persistence/aof/mod.rs
  • src/persistence/aof/pool.rs
  • src/persistence/aof/rewrite.rs
  • src/persistence/aof/rewrite_overflow.rs
  • src/persistence/aof/writer_task.rs
  • src/persistence/wal_v3/replay.rs
  • src/replication/reason_del.rs
  • src/shard/spsc_handler.rs
  • tests/aof_auto_rewrite.rs
  • tests/recovery_matrix_w1.rs

Comment thread CHANGELOG.md
Comment thread src/persistence/aof/mod.rs
Comment thread src/persistence/aof/pool.rs
Comment thread src/persistence/aof/pool.rs Outdated
Comment thread src/persistence/aof/pool.rs
Comment on lines +342 to +366
if result.torn && idx + 1 < segments.len() {
// Mid-chain tear: records beyond the hole exist in later
// segments (#452.2).
if salvage {
tracing::error!(
"WAL v3 replay: mid-chain tear in {} with {} later segment(s) — \
MOON_WAL_SALVAGE=1 set, CONTINUING past the hole; recovered state \
may be missing acked writes that later operations depended on",
seg_path.display(),
segments.len() - idx - 1,
);
} else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"WAL v3 mid-chain tear: segment {} ends in a corrupt/truncated record \
but {} later segment(s) exist — applying them would silently replay \
operations from after a hole. Refusing to recover. Restore from a \
backup/replica, or set MOON_WAL_SALVAGE=1 to accept partial recovery.",
seg_path.display(),
segments.len() - idx - 1,
),
));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Check stop_at_lsn before the mid-chain tear error.

If the valid prefix already reaches stop_at_lsn, this code returns InvalidData for a later corrupt record that the recovery target does not require. Move the existing cutoff check before the mid-chain tear check. Reject the replay only when the target requires records after the tear.

Add a test with a valid record at the target, a corrupt following record, and a later segment. The replay should succeed and apply only the target prefix.

🤖 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/persistence/wal_v3/replay.rs` around lines 342 - 366, Move the existing
stop_at_lsn cutoff check in the WAL replay flow before the mid-chain tear
handling around result.torn, so replay returns successfully once the valid
prefix reaches the target and does not inspect later corruption. Keep
InvalidData rejection for tears only when records beyond the tear are required,
and add a regression test covering a target record, a following corrupt record,
and a later segment that verifies only the target prefix is applied.

Comment on lines +408 to +413
// A zero-byte file is a benign fresh segment; a partial header is a
// tear (#452.2).
return Ok(WalV3ReplayResult {
torn: !data.is_empty(),
..WalV3ReplayResult::default()
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject a zero-byte segment when later segments exist.

A zero-byte file returns torn: false, so directory replay silently applies later segments after an empty middle segment. A zero-byte file is only a benign fresh segment when it is final. If later .wal files exist, treat the empty file as a mid-chain gap and apply the same default-fail or explicit-salvage policy.

Add coverage for 000000000002.wal being empty between valid first and third segments.

🤖 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/persistence/wal_v3/replay.rs` around lines 408 - 413, Update the WAL
replay handling around the zero-byte `data` check so an empty segment is
accepted only when it is the final segment; when later `.wal` segments exist,
apply the existing default-fail or explicit-salvage behavior instead of
returning `torn: false`. Add coverage for an empty `000000000002.wal` between
valid first and third segments.

Comment on lines +139 to 147
fn record_reason_del_dropped(key: &[u8]) {
crate::persistence::aof::AOF_REASON_DEL_DROPPED
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
crate::persistence::aof::AOF_LAST_APPEND_OK.store(false, std::sync::atomic::Ordering::Relaxed);
tracing::error!(
"reason-DEL LOST for key {:?}: AOF writer saturated past the escalated bound — \
restart replay will RESURRECT this key unless a rewrite completes first",
String::from_utf8_lossy(&key[..key.len().min(64)]),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not log raw key bytes at error level.

record_reason_del_dropped writes up to 64 bytes of the key into the log. Keys frequently contain user identifiers or other personal data, so this exports PII into log sinks on a path that fires under writer saturation (potentially at high volume). Log a non-reversible identifier instead, and keep the counter as the alerting signal.

Note: record_bytes_conn passes a full serialized RESP record here, not a key, so the current form can also emit value bytes. See the separate comment on lines 254-259.

🔐 Proposed fix: hash the key instead of printing it
 fn record_reason_del_dropped(key: &[u8]) {
     crate::persistence::aof::AOF_REASON_DEL_DROPPED
         .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
     crate::persistence::aof::AOF_LAST_APPEND_OK.store(false, std::sync::atomic::Ordering::Relaxed);
     tracing::error!(
-        "reason-DEL LOST for key {:?}: AOF writer saturated past the escalated bound — \
-         restart replay will RESURRECT this key unless a rewrite completes first",
-        String::from_utf8_lossy(&key[..key.len().min(64)]),
+        "reason-DEL LOST (key_len={}, key_hash={:016x}): AOF writer saturated past the \
+         escalated bound — restart replay will RESURRECT this key unless a rewrite \
+         completes first",
+        key.len(),
+        {
+            use std::hash::{Hash, Hasher};
+            let mut h = std::collections::hash_map::DefaultHasher::new();
+            key.hash(&mut h);
+            h.finish()
+        },
     );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn record_reason_del_dropped(key: &[u8]) {
crate::persistence::aof::AOF_REASON_DEL_DROPPED
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
crate::persistence::aof::AOF_LAST_APPEND_OK.store(false, std::sync::atomic::Ordering::Relaxed);
tracing::error!(
"reason-DEL LOST for key {:?}: AOF writer saturated past the escalated bound — \
restart replay will RESURRECT this key unless a rewrite completes first",
String::from_utf8_lossy(&key[..key.len().min(64)]),
);
fn record_reason_del_dropped(key: &[u8]) {
crate::persistence::aof::AOF_REASON_DEL_DROPPED
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
crate::persistence::aof::AOF_LAST_APPEND_OK.store(false, std::sync::atomic::Ordering::Relaxed);
tracing::error!(
"reason-DEL LOST (key_len={}, key_hash={:016x}): AOF writer saturated past the \
escalated bound — restart replay will RESURRECT this key unless a rewrite \
completes first",
key.len(),
{
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut h);
h.finish()
},
);
}
🤖 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/replication/reason_del.rs` around lines 139 - 147, Update
record_reason_del_dropped to remove the truncated raw-byte value from the
tracing::error! call and log only a non-reversible identifier, such as a stable
hash of the provided key/serialized record. Preserve the existing dropped
counter and saturation error context, ensuring no key or value bytes are emitted
at error level.

Comment on lines 253 to +259
if let Some(pool) = aof_pool {
let mut budget = AOF_SPSC_BACKPRESSURE_BOUND;
let _ = pool.send_append_bounded_blocking(shard_id, 0, db, bytes, &mut budget);
// #452.4: escalated bound + fail-loud accounting — see
// `record_reason_del`'s comment for the resurrection rationale.
let mut budget = crate::persistence::aof::AOF_REASON_DEL_BACKPRESSURE_BOUND;
if !pool.send_append_bounded_blocking(shard_id, 0, db, bytes.clone(), &mut budget) {
record_reason_del_dropped(&bytes);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass a key, not the serialized record, and use a caller-neutral message.

record_bytes_conn calls record_reason_del_dropped(&bytes). bytes is the complete serialized RESP record, not a key. Two consequences:

  1. The log line claims "reason-DEL LOST for key <...>" while printing the whole command frame, including argument values. That widens the data exposure described in the comment on lines 139-147.
  2. record_bytes_conn also serves record_effect_write, which carries script write effects, not reason-DELs. The message misattributes those drops.

Split the accounting from the message, or pass an explicit context label plus the key where one exists.

♻️ Proposed shape
-    if let Some(pool) = aof_pool {
-        // `#452.4`: escalated bound + fail-loud accounting — see
-        // `record_reason_del`'s comment for the resurrection rationale.
-        let mut budget = crate::persistence::aof::AOF_REASON_DEL_BACKPRESSURE_BOUND;
-        if !pool.send_append_bounded_blocking(shard_id, 0, db, bytes.clone(), &mut budget) {
-            record_reason_del_dropped(&bytes);
-        }
-    }
+    if let Some(pool) = aof_pool {
+        // `#452.4`: escalated bound + fail-loud accounting — see
+        // `record_reason_del`'s comment for the resurrection rationale.
+        let mut budget = crate::persistence::aof::AOF_REASON_DEL_BACKPRESSURE_BOUND;
+        let record_len = bytes.len();
+        if !pool.send_append_bounded_blocking(shard_id, 0, db, bytes, &mut budget) {
+            record_conn_record_dropped(record_len);
+        }
+    }

Add a sibling helper that shares the counter and latch but logs a record length instead of payload bytes.

🤖 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/replication/reason_del.rs` around lines 253 - 259, Update the
drop-accounting flow around record_reason_del_dropped, record_bytes_conn, and
record_effect_write so reason-DEL drops log only the relevant key, not the
serialized RESP payload. Add or reuse a sibling accounting helper that shares
the counter and latch but emits a caller-neutral message with record length for
generic/script-effect drops. Ensure record_bytes_conn selects the appropriate
helper based on its caller context.

Comment on lines +215 to +222
// Diagnostics (not an assert: older binaries lack the field): how much
// the rewrite overflow actually spilled this run.
let spilled = cli(port, &["INFO", "persistence"])
.lines()
.find(|l| l.starts_with("aof_rewrite_overflow_spilled:"))
.map(|l| l.to_string())
.unwrap_or_else(|| "aof_rewrite_overflow_spilled:<absent>".into());
eprintln!("[recovery_matrix_w1] {spilled}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Assert that this test entered the overflow path.

aof_rewrite_overflow_spilled is diagnostic only. If the rewrite finishes before the flood saturates the writer channel, this test validates normal recovery and can pass without exercising the regression.

Coordinate or retry the flood until the spill count is greater than zero, then assert it before SIGKILL.

🤖 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/recovery_matrix_w1.rs` around lines 215 - 222, Update the recovery
test’s flood coordination before SIGKILL so it retries or continues until the
persistence diagnostic aof_rewrite_overflow_spilled reports a value greater than
zero. Parse and assert that spill count before killing the process, ensuring the
test exercises the overflow path rather than merely validating normal recovery.

@qodo-code-review

qodo-code-review Bot commented Aug 8, 2026

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. Overflow loss not accounted ✓ Resolved 🐞 Bug ≡ Correctness
Description
RewriteOverflow::finish_inner drains the entire spill buffer via mem::take() and, if
drain/write/fsync fails, only logs and then clears/disarms without calling record_append_dropped()
for the lost spilled appends, contradicting the module comment that such loss is “counted” and
potentially hiding rewrite-window losses from both the drop counter and the degraded latch.
Separately, AofWriterPool::try_send_append_no_spill still performs ad-hoc accounting (and does
nothing on Disconnected) by incrementing AOF_BACKPRESSURE_DROPPED directly instead of using
record_append_dropped(), so AOF_LAST_APPEND_OK can remain true after an acked append is lost
and INFO persistence’s aof_last_append_status becomes unreliable for alerting.
Code

src/persistence/aof/rewrite_overflow.rs[R242-245]

+        if let Err(ref e) = result {
+            error!(
+                "rewrite overflow drain FAILED — spilled appends may be lost: {}",
+                e
Relevance

●●● Strong

Team has accepted fixes making enqueue/drop failures observable/accounted; missing
record_append_dropped on loss likely fixed.

PR-#291

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rewrite overflow code is documented as having drain-time failures “counted and logged”, yet the
error path only logs and then clears/disarms the buffer without any record_append_dropped() call;
because mem::take() empties the buffer before I/O, those spilled messages are discarded from
memory even when the drain/write/fsync path errors. In addition, the PR’s contract is that all
dropped acked appends must go through record_append_dropped() so the degraded-state latch
(AOF_LAST_APPEND_OK) cannot miss losses, but try_send_append_no_spill still increments
AOF_BACKPRESSURE_DROPPED directly when spill fails (bypassing latching) and its
TrySendError::Disconnected path also bypasses the helper entirely, allowing losses that are
counted inconsistently or not latched at all.

src/persistence/aof/rewrite_overflow.rs[122-128]
src/persistence/aof/rewrite_overflow.rs[159-249]
src/persistence/aof/mod.rs[115-122]
src/persistence/aof/mod.rs[95-122]
src/persistence/aof/pool.rs[470-519]
PR-#291

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

## Issue description
Two AOF loss paths bypass the unified loss-accounting helper: (1) `RewriteOverflow::finish_inner` extracts the entire spill buffer with `mem::take()`, attempts to drain+write+fsync, and on any error it logs and then clears/disarms without updating loss accounting via `record_append_dropped()`, despite documentation promising drain-time failures are “counted”; and (2) `AofWriterPool::try_send_append_no_spill` still uses ad-hoc accounting by incrementing `AOF_BACKPRESSURE_DROPPED` directly (and doing nothing on `TrySendError::Disconnected`) rather than using `record_append_dropped()`, so the sticky degraded latch (`AOF_LAST_APPEND_OK`) can remain `true` after an acked append is lost.

## Issue Context
- In `finish_inner`, because `mem::take()` empties the spill buffer before I/O, any failure path discards spilled messages from memory; if those messages correspond to acked operations, operators/tests should see a loud degraded state and consistent loss counting.
- The latch is exposed as `aof_last_append_status` in `INFO persistence` and is intended to be an operator/test-harness alert signal; the PR explicitly mandates routing *every* dropped acked append through `record_append_dropped()` so the degraded-state latch cannot miss losses.

## Fix Focus Areas
- src/persistence/aof/rewrite_overflow.rs[122-128]
- src/persistence/aof/rewrite_overflow.rs[159-249]
- src/persistence/aof/pool.rs[470-519]
- src/persistence/aof/mod.rs[95-122]

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



Remediation recommended

2. RewriteOverflow uses Mutex<Vec> 📘 Rule violation ➹ Performance
Description
RewriteOverflow buffers per-append spill messages via a mutex-protected Vec, which introduces
lock-based producer→consumer handoff on the append path during rewrites. This violates the hot-path
requirement to use lock-free flume channels for pipeline communication and can add contention under
sustained load.
Code

src/persistence/aof/rewrite_overflow.rs[R58-61]

+    armed: std::sync::atomic::AtomicBool,
+    /// Spilled messages in enqueue order. Only `Append` / `AppendSync` are
+    /// ever pushed.
+    buf: parking_lot::Mutex<Vec<AofMessage>>,
Relevance

●● Moderate

No close precedent rejecting Mutex spill buffer; ordering/rare-path design may trump flume-only
preference.

PR-#361

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 209930 requires hot-path pipeline communication between producer/consumer stages to
use lock-free flume channels rather than mutex-protected queues. The new RewriteOverflow
introduces a parking_lot::Mutex<Vec<AofMessage>> buffer for spilled appends, which is a
mutex-protected queue used for message handoff during rewrite-window append overflow handling.

Rule 209930: Use lock-free flume channels for hot-path pipeline communication
src/persistence/aof/rewrite_overflow.rs[55-65]

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

## Issue description
`RewriteOverflow` uses `parking_lot::Mutex<Vec<AofMessage>>` as a queue between producers and the writer during rewrite folds. The compliance rule requires lock-free `flume::Sender`/`Receiver` (or equivalent) for hot-path pipeline messaging.

## Issue Context
This buffer is fed per-append when rewrites are in progress (and potentially when `spill_first()` is true), so it is part of the request/write hot path under sustained load.

## Fix Focus Areas
- src/persistence/aof/rewrite_overflow.rs[55-65]

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



Informational

3. recovery_matrix_w1 calls find_moon_binary 📘 Rule violation ▣ Testability
Description
The new integration test spawns the server binary via common::find_moon_binary(), which falls back
to guessed build paths when MOON_BIN is unset. This violates the requirement that integration
tests must require an explicit MOON_BIN to avoid running the wrong artifact in CI/dev
environments.
Code

tests/recovery_matrix_w1.rs[R39-42]

+    let mut cmd = Command::new(common::find_moon_binary());
+    cmd.args([
+        "--port",
+        &port_s,
Relevance

● Weak

Repeated close precedents rejected requiring explicit MOON_BIN; repo keeps find_moon_binary fallback
in tests.

PR-#446
PR-#421
PR-#427

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 992389 explicitly forbids tests from using helpers like find_moon_binary() that
fall back to default paths when MOON_BIN is unset. The new test uses common::find_moon_binary()
to construct the Command, and its error message indicates fallback behavior ("cargo build first;
MOON_BIN to override") rather than requiring MOON_BIN.

Rule 992389: Integration tests must set MOON_BIN explicitly for server binaries
tests/recovery_matrix_w1.rs[36-63]

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/recovery_matrix_w1.rs` starts the server using `common::find_moon_binary()`, which may run without `MOON_BIN` being set by falling back to `target/{release,debug}/moon`.

## Issue Context
The compliance checklist requires integration tests that spawn a real server process to require `MOON_BIN` explicitly (fail fast if missing) and not rely on fallback binary resolution.

## Fix Focus Areas
- tests/recovery_matrix_w1.rs[36-63]

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


Grey Divider

Context used
✅ Compliance rules (platform): 55 rules

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

Qodo Logo

Comment on lines +58 to +61
armed: std::sync::atomic::AtomicBool,
/// Spilled messages in enqueue order. Only `Append` / `AppendSync` are
/// ever pushed.
buf: parking_lot::Mutex<Vec<AofMessage>>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. rewriteoverflow uses mutex 📘 Rule violation ➹ Performance

RewriteOverflow buffers per-append spill messages via a mutex-protected Vec, which introduces
lock-based producer→consumer handoff on the append path during rewrites. This violates the hot-path
requirement to use lock-free flume channels for pipeline communication and can add contention under
sustained load.
Agent Prompt
## Issue description
`RewriteOverflow` uses `parking_lot::Mutex<Vec<AofMessage>>` as a queue between producers and the writer during rewrite folds. The compliance rule requires lock-free `flume::Sender`/`Receiver` (or equivalent) for hot-path pipeline messaging.

## Issue Context
This buffer is fed per-append when rewrites are in progress (and potentially when `spill_first()` is true), so it is part of the request/write hot path under sustained load.

## Fix Focus Areas
- src/persistence/aof/rewrite_overflow.rs[55-65]

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

Comment thread src/persistence/aof/rewrite_overflow.rs
TinDang97 added a commit that referenced this pull request Aug 8, 2026
…sarial review

Pre-merge hardening of the #452/#54 durability wave, addressing every
finding from the adversarial review of PR #454:

- P0.1 snapshot cut: RewriteOverflow::mark_cut records the buffer length
  at the fold's atomic snapshot instant (AofFold handler for sharded
  folds, under all db write guards for the single-writer fold). A
  committed finish discards [0..cut) — those entries' effects are in the
  new base, and replaying them onto it would double-apply non-idempotent
  commands (INCR/APPEND/LPUSH). Aborted finishes write everything (the
  rolled-back old base predates all spills). Parked AppendSync acks for
  discarded pre-cut entries resolve Synced after the finish fsync.
- P0.2 producer gating: every enqueue leg (try_send_append,
  send_append_bounded_blocking, send_append_backpressure,
  try_send_append_sync) checks spill_first BEFORE touching the channel —
  the fold's own phase-1/3 drains free slots mid-fold, so an ungated
  producer could place a newer record ahead of older buffered ones.
- Cap semantics: try_spill returns SpillReject::Disarmed(msg) (fall
  through to the normal path) vs CapExceeded (record the loss and drop —
  inserting into the channel while the buffer is non-empty would invert
  same-key replay order).
- U1 unwind safety: arm_scoped returns an ArmGuard; a fold panic
  (release profile unwinds) disarms with drop accounting instead of
  leaving the overflow armed forever silently eating up to 256 MiB.
- P1.4: boot callers now classify mid-chain tears (is_mid_chain_tear)
  and abort startup (exit 70) instead of falling back to legacy
  recovery, at all four WAL v3 replay sites.
- P2.5: all cap/shutdown/disconnect drop paths route through
  record_append_dropped so the degraded latch can never miss a loss.
- P2.6: finish drain is two-phase — channel (bounded by its length,
  outside the buffer lock) before buffer, disarm in the empty-swap
  round under the lock; bytes not being reset per swap keeps
  spill_first true across rounds.
- P2.7: a finish that observes shutdown_requested warns loudly.
- P2.8: reason-DEL backpressure budget is shared per eviction/expiry
  sweep (one 500ms bound total), not minted per victim key — a
  1000-victim OOM sweep against a hung disk used to block the shard
  event loop for 1000 x 500ms.

Red/green coverage: snapshot-cut discard/abort/rearm, gated
backpressure + AppendSync spill paths, ArmGuard unwind + no-op-after-
finish, shared-sweep budget fast-fail, and is_mid_chain_tear
classification pinned in the existing tear test.

Refs: #452 #54, PR #454 review
author: Tin Dang
…rites mid-fold (#452.1)

While an AOF rewrite fold runs, the writer thread is out of its recv
loop, so the bounded (10k) append channel saturates under sustained
write load and try_send_append / send_append_bounded_blocking dropped
acked records — lost even on a clean restart. #433 made rewrites
automatic, so the trigger condition became default-on in production.

Fix: a per-writer RewriteOverflow spill buffer (aof_rewrite_buf
equivalent), armed by the writer task around every rewrite fold:

- Producers spill channel-overflow appends into the buffer instead of
  dropping (capped at 256 MiB; beyond the cap the pre-existing
  fail-loud drop path is unchanged).
- Ordering invariant: once spilling, keep spilling (a producer never
  bypasses older buffered items via a freed channel slot); at drain
  time the channel (older) is written before the buffer (newer), under
  the buffer lock end-to-end.
- The writer drains channel-then-buffer into the committed incr
  immediately after the fold — all six fold arms wired (monoio/tokio ×
  TopLevel Rewrite / RewriteSharded / per-shard), with the committed-
  generation file from the fold's phase-8 barrier, so an aborted
  rewrite drains into the rolled-back OLD incr correctly.
- Spilled AppendSync acks park until the post-drain boundary fsync
  (issue #140 discipline) and resolve Synced/FsyncFailed.
- Fatal writer-exit paths disarm loudly (records counted as dropped,
  never silently buffered forever).
- INFO persistence gains aof_rewrite_overflow_spilled.

The stale "known limitation (F6 behind --experimental-per-shard-
rewrite)" doc block is replaced with the closed-by-overflow contract.
RewriteOverflow lives in its own module (rewrite.rs was at the
1500-line limit). Red/green: pool_bounded_blocking_spills_instead_of_
dropping_during_rewrite pins the old drop as baseline and the new
spill as the contract, plus ordering, cap, disarm, and AppendSync-ack
tests.

Refs #452, #433, #54
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: 4

🧹 Nitpick comments (1)
src/persistence/aof/pool.rs (1)

828-834: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace the empty-arm match with is_ok().

The Err(_) => {} arm carries no logic. if ... .is_ok() states the same intent and avoids a possible clippy::single_match diagnostic, which the repository requires to stay at zero warnings.

Also note that self.overflow_for(shard_id) is resolved a second time here; the local ovf binding from line 813 is already in scope.

♻️ Proposed refactor
             Err(flume::TrySendError::Full(returned)) => {
                 // `#452.1`: channel saturated — spill if a fold is in
                 // progress (armed) before falling to the counted drop.
-                match self.overflow_for(shard_id).try_spill(returned) {
-                    Ok(()) => return ack_rx,
-                    Err(_) => {}
-                }
+                if ovf.try_spill(returned).is_ok() {
+                    return ack_rx;
+                }

As per coding guidelines: "Run and maintain formatting, clippy with zero warnings".

🤖 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/persistence/aof/pool.rs` around lines 828 - 834, In the
TrySendError::Full branch, replace the match around
overflow_for(shard_id).try_spill(returned) with an is_ok() conditional that
returns ack_rx on success and otherwise continues to the existing counted-drop
path. Reuse the already scoped ovf binding instead of resolving
self.overflow_for(shard_id) again.

Source: Coding guidelines

🤖 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/persistence/aof/pool.rs`:
- Around line 705-709: Update the disconnected-writer handling in the async
append paths around the sender try_send match and ack_rx resolution to call
super::record_append_dropped, matching send_append_bounded_blocking and
try_send_append_no_spill. Ensure dead-writer appends increment the drop metric
and propagate the failed append status instead of returning WriteFailed directly
or allowing RecvError without recording the drop.

In `@src/persistence/aof/rewrite_overflow.rs`:
- Around line 285-383: Track the number of records remaining in the local
spilled batch while the rewrite-overflow drain processes it, updating the
counter as each message is successfully handled. In the drain error path, add
this in-flight remainder to the records still in self.buf before calling
super::record_append_dropped, then clear both sources and preserve the existing
disarm/reset behavior so lost appends mark the failure status.

In `@src/shard/shared_databases.rs`:
- Around line 1205-1208: Update replay_graph_wal to apply the mid-chain tear
policy across graph WAL segments, preferably by using replay_wal_v3_dir or by
rejecting a torn non-final segment before replay continues. Add a regression
test covering a torn first segment followed by a valid segment, asserting
startup aborts and no graph commands are applied.

In `@src/shard/spsc_handler.rs`:
- Around line 959-962: Make reason_del_budget batch-scoped rather than
command-scoped in all four affected sites: src/shard/spsc_handler.rs lines
959-962, 1163-1166, 1620-1623, and 1825-1828. In each batch arm, move its
initialization beside aof_budget and before the corresponding command loop,
preserving one shared AOF_REASON_DEL_BACKPRESSURE_BOUND budget per batch.

---

Nitpick comments:
In `@src/persistence/aof/pool.rs`:
- Around line 828-834: In the TrySendError::Full branch, replace the match
around overflow_for(shard_id).try_spill(returned) with an is_ok() conditional
that returns ack_rx on success and otherwise continues to the existing
counted-drop path. Reuse the already scoped ovf binding instead of resolving
self.overflow_for(shard_id) again.
🪄 Autofix

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: 3983b34d-d343-4597-827c-dc3f7e247556

📥 Commits

Reviewing files that changed from the base of the PR and between e2eaef8 and b5cf67e.

📒 Files selected for processing (13)
  • CHANGELOG.md
  • src/persistence/aof/pool.rs
  • src/persistence/aof/rewrite.rs
  • src/persistence/aof/rewrite_overflow.rs
  • src/persistence/aof/writer_task.rs
  • src/persistence/recovery.rs
  • src/persistence/wal_v3/replay.rs
  • src/replication/reason_del.rs
  • src/shard/mod.rs
  • src/shard/persistence_tick.rs
  • src/shard/shared_databases.rs
  • src/shard/spsc_handler.rs
  • src/shard/timers.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • CHANGELOG.md
  • src/replication/reason_del.rs
  • src/persistence/aof/writer_task.rs

Comment thread src/persistence/aof/pool.rs
Comment thread src/persistence/aof/rewrite_overflow.rs
Comment on lines +1205 to +1208
// #452.2: mid-chain tear ⇒ refuse to boot (see recovery.rs).
if crate::persistence::wal_v3::replay::is_mid_chain_tear(&e) {
crate::persistence::wal_v3::replay::abort_boot_on_mid_chain_tear(shard_id, &e);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Apply the mid-chain tear policy to legacy graph replay.

replay_graph_wal at Lines 1075-1094 replays each file with replay_wal_v3_file and ignores WalV3ReplayResult.torn. A torn non-final graph WAL segment can therefore continue into later segments. The collected graph commands then apply after a missing history range.

Use replay_wal_v3_dir in replay_graph_wal, or enforce the same non-final tear check there. Add a regression test with a torn first segment and a valid later segment. Verify that startup aborts and no graph commands apply.

🤖 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/shard/shared_databases.rs` around lines 1205 - 1208, Update
replay_graph_wal to apply the mid-chain tear policy across graph WAL segments,
preferably by using replay_wal_v3_dir or by rejecting a torn non-final segment
before replay continues. Add a regression test covering a torn first segment
followed by a valid segment, asserting startup aborts and no graph commands are
applied.

Comment thread src/shard/spsc_handler.rs
Comment on lines +959 to +962
// #454 P2.8: ONE shared backpressure bound for this entire sweep
// (per-key minting could stall the shard bound x victim-count).
let mut reason_del_budget =
crate::persistence::aof::AOF_REASON_DEL_BACKPRESSURE_BOUND;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

reason_del_budget is minted per command instead of per batch in all four batch arms. Each arm already hoists aof_budget above its command loop so the batch shares one bound (the PR #211 fix). The new reason_del_budget is declared inside the loop, so an N-command batch with evict_active true can stall the shard event loop for N × AOF_REASON_DEL_BACKPRESSURE_BOUND.

  • src/shard/spsc_handler.rs#L959-L962: move the declaration next to aof_budget at line 885, above the loop at line 886.
  • src/shard/spsc_handler.rs#L1163-L1166: move the declaration next to aof_budget at line 1088, above the loop at line 1089.
  • src/shard/spsc_handler.rs#L1620-L1623: move the declaration next to aof_budget at line 1546, above the loop at line 1547.
  • src/shard/spsc_handler.rs#L1825-L1828: move the declaration next to aof_budget at line 1750, above the loop at line 1751.
📍 Affects 1 file
  • src/shard/spsc_handler.rs#L959-L962 (this comment)
  • src/shard/spsc_handler.rs#L1163-L1166
  • src/shard/spsc_handler.rs#L1620-L1623
  • src/shard/spsc_handler.rs#L1825-L1828
🤖 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/shard/spsc_handler.rs` around lines 959 - 962, Make reason_del_budget
batch-scoped rather than command-scoped in all four affected sites:
src/shard/spsc_handler.rs lines 959-962, 1163-1166, 1620-1623, and 1825-1828. In
each batch arm, move its initialization beside aof_budget and before the
corresponding command loop, preserving one shared
AOF_REASON_DEL_BACKPRESSURE_BOUND budget per batch.

…d reason-DEL bound (#452.4)

send_append_bounded_blocking dropped acked records after budget
exhaustion with only an error! and a rolling counter — nothing
transitioned the server into a visible degraded state, and
eviction/expiry reason-DELs took the same 5ms bound as ordinary
appends even though a dropped reason-DEL makes restart replay
RESURRECT a key clients were told is gone.

- New sticky latch AOF_LAST_APPEND_OK, exposed as
  aof_last_append_status:{ok|err} in INFO persistence. Every drop
  site now routes through record_append_dropped() (counter + latch),
  including the rewrite-overflow fatal disarm path. Deliberately
  never resets: the AOF stays short one acked record for this
  generation's lifetime; the reset-on-clean-rewrite follow-up stays
  tracked in #452.
- Reason-DELs get a 100x escalated bound (500ms,
  AOF_REASON_DEL_BACKPRESSURE_BOUND) — not unbounded (a dead writer
  must not hang the shard; a deferred-retry queue would reorder
  DEL-after-SET on replay) — and a dedicated fail-loud counter
  aof_reason_del_dropped + resurrection-warning error! at both
  emission contexts (event-loop and conn-handler legs).

Red/green: dropped_append_latches_degraded_status (pool) and
record_reason_del_drop_is_counted_and_latches_degraded (pays the
real 500ms bound against a full capacity-1 channel).

Refs #452
author: Tin Dang
…playing past the hole (#452.2)

WAL v3 replay stopped at the first corrupt record WITHIN a segment but
kept applying every LATER segment — silently replaying operations from
after a hole (a lost SET followed by an applied INCR/DEL). Plain crash
tails are safe (rotation fsyncs the old segment before opening the
next), so a mid-chain tear can only mean on-disk corruption — the
class InnoDB/RocksDB treat as fatal-by-default.

- WalV3ReplayResult gains a `torn` flag, set on every torn-tail shape
  (corrupt record, truncated header tail, partial file header; a
  zero-byte segment stays benign).
- replay_wal_v3_dir_until refuses (InvalidData, actionable message)
  when a torn segment is followed by later segments. A tear in the
  FINAL segment keeps the pre-existing keep-the-valid-prefix behavior.
- MOON_WAL_SALVAGE=1 downgrades the abort to a loud error! and
  continues — operator-driven partial recovery only, never default.
  The internal _with_salvage variant takes the decision as a parameter
  so tests don't touch process-global env.

Red/green: mid-chain tear aborts with only the pre-hole prefix applied
(the pre-fix replay applied lsn 3 after the hole), salvage continues,
tail tear stays benign. Full lib suite green (4514).

Refs #452, #54
author: Tin Dang
…oad e2e (#54)

Adds tests/recovery_matrix_w1.rs, the whole-stack leg for the #452
durability fixes: two connections flood >channel-capacity (30k) pipelined
SET bursts against a live BGREWRITEAOF fold over a 600k-key dataset,
then SIGKILL + restart and assert exact recovery (DBSIZE + spot keys),
with a strict every-reply-must-be-+OK harness so the fail-loud
-MOONERR drop path can never masquerade as success.

Merge-base A/B on the dev host: main fails 1-2 of 3 runs (~5.6k
bounded-blocking drops per hit on the cross-shard leg); the fixed
binary is stably green on macOS and the Linux VM (monoio). The race is
probabilistic on fast disks — the deterministic drop/spill contract is
unit-level in rewrite_overflow; this is the regression net and the
recovery-exactness proof. Harness notes: auto-rewrite is disabled
because a post-flood auto fold would fold live memory into a fresh
base and heal the loss before the kill, masking the bug.

Also: CHANGELOG entry for the wave; #[allow(dead_code)] + doc for the
pre-existing wait_for_compaction helper warning in aof_auto_rewrite
(unblocks clippy --all-targets -D warnings).

Refs #54, #452
author: Tin Dang
…sarial review

Pre-merge hardening of the #452/#54 durability wave, addressing every
finding from the adversarial review of PR #454:

- P0.1 snapshot cut: RewriteOverflow::mark_cut records the buffer length
  at the fold's atomic snapshot instant (AofFold handler for sharded
  folds, under all db write guards for the single-writer fold). A
  committed finish discards [0..cut) — those entries' effects are in the
  new base, and replaying them onto it would double-apply non-idempotent
  commands (INCR/APPEND/LPUSH). Aborted finishes write everything (the
  rolled-back old base predates all spills). Parked AppendSync acks for
  discarded pre-cut entries resolve Synced after the finish fsync.
- P0.2 producer gating: every enqueue leg (try_send_append,
  send_append_bounded_blocking, send_append_backpressure,
  try_send_append_sync) checks spill_first BEFORE touching the channel —
  the fold's own phase-1/3 drains free slots mid-fold, so an ungated
  producer could place a newer record ahead of older buffered ones.
- Cap semantics: try_spill returns SpillReject::Disarmed(msg) (fall
  through to the normal path) vs CapExceeded (record the loss and drop —
  inserting into the channel while the buffer is non-empty would invert
  same-key replay order).
- U1 unwind safety: arm_scoped returns an ArmGuard; a fold panic
  (release profile unwinds) disarms with drop accounting instead of
  leaving the overflow armed forever silently eating up to 256 MiB.
- P1.4: boot callers now classify mid-chain tears (is_mid_chain_tear)
  and abort startup (exit 70) instead of falling back to legacy
  recovery, at all four WAL v3 replay sites.
- P2.5: all cap/shutdown/disconnect drop paths route through
  record_append_dropped so the degraded latch can never miss a loss.
- P2.6: finish drain is two-phase — channel (bounded by its length,
  outside the buffer lock) before buffer, disarm in the empty-swap
  round under the lock; bytes not being reset per swap keeps
  spill_first true across rounds.
- P2.7: a finish that observes shutdown_requested warns loudly.
- P2.8: reason-DEL backpressure budget is shared per eviction/expiry
  sweep (one 500ms bound total), not minted per victim key — a
  1000-victim OOM sweep against a hung disk used to block the shard
  event loop for 1000 x 500ms.

Red/green coverage: snapshot-cut discard/abort/rearm, gated
backpressure + AppendSync spill paths, ArmGuard unwind + no-op-after-
finish, shared-sweep budget fast-fail, and is_mid_chain_tear
classification pinned in the existing tear test.

Refs: #452 #54, PR #454 review
author: Tin Dang
…-order and accounting closures

Second adversarial pass on the rewrite-overflow work refuted the first
fix round with one new P0 and three narrower defects; all four are
closed here (the two remaining architectural findings are tracked in
issue #455):

- P0 abort-treated-as-commit: do_rewrite_per_shard returned Ok(()) on
  the coordinator-ABORT path (rollback to the OLD incr after a sibling
  shard failed), and both per-shard writer arms mapped is_ok() to
  committed=true — finish then DISCARDED pre-cut spills whose effects
  exist only in the PRUNED base and resolved their parked AppendSync
  acks Synced. Permanent, acked data loss on any multi-shard fold
  abort under write load. The fold now returns Ok(bool) ("new
  generation committed") and the arms pass matches!(res, Ok(true)).
- Same-key replay inversion in the empty-buffer finish window: an
  entry admitted to the channel after finish captured its bound (only
  possible while the buffer is empty) was drained AFTER newer spilled
  entries. finish now re-drains the channel (bounded by its current
  length) before writing every swap round — channel entries at any
  swap instant are provably older than that round's buffer.
- Ungated ordered leg: try_send_append_ordered (cross-shard TXN
  plumbing) bypassed the spill-first gate and dropped silently on
  Full/Disconnected. Now gated + cap/full/disconnect drops routed
  through record_append_dropped.
- Taken-batch loss accounting: a mid-batch write error in the finish
  drain lost the swapped batch's un-written remainder without
  accounting (drain(..) drop cleared it; the outer error path only
  counts what is still in the buffer). The remainder is now counted
  before the error propagates.

The stale "enqueued in-guard" claim on do_rewrite_single's mark_cut is
corrected to document the residual mutation-to-enqueue suspension gap
(issue #455), which also predates this wave on the channel-count side.

Refs: #452 #54 #455, PR #454 re-verification
author: Tin Dang
@TinDang97
TinDang97 force-pushed the fix/w1-durability-452 branch from b5cf67e to 7a17104 Compare August 8, 2026 08:39
@TinDang97
TinDang97 merged commit b346910 into main Aug 8, 2026
8 checks passed
@TinDang97
TinDang97 deleted the fix/w1-durability-452 branch August 8, 2026 08:45
TinDang97 added a commit that referenced this pull request Aug 8, 2026
… writes (#456)

Patch release rolling up the 2026-08 deep-review wave (#453) and
durability wave 1 (#452/#54, PR #454), both merged after two-round
adversarial review.

Headline: AOF rewrites no longer drop acked writes under sustained
pipelined load. A per-writer RewriteOverflow spill buffer (256 MiB cap,
strict ordering, all six fold arms on both runtimes) buffers appends
while the writer is mid-fold, with an exactly-once snapshot cut so a
committed fold discards pre-snapshot spills (effects live in the new
base) and an aborted fold writes everything. Merge-base A/B: main lost
~5.6k acked writes per hit; fixed is exact across SIGKILL + recovery
(251k appends through the overflow in the release-gate e2e). The
re-verify round closed an abort-treated-as-commit P0, a same-key
replay-inversion window, an ungated ordered-append leg, and taken-batch
loss accounting; residual architectural findings tracked in #455.

Also: WAL v3 mid-chain tears abort boot (exit 70; MOON_WAL_SALVAGE=1
override) instead of silently replaying past a hole; sticky
aof_last_append_status + per-writer aof_last_fsync_status latches;
reason-DEL escalated backpressure with one shared bound per eviction
sweep; manifest-sync failure latch; failed-spill victim re-insert;
eviction metadata widened (LFU decay, LRU inversion, OBJECT IDLETIME
wrap, WATCH ABA); cluster election acks received + inline fast path
disabled in cluster mode; CLIENT TRACKING max_keys enforced.

Validation: fmt + clippy x2 feature sets; macOS monoio 4541 + tokio
3705 and VM Linux 4563 lib tests; rewrite-under-pipelined-load e2e;
PR CI green on both PRs pre-merge; crash-matrix nightly + ITERS=20
soak dispatched on the RC (b346910), green before tag.

Rolls CHANGELOG [Unreleased] into [0.8.5], bumps Cargo.toml/lock,
adds the RELEASES.md row, updates the README milestone table.

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