Skip to content

fix(kv): v3-4 KV write correctness & data-integrity parity#206

Closed
pilotspacex-byte wants to merge 10 commits into
mainfrom
fix/v3-4-kv-correctness
Closed

fix(kv): v3-4 KV write correctness & data-integrity parity#206
pilotspacex-byte wants to merge 10 commits into
mainfrom
fix/v3-4-kv-correctness

Conversation

@pilotspacex-byte

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

Copy link
Copy Markdown
Contributor

Milestone: v3-4 — KV Write Correctness & Data-Integrity Parity

Brings Moon's primary KV engine into line with Redis's data-integrity contracts:
no wrong-type data loss, no integer-overflow panics/wraps, past-expire deletes,
atomic MSETNX, and — closing the milestone's adversarial-review Finding 1 —
durable cross-shard coordinator local legs.

Single-shard (--shards 1, the recommended default) users are unaffected by the
cross-shard items; the coordinator is bypassed when num_shards <= 1.

Changes

Data-integrity (P0)

  • Wrong-type protection: GETDEL / GETSET / SET … GET on a key holding a
    non-string now return WRONGTYPE and preserve the value (check-before-mutate),
    instead of silently deleting/overwriting it.
  • Integer overflow: DECRBY key -9223372036854775808 no longer panics; every
    expiry-setting command (SET EX/PX/EXAT, SETEX, PSETEX,
    EXPIRE/EXPIREAT/PEXPIRE) rejects a time whose absolute expiry falls outside
    the i64-millisecond domain — matching Redis. Closes a latent wrap where an
    accepted-but-huge TTL surfaced as a negative PTTL/PEXPIRETIME on a live key.
  • Expire-in-the-past: EXPIRE/PEXPIRE/EXPIREAT with a non-positive/already-past
    time deletes the key and returns 1 (Redis parity); verified to propagate through
    command-based WAL replay so replicas stay consistent.

New command

  • MSETNX — atomic multi-key string write (set all iff none exist; returns 1/0).
    Single-shard atomic (two-phase check-then-set, no await between phases). The
    cross-shard coordinator rejects a key span with CROSSSLOT (by design — no 2PC) and
    runs co-located {hash-tag} keys atomically on the owning shard.

Finding 1 (HIGH, pre-existing) — cross-shard coordinator local-leg durability

The coordinator's local leg executed co-located multi-key writes in memory but
never appended them to the owning shard's AOF, while the remote leg
(MultiExecute) always persists via wal_append_and_fanout. So a co-located
MSET/MSETNX was durable when the owner was a remote shard but silently
non-durable when the owner was the connection's own shard
(blast radius: --shards >1 + appendonly + own-shard keys).

Fix: the local leg now persists to the owning shard's AOF via a new
persist_local_leg helper — the same AofWriterPool::issue_append_lsn +
try_send_append_durable path every local single-key write already uses
(ChannelMesh has no self-send slot, so a local leg cannot route through the SPSC
wal_append_and_fanout). Granularity: the whole command for a co-located owner;
a synthesized MSET over only the local keys for a scattered MSET's local slice
(never the full scattered command — my_shard doesn't own the remote keys, and replay
re-dispatches raw commands). Returns AOF_FSYNC_ERR instead of a false +OK on append
failure. This strictly adds durability and changes nothing about replication
(live fan-out via replica_txs is SPSC-only and untouched).

Tests

  • Red/green TDD across the milestone (unit + --shards 4 integration).
  • Finding 1: tests/coordinator_local_leg_durability.rs — 3 crash-recovery tests
    (--shards 4 --appendonly yes; one co-located group per shard so exactly one is the
    local leg regardless of SO_REUSEPORT landing; SIGKILL → restart → reload). RED before
    the fix, GREEN after, on both monoio and tokio.
  • cargo fmt --check + both clippy gates clean; full lib suites green on monoio and
    tokio (MOON_NO_URING=1).

Follow-up (disclosed, not in this milestone's command scope)

The same coordinator local-leg non-durability still applies to BITOP / COPY
(via run_on_owner's local branch) and DEL / UNLINK (via
coordinate_multi_del_or_exists). These are tracked as a focused follow-up to route
their local legs through persist_local_leg too.

Summary by CodeRabbit

  • New Features
    • Added support for MSETNX, including atomic all-or-nothing writes on a single shard and CROSSSLOT rejection across shards.
  • Bug Fixes
    • Fixed wrong-type handling so GETDEL, GETSET, and SET ... GET no longer overwrite or delete values unexpectedly.
    • Improved expiration behavior to match Redis for past or non-positive TTLs, and reject invalid overflow cases instead of corrupting data.
    • Improved durability for co-located multi-key writes during restart recovery.

tindangtts added 10 commits July 2, 2026 19:30
… v3-3 scope)

Doc-only follow-through from the 2026-07-02 KV deep review. No code changes.

- BENCHMARK.md: clarify that the 0.46-0.51x p=1 figure is a bench-production.sh
  confound (distributed -r keys + 512B-4KB values + c=100/200), not a clean
  shard-count signal; the cross-shard hop is ~10us (~2% of the ~460us baseline),
  and clean bench-compare is flat 0.79->0.82 across shards.
- CLAUDE.md: correct the pending_wakers lock rule. The cross-shard reply path
  awaits a flume oneshot directly; the pending_wakers relay is still swept each
  event-loop iteration (pub/sub + backpressure) but is no longer the reply-wake
  mechanism (test swf0 retired that premise; event_loop.rs ~1730).
- v3-3-vector-kv-polish/MILESTONE.md: fold the review's KV hot-path findings into
  scope -- kv-inline-get-nocopy (blocking.rs:1275 double-copy) and the inline-path
  std::sync::RwLock replica-check site (handler_monoio/mod.rs:524) into
  kv-dispatch-lock-discipline.

Refs: tmp/KV-DEEP-REVIEW.md (durable review deliverable, gitignored)
author: Tin Dang
GETDEL called db.remove(key) BEFORE checking the value type. For a key holding
a non-string (hash/list/set/zset), the key was destroyed and the handler then
returned WRONGTYPE -- the client saw an error implying nothing happened while
the data was already gone. Unrecoverable.

Fix: peek the type with a cheap non-cloning as_bytes() borrow first; return
WRONGTYPE without mutating if the value is not a string, and only call
db.remove() once the string type is confirmed. Matches Redis check-before-
mutate semantics (APPEND/GETRANGE already do this).

Red/green TDD: test_getdel_wrongtype_preserves_key asserts the wrong-type key
still exists after GETDEL returns WRONGTYPE -- failed before the fix (key gone),
passes after.

Refs: tmp/KV-AUDIT-CORRECTNESS.md #1 (P0 CRITICAL); tmp/KV-DEEP-REVIEW.md §7
author: Tin Dang
… (data loss)

Both SET key val GET and legacy GETSET computed the WRONGTYPE error for a
non-string key but then ran db.set/db.set_string UNCONDITIONALLY, overwriting
(destroying) the hash/list/set/zset before returning the error. The client saw
WRONGTYPE while the original value was already gone. Unrecoverable.

Fix (Redis check-before-mutate parity):
- SET: return WRONGTYPE immediately after computing old_value, BEFORE the NX/XX
  blocks and before db.set -- the wrong-type error takes precedence over NX/XX,
  matching Redis's evaluation order.
- GETSET: only call db.set_string when the old value is not a wrong-type error;
  otherwise return WRONGTYPE untouched.

Red/green TDD: test_set_get_option_wrongtype_preserves_key and
test_getset_wrongtype_preserves_key assert a plain GET after the op still reports
WRONGTYPE (key unchanged). Both failed before (GET returned the new string),
pass after. Full command::string suite green (79 tests).

Refs: tmp/KV-AUDIT-CORRECTNESS.md #1 (P0 CRITICAL); tmp/KV-DEEP-REVIEW.md §7
author: Tin Dang
…low guard

Two correctness bugs on the key-TTL path:

1. Non-positive TTL (Redis parity). EXPIRE/PEXPIRE with seconds/millis <= 0
   returned "ERR invalid expire time" and left the key in place. Redis treats a
   non-positive TTL as a past-time expiry and DELETES the key immediately,
   returning 1 (or 0 if absent). EXPIREAT already did this; EXPIRE/PEXPIRE now
   mirror it. The doc comment that mislabeled the error path as "modern Redis 7+
   behavior" is corrected.

2. Unchecked u64 overflow. now_ms + seconds*1000 wrapped silently in release and
   panicked in debug ("attempt to multiply with overflow") for large seconds
   (up to i64::MAX). Now guarded with checked_mul/checked_add; an out-of-range
   expiry returns an error and leaves the key untouched, matching Redis.

Propagation verified (advisor gate): WAL replay re-dispatches the raw command
via crate::command::dispatch, so replaying `EXPIRE k -1` re-runs the fixed
handler and deletes the key -- a WAL-recovered replica stays consistent with the
master. New test persistence::replay::replay_expire_nonpositive_deletes_key
proves this (RED before fix: replay kept the key; GREEN after). CdcOp still
classifies EXPIRE as Upsert, which is pre-existing and shared with EXPIREAT --
it does not affect KV replay (raw command is re-dispatched regardless).

Red/green TDD: test_expire_negative replaced by test_expire_nonpositive_deletes;
added test_expire_nonpositive_missing_key, test_expire_overflow_rejected,
test_pexpire_nonpositive_deletes, and the two replay guards. key + replay suites
green (68 tests).

Refs: tmp/KV-AUDIT-CORRECTNESS.md #3 (P0 HIGH); tmp/KV-DEEP-REVIEW.md §7
author: Tin Dang
…ror)

Several arithmetic sites could panic in debug (attempt to multiply/negate with
overflow) and silently wrap in release, given adversarial-but-valid i64 input:

- DECRBY key <i64::MIN>: negating i64::MIN is unrepresentable. Now checked_neg
  returns "ERR decrement would overflow" (INCRBY/DECR already used checked_add
  for the value itself; only the DECRBY pre-negation was unguarded).
- SET ... EX / EXAT, SETEX, EXPIREAT: (seconds|timestamp) * 1000 (+ now_ms)
  overflowed u64 for large values. Now guarded with checked_mul/checked_add;
  an out-of-range expiry returns "ERR invalid expire time" and performs no write,
  matching Redis. PSETEX/PEXPIRE/PEXPIREAT/SET PX/PXAT operate in ms and cannot
  overflow u64 from a valid i64, so they are left as-is (SETEX/PSETEX <=0-is-error
  is already correct and unchanged).

Red/green TDD: test_decrby_min_overflow, test_setex_overflow_rejected,
test_set_ex_overflow_rejected, test_expireat_overflow_rejected. All panicked
before the fix, return clean errors after; the affected key is untouched.
string + key suites green (141 tests).

Refs: tmp/KV-AUDIT-CORRECTNESS.md #4 (P0); tmp/KV-DEEP-REVIEW.md §7
author: Tin Dang
Formatting-only: rustfmt wrapping of long assert_eq!/assert! lines in the
EXPIRE/PEXPIRE/EXPIREAT and replay test additions. No logic change.

author: Tin Dang
Moon was missing MSETNX. Add it as a Redis-parity atomic multi-key write:
set every key/value pair iff NONE of the keys already exist; return 1 when
all were set, 0 when any key existed (writing nothing).

MSETNX's all-or-nothing contract cannot be honored atomically across shards
(like MSET, cross-shard writes scatter with no two-phase commit or rollback).
Per design decision, the coordinator REJECTS a MSETNX whose keys span more
than one shard with a CROSSSLOT error and writes nothing; when the keys are
co-located on a single shard (naturally or via a {hash-tag}) it runs the
whole command atomically on that shard's owner via run_on_owner.

- string::msetnx: single-shard two-phase handler (check-all, then set-all;
  no await between phases -> atomic on one database).
- coordinate_msetnx: groups keys by shard, CROSSSLOT on span>1, else routes
  the intact command to the owning shard (local dispatch or remote MultiExecute).
- Wired into is_multi_key_command, coordinate_multi_key, the phf metadata
  table (arity -3, write, keys 1..-1 step 2), and the (6,'m') dispatch arm.

Tests (red/green verified):
- unit: all-new -> 1, one-exists -> 0 (no partial), odd-arity error.
- integration (tests/msetnx_cross_shard_reject.rs, --shards 4): provably
  cross-shard keys -> CROSSSLOT + no partial write; co-located {t} keys ->
  atomic 1/0. RED confirmed by neutralizing only the CROSSSLOT branch (the
  cross-shard case regressed to Int(1) while co-located stayed green).
- scripts: MSETNX entries in test-consistency.sh (hash-tagged for 1/4/12
  shard parity with Redis) and test-commands.sh.

author: Tin Dang
…TL wrap

The expire-overflow guards added earlier in this milestone bounded the arithmetic
against u64::MAX, but expiry is stored as u64 and read back through PTTL/PEXPIRETIME
as i64 — an absolute expiry in (i64::MAX, u64::MAX] was accepted, then surfaced as a
NEGATIVE TTL on a key that is very much alive (e.g. `EXPIRE k 15000000000000000` ->
PTTL < 0). Redis rejects such out-of-range expiries outright (`when > LLONG_MAX/1000`).

Add a shared `expiry_ms_in_range` helper (u64 <= i64::MAX) and apply it at every
expiry-setting site: EXPIRE, PEXPIRE, EXPIREAT (key.rs) and SET EX/PX/EXAT, SETEX,
PSETEX (string_write.rs). Also reorder EXPIRE/EXPIREAT so an extreme negative
(`seconds < i64::MIN/1000`) errors instead of taking the past-time delete branch —
matching Redis, which bounds negatives before deleting.

Found by the v3-4 adversarial code review (Findings 2 & 3): tightens the
kv-integer-overflow-guard task so the exit criterion "not a wrapped TTL" holds on
the READ path too, not just the set path.

Red/green TDD: 9 new unit tests (i64-bound reject + extreme-negative preserve) across
key.rs and string/mod.rs — RED before, GREEN after. Full default lib suite 3642 pass,
tokio+jemalloc 2984 pass, both clippy gates + fmt clean.

author: Tin Dang
…sclosure)

Record the whole-diff adversarial code review (SHIP verdict) in the v3-4
MILESTONE.md ship-review:
- Finding 2/3 (i64-domain expiry bound) FIXED in d6b4136 — evidence row updated
  with the new commit and the 9 red/green tests.
- Finding 1 (pre-existing cross-shard coordinator local-leg WAL durability
  asymmetry) disclosed with the verified blast radius and deferred to a dedicated
  P0 follow-up (out of v3-4 scope — a co-located MSET/MSETNX on the connection's
  own shard is not persisted, while the remote leg is; NOT an MSETNX regression).

Also commits the .add/state.json registration of v3-4 as the active milestone.

author: Tin Dang
… AOF

The cross-shard coordinator's LOCAL leg executed co-located multi-key
writes in memory but never appended them to the owning shard's AOF, while
the REMOTE leg (MultiExecute in spsc_handler) always persists via
wal_append_and_fanout. So a co-located MSET/MSETNX was durable when the
owner was a remote shard but silently non-durable when the owner was the
connection's own shard.

Blast radius: multi-shard (--shards >1) + appendonly + keys hashing to the
connection's own shard. Single-shard (the recommended default) was never
affected — the coordinator is bypassed for num_shards<=1 and normal
dispatch persists. Pre-existing; not introduced by MSETNX (it copied the
existing MSET coordinator pattern).

Fix: the local leg now persists to the owning shard's AOF via a new
persist_local_leg helper — the same AofWriterPool::issue_append_lsn +
try_send_append_durable path every local single-key write already uses
(the local-write contract, not the SPSC remote path; ChannelMesh has no
self-send slot, so a local leg cannot route through wal_append_and_fanout).
Granularity: the whole command for a co-located owner (MSETNX; MSET fast
path), and a synthesized MSET over only the local keys for a scattered
MSET's local slice (never the full scattered command — my_shard does not
own the remote keys, and replay re-dispatches raw commands). On AOF append
failure the leg returns AOF_FSYNC_ERR instead of a false +OK
(design-for-failure).

This strictly adds durability and changes nothing about replication (live
fan-out via replica_txs is SPSC-only and untouched).

Red/green crash-recovery TDD in tests/coordinator_local_leg_durability.rs
(--shards 4 --appendonly yes; one co-located group per shard so exactly one
is the local leg regardless of SO_REUSEPORT landing; SIGKILL -> restart ->
reload): RED before, GREEN after, on both monoio and tokio. fmt + both
clippy gates clean; full lib suites green on both runtimes.

Remaining (tracked follow-up, same mechanism, out of this KV milestone's
command scope): coordinator BITOP / COPY (via run_on_owner's local branch)
and DEL / UNLINK (via coordinate_multi_del_or_exists) carry the identical
local-leg non-durability and are not yet fixed.

Resolves v3-4 milestone Finding 1 (HIGH).
author: Tin Dang
@qodo-code-review

Copy link
Copy Markdown

Qodo is busy working

Check back in a few minutes. Qodo's code review agents are on it.

Grey Divider

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a KV correctness milestone with expiry overflow guards, wrong-type non-destructive checks for GETDEL/GETSET/SET..GET, a new atomic MSETNX command with cross-shard CROSSSLOT rejection, coordinator local-leg AOF durability for co-located MSET/MSETNX, along with milestone/changelog docs and extensive tests.

Changes

KV correctness and MSETNX feature

Layer / File(s) Summary
Expiry range helper
src/command/helpers.rs
Adds expiry_ms_in_range to validate absolute expiry timestamps against the i64 domain.
EXPIRE/PEXPIRE/EXPIREAT overflow and past-time semantics
src/command/key.rs
Non-positive TTLs now delete immediately; extreme-negative inputs error; checked arithmetic replaces unchecked timestamp math, with extensive new tests.
WAL replay deletion of past-expired keys
src/persistence/replay.rs
New tests confirm EXPIRE/EXPIREAT past-time deletes propagate through WAL replay.
SET/SETEX/PSETEX/DECRBY overflow and wrong-type fixes
src/command/string/string_write.rs, src/command/string/mod.rs
Checked arithmetic and expiry validation applied to SET EX/PX/EXAT, SETEX, PSETEX; wrong-type guards added to SET..GET/GETSET; DECRBY negation overflow guarded.
GETDEL wrong-type non-destructive check
src/command/string/string_read.rs, src/command/string/mod.rs
GETDEL pre-checks type before removal, returning WRONGTYPE without deleting.
MSETNX command implementation and dispatch wiring
src/command/string/string_write.rs, src/command/metadata.rs, src/command/mod.rs, src/server/conn/shared.rs, src/command/string/mod.rs
New atomic two-phase msetnx handler, registered metadata, dispatch routing, and multi-key shard classification.
Coordinator cross-shard MSET/MSETNX local-leg AOF durability
src/shard/coordinator.rs, src/server/conn/handler_monoio/dispatch.rs, src/server/conn/handler_sharded/mod.rs
Coordinator functions gain aof_pool/repl_state params to persist owned-key writes locally, with new helper functions and error handling on AOF failure.
Integration and script tests for MSETNX and durability
tests/coordinator_local_leg_durability.rs, tests/msetnx_cross_shard_reject.rs, scripts/test-commands.sh, scripts/test-consistency.sh
New Rust integration tests and shell-script coverage validate MSETNX atomicity, cross-shard rejection, and crash/restart durability.
Milestone docs, changelog, state, and benchmark notes
.add/milestones/*, .add/state.json, CHANGELOG.md, CLAUDE.md, BENCHMARK.md
Adds v3-4 milestone doc, updates v3-3 lock-discipline requirements, active-milestone state, changelog entry, waker description, and benchmark clarification.

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

Possibly related PRs

  • pilotspace/moon#68: Both PRs modify expiration command semantics in src/command/key.rs for EXPIRE/PEXPIRE/EXPIREAT handling of past/negative timestamps.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: KV write correctness and data-integrity parity fixes.
Description check ✅ Passed The description covers the milestone goal, key changes, tests, and follow-up notes, but omits the template's Checklist and Performance Impact sections.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/v3-4-kv-correctness

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix KV write correctness: WRONGTYPE safety, overflow guards, atomic MSETNX, durable coordinator local legs

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

Grey Divider

AI Description

• Prevent wrong-type GETDEL/GETSET/SET ... GET from deleting/overwriting non-strings.
• Add Redis-parity overflow and past-expire semantics across expiry setters and DECRBY.
• Introduce atomic MSETNX with CROSSSLOT rejection and durable coordinator local-leg AOF appends.
Diagram

graph TD
  C["Client"] --> H["Conn handler"] --> CO["Cross-shard coordinator"] --> LDB["Local shard DB"] --> AP["AOF writer pool"] --> AOF[("Shard AOF")]
  CO --> R["Remote shard exec"] --> AP
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Add a coordinator self-send path to reuse WAL fan-out
  • ➕ Unifies local-leg and remote-leg durability through one mechanism (wal_append_and_fanout)
  • ➕ Reduces risk of future commands forgetting to call persist_local_leg
  • ➖ Requires changes to ChannelMesh/SPSC plumbing (non-trivial concurrency surface)
  • ➖ Harder to reason about performance/latency for the in-process fast path
2. Implement cross-shard MSETNX via 2PC
  • ➕ Would allow atomic MSETNX across shards (no CROSSSLOT)
  • ➖ Large scope: coordinator protocol, logging, timeouts/rollback, failure semantics
  • ➖ Higher operational risk; expands test matrix substantially
3. Centralize multi-key durability at the coordinator (write-ahead coordinator log)
  • ➕ Single durability point; local/remote legs replay from one log
  • ➕ Could simplify reasoning about scattered multi-key recovery
  • ➖ Introduces a new persistence component and replay logic
  • ➖ Potentially adds latency and creates a bottleneck/hotspot

Recommendation: The PR’s approach (explicit persist_local_leg using the existing local single-key AOF append path) is the best scope/impact tradeoff: it fixes a concrete durability gap without reshaping coordinator networking or introducing 2PC complexity. Consider the self-send/WAL-unification alternative only if more coordinator commands need the same durability guarantee and omissions become recurring.

Files changed (22) +1820 / -40

Enhancement (3) +8 / -1
metadata.rsRegister MSETNX command metadata +1/-0

Register MSETNX command metadata

• Adds MSETNX to the PHF command metadata map with correct arity, key-spec, and ACL categories.

src/command/metadata.rs

mod.rsWire MSETNX into command dispatcher +4/-1

Wire MSETNX into command dispatcher

• Routes the MSETNX command to the string command module during dispatch.

src/command/mod.rs

shared.rsTreat MSETNX as a multi-key command for coordinator routing +3/-0

Treat MSETNX as a multi-key command for coordinator routing

• Updates multi-key detection to include MSETNX so it is coordinated (CROSSSLOT enforcement and owner-atomic execution).

src/server/conn/shared.rs

Bug fix (7) +582 / -30
helpers.rsAdd shared i64-domain expiry bound helper +13/-0

Add shared i64-domain expiry bound helper

• Introduces 'expiry_ms_in_range()' to prevent storing expiries that would wrap negative when read back via i64-casting TTL/expiretime commands.

src/command/helpers.rs

key.rsMake expire family Redis-parity: past-time delete + overflow rejection +222/-14

Make expire family Redis-parity: past-time delete + overflow rejection

• Changes EXPIRE/PEXPIRE/EXPIREAT to delete immediately on non-positive/past TTL, while rejecting arithmetic overflow and out-of-i64-range absolute expiries. Adds extensive unit tests covering delete semantics and overflow/i64-bound edge cases.

src/command/key.rs

string_read.rsFix GETDEL wrong-type data loss via check-before-mutate +16/-3

Fix GETDEL wrong-type data loss via check-before-mutate

• Ensures GETDEL peeks the entry type before removal so wrong-type keys return WRONGTYPE without being deleted.

src/command/string/string_read.rs

string_write.rsAdd MSETNX; guard DECRBY i64::MIN; harden expiry arithmetic; preserve wrong-type keys +127/-11

Add MSETNX; guard DECRBY i64::MIN; harden expiry arithmetic; preserve wrong-type keys

• Implements single-shard atomic MSETNX, prevents DECRBY from panicking/wrapping on i64::MIN, adds checked arithmetic + i64-domain bounds for SET/SETEX/PSETEX expiry options, and ensures GETSET/SET..GET return WRONGTYPE without overwriting non-strings.

src/command/string/string_write.rs

dispatch.rsThread AOF pool and replication state into coordinator calls (monoio) +2/-0

Thread AOF pool and replication state into coordinator calls (monoio)

• Passes AOF writer pool and replication state references to the cross-shard coordinator so local-leg writes can be durably appended.

src/server/conn/handler_monoio/dispatch.rs

mod.rsThread AOF pool and replication state into coordinator calls (sharded) +1/-1

Thread AOF pool and replication state into coordinator calls (sharded)

• Extends sharded handler coordinator invocation with AOF writer pool and replication state for local-leg durability.

src/server/conn/handler_sharded/mod.rs

coordinator.rsAdd MSETNX coordination and persist coordinator local legs to AOF +201/-1

Add MSETNX coordination and persist coordinator local legs to AOF

• Adds coordinate_msetnx with CROSSSLOT rejection for cross-shard spans and owner-atomic execution for co-located keys. Fixes a pre-existing durability gap by persisting local-leg MSET/MSETNX writes to the owning shard’s AOF via a shared 'persist_local_leg' helper, including a synthesized local-only MSET for scattered MSET’s local slice.

src/shard/coordinator.rs

Tests (4) +1009 / -0
mod.rsAdd unit tests for MSETNX, overflow, and wrong-type regressions +166/-0

Add unit tests for MSETNX, overflow, and wrong-type regressions

• Adds tests for MSETNX all-or-nothing behavior, DECRBY i64::MIN overflow handling, expiry overflow rejection in SET/SETEX/PSETEX, and wrong-type preservation for GETSET/SET..GET/GETDEL.

src/command/string/mod.rs

replay.rsAdd WAL replay tests for EXPIRE<=0 delete propagation +50/-0

Add WAL replay tests for EXPIRE<=0 delete propagation

• Adds replay tests verifying that EXPIRE with non-positive TTL deletes the key during replay, preventing master/replica semantic divergence.

src/persistence/replay.rs

coordinator_local_leg_durability.rsAdd crash-recovery regression tests for coordinator local-leg durability +482/-0

Add crash-recovery regression tests for coordinator local-leg durability

• Introduces SIGKILL/restart tests under '--shards 4 --appendonly yes' validating that both co-located and scattered MSET/MSETNX survive restart regardless of which shard the connection lands on.

tests/coordinator_local_leg_durability.rs

msetnx_cross_shard_reject.rsAdd integration tests for MSETNX CROSSSLOT rejection and owner-atomic behavior +311/-0

Add integration tests for MSETNX CROSSSLOT rejection and owner-atomic behavior

• Adds live server tests ensuring cross-shard MSETNX is rejected without partial writes and co-located hash-tagged MSETNX is atomic and returns correct 1/0 results.

tests/msetnx_cross_shard_reject.rs

Documentation (5) +200 / -7
MILESTONE.mdFold KV deep-review corrections into v3-3 milestone scope +15/-6

Fold KV deep-review corrections into v3-3 milestone scope

• Expands the v3-3 milestone notes with two additional KV hot-path tasks (inline GET no-copy and lock discipline for replica-role gating) based on review findings.

.add/milestones/v3-3-vector-kv-polish/MILESTONE.md

MILESTONE.mdAdd v3-4 KV correctness milestone SDD and exit criteria +147/-0

Add v3-4 KV correctness milestone SDD and exit criteria

• Introduces a new milestone doc describing scope, shared decisions, tasks, and ship evidence for KV write correctness/parity work (wrong-type safety, overflow guards, past-expire semantics, MSETNX, and coordinator durability).

.add/milestones/v3-4-kv-correctness/MILESTONE.md

BENCHMARK.mdClarify shard-count vs workload confounds in p=1 benchmark discussion +2/-0

Clarify shard-count vs workload confounds in p=1 benchmark discussion

• Adds a note explaining why a previously cited 0.46–0.51× p=1 figure is not a clean shard-count signal and points to controlled results.

BENCHMARK.md

CHANGELOG.mdDocument v3-4 KV correctness fixes and MSETNX addition +35/-0

Document v3-4 KV correctness fixes and MSETNX addition

• Adds Unreleased changelog entries covering MSETNX, wrong-type protections, overflow/expiry parity, past-expire delete semantics, and coordinator local-leg durability fix with test references.

CHANGELOG.md

CLAUDE.mdUpdate monoio cross-thread wakeup guidance to current design +1/-1

Update monoio cross-thread wakeup guidance to current design

• Corrects documentation to reflect that cross-shard replies use a flume oneshot directly, while pending_wakers remains for other paths.

CLAUDE.md

Other (3) +21 / -2
state.jsonMark v3-4-kv-correctness as the active milestone +10/-2

Mark v3-4-kv-correctness as the active milestone

• Updates project state to set the active milestone to v3-4-kv-correctness and refreshes timestamps.

.add/state.json

test-commands.shAdd MSETNX assertions to command smoke test script +4/-0

Add MSETNX assertions to command smoke test script

• Extends the string command script to validate MSETNX success and no-op-on-exists behaviors in both Moon and Redis modes.

scripts/test-commands.sh

test-consistency.shAdd MSETNX parity checks to Redis/Moon consistency script +7/-0

Add MSETNX parity checks to Redis/Moon consistency script

• Adds hash-tagged MSETNX cases to ensure shard-count parity while respecting the CROSSSLOT design for cross-shard spans.

scripts/test-consistency.sh

@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

🧹 Nitpick comments (3)
src/command/key.rs (2)

1337-2181: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

key.rs exceeds the 1500-line guideline.

The file already spans well past the 1500-line limit; this PR's new tests push it further. Consider splitting command groups/tests into submodules, particularly since this file houses many unrelated key-command families.

As per coding guidelines, "No single Rust source file should exceed 1500 lines; split command groups into submodules and move read/write operations into separate files when they exceed 1000 lines."

🤖 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/command/key.rs` around lines 1337 - 2181, The test module in key.rs is
pushing the file past the 1500-line limit, so it should be split into smaller
submodules. Move the grouped key-command tests from mod tests into separate test
modules/files organized by command family (for example, expiry, rename, scan,
type, and misc), and keep the existing helpers like setup_db_with_key,
setup_db_with_expiry, and bs in a shared test support module so the individual
test groups can reuse them cleanly.

Source: Coding guidelines


88-116: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated overflow/range-check pattern into a shared helper.

The checked_mul/checked_add + expiry_ms_in_range filter chain is duplicated three times with only the unit multiplier and base (current_time_ms() vs. 0) differing. Consolidating into helpers.rs would reduce duplication and centralize any future adjustment to the overflow-rejection semantics.

♻️ Proposed helper extraction
+/// Computes `base_ms + delta * unit_ms`, bounded to the i64 domain.
+/// Returns `None` on overflow or when the result is not representable
+/// as a positive i64 (see `expiry_ms_in_range`).
+#[inline]
+pub fn checked_expiry_ms(base_ms: u64, delta: u64, unit_ms: u64) -> Option<u64> {
+    delta
+        .checked_mul(unit_ms)
+        .and_then(|d| base_ms.checked_add(d))
+        .filter(|ms| expiry_ms_in_range(*ms))
+}
-    let expires_at_ms = match (seconds as u64)
-        .checked_mul(1000)
-        .and_then(|delta| current_time_ms().checked_add(delta))
-        .filter(|ms| expiry_ms_in_range(*ms))
-    {
+    let expires_at_ms = match checked_expiry_ms(current_time_ms(), seconds as u64, 1000) {
         Some(ms) => ms,
         None => { ... }
     };

Also applies to: 152-164, 296-308

🤖 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/command/key.rs` around lines 88 - 116, The overflow/range-check logic is
duplicated in the EXPIRE/EXPIREAT/related expiry paths, so extract the
checked_mul/checked_add plus expiry_ms_in_range sequence into a shared helper in
helpers.rs and have the key command code call that helper from the EXPIRE
handling in key.rs. Keep the helper generic over the base time and multiplier so
the existing semantics stay unchanged while centralizing the rejection behavior
for all call sites.
src/shard/coordinator.rs (1)

1-2575: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

File exceeds the 1500-line cap and this PR adds further to it.

This file is already well over the guideline's line limit (the tests module alone starts around line 2264), and this PR adds ~150 more lines (persist_local_leg, serialize_local_mset, coordinate_msetnx). Consider splitting command-group coordination logic (e.g., MSET/MSETNX coordination, or the local-leg persistence helpers) into a submodule as a follow-up.

As per coding guidelines, "No single Rust source file should exceed 1500 lines; split command groups into submodules and move read/write operations into separate files when they exceed 1000 lines."

#!/bin/bash
wc -l src/shard/coordinator.rs
🤖 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/coordinator.rs` around lines 1 - 2575, The coordinator module is
far beyond the file-size guideline and this change adds more logic to an already
oversized file. Move the newly added MSET/MSETNX local-persistence helpers and
related coordination functions out of `coordinate_multi_key` into a dedicated
submodule, keeping `src/shard/coordinator.rs` as a thin dispatcher with shared
utilities only. Use the existing symbols `persist_local_leg`,
`serialize_local_mset`, and `coordinate_msetnx` as the split boundary, and move
the `tests` module or most of it into separate test files if needed to bring the
source file back under the limit.

Source: Coding guidelines

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

Inline comments:
In `@src/command/string/string_write.rs`:
- Around line 254-274: The MSETNX flow in string_write::msetnx only validates
keys before writing, so a non-bytes value in a later pair can cause partial
writes and then an err_wrong_args("MSETNX") return. Update the MSETNX handling
to validate every key and value in the first pass, alongside the existing
existence checks, and only call db.set_string after all pairs have been
confirmed valid and absent; use the msetnx loop and extract_bytes paths to
locate the fix.

---

Nitpick comments:
In `@src/command/key.rs`:
- Around line 1337-2181: The test module in key.rs is pushing the file past the
1500-line limit, so it should be split into smaller submodules. Move the grouped
key-command tests from mod tests into separate test modules/files organized by
command family (for example, expiry, rename, scan, type, and misc), and keep the
existing helpers like setup_db_with_key, setup_db_with_expiry, and bs in a
shared test support module so the individual test groups can reuse them cleanly.
- Around line 88-116: The overflow/range-check logic is duplicated in the
EXPIRE/EXPIREAT/related expiry paths, so extract the checked_mul/checked_add
plus expiry_ms_in_range sequence into a shared helper in helpers.rs and have the
key command code call that helper from the EXPIRE handling in key.rs. Keep the
helper generic over the base time and multiplier so the existing semantics stay
unchanged while centralizing the rejection behavior for all call sites.

In `@src/shard/coordinator.rs`:
- Around line 1-2575: The coordinator module is far beyond the file-size
guideline and this change adds more logic to an already oversized file. Move the
newly added MSET/MSETNX local-persistence helpers and related coordination
functions out of `coordinate_multi_key` into a dedicated submodule, keeping
`src/shard/coordinator.rs` as a thin dispatcher with shared utilities only. Use
the existing symbols `persist_local_leg`, `serialize_local_mset`, and
`coordinate_msetnx` as the split boundary, and move the `tests` module or most
of it into separate test files if needed to bring the source file back under the
limit.
🪄 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: 3e7872d6-69aa-41a8-bac0-67f7cdc96e78

📥 Commits

Reviewing files that changed from the base of the PR and between c85af24 and cd7c51c.

📒 Files selected for processing (22)
  • .add/milestones/v3-3-vector-kv-polish/MILESTONE.md
  • .add/milestones/v3-4-kv-correctness/MILESTONE.md
  • .add/state.json
  • BENCHMARK.md
  • CHANGELOG.md
  • CLAUDE.md
  • scripts/test-commands.sh
  • scripts/test-consistency.sh
  • src/command/helpers.rs
  • src/command/key.rs
  • src/command/metadata.rs
  • src/command/mod.rs
  • src/command/string/mod.rs
  • src/command/string/string_read.rs
  • src/command/string/string_write.rs
  • src/persistence/replay.rs
  • src/server/conn/handler_monoio/dispatch.rs
  • src/server/conn/handler_sharded/mod.rs
  • src/server/conn/shared.rs
  • src/shard/coordinator.rs
  • tests/coordinator_local_leg_durability.rs
  • tests/msetnx_cross_shard_reject.rs

Comment on lines +254 to +274
// Phase 1: verify NONE of the keys exist.
for pair in args.chunks(2) {
let key = match extract_bytes(&pair[0]) {
Some(k) => k,
None => return err_wrong_args("MSETNX"),
};
if db.exists(key) {
return Frame::Integer(0);
}
}
// Phase 2: all keys absent -> set them all.
for pair in args.chunks(2) {
let key = match extract_bytes(&pair[0]) {
Some(k) => k.clone(),
None => return err_wrong_args("MSETNX"),
};
let value = match extract_bytes(&pair[1]) {
Some(v) => v.clone(),
None => return err_wrong_args("MSETNX"),
};
db.set_string(key, value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Validate all MSETNX values before any write.

Phase 1 only validates keys. If a later value is not bytes, Phase 2 can write earlier pairs and then return err_wrong_args("MSETNX"), breaking the command’s all-or-nothing contract.

Proposed fix
-    // Phase 1: verify NONE of the keys exist.
+    // Phase 1: validate all key/value frames before any semantic check or write.
+    for pair in args.chunks(2) {
+        if extract_bytes(&pair[0]).is_none() || extract_bytes(&pair[1]).is_none() {
+            return err_wrong_args("MSETNX");
+        }
+    }
+    // Phase 2: verify NONE of the keys exist.
     for pair in args.chunks(2) {
         let key = match extract_bytes(&pair[0]) {
             Some(k) => k,
             None => return err_wrong_args("MSETNX"),
         };
         if db.exists(key) {
             return Frame::Integer(0);
         }
     }
-    // Phase 2: all keys absent -> set them all.
+    // Phase 3: all keys absent -> set them all.
📝 Committable suggestion

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

Suggested change
// Phase 1: verify NONE of the keys exist.
for pair in args.chunks(2) {
let key = match extract_bytes(&pair[0]) {
Some(k) => k,
None => return err_wrong_args("MSETNX"),
};
if db.exists(key) {
return Frame::Integer(0);
}
}
// Phase 2: all keys absent -> set them all.
for pair in args.chunks(2) {
let key = match extract_bytes(&pair[0]) {
Some(k) => k.clone(),
None => return err_wrong_args("MSETNX"),
};
let value = match extract_bytes(&pair[1]) {
Some(v) => v.clone(),
None => return err_wrong_args("MSETNX"),
};
db.set_string(key, value);
// Phase 1: validate all key/value frames before any semantic check or write.
for pair in args.chunks(2) {
if extract_bytes(&pair[0]).is_none() || extract_bytes(&pair[1]).is_none() {
return err_wrong_args("MSETNX");
}
}
// Phase 2: verify NONE of the keys exist.
for pair in args.chunks(2) {
let key = match extract_bytes(&pair[0]) {
Some(k) => k,
None => return err_wrong_args("MSETNX"),
};
if db.exists(key) {
return Frame::Integer(0);
}
}
// Phase 3: all keys absent -> set them all.
for pair in args.chunks(2) {
let key = match extract_bytes(&pair[0]) {
Some(k) => k.clone(),
None => return err_wrong_args("MSETNX"),
};
let value = match extract_bytes(&pair[1]) {
Some(v) => v.clone(),
None => return err_wrong_args("MSETNX"),
};
db.set_string(key, value);
🤖 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/command/string/string_write.rs` around lines 254 - 274, The MSETNX flow
in string_write::msetnx only validates keys before writing, so a non-bytes value
in a later pair can cause partial writes and then an err_wrong_args("MSETNX")
return. Update the MSETNX handling to validate every key and value in the first
pass, alongside the existing existence checks, and only call db.set_string after
all pairs have been confirmed valid and absent; use the msetnx loop and
extract_bytes paths to locate the fix.

@pilotspacex-byte

Copy link
Copy Markdown
Contributor Author

GCloud A/B benchmark — Finding-1 durability fix validated

Measured what making the co-located MSET/MSETNX local leg actually durable (cd7c51c) costs, and whether Moon still holds up vs Redis. Ran on GCE c2d-standard-16 (16 vCPU AMD EPYC 7B13, x86_64), redis-benchmark, 50 clients, median of 3 reps.

A/B design: moon-post (cd7c51c, fix present) vs moon-pre (cd7c51c^, fix absent), md5-verified distinct. This isolates the fix's own cost from Moon's baseline-vs-Redis gap — a real fix cost shows as consistently-signed negative; noise centers on zero.

Verdict: ship. The fix's cost is free or confined to a non-default config:

fsync policy fix cost (post vs pre) Moon vs Redis
everysec (default) free — A/B straddles 0, inside ±5% noise
always, no pipeline (P1) clean −8.8% (one extra awaited fsync) Moon wins 1.27×
always + pipeline + co-located far-tail only, < 0.5% of writes (orthogonal single-hot-shard gap)

The always + pipeline far tail

It's a bounded fsync-await stacked on upstream pipeline queue wait — redis-benchmark times send→reply, so it measures their sum:

  1. Bounded await (load-independent): under always, each fsync-ack await is bounded at 2000 ms (config.rs:129 --aof-fsync-timeout-ms, a pre-existing design-for-failure guard), returning AOF_FSYNC_ERR on elapse (pool.rs:262-268).
  2. Queue wait (load-dependent): P16 × 50 clients ≈ 800 requests outstanding against the one hot shard; a command waits in the dispatch/writer queue before persist_local_leg even runs.

This resolves the n-scaling exactly: n=50k piles at ~2023 ms (bound dominates, little queue); n=100k reaches ~3000 ms (2000 ms bound + ~1000 ms queue). p50/p95/p99 all stay healthy (~28 ms) — the damage is entirely past p99.9.

No silent data loss. The tail-most writes (whose await reaches the full 2000 ms bound) fail loudly with AOF_FSYNC_ERR — never a false +OK (pool.rs contract 237–240). Server verified healthy after the run (PING/MSET/MGET correct, no panic/diskfull). A client that retries on error stays correct.

Follow-up (filed)

  • [HIGH] Route the coordinator local-leg persist through group-commit / fsync_barrier so the single hot shard batches fsyncs under pipeline (like MultiExecute does), keeping the far tail well under the 2000 ms bound.
  • [MED] The tracked BITOP / COPY / DEL / UNLINK local-leg gap should land on that same batched path.

Method + raw matrix retained as a local artifact (tmp/V3-4-GCLOUD-BENCH.md); this benchmark validates performance — durability correctness was proven separately by the crash-recovery tests (RED→GREEN, both runtimes).

pilotspacex-byte added a commit that referenced this pull request Jul 4, 2026
…c reply-slot UAF fix (#207)

Bundles everything since v0.4.1: v3-4 KV write correctness & data-integrity parity (supersedes #206), the p=1 poll-mode park (--io-busy-poll-us) + multi-shard convoy fix + L3b, the vendored monoio 0.2.4 fork, and the review-driven Arc-owned ResponseSlotPtr fix for the panic-unwind cross-shard reply UAF. Reviews: security (supply-chain) clean; rust drop-safety finding fixed in-PR. Full CI green both runtimes.
TinDang97 pushed a commit that referenced this pull request Jul 4, 2026
Version 0.4.1 -> 0.5.0 (Cargo.toml + Cargo.lock lockstep).

Bundles everything since v0.4.1 (merged in #207):
- v3-4 KV write correctness & data-integrity parity (supersedes #206).
- p=1 & multi-shard throughput: --io-busy-poll-us poll-mode park (beats
  Redis at p=1 on both GCE arches), C2 reply-spin convoy fix (multi-shard
  wins from c8 up), L3b ResponseSlotPool, corrected default docs + tuning
  guide.
- Vendored monoio 0.2.4 fork (bounded moon-patch, verified vs upstream).
- Memory-safety: Arc-owned ResponseSlotPtr closes a panic-unwind
  cross-shard reply UAF (found in review; also fixes the pre-existing
  tokio path; net -4 unsafe blocks).

Reviews: security supply-chain CLEAN; rust drop-safety finding FIXED
in-PR. Full CI green both runtimes; GCloud A/B confirmed the multi-shard
win held. shardslice cross-shard-read waiver rides (validated, retirable).

- CHANGELOG.md: reconcile [Unreleased] -> [0.5.0]
- RELEASES.md: ledger row (waiver disclosed)

Engine records the cut; tag + publish follow.

author: Tin Dang
@pilotspacex-byte

Copy link
Copy Markdown
Contributor Author

Superseded by #207 (merged): v0.5.0 bundles this exact v3-4 KV-correctness work (commits 1–10 of #207 are this branch) together with the p=1/multi-shard throughput work and the review-driven Arc reply-slot UAF fix. Closing as superseded.

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