Skip to content

feat(shard): ShardSlice Phase 4 — shared-nothing storage live, lock wrappers deleted#175

Merged
pilotspacex-byte merged 15 commits into
mainfrom
feat/shardslice-shared-nothing
Jun 12, 2026
Merged

feat(shard): ShardSlice Phase 4 — shared-nothing storage live, lock wrappers deleted#175
pilotspacex-byte merged 15 commits into
mainfrom
feat/shardslice-shared-nothing

Conversation

@pilotspacex-byte

@pilotspacex-byte pilotspacex-byte commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

ShardSlice Phase 4 — thread-local shared-nothing storage becomes the live path

init_shard is wired at shard startup on both runtimes, every command path runs on the thread-local ShardSlice, and the RwLock/Mutex wrappers are deleted from ShardDatabases. This completes the milestone's "no per-command global locks" goal: before this PR, init_shard had zero production call sites and all ~130 dual-branch points took the lock path.

Contract (frozen @ v1, approved 2026-06-12)

  • C1 boot handoffShardDatabases::new returns (Arc<Self>, Vec<ShardSliceInit>); recovery replays before construction; init_shard runs at the top of Shard::run and logs ShardSlice initialized shard=N before the first accept; assert_initialized guards the loop.
  • C2 SPSC owner-routing — four new ShardMessage variants (MqCommand, MqTxnMaterialize, WsDropCleanup, AofFold) route the five formerly-divergent MQ/WS/TXN groups to the owning shard.
  • C3 — one process-global workspace registry (control-plane, rare ops).
  • C4 AOF cooperative snapshot — the rewrite fold no longer takes foreign shard locks; the shard hands the writer a frozen snapshot + pending_aof_count, giving an exactly-once boundary (verified 10/10 serial under saturating INCR load; +1 double-apply and a fold deadlock found and fixed on the way). New fsync_barrier preserves the appendfsync=always ack for cross-shard writes.
  • C5 — per-shard store-memory atomics; Prometheus/MEMORY DOCTOR read lock-free.
  • C6 residual structShardDatabases keeps only WAL senders, the global registry, published atomics, elastic budgets, and counts.

Evidence

  • Frozen oracles: shape 5/5, live 6/6 (serial), cdg 7/7; test_ssm4a 10/10 serial repeats.
  • Lib 3594 (monoio) + full tokio integration sweep on Linux VM (first time these suites compile post-cutover; two latent defects they exposed are fixed here).
  • Crash matrix ×8 runs; loom 4/4; clippy -D warnings + fmt on all OS×featureset combos (incl. the previously never-compiled cfg(linux, runtime-tokio) uring path).
  • Consistency sweep 197/197 at shards=1/4/12.
  • Bench (idle VM, 3 reps, fresh server per rep): s1 parity; s4 parity-or-better vs routed main (+12 % on s4/P16 GET).

Accepted risk (gate: RISK-ACCEPTED, owner @tindang, expires 2026-08-01)

Main's default --cross-shard-fast-path served cross-shard GETs via a cross-thread RwLock read — definitionally incompatible with shared-nothing. Those cells regress (worst: single-client cross-shard GET 4 µs → ~47 µs SPSC round trip; P1 GET −16 %). Mitigations: {tag} co-location, --shards 1; follow-up task: lock-free cross-shard read acceleration.

Notes for reviewers

  • Branch is cherry-picked clean onto main (post-feat(vector-search): HYBRID FILTER push-down on both branches (CHANGE A–G) #174); src/command/vector_search and sdk/ are byte-identical to main. The three hybrid_filter_* test harnesses are migrated to the new boot API in the final commit.
  • ADD task record (spec → frozen contract → red suite → build → verify evidence → signed gate): .add/tasks/shardslice-migration/TASK.md.

Summary by CodeRabbit

  • New Features

    • Cooperative per-shard AOF snapshotting to improve background rewrite safety and exactly-once durability
    • Owner-routing for workspace and queue commands to ensure correct cross-shard execution
    • Per-shard published memory metrics for improved observability
  • Bug Fixes

    • Ignored zero-length AOF barrier records to prevent replay corruption
    • Reduced races during shard startup, commit and cross-shard operations
  • Performance

    • Lower lock contention by routing hot paths through thread-local shard slices
  • Tests

    • Added integration and shape-gating tests for shard-slice migration and durability

…rozen @ v1)

Specification bundle for the ShardSlice Phase 4 cutover (ADD task
shardslice-migration): wire init_shard at shard startup on BOTH runtimes,
make thread-local shared-nothing storage the live path, and delete the
RwLock/Mutex wrappers from ShardDatabases.

Bundle contents:
- TASK.md §1–§4: spec (M1–M7 musts, 6 rejects), scenarios ssm1–ssm7,
  CONTRACT FROZEN @ v1 (boot returns (ShardDatabases, Vec<ShardSliceInit>);
  new ShardMessage variants MqCommand/MqTxnMaterialize/WsDropCleanup/AofFold;
  process-global workspace registry; AOF cooperative-snapshot protocol with
  the exactly-once invariant transferred from RwLock ordering to event-loop
  ordering; published ShardStoreMemory atomics; residual ShardDatabases
  shape), test plan + verified red-suite record.
- tests/shardslice_shape.rs: code-shape pins. RED: wrappers-gone (primary),
  observer-lockfree, no-ws-field-in-slice, wrapper-resurrection guard.
  GREEN: borrow-across-await guard.
- tests/shardslice_live.rs: live-server tests. RED: slice-init log marker
  (ssm1), WS registry restart survival (confirmed pre-existing bug:
  replay_workspace_wal scans shard-{id}/ but WAL v3 segments live in
  shard-{id}/wal-v3/ — fixed by Build under frozen C3). GREEN oracles:
  WS cross-connection, AOF exactly-once across BGREWRITEAOF at 1-shard and
  4-shard --experimental-per-shard-rewrite, junk-burst wire guard.

Red suite verified on macOS (MOON_BIN pinned): shape 4 RED / 1 GREEN,
live 2 RED / 4 GREEN, clippy + fmt clean.

Contract freeze approved by Tin Dang 2026-06-12 with both lowest-confidence
flags accepted (AOF cooperative-snapshot containability; scout inventory
completeness).

author: Tin Dang
…ry atomics

C3 (contract M3 — global workspace registry):
- Replace per-shard `workspace_registries: Vec<Mutex<…>>` array in
  ShardDatabases with a single process-global `workspace_registry:
  Mutex<Option<Box<WorkspaceRegistry>>>`.
- Remove `workspace_registry` field from ShardSlice and ShardSliceInit;
  all paths use `shard_databases.workspace_registry()` directly.
- Fix `replay_workspace_wal` WAL-path bug: workspace WAL records are
  stored as outer Command(0x01) records whose payload is a complete inner
  WorkspaceCreate/WorkspaceDrop WAL record (the event loop wraps all
  wal_append bytes as Command). Replay now decodes the inner record from
  the Command payload and processes WorkspaceCreate/WorkspaceDrop types.
  Also corrects the scan directory from shard-{id}/ to shard-{id}/wal-v3/
  to match the actual WAL v3 segment location (recovery.rs:361 convention).
- test_ssm3_ws_registry_survives_restart: RED → GREEN
- test_ssm3_shape_no_ws_field_in_slice: RED → GREEN
- test_ssm3_ws_global_registry: confirmed GREEN (still passes)

C5 (contract M4 — published store-memory atomics):
- Add ShardStoreMemory struct with pub vector/text/graph AtomicUsize fields.
- Add store_memory_per_shard: Box<[Arc<ShardStoreMemory>]> to ShardDatabases.
- Add store_memory: Arc<ShardStoreMemory> to ShardSliceInit and ShardSlice.
- Publish on 100ms tick via shard_databases.publish_store_memory(shard_id):
  VectorStore::resident_bytes() (mutable+immutable), TextStore (0 — no
  aggregate API yet), GraphStore::resident_bytes() (cfg-gated).
- Migrate metrics_setup.rs update_moon_memory_bytes() to read from atomics:
  zero read_db/vector_store/graph_store_read lock acquisitions.
- Migrate server_admin.rs memory_doctor() to read from atomics.
- Replace 3 aggregate_memory() call sites in persistence_tick.rs (lines
  344, 499, 589) with lock-free published_shard_memory(shard_id) reads.

Both feature sets (default + --no-default-features --features
runtime-tokio,jemalloc) compile clean. Clippy -D warnings clean.
3578 lib tests pass on both features.

author: Tin Dang
Wave A1 of the shardslice-migration build: the four frozen-contract C2
ShardMessage variants and their owner-side execution arms. New code only —
nothing sends these messages yet (Wave B rewires the connection handlers).

- dispatch.rs: MqCommand(Box<MqCommandPayload>) / MqTxnMaterialize /
  WsDropCleanup / AofFold + AofFoldSnapshot, all within the enum size cap
  (MqCommand boxed per contract).
- shard/mq_exec.rs (NEW): execute_mq_on_owner — faithful slice-side
  extraction of all six MQ subcommand arms (CREATE/PUSH/POP/ACK/DLQLEN/
  TRIGGER) from the runtime handler mirrors, including durable flag, DLQ
  routing on max-delivery exhaustion, trigger debounce (ws_hex:key
  derivation verified against the live arm), and MqCreate/MqAck WAL records
  via the slice's wal_append_tx. No mirror divergences found.
- spsc_handler.rs: four slice-only arms (unreachable until init_shard is
  wired); AofFold reproduces the rewrite.rs phase-4 expired-filtered fold
  on the shard thread.

Both feature sets compile; clippy -D warnings clean ×2; fmt clean; 6 new
unit tests pass (effective-key/trig-key derivation, DLQLEN, CREATE round-
trip via init_shard on a dedicated test thread).

author: Tin Dang
…ntimes)

Wave B of the shardslice-migration build: the five divergence groups' SLICE
arms in both runtimes' connection handlers now owner-route through the Wave
A1 hop messages instead of operating conn-locally. Lock arms and gates are
byte-identical to pre-task state; the slice arms remain dead code at runtime
until init_shard is wired (next wave), so behavior is unchanged — proven by
the green suite.

- MQ CREATE/PUSH/POP/ACK/DLQLEN/TRIGGER (handler_monoio + handler_sharded
  write.rs): slice arm = self-short-circuit to mq_exec::execute_mq_on_owner
  when owner == conn shard, else ShardMessage::MqCommand hop + oneshot await
  (GraphCommand send precedent). Shared helpers mq_ws_prefix/mq_key_prefix +
  mq_hop_or_local/mq_dispatch_to_owner per runtime.
- handler_sharded MQ TRIGGER had NO slice gate before (scout inventory gap,
  now closed): gate added; slice arm owner-routes, lock arm is the original
  trigger_registry(owner) code.
- WS.DROP cleanup: slice arm computes the {wsid}: prefix owner; local
  with_shard_db(0) delete when self, else WsDropCleanup hop with count reply.
- TXN.COMMIT MQ materialize (both txn.rs): slice arm partitions mq_intents
  by owner shard — self group folds locally, foreign groups hop via
  MqTxnMaterialize and all acks are awaited before replying. with_shard
  closures stay synchronous; no borrow crosses an .await.
- try_handle_mq_command / try_handle_ws_command / try_handle_txn_commit
  promoted to async fn; monoio txn.rs vector_store guard rescoped to an
  explicit block so clippy await_holding_lock proves it drops pre-await.

Combined-tree verification: clippy -D warnings clean on both feature sets,
fmt clean, 3578 lib tests pass, cross_shard_consistency_red 7/7.

author: Tin Dang
…the slice path

Wave E1 of the shardslice-migration build: every slice-vs-lock dual-branch
gate outside slice.rs is collapsed to the slice arm — the gate and the
lock-path arm are deleted. ~130 decision points across 15 files
(spsc_handler 20, event_loop 19, coordinator 36, persistence_tick 9,
scatter_* 12, handler_monoio 53, handler_sharded 46, transaction/abort 1).
Net -1000+ lines of lock-path dead code.

The slice path is now UNCONDITIONAL but not yet initialized in production —
init_shard is wired in the next wave (the structural cutover: boot handoff +
wrapper deletion + AOF cooperative fold). Until then:
- default-feature lib suite: 3578/3578 green (collapsed paths not exercised
  without a live server on this set);
- tokio-feature lib suite: 4 known reds (coordinator all-local tests panic
  at slice.rs "not initialized") — expected, documented, cleared by the
  cutover wave;
- both feature sets compile; clippy -D warnings clean x2; fmt clean.

Remaining is_initialized references: slice.rs (the gate fn itself, kept for
tests) and one aof/rewrite.rs doc comment (rewritten in the cutover wave).
Unused params from deleted lock arms are _-prefixed pending removal with the
ShardDatabases wrapper fields.

author: Tin Dang
…OF drain + throttle

Root cause: `drain_pending_appends_framed` used an unbounded `while let Ok`
loop that never terminated when the INCR producer kept the AOF channel
non-empty.  This caused the per-shard fold to hang, blocking the
`await_outcome` barrier and preventing the manifest seq from advancing.
Combined with a 1 000 INCR/s writer saturating the 10 000-slot channel,
ACK'd INCRs were silently dropped and lost on restart.

Changes:

1. `src/persistence/aof/rewrite.rs` — add `max_drain: usize` parameter to
   `drain_pending_appends_framed`; break on `TryRecvError::Empty` once the
   bound is reached.  Phase 1 uses `rx.len()` snapshot as bound; phase 3
   uses `AofFoldSnapshot::pending_aof_count`.

2. `src/shard/dispatch.rs` — add `pending_aof_count: usize` field to
   `AofFoldSnapshot`; documents the single-threaded atomicity invariant that
   makes this count exact.

3. `src/shard/spsc_handler.rs` — populate `pending_aof_count` in the
   `AofFold` handler by reading `aof_pool.sender(shard_id).len()` before
   building the snapshot (no new commands can run between the read and the
   reply because the event loop is single-threaded).

4. `src/persistence/aof/pool.rs` — add `fold_producers` / `fold_notifiers`
   fields + `per_shard_with_fold_channels` constructor +
   `set_fold_channels` mutator; wire `RewritePerShard` sends to push an
   `AofFold` ShardMessage over the SPSC ring instead of the legacy lock
   path; add a C4 deadlock guard that aborts cleanly when fold channels are
   not wired.  Add a `warn!` log when `try_send_append` drops a message.

5. `src/persistence/aof/writer_task.rs` — update post-fold drain call site
   to pass `usize::MAX` (post-fold drain is bounded by writer shutdown, not
   by a snapshot count).

6. `tests/shardslice_live.rs` — throttle INCR writer from 1 000/s to
   100/s (10 ms sleep) so the 10 000-slot AOF append channel takes ~100 s
   to fill — far beyond the ~20 s fold window.  Remove temporary diagnostic
   `[DBG]` blocks and server-log dumps that were added during investigation.

Result: all 6 `shardslice_live` tests pass, 3590 lib unit tests pass,
clippy clean.

author: Tin Dang
…s deleted

Wave E2 of the shardslice-migration build — the cutover the task exists for:

- C1 boot handoff: ShardDatabases::new(dbs) now returns
  (Arc<Self>, Vec<ShardSliceInit>); recovery replays into the raw per-shard
  databases BEFORE packaging; ShardSliceInit::build_all lives in slice.rs
  with the type it builds. main.rs and embedded.rs destructure and move each
  init package into its shard thread; init_shard runs at the top of
  Shard::run on BOTH runtimes, logs "ShardSlice initialized shard=N" per
  shard (ssm1 pin), and slice::assert_initialized(shard_id) guards the
  accept loop (no is_initialized call sites outside slice.rs — ssm5 pin).
- C6 wrapper deletion: the eleven per-shard wrapper fields and the
  write_db/read_db/all_shard_dbs/aggregate_memory methods are GONE from
  ShardDatabases; the residual struct holds only WAL senders, the global
  workspace registry, published atomics, elastic budgets, and counts.
  uring_handler dispatches batches inside a single with_shard_db borrow.
  Dead Cold variant, dead params, and stale lock-path comments removed;
  ~30 test/bench construct sites updated to the tuple shape with identical
  assertions (setup migrated to reset_test_shard/init_shard patterns).
- Slice re-entrancy fix (reject "slice_reentrancy" caught live by the cdg
  suite + BITOP backtrace): the SPSC drain is no longer wrapped in
  with_shard — drain_spsc_shared/handle_shard_message_shared lost their
  &mut VectorStore parameter and every arm takes its own flat borrow.
- Cross-shard shared-read fast-path (tokio handler) removed — a fourth,
  previously un-inventoried cross-thread lock consumer (the ⚠ scout-
  completeness flag partially materialized); reads now route via SPSC
  dispatch. Perf delta lands in the M7 bench comparison.

Verified on this commit's tree: shape suite 5/5; cdg 7/7; lib 3590 (monoio)
/ 2952 (tokio) green; clippy -D warnings ×2 clean; fmt clean; 4-shard
BITOP/INCR smoke clean with 4× init markers.

Note: seven vector-search HYBRID FILTER commits from a concurrent session
are interleaved on this branch (4e9c03c..e969cf0); their uncommitted
follow-on edits are intentionally left out of this commit.

author: Tin Dang
…-shard writes

Closes the ±1 double-apply flake in test_ssm4a_fold_4shard_experimental
and restores the appendfsync=always durability ack the first fix removed.

1. C4 fold boundary (double-apply root cause): cross-shard pipelined
   writes (PipelineBatch / PipelineBatchSlotted) deferred their AOF append
   to the connection handler, AFTER drain_spsc_shared returned — so an
   AofFold processed in the same drain read sender.len() before those
   appends landed. pending_aof_count undercounted, the appends escaped
   into the NEW incr, and replay applied them on top of a base that
   already contained the mutation (+1 after restart). The SPSC arms now
   enqueue the append BEFORE the response slot fills (the same
   wal_append_and_fanout call the local path uses, error-gated), making
   the channel depth at fold time exactly the pre-snapshot append set.
   The handler-side append — the original FIX-W1-2 r2 double-write
   guard's counterpart — is deleted.

2. H1 fsync barrier: the deleted handler path was also the
   appendfsync=always durable ack (await fsync, surface AOF_FSYNC_ERR).
   New AofWriterPool::fsync_barrier(shard): under Always it enqueues a
   zero-length AppendSync and awaits the ack with the F2 bounded wait —
   the writer channel is FIFO, so one acked barrier per target shard
   proves every prior append durable. EverySec/No return immediately.
   Handlers call it once per distinct target shard after collecting
   responses and overwrite that batch's write responses with
   AOF_FSYNC_ERR on failure.

3. Barrier on-disk safety (P0 found in review): a zero-length framed
   record would be parsed by replay_incr_framed as corruption
   (empty payload -> Ok(None) -> RewriteFailed) and brick the next boot.
   Both per-shard writers and the rewrite mid-drain now recognize the
   empty payload and fsync+ack WITHOUT writing a record; the mid-drain
   still counts it toward drained (pending_aof_count is a channel-message
   count) and parks its ack for the boundary fsync. replay_incr_framed
   additionally skips len=0 records as defense-in-depth.

Also reverts the 10ms throttle a fix iteration had added to the frozen
aof_fold_exactly_once writer loop (test weakening) — the tight loop
passes once the boundary is exact.

4. Crash-matrix harness isolation (pre-existing flake, exposed by these
   reruns): the compose test's unique_port()+1 lands exactly on the port
   the straddle test's own unique_port() returns next (sequential
   ephemeral allocation), and SO_REUSEPORT lets both servers bind it
   silently — each test's redis-cli connections then split between the
   two moons and "rewrite did not compact" fires on whichever server
   lost its traffic. Dropped the +1; two independent kernel allocations
   never collide. 5x parallel + 3x serial green after the fix.

Tests: replay_incr_framed_skips_zero_length_barrier_record,
drain_framed_barrier_writes_no_bytes_but_parks_ack, H1-BARRIER pool
tests (EverySec no-op / Always ack / dead-writer WriteFailed).
Verified: ssm4a 10/10 serial at full INCR rate, shardslice_live 6/6,
crash_matrix_per_shard_bgrewriteaof, lib 3590 (monoio) + 2955 (tokio),
clippy -D warnings x2, fmt.

author: Tin Dang
…ess migration

First-ever post-cutover run of the tokio-gated integration suites (they
never compile under default monoio features, so every macOS run missed
them) surfaced two E2 gaps:

1. Slice re-entrancy in the cross-store TXN undo-capture: the write
   closure re-entered with_shard to record kv_write_intents —
   handler_sharded nested it inside with_shard_db, handler_monoio inside
   its own outer with_shard — tripping the re-entrancy guard
   (BorrowMutError) on every TXN write. Both now use direct disjoint NLL
   field borrows (db borrows s.databases; s.kv_write_intents is a
   separate field); handler_sharded's do_write takes the whole
   ShardSlice and derives db with with_shard_db's exact clamp/panic
   semantics. txn_kv_wiring under tokio: 1/12 -> 12/12.

2. 29 stale ShardDatabases::new call sites across 28 tokio-gated
   integration tests migrated to the C1 tuple API
   ((Arc<Self>, Vec<ShardSliceInit>)) and the shard.run slice_init
   parameter, mirroring the main.rs spawn pattern. 25 of those files are
   in this commit; tests/hybrid_filter_{tag,backward_compat,multishard}.rs
   received the same harness fix in the working tree but are NOT
   committed here — they carry a concurrent session's uncommitted edits
   and will land with that work (until then those three suites only
   compile from this working tree, not from a clean checkout).

Also: uring_handler's now-unused shard_id parameter underscored (the
thread-local slice IS the shard) — was the sole -D warnings failure on
the linux+tokio cfg, which had never been compiled before this pass.

Note: test_txn_commit_wal_crash_recovery on the VM requires
MOON_BIN=target-linux/debug/moon — the find_moon_binary fallback execs
the macOS Mach-O via OrbStack host-proxy and binds the host loopback
(known trap, documented in memory).

Verified: txn_kv_wiring 12/12 (VM tokio); cargo check clean both
feature sets; VM clippy -D warnings clean (tokio+jemalloc);
full VM tokio suite green (see task record).

author: Tin Dang
Build record: 9 findings fixed during build (re-entrancy ×2, fold
boundary, H1 barrier + len-0 P0, harness migrations, crash-matrix port
isolation, throttle revert), all caught by frozen oracles or
orchestrator review.

Verify evidence: full M6 matrix green (frozen oracles, lib both
runtimes, full VM tokio integration sweep, crash matrix ×8, loom,
clippy/fmt all combos, consistency 197/197 @ 1/4/12). M7 bench-swf:
s1 parity, s4 parity-or-better vs routed main; cross-shard read
fast-path cells regress by mechanism (lock reads are incompatible with
shared-nothing) — escalated to the human gate, not auto-passed.

author: Tin Dang
Gate ruled by Tin Dang 2026-06-13: read fast-path delta accepted
(non-security, flagged at freeze), follow-up task at observe;
clean cherry-picked PR branch approved.

author: Tin Dang
…ost cherry-pick

Cherry-picking the shardslice migration onto main (which now carries
PR #174 HYBRID FILTER) needed two reconciliations:
- restore the tested FtHybrid filter threading + SwapDb with_shard arm
  in spsc_handler.rs / scatter_hybrid.rs (file-level conflict resolution
  had picked the pre-filter side);
- migrate the three hybrid_filter test harnesses to the C1 tuple API +
  slice_init run() parameter (same pattern as the other 28 suites).

src/command/vector_search and sdk are byte-identical to main.

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 Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

More reviews will be available in 20 minutes and 55 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

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.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 65ffe405-ebd0-41ad-a681-0a4e75b93591

📥 Commits

Reviewing files that changed from the base of the PR and between 25c0cf3 and b3d56c7.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • src/persistence/aof/writer_task.rs
📝 Walkthrough

Walkthrough

Thread-local ShardSlice becomes the live execution path; ShardDatabases is narrowed to shared infra and returns per-shard init tokens; AOF rewrites use cooperative per-shard fold channels; dispatch and owner-routing move to slice closures; tests and shape/live suites updated.

Changes

ShardSlice Migration

Layer / File(s) Summary
All migration changes (spec, AOF, dispatch, slice, tests)
.add/state.json, .add/tasks/shardslice-migration/TASK.md, src/*, tests/*
Combines task/spec, AOF fold/pool/rewrite/writer updates, unconditional with_shard/with_shard_db dispatch refactor, new ShardMessage variants (MqCommand, MqTxnMaterialize, WsDropCleanup, AofFold), create_aof_fold_channels, execute_mq_on_owner module, ShardDatabases::new(Arc<ShardDatabases>, Vec<ShardSliceInit>), ShardSlice additions (store_memory, assert_initialized, reset_test_shard), and broad test/harness/live/shape updates to pass per-shard shard_slice_init into shard.run.

Sequence Diagram(s)

sequenceDiagram
  participant Coordinator
  participant AofWriterPool
  participant ShardEventLoop
  participant ShardSlice
  Coordinator->>AofWriterPool: try_send_rewrite_per_shard(shard_id, fold_producer, fold_notifier)
  AofWriterPool->>ShardEventLoop: enqueued RewritePerShard on shard SPSC
  ShardEventLoop->>ShardSlice: receive AofFold request
  ShardSlice-->>ShardEventLoop: reply AofFoldSnapshot (dbs, pending_aof_count)
  ShardEventLoop->>AofWriterPool: deliver AofFoldSnapshot to writer
  AofWriterPool->>AofWriterPool: fsync_barrier(target_shard)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

Possibly related issues

Possibly related PRs

  • pilotspace/moon#136: Related AOF rewrite fan-out and per-shard rewrite plumbing extended here.
  • pilotspace/moon#95: Overlaps embedded sharded bootstrap/ShardDatabases::new return-shape and AOF writer wiring.
  • pilotspace/moon#144: Related cooperative BGREWRITEAOF/drain/fsync sequencing changes.

Suggested labels

enhancement

Poem

🐇 I hop through shards with eager paws,
I stitch each fold and mind the laws.
One slice to hold each busy shard,
One hop to route where work is hard.
A thump, a sync — migration applause.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/shardslice-shared-nothing

let-else replaces the is_none guard + two 'checked above' expects;
the two per-index expects keep fail-fast semantics (zip-truncation
would wedge the rewrite coordinator) and gain the ratchet's required
allow annotations.

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

Caution

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

⚠️ Outside diff range comments (9)
src/main.rs (1)

1296-1303: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Delay /readyz until every shard has finished its slice handoff.

These lines publish readiness before any shard thread has consumed its ShardSliceInit and entered Shard::run(...). That means health checks can flip to 200 while some shards are still uninitialized, so traffic admitted immediately after the probe can hit the new slice path before startup is complete. Move the readiness flip behind an explicit per-shard startup ack/barrier after init_shard has run on every shard.

🤖 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/main.rs` around lines 1296 - 1303, The readiness flip is happening too
early — call to moon::admin::metrics_setup::set_server_ready() and
readiness_flag.store(true, ...) should be moved until after every shard has
processed its ShardSliceInit and entered Shard::run(...) following init_shard;
add an explicit per-shard startup ack/barrier (e.g., a oneshot/async channel or
Barrier) that each shard signals once init_shard completes and its run loop has
started, wait for all those acks in the main thread, then register shard DBs
(set_global_shard_databases(&shard_databases)) and finally call
set_server_ready() and store the readiness_flag; use the existing
shard-identifying symbols (init_shard, Shard::run, ShardSliceInit,
readiness_flag, set_server_ready) to locate where to wire the barrier and wait.
src/persistence/aof/pool.rs (1)

744-759: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

This test no longer exercises the partial fan-out failure path.

Because the pool is built without fold channels, try_send_rewrite_per_shard() now exits at Lines 632-643 before it ever reaches the disconnected tx0 send. The assertion still passes, but the test no longer proves the failure mode described by its name/docstring. Wire dummy fold channels here or rename the test to the missing-channel guard.

🤖 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 744 - 759, The test no longer
reaches the disconnected-tx fan-out path because
AofWriterPool::per_shard_with_base_dir was constructed without fold channels so
try_send_rewrite_per_shard returns early; fix by wiring dummy fold channels when
building the pool (so the path reaches the per-shard send and exercises tx0/tx1
failure) or alternatively rename the test to reflect it only verifies the
missing-channel guard; specifically update the pool construction in this test to
supply fold channel senders/receivers matching the pool's expected shape (so
try_send_rewrite_per_shard executes the fan-out to tx0/tx1) or change the test
name/docstring to indicate it asserts the missing-channel guard instead of
partial fan-out failure.
src/server/conn/handler_sharded/ft.rs (1)

633-728: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore FT.INVALIDATE_RANGE in the single-shard dispatcher.

This refactor dropped the single-shard FT.INVALIDATE_RANGE arm, so when ctx.num_shards == 1 the command now falls through to ERR unknown FT.* command. src/server/conn/handler_monoio/ft.rs still handles it in its single-shard path, so the runtimes diverge here.

Suggested fix
         } else if cmd.eq_ignore_ascii_case(b"FT.EXPAND") {
             #[cfg(feature = "graph")]
             {
                 crate::command::vector_search::ft_expand(&s.graph_store, cmd_args)
             }
             #[cfg(not(feature = "graph"))]
             {
                 Frame::Error(Bytes::from_static(b"ERR FT.EXPAND requires graph feature"))
             }
+        } else if cmd.eq_ignore_ascii_case(b"FT.INVALIDATE_RANGE") {
+            #[cfg(feature = "text-index")]
+            {
+                crate::command::vector_search::ft_invalidate_range(&mut s.text_store, cmd_args)
+            }
+            #[cfg(not(feature = "text-index"))]
+            {
+                Frame::Error(Bytes::from_static(
+                    b"ERR FT.INVALIDATE_RANGE requires text-index feature",
+                ))
+            }
         } else {
             Frame::Error(Bytes::from_static(b"ERR unknown FT.* command"))
         }
🤖 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/handler_sharded/ft.rs` around lines 633 - 728, The
single-shard dispatcher accidentally dropped the FT.INVALIDATE_RANGE arm so the
command falls through to "ERR unknown FT.* command"; restore it by adding an
else-if branch for cmd.eq_ignore_ascii_case(b"FT.INVALIDATE_RANGE") that calls
the vector-search handler (e.g.
crate::command::vector_search::ft_invalidate_range) and borrow stores the same
way FT.DROPINDEX does (let (vs, ts, dbs) = (&mut s.vector_store, &mut
s.text_store, &mut s.databases); then call ft_invalidate_range(vs, ts, Some(&mut
dbs[0]), cmd_args)). Ensure the branch is placed alongside the other FT.*
conditionals inside with_shard so single-shard behavior matches
handler_monoio/ft.rs.
src/shard/scatter_aggregate.rs (1)

229-259: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Initialize the test shard before calling the single-shard fast path.

Line 229 now captures the returned shard init bundle, but Line 249 still calls scatter_text_aggregate(...) without installing it first. Because the production fast path now unconditionally enters crate::shard::slice::with_shard(...), this test will panic with ShardSlice not initialized on this thread before it can assert the "unknown index" error.

Suggested fix
-        let (shard_databases, _inits) = ShardDatabases::new(vec![dbs]);
+        let (shard_databases, mut inits) = ShardDatabases::new(vec![dbs]);
+        crate::shard::slice::reset_test_shard(crate::shard::slice::ShardSlice::new(
+            inits.remove(0),
+        ));

Based on src/shard/slice.rs, with_shard requires init_shard/reset_test_shard to have run on the current thread first.

🤖 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/scatter_aggregate.rs` around lines 229 - 259, The test calls
scatter_text_aggregate (fast-path single-shard) but never installs the shard
slice returned by ShardDatabases::new, causing with_shard to panic; fix by
initializing the test shard on the current thread before calling
scatter_text_aggregate: invoke the test shard installer (e.g., call init_shard
or reset_test_shard using the returned shard init bundle from
ShardDatabases::new or otherwise install the ShardSlice) so that
with_shard/ShardSlice is initialized for this test thread prior to calling
scatter_text_aggregate.
src/server/conn/handler_monoio/txn.rs (1)

57-83: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clean up the cross-store txn before returning the killed-snapshot error.

conn.active_cross_txn.take() happens before the killed-snapshot check. On Line 74 this returns early after abort_killed(), but it never releases kv_write_intents or drains deferred HNSW inserts. The disconnect path in src/server/conn/handler_monoio/mod.rs already has to call abort_cross_store_txn_routed(...) for this cleanup; skipping it here can leave keys from a killed txn invisible after the commit is rejected.

🤖 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/handler_monoio/txn.rs` around lines 57 - 83,
conn.active_cross_txn.take() is removing the cross-store txn but when the
snapshot is killed the early return after abort_killed(...) leaves
kv_write_intents and deferred HNSW inserts uncleared; call the same cleanup used
on disconnect so the txn is fully aborted before returning the error.
Specifically, when killed is true invoke abort_cross_store_txn_routed(...) (or
the module's equivalent cleanup function) passing the taken txn to release
kv_write_intents and drain deferred HNSW inserts, then emit the error response
and return; ensure this cleanup runs while you still own the taken txn (refer to
conn.active_cross_txn.take(), abort_killed/commit, and
abort_cross_store_txn_routed).
src/server/conn/handler_sharded/mod.rs (1)

373-373: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Batch remote commands by (target_shard, db_index), not just shard.

Line 1583 reuses the first entry's DB for the whole remote batch, but remote_groups is keyed only by target. A pipelined SELECT 1, remote command, SELECT 2, remote command to the same shard will merge both commands into one bucket and execute the second command against DB 1. That breaks logical DB isolation and can read or write the wrong keyspace.

Suggested fix
-                let mut remote_groups: HashMap<usize, Vec<(usize, std::sync::Arc<Frame>, Option<Bytes>, Bytes, usize)>> = HashMap::with_capacity(ctx.num_shards);
+                let mut remote_groups: HashMap<(usize, usize), Vec<(usize, std::sync::Arc<Frame>, Option<Bytes>, Bytes)>> =
+                    HashMap::with_capacity(ctx.num_shards);
...
-                        remote_groups.entry(target).or_default().push((resp_idx, std::sync::Arc::new(dispatch_frame), aof_bytes, cmd_bytes, conn.selected_db));
+                        remote_groups
+                            .entry((target, conn.selected_db))
+                            .or_default()
+                            .push((resp_idx, std::sync::Arc::new(dispatch_frame), aof_bytes, cmd_bytes));
...
-                    for (target, entries) in remote_groups {
+                    for ((target, batch_db), entries) in remote_groups {
                         let slot_ptr = response_pool.slot_ptr(target);
-                        // Use the db_index captured with the first command (all commands in a
-                        // pipeline batch targeting the same shard share the same db_index).
-                        let batch_db = entries.first().map(|(_, _, _, _, db)| *db).unwrap_or(conn.selected_db);
                         let (meta, commands): (Vec<(usize, Option<Bytes>, Bytes)>, Vec<std::sync::Arc<Frame>>) =
-                            entries.into_iter().map(|(idx, arc_frame, aof, cmd, _db)| ((idx, aof, cmd), arc_frame)).unzip();
+                            entries.into_iter().map(|(idx, arc_frame, aof, cmd)| ((idx, aof, cmd), arc_frame)).unzip();

Also applies to: 1558-1566, 1578-1586

🤖 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/handler_sharded/mod.rs` at line 373, The batching uses
remote_groups keyed only by target shard which causes different DB indices to be
mixed; change the grouping key to include both target_shard and db_index (e.g.,
use (usize, usize) or a small struct) wherever remote_groups is declared/used,
ensure pushes populate the tuple key using the entry's db_index instead of
assuming the first entry's DB, and update the code that iterates/executes groups
(the logic around remote_groups, target_shard and the per-entry db selection) so
each batch executes against the correct DB index rather than a shard-only DB.
src/server/conn/handler_sharded/txn.rs (1)

58-84: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Abort the staged cross-store transaction when a killed snapshot rejects TXN.COMMIT.

Line 58 takes conn.active_cross_txn, but the killed-snapshot branch returns after touching only the vector-store txn manager. That skips rollback of txn.kv_undo, vector intents, MQ intents, and s.kv_write_intents. Because the disconnect cleanup in src/server/conn/handler_sharded/mod.rs only runs when conn.active_cross_txn is still present, this path can strand the transaction permanently.

Suggested fix
             if let Some(txn) = conn.active_cross_txn.take() {
+                let txn_id = txn.txn_id;
                 // MA2: reject commit if the snapshot was killed (by operator KILL SNAPSHOT
                 // or by the automatic old_snapshot_threshold sweep). A killed snapshot may
                 // have been excluded from oldest_snapshot, allowing prune_committed to
                 // advance past its LSN. Committing with a stale read set is undefined
                 // behaviour — force the client to restart the transaction.
                 let was_killed = crate::shard::slice::with_shard(|s| {
-                    if s.vector_store.txn_manager().is_killed(txn.txn_id) {
-                        s.vector_store.txn_manager_mut().abort_killed(txn.txn_id);
+                    if s.vector_store.txn_manager().is_killed(txn_id) {
+                        s.vector_store.txn_manager_mut().abort_killed(txn_id);
                         true
                     } else {
-                        s.vector_store.txn_manager_mut().commit(txn.txn_id);
+                        s.vector_store.txn_manager_mut().commit(txn_id);
                         false
                     }
                 });
                 if was_killed {
+                    crate::transaction::abort::abort_cross_store_txn_routed(
+                        &ctx.shard_databases,
+                        ctx.shard_id,
+                        conn.selected_db,
+                        ctx.num_shards,
+                        &ctx.dispatch_tx,
+                        &ctx.spsc_notifiers,
+                        txn,
+                    )
+                    .await;
                     tracing::warn!(
-                        txn_id = txn.txn_id,
+                        txn_id,
                         "TXN.COMMIT rejected: snapshot was killed (snapshot too old)"
                     );
                     let mut msg = bytes::BytesMut::new();
                     use std::fmt::Write as _;
-                    let _ = write!(msg, "MOONERR snapshot too old: {}", txn.txn_id);
+                    let _ = write!(msg, "MOONERR snapshot too old: {}", txn_id);
                     responses.push(Frame::Error(msg.freeze()));
                     return true;
                 }
🤖 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/handler_sharded/txn.rs` around lines 58 - 84, When the
killed-snapshot branch rejects TXN.COMMIT it returns after only touching the
vector-store txn manager, leaving conn.active_cross_txn resources (txn.kv_undo,
vector intents, MQ intents and s.kv_write_intents) unrolled and stranded; before
returning on was_killed you must perform the same staged-transaction
abort/cleanup that disconnect cleanup would do: take conn.active_cross_txn (as
already done), call the rollback/undo routine for txn.kv_undo, clear any vector
intents and MQ intents associated with txn.txn_id, and remove/cleanup any
s.kv_write_intents entries for that txn (or invoke the existing rollback helper
if one exists) so the transaction is fully aborted before pushing the error
response and returning.
src/shard/spsc_handler.rs (1)

1-2772: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Split this module; it exceeds the repository file-size ceiling.

src/shard/spsc_handler.rs is now ~2.7k lines, which is above the project cap and will keep increasing review/debug cost. Please split it into focused submodules (e.g., SPSC drain, execute/pipeline handlers, AOF fold/rewrite handlers, and tests).

As per coding guidelines, “No single .rs file should exceed 1500 lines. Split into submodules if approaching this limit.”

🤖 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 1 - 2772, File is too large; split
into smaller submodules and rewire exports. Move top-level SPSC loop into a
spsc_drain.rs containing drain_spsc_shared and the thread-local scratch, move
message handling into handler_shared.rs with handle_shard_message_shared and
related ShardMessage arms, extract vector logic into vector_dispatch.rs with
dispatch_vector_command and related helpers (has_session_keyword,
dispatch_vector_command internals), extract auto-indexing into auto_index.rs
with auto_index_hset_public/auto_index_hset and helpers (find_vector_blob,
handle_vector_insert*, update_metadata_only, index_payload_field,
parse_geo_value), and extract WAL/AOF fanout into wal.rs with
wal_append_and_fanout and its tests; split tests into matching test modules
(wal_append_tests, drain_cap_tests) placed next to their code. Update the parent
module to mod-declare and pub(crate) use the moved symbols (drain_spsc_shared,
handle_shard_message_shared, dispatch_vector_command, auto_index_hset_public,
wal_append_and_fanout, etc.), adjust imports (crate::... paths) and visibility
as needed, and run cargo test to fix any broken uses or privacy issues.

Source: Coding guidelines

tests/txn_kv_wiring.rs (1)

26-258: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Split the shared harness/helpers out of this file to get back under the 1500-line limit.

tests/txn_kv_wiring.rs is now 1512 lines. Extracting the reusable server boot/process helpers from this file into a tests/support module would bring it back under the repository cap without changing the individual test cases.

As per coding guidelines, "No single .rs file should exceed 1500 lines. Split into submodules if approaching this limit."

Also applies to: 845-1217

🤖 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/txn_kv_wiring.rs` around lines 26 - 258, The file exceeds the 1500-line
limit because reusable test harness and helpers (notably start_txn_server,
ChannelMesh/shard boot logic, and related helper data structures) are embedded
in tests/txn_kv_wiring.rs; extract these into a new tests/support module (e.g.,
tests/support/mod.rs or tests/support/harness.rs). Move start_txn_server,
ChannelMesh creation, shard thread spawn logic, and any helper types/constants
into that module, make the functions/types pub(crate) as needed, update
tests/txn_kv_wiring.rs to use crate::support::start_txn_server (or
tests::support) and adjust imports, and run cargo test to ensure visibility and
paths compile; keep test-specific cases in txn_kv_wiring.rs so its lines drop
below 1500.

Source: Coding guidelines

🧹 Nitpick comments (3)
src/persistence/aof_manifest/shard_replay.rs (1)

257-263: len == 0 skip aligns with the fsync barrier’s empty-payload encoding.

  • AofWriterPool::fsync_barrier enqueues AppendSync { lsn: 0, bytes: Bytes::new() }, and the per-shard writer treats an empty payload as the fsync barrier and writes no framed record to disk.
  • So any on-disk [len=0] frame can only plausibly come from a leaked/legacy barrier header or corruption; framed RESP commands are never zero-length, and the new test (replay_incr_framed_skips_zero_length_barrier_record) covers the expected leaked form (lsn=0,len=0).
  • Optional: tighten the defense-in-depth to gate on lsn == 0 too (or otherwise validate the header) to reduce the chance of silently discarding length-field corruption.
🤖 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_manifest/shard_replay.rs` around lines 257 - 263, The
current zero-length-frame handling in shard_replay.rs simply skips any record
with len == 0; tighten this defense-in-depth by only treating a zero-length
frame as the fsync barrier when its header indicates lsn == 0 (the form enqueued
by AofWriterPool::fsync_barrier / AppendSync { lsn: 0, bytes: Bytes::new() });
change the check around the len==0 branch to require header.lsn == 0 && len == 0
and, if len == 0 but header.lsn != 0, treat it as a header corruption/error
(log/return an error instead of silently advancing offset), and update/ensure
the replay_incr_framed_skips_zero_length_barrier_record test still passes.
tests/workspace_integration.rs (1)

155-156: ShardSliceInit pairing is already index-stable, but remove the front-pop and assert intent.

  • ShardDatabases::new builds Vec<ShardSliceInit> via ShardSliceInit::build_all(...).enumerate(), setting ShardSliceInit { shard_id: <index>, ... }.
  • tests/workspace_integration.rs builds all_dbs from shards created with IDs 0..num_shards, so positional pairing already corresponds to shard-id order.
  • Replace slice_inits.remove(0) with zip(..., slice_inits.into_iter()) (and optionally assert shard_slice_init.shard_id == id) to make the contract explicit.
♻️ Proposed change
-        let (shard_databases, mut slice_inits) =
+        let (shard_databases, slice_inits) =
             moon::shard::shared_databases::ShardDatabases::new(all_dbs);

+        assert_eq!(
+            slice_inits.len(),
+            num_shards,
+            "expected one ShardSliceInit per shard"
+        );
         let mut shard_handles = Vec::with_capacity(num_shards);
-        for (id, mut shard) in shards.into_iter().enumerate() {
+        for ((id, mut shard), shard_slice_init) in
+            shards.into_iter().enumerate().zip(slice_inits.into_iter())
+        {
             let producers = mesh.take_producers(id);
             let consumers = mesh.take_consumers(id);
             let conn_rx = mesh.take_conn_rx(id);
@@
-            let shard_slice_init = slice_inits.remove(0);
-
             let handle = std::thread::Builder::new()
🤖 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/workspace_integration.rs` around lines 155 - 156, The test currently
pops the first element from slice_inits (slice_inits.remove(0)) to pair shard
databases, but ShardSliceInit::build_all already assigns shard_id by enumeration
so the positional order is stable; change the pairing to use
zip(shard_databases, slice_inits.into_iter()) instead of removing the front, and
add an assert like assert_eq!(shard_slice_init.shard_id, id) inside the loop to
make the positional contract explicit; this touches the call site that invokes
ShardDatabases::new and iterates over slice_inits and uses ShardSliceInit
values.
tests/txn_ft_search_snapshot.rs (1)

171-230: ⚡ Quick win

Make the C1 shard/init handoff explicit in both test harnesses.

Both tests/txn_ft_search_snapshot.rs and tests/txn_kv_wiring.rs validate the new boot path by pulling ShardSliceInit values with slice_inits.remove(0). That keeps the happy path working, but if ShardDatabases::new ever stops returning exactly one init per shard, both harnesses will fail with a generic index panic instead of a targeted contract failure. Assert slice_inits.len() == num_shards once, then zip shards.into_iter().enumerate() with slice_inits.into_iter() so the shard/init pairing is explicit in both files.

🤖 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/txn_ft_search_snapshot.rs` around lines 171 - 230, The test currently
relies on slice_inits.remove(0) which causes an index panic if
ShardDatabases::new no longer returns exactly one init per shard; instead assert
that slice_inits.len() == num_shards and replace the for (id, mut shard) in
shards.into_iter().enumerate() { ... let shard_slice_init =
slice_inits.remove(0); ... } pattern by iterating shards and slice_inits
together (e.g., zip shards.into_iter().enumerate() with slice_inits.into_iter())
so each shard gets its corresponding ShardSliceInit explicitly; update both
tests/txn_ft_search_snapshot.rs and tests/txn_kv_wiring.rs to perform the length
assertion and use the zipped iteration to bind shard_slice_init rather than
remove(0).
🤖 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 404-418: The try_send_append function currently swallows
flume::TrySendError and only logs a warning, which lets callers (e.g. the
EverySec/No ack paths) treat an append as successful even when it was dropped;
change try_send_append to return a Result<(), Error> (or Result<bool, Error>)
and propagate the flume error to the caller instead of just logging: update the
signature of try_send_append, return Err when
sender(shard_id).try_send(AofMessage::Append { lsn, bytes }) fails (map
flume::TrySendError to your crate error type), and adjust callers in the
EverySec/No code paths to handle the failure (abort/return error to the client)
rather than proceeding as if the append succeeded. Ensure references to
try_send_append, sender, and AofMessage::Append are updated accordingly.
- Around line 138-170: per_shard_with_fold_channels (and set_fold_channels)
currently only use debug_assert_eq! for vector parity, letting a miswire slip in
release builds and causing try_send_rewrite_per_shard to later index with
expect(...) and panic; change these APIs to perform runtime validation of
lengths/contents and refuse (return a Result::Err or log+no-op) instead of
storing bad wiring: replace debug_assert_eq! checks in
per_shard_with_fold_channels and set_fold_channels with real checks that return
an error (or Result<Arc<Self>, Error>) when senders.len() !=
fold_producers.len() or != fold_notifiers.len(), and update
try_send_rewrite_per_shard to not rely on expect() — handle the invalid state
gracefully (return Err or skip rewrite) and log a clear error; update
callers/tests to handle the new Result/option behavior.

In `@src/persistence/aof/rewrite.rs`:
- Around line 871-886: AofMessage::RewriteSharded can still be sent/handled but
do_rewrite_sharded/rewrite_aof_sharded_sync are now hard-fail stubs, so either
prevent that message from being produced or route it to the PerShard
implementation; update the producer at try_send_rewrite (where
AofMessage::RewriteSharded is created in src/command/persistence.rs) to emit the
PerShard variant (or include layout check and choose per-shard), or change the
consumer in writer_task.rs that matches on AofMessage::RewriteSharded to call
the working per-shard entry (e.g., rewrite_aof_per_shard_sync /
do_rewrite_per_shard) or return a guarded error before dispatch; additionally
ensure any required fold_producers/fold_notifiers plumbing is provided when
routing to the per-shard path so the call signatures match.

In `@src/persistence/aof/writer_task.rs`:
- Around line 1032-1051: The Ok-only handling of drain_pending_appends_framed
and the subsequent sync_and_fulfill_drain error path can leave write_error unset
and continue appending into a potentially torn framed stream; update the branch
that calls drain_pending_appends_framed and the sync_and_fulfill_drain Err case
to set the shared write_error (or similar failure flag used in this function) to
the I/O error returned, stop further appends for this shard, and ensure any
resources are cleaned or the loop exits; specifically modify the code around
drain_pending_appends_framed(...) and sync_and_fulfill_drain(...) to propagate
errors into write_error (and not just log them) so later logic sees the failure
for shard_id (and avoid backdating last_fsync on error).

In `@src/server/conn/handler_monoio/txn.rs`:
- Around line 143-159: The commit path currently awaits reply_rx.recv().await
with no timeout after sending ShardMessage::MqTxnMaterialize via spsc_send,
which can hang forever if the owner shard wedges; update the code that calls
spsc_send(&ctx.dispatch_tx, ctx.shard_id, owner, msg, &ctx.spsc_notifiers).await
to await the reply with a bounded timeout/cancellation (e.g., wrap
reply_rx.recv() in the runtime timeout or cancellation API used elsewhere in
handler_monoio/mod.rs) and treat a timeout or cancelled wait as an explicit
failure that returns an error response to the client instead of hanging; ensure
the failure path cleans up any state and logs context (db_index, shard_id,
owner) so the caller of ShardMessage::MqTxnMaterialize observes a timely error.

In `@src/shard/event_loop.rs`:
- Around line 1581-1595: The monoio shutdown path is missing the same
graph-store persistence performed in the tokio branch; ensure the monoio
shutdown block calls the same logic that saves
crate::shard::slice::with_shard(|s| { if s.graph_store.graph_count() > 0 {
crate::graph::recovery::save_graph_store(...) } }) so the graph_store is written
to disk on monoio runtime shutdown as well (use the same dir and shard_id and
log warnings/errors similarly via tracing::warn! / info!).

In `@src/shard/mq_exec.rs`:
- Around line 238-248: The MqCreate WAL record must include the current db_index
so replays can target the correct DB: modify crate::mq::wal::encode_mq_create to
accept and serialize db_index (in addition to eff_key and max_delivery_count),
update the call site in handle_create (the block that constructs payload and
calls wal_append_on_slice / WalRecordType::MqCreate) to pass the current
db_index, and update the MqCreate replay/decoder path to read that db_index and
switch to/apply against that DB before restoring the queue state; reference
encode_mq_create, handle_create (the WAL write block), wal_append_on_slice, and
WalRecordType::MqCreate when making these changes.
- Around line 435-463: The current logic WAL-logs every requested id even when
stream.xack only acknowledges a subset; change the flow to compute the actual
acked IDs and persist only those: before calling stream.xack (or by changing
stream.xack to return the acked id list), determine the ackable subset from the
stream (e.g. check PEL membership for each id from msg_ids/ids or have
stream.xack return Vec<MsgId>), call xack with that subset (or keep xack as-is
but use its returned acked ids), and then iterate over that acked_ids when
calling crate::mq::wal::encode_mq_ack and wal_append_on_slice; update references
to with_shard_db, db.get_stream_mut, stream.xack, msg_ids/ids, encode_mq_ack,
and wal_append_on_slice accordingly so only actually-acked message ids are
written to WAL.

In `@src/shard/scatter_aggregate.rs`:
- Around line 56-63: Replace the panicking expect() calls in the with_shard
closure with a non-panicking error return: check s.databases.first() (and the
analogous check at the other site around lines 87-93) and if it is None return a
protocol error by returning Frame::Error(...) with an appropriate error Bytes
payload instead of calling expect/unwrap; update the closure in
crate::shard::slice::with_shard so the variable db is obtained via match/if-let
and any missing-db path returns Frame::Error(Bytes::from(...)) rather than
panicking.

In `@src/shard/shared_databases.rs`:
- Around line 397-437: The replay functions replay_mq_wal and
replay_temporal_wal are scanning shard-{id}/ instead of the nested
shard-{id}/wal-v3/ directory, so replay_wal_v3_file never sees the v3 files;
update the wal_dir construction in both functions (the code that builds wal_dir
from persistence_dir and shard_id) to point to the wal-v3 subdirectory (e.g.,
persistence_dir.join(format!("shard-{}")).join("wal-v3") or equivalent) before
listing files and call replay_wal_v3_file as before so the WAL v3 files are
discovered and replayed.

In `@src/shard/slice.rs`:
- Around line 150-179: build_all currently hard-codes
ShardSliceInit::wal_append_tx to None which leaves each ShardSlice with a
permanently stale WAL sender and causes slice-based writes to skip WAL; fix by
wiring the real mpsc_bounded sender into each slice after the sender is created
in startup (where Shard::run installs it into ShardDatabases) — locate build_all
and Shard::run and, after creating the mpsc_bounded sender, iterate the
Vec<ShardSliceInit> (or set the field when converting to ShardSlice) to set
ShardSlice::wal_append_tx = Some(sender.clone()) for the matching shard_id, or
alternatively remove the wal_append_tx field from ShardSlice and always route
WAL enqueue via the ShardDatabases path consistently.
- Around line 363-374: assert_initialized currently only checks is_initialized()
and therefore allows a shard thread to run with the wrong ShardSlice; update it
to also validate the initialized shard identity by comparing the stored init
shard id (from the global/once-initialized ShardSlice/ShardSliceInit instance)
against the shard_id parameter passed to assert_initialized, and panic with a
clear message if they differ. Locate assert_initialized and the global/once cell
that holds ShardSliceInit (or the accessor that returns the installed
ShardSlice) and add an explicit check like "installed_shard.id == shard_id" (or
equivalent accessor) so a mismatch aborts startup rather than silently using the
wrong slice. Ensure the panic message references both expected and actual shard
ids to aid debugging.

In `@tests/shardslice_shape.rs`:
- Around line 78-93: split_off_test_module currently records the last
#[cfg(test)] location unconditionally which can cut off production code if the
following non-blank line is not a test module; update split_off_test_module to,
upon seeing a trimmed == "#[cfg(test)]", scan subsequent lines (skipping blank
lines) and only set cfg_test_pos when the first non-blank trimmed line after it
is a test module declaration (e.g., starts_with "mod " and typically "mod tests"
or "mod tests {"/"mod tests;"); otherwise ignore that #[cfg(test)] and continue
scanning. Keep the same byte_offset calculation and return behavior (use the
recorded pos to slice &text[..pos] when set).

---

Outside diff comments:
In `@src/main.rs`:
- Around line 1296-1303: The readiness flip is happening too early — call to
moon::admin::metrics_setup::set_server_ready() and readiness_flag.store(true,
...) should be moved until after every shard has processed its ShardSliceInit
and entered Shard::run(...) following init_shard; add an explicit per-shard
startup ack/barrier (e.g., a oneshot/async channel or Barrier) that each shard
signals once init_shard completes and its run loop has started, wait for all
those acks in the main thread, then register shard DBs
(set_global_shard_databases(&shard_databases)) and finally call
set_server_ready() and store the readiness_flag; use the existing
shard-identifying symbols (init_shard, Shard::run, ShardSliceInit,
readiness_flag, set_server_ready) to locate where to wire the barrier and wait.

In `@src/persistence/aof/pool.rs`:
- Around line 744-759: The test no longer reaches the disconnected-tx fan-out
path because AofWriterPool::per_shard_with_base_dir was constructed without fold
channels so try_send_rewrite_per_shard returns early; fix by wiring dummy fold
channels when building the pool (so the path reaches the per-shard send and
exercises tx0/tx1 failure) or alternatively rename the test to reflect it only
verifies the missing-channel guard; specifically update the pool construction in
this test to supply fold channel senders/receivers matching the pool's expected
shape (so try_send_rewrite_per_shard executes the fan-out to tx0/tx1) or change
the test name/docstring to indicate it asserts the missing-channel guard instead
of partial fan-out failure.

In `@src/server/conn/handler_monoio/txn.rs`:
- Around line 57-83: conn.active_cross_txn.take() is removing the cross-store
txn but when the snapshot is killed the early return after abort_killed(...)
leaves kv_write_intents and deferred HNSW inserts uncleared; call the same
cleanup used on disconnect so the txn is fully aborted before returning the
error. Specifically, when killed is true invoke
abort_cross_store_txn_routed(...) (or the module's equivalent cleanup function)
passing the taken txn to release kv_write_intents and drain deferred HNSW
inserts, then emit the error response and return; ensure this cleanup runs while
you still own the taken txn (refer to conn.active_cross_txn.take(),
abort_killed/commit, and abort_cross_store_txn_routed).

In `@src/server/conn/handler_sharded/ft.rs`:
- Around line 633-728: The single-shard dispatcher accidentally dropped the
FT.INVALIDATE_RANGE arm so the command falls through to "ERR unknown FT.*
command"; restore it by adding an else-if branch for
cmd.eq_ignore_ascii_case(b"FT.INVALIDATE_RANGE") that calls the vector-search
handler (e.g. crate::command::vector_search::ft_invalidate_range) and borrow
stores the same way FT.DROPINDEX does (let (vs, ts, dbs) = (&mut s.vector_store,
&mut s.text_store, &mut s.databases); then call ft_invalidate_range(vs, ts,
Some(&mut dbs[0]), cmd_args)). Ensure the branch is placed alongside the other
FT.* conditionals inside with_shard so single-shard behavior matches
handler_monoio/ft.rs.

In `@src/server/conn/handler_sharded/mod.rs`:
- Line 373: The batching uses remote_groups keyed only by target shard which
causes different DB indices to be mixed; change the grouping key to include both
target_shard and db_index (e.g., use (usize, usize) or a small struct) wherever
remote_groups is declared/used, ensure pushes populate the tuple key using the
entry's db_index instead of assuming the first entry's DB, and update the code
that iterates/executes groups (the logic around remote_groups, target_shard and
the per-entry db selection) so each batch executes against the correct DB index
rather than a shard-only DB.

In `@src/server/conn/handler_sharded/txn.rs`:
- Around line 58-84: When the killed-snapshot branch rejects TXN.COMMIT it
returns after only touching the vector-store txn manager, leaving
conn.active_cross_txn resources (txn.kv_undo, vector intents, MQ intents and
s.kv_write_intents) unrolled and stranded; before returning on was_killed you
must perform the same staged-transaction abort/cleanup that disconnect cleanup
would do: take conn.active_cross_txn (as already done), call the rollback/undo
routine for txn.kv_undo, clear any vector intents and MQ intents associated with
txn.txn_id, and remove/cleanup any s.kv_write_intents entries for that txn (or
invoke the existing rollback helper if one exists) so the transaction is fully
aborted before pushing the error response and returning.

In `@src/shard/scatter_aggregate.rs`:
- Around line 229-259: The test calls scatter_text_aggregate (fast-path
single-shard) but never installs the shard slice returned by
ShardDatabases::new, causing with_shard to panic; fix by initializing the test
shard on the current thread before calling scatter_text_aggregate: invoke the
test shard installer (e.g., call init_shard or reset_test_shard using the
returned shard init bundle from ShardDatabases::new or otherwise install the
ShardSlice) so that with_shard/ShardSlice is initialized for this test thread
prior to calling scatter_text_aggregate.

In `@src/shard/spsc_handler.rs`:
- Around line 1-2772: File is too large; split into smaller submodules and
rewire exports. Move top-level SPSC loop into a spsc_drain.rs containing
drain_spsc_shared and the thread-local scratch, move message handling into
handler_shared.rs with handle_shard_message_shared and related ShardMessage
arms, extract vector logic into vector_dispatch.rs with dispatch_vector_command
and related helpers (has_session_keyword, dispatch_vector_command internals),
extract auto-indexing into auto_index.rs with
auto_index_hset_public/auto_index_hset and helpers (find_vector_blob,
handle_vector_insert*, update_metadata_only, index_payload_field,
parse_geo_value), and extract WAL/AOF fanout into wal.rs with
wal_append_and_fanout and its tests; split tests into matching test modules
(wal_append_tests, drain_cap_tests) placed next to their code. Update the parent
module to mod-declare and pub(crate) use the moved symbols (drain_spsc_shared,
handle_shard_message_shared, dispatch_vector_command, auto_index_hset_public,
wal_append_and_fanout, etc.), adjust imports (crate::... paths) and visibility
as needed, and run cargo test to fix any broken uses or privacy issues.

In `@tests/txn_kv_wiring.rs`:
- Around line 26-258: The file exceeds the 1500-line limit because reusable test
harness and helpers (notably start_txn_server, ChannelMesh/shard boot logic, and
related helper data structures) are embedded in tests/txn_kv_wiring.rs; extract
these into a new tests/support module (e.g., tests/support/mod.rs or
tests/support/harness.rs). Move start_txn_server, ChannelMesh creation, shard
thread spawn logic, and any helper types/constants into that module, make the
functions/types pub(crate) as needed, update tests/txn_kv_wiring.rs to use
crate::support::start_txn_server (or tests::support) and adjust imports, and run
cargo test to ensure visibility and paths compile; keep test-specific cases in
txn_kv_wiring.rs so its lines drop below 1500.

---

Nitpick comments:
In `@src/persistence/aof_manifest/shard_replay.rs`:
- Around line 257-263: The current zero-length-frame handling in shard_replay.rs
simply skips any record with len == 0; tighten this defense-in-depth by only
treating a zero-length frame as the fsync barrier when its header indicates lsn
== 0 (the form enqueued by AofWriterPool::fsync_barrier / AppendSync { lsn: 0,
bytes: Bytes::new() }); change the check around the len==0 branch to require
header.lsn == 0 && len == 0 and, if len == 0 but header.lsn != 0, treat it as a
header corruption/error (log/return an error instead of silently advancing
offset), and update/ensure the
replay_incr_framed_skips_zero_length_barrier_record test still passes.

In `@tests/txn_ft_search_snapshot.rs`:
- Around line 171-230: The test currently relies on slice_inits.remove(0) which
causes an index panic if ShardDatabases::new no longer returns exactly one init
per shard; instead assert that slice_inits.len() == num_shards and replace the
for (id, mut shard) in shards.into_iter().enumerate() { ... let shard_slice_init
= slice_inits.remove(0); ... } pattern by iterating shards and slice_inits
together (e.g., zip shards.into_iter().enumerate() with slice_inits.into_iter())
so each shard gets its corresponding ShardSliceInit explicitly; update both
tests/txn_ft_search_snapshot.rs and tests/txn_kv_wiring.rs to perform the length
assertion and use the zipped iteration to bind shard_slice_init rather than
remove(0).

In `@tests/workspace_integration.rs`:
- Around line 155-156: The test currently pops the first element from
slice_inits (slice_inits.remove(0)) to pair shard databases, but
ShardSliceInit::build_all already assigns shard_id by enumeration so the
positional order is stable; change the pairing to use zip(shard_databases,
slice_inits.into_iter()) instead of removing the front, and add an assert like
assert_eq!(shard_slice_init.shard_id, id) inside the loop to make the positional
contract explicit; this touches the call site that invokes ShardDatabases::new
and iterates over slice_inits and uses ShardSliceInit values.
🪄 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: 9b51ea18-dbdf-467e-8398-a2a279a9c175

📥 Commits

Reviewing files that changed from the base of the PR and between 58c26e8 and dadc855.

📒 Files selected for processing (73)
  • .add/state.json
  • .add/tasks/shardslice-migration/TASK.md
  • src/admin/metrics_setup.rs
  • src/command/persistence.rs
  • src/command/server_admin.rs
  • src/main.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/server/conn/blocking.rs
  • src/server/conn/handler_monoio/dispatch.rs
  • src/server/conn/handler_monoio/ft.rs
  • src/server/conn/handler_monoio/mod.rs
  • src/server/conn/handler_monoio/txn.rs
  • src/server/conn/handler_monoio/write.rs
  • src/server/conn/handler_sharded/dispatch.rs
  • src/server/conn/handler_sharded/ft.rs
  • src/server/conn/handler_sharded/mod.rs
  • src/server/conn/handler_sharded/txn.rs
  • src/server/conn/handler_sharded/write.rs
  • src/server/conn/handler_single.rs
  • src/server/conn/shared.rs
  • src/server/conn/tests.rs
  • src/server/embedded.rs
  • src/shard/coordinator.rs
  • src/shard/dispatch.rs
  • src/shard/event_loop.rs
  • src/shard/mesh.rs
  • src/shard/mod.rs
  • src/shard/mq_exec.rs
  • src/shard/persistence_tick.rs
  • src/shard/scatter_aggregate.rs
  • src/shard/scatter_hybrid.rs
  • src/shard/shared_databases.rs
  • src/shard/slice.rs
  • src/shard/spsc_handler.rs
  • src/shard/timers.rs
  • src/shard/uring_handler.rs
  • src/transaction/abort.rs
  • tests/adversarial_v0110_fix01_set_delete_rollback.rs
  • tests/adversarial_v0110_fix02_err_path_intent.rs
  • tests/adversarial_v0110_fix03_simplestring_graph.rs
  • tests/adversarial_v0110_fix04_shortest_path_call_parity.rs
  • tests/adversarial_v0110_fix06_shortest_path_min_hops.rs
  • tests/adversarial_v0110_fix07_multihop_edge_var_reject.rs
  • tests/crash_matrix_per_shard_bgrewriteaof.rs
  • tests/ft_search_as_of_boundary.rs
  • tests/ft_search_as_of_filter.rs
  • tests/ft_search_concurrent_readers.rs
  • tests/ft_search_multi_shard_as_of.rs
  • tests/ft_search_temporal_parity.rs
  • tests/hybrid_filter_backward_compat.rs
  • tests/hybrid_filter_multishard.rs
  • tests/hybrid_filter_tag.rs
  • tests/integration.rs
  • tests/kill_snapshot.rs
  • tests/lunaris_cypher_shortest_path.rs
  • tests/lunaris_cypher_temporal.rs
  • tests/lunaris_hybrid_ft_search.rs
  • tests/mq_integration.rs
  • tests/pipeline_auto_index.rs
  • tests/quickwins_red.rs
  • tests/shardslice_live.rs
  • tests/shardslice_shape.rs
  • tests/txn_completeness_edge_cases.rs
  • tests/txn_cypher_write_rollback.rs
  • tests/txn_ft_search_snapshot.rs
  • tests/txn_graph_wiring.rs
  • tests/txn_kv_wiring.rs
  • tests/workspace_integration.rs

Comment on lines +138 to 170
pub fn per_shard_with_fold_channels(
senders: Vec<channel::MpscSender<AofMessage>>,
fsync_policy: FsyncPolicy,
fsync_timeout: Duration,
base_dir: PathBuf,
fold_producers: Vec<
Arc<parking_lot::Mutex<ringbuf::HeapProd<crate::shard::dispatch::ShardMessage>>>,
>,
fold_notifiers: Vec<Arc<crate::runtime::channel::Notify>>,
) -> Arc<Self> {
debug_assert!(
senders.len() >= 2,
"per_shard pool needs >=2 writers; use top_level for single-writer"
);
debug_assert_eq!(
senders.len(),
fold_producers.len(),
"fold_producers must have one entry per shard"
);
debug_assert_eq!(
senders.len(),
fold_notifiers.len(),
"fold_notifiers must have one entry per shard"
);
Arc::new(Self {
senders,
layout: crate::persistence::aof_manifest::AofLayout::PerShard,
fsync_policy,
fsync_timeout,
base_dir: Some(base_dir),
fold_producers: Some(fold_producers),
fold_notifiers: Some(fold_notifiers),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject mis-sized fold-channel wiring in release builds.

per_shard_with_fold_channels and set_fold_channels only validate vector parity with debug_assert_eq!, but try_send_rewrite_per_shard() later indexes those vectors with expect(...). In release builds, a startup miswire turns BGREWRITEAOF into a panic instead of a clean rewrite refusal. Please add runtime validation here and refuse/log bad wiring before storing it.

As per coding guidelines, "No unwrap() or expect() in library code outside tests."

Also applies to: 189-220, 645-657

🤖 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 138 - 170,
per_shard_with_fold_channels (and set_fold_channels) currently only use
debug_assert_eq! for vector parity, letting a miswire slip in release builds and
causing try_send_rewrite_per_shard to later index with expect(...) and panic;
change these APIs to perform runtime validation of lengths/contents and refuse
(return a Result::Err or log+no-op) instead of storing bad wiring: replace
debug_assert_eq! checks in per_shard_with_fold_channels and set_fold_channels
with real checks that return an error (or Result<Arc<Self>, Error>) when
senders.len() != fold_producers.len() or != fold_notifiers.len(), and update
try_send_rewrite_per_shard to not rely on expect() — handle the invalid state
gracefully (return Err or skip rewrite) and log a clear error; update
callers/tests to handle the new Result/option behavior.

Source: Coding guidelines

Comment on lines 404 to +418
pub fn try_send_append(&self, shard_id: usize, lsn: u64, bytes: Bytes) {
let _ = self
if let Err(e) = self
.sender(shard_id)
.try_send(AofMessage::Append { lsn, bytes });
.try_send(AofMessage::Append { lsn, bytes })
{
warn!(
"AOF append dropped for shard {} (lsn {}): channel {}",
shard_id,
lsn,
match e {
flume::TrySendError::Full(_) => "full",
flume::TrySendError::Disconnected(_) => "disconnected",
}
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't turn dropped AOF appends into warning-only success.

This still drops the append on Full/Disconnected without surfacing failure to the caller. On the EverySec/No path that means the command can still be acknowledged even though the AOF entry never reached the writer, which is persisted-data loss rather than a logging concern.

🤖 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 404 - 418, The try_send_append
function currently swallows flume::TrySendError and only logs a warning, which
lets callers (e.g. the EverySec/No ack paths) treat an append as successful even
when it was dropped; change try_send_append to return a Result<(), Error> (or
Result<bool, Error>) and propagate the flume error to the caller instead of just
logging: update the signature of try_send_append, return Err when
sender(shard_id).try_send(AofMessage::Append { lsn, bytes }) fails (map
flume::TrySendError to your crate error type), and adjust callers in the
EverySec/No code paths to handle the failure (abort/return error to the client)
rather than proceeding as if the append succeeded. Ensure references to
try_send_append, sender, and AofMessage::Append are updated accordingly.

Comment thread src/persistence/aof/rewrite.rs Outdated
Comment thread src/persistence/aof/writer_task.rs
Comment on lines +143 to +159
// Foreign: send MqTxnMaterialize hop and await ack.
let (reply_tx, reply_rx) = crate::runtime::channel::oneshot();
let msg = crate::shard::dispatch::ShardMessage::MqTxnMaterialize {
db_index: conn.selected_db as usize,
intents,
reply_tx,
};
crate::shard::coordinator::spsc_send(
&ctx.dispatch_tx,
ctx.shard_id,
owner,
msg,
&ctx.spsc_notifiers,
)
.await;
// Await the ack before replying OK to the client.
let _ = reply_rx.recv().await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't let TXN.COMMIT wait forever on foreign MQ materialization.

After spsc_send(...), Line 159 does an unbounded reply_rx.recv().await. If the owner shard accepts the hop and then wedges, this monoio task never finishes the commit and the client connection stays parked indefinitely. src/server/conn/handler_monoio/mod.rs already added bounded cross-shard waits for batched dispatch for the same reason; this path needs the same timeout/cancel handling and an explicit failure instead of a permanent hang.

🤖 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/handler_monoio/txn.rs` around lines 143 - 159, The commit
path currently awaits reply_rx.recv().await with no timeout after sending
ShardMessage::MqTxnMaterialize via spsc_send, which can hang forever if the
owner shard wedges; update the code that calls spsc_send(&ctx.dispatch_tx,
ctx.shard_id, owner, msg, &ctx.spsc_notifiers).await to await the reply with a
bounded timeout/cancellation (e.g., wrap reply_rx.recv() in the runtime timeout
or cancellation API used elsewhere in handler_monoio/mod.rs) and treat a timeout
or cancelled wait as an explicit failure that returns an error response to the
client instead of hanging; ensure the failure path cleans up any state and logs
context (db_index, shard_id, owner) so the caller of
ShardMessage::MqTxnMaterialize observes a timely error.

Comment on lines +56 to +63
let result = crate::shard::slice::with_shard(|s| {
// Every shard slice is constructed with at least one database
// (db 0); see Shard::new in src/shard/mod.rs.
#[allow(clippy::expect_used)]
let db = s
.databases
.first()
.expect("shard slice must have at least db 0");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return Frame::Error here instead of panicking.

These new expect() calls turn a bad shard setup into a process panic in library code. This path should surface a protocol error, not crash the server.

Suggested fix
-            #[allow(clippy::expect_used)]
-            let db = s
-                .databases
-                .first()
-                .expect("shard slice must have at least db 0");
+            let Some(db) = s.databases.first() else {
+                return Frame::Error(Bytes::from_static(b"ERR shard has no databases"));
+            };

Apply the same change in the local-partial branch.

As per coding guidelines, "All command errors return Frame::Error(Bytes)" and "No unwrap() or expect() in library code outside tests."

Also applies to: 87-93

🤖 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/scatter_aggregate.rs` around lines 56 - 63, Replace the panicking
expect() calls in the with_shard closure with a non-panicking error return:
check s.databases.first() (and the analogous check at the other site around
lines 87-93) and if it is None return a protocol error by returning
Frame::Error(...) with an appropriate error Bytes payload instead of calling
expect/unwrap; update the closure in crate::shard::slice::with_shard so the
variable db is obtained via match/if-let and any missing-db path returns
Frame::Error(Bytes::from(...)) rather than panicking.

Source: Coding guidelines

Comment on lines +397 to +437
for init in inits.iter_mut() {
let shard_id = init.shard_id;
let wal_dir = persistence_dir.join(format!("shard-{}", shard_id));
if !wal_dir.exists() {
continue;
}

let on_command = &mut |record: &WalRecord| match record.record_type {
WalRecordType::MqCreate => {
if let Some((queue_key, max_delivery_count)) =
crate::mq::wal::decode_mq_create(&record.payload)
{
durable_configs.insert(queue_key, max_delivery_count);
}
}
WalRecordType::MqAck => {
if crate::mq::wal::decode_mq_ack(&record.payload).is_some() {
ack_count += 1;
}
let mut durable_configs: HashMap<Vec<u8>, u32> = HashMap::new();
let mut ack_count = 0u64;

let on_command = &mut |record: &WalRecord| match record.record_type {
WalRecordType::MqCreate => {
if let Some((queue_key, max_delivery_count)) =
crate::mq::wal::decode_mq_create(&record.payload)
{
durable_configs.insert(queue_key, max_delivery_count);
}
_ => {}
};
let on_fpi = &mut |_: &WalRecord| {};

// Scan WAL files in the shard directory
if let Ok(entries) = std::fs::read_dir(&wal_dir) {
let mut wal_files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_str().is_some_and(|n| n.ends_with(".wal")))
.map(|e| e.path())
.collect();
wal_files.sort();

for wal_file in &wal_files {
let _ = crate::persistence::wal_v3::replay::replay_wal_v3_file(
wal_file, 0, on_command, on_fpi,
);
}
WalRecordType::MqAck => {
if crate::mq::wal::decode_mq_ack(&record.payload).is_some() {
ack_count += 1;
}
}
_ => {}
};
let on_fpi = &mut |_: &WalRecord| {};

if let Ok(entries) = std::fs::read_dir(&wal_dir) {
let mut wal_files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_str().is_some_and(|n| n.ends_with(".wal")))
.map(|e| e.path())
.collect();
wal_files.sort();

for wal_file in &wal_files {
let _ = crate::persistence::wal_v3::replay::replay_wal_v3_file(
wal_file, 0, on_command, on_fpi,
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

wal-v3 replay is scanning the wrong directory.

These two functions call replay_wal_v3_file(...), but they read shard-{id}/ instead of shard-{id}/wal-v3/. replay_workspace_wal in the same module already uses the nested wal-v3 directory, so MQ and temporal replay will quietly miss their persisted records on normal boots. That means durable queue metadata and temporal bindings can disappear after restart.

Suggested fix
-        let wal_dir = persistence_dir.join(format!("shard-{}", shard_id));
+        let wal_dir = persistence_dir
+            .join(format!("shard-{}", shard_id))
+            .join("wal-v3");

Apply the same change in both replay_mq_wal(...) and replay_temporal_wal(...).

Also applies to: 584-659

🤖 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 397 - 437, The replay functions
replay_mq_wal and replay_temporal_wal are scanning shard-{id}/ instead of the
nested shard-{id}/wal-v3/ directory, so replay_wal_v3_file never sees the v3
files; update the wal_dir construction in both functions (the code that builds
wal_dir from persistence_dir and shard_id) to point to the wal-v3 subdirectory
(e.g., persistence_dir.join(format!("shard-{}")).join("wal-v3") or equivalent)
before listing files and call replay_wal_v3_file as before so the WAL v3 files
are discovered and replayed.

Comment thread src/shard/slice.rs
Comment on lines +150 to +179
pub fn build_all(
shard_databases: Vec<Vec<Database>>,
memory_per_shard: &[Arc<AtomicUsize>],
store_memory_per_shard: &[Arc<ShardStoreMemory>],
) -> Vec<ShardSliceInit> {
shard_databases
.into_iter()
.enumerate()
.map(|(shard_id, dbs)| {
let databases: Box<[Database]> = dbs.into_boxed_slice();
ShardSliceInit {
shard_id,
databases,
vector_store: VectorStore::new(),
text_store: TextStore::new(),
#[cfg(feature = "graph")]
graph_store: GraphStore::new(),
kv_write_intents: KvWriteIntents::new(),
deferred_hnsw_inserts: DeferredHnswInserts::new(),
temporal_registry: None,
temporal_kv_index: None,
durable_queue_registry: None,
trigger_registry: None,
wal_append_tx: None,
estimated_memory: memory_per_shard[shard_id].clone(),
store_memory: store_memory_per_shard[shard_id].clone(),
}
})
.collect()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't seed a permanently stale wal_append_tx.

build_all() hard-codes wal_append_tx: None, and Shard::run() later installs the real sender only into ShardDatabases. That leaves ShardSlice::wal_append_tx wrong whenever appendonly or disk-offload is enabled, so any slice-based write path will observe None and skip WAL enqueue. Either populate the slice after mpsc_bounded() in startup or remove this field from ShardSlice entirely.

🤖 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/slice.rs` around lines 150 - 179, build_all currently hard-codes
ShardSliceInit::wal_append_tx to None which leaves each ShardSlice with a
permanently stale WAL sender and causes slice-based writes to skip WAL; fix by
wiring the real mpsc_bounded sender into each slice after the sender is created
in startup (where Shard::run installs it into ShardDatabases) — locate build_all
and Shard::run and, after creating the mpsc_bounded sender, iterate the
Vec<ShardSliceInit> (or set the field when converting to ShardSlice) to set
ShardSlice::wal_append_tx = Some(sender.clone()) for the matching shard_id, or
alternatively remove the wal_append_tx field from ShardSlice and always route
WAL enqueue via the ShardDatabases path consistently.

Comment thread src/shard/slice.rs
Comment on lines +363 to +374
pub fn assert_initialized(shard_id: usize) {
if !is_initialized() {
// Startup abort: the shard event loop entered without init_shard.
// Panic here (before the first accept) so the process terminates
// before any command can be answered on an uninitialized thread.
panic!(
"ShardSlice not initialized on shard thread {} — \
init_shard must be called before the accept/drain loop. \
This is a startup-configuration bug, not a runtime error.",
shard_id
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate the shard identity here, not just initialization.

This only proves that some ShardSlice is installed. If shard 2 is accidentally started with shard 3's ShardSliceInit, assert_initialized(2) still passes and the thread will run on the wrong databases/stores.

Suggested fix
 pub fn assert_initialized(shard_id: usize) {
-    if !is_initialized() {
-        // Startup abort: the shard event loop entered without init_shard.
-        // Panic here (before the first accept) so the process terminates
-        // before any command can be answered on an uninitialized thread.
-        panic!(
-            "ShardSlice not initialized on shard thread {} — \
-             init_shard must be called before the accept/drain loop. \
-             This is a startup-configuration bug, not a runtime error.",
-            shard_id
-        );
-    }
+    SHARD.with(|cell| {
+        let guard = cell
+            .try_borrow()
+            .expect("assert_initialized must not run inside with_shard");
+        match guard.as_ref() {
+            Some(slice) if slice.shard_id == shard_id => {}
+            Some(slice) => panic!(
+                "ShardSlice initialized for shard {} but running on shard thread {}",
+                slice.shard_id,
+                shard_id
+            ),
+            None => panic!(
+                "ShardSlice not initialized on shard thread {} — \
+                 init_shard must be called before the accept/drain loop. \
+                 This is a startup-configuration bug, not a runtime error.",
+                shard_id
+            ),
+        }
+    });
 }
🤖 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/slice.rs` around lines 363 - 374, assert_initialized currently only
checks is_initialized() and therefore allows a shard thread to run with the
wrong ShardSlice; update it to also validate the initialized shard identity by
comparing the stored init shard id (from the global/once-initialized
ShardSlice/ShardSliceInit instance) against the shard_id parameter passed to
assert_initialized, and panic with a clear message if they differ. Locate
assert_initialized and the global/once cell that holds ShardSliceInit (or the
accessor that returns the installed ShardSlice) and add an explicit check like
"installed_shard.id == shard_id" (or equivalent accessor) so a mismatch aborts
startup rather than silently using the wrong slice. Ensure the panic message
references both expected and actual shard ids to aid debugging.

Comment thread tests/shardslice_shape.rs
Comment on lines +78 to +93
fn split_off_test_module(text: &str) -> &str {
let mut cfg_test_pos: Option<usize> = None;
let mut byte_offset = 0usize;

for line in text.lines() {
let trimmed = line.trim();
if trimmed == "#[cfg(test)]" {
cfg_test_pos = Some(byte_offset);
}
byte_offset += line.len() + 1; // +1 for the '\n'
}

match cfg_test_pos {
Some(pos) => &text[..pos],
None => text,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

split_off_test_module doesn’t implement its documented boundary rule.

The function currently truncates at the last #[cfg(test)] line, but never verifies the following non-blank line is mod tests. That can mis-slice files and make these shape-pin tests miss production violations or scan test code as production.

Suggested fix
 fn split_off_test_module(text: &str) -> &str {
-    let mut cfg_test_pos: Option<usize> = None;
-    let mut byte_offset = 0usize;
-
-    for line in text.lines() {
-        let trimmed = line.trim();
-        if trimmed == "#[cfg(test)]" {
-            cfg_test_pos = Some(byte_offset);
-        }
-        byte_offset += line.len() + 1; // +1 for the '\n'
-    }
-
-    match cfg_test_pos {
-        Some(pos) => &text[..pos],
-        None => text,
-    }
+    let lines: Vec<&str> = text.lines().collect();
+    let mut offsets = Vec::with_capacity(lines.len());
+    let mut off = 0usize;
+    for line in &lines {
+        offsets.push(off);
+        off += line.len() + 1;
+    }
+
+    for (i, line) in lines.iter().enumerate() {
+        if line.trim() == "#[cfg(test)]" {
+            let mut j = i + 1;
+            while j < lines.len() && lines[j].trim().is_empty() {
+                j += 1;
+            }
+            if j < lines.len() && lines[j].contains("mod tests") {
+                return &text[..offsets[i]];
+            }
+        }
+    }
+    text
 }
🤖 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/shardslice_shape.rs` around lines 78 - 93, split_off_test_module
currently records the last #[cfg(test)] location unconditionally which can cut
off production code if the following non-blank line is not a test module; update
split_off_test_module to, upon seeing a trimmed == "#[cfg(test)]", scan
subsequent lines (skipping blank lines) and only set cfg_test_pos when the first
non-blank trimmed line after it is a test module declaration (e.g., starts_with
"mod " and typically "mod tests" or "mod tests {"/"mod tests;"); otherwise
ignore that #[cfg(test)] and continue scanning. Keep the same byte_offset
calculation and return behavior (use the recorded pos to slice &text[..pos] when
set).

…F at --shards 1

PR review found BGREWRITEAOF silently inoperative on the TopLevel AOF
layout (always used at --shards 1): the writer routed RewriteSharded to a
stub that errored "RwLock path removed", while the client still received
"Background append only file rewriting started" and the AOF grew
unboundedly. The frozen live test missed it because its completion poll
broke out of the 30s deadline silently instead of failing.

Implement the C4 cooperative fold for the TopLevel layout on both
runtimes:

- main.rs: wire fold channels (SPSC producer + shard-0 notifier) at pool
  construction for the TopLevel layout; consumer is merged into shard 0's
  consumer set. listener/embedded pass None (no TopLevel writer there).
- do_rewrite_sharded (monoio): full fold — pre-drain (raw RESP) into the
  old generation, AofFold snapshot from shard 0, phase-3 mid-drain
  bounded by pending_aof_count (C4-DRAIN-BOUND: draining beyond it would
  pull post-snapshot appends into the old generation, which
  manifest.advance() deletes), base write, manifest advance. Clean-abort
  if fold channels are unwired — old generation stays authoritative.
- rewrite_aof_sharded_sync (tokio): same protocol against the legacy
  single appendonly.aof — bounded phase-3 mid-drain, atomic tmp+rename;
  post-snapshot appends remain queued and reach the reopened new file.
  AppendSync acks in both drains are parked and resolved only after the
  boundary fsync (Synced/FsyncFailed), never before durability.
- writer_task: thread fold channels through both writer loops; the tokio
  arm converts BufWriter -> std File for the sync fold, then reopens.
- shardslice_live test: completion poll now panics on the 30s deadline
  (no silent proceed) and recognizes all three layouts, including the
  tokio TopLevel in-place rewrite (MOON RDB magic on appendonly.aof).

Verification: VM tokio shardslice_live 6/6 (7.0s, previously 40s+ with
the silent timeout), ssm4a x3 deterministic; local monoio 6/6 + crash
matrix + lib suite; clippy -D warnings both feature sets; fmt;
audit-unsafe + unwrap ratchet clean.

Refs: #175 (shardslice-migration, contract C4)
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

Caution

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

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

140-166: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wire the C4 fold path in embedded mode too.

run_embedded now boots the sharded handler, but this path still launches the TopLevel AOF writer with fold_channels = None and never prepends an AOF-fold consumer to the shard loops. The new TopLevel rewrite helpers abort when fold channels are missing, so BGREWRITEAOF in embedded mode will fail by construction instead of folding shard 0 like main.rs does.

Also applies to: 303-367

🤖 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/rewrite.rs`:
- Around line 918-920: The TopLevel phase-1 drain currently waits for rx to
empty and can block forever; capture a pre_drain_bound = rx.len() before
starting the tokio drain loop and enforce it by introducing a local drained
counter that you increment each time you handle a ShardMessage::Append or
ShardMessage::AppendSync; break the drain loop once drained >= pre_drain_bound,
then call sync_and_fulfill_drain (same change needed for the second TopLevel
loop around the 1138-1180 region). Ensure you update the loop that calls
drain_pending_appends(rx, file) to use this bounded behavior so
ShardMessage::AofFold can be sent.
🪄 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: 907a79fd-0024-4c9d-837c-e60b51b52bbd

📥 Commits

Reviewing files that changed from the base of the PR and between f6b4edb and 25c0cf3.

📒 Files selected for processing (6)
  • src/main.rs
  • src/persistence/aof/rewrite.rs
  • src/persistence/aof/writer_task.rs
  • src/server/embedded.rs
  • src/server/listener.rs
  • tests/shardslice_live.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/shardslice_live.rs

Comment on lines +918 to 920
// Phase 1: drain pre-rewrite queued appends into old incr (RAW RESP), fsync.
let mut pre_drain = drain_pending_appends(rx, file)?;
sync_and_fulfill_drain(&mut pre_drain, file, manifest.incr_path())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bound the TopLevel phase-1 drain before starting the fold.

Both TopLevel paths still wait for the AOF queue to become empty before they send ShardMessage::AofFold. Under sustained writes that queue can stay non-empty forever, so BGREWRITEAOF never reaches the snapshot phase. The per-shard path already fixed this by snapshotting rx.len() first; the TopLevel phase-1 drain needs the same bound.

Suggested fix
-    let mut pre_drain = drain_pending_appends(rx, file)?;
+    let pre_drain_bound = rx.len();
+    let mut pre_drain = drain_pending_appends_bounded(rx, file, pre_drain_bound)?;

Mirror the same pre_drain_bound = rx.len() cap in the tokio phase-1 loop by incrementing a local drained counter for each Append / AppendSync message processed.

Also applies to: 1138-1180

🤖 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.rs` around lines 918 - 920, The TopLevel phase-1
drain currently waits for rx to empty and can block forever; capture a
pre_drain_bound = rx.len() before starting the tokio drain loop and enforce it
by introducing a local drained counter that you increment each time you handle a
ShardMessage::Append or ShardMessage::AppendSync; break the drain loop once
drained >= pre_drain_bound, then call sync_and_fulfill_drain (same change needed
for the second TopLevel loop around the 1138-1180 region). Ensure you update the
loop that calls drain_pending_appends(rx, file) to use this bounded behavior so
ShardMessage::AofFold can be sent.

CI (macOS, tokio) caught test_ssm4a_aof_fold_cooperative losing 266
acked INCRs across a SIGKILL — almost exactly one 8KB tokio BufWriter
of ~29-byte entries. The TopLevel tokio writer enforced everysec with
an interval.tick() select arm gated on last_fsync >= 1s: the gate
stretched the worst-case flush cadence to ~2s (a tick arriving at
0.999s elapsed was skipped, deferring the flush a full period), and a
long-lived tick arm is fairness-starvable under sustained writes — the
per-shard writer already documents and avoids exactly this. The new
TopLevel cooperative fold re-phased the timing enough for slow CI to
land a SIGKILL inside the stretched window.

Convert the loop to the validated per-shard pattern: 200ms
timeout-bounded recv + post-select 1s flush deadline, so the oldest
unflushed byte reaches disk at most ~1.2s after it was written
regardless of message pressure. last_fsync is back-dated by 900ms after
both rewrite reopens so the channel backlog drained during a blocking
fold reaches disk within ~100ms + wake floor instead of a full second.
monoio is unaffected (unbuffered std File — every append reaches the
page cache immediately and survives process kill).

Also adds the Unreleased CHANGELOG entry for the shared-nothing
migration (CI changelog gate requires it).

Verification: macOS tokio ssm4a x5 green; VM tokio shardslice_live 6/6;
local monoio 6/6 + lib 3594; clippy -D warnings both feature sets; fmt;
audit scripts clean.

Refs: #175 (shardslice-migration, contract C4/H1)
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.

2 participants