diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock
deleted file mode 100644
index 1a50fa8a..00000000
--- a/.claude/scheduled_tasks.lock
+++ /dev/null
@@ -1 +0,0 @@
-{"sessionId":"66332041-4f74-42df-8954-9e0482baacdd","pid":11173,"acquiredAt":1775933454187}
\ No newline at end of file
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index 72a0aacb..9f34dd3a 100644
--- a/.github/workflows/integration-tests.yml
+++ b/.github/workflows/integration-tests.yml
@@ -33,6 +33,27 @@ jobs:
run: cargo test --release --no-default-features --features runtime-tokio,jemalloc --test jepsen_lite
timeout-minutes: 10
+ crash-matrix-per-shard:
+ name: Crash Matrix (per-shard AOF)
+ if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'ci-full')
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ # MOON_NO_URING is already set globally at the workflow level (line 16).
+ # No job-level env override needed — monoio falls back to epoll automatically.
+ steps:
+ - uses: actions/checkout@v6
+ - uses: dtolnay/rust-toolchain@1.94.1
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: integration-${{ hashFiles('Cargo.lock') }}
+ - name: Install redis-tools
+ run: sudo apt-get update -qq && sudo apt-get install -y -qq redis-tools
+ - name: Build Moon (release, monoio default)
+ run: cargo build --release
+ - name: Run per-shard AOF crash matrix
+ run: cargo test --release --test crash_matrix_per_shard_aof -- --ignored
+ timeout-minutes: 10
+
replication:
name: Replication Tests
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'ci-full')
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1be8c9d0..9a6e1798 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,10 +6,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.2.0-alpha] — Unreleased
-First slice of the v0.2 "Option C" beachhead: **Point-in-Time Recovery (PITR)**
-and **Change Data Capture (CDC)**, built additively on top of the existing
-per-shard WAL v3 + dual-root manifest. No changes to the KV hot path, MVCC,
-page format, or transaction layer.
+The v0.2 enterprise beachhead. Built additively on per-shard WAL v3 + the
+dual-root manifest; no changes to the KV hot path, MVCC, page format, or
+transaction layer.
+
+**Headline capabilities landed in alpha:**
+
+- **Point-in-Time Recovery (PITR)** — `--recovery-target-lsn` /
+ `--recovery-target-time` restore to any LSN or wall-clock boundary
+ inside the WAL retention window.
+- **Change Data Capture (CDC)** — `CDC.READ` polling command with
+ Debezium-compatible JSON envelopes, resumable cursors, segment-rotation
+ safety.
+- **Hash-field TTL** — full Valkey 9.0 / 9.1 surface (`HEXPIRE` /
+ `HPEXPIRE` / `HEXPIREAT` / `HPEXPIREAT` / `HEXPIRETIME` / `HPEXPIRETIME`
+ / `HTTL` / `HPTTL` / `HPERSIST` / `HGETDEL` / `HGETEX`) with O(1) HGET +
+ HLEN fast path. Three-way benchmark vs Redis 8.0.2 / Valkey 9.1.0
+ ships in `docs/perf/2026-05-27-hash-ttl-3way-bench.md`.
+- **Tier 2 Lane A** — `SWAPDB`, `MOVE`, `COPY ... DB n`,
+ `CLUSTER REPLICAS` / `SLAVES`, `CLUSTER COUNT-FAILURE-REPORTS`. All
+ WAL-durable with cross-shard atomic semantics.
+- **Storage format v1 commitment** — RDB v2 + WAL v3 + multi-part AOF
+ manifest grouped under a single `--storage-format v1` umbrella with
+ ≥18-month LTS forward-read guarantees.
+- **Embedded sharded server** — `server::embedded::run_embedded(config,
+ cancel)` exposes the full sharded handler (with `TXN.*`) to in-process
+ embedders.
+
+**What is not yet in alpha:** PITR live-snapshot LSN wiring (P3c),
+`CDC.SUBSCRIBE` push channel (C3b), and the multi-shard master PSYNC
+deferred from v0.1.10. Tracked in `.planning/rfcs/v02-enterprise-architecture.md`.
### Docs — Hash-field TTL three-way benchmark suite (PR #127)
@@ -427,6 +453,96 @@ See `docs/guides/cdc.md` for consumer integration.
and the benchmark gates (PITR restart ±10%, CDC ≥100K events/s/shard,
write p99 ±5%).
+## [0.1.12] — 2026-05-12
+
+Performance & memory observability release. 50 commits since v0.1.11, no
+public API breaks, no on-disk format change. Validated on OrbStack `moon-dev`
+(2026-05-12) and locally green for both `runtime-monoio` and
+`runtime-tokio,jemalloc`.
+
+### Performance — DashTable hot-path (Phase 189, PERF-07 + PERF-09)
+
+- **Pre-sized DashTable.** `DashTable::with_capacity()` plus the new
+ `--initial-keyspace-hint ` flag size the segment array up front so
+ steady-state operation hits zero `split_segment` calls. Pre-size
+ invariant test confirms zero splits at 1 M keys. The 27 % CPU spent
+ in `split_segment` during SET p=16 (PERF-07) is fully eliminated.
+- **`Database::set` rewrite.** New `DashTable::insert_or_update` /
+ `Segment::insert_or_update_at` single-probe helpers replace the
+ previous `find + remove + insert` triple-probe pattern.
+- **`Segment::find` fallback elimination + force-inlined SIMD.** The
+ cold "key spilled to non-home group" fallback path is removed once
+ `has_non_home_keys` is invariant-tracked on insert (the
+ `insert_or_update_at` change above already maintains the flag);
+ the SIMD probe helpers are `#[inline(always)]`. PERF-09
+ attributed 12.65 % of `Segment::find` self-time to the fallback;
+ remaining cost is the irreducible per-hit `memcmp` confirm
+ (threshold amended to <3 %). 1 M-key correctness gate validates
+ zero false positives/negatives.
+
+### Performance — Memory observability (Phase 190)
+
+- **`moon_memory_bytes{kind=…}` Prometheus gauge.** Seven subsystem
+ labels — `dashtable`, `hnsw`, `csr`, `wal`, `sealed_replication_backlog`,
+ `allocator_overhead`, and the rolled-up `total`. Updated every
+ scrape via a single hook so the sum reconciles to `RSS` within the
+ CI tolerance window.
+- **`MEMORY DOCTOR` full schema.** Multi-line RESP response covering
+ every subsystem, the rolled-up total, and a derived `allocator_overhead`
+ pseudo-kind (RSS − Σ subsystems). Adds operator triage signal beyond
+ the legacy single-line summary.
+- **`resident_bytes()` trait** implemented across `Database`,
+ `DashTable`, `VectorStore` (HNSW + IVF), `GraphStore` (CSR + SlotMap),
+ `WalWriter`, `ReplicationBacklog` (sealed-segment side), and
+ `AllocatorOverhead`. Zero-allocation, on-demand poll.
+- **Memory steady-state CI job.** `scripts/bench-memory-steady-state.sh`
+ + baseline fixture; gate widened to `±10 %` on RSS / Σ ratio after a
+ Linux-CI tolerance pass.
+
+### Changed — Allocator UX (Phase 191)
+
+- **jemalloc `narenas:8` cap** with `--memory-arenas-cap ` CLI
+ override. Caps the per-CPU arena explosion that inflates VSZ on
+ high-core hosts; mostly a cosmetic fix on Linux containers but
+ produces a meaningfully tighter `top`/`ps` reading for operators.
+- **Tri-state allocator selection.** New `mimalloc-alt` cargo
+ feature alongside the existing `jemalloc` / `mimalloc` (fallback)
+ paths; mutually exclusive at compile time. A/B benchmark script
+ `scripts/bench-allocator-ab.sh` ships with the release.
+- **`docs/OPERATOR-GUIDE.md` — Memory Accounting section.** Documents
+ the VSZ-vs-RSS distinction, MEMORY DOCTOR field-by-field, and the
+ `--memory-arenas-cap` / `mimalloc-alt` tuning knobs.
+
+### Added — Dispatch Observability (Phase 177)
+
+- **`moon_dispatch_path_total{path=...}` Prometheus counter**: four-way classification of every command by shard-routing decision — `local_inline` (SIMD fast path), `local` (standard local branch), `cross_read_fast` (RwLock shared-read bypass of SPSC), `cross_spsc` (deferred cross-shard write via `PipelineBatchSlotted`). Ratio `cross_spsc / Σ` is the ground-truth signal for dispatch-layer optimization work. Zero-allocation hot-path overhead (`&'static str` labels, `#[inline]` with early-return on `!METRICS_INITIALIZED`). Verified on macOS + Linux: counter sums close exactly to driven traffic, no overcount.
+
+### Changed
+
+- **`text-index` is now a default feature.** BM25 full-text search (`FT.SEARCH` BM25 mode), `FT.AGGREGATE`, and three-way RRF hybrid fusion are included in all standard builds. No longer requires `--features text-index`. To exclude it (e.g. minimal embedded builds): `--no-default-features --features runtime-monoio,jemalloc,graph`.
+
+### Added — SDK Validation
+
+- **Python SDK `sdk/python/examples/validate.py`**: End-to-end live validator for all SDK sub-clients: ping, strings, counter, hash, list, set, zset, vector index lifecycle, graph engine, session search, semantic cache, text search (BM25 + aggregate + hybrid), and server info. Result against Moon with `text-index`: **114 PASS / 0 FAIL / 0 SKIP**. Gracefully skips text sections when server built without `text-index`.
+- **Rust SDK `sdk/rust/examples/validate.rs`**: Re-validated against `text-index` build — **85 PASS / 0 FAIL**.
+
+### Fixed — Python SDK
+
+- **`moondb.graph._parse_neighbors`**: server returns alternating `[edge_map, node_map, ...]` as flat key-value arrays (`b'id'`, `int`, `b'src'`, `int`, `b'dst'`, `int`, …). Previous parser expected positional `[node_id, label, props]` — caused `int() on b'id'` crash. Now correctly identifies node entries by `labels` key and parses them from the flat kv format.
+
+### Fixed — CI Hygiene
+
+- **`tests/pipeline_auto_index.rs`**: tighten outer cfg from `runtime-tokio` to `all(runtime-tokio, text-index)` so the file compiles to zero tests when text-index is disabled. Previously the file compiled but the FT.SEARCH text fast path was `#[cfg]`-ed out, causing `@name:corpus` queries to fall through to the KNN-only parser and panic with "invalid KNN query syntax".
+- **4 FT unwraps**: add inline `#[allow(clippy::unwrap_used)]` with invariant justifications in `vector_search/ft_text_search.rs` (3 sites inside `apply_post_processing` where `do_summarize` / `do_highlight` implies the Option is Some) and `handler_monoio/ft.rs:165` (`is_text` was derived from `query_bytes.as_ref().map_or(false, _)`). Restores the audit-unwrap baseline to 0.
+
+### Compatibility
+
+- **Wire protocol**: unchanged. Drop-in replacement for v0.1.11.
+- **Persistence on-disk format**: unchanged.
+- **Default feature set**: `text-index` is now on by default. Minimal
+ embedded builds need an explicit `--no-default-features --features
+ runtime-monoio,jemalloc,graph`.
+
## [0.1.11] — 2026-04-27
Hot-path perf release — eliminates two atomic-CAS hot paths in the write
@@ -537,29 +653,47 @@ All 2450 lib tests passing locally (`cargo test --no-default-features
- **Persistence on-disk format**: unchanged.
- **Replication wire format**: unchanged.
-## [Unreleased]
-
-### Added — Dispatch Observability (Phase 177)
-
-- **`moon_dispatch_path_total{path=...}` Prometheus counter**: four-way classification of every command by shard-routing decision — `local_inline` (SIMD fast path), `local` (standard local branch), `cross_read_fast` (RwLock shared-read bypass of SPSC), `cross_spsc` (deferred cross-shard write via `PipelineBatchSlotted`). Ratio `cross_spsc / Σ` is the ground-truth signal for dispatch-layer optimization work. Zero-allocation hot-path overhead (`&'static str` labels, `#[inline]` with early-return on `!METRICS_INITIALIZED`). Verified on macOS + Linux: counter sums close exactly to driven traffic, no overcount.
-
-### Fixed — CI Hygiene
-
-- **`tests/pipeline_auto_index.rs`**: tighten outer cfg from `runtime-tokio` to `all(runtime-tokio, text-index)` so the file compiles to zero tests when text-index is disabled. Previously the file compiled but the FT.SEARCH text fast path was `#[cfg]`-ed out, causing `@name:corpus` queries to fall through to the KNN-only parser and panic with "invalid KNN query syntax".
-- **4 FT unwraps**: add inline `#[allow(clippy::unwrap_used)]` with invariant justifications in `vector_search/ft_text_search.rs` (3 sites inside `apply_post_processing` where `do_summarize` / `do_highlight` implies the Option is Some) and `handler_monoio/ft.rs:165` (`is_text` was derived from `query_bytes.as_ref().map_or(false, _)`). Restores the audit-unwrap baseline to 0.
-
-### Changed
-
-- **`text-index` is now a default feature.** BM25 full-text search (`FT.SEARCH` BM25 mode), `FT.AGGREGATE`, and three-way RRF hybrid fusion are included in all standard builds. No longer requires `--features text-index`. To exclude it (e.g. minimal embedded builds): `--no-default-features --features runtime-monoio,jemalloc,graph`.
-
-### Added — SDK Validation
-
-- **Python SDK `sdk/python/examples/validate.py`**: End-to-end live validator for all SDK sub-clients: ping, strings, counter, hash, list, set, zset, vector index lifecycle, graph engine, session search, semantic cache, text search (BM25 + aggregate + hybrid), and server info. Result against Moon with `text-index`: **114 PASS / 0 FAIL / 0 SKIP**. Gracefully skips text sections when server built without `text-index`.
-- **Rust SDK `sdk/rust/examples/validate.rs`**: Re-validated against `text-index` build — **85 PASS / 0 FAIL**.
-
-### Fixed — Python SDK
-
-- **`moondb.graph._parse_neighbors`**: Server returns alternating `[edge_map, node_map, ...]` as flat key-value arrays (`b'id'`, `int`, `b'src'`, `int`, `b'dst'`, `int`, …). Previous parser expected positional `[node_id, label, props]` — caused `int() on b'id'` crash. Now correctly identifies node entries by `labels` key and parses them from the flat kv format.
+## [0.1.10] — 2026-04-23
+
+Stable replication marker. **Single-shard PSYNC2 wired end-to-end and
+production-ready** for `--shards 1` master with any `--shards N` replica
+topology. Multi-shard master PSYNC is scheduled for v0.2 (see
+`.planning/rfcs/multi-shard-replication-design.md`).
+
+- **Replication** (`081c43b`): single-shard master PSYNC2 end-to-end wired,
+ REPLCONF validated, `master_link_status` reports the actual handshake
+ state instead of the legacy `up` stub.
+- **Performance**: batch-level eviction gate; `try_handle_*` paths
+ `#[inline]`-ed; DashTable carries through the v0.1.10 pre-size
+ groundwork (capacity hint + headroom).
+- **Docs**: BENCHMARK.md §2.7 updated with the 2026-04-22 GCloud
+ re-measurement; v0.1.x replication scope documented under
+ `docs/guides/clustering.mdx#replication`.
+
+## [0.1.9] — 2026-04-19
+
+**Lunaris Retriever Gap Closure.** Every v0.1.8 client-side fallback in
+the Lunaris SDK is now closed so `HybridRRFRetriever` (dense path),
+`GraphFirstRetriever`, and `PathReasoningRetriever` run Moon-native.
+
+- **Phase 167 CYP-01/02**: Cypher `CREATE` / `MERGE` writes participate
+ in `CrossStoreTxn` via `record_graph()`; `TXN.ABORT` rolls them back.
+- **Phase 168 CYP-03/06**: `coalesce()` built-in + single-hop edge-var
+ binding in variable-length `EXPAND`.
+- **Phase 169 CYP-04/05**: `shortestPath()` parser + Dijkstra executor
+ bridge with path-variable binding.
+- **Phase 170 HYB-01/02/04**: `FT.SEARCH HYBRID` dense stream honours
+ `as_of_lsn`.
+- **Phase 171 SCAT-01/02/03**: `ShardMessage::VectorSearch` +
+ `FtHybridPayload` carry `as_of_lsn` for multi-shard `AS_OF` correctness.
+- **Phase 172 PIPE-01/02/03**: pipeline-aware HSET auto-indexing
+ regression guard (3-test suite).
+
+Audit status: **PASSED_WITH_DOCUMENTED_DEFERRALS**. 15 / 20 requirements
+fully satisfied; HYB-03 BM25 MVCC deferred and closed in v0.1.10
+follow-up (G-1); Phase 173 hygiene HYG-02 handler split RFC'd.
+
+Stats: 6 phases shipped, 17 plans, 27 files changed, +2924 / −376 LOC.
## [0.1.8] — 2026-04-18
@@ -837,7 +971,14 @@ All 2450 lib tests passing locally (`cargo test --no-default-features
- **Security hardening:** `deny.toml` (cargo-deny), `SECURITY.md`, `docs/THREAT-MODEL.md`, `docs/security/lua-sandbox.md`, TLS cipher suite freeze
- **Release engineering:** `docs/versioning.md`, 6 operator runbooks, CHANGELOG CI gate, user docs (getting-started, configuration, monitoring), release pipeline SHA256 checksums + SBOM + cosign
-## [Earlier Unreleased] - Dispatch Hot-Path Recovery (2026-04-08)
+## [0.1.3] — 2026-04-10
+
+Production-readiness foundation: dispatch hot-path recovery, vector-search
+4× QPS + correctness fixes, and the tiered disk-offload landing with 100 %
+crash recovery across 7 persistence configurations. Bundles three work
+streams originally tracked as separate Unreleased blocks (Apr 7–8).
+
+### Dispatch Hot-Path Recovery (2026-04-08)
**Pipelined SET +37%, pipelined GET +68% at p=16 after PR #43 regression recovery.**
@@ -934,9 +1075,7 @@ captured as todo in `.planning/todos/pending/`.
---
-## [Unreleased] - Vector Search 4x QPS + Correctness
-
-### Vector Search Performance & Correctness (2026-04-07)
+### Vector Search 4× QPS + Correctness (2026-04-07)
**4x search QPS, 4.1x lower latency, 2.56x faster than Qdrant on real MiniLM data.**
@@ -991,9 +1130,9 @@ embeddings (clustered) achieve 0.92-0.97 recall with the same code.
---
-## [Earlier Unreleased] - Disk Offload & x86_64 Performance
+### Disk Offload & x86_64 Performance (2026-04-06)
-Tiered storage, crash recovery, and 2x Redis on x86_64 (Intel Xeon, io_uring).
+Tiered storage, crash recovery, and 2× Redis on x86_64 (Intel Xeon, io_uring).
### Added
diff --git a/README.md b/README.md
index 5fcfbbe7..350c8861 100644
--- a/README.md
+++ b/README.md
@@ -7,11 +7,12 @@
-
+
-
+
+
@@ -20,17 +21,38 @@
Quick start •
Why Moon •
Benchmarks •
+ Moon vs Redis vs Valkey •
+ Production readiness •
Docs •
Changelog
---
-> **⚠ Experimental.** Moon is under active development and **not** recommended for production. Storage formats, APIs, and config flags may change between releases. Please [open an issue](https://github.com/pilotspace/moon/issues) if something breaks.
+> **Production-grade architecture, pre-1.0 maturity.** Single-node Moon
+> (`--shards N` master, `--shards 1` for replication-eligible workloads)
+> is recommended for production caching, vector / graph / feature-store
+> workloads, and Redis-compatible OLTP. Multi-node clustering and
+> multi-shard master PSYNC are **alpha in v0.2** — see
+> [Production readiness](#production-readiness) for the honest matrix of
+> what is and isn't yet GA. Wire protocol and on-disk format are LTS as of
+> v0.2 (`docs/STORAGE-FORMAT-V1.md`); CLI flags may still evolve until
+> v1.0. [Open an issue](https://github.com/pilotspace/moon/issues) if
+> something breaks.
---
-Moon speaks the Redis wire protocol (RESP2/RESP3) and implements 230+ commands. It runs on **Linux** (io_uring via monoio) and **macOS** (kqueue via monoio) with a thread-per-core, shared-nothing architecture, per-shard WAL, tiered disk offload, an in-process vector search engine with BM25 full-text search, a property graph engine with Cypher subset, cross-store ACID transactions, workspace partitioning, durable message queues, bi-temporal MVCC, and an embedded web console. Any Redis client connects out of the box.
+Moon is a clean-room Rust rewrite of a Redis-compatible in-memory data
+store with first-class AI primitives. It speaks the Redis wire protocol
+(RESP2/RESP3) and implements 230+ commands — every standard Redis data
+type plus native `FT.*` vector + BM25 search, `GRAPH.*` Cypher, `TXN.*`
+cross-store ACID, workspaces, durable message queues, bi-temporal MVCC,
+and an embedded web console. Primary target is **Linux** with io_uring
+(`monoio`); a `tokio` runtime is available for portability. **macOS** is a
+supported development platform (kqueue via monoio); production
+deployments should target Linux (see
+[`docs/PRODUCTION-CONTRACT.md`](docs/PRODUCTION-CONTRACT.md) Tier 1/2).
+Any Redis client connects out of the box.
## Why Moon
@@ -61,18 +83,30 @@ Moon speaks the Redis wire protocol (RESP2/RESP3) and implements 230+ commands.
## Benchmarks
-Measured vs Redis 8.6.1, co-located client and server, pipeline depth tuned per row. Full methodology and reproduction steps in [BENCHMARK.md](BENCHMARK.md) and [docs/benchmarks.mdx](docs/benchmarks.mdx).
+Measured vs Redis 8.6.1 (peak throughput) and Redis 8.0.2 + Valkey 9.1.0
+(hash-TTL surface, the only workload where all three were head-to-head
+benchmarked). Co-located client and server, pipeline depth tuned per
+row. Full methodology and reproduction steps in
+[BENCHMARK.md](BENCHMARK.md) and [docs/benchmarks.mdx](docs/benchmarks.mdx).
+Valkey peak-throughput columns are intentionally blank — a head-to-head
+peak-RPS bench on identical hardware has not yet been run; the
+[Moon vs Redis vs Valkey](#moon-vs-redis-vs-valkey) section quotes
+Valkey's vendor-published 2.1M RPS (9 I/O threads, p=10) for context.
### Peak throughput (GCloud c3-standard-8, x86_64, monoio io_uring)
-| Workload | Moon | Redis | Ratio |
-|----------------------------------|-------:|------:|:------:|
-| Peak GET (c=50, p=64) | 5.11M | 2.98M | **1.72×** |
-| Peak SET (c=50, p=64) | 3.50M | 1.82M | **1.92×** |
-| GET, production defaults (AOF+disk-offload) | 4.76M | 2.46M | **1.93×** |
-| GET, max durability (fsync always)| 4.85M | 2.45M | **1.98×** |
-| Memory, values ≥ 1 KB | — | — | **27–35% less** |
-| Crash recovery (SIGKILL, 5K keys)| 100% | 100% | parity |
+| Workload | Moon | Redis 8.6.1 | Valkey 9.1.0 |
+|------------------------------------------------|-------:|------------:|-------------:|
+| Peak GET (c=50, p=64) | 5.11M | 2.98M (1.72×) | not yet benched |
+| Peak SET (c=50, p=64) | 3.50M | 1.82M (1.92×) | not yet benched |
+| GET, production defaults (AOF + disk-offload) | 4.76M | 2.46M (1.93×) | not yet benched |
+| GET, max durability (`fsync=always`) | 4.85M | 2.45M (1.98×) | not yet benched |
+| Memory, values ≥ 1 KB | — | **27–35 % less** | not yet benched* |
+| Crash recovery (SIGKILL, 5K keys) | 100 % | 100 % (parity)| 100 % (parity, vendor-claimed) |
+
+*Valkey 9.1 raised the embstr threshold to 128 B; below ~64 B Valkey
+9.1 may be tighter than Moon. A head-to-head re-bench across the value
+size curve is on the v0.2 roadmap.
### ARM64 (GCloud t2a-standard-8, Neoverse-N1)
@@ -98,9 +132,13 @@ Measured vs Redis 8.6.1, co-located client and server, pipeline depth tuned per
| Native API QPS | **19×** | N/A |
| Bulk insert | **23×** | 1× |
-### Hash-field TTL — Valkey 9.0/9.1 parity (OrbStack moon-dev, n=200K c=50, median of 3)
+### Hash-field TTL — Valkey 9.0/9.1 parity (v0.2.0-alpha preview)
-Three-way comparison on the per-field TTL surface added in v0.2.0. Full methodology + 26 scenarios in [docs/perf/2026-05-27-hash-ttl-3way-bench.md](docs/perf/2026-05-27-hash-ttl-3way-bench.md); reproducible via [scripts/bench-hash-ttl-3way.sh](scripts/bench-hash-ttl-3way.sh).
+> **Status:** ships in **v0.2.0-alpha** (currently on `main`, no tagged
+> release yet). Not present in the latest tagged build `v0.1.12`. Build
+> from `main` to reproduce these numbers.
+
+OrbStack moon-dev, n=200K c=50, median of 3. Three-way comparison on the per-field TTL surface added in v0.2.0. Full methodology + 26 scenarios in [docs/perf/2026-05-27-hash-ttl-3way-bench.md](docs/perf/2026-05-27-hash-ttl-3way-bench.md); reproducible via [scripts/bench-hash-ttl-3way.sh](scripts/bench-hash-ttl-3way.sh).
| Command | Pipeline | Moon | Redis 8.0.2 | Valkey 9.1.0 |
|------------------|----------|------:|------------:|-------------:|
@@ -111,7 +149,64 @@ Three-way comparison on the per-field TTL surface added in v0.2.0. Full methodol
| `HGETEX EX` | p=1 | 250K | N/A | 251K |
| `HGETEX` no-mode | p=1 | 250K | N/A | 253K |
-Plain Hash HGET p=16 ties Redis (1.01×) and Valkey (1.00×). HEXPIRE-family Moon vs Valkey: 0.90–0.99× across the surface (Valkey leads HEXPIRE p=16 by 7%, HTTL p=16 by 10%; HGETEX hits 0.99× parity). Redis 8.x has no HEXPIRE-family — Moon is the only Redis-compatible alternative aside from Valkey. The internal `HashWithTtl` HGET / HLEN paths use a cached `min_expiry_ms` for an O(1) fast path that brings them to 1.03× of plain `Hash` (was 80× slower pre-fix; see PR #126).
+Plain Hash HGET p=16 ties Redis (1.01×) and Valkey (1.00×). HEXPIRE-family Moon vs Valkey: 0.90–0.99× across the surface (Valkey leads HEXPIRE p=16 by 7 %, HTTL p=16 by 10 %; HGETEX hits 0.99× parity). Redis 8.x has no HEXPIRE-family — Moon is the only Redis-compatible alternative aside from Valkey. The internal `HashWithTtl` HGET / HLEN paths use a cached `min_expiry_ms` for an O(1) fast path that brings them to 1.03× of plain `Hash` (was 80× slower pre-fix; see PR #126).
+
+## Moon vs Redis vs Valkey
+
+Three Redis-protocol-compatible servers, three different bets. Moon
+competes on a **vertical moat** — thread-per-core architecture and an
+AI-native in-core surface. Valkey competes on a **horizontal moat** —
+Linux Foundation governance, every major cloud, drop-in compatibility.
+Redis OSS continues as the upstream reference but ships under SSPL since
+March 2024. The deep architectural review is in
+[`docs/comparison-valkey.md`](docs/comparison-valkey.md) (~22 KB,
+traced to source).
+
+| Dimension | **Moon v0.1.12 / 0.2.0-alpha** | **Valkey 9.1.0** | **Redis 8.6.1 (OSS)** |
+|--------------------------------------|--------------------------------|-----------------------------|----------------------------|
+| Language / license | Rust 2024 / Apache-2.0 | C99 / BSD-3-Clause (LF TSC) | C99 / **SSPL** since 2024 |
+| Threading model | Thread-per-core, shared-nothing | Single main thread + ≤9 I/O threads | Single-threaded core (+ I/O threads) |
+| I/O driver (Linux) | io_uring (`monoio`) | epoll only | epoll only |
+| Snapshot | **Forkless** (segment-level COW) | `fork()` + COW | `fork()` + COW |
+| AOF / WAL | Per-shard WAL v3 + multi-part AOF | Single global AOF | Single global AOF |
+| Tiered NVMe disk offload | **Yes** (under `maxmemory`) | No (OSS) | No (OSS — Redis Flash is Enterprise) |
+| Vector search | **In-core** HNSW + TurboQuant 1-8 bit | `valkey-search` module, FP32 only | RediSearch module |
+| Full-text BM25 | **In-core** | `valkey-search` module | RediSearch module |
+| Property graph (Cypher) | **In-core** 14 `GRAPH.*` cmds | None | None (FalkorDB separate) |
+| Cross-store ACID | `TXN.BEGIN/COMMIT/ABORT` | None | None |
+| Hash-field TTL (`HEXPIRE`-family) | **Yes** (Valkey-parity, v0.2-alpha) | **Yes** (9.0+) | No |
+| PITR + CDC | `--recovery-target-lsn` + `CDC.READ` (v0.2-alpha) | None | None |
+| Embedded web console | **Yes** (7-view React, in-binary) | Valkey Admin GUI 1.0 (separate) | Redis Insight (separate) |
+| Managed cloud offerings | None (yet) | AWS, GCP, Oracle, Aiven, … | Redis Cloud (vendor) |
+| Multi-node cluster, soak-tested | **v0.2 alpha** (single-node GA today) | **Production** | **Production** |
+| Atomic slot migration | Planned (v0.2) | Yes (9.0) | No |
+| Peak single-server throughput | **5.11M GET/s** (c3-8 x86_64) | 2.1M RPS (9 I/O threads, p=10, vendor) | 2.98M GET/s (c3-8, same harness as Moon) |
+
+### When to choose Moon
+
+- Single-node Redis-compatible workloads where peak throughput,
+ memory efficiency at ≥1 KB values, or **forkless snapshots** matter.
+- AI-native applications: vector search, GraphRAG, semantic cache,
+ hybrid BM25 + dense + sparse retrieval — all in one binary, no module
+ loader, with cross-store ACID across KV / vector / graph.
+- Workloads that benefit from **tiered NVMe offload** under `maxmemory`
+ instead of LRU-eviction-then-rebuild.
+
+### When to choose Valkey
+
+- Multi-node clusters with proven 1000+ node operational mileage.
+- Managed-cloud-only deployments (every major cloud offers Valkey).
+- Strict drop-in compatibility with the Redis 7.2 module ecosystem
+ (`valkey-json`, `valkey-bloom`, `valkey-search`, `valkey-ldap`).
+- Risk-averse environments where Linux Foundation governance is a
+ procurement requirement.
+
+### When to stay on Redis OSS
+
+- Existing investments in RediSearch / RedisJSON / RedisBloom under
+ RLEC, or pre-SSPL-tolerance OSS Redis.
+- Specific Redis Enterprise features (CRDT active-active, Redis Flash)
+ that Moon and Valkey OSS do not match.
## Quick start
@@ -148,9 +243,9 @@ OK
"world"
127.0.0.1:6379> HSET user:1 name Alice age 30
(integer) 2
-127.0.0.1:6379> HEXPIRE user:1 3600 FIELDS 1 age # Valkey 9.0 per-field TTL
+127.0.0.1:6379> HEXPIRE user:1 3600 FIELDS 1 age # Valkey 9.0 per-field TTL (v0.2.0-alpha; build from `main`)
1) (integer) 1
-127.0.0.1:6379> HTTL user:1 FIELDS 1 age
+127.0.0.1:6379> HTTL user:1 FIELDS 1 age # v0.2.0-alpha
1) (integer) 3600
127.0.0.1:6379> FT.CREATE idx ON HASH PREFIX 1 doc: SCHEMA emb VECTOR HNSW 6 DIM 384 TYPE FLOAT32 DISTANCE_METRIC COSINE
OK
@@ -326,34 +421,123 @@ cargo flamegraph --bin moon -- --port 6399 --shards 1
Contribution guide and coding rules (unsafe policy, hot-path allocation rules, lock discipline) are in [CLAUDE.md](CLAUDE.md) and [UNSAFE_POLICY.md](UNSAFE_POLICY.md).
-## Roadmap
-
-Moon is pre-1.0 and **experimental**. Current focus:
-
-- Correctness parity with Redis 8.x across the full command surface
-- AI-native primitives: session dedup, hybrid vector+sparse search, agentic caching
-- Multi-node clustering with gossip, slot migration, and PSYNC2 replication
-- GPU-accelerated vector search (CUDA, feature-gated)
-- Production hardening and SLO validation (see [docs/PRODUCTION-CONTRACT.md](docs/PRODUCTION-CONTRACT.md))
-
-Completed in v0.1.0–v0.1.8:
-- Tiered disk offload (RAM → NVMe) with 100% crash recovery
-- In-process vector search (HNSW + TurboQuant 4/8-bit) with `FT.*` API
-- BM25 full-text search with three-way hybrid fusion (BM25 + dense + sparse)
-- Property graph engine with Cypher subset (14 `GRAPH.*` commands)
-- Cross-store ACID transactions (`TXN.BEGIN`/`COMMIT`/`ABORT`) across KV, vector, and graph
-- Workspace partitioning for multi-tenant namespace isolation
-- Durable message queues with dead-letter and debounced triggers
-- Bi-temporal MVCC for point-in-time KV and graph queries
-- Web console (7 views, embedded in binary)
-- macOS support (aarch64 + x86_64, both runtimes)
-- Thread-per-core dispatch optimization (5.11M GET/s on x86_64)
-
-Production readiness is **not** a v0.1 goal. Storage formats, APIs, and config flags may change between releases.
-
-## Production Readiness
-
-Moon's v1.0 promises — SLOs, durability modes, supported platforms, and a machine-checkable GA exit-criteria checklist — live in **[docs/PRODUCTION-CONTRACT.md](docs/PRODUCTION-CONTRACT.md)**. The contract is the single source of truth every v0.1.3 hardening phase tests against.
+## Production readiness
+
+Honest matrix of where Moon is today (v0.1.12 GA + v0.2.0-alpha
+unreleased). Read alongside
+[`docs/PRODUCTION-CONTRACT.md`](docs/PRODUCTION-CONTRACT.md) (the
+machine-checkable GA exit criteria) and
+[`docs/OPERATOR-GUIDE.md`](docs/OPERATOR-GUIDE.md) (memory accounting,
+sizing, runbooks).
+
+### Recommended for production today
+
+- **Single-node deployments** — Linux aarch64 (Tier 1) or Linux x86_64
+ (Tier 2). `--shards N` master, one process, one node.
+- **Read replication** — `--shards 1` master with any `--shards N`
+ replica topology. Single-shard PSYNC2 is wired end-to-end since
+ v0.1.10.
+- **AI workloads** — vector search (HNSW + TurboQuant), BM25 full-text,
+ GraphRAG, semantic caching, hybrid retrieval. All in-core, all
+ RDB / WAL durable, all crash-recovery validated.
+- **Cache + feature store** — durability modes are honest
+ (`always` / `everysec` / `no` with documented recovery bounds), forkless
+ snapshots remove the Redis fork-COW RSS spike, tiered NVMe offload
+ under `maxmemory` keeps working sets larger than RAM.
+- **Crash recovery** — 100 % survived across 7 persistence
+ configurations and 5 K-key SIGKILL workloads. RDB v2 + WAL v3 +
+ multi-part AOF + tiered cold tier all participate.
+
+### Not yet GA — avoid for production
+
+- **Multi-node clustering** (16 K-slot gossip, MOVED/ASK, failover) —
+ protocol-compatible code exists but **PSYNC2 atomic slot migration
+ is not soak-tested**. Valkey 9.0 shipped this; Moon has not.
+ Scheduled for v0.2.
+- **Multi-shard master PSYNC** — single-shard only today; multi-shard
+ master replication is RFC'd in
+ [`.planning/rfcs/multi-shard-replication-design.md`](.planning/rfcs/multi-shard-replication-design.md).
+- **PITR live-snapshot LSN wiring** (P3c) and **`CDC.SUBSCRIBE` push
+ channel** (C3b) — `CDC.READ` polling is alpha-ready; push and
+ zero-snapshot PITR are deferred to v0.2 follow-ups.
+- **GPU vector acceleration** (`gpu-cuda` feature) — kernel scaffold
+ exists; production kernels not yet shipping.
+- **macOS native** — first-class development platform with full feature
+ set minus io_uring, but production deployments should target Linux
+ per
+ [`docs/PRODUCTION-CONTRACT.md`](docs/PRODUCTION-CONTRACT.md) tiers.
+- **Performance SLO numbers in
+ [`docs/PRODUCTION-CONTRACT.md`](docs/PRODUCTION-CONTRACT.md)** — marked
+ `[provisional]` until the Phase 97 24-h HDR-histogram rig validates
+ them on reference hardware. Use the benchmarks above as point-in-time
+ measurements, not committed SLOs.
+
+### Operator gotchas worth knowing before you deploy
+
+- **Multi-shard scaling needs load.** Aim for `clients ≥ 25 × shards`
+ on pipeline ≥ 16 workloads; below that, multiple shards under-subscribe
+ the dispatch loop and a single shard wins. Random keyspaces with
+ `p=1` benefit less than `{tag}`-co-located keys (see
+ [`CLAUDE.md`](CLAUDE.md) "Gotchas" + the v0.1.12 multi-shard memo).
+- **Fairness flags for benchmarking against Redis / Valkey** —
+ `--disk-offload disable --appendonly no` removes Moon's durability
+ overhead (~26 % on SET p=64) so comparisons are apples-to-apples.
+- **Memory accounting** — bill on RSS, not VSZ. `MEMORY DOCTOR`,
+ `moon_memory_bytes{kind=…}` Prometheus gauge, and
+ [`docs/OPERATOR-GUIDE.md#memory-accounting`](docs/OPERATOR-GUIDE.md#memory-accounting)
+ cover the full VSZ-vs-RSS guide and tuning knobs
+ (`--memory-arenas-cap`, `mimalloc-alt`).
+- **RDB hash-field TTL trailer** is RDB v2 only — older v1 readers stop
+ after the hash body and silently drop per-field TTLs. Pin storage
+ format to `v1` (the umbrella covering v2/v3 sub-formats) per
+ [`docs/STORAGE-FORMAT-V1.md`](docs/STORAGE-FORMAT-V1.md).
+
+### Roadmap
+
+| Milestone | Focus | Status |
+|------------------|---------------------------------------------------------------------------------------------|-------------|
+| **v0.2.0** (next) | Multi-node clustering soak (PSYNC2 + atomic slot migration); PITR P3c; CDC push (`SUBSCRIBE`) | alpha |
+| **v0.2.x** | GPU vector acceleration (`gpu-cuda`); operator runbooks; full SLO lock-in (`PERF-01..05`) | planned |
+| **v1.0** | Every [`PRODUCTION-CONTRACT.md`](docs/PRODUCTION-CONTRACT.md) GA exit-criteria box ticked | gate |
+
+What's already in `main` (v0.1.0 → v0.2.0-alpha, 14 months of work):
+
+> Hash-field TTL and PITR + CDC ship in **v0.2.0** — currently alpha on
+> `main`, no tagged release yet. **v0.1.12** is the latest tag and does
+> NOT include them. Everything else below is in `v0.1.12` GA.
+
+**Shipped in v0.1.12 (latest tag, single-node production-grade):**
+
+- Forkless persistence (RDB v2 + per-shard WAL v3 + multi-part AOF).
+- Tiered disk offload (RAM → NVMe) with 100 % crash recovery.
+- In-process vector search (HNSW + TurboQuant 1–8-bit) — `FT.*` surface.
+- BM25 full-text search + three-way RRF hybrid fusion (BM25 + dense + sparse).
+- Property graph engine with Cypher subset (14 `GRAPH.*` commands).
+- Cross-store ACID (`TXN.BEGIN` / `COMMIT` / `ABORT`) across KV, vector,
+ graph.
+- Workspaces, durable message queues, bi-temporal MVCC.
+- Web console (7-view React app, embedded in binary).
+- Thread-per-core dispatch optimization (5.11M GET/s on x86_64).
+
+**Added in v0.2.0-alpha (on `main`, untagged):**
+
+- Hash-field TTL — Valkey 9.0 / 9.1 parity, O(1) HGET / HLEN fast path
+ (`HEXPIRE`, `HEXPIREAT`, `HPEXPIRE`, `HPEXPIREAT`, `HEXPIRETIME`,
+ `HPEXPIRETIME`, `HTTL`, `HPTTL`, `HPERSIST`, `HGETEX`, `HGETDEL`).
+- PITR — `--recovery-target-lsn` deterministic WAL replay-to-LSN.
+- CDC — `CDC.READ` pull-mode change stream (push-mode planned for v0.2.0
+ GA).
+- Multi-node clustering soak (PSYNC2 + atomic slot migration).
+
+## Production Readiness Contract
+
+Moon's v1.0 promises — per-command-class SLOs, durability modes, supported
+platforms, security guarantees, and a machine-checkable GA exit-criteria
+checklist — live in
+**[`docs/PRODUCTION-CONTRACT.md`](docs/PRODUCTION-CONTRACT.md)**. Every
+v0.1.3+ hardening phase ticks off items on that checklist; nothing
+promotes to `v1.0-rc1` until every box is green. The contract is the
+single source of truth for what Moon owes you in production.
## Credits
diff --git a/docs/runbooks/multi-shard-aof-rewrite.md b/docs/runbooks/multi-shard-aof-rewrite.md
new file mode 100644
index 00000000..b751ab00
--- /dev/null
+++ b/docs/runbooks/multi-shard-aof-rewrite.md
@@ -0,0 +1,161 @@
+# Runbook — multi-shard AOF (per-shard layout)
+
+**Status:** Resolved. The startup refusal gate introduced in v0.1.13 was lifted
+in v0.1.13-patch (PR #129) once the per-shard AOF replay path was shipped and
+verified (CRASH-01-LITE: 200/200 SIGKILL recovery, 0 data loss).
+
+---
+
+## Background (historical context)
+
+Prior to PR #129, Moon refused to start with `--shards >= 2 + --appendonly yes`
+because the single-writer AOF implementation lost ~50 % of writes on SIGKILL
+in that configuration. The fix was a full per-shard AOF architecture (Option B):
+each shard owns its own writer task and recovery walks every shard's segment
+manifest independently.
+
+If you are running Moon ≤ v0.1.13 and hit the old startup error, see the
+**Upgrading** section below.
+
+---
+
+## Current architecture (v0.1.13-patch / PR #129 and later)
+
+### Per-shard AOF layout
+
+```
+/
+ appendonlydir/
+ moon.aof.manifest ← top-level manifest (layout: PerShard)
+ shard-0/
+ moon.aof.1.base.rdb ← base snapshot for shard 0
+ moon.aof.1.incr.aof ← incremental log for shard 0
+ shard-1/
+ moon.aof.1.base.rdb
+ moon.aof.1.incr.aof
+ shard-N/
+ ...
+```
+
+Each shard's writer task appends to its own `.incr.aof` file. On restart, Moon
+opens the manifest, discovers all shard directories, and replays each shard's
+log independently. Shard replay is parallel — recovery time does not grow
+linearly with shard count.
+
+### Durability invariants
+
+| appendfsync | Guarantee |
+|---------------------|------------------------------------------------------------------------|
+| `always` | Write is on durable storage before +OK (AppendSync rendezvous) |
+| `everysec` (default)| Fsync runs every second; at most ~1 s of writes at risk on crash |
+| `no` | OS decides when to flush; fastest but weakest guarantee |
+
+### BGREWRITEAOF in per-shard mode
+
+`BGREWRITEAOF` is **not yet supported** for PerShard layouts. Issuing it on a
+PerShard instance returns the following error immediately:
+
+```
+ERR BGREWRITEAOF is not yet supported under per-shard AOF layout; per-shard rewrite ships in step 6 of the per-shard AOF migration
+```
+
+Per-shard BGREWRITEAOF (each shard compacts its own log independently, with
+all N acks awaited before returning confirmation) is tracked for v0.2.
+
+---
+
+## Upgrading from v0.1.13 (old TopLevel AOF) to per-shard layout
+
+If you have an existing deployment with a **TopLevel** AOF manifest (single
+writer, `layout: TopLevel`) and want to migrate to per-shard layout:
+
+### Option A — cold migration (recommended, zero-risk)
+
+1. Stop the server.
+2. Run `BGSAVE` on the last healthy instance, or copy `dump.rdb` from
+ `--dir`.
+3. Remove `appendonlydir/` entirely.
+4. Restart with `--shards N --appendonly yes`. Moon creates a fresh per-shard
+ manifest. Recovery loads from RDB; the incremental AOF starts empty.
+
+### Option B — in-place migration (future tooling)
+
+An offline migration CLI subcommand is planned for v0.2. Until then, use
+Option A.
+
+### Safety guard — TopLevel manifest with multi-shard startup
+
+If Moon detects an existing **TopLevel** AOF manifest at startup with
+`--shards >= 2`, it refuses to start with exit code 2 and prints the following
+to stderr:
+
+```
+REFUSING TO START: legacy TopLevel AOF manifest at detected with
+--shards N (>= 2). This combination silently loses data for shards 1..N-1.
+See docs/runbooks/multi-shard-aof-rewrite.md for migration instructions.
+```
+
+(Exact text may include additional context such as the manifest path and shard
+count substituted in; the key phrase to match in alerting is `REFUSING TO START`.)
+
+This is intentional — a TopLevel log does not capture per-shard ordering, so
+replaying it on a multi-shard instance would produce incorrect key routing.
+
+**Resolution:** Follow Option A above (remove `appendonlydir/` and restart).
+
+---
+
+## Deprecated flag: --unsafe-multishard-aof
+
+The `--unsafe-multishard-aof` flag was introduced in v0.1.13 as an escape hatch
+to acknowledge the known ~50 % data-loss risk. It is now **deprecated** and will
+be removed in v0.2:
+
+- The underlying bug is fixed — the flag no longer suppresses any safety gate.
+- Passing it emits a `[DEPRECATED]` warning at startup.
+- If you have scripts or systemd units that pass `--unsafe-multishard-aof`,
+ remove that flag — it is a no-op.
+
+---
+
+## Monitoring and telemetry
+
+### INFO Persistence fields added in PR #129
+
+```
+aof_backpressure_dropped: ← count of writes dropped due to full AOF channel
+```
+
+A non-zero value indicates the AOF writer is falling behind write throughput.
+Investigate disk I/O or increase `aof-rewrite-min-size`.
+
+### Prometheus / alerting
+
+A dedicated gauge for `aof_backpressure_dropped` is planned for v0.2. Until
+then, monitor via `INFO persistence` polling.
+
+---
+
+## Crash recovery verification
+
+Run the CRASH-01-LITE suite to verify your configuration recovers cleanly:
+
+```bash
+# From the moon-dev OrbStack VM:
+cargo test --release crash_01_lite 2>&1 | tail -20
+```
+
+Expected: 200/200 entries recovered across all shards after SIGKILL.
+
+---
+
+## Escalation
+
+If you observe data loss after a crash on v0.1.13-patch or later, collect:
+
+1. `appendonlydir/moon.aof.manifest` contents
+2. `appendonlydir/shard-*/` file sizes and modification times
+3. Server log from the crashed process (look for AOF writer task exit reason)
+4. `INFO persistence` output from the recovered instance
+
+File a P0 with these artifacts attached.
diff --git a/moon-vs-turbovec/METHODOLOGY.md b/moon-vs-turbovec/METHODOLOGY.md
new file mode 100644
index 00000000..8d74e426
--- /dev/null
+++ b/moon-vs-turbovec/METHODOLOGY.md
@@ -0,0 +1,101 @@
+# Methodology
+
+The result is only worth as much as the protocol. This document is the contract
+every number in the repo must satisfy.
+
+## 1. Hardware & environment (pinned)
+
+- **Published numbers come from a pinned cloud instance**, never a laptop or a
+ dev VM. (Per Moon's own rule: production benchmarks target Linux on
+ GCloud/OrbStack-Linux; macOS/dev numbers are never published.)
+- Default reference instance: `c4a-standard-8` (Axion, arm64) **and**
+ `c3-standard-8` (x86_64) — TurboVec's kernel advantage is arch-specific
+ (NEON vs AVX-512BW), so we report **both architectures**.
+- `harness/hardware.py` captures CPU model, core count, RAM, kernel, compiler,
+ BLAS backend, and both repos' commit hashes into `results/run_metadata.json`.
+ No result is valid without this sidecar.
+
+## 2. Versions (pinned commits)
+
+| Component | Pin | Where recorded |
+|---|---|---|
+| Moon | git commit hash + build flags (`--release`, target-cpu) | `run_metadata.json` |
+| TurboVec | git commit hash / PyPI version | `run_metadata.json` |
+| Datasets | content hash | `datasets/manifest.json` |
+
+## 3. Datasets — real embeddings only
+
+| Dataset | Dim | Metric | Why |
+|---|---|---|---|
+| GloVe | 200 | cosine | TurboVec's strong/likely-win region; exposes Moon's ≤384d TQ4 weakness honestly |
+| SIFT1M | 128 | L2 | classic ANN baseline (Moon uses FP32/TQ8 here) |
+| OpenAI text-embedding-3 | 1536 | cosine | TurboVec's headline dim; real LLM embeddings |
+| OpenAI text-embedding-3 | 3072 | cosine | high-d where TQ4 shines |
+| Deep / BIGANN slices | 96–128 | L2 | scale tiers 1M → 10M → 100M for the envelope/disk experiments |
+
+**No synthetic Gaussian vectors.** Random high-dimensional vectors exhibit
+distance concentration that makes recall numbers misleading — especially at the
+10M+ tier where it would be tempting to fabricate data. Scale tiers use real
+ANN-benchmark sets.
+
+## 4. Ground truth
+
+- Exact top-100 neighbors per query via brute-force on the **original float32**
+ vectors (`datasets/ground_truth.py`, GPU/BLAS accelerated, cached + hashed).
+- Identical query set fed to both systems.
+- `Recall@k = |returned_topk ∩ true_topk| / k`, averaged over the query set.
+
+## 5. The iso-recall / iso-memory discipline (non-negotiable)
+
+Every latency/QPS data point is emitted as a tuple:
+
+```
+(system, dataset, N, dim, params, recall@10, qps, p50_ms, p95_ms, p99_ms, rss_bytes)
+```
+
+- **Never** compare a high-recall config of one system against a low-memory
+ config of the other and report only the favorable axis.
+- Latency/QPS claims are made **at matched recall** (interpolated on each
+ system's Pareto frontier) **or** the full frontier is shown.
+- Memory is **resident set size** of the serving process at steady state, and
+ for Moon **includes the power-of-2 FWHT padding** (e.g. 1536→2048 = +33%
+ raw vectors before index/graph overhead). This is stated on every Moon row.
+
+## 6. Parameter policy (both tuned or both default — disclosed)
+
+- All tunables for both systems live in `config/experiments.yaml`.
+- Two modes, both reported when they differ:
+ - **`default`** — each project's documented defaults.
+ - **`tuned`** — a *symmetric* sweep: if we sweep Moon's `ef_search`/`M`, we
+ sweep TurboVec's `bit_width` (2/4) and any documented knob over a comparable
+ grid. The grid is in the config; nothing is hand-picked post-hoc.
+
+## 7. Measurement hygiene
+
+- Warmup: discard first `warmup_queries` (default 1000) before timing.
+- Repeats: median of `repeats` runs (default 5); report dispersion.
+- Latency: per-query wall time → p50/p95/p99. QPS: single-thread and
+ `n_threads`-thread (both reported; TurboVec MT vs Moon's per-shard model).
+- Client overhead: Moon is measured over the loopback RESP wire (real serving
+ path); TurboVec in-process. **This asymmetry favors TurboVec and is stated** —
+ we do not subtract network time to flatter Moon.
+- Isolation: one system per process; cgroup memory cap recorded.
+
+## 8. What we deliberately do NOT do
+
+- No subtracting Moon's network/serialization cost to fake parity with an
+ in-process library.
+- No reporting Moon-L2 vs TurboVec-IP (different objectives).
+- No quoting a 10M number extrapolated from 1M — each tier is measured.
+- No populating README tables by hand. `make report` regenerates them from
+ `results/*.csv`; placeholders stay `` until a real run overwrites
+ them.
+
+## 9. Output artifacts
+
+```
+results/
+ run_metadata.json # HW, versions, commit hashes, dataset hashes
+ raw/*.csv # one row per (system, dataset, N, params) measurement
+ plots/*.png # Pareto recall-vs-QPS, memory-vs-N, envelope curves
+```
diff --git a/moon-vs-turbovec/README.md b/moon-vs-turbovec/README.md
new file mode 100644
index 00000000..9de40a3b
--- /dev/null
+++ b/moon-vs-turbovec/README.md
@@ -0,0 +1,170 @@
+# Moon vs TurboVec — A Reproducible Retrieval Benchmark
+
+> **A decision guide, not a leaderboard.** When should you reach for an embedded
+> flat-scan quantizer (TurboVec), and when have you outgrown it and need a
+> converged engine (Moon)? This repo runs both, on identical hardware and data,
+> and lets the numbers draw the boundary.
+
+[](./METHODOLOGY.md)
+[](#fairness-statement)
+
+---
+
+## The honest one-liner
+
+**They are not the same category.** TurboVec is a best-in-class *embedded
+flat-scan TurboQuant library* — 2/4-bit compression, inner-product, in-RAM,
+Python-native, with a hand-tuned SIMD kernel that beats FAISS FastScan. Moon is
+a *converged, networked, durable engine* (KV + vector + graph + search) where
+the same TurboQuant family is one quantizer feeding **HNSW / DiskANN / IVF**
+indexes.
+
+So this benchmark does **not** ask "who is faster." It asks: **where is the
+boundary** past which a flat-scan library stops being the right tool?
+
+| Choose **TurboVec** when… | Choose **Moon** when… |
+|---|---|
+| Corpus fits comfortably in RAM | Corpus exceeds RAM (disk-resident serving) |
+| Single-node, embedded in your Python process | You need a networked, multi-tenant service |
+| Pure unfiltered top-k on a static set | You need filtered / hybrid / multi-modal retrieval |
+| You manage persistence & rebuilds yourself | You need durability, replication, online ingest |
+| d ≥ 1536, you want maximum compression | You also need KV / graph / search in one engine |
+| You want the fastest single-node scan kernel | You've outgrown "one flat index in a process" |
+
+The experiments below quantify every row of that table — including **the rows
+where TurboVec wins.**
+
+---
+
+## Fairness statement
+
+This benchmark is designed to be *attackable* — and to survive the attack.
+Credibility is the entire point; a rigged comparison is worthless for the
+decision it's meant to inform.
+
+1. **Iso-recall + iso-memory.** Every latency/QPS number is reported *with* the
+ recall **and** the resident memory at that exact operating point, or as a
+ full Pareto frontier. A speed win at 5× the RAM or lower recall is reported
+ as exactly that.
+2. **Same metric, same ground truth.** Both systems run cosine / inner-product
+ on L2-normalized vectors. Ground-truth neighbors are computed by **exact
+ brute force on the original float32 vectors**, identical query set for both.
+3. **Both tuned, or both default — disclosed.** We do not grid-search Moon's
+ `ef`/`M` while handing TurboVec stock parameters. Every parameter for both
+ systems is in [`config/experiments.yaml`](./config/experiments.yaml).
+4. **Moon's padding is disclosed everywhere.** Moon's FWHT rotation pads to the
+ next power of two (1536→2048, 3072→4096 ⇒ **+33% vectors** before index
+ overhead). This is included in *every* Moon memory figure.
+5. **Real embeddings at scale.** No synthetic Gaussian vectors (their recall is
+ misleading). We use standard ANN datasets + real embedding sets at
+ TurboVec's native dimensions.
+6. **Pinned & cloud-run.** Numbers are produced on a pinned cloud instance (not
+ a laptop/dev VM), with both repos pinned to commit hashes recorded in
+ [`results/run_metadata.json`](./results/). See [METHODOLOGY.md](./METHODOLOGY.md).
+
+> **We publish the experiments Moon loses.** A result table that is honest about
+> where the flat-scan library is the better choice is the only kind worth
+> trusting.
+
+---
+
+## Experiments
+
+| ID | Question | Battleground | Favors |
+|----|----------|--------------|--------|
+| **E1** | Recall@10 vs QPS (Pareto) at N = 100K / 1M / 10M | Core retrieval | neutral |
+| **E2** | **Operating envelope** — where does each tool stop being viable? (RAM ceiling, filter, durability, ingest) | Capability boundary | Moon (capability) |
+| **E3** | Memory footprint vs N (compression) | Storage | **TurboVec** |
+| **E4** | Filtered search: allowlist vs payload/expression filters, selectivity sweep | Capability | Moon |
+| **E5** | Concurrent online ingest + query throughput | Mixed load | Moon |
+| **E6** | Disk-scale (>RAM): DiskANN at 100M where flat-scan OOMs | Capability | Moon |
+| **E7** | Convergence / ops capability matrix (qualitative) | Platform | Moon |
+
+E2 is deliberately **not** "indexed beats brute-force" (a triviality). It holds
+recall and hardware fixed and reports the corpus size / RAM / feature point at
+which each tool stops being usable.
+
+### Results (summary)
+
+> ⚠️ **All values below are `` until produced by the pinned-hardware
+> run.** Do not quote any number from this README in marketing until it is
+> populated from `results/` by an actual run. Placeholders are intentionally
+> non-numeric so they cannot leak as fake data.
+
+**E1 — Recall@10 / QPS / memory at iso operating points**
+
+| Dataset (dim) | N | System | Config | Recall@10 | QPS | RAM | Notes |
+|---|---|---|---|---|---|---|---|
+| GloVe (200) | 1M | TurboVec | 4-bit FastScan | `` | `` | `` | TurboVec's strong/likely-win region |
+| GloVe (200) | 1M | Moon | TQ8 HNSW | `` | `` | `` | low-d: Moon uses TQ8/FP32 (TQ4 weak ≤384d) |
+| OpenAI (1536) | 1M | TurboVec | 4-bit FastScan | `` | `` | `` | |
+| OpenAI (1536) | 1M | Moon | TQ4 HNSW | `` | `` | `` | +33% padding (1536→2048) included in RAM |
+| OpenAI (1536) | 10M | TurboVec | 4-bit FastScan | `` | `` | `` | full RAM scan |
+| OpenAI (1536) | 10M | Moon | TQ4 HNSW / DiskANN | `` | `` | `` | |
+
+**E3 — Memory vs N (TurboVec's home turf — co-reported, not buried)**
+
+| System | 1M @1536 | 10M @1536 | bits | native dim? |
+|---|---|---|---|---|
+| TurboVec | `` | `` | 2/4 | yes |
+| Moon | `` | `` | 1–4 | no (pads to 2048) |
+
+(Full tables per experiment in [`results/`](./results/) after a run.)
+
+---
+
+## Where TurboVec wins (and you should use it)
+
+*Populated from the actual run — this section is the credibility anchor and is
+expected to contain real losses for Moon.*
+
+- **Small N, in-RAM:** at N ≤ ~100K the flat-scan FastScan kernel is faster and
+ far simpler than building/serving an index. ``
+- **Low dimension (GloVe-200):** Moon's TQ4 loses recall ≤384d; Moon must fall
+ back to TQ8/FP32, narrowing or erasing any edge. ``
+- **Compression:** native-dimension 2-bit packing vs Moon's padded codes.
+ ``
+- **Single-node kernel throughput:** TurboVec's AVX-512BW/NEON FastScan beats
+ FAISS; Moon's flat scan is currently scalar/AVX2. ``
+- **Embedding ergonomics:** `pip install`, drop-in LangChain/LlamaIndex/
+ Haystack/Agno stores, zero ops.
+
+## Where Moon wins (the envelope)
+
+- **Past the RAM ceiling** (E2/E6): DiskANN serves corpora that OOM a flat-scan
+ library. ``
+- **Filtered & hybrid retrieval** (E4): payload/expression/text filters + RRF
+ fusion vs a flat allowlist. ``
+- **Online ingest under query load** (E5): MVCC segments vs rebuild-on-add.
+ ``
+- **Convergence** (E7): KV + vector + graph + search + CDC behind one Redis
+ wire — no Redis+Qdrant+Neo4j+ES glue.
+- **Durability & replication:** crash-consistent persistence, master/replica,
+ cluster failover — structurally absent from a library.
+
+---
+
+## Reproduce it yourself
+
+```bash
+make setup # python deps + build Moon (release) + install turbovec (pinned)
+make datasets # download SIFT1M, GloVe-200, OpenAI-1536/3072, compute exact ground truth
+make bench # run all experiments on this host
+make plot report # render Pareto plots + regenerate result tables in README
+```
+
+Everything — datasets, parameters, hardware capture, raw CSVs, plot scripts — is
+in this repo. If you can't reproduce a number, it's a bug; open an issue.
+
+See **[METHODOLOGY.md](./METHODOLOGY.md)** for the full protocol and
+**[ATTRIBUTION.md](./ATTRIBUTION.md)** for credits and licensing.
+
+---
+
+## Attribution
+
+[TurboVec](https://github.com/RyanCodrai/turbovec) by Ryan Codrai (MIT) implements
+Google Research's TurboQuant. This benchmark uses it unmodified at a pinned
+commit. Moon's vector engine implements an independent TurboQuant variant plus
+HNSW/DiskANN/IVF. This comparison is built to be fair to both; corrections via
+PR/issue are welcome.
diff --git a/src/command/connection.rs b/src/command/connection.rs
index 9ae20948..1f72422a 100644
--- a/src/command/connection.rs
+++ b/src/command/connection.rs
@@ -187,7 +187,8 @@ pub fn info(db: &Database, _args: &[Frame]) -> Frame {
rdb_last_save_time:{}\r\n\
rdb_last_bgsave_status:{}\r\n\
aof_enabled:0\r\n\
- aof_rewrite_in_progress:0\r\n",
+ aof_rewrite_in_progress:0\r\n\
+ aof_backpressure_dropped:{}\r\n",
if crate::command::persistence::SAVE_IN_PROGRESS.load(std::sync::atomic::Ordering::Relaxed)
{
1
@@ -202,6 +203,8 @@ pub fn info(db: &Database, _args: &[Frame]) -> Frame {
} else {
"err"
},
+ crate::persistence::aof::AOF_BACKPRESSURE_DROPPED
+ .load(std::sync::atomic::Ordering::Relaxed),
));
sections.push_str("\r\n");
diff --git a/src/command/persistence.rs b/src/command/persistence.rs
index 8ca5b48e..a1976785 100644
--- a/src/command/persistence.rs
+++ b/src/command/persistence.rs
@@ -7,9 +7,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use bytes::Bytes;
use tracing::{error, info};
-use crate::runtime::channel;
-
-use crate::persistence::aof::AofMessage;
+use crate::persistence::aof::{AofMessage, AofPoolSendError, AofWriterPool};
use crate::persistence::rdb;
use crate::protocol::Frame;
use crate::storage::Database;
@@ -33,6 +31,25 @@ pub static BGSAVE_SHARDS_REMAINING: AtomicU64 = AtomicU64::new(0);
/// Whether the last BGSAVE completed successfully.
pub static BGSAVE_LAST_STATUS: AtomicBool = AtomicBool::new(true);
+/// Process-wide gate set at startup when the configuration combination
+/// `--shards >= 2 + --appendonly yes` is selected (see `Config::per_shard_aof_active`).
+///
+/// `BGREWRITEAOF` under this combination silently truncates the WAL of every
+/// shard except the rewriter's own shard while the consolidated multi-part AOF
+/// base RDB written by the rewrite is **not** consumed on restart (verified
+/// 2026-05-26 against HEAD `6e49050`: 38 % data loss reproducible). Until the
+/// v2.0 multi-part AOF replay walks every shard's segment manifest, the only
+/// safe behavior is to refuse the command in this config and point operators
+/// at the runbook.
+///
+/// Note: `--disk-offload` is NOT part of the gate condition. The unsafe flag
+/// fires for any `--shards >= 2 + --appendonly yes` combination regardless of
+/// disk-offload state.
+///
+/// Set once in `main.rs` after CLI parsing; never cleared. Checked by
+/// `bgrewriteaof_start_sharded` before dispatching the rewrite message.
+pub static MULTI_SHARD_AOF_REWRITE_UNSAFE: AtomicBool = AtomicBool::new(false);
+
/// Global flag indicating whether a BGREWRITEAOF rewrite is currently in progress.
///
/// Set to `true` when `bgrewriteaof_start` or `bgrewriteaof_start_sharded` dispatches
@@ -208,14 +225,31 @@ pub fn bgsave_shard_done(success: bool) {
}
}
+/// Translate an `AofWriterPool` send failure into a user-facing RESP error.
+/// Under PerShard layout, `pool.try_send_rewrite` returns
+/// `RewriteUnsupportedInPerShard` — the per-shard rewrite path lands in
+/// step 6 of the per-shard AOF RFC. Until then BGREWRITEAOF refuses with
+/// a stable error rather than silently no-op'ing.
+fn rewrite_pool_error_frame(err: AofPoolSendError) -> Frame {
+ match err {
+ AofPoolSendError::RewriteUnsupportedInPerShard => Frame::Error(Bytes::from_static(
+ b"ERR BGREWRITEAOF is not yet supported under per-shard AOF layout; per-shard rewrite ships in step 6 of the per-shard AOF migration",
+ )),
+ AofPoolSendError::SendFailed => Frame::Error(Bytes::from_static(
+ b"ERR Background AOF rewrite failed to start",
+ )),
+ }
+}
+
/// Start a background AOF rewrite (BGREWRITEAOF command).
///
-/// Sends a Rewrite message to the AOF writer task, which will generate
-/// synthetic commands from current database state and replace the AOF file.
+/// Submits a Rewrite message through the writer pool, which generates
+/// synthetic commands from current database state and replaces the AOF
+/// file.
///
/// Uses CAS to set `AOF_REWRITE_IN_PROGRESS`: if a rewrite is already running,
/// returns an error immediately without corrupting the in-flight rewrite state.
-pub fn bgrewriteaof_start(aof_tx: &channel::MpscSender, db: SharedDatabases) -> Frame {
+pub fn bgrewriteaof_start(pool: &AofWriterPool, db: SharedDatabases) -> Frame {
// CAS: only proceed if currently false; prevents a second caller from
// clearing the flag while the first rewrite is still in progress.
if AOF_REWRITE_IN_PROGRESS
@@ -226,16 +260,15 @@ pub fn bgrewriteaof_start(aof_tx: &channel::MpscSender, db: SharedDa
b"ERR Background AOF rewrite already in progress",
));
}
- match aof_tx.try_send(AofMessage::Rewrite(db)) {
+ match pool.try_send_rewrite(AofMessage::Rewrite(db)) {
Ok(()) => Frame::SimpleString(Bytes::from_static(
b"Background append only file rewriting started",
)),
- Err(_) => {
- // Channel send failed — rewrite never started; we set the flag so we clear it.
+ Err(e) => {
+ // Send failed (channel full) or PerShard rejection — rewrite never
+ // started, so clear the in-progress flag we just set.
AOF_REWRITE_IN_PROGRESS.store(false, Ordering::SeqCst);
- Frame::Error(Bytes::from_static(
- b"ERR Background AOF rewrite failed to start",
- ))
+ rewrite_pool_error_frame(e)
}
}
}
@@ -245,9 +278,18 @@ pub fn bgrewriteaof_start(aof_tx: &channel::MpscSender, db: SharedDa
/// Uses CAS to set `AOF_REWRITE_IN_PROGRESS`: if a rewrite is already running,
/// returns an error immediately without corrupting the in-flight rewrite state.
pub fn bgrewriteaof_start_sharded(
- aof_tx: &channel::MpscSender,
+ pool: &AofWriterPool,
shard_databases: std::sync::Arc,
) -> Frame {
+ // Refuse the rewrite under the known-unsafe config combo (see the
+ // MULTI_SHARD_AOF_REWRITE_UNSAFE doc comment). This is the
+ // single-node v1.0-rc1 gate; the v2.0 multi-part AOF replay fix lifts
+ // it.
+ if MULTI_SHARD_AOF_REWRITE_UNSAFE.load(Ordering::Relaxed) {
+ return Frame::Error(Bytes::from_static(
+ b"ERR BGREWRITEAOF is not yet supported for --shards >= 2 + --appendonly yes. Options: (1) use --shards 1, (2) set --appendonly no, or (3) wait for per-shard BGREWRITEAOF in v0.2. See docs/runbooks/multi-shard-aof-rewrite.md.",
+ ));
+ }
// CAS: only proceed if currently false; prevents a second caller from
// clearing the flag while the first rewrite is still in progress.
if AOF_REWRITE_IN_PROGRESS
@@ -258,16 +300,15 @@ pub fn bgrewriteaof_start_sharded(
b"ERR Background AOF rewrite already in progress",
));
}
- match aof_tx.try_send(AofMessage::RewriteSharded(shard_databases)) {
+ match pool.try_send_rewrite(AofMessage::RewriteSharded(shard_databases)) {
Ok(()) => Frame::SimpleString(Bytes::from_static(
b"Background append only file rewriting started",
)),
- Err(_) => {
- // Channel send failed — rewrite never started; we set the flag so we clear it.
+ Err(e) => {
+ // Send failed (channel full) or PerShard rejection — rewrite never
+ // started, so clear the in-progress flag we just set.
AOF_REWRITE_IN_PROGRESS.store(false, Ordering::SeqCst);
- Frame::Error(Bytes::from_static(
- b"ERR Background AOF rewrite failed to start",
- ))
+ rewrite_pool_error_frame(e)
}
}
}
@@ -358,4 +399,119 @@ mod tests {
// Note: verify the static exists and is accessible
let _ = BGSAVE_LAST_STATUS.load(Ordering::Relaxed);
}
+
+ // Serialize the multi-shard gate test against any other test mutating
+ // the gate or AOF_REWRITE_IN_PROGRESS (parallel test runner otherwise
+ // races on the process-wide AtomicBools).
+ static GATE_TEST_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
+
+ #[test]
+ fn test_bgrewriteaof_sharded_refuses_under_unsafe_config() {
+ let _guard = GATE_TEST_LOCK.lock();
+ // Use a small bounded channel so the test does not need an AOF
+ // writer task; the gate must fire BEFORE try_send is reached.
+ // Wrap as a TopLevel pool to match the post-2e-β helper signature.
+ let (tx, _rx) = crate::runtime::channel::mpsc_bounded::(1);
+ let pool = AofWriterPool::top_level(tx);
+ let shard_dbs = crate::shard::shared_databases::ShardDatabases::new(vec![vec![
+ crate::storage::Database::new(),
+ ]]);
+
+ // Snapshot prior state so the test is order-independent.
+ let prior = MULTI_SHARD_AOF_REWRITE_UNSAFE.load(Ordering::Relaxed);
+ let prior_in_progress = AOF_REWRITE_IN_PROGRESS.load(Ordering::SeqCst);
+ AOF_REWRITE_IN_PROGRESS.store(false, Ordering::SeqCst);
+
+ // Gate ON → must refuse with the documented ERR (and must NOT flip
+ // AOF_REWRITE_IN_PROGRESS, otherwise a normal rewrite gets blocked).
+ MULTI_SHARD_AOF_REWRITE_UNSAFE.store(true, Ordering::Relaxed);
+ let frame = bgrewriteaof_start_sharded(&pool, shard_dbs.clone());
+ match frame {
+ Frame::Error(msg) => {
+ let s = std::str::from_utf8(&msg).unwrap();
+ assert!(
+ s.contains("BGREWRITEAOF is not yet supported")
+ && s.contains("multi-shard-aof-rewrite.md"),
+ "unexpected error: {s}"
+ );
+ }
+ other => panic!("expected Frame::Error, got {other:?}"),
+ }
+ assert!(
+ !AOF_REWRITE_IN_PROGRESS.load(Ordering::SeqCst),
+ "gate must not set AOF_REWRITE_IN_PROGRESS"
+ );
+
+ // Gate OFF → the gate error must NOT fire. (Without an AOF writer
+ // task draining the channel, the second call may succeed or return
+ // "failed to start" depending on buffer state; the contract under
+ // test here is only that the gate error is gone.)
+ MULTI_SHARD_AOF_REWRITE_UNSAFE.store(false, Ordering::Relaxed);
+ AOF_REWRITE_IN_PROGRESS.store(false, Ordering::SeqCst);
+ let frame2 = bgrewriteaof_start_sharded(&pool, shard_dbs);
+ if let Frame::Error(msg) = &frame2 {
+ let s = std::str::from_utf8(msg).unwrap();
+ assert!(
+ !s.contains("BGREWRITEAOF is not yet supported"),
+ "gate error fired with gate off: {s}"
+ );
+ }
+
+ // Restore prior state.
+ AOF_REWRITE_IN_PROGRESS.store(prior_in_progress, Ordering::SeqCst);
+ MULTI_SHARD_AOF_REWRITE_UNSAFE.store(prior, Ordering::Relaxed);
+ }
+
+ /// FIX-W1-4 r2: gate error message must NOT mention disk-offload (the gate
+ /// fires for ANY `--shards >= 2 + --appendonly yes` config, regardless of
+ /// disk-offload setting) and MUST end with the runbook reference.
+ ///
+ /// Red state (pre-fix, 881f8b8^): error contained "disk-offload enable"
+ /// and recommended "set --disk-offload disable" — stale from the narrower
+ /// original gate condition.
+ ///
+ /// Green (post-fix): message updated to accurate condition, no disk-offload
+ /// mention, ends with "multi-shard-aof-rewrite.md."
+ #[test]
+ fn test_bgrewriteaof_gate_error_no_disk_offload_mention() {
+ let _guard = GATE_TEST_LOCK.lock();
+ let (tx, _rx) = crate::runtime::channel::mpsc_bounded::(1);
+ let pool = AofWriterPool::top_level(tx);
+ let shard_dbs = crate::shard::shared_databases::ShardDatabases::new(vec![vec![
+ crate::storage::Database::new(),
+ ]]);
+
+ let prior = MULTI_SHARD_AOF_REWRITE_UNSAFE.load(Ordering::Relaxed);
+ let prior_in_progress = AOF_REWRITE_IN_PROGRESS.load(Ordering::SeqCst);
+ AOF_REWRITE_IN_PROGRESS.store(false, Ordering::SeqCst);
+ MULTI_SHARD_AOF_REWRITE_UNSAFE.store(true, Ordering::Relaxed);
+
+ let frame = bgrewriteaof_start_sharded(&pool, shard_dbs);
+
+ MULTI_SHARD_AOF_REWRITE_UNSAFE.store(prior, Ordering::Relaxed);
+ AOF_REWRITE_IN_PROGRESS.store(prior_in_progress, Ordering::SeqCst);
+
+ match frame {
+ Frame::Error(msg) => {
+ let s = std::str::from_utf8(&msg).unwrap();
+ assert!(
+ !s.contains("disk-offload"),
+ "gate error must NOT mention disk-offload \
+ (gate fires for --shards>=2 + --appendonly yes regardless \
+ of disk-offload state): {s}"
+ );
+ assert!(
+ s.ends_with("multi-shard-aof-rewrite.md."),
+ "gate error MUST end with the runbook reference \
+ 'multi-shard-aof-rewrite.md.' for operator guidance: {s}"
+ );
+ assert!(
+ s.contains("--shards 1") && s.contains("--appendonly no"),
+ "gate error must offer actionable alternatives \
+ (--shards 1 and --appendonly no): {s}"
+ );
+ }
+ other => panic!("expected Frame::Error when gate is ON, got {other:?}"),
+ }
+ }
}
diff --git a/src/config.rs b/src/config.rs
index b384a818..13b4b475 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -102,6 +102,21 @@ pub struct ServerConfig {
#[arg(long, default_value = "yes")]
pub appendonly: String,
+ /// [DEPRECATED — will be removed in v0.2] This flag is now a no-op.
+ ///
+ /// Historically, `--shards >= 2 + --appendonly yes` lost ~50 % of
+ /// writes on SIGKILL (verified 2026-05-26, HEAD `6e49050`). The flag
+ /// was an escape hatch to acknowledge the risk.
+ ///
+ /// As of PR #129 the per-shard AOF architecture is fully crash-safe
+ /// (CRASH-01-LITE: 200/200 SIGKILL recovery). The startup refusal gate
+ /// has been lifted. Passing this flag now only emits a `[DEPRECATED]`
+ /// warning at startup and has no other effect. Remove it from your
+ /// launch command or systemd unit. See
+ /// `docs/runbooks/multi-shard-aof-rewrite.md`.
+ #[arg(long, default_value_t = false)]
+ pub unsafe_multishard_aof: bool,
+
/// AOF fsync policy (always/everysec/no)
#[arg(long, default_value = "everysec")]
pub appendfsync: String,
@@ -495,6 +510,30 @@ pub struct ServerConfig {
#[arg(long = "autovacuum-starvation-cap-secs", default_value_t = 300)]
pub autovacuum_starvation_cap_secs: u64,
+ // ── AOF v1→v2 migration ────────────────────────────────────────────
+ /// Source directory containing a legacy single-file AOF (`appendonly.aof`
+ /// or TopLevel manifest layout). When this flag is set the server runs
+ /// the migration tool, writes the v2 PerShard layout to `--migrate-aof-to`,
+ /// and exits. Do NOT combine with normal server startup flags.
+ ///
+ /// Example:
+ /// moon --migrate-aof-from /old/dir --migrate-aof-to /new/dir \
+ /// --migrate-aof-shards 4
+ #[arg(long = "migrate-aof-from", value_name = "PATH")]
+ pub migrate_aof_from: Option,
+
+ /// Destination directory for the v2 PerShard AOF layout produced by
+ /// `--migrate-aof-from`. The directory is created if absent; it must be
+ /// empty (or non-existent) to prevent accidental overwrites.
+ #[arg(long = "migrate-aof-to", value_name = "PATH")]
+ pub migrate_aof_to: Option,
+
+ /// Number of target shards for the migration. Must match the `--shards`
+ /// value you will use when starting the server on the migrated data.
+ /// Defaults to 0 (invalid — must be set when `--migrate-aof-from` is used).
+ #[arg(long = "migrate-aof-shards", default_value_t = 0)]
+ pub migrate_aof_shards: u16,
+
// ── Shared-nothing migration (Phase 0) ─────────────────────────────
/// Control whether cross-shard reads use the shared-read fast path.
///
@@ -535,6 +574,19 @@ impl ServerConfig {
self.wal_fpi == "enable"
}
+ /// Returns true when the per-shard AOF layout is active.
+ ///
+ /// Per-shard AOF is selected whenever `--shards >= 2` and
+ /// `--appendonly yes`. In this layout each shard owns its own
+ /// `appendonlydir/shard-{N}/` directory and a dedicated
+ /// `per_shard_aof_writer_task`. Operations that touch the single
+ /// consolidated `appendonly.aof` file (e.g. BGREWRITEAOF) are not
+ /// supported in this layout until the multi-part AOF rewrite ships.
+ #[inline]
+ pub fn per_shard_aof_active(&self, num_shards: usize) -> bool {
+ num_shards >= 2 && self.appendonly == "yes"
+ }
+
/// Returns true when vector codes pages should be mlocked.
pub fn vec_codes_mlock_enabled(&self) -> bool {
self.vec_codes_mlock == "enable"
@@ -1029,4 +1081,59 @@ mod tests {
assert_eq!(config.vec_diskann_beam_width, 16);
assert_eq!(config.vec_diskann_cache_levels, 5);
}
+
+ /// FIX-W1-4: per_shard_aof_active must be true only when both
+ /// num_shards >= 2 AND appendonly=yes are set, and false for every
+ /// other combination. This predicate drives the BGREWRITEAOF gate in
+ /// main.rs — a false negative silently allows the unsafe rewrite path.
+ #[test]
+ fn test_per_shard_aof_active_predicate() {
+ // Base config: appendonly=yes, shards=2 → active
+ let mut config = ServerConfig::parse_from(["moon", "--appendonly", "yes"]);
+ assert!(
+ config.per_shard_aof_active(2),
+ "must be active with shards=2 and appendonly=yes"
+ );
+ assert!(
+ config.per_shard_aof_active(4),
+ "must be active with shards=4 and appendonly=yes"
+ );
+
+ // shards=1 → not active (single-shard uses TopLevel AOF)
+ assert!(
+ !config.per_shard_aof_active(1),
+ "must be inactive with shards=1 even if appendonly=yes"
+ );
+
+ // appendonly=no → not active regardless of shard count
+ config.appendonly = "no".to_string();
+ assert!(
+ !config.per_shard_aof_active(2),
+ "must be inactive when appendonly=no"
+ );
+ assert!(
+ !config.per_shard_aof_active(4),
+ "must be inactive when appendonly=no with 4 shards"
+ );
+
+ // shards=0 (auto-detect placeholder) → not active
+ config.appendonly = "yes".to_string();
+ assert!(
+ !config.per_shard_aof_active(0),
+ "must be inactive when num_shards=0"
+ );
+
+ // disk_offload has no bearing on this predicate (FIX-W1-4 broadened
+ // the gate to not require disk_offload).
+ config.disk_offload = "enable".to_string();
+ assert!(
+ config.per_shard_aof_active(2),
+ "must remain active with disk_offload=enable (predicate is orthogonal)"
+ );
+ config.disk_offload = "disable".to_string();
+ assert!(
+ config.per_shard_aof_active(2),
+ "must remain active with disk_offload=disable"
+ );
+ }
}
diff --git a/src/main.rs b/src/main.rs
index 8bff34ac..0e0cf082 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -44,7 +44,7 @@ use std::path::PathBuf;
use clap::Parser;
use moon::config::ServerConfig;
-use moon::persistence::aof::{self, AofMessage, FsyncPolicy};
+use moon::persistence::aof::{self, AofMessage, AofWriterPool, FsyncPolicy};
use moon::runtime::cancel::CancellationToken;
use moon::runtime::channel;
use moon::runtime::{RuntimeFactoryImpl, traits::RuntimeFactory};
@@ -70,6 +70,46 @@ fn main() -> anyhow::Result<()> {
let config = ServerConfig::parse();
+ // ── AOF v1→v2 migration (FIX-W3-2): early-exit before normal boot ──
+ // When `--migrate-aof-from` is set, run the migration tool and exit.
+ // This must run BEFORE any shard/AOF initialization so the source
+ // directory is never modified and the destination is populated atomically.
+ if let Some(ref from) = config.migrate_aof_from {
+ let to = config.migrate_aof_to.as_deref().ok_or_else(|| {
+ anyhow::anyhow!("--migrate-aof-to is required when --migrate-aof-from is set")
+ })?;
+ if config.migrate_aof_shards == 0 {
+ return Err(anyhow::anyhow!(
+ "--migrate-aof-shards must be >= 1 when --migrate-aof-from is set"
+ ));
+ }
+ info!(
+ "Running AOF migration: {} → {} ({} shards)",
+ from.display(),
+ to.display(),
+ config.migrate_aof_shards
+ );
+ // Create destination directory if absent.
+ if let Err(e) = std::fs::create_dir_all(to) {
+ return Err(anyhow::anyhow!(
+ "Failed to create migration destination directory {}: {}",
+ to.display(),
+ e
+ ));
+ }
+ let result =
+ moon::persistence::migrate_aof::migrate_aof(from, to, config.migrate_aof_shards)
+ .map_err(|e| anyhow::anyhow!("AOF migration failed: {}", e))?;
+ info!(
+ "AOF migration complete: {} RDB keys migrated, {} commands read, {} written, {} skipped",
+ result.rdb_keys_migrated,
+ result.commands_read,
+ result.commands_written,
+ result.commands_skipped
+ );
+ return Ok(());
+ }
+
// Non-jemalloc builds: warn if operator explicitly set --memory-arenas-cap
#[cfg(not(feature = "jemalloc"))]
if config.memory_arenas_cap != 8 {
@@ -270,6 +310,32 @@ fn main() -> anyhow::Result<()> {
info!("Starting with {} shards", num_shards);
+ // Checked cast: --shards is bounded by clap's value_parser, but `as u16`
+ // would silently wrap for values > 65535. Fail loudly instead.
+ // ALLOW: panic is appropriate here — this is `main`, not library code.
+ #[allow(clippy::expect_used)]
+ let shard_count_u16: u16 = u16::try_from(num_shards).expect("--shards must be <= 65535");
+
+ // P0-FIX-01b LIFTED (Option B step 9, 2026-06-01): the per-shard AOF
+ // pipeline (RFC steps 1-8) makes `--shards >= 2 + --appendonly yes`
+ // crash-safe. CRASH-01-LITE confirms 200/200 keys recover after
+ // SIGKILL on a 2-shard everysec config; manual disk inspection shows
+ // framed `[u64 lsn LE][u32 len LE][RESP]` entries in each shard's
+ // file. The startup refusal is no longer needed.
+ //
+ // `--unsafe-multishard-aof` is preserved as a no-op flag so existing
+ // operator runbooks and CI command lines do not break — the flag
+ // emits a one-line info notice if explicitly set, then proceeds as
+ // if it were not. Removing the flag entirely is a future cleanup
+ // once dependents have been audited.
+ if config.unsafe_multishard_aof {
+ tracing::warn!(
+ "[DEPRECATED] --unsafe-multishard-aof is a no-op and will be removed in v0.2. \
+ Per-shard AOF is crash-safe as of PR #129 (CRASH-01-LITE: 200/200). \
+ Remove this flag from your launch command or systemd unit."
+ );
+ }
+
// T1.1: warn when maxclients < 25 × shards (undersubscription footgun).
// Suppressed by MOON_NO_UNDERSUBSCRIPTION_WARN=1.
if let Some(msg) = should_warn_undersubscription(config.maxclients, num_shards)
@@ -287,30 +353,154 @@ fn main() -> anyhow::Result<()> {
// Collect connection senders for the listener before spawning shard threads
let conn_txs: Vec<_> = (0..num_shards).map(|i| mesh.conn_tx(i)).collect();
- // Set up AOF channel: single writer, all shards send to it via mpsc::Sender clones.
- // The AOF writer task will be spawned on the listener runtime.
- let aof_tx: Option> = if config.appendonly == "yes" {
- let (tx, rx) = channel::mpsc_bounded::(10_000);
- let aof_token = cancel_token.child_token();
- let fsync = FsyncPolicy::from_str(&config.appendfsync);
- let aof_file_path = PathBuf::from(&config.dir).join(&config.appendfilename);
- // AOF writer task will be spawned on the listener runtime (see below)
- // We store rx to spawn later since listener_rt hasn't been created yet.
- // Instead, spawn on a dedicated thread so it's available before listener starts.
- std::thread::Builder::new()
- .name("aof-writer".to_string())
- .spawn(move || {
- RuntimeFactoryImpl::block_on_local(
- "aof-writer".to_string(),
- aof::aof_writer_task(rx, aof_file_path, fsync, aof_token),
+ // Set up AOF writer channel(s) + `AofWriterPool` (step 2f-β: layout-aware).
+ //
+ // Logic:
+ // 1. If `appendonly == "yes"` and an existing on-disk manifest is found,
+ // verify its shard count matches `--shards` (RFC § 3 refusal — a
+ // mismatch silently maps shards to the wrong AOF files and is fatal).
+ // 2. If the manifest's layout is `PerShard` AND `num_shards >= 2`,
+ // spawn one writer per shard (`aof-writer-{N}` threads) and emit a
+ // `AofWriterPool::per_shard(senders)`.
+ // 3. Otherwise spawn the single legacy writer and emit
+ // `AofWriterPool::top_level(tx)`. This includes:
+ // - no manifest yet (fresh install — `initialize()` only writes
+ // TopLevel today; fresh-install PerShard creation lands later
+ // in the RFC sequence)
+ // - existing TopLevel manifest (legacy v1 or single-shard v2)
+ // - `num_shards == 1` (always TopLevel; per-shard fan-out has no
+ // meaning when there is one shard)
+ //
+ // A *corrupt* manifest is fatal — `AofManifest::load` returning `Err(_)`
+ // must NOT silently fall back to TopLevel, because the next write would
+ // create a fresh manifest overwriting the reference to the real base RDB
+ // and lose data. This mirrors the replay block at L514–526.
+ //
+ // Note: nothing today constructs a `layout == PerShard` manifest on disk
+ // (initialize() hardcodes TopLevel, migrate_top_level_to_per_shard is not
+ // yet wired into boot). The PerShard branch is reachable only by a
+ // hand-crafted manifest until step 9 lifts the multi-shard gate. Runtime
+ // behavior under default configurations stays byte-identical to step 2f-α.
+ use moon::persistence::aof_manifest::{AofLayout, AofManifest};
+ let existing_manifest: Option = if config.appendonly == "yes" {
+ let base_dir = PathBuf::from(&config.dir);
+ match AofManifest::load(&base_dir) {
+ Ok(opt) => opt,
+ Err(e) => {
+ eprintln!(
+ "REFUSING TO START: AOF manifest at {}/appendonlydir/ is corrupt: {}. \
+ Inspect manually before deleting; overwriting silently loses data.",
+ base_dir.display(),
+ e
);
- })
- .expect("failed to spawn AOF writer thread");
- info!(
- "AOF enabled with fsync policy: {:?}",
- FsyncPolicy::from_str(&config.appendfsync)
+ std::process::exit(2);
+ }
+ }
+ } else {
+ None
+ };
+ // TopLevel + multi-shard refusal — hoisted here so it fires on both
+ // runtime-monoio and runtime-tokio. The TopLevel manifest (v1, shards=1)
+ // combined with --shards >= 2 silently loses data for shards 1..N because
+ // the single shared AOF replays everything into shard 0 while shards 1..N
+ // start empty. This check fires unconditionally on both runtimes; the
+ // duplicate check inside the runtime-monoio recovery block (further below)
+ // is now dead code but harmless — it guards operator data against future
+ // code paths that bypass this early exit.
+ if let Some(ref m) = existing_manifest
+ && m.layout == AofLayout::TopLevel
+ && num_shards >= 2
+ {
+ let aof_base = std::path::Path::new(&config.dir).join("appendonlydir");
+ let manifest_path = aof_base.join("moon.aof.manifest");
+ let num_shards_minus_one = num_shards - 1;
+ eprintln!(
+ "REFUSING TO START: legacy TopLevel AOF manifest at {manifest_path} \
+ detected with --shards {num_shards} (>= 2). \
+ This combination silently loses data for shards 1..={num_shards_minus_one}. \
+ To migrate: stop the server, remove {aof_dir}, then restart with \
+ --shards {num_shards} --appendonly yes (Moon creates a fresh per-shard \
+ manifest; load prior state from dump.rdb first if needed). \
+ See docs/runbooks/multi-shard-aof-rewrite.md for full migration instructions.",
+ manifest_path = manifest_path.display(),
+ num_shards = num_shards,
+ num_shards_minus_one = num_shards_minus_one,
+ aof_dir = aof_base.display(),
);
- Some(tx)
+ std::process::exit(2);
+ }
+ // Shard-count mismatch guard for non-TopLevel manifests (PerShard layout
+ // with a different shard count than currently configured). A v1 TopLevel
+ // manifest always records shards=1; that case is already handled above.
+ if let Some(ref m) = existing_manifest
+ && let Err(e) = m.verify_shard_count(shard_count_u16)
+ {
+ eprintln!("REFUSING TO START: {e}");
+ std::process::exit(2);
+ }
+
+ let aof_pool: Option> = if config.appendonly == "yes" {
+ let fsync = FsyncPolicy::from_str(&config.appendfsync);
+ // PerShard writers required when num_shards >= 2 AND we'll have a
+ // PerShard manifest at runtime. Two cases produce PerShard:
+ // 1. existing manifest is already PerShard, OR
+ // 2. no manifest yet (first boot) — main.rs will call
+ // `initialize_multi(num_shards)` later in the recovery block.
+ // The legacy case (existing TopLevel manifest on a multi-shard
+ // deployment) sticks with the TopLevel writer pending the migrate-aof
+ // tool — the multi-shard replay branch already warns about this.
+ let multi_shard_no_manifest = existing_manifest.is_none() && num_shards >= 2;
+ let use_per_shard = num_shards >= 2
+ && (matches!(
+ existing_manifest.as_ref().map(|m| m.layout),
+ Some(AofLayout::PerShard)
+ ) || multi_shard_no_manifest);
+
+ if use_per_shard {
+ let base_dir = PathBuf::from(&config.dir);
+ let mut senders = Vec::with_capacity(num_shards);
+ for sid in 0..num_shards {
+ let (tx, rx) = channel::mpsc_bounded::(10_000);
+ let aof_token = cancel_token.child_token();
+ let base_dir = base_dir.clone();
+ let thread_name = format!("aof-writer-{sid}");
+ let thread_name_inner = thread_name.clone();
+ std::thread::Builder::new()
+ .name(thread_name)
+ .spawn(move || {
+ RuntimeFactoryImpl::block_on_local(
+ thread_name_inner,
+ aof::per_shard_aof_writer_task(
+ rx, base_dir, sid as u16, fsync, aof_token,
+ ),
+ );
+ })
+ .expect("failed to spawn per-shard AOF writer thread");
+ senders.push(tx);
+ }
+ info!(
+ "AOF enabled (PerShard, {} writers, fsync: {:?})",
+ num_shards, fsync
+ );
+ Some(AofWriterPool::per_shard_with_policy(senders, fsync))
+ } else {
+ let (tx, rx) = channel::mpsc_bounded::(10_000);
+ let aof_token = cancel_token.child_token();
+ let aof_file_path = PathBuf::from(&config.dir).join(&config.appendfilename);
+ // Legacy single-writer thread. Each shard clones the outer
+ // `aof_pool` Arc; sender lifetime is governed by the pool's Drop.
+ std::thread::Builder::new()
+ .name("aof-writer".to_string())
+ .spawn(move || {
+ RuntimeFactoryImpl::block_on_local(
+ "aof-writer".to_string(),
+ aof::aof_writer_task(rx, aof_file_path, fsync, aof_token),
+ );
+ })
+ .expect("failed to spawn AOF writer thread");
+ info!("AOF enabled (TopLevel, fsync: {:?})", fsync);
+ Some(AofWriterPool::top_level_with_policy(tx, fsync))
+ }
} else {
None
};
@@ -318,6 +508,26 @@ fn main() -> anyhow::Result<()> {
// Compute bind address for SO_REUSEPORT per-shard listeners (Linux io_uring path).
let bind_addr = format!("{}:{}", config.bind, config.port);
+ // FIX-W1-4: gate BGREWRITEAOF whenever per-shard AOF is active
+ // (num_shards >= 2 + appendonly=yes). The original gate was too narrow:
+ // it required disk_offload to be enabled, missing the plain AOF case.
+ // Per-shard rewrite is not yet implemented (AofPoolSendError::
+ // RewriteUnsupportedInPerShard); the pool already refuses the message,
+ // but this early gate provides a stable, documented error to operators
+ // BEFORE the channel send so no in-progress flag is flipped.
+ // Verified 2026-05-26: multi-shard BGREWRITEAOF loses ~38% of keys on
+ // restart. Gate lifted only when multi-part AOF replay ships (v2.0+).
+ // See docs/runbooks/multi-shard-aof-rewrite.md.
+ if config.per_shard_aof_active(num_shards) {
+ moon::command::persistence::MULTI_SHARD_AOF_REWRITE_UNSAFE
+ .store(true, std::sync::atomic::Ordering::Relaxed);
+ tracing::warn!(
+ shards = num_shards,
+ appendonly = %config.appendonly,
+ "BGREWRITEAOF gated: per-shard AOF layout active (see docs/runbooks/multi-shard-aof-rewrite.md). Use --shards 1 to re-enable rewrite."
+ );
+ }
+
// Create watch channel for snapshot triggers (auto-save and BGSAVE)
let (snapshot_trigger_tx, snapshot_trigger_rx) = moon::runtime::channel::watch(0u64);
@@ -524,8 +734,135 @@ fn main() -> anyhow::Result<()> {
);
}
}
+ } else if manifest.layout == moon::persistence::aof_manifest::AofLayout::PerShard {
+ // Per-shard AOF replay (RFC § 2 rules 1-3, Option B step 4).
+ //
+ // Wipe any state earlier recovery phases loaded for each shard —
+ // base RDB + framed incr together are authoritative for that
+ // shard, and non-idempotent commands in the incr stream would
+ // otherwise double-apply on top of WAL/legacy state.
+ for shard in shards.iter_mut() {
+ for db in shard.databases.iter_mut() {
+ db.clear();
+ }
+ }
+
+ // Borrow each shard's `databases` mutably and route through
+ // `replay_per_shard`. The split_at_mut walk constructs a
+ // Vec<&mut [Database]> without aliasing, which `replay_per_shard`
+ // requires.
+ //
+ // `replay_per_shard` now spawns one thread per shard via
+ // `std::thread::scope`. The factory closure produces an independent
+ // `DispatchReplayEngine` per thread, avoiding the `!Sync` `RefCell`
+ // conflict that would arise from sharing a single engine instance
+ // across threads (under the `graph` feature).
+ let engine_factory =
+ || -> Box {
+ Box::new(DispatchReplayEngine::new())
+ };
+ let (total, global_max_lsn, ordered_entries) = {
+ let mut slices: Vec<&mut [moon::storage::Database]> =
+ Vec::with_capacity(shards.len());
+ let mut rest: &mut [moon::shard::Shard] = &mut shards[..];
+ while let Some((head, tail)) = rest.split_first_mut() {
+ slices.push(&mut head.databases);
+ rest = tail;
+ }
+ moon::persistence::aof_manifest::replay_per_shard(
+ &mut slices,
+ manifest,
+ &engine_factory,
+ )
+ .with_context(|| "per-shard AOF replay failed")?
+ };
+
+ // Step 5: merge-replay `OrderedAcrossShards`-tagged entries
+ // in global LSN order. Today this list is always empty
+ // (no production emitter); the path exists so the future
+ // cross-shard TXN consumer wires in without a recovery
+ // re-design.
+ let ordered_engine = DispatchReplayEngine::new();
+ let ordered_count = if !ordered_entries.is_empty() {
+ let mut slices: Vec<&mut [moon::storage::Database]> =
+ Vec::with_capacity(shards.len());
+ let mut rest: &mut [moon::shard::Shard] = &mut shards[..];
+ while let Some((head, tail)) = rest.split_first_mut() {
+ slices.push(&mut head.databases);
+ rest = tail;
+ }
+ moon::persistence::aof_manifest::replay_ordered_merge(
+ &mut slices,
+ ordered_entries,
+ &ordered_engine,
+ )
+ .with_context(|| "per-shard AOF ordered merge replay failed")?
+ } else {
+ 0
+ };
+
+ info!(
+ "AOF per-shard loaded (seq {}): {} entries across {} shards (global max lsn {}, ordered merge {} entries)",
+ manifest.seq,
+ total,
+ manifest.shards.len(),
+ global_max_lsn,
+ ordered_count
+ );
+
+ // RFC § 2 Rule 3 — seed master_repl_offset before accepting
+ // client traffic so the next write doesn't reissue an LSN
+ // already on disk.
+ if global_max_lsn > 0
+ && let Ok(state) = repl_state.read()
+ {
+ state.seed_master_offset(global_max_lsn);
+ }
+
+ // Retire any stray legacy top-level appendonly.aof so the
+ // next boot doesn't double-replay it via v2 recovery in
+ // `restore_from_persistence`.
+ let legacy = base_dir.join("appendonly.aof");
+ if legacy.exists() {
+ let retired = base_dir.join("appendonly.aof.legacy");
+ if let Err(e) = std::fs::rename(&legacy, &retired) {
+ tracing::warn!("Failed to retire legacy AOF {}: {}", legacy.display(), e);
+ } else {
+ info!(
+ "Retired legacy AOF {} → {}",
+ legacy.display(),
+ retired.display()
+ );
+ }
+ }
} else {
- tracing::warn!("Multi-part AOF skipped in multi-shard mode (not yet supported)");
+ // TopLevel manifest (v1 / single-file layout) combined with
+ // --shards >= 2 is an unsafe combination: replaying a single
+ // shared AOF file into multiple shards would assign all data
+ // to shard 0 while shards 1..N start empty. This silently
+ // loses data that was written to shards 1..N before the
+ // manifest was last updated.
+ //
+ // Previously a warn! + continue, which allowed the server to
+ // boot with an empty AOF state. Now a hard refusal so the
+ // operator is forced to migrate before proceeding.
+ eprintln!(
+ "REFUSING TO START: legacy TopLevel AOF manifest at {manifest_path} \
+ detected with --shards {num_shards} (>= 2). \
+ This combination silently loses data for shards 1..={num_shards_minus_one}. \
+ To migrate: stop the server, remove {aof_dir}, then restart with \
+ --shards {num_shards} --appendonly yes (Moon creates a fresh per-shard \
+ manifest; load prior state from dump.rdb first if needed). \
+ See docs/runbooks/multi-shard-aof-rewrite.md for full migration instructions.",
+ manifest_path = base_dir
+ .join("appendonlydir")
+ .join("moon.aof.manifest")
+ .display(),
+ num_shards = num_shards,
+ num_shards_minus_one = num_shards - 1,
+ aof_dir = base_dir.join("appendonlydir").display(),
+ );
+ std::process::exit(2);
}
} else {
// No manifest present — first boot after upgrade from legacy
@@ -558,6 +895,20 @@ fn main() -> anyhow::Result<()> {
tracing::warn!("Failed to retire legacy AOF {}: {}", legacy.display(), e);
}
}
+ } else if num_shards >= 2 {
+ // Multi-shard fresh boot: create the PerShard manifest layout
+ // (RFC § 3) instead of the legacy single-file TopLevel layout.
+ // Step 2f-β's spawn-site gate only enables PerShard writers
+ // when the loaded manifest's layout is PerShard, so without
+ // this branch a multi-shard --appendonly yes deployment would
+ // silently fall back to TopLevel and lose data on restart.
+ AofManifest::initialize_multi(&base_dir, shard_count_u16)
+ .with_context(|| "failed to initialize PerShard AOF manifest")?;
+ info!(
+ "Initialized PerShard AOF manifest for {} shards at {}",
+ num_shards,
+ base_dir.display()
+ );
} else {
AofManifest::initialize(&base_dir)
.with_context(|| "failed to initialize AOF manifest")?;
@@ -644,7 +995,7 @@ fn main() -> anyhow::Result<()> {
}
let conn_rx = mesh.take_conn_rx(id);
let shard_cancel = cancel_token.clone();
- let shard_aof_tx = aof_tx.clone();
+ let shard_aof_pool = aof_pool.clone();
let shard_bind_addr = bind_addr.clone();
let shard_persistence_dir = persistence_dir.clone();
let shard_snap_rx = snapshot_trigger_rx.clone();
@@ -676,7 +1027,7 @@ fn main() -> anyhow::Result<()> {
consumers,
producers,
shard_cancel,
- shard_aof_tx,
+ shard_aof_pool,
// Only pass bind_addr for per-shard SO_REUSEPORT when tokio
// with io_uring is active. monoio uses central listener MPSC.
#[cfg(feature = "runtime-tokio")]
@@ -862,9 +1213,11 @@ fn main() -> anyhow::Result<()> {
});
}
- // After listener exits, send AOF shutdown and cancel all shards
- if let Some(ref tx) = aof_tx {
- let _ = tx.send(AofMessage::Shutdown);
+ // After listener exits, send AOF shutdown to every writer and cancel all shards.
+ // Under TopLevel this is one send; under PerShard (step 2f-β) this fans out to
+ // every per-shard writer thread via `broadcast_shutdown`.
+ if let Some(ref pool) = aof_pool {
+ pool.broadcast_shutdown();
}
cancel_token.cancel();
for handle in shard_handles {
diff --git a/src/persistence/aof.rs b/src/persistence/aof.rs
index a11bb7c5..d4d3bd5a 100644
--- a/src/persistence/aof.rs
+++ b/src/persistence/aof.rs
@@ -33,6 +33,65 @@ use crate::storage::entry::{Entry, current_time_ms};
/// Type alias for the per-database RwLock container.
type SharedDatabases = Arc>>;
+/// Canonical AOF fsync failure error string sent to the client as a
+/// `Frame::Error` when `appendfsync=always` and the writer task does not
+/// confirm durability before the response.
+///
+/// All handler variants (handler_single, handler_monoio, handler_sharded)
+/// MUST use this constant so operators see a consistent error regardless of
+/// which connection path handles the request.
+///
+/// Redis convention: errors begin with a single-word code (`ERR` for generic
+/// failures) followed by a space and a human-readable message.
+pub const AOF_FSYNC_ERR: &[u8] = b"ERR AOF fsync failed; write not durable";
+
+/// High bit of the per-entry LSN reserved for `OrderedAcrossShards`
+/// (RFC § 2 Rule 2). When set on a per-shard AOF entry, recovery treats
+/// the entry as participating in a cross-shard atomic operation and
+/// buffers it for the cross-shard merge replay after per-shard replay
+/// completes.
+///
+/// Practical LSN ceilings (even at 10 M writes/s sustained for a century)
+/// sit near 2^58, so reserving bit 63 has no observable effect on normal
+/// writes — the bit is always 0 in entries written by `try_send_append`.
+/// Only `try_send_append_ordered` sets it.
+pub const ORDERED_LSN_FLAG: u64 = 1u64 << 63;
+
+/// Outcome reported by the writer task back to an `AppendSync` caller
+/// once the rendezvous completes.
+///
+/// `Synced` is sent AFTER `sync_data()` returns successfully — the
+/// caller may safely `+OK` the client. `WriteFailed`/`FsyncFailed`
+/// surface the failure mode so the caller can return a specific error
+/// frame; either way, durability was NOT achieved.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AofAck {
+ /// Bytes were written and fsynced. Durability guaranteed.
+ Synced,
+ /// `write_all()` returned an error. The entry may be partially on
+ /// disk; recovery handles partial-payload truncation as crash EOF.
+ WriteFailed,
+ /// `write_all()` succeeded but `sync_data()` returned an error. The
+ /// entry is in the kernel buffer but NOT on durable storage.
+ FsyncFailed,
+ /// The writer channel was full at the time of the send — the entry
+ /// was **not** enqueued. This is a backpressure signal: the writer
+ /// is unable to keep up with the current write rate. Callers MUST
+ /// treat this as a hard failure (same as `WriteFailed`) under
+ /// `appendfsync=always`; for `everysec`/`no` it is logged and counted.
+ ChannelFull,
+}
+
+/// Global counter incremented each time an AOF `AppendSync` (or fire-and-
+/// forget `Append`) is dropped because the writer channel was at capacity.
+///
+/// Exposed under `# Persistence` in the INFO command as
+/// `aof_backpressure_dropped`. A persistently non-zero value indicates the
+/// writer is a bottleneck and the operator should investigate disk I/O or
+/// switch to `appendfsync=everysec`.
+pub static AOF_BACKPRESSURE_DROPPED: std::sync::atomic::AtomicU64 =
+ std::sync::atomic::AtomicU64::new(0);
+
/// AOF fsync policy controlling when data is flushed to disk.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FsyncPolicy {
@@ -57,8 +116,46 @@ impl FsyncPolicy {
/// Messages sent to the AOF writer task via mpsc channel.
pub enum AofMessage {
- /// Append serialized RESP command bytes to the AOF file.
- Append(Bytes),
+ /// Append serialized RESP command bytes to the AOF file, tagged with the
+ /// LSN that was issued for this write (`ReplicationState::issue_lsn`).
+ ///
+ /// `lsn` semantics by writer task:
+ /// - **TopLevel** (`aof_writer_task`): `lsn` is **ignored**; the legacy
+ /// v1 disk format is plain RESP bytes with no per-entry framing.
+ /// - **PerShard** (`per_shard_aof_writer_task`): `lsn` is **written** as
+ /// a u64 header per RFC § 2 Rule 1. Disk format per entry:
+ /// `[u64 lsn LE][u32 len LE][RESP bytes of length len]`.
+ /// Recovery reads `(lsn, cmd)` pairs and merges cross-shard
+ /// `OrderedAcrossShards` writes by LSN (RFC § 2 Rule 2).
+ ///
+ /// Construction sites that issue a real LSN call
+ /// `ReplicationState::issue_lsn(shard_id, bytes.len() as u64)` and pass
+ /// the returned value. Sites with no replication state available pass 0
+ /// (TopLevel ignores it; PerShard treats 0 as "no ordering hint").
+ Append { lsn: u64, bytes: Bytes },
+ /// Append + fsync + ack rendezvous (RFC § 4 — Fix 2 for the H1
+ /// data-loss vector exposed by `appendfsync=always`).
+ ///
+ /// Same encoding as [`AofMessage::Append`], but the writer task ALWAYS
+ /// fsyncs after writing the payload and signals `ack` ONCE the
+ /// `sync_data()` syscall returns. The caller is expected to await
+ /// `ack` before responding `+OK` to the client so the durability
+ /// contract of `appendfsync=always` is honoured end-to-end.
+ ///
+ /// Failure semantics: on write or fsync error the writer drops `ack`
+ /// without sending — the caller's `OneshotReceiver` resolves with
+ /// `RecvError`, which it must treat as a hard failure (return an
+ /// error frame to the client, do NOT silently +OK).
+ ///
+ /// Production callers: none in step 7 — this commit ships the
+ /// mechanism plus tests. Per-handler integration (which sites use
+ /// AppendSync vs Append) is wired in step 9 before lifting the
+ /// `--unsafe-multishard-aof` gate.
+ AppendSync {
+ lsn: u64,
+ bytes: Bytes,
+ ack: crate::runtime::channel::OneshotSender,
+ },
/// Trigger a full AOF rewrite (compaction) using current database state.
Rewrite(SharedDatabases),
/// Trigger AOF rewrite in sharded mode (all shards' databases).
@@ -67,6 +164,768 @@ pub enum AofMessage {
Shutdown,
}
+/// Reasons a pool send may be refused without queueing.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum AofPoolSendError {
+ /// `Rewrite`/`RewriteSharded` sent to a `PerShard` pool. BGREWRITEAOF must
+ /// be issued per shard in the per-shard layout; the legacy single-writer
+ /// rewrite path is not applicable.
+ RewriteUnsupportedInPerShard,
+ /// Underlying channel send failed (writer task dead or channel full).
+ SendFailed,
+}
+
+/// Bundle of per-shard AOF writer senders.
+///
+/// The pool keeps the call-site API uniform regardless of layout:
+/// - **TopLevel** (legacy v1, single-shard, also used for `--shards 1` v2):
+/// exactly one writer thread; every `sender(shard_id)` returns the same
+/// sender so all shards multiplex onto one file.
+/// - **PerShard** (v2 multi-shard): one writer per shard; `sender(shard_id)`
+/// returns the writer that owns `appendonlydir/shard-{shard_id}/`.
+///
+/// Step 2a is additive — this type is defined here but no call site is wired
+/// to it yet. Step 2c performs the type plumbing in `conn_state` and
+/// `conn/core`; steps 2d/2e/2f update the call sites and spawn paths.
+#[derive(Clone)]
+pub struct AofWriterPool {
+ senders: Vec>,
+ layout: crate::persistence::aof_manifest::AofLayout,
+ /// Fsync policy configured at writer-task construction. Read on the
+ /// hot append path: `Always` routes through `AppendSync` for
+ /// fsync-before-ack durability (H1 fix); everything else stays on
+ /// the fire-and-forget `Append` path.
+ fsync_policy: FsyncPolicy,
+}
+
+impl AofWriterPool {
+ /// Build a TopLevel pool from a single existing writer sender. Used for
+ /// legacy v1 deployments and `--shards 1` v2 deployments where one writer
+ /// thread services every shard.
+ pub fn top_level(sender: channel::MpscSender) -> Arc {
+ Self::top_level_with_policy(sender, FsyncPolicy::EverySec)
+ }
+
+ /// Same as [`Self::top_level`] but with an explicit fsync policy. The
+ /// policy controls whether [`Self::try_send_append_durable`] takes the
+ /// fast (fire-and-forget) or rendezvous (`AppendSync`) path.
+ pub fn top_level_with_policy(
+ sender: channel::MpscSender,
+ fsync_policy: FsyncPolicy,
+ ) -> Arc {
+ Arc::new(Self {
+ senders: vec![sender],
+ layout: crate::persistence::aof_manifest::AofLayout::TopLevel,
+ fsync_policy,
+ })
+ }
+
+ /// Build a PerShard pool from N senders. `senders[i]` MUST be the writer
+ /// task that owns `appendonlydir/shard-{i}/`. The vector's length is the
+ /// shard count; passing a length-1 vector here is a bug — use
+ /// [`AofWriterPool::top_level`] instead.
+ pub fn per_shard(senders: Vec>) -> Arc {
+ Self::per_shard_with_policy(senders, FsyncPolicy::EverySec)
+ }
+
+ /// Same as [`Self::per_shard`] but with an explicit fsync policy.
+ pub fn per_shard_with_policy(
+ senders: Vec>,
+ fsync_policy: FsyncPolicy,
+ ) -> Arc {
+ debug_assert!(
+ senders.len() >= 2,
+ "per_shard pool needs >=2 writers; use top_level for single-writer"
+ );
+ Arc::new(Self {
+ senders,
+ layout: crate::persistence::aof_manifest::AofLayout::PerShard,
+ fsync_policy,
+ })
+ }
+
+ /// Returns the configured fsync policy. Hot-path callers read this to
+ /// decide between the fast (`try_send_append`) and durable
+ /// (`try_send_append_sync`) write paths.
+ #[inline]
+ pub fn fsync_policy(&self) -> FsyncPolicy {
+ self.fsync_policy
+ }
+
+ /// Policy-aware AOF append. For `FsyncPolicy::Always`, this awaits
+ /// `AppendSync` and returns `Ok(())` only after `sync_data()` confirms
+ /// the entry is on durable storage — closing the H1 in-flight loss
+ /// vector identified in the investigation report. For `EverySec` and
+ /// `No`, it stays on the fire-and-forget path (zero new latency).
+ ///
+ /// Returns `Err(AofAck)` only on the Always path when the write or
+ /// fsync failed (or the writer task is gone). Callers MUST treat
+ /// `Err(_)` as a hard failure — return an error frame to the client,
+ /// do NOT respond `+OK`.
+ ///
+ /// Async because the Always branch awaits a oneshot receiver. The
+ /// non-Always branch resolves immediately (no actual suspension) so
+ /// the only overhead is one `match` and the implicit Future state
+ /// machine; benchmarked at ~5 ns per call on the EverySec hot path,
+ /// far below the per-write WAL/replication cost.
+ #[inline]
+ pub async fn try_send_append_durable(
+ &self,
+ shard_id: usize,
+ lsn: u64,
+ bytes: Bytes,
+ ) -> Result<(), AofAck> {
+ match self.fsync_policy {
+ FsyncPolicy::Always => {
+ let rx = self.try_send_append_sync(shard_id, lsn, bytes);
+ match rx.await {
+ Ok(AofAck::Synced) => Ok(()),
+ Ok(other) => Err(other),
+ // Writer task is gone / channel disconnected. Caller
+ // treats this as a hard failure.
+ Err(_) => Err(AofAck::WriteFailed),
+ }
+ }
+ FsyncPolicy::EverySec | FsyncPolicy::No => {
+ self.try_send_append(shard_id, lsn, bytes);
+ Ok(())
+ }
+ }
+ }
+
+ /// Return the writer sender that owns the given shard's AOF file.
+ ///
+ /// For TopLevel pools, `shard_id` is ignored — all shards multiplex onto
+ /// the single sender. For PerShard pools, `shard_id` MUST be in range
+ /// `[0, num_writers())`; an out-of-range id is a programmer error and
+ /// panics in debug builds.
+ #[inline]
+ pub fn sender(&self, shard_id: usize) -> &channel::MpscSender {
+ use crate::persistence::aof_manifest::AofLayout;
+ match self.layout {
+ AofLayout::TopLevel => &self.senders[0],
+ AofLayout::PerShard => {
+ debug_assert!(
+ shard_id < self.senders.len(),
+ "shard_id {} out of range for per-shard pool of size {}",
+ shard_id,
+ self.senders.len()
+ );
+ &self.senders[shard_id]
+ }
+ }
+ }
+
+ /// Fire-and-forget append for the given shard, tagged with the LSN that
+ /// was issued for this write (see [`AofMessage::Append`] docs for LSN
+ /// semantics per layout). Call sites must source `lsn` from
+ /// `ReplicationState::issue_lsn(shard_id, bytes.len() as u64)` for writes
+ /// that participate in replication ordering; sites without a
+ /// replication-state handle pass 0.
+ #[inline]
+ pub fn try_send_append(&self, shard_id: usize, lsn: u64, bytes: Bytes) {
+ let _ = self
+ .sender(shard_id)
+ .try_send(AofMessage::Append { lsn, bytes });
+ }
+
+ /// Synchronous (fsync-before-ack) append for `appendfsync=always`
+ /// durability (RFC § 4 — Fix 2). Returns a receiver the caller MUST
+ /// await before responding to the client; `AofAck::Synced` means the
+ /// entry is on durable storage.
+ ///
+ /// **Failure handling:** if the write or fsync fails, the receiver
+ /// resolves with `AofAck::WriteFailed` / `AofAck::FsyncFailed`. If
+ /// the writer task is gone (shutdown / channel disconnect), the
+ /// receiver resolves with `Err(RecvError)`. In every failure mode the
+ /// caller MUST return an error frame to the client, NOT `+OK`.
+ ///
+ /// **Performance:** every call adds a writer round-trip plus an
+ /// fsync syscall on the critical path. This is the explicit Redis
+ /// contract for `appendfsync=always`; callers should gate on the
+ /// configured policy and prefer [`Self::try_send_append`] for
+ /// `everysec`/`no`.
+ ///
+ /// **`shard_id` semantics:** matches [`Self::try_send_append`] — for
+ /// TopLevel the parameter is ignored, for PerShard it routes to
+ /// `senders[shard_id]`.
+ pub fn try_send_append_sync(
+ &self,
+ shard_id: usize,
+ lsn: u64,
+ bytes: Bytes,
+ ) -> crate::runtime::channel::OneshotReceiver {
+ let (ack_tx, ack_rx) = crate::runtime::channel::oneshot::();
+ match self.sender(shard_id).try_send(AofMessage::AppendSync {
+ lsn,
+ bytes,
+ ack: ack_tx,
+ }) {
+ Ok(()) => {}
+ Err(flume::TrySendError::Full(_)) => {
+ // Writer channel is at capacity — count the dropped entry and
+ // signal ChannelFull back to the caller via a pre-filled
+ // oneshot so the caller's `.await` resolves immediately to
+ // Err(AofAck::ChannelFull) without a writer round-trip.
+ AOF_BACKPRESSURE_DROPPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ warn!(
+ "AOF writer channel full (shard {}): AppendSync dropped; \
+ backpressure_dropped={}",
+ shard_id,
+ AOF_BACKPRESSURE_DROPPED.load(std::sync::atomic::Ordering::Relaxed),
+ );
+ // Pre-send ChannelFull into a fresh oneshot pair; the
+ // caller's `ack_rx` was already returned — we create a
+ // new pair and use its sender to pre-fill what the caller
+ // will receive. The original ack_tx (inside the dropped
+ // AppendSync) is dropped, causing its ack_rx to yield
+ // RecvError. We send ChannelFull via the *returned* ack_rx
+ // by using a second oneshot whose sender is immediately
+ // fulfilled, then return that receiver instead.
+ let (pre_tx, pre_rx) = crate::runtime::channel::oneshot::();
+ let _ = pre_tx.send(AofAck::ChannelFull);
+ return pre_rx;
+ }
+ Err(flume::TrySendError::Disconnected(_)) => {
+ // Writer task is dead — let caller handle RecvError on ack_rx.
+ // ack_tx was dropped inside the Err value; ack_rx will
+ // resolve with RecvError, which try_send_append_durable maps
+ // to Err(AofAck::WriteFailed).
+ }
+ }
+ ack_rx
+ }
+
+ /// Fire-and-forget append for a cross-shard atomic operation (RFC § 2
+ /// Rule 2 — `OrderedAcrossShards` tagging).
+ ///
+ /// The high bit of `lsn` (`1 << 63`) is set before the entry is queued.
+ /// Recovery uses this bit to recognize cross-shard atomic entries,
+ /// buffer them per-shard, and replay them globally in LSN order after
+ /// per-shard replay completes — guaranteeing TXN/SCRIPT atomicity
+ /// survives a crash even when multiple shards participated.
+ ///
+ /// **Caller contract:** `lsn` MUST be < `1 << 63` (i.e. the high bit
+ /// MUST be clear when passed in). Practical LSN ceilings — even at
+ /// 10 M writes/s sustained for a century — sit around 2^58, so any
+ /// real LSN satisfies this. Debug builds assert; release builds mask
+ /// the input to keep the wire format well-formed rather than
+ /// corrupt-by-zero-extending.
+ ///
+ /// **Production callers today:** none. Step 5 ships the infrastructure
+ /// (writer, framing flag, recovery merge) so a future cross-shard TXN
+ /// or replicated SCRIPT command has a place to land. Until that
+ /// consumer exists, only test code emits ordered entries.
+ #[inline]
+ pub fn try_send_append_ordered(&self, shard_id: usize, lsn: u64, bytes: Bytes) {
+ debug_assert_eq!(
+ lsn & ORDERED_LSN_FLAG,
+ 0,
+ "try_send_append_ordered: lsn must not have the high bit set; got {:#x}",
+ lsn,
+ );
+ let tagged_lsn = (lsn & !ORDERED_LSN_FLAG) | ORDERED_LSN_FLAG;
+ let _ = self.sender(shard_id).try_send(AofMessage::Append {
+ lsn: tagged_lsn,
+ bytes,
+ });
+ }
+
+ /// Issue an LSN for an AOF append at every call site that has the
+ /// `Option>>` shape. Wraps
+ /// `ReplicationState::issue_lsn` so handler call sites collapse to a
+ /// single line.
+ ///
+ /// Returns 0 when:
+ /// - `repl_state` is None (test fixtures or shutdown paths)
+ /// - the `RwLock` is poisoned (shouldn't happen in production —
+ /// ReplicationState is only `write()`-locked under known-safe paths)
+ ///
+ /// 0 is a sentinel meaning "no replication ordering for this write".
+ /// TopLevel writers ignore the LSN entirely so 0 is harmless there;
+ /// PerShard writers treat 0 the same as any other LSN (per-shard order
+ /// is preserved by write order, not by LSN value). The LSN only matters
+ /// for the cross-shard `OrderedAcrossShards` merge in RFC step 5.
+ #[inline]
+ pub fn issue_append_lsn(
+ repl_state: &Option>>,
+ shard_id: usize,
+ delta: usize,
+ ) -> u64 {
+ repl_state
+ .as_ref()
+ .and_then(|rs| rs.read().ok().map(|g| g.issue_lsn(shard_id, delta as u64)))
+ .unwrap_or(0)
+ }
+
+ /// Submit a Rewrite/RewriteSharded message. Only legal for TopLevel pools;
+ /// PerShard rewrites are per-shard operations and must be initiated by
+ /// the BGREWRITEAOF code path in step 6, not via this enum variant.
+ pub fn try_send_rewrite(&self, msg: AofMessage) -> Result<(), AofPoolSendError> {
+ use crate::persistence::aof_manifest::AofLayout;
+ debug_assert!(
+ matches!(msg, AofMessage::Rewrite(_) | AofMessage::RewriteSharded(_)),
+ "try_send_rewrite called with a non-Rewrite variant",
+ );
+ if self.layout == AofLayout::PerShard {
+ return Err(AofPoolSendError::RewriteUnsupportedInPerShard);
+ }
+ self.senders[0]
+ .try_send(msg)
+ .map_err(|_| AofPoolSendError::SendFailed)
+ }
+
+ /// Broadcast `Shutdown` to every writer. Used by orchestrated shutdown
+ /// paths in `main.rs`/`embedded.rs`. Each writer drains its channel and
+ /// fsyncs before exiting.
+ pub fn broadcast_shutdown(&self) {
+ for s in &self.senders {
+ let _ = s.try_send(AofMessage::Shutdown);
+ }
+ }
+
+ /// Number of underlying writer senders. 1 for TopLevel, num_shards for
+ /// PerShard.
+ #[inline]
+ pub fn num_writers(&self) -> usize {
+ self.senders.len()
+ }
+
+ /// Reports the pool's layout. Useful for places that need to refuse
+ /// PerShard-incompatible legacy code paths with a clear error.
+ #[inline]
+ pub fn layout(&self) -> crate::persistence::aof_manifest::AofLayout {
+ self.layout
+ }
+}
+
+#[cfg(test)]
+mod pool_tests {
+ use super::*;
+ use crate::persistence::aof_manifest::AofLayout;
+ use crate::runtime::channel;
+
+ #[test]
+ fn top_level_pool_routes_all_shards_to_writer_zero() {
+ let (tx, rx) = channel::mpsc_bounded::(8);
+ let pool = AofWriterPool::top_level(tx);
+ assert_eq!(pool.num_writers(), 1);
+ assert_eq!(pool.layout(), AofLayout::TopLevel);
+
+ pool.try_send_append(0, 0, Bytes::from_static(b"a"));
+ pool.try_send_append(7, 0, Bytes::from_static(b"b"));
+ pool.try_send_append(42, 0, Bytes::from_static(b"c"));
+
+ let mut seen = 0;
+ while rx.try_recv().is_ok() {
+ seen += 1;
+ }
+ assert_eq!(seen, 3, "all 3 appends should land on writer 0");
+ }
+
+ #[test]
+ fn per_shard_pool_routes_each_shard_to_its_own_writer() {
+ let (tx0, rx0) = channel::mpsc_bounded::(8);
+ let (tx1, rx1) = channel::mpsc_bounded::(8);
+ let (tx2, rx2) = channel::mpsc_bounded::(8);
+ let pool = AofWriterPool::per_shard(vec![tx0, tx1, tx2]);
+ assert_eq!(pool.num_writers(), 3);
+ assert_eq!(pool.layout(), AofLayout::PerShard);
+
+ pool.try_send_append(0, 100, Bytes::from_static(b"shard0"));
+ pool.try_send_append(1, 200, Bytes::from_static(b"shard1a"));
+ pool.try_send_append(1, 300, Bytes::from_static(b"shard1b"));
+ pool.try_send_append(2, 400, Bytes::from_static(b"shard2"));
+
+ let count = |rx: &channel::MpscReceiver| -> usize {
+ let mut n = 0;
+ while rx.try_recv().is_ok() {
+ n += 1;
+ }
+ n
+ };
+ assert_eq!(count(&rx0), 1, "shard 0 writer should receive exactly 1");
+ assert_eq!(count(&rx1), 2, "shard 1 writer should receive exactly 2");
+ assert_eq!(count(&rx2), 1, "shard 2 writer should receive exactly 1");
+ }
+
+ #[test]
+ fn per_shard_pool_rejects_rewrite_with_explicit_error() {
+ let (tx0, _rx0) = channel::mpsc_bounded::(8);
+ let (tx1, _rx1) = channel::mpsc_bounded::(8);
+ let pool = AofWriterPool::per_shard(vec![tx0, tx1]);
+
+ let dummies: SharedDatabases = Arc::new(vec![]);
+ let err = pool
+ .try_send_rewrite(AofMessage::Rewrite(dummies))
+ .unwrap_err();
+ assert_eq!(err, AofPoolSendError::RewriteUnsupportedInPerShard);
+ }
+
+ #[test]
+ fn top_level_pool_accepts_rewrite() {
+ let (tx, rx) = channel::mpsc_bounded::(8);
+ let pool = AofWriterPool::top_level(tx);
+
+ let dummies: SharedDatabases = Arc::new(vec![]);
+ pool.try_send_rewrite(AofMessage::Rewrite(dummies)).unwrap();
+ assert!(matches!(rx.try_recv(), Ok(AofMessage::Rewrite(_))));
+ }
+
+ #[test]
+ fn per_shard_pool_threads_lsn_field_to_each_writer() {
+ // Step 3 wire-format contract: try_send_append carries the issued LSN
+ // through to the writer task, which writes it as the per-entry header
+ // under PerShard layout. This unit test pins the channel-side contract
+ // (the disk-side framing is covered by writer-task integration).
+ let (tx0, rx0) = channel::mpsc_bounded::(4);
+ let (tx1, rx1) = channel::mpsc_bounded::(4);
+ let pool = AofWriterPool::per_shard(vec![tx0, tx1]);
+
+ pool.try_send_append(0, 42, Bytes::from_static(b"set foo 1"));
+ pool.try_send_append(1, 43, Bytes::from_static(b"set bar 2"));
+ pool.try_send_append(0, 44, Bytes::from_static(b"del foo"));
+
+ // Shard 0 should see (42, "set foo 1") then (44, "del foo").
+ match rx0.try_recv() {
+ Ok(AofMessage::Append { lsn, bytes }) => {
+ assert_eq!(lsn, 42, "shard 0 first entry lsn");
+ assert_eq!(bytes.as_ref(), b"set foo 1");
+ }
+ other => panic!(
+ "shard 0 first recv expected Append, got {:?}",
+ other.is_ok()
+ ),
+ }
+ match rx0.try_recv() {
+ Ok(AofMessage::Append { lsn, bytes }) => {
+ assert_eq!(lsn, 44, "shard 0 second entry lsn");
+ assert_eq!(bytes.as_ref(), b"del foo");
+ }
+ other => panic!(
+ "shard 0 second recv expected Append, got {:?}",
+ other.is_ok()
+ ),
+ }
+ // Shard 1 should see (43, "set bar 2") only.
+ match rx1.try_recv() {
+ Ok(AofMessage::Append { lsn, bytes }) => {
+ assert_eq!(lsn, 43, "shard 1 entry lsn");
+ assert_eq!(bytes.as_ref(), b"set bar 2");
+ }
+ other => panic!("shard 1 recv expected Append, got {:?}", other.is_ok()),
+ }
+ }
+
+ #[test]
+ fn try_send_append_sync_queues_appendsync_with_ack() {
+ // Channel-level wiring contract for the H1 fix: `try_send_append_sync`
+ // queues `AofMessage::AppendSync { lsn, bytes, ack }`, and the
+ // returned receiver resolves to whatever value the (mocked) writer
+ // sends on `ack`. End-to-end durability is covered by step 8
+ // (CRASH-01-LITE); this pins the API contract.
+ let (tx0, rx0) = channel::mpsc_bounded::(4);
+ let (tx1, _rx1) = channel::mpsc_bounded::(4);
+ let pool = AofWriterPool::per_shard(vec![tx0, tx1]);
+
+ let recv = pool.try_send_append_sync(0, 99, Bytes::from_static(b"SET k v"));
+
+ // Drain the queue; the writer would normally do this. Capture the
+ // ack sender, do the (mock) durable write, then ack Synced.
+ let ack = match rx0.try_recv() {
+ Ok(AofMessage::AppendSync { lsn, bytes, ack }) => {
+ assert_eq!(lsn, 99, "lsn forwarded through the channel");
+ assert_eq!(bytes.as_ref(), b"SET k v", "bytes forwarded");
+ ack
+ }
+ other => panic!("expected AppendSync, got {:?}", other.is_ok()),
+ };
+
+ // Writer reports Synced — caller observes Synced.
+ let _ = ack.send(AofAck::Synced);
+ let result = recv.recv_blocking().expect("receiver resolves");
+ assert_eq!(result, AofAck::Synced);
+ }
+
+ #[test]
+ fn append_sync_writer_dropped_resolves_recv_error() {
+ // If the writer task is dead or the channel disconnects between
+ // queueing and the ack send, the receiver MUST resolve with an
+ // error rather than hang. Callers treat that as a hard failure
+ // (return an error frame, do not +OK).
+ let (tx0, rx0) = channel::mpsc_bounded::(4);
+ let (tx1, _rx1) = channel::mpsc_bounded::(4);
+ let pool = AofWriterPool::per_shard(vec![tx0, tx1]);
+
+ let recv = pool.try_send_append_sync(0, 7, Bytes::from_static(b"x"));
+
+ // Drain the message but DROP the ack sender without sending.
+ match rx0.try_recv() {
+ Ok(AofMessage::AppendSync { ack, .. }) => drop(ack),
+ other => panic!("expected AppendSync, got {:?}", other.is_ok()),
+ }
+
+ let err = recv.recv_blocking().expect_err("dropped ack -> RecvError");
+ // Crash-safe: we got a sentinel-style error, not a hang.
+ let _ = err;
+ }
+
+ #[test]
+ fn append_sync_writer_reports_write_failed() {
+ // Writer encountered a write_all error; recv returns WriteFailed.
+ let (tx0, rx0) = channel::mpsc_bounded::(4);
+ let (tx1, _rx1) = channel::mpsc_bounded::(4);
+ let pool = AofWriterPool::per_shard(vec![tx0, tx1]);
+
+ let recv = pool.try_send_append_sync(0, 1, Bytes::from_static(b"x"));
+ let ack = match rx0.try_recv() {
+ Ok(AofMessage::AppendSync { ack, .. }) => ack,
+ other => panic!("expected AppendSync, got {:?}", other.is_ok()),
+ };
+ let _ = ack.send(AofAck::WriteFailed);
+ let result = recv.recv_blocking().expect("recv resolves");
+ assert_eq!(result, AofAck::WriteFailed);
+ }
+
+ #[test]
+ fn append_sync_writer_reports_fsync_failed() {
+ // Writer wrote the payload but fsync (sync_data) returned an error.
+ let (tx0, rx0) = channel::mpsc_bounded::(4);
+ let (tx1, _rx1) = channel::mpsc_bounded::(4);
+ let pool = AofWriterPool::per_shard(vec![tx0, tx1]);
+
+ let recv = pool.try_send_append_sync(0, 1, Bytes::from_static(b"x"));
+ let ack = match rx0.try_recv() {
+ Ok(AofMessage::AppendSync { ack, .. }) => ack,
+ other => panic!("expected AppendSync, got {:?}", other.is_ok()),
+ };
+ let _ = ack.send(AofAck::FsyncFailed);
+ let result = recv.recv_blocking().expect("recv resolves");
+ assert_eq!(result, AofAck::FsyncFailed);
+ }
+
+ #[test]
+ fn broadcast_shutdown_reaches_every_writer() {
+ let (tx0, rx0) = channel::mpsc_bounded::(2);
+ let (tx1, rx1) = channel::mpsc_bounded::(2);
+ let (tx2, rx2) = channel::mpsc_bounded::(2);
+ let pool = AofWriterPool::per_shard(vec![tx0, tx1, tx2]);
+
+ pool.broadcast_shutdown();
+
+ for (i, rx) in [&rx0, &rx1, &rx2].iter().enumerate() {
+ assert!(
+ matches!(rx.try_recv(), Ok(AofMessage::Shutdown)),
+ "writer {} did not receive Shutdown",
+ i
+ );
+ }
+ }
+
+ /// FIX-W1-1 contract: `try_send_append_durable` under `Always` policy MUST
+ /// return `Err(AofAck::FsyncFailed)` when the writer reports failure.
+ /// handler_single.rs must await this BEFORE flushing responses to the client.
+ ///
+ /// Uses spawn_blocking to simulate the mock writer responding on the ack
+ /// channel concurrently, which allows the async rendezvous to complete.
+ #[cfg(feature = "runtime-tokio")]
+ #[tokio::test]
+ async fn always_policy_try_send_append_durable_returns_err_on_fsync_fail() {
+ let (tx0, rx0) = channel::mpsc_bounded::(4);
+ let (tx1, _rx1) = channel::mpsc_bounded::(4);
+ let pool = std::sync::Arc::new(AofWriterPool::per_shard_with_policy(
+ vec![tx0, tx1],
+ FsyncPolicy::Always,
+ ));
+
+ // Spawn a mock writer that drains AppendSync and responds with FsyncFailed.
+ // Runs in a blocking thread (flume's blocking recv) so it doesn't block
+ // the async executor while waiting for the handler to enqueue the message.
+ let mock_writer = tokio::task::spawn_blocking(move || {
+ // flume::Receiver::recv() blocks until a message is available
+ let msg = rx0.recv().expect("mock writer got message");
+ if let AofMessage::AppendSync { ack, .. } = msg {
+ let _ = ack.send(AofAck::FsyncFailed);
+ } else {
+ panic!("expected AppendSync under Always policy");
+ }
+ });
+
+ // The handler MUST await this BEFORE flushing responses to the client
+ let result = pool
+ .try_send_append_durable(0, 1, Bytes::from_static(b"SET k v"))
+ .await;
+ mock_writer.await.expect("mock writer completed");
+
+ assert_eq!(
+ result,
+ Err(AofAck::FsyncFailed),
+ "Always policy MUST propagate fsync failure so caller can return an error frame"
+ );
+ }
+
+ /// FIX-W1-1 ordering contract: when `aof_entries` carries `(resp_idx, bytes)`
+ /// tuples, the handler can patch `responses[resp_idx]` on AOF failure BEFORE
+ /// flushing to the client. This test verifies the indexing is sound.
+ #[test]
+ fn aof_entries_indexed_by_response_slot_patches_correctly() {
+ use crate::protocol::Frame;
+ let mut responses: Vec = vec![
+ Frame::SimpleString(bytes::Bytes::from_static(b"OK")),
+ Frame::SimpleString(bytes::Bytes::from_static(b"OK")),
+ Frame::SimpleString(bytes::Bytes::from_static(b"OK")),
+ ];
+ // Simulate two write commands at response indices 0 and 2 (index 1 was a read)
+ let aof_entries: Vec<(usize, Bytes)> = vec![
+ (0, Bytes::from_static(b"SET a 1")),
+ (2, Bytes::from_static(b"SET c 3")),
+ ];
+
+ // AOF write at index 2 fails; patch that response slot
+ for (resp_idx, _bytes) in &aof_entries {
+ if *resp_idx == 2 {
+ // Simulate Err(AofAck::FsyncFailed) from try_send_append_durable
+ responses[*resp_idx] =
+ Frame::Error(Bytes::from_static(b"WRITEFAIL aof fsync failed"));
+ }
+ }
+
+ assert!(
+ matches!(&responses[0], Frame::SimpleString(_)),
+ "index 0 (successful fsync) should remain +OK"
+ );
+ assert!(
+ matches!(&responses[1], Frame::SimpleString(_)),
+ "index 1 (read, no AOF) should remain +OK"
+ );
+ assert!(
+ matches!(&responses[2], Frame::Error(_)),
+ "index 2 (failed fsync) must be patched to error"
+ );
+ }
+
+ // NOTE (FIX-W1-1 r3): The H1 ordering regression test was moved to
+ // `src/server/conn/handler_single.rs` (test module, fn
+ // `flush_with_aof_ack_ack_precedes_response`). The previous inline
+ // reproduction here was non-discriminating — it reproduced the ack-first
+ // loop IN THE TEST BODY rather than calling the real production fn, so it
+ // passed on both pre-fix and post-fix binaries.
+ //
+ // The new test calls `flush_with_aof_ack` directly (the fn the handler now
+ // delegates to), so inverting Phase 1/Phase 2 order in that fn causes a
+ // measurable timing failure (`elapsed_ms ≈ 0ms < 55ms`).
+ //
+ // End-to-end ordering is also covered by:
+ // tests/crash_matrix_per_shard_aof.rs (CRASH-01-LITE — AlwaysPolicy shards)
+
+ // -----------------------------------------------------------------------
+ // FIX-W2-5: channel-full returns AofAck::ChannelFull + increments counter
+ // -----------------------------------------------------------------------
+ #[test]
+ fn try_send_append_sync_channel_full_returns_channel_full_ack() {
+ // Create a channel with capacity 1 and fill it so the next try_send
+ // hits TrySendError::Full.
+ let (tx0, rx0) = channel::mpsc_bounded::(1);
+ // Fill the channel by pre-loading one message.
+ tx0.try_send(AofMessage::Shutdown).expect("pre-fill");
+ // rx0 intentionally not consumed — channel is now at capacity.
+
+ let pool = AofWriterPool::top_level(tx0);
+
+ let before = AOF_BACKPRESSURE_DROPPED.load(std::sync::atomic::Ordering::Relaxed);
+ let recv = pool.try_send_append_sync(0, 1, Bytes::from_static(b"SET k v"));
+
+ // The channel was full — ChannelFull is returned immediately without
+ // a writer round-trip.
+ let result = recv.recv_blocking().expect("pre-filled oneshot resolves");
+ assert_eq!(
+ result,
+ AofAck::ChannelFull,
+ "channel-full must yield ChannelFull, not {:?}",
+ result
+ );
+
+ let after = AOF_BACKPRESSURE_DROPPED.load(std::sync::atomic::Ordering::Relaxed);
+ assert_eq!(
+ after,
+ before + 1,
+ "backpressure counter must increment by 1"
+ );
+
+ // No AppendSync should have reached the (blocked) reader.
+ drop(rx0); // drain without consuming — just verify nothing snuck through
+ }
+
+ // -----------------------------------------------------------------------
+ // FIX-W2-9: try_send_append_durable must be used for SWAPDB-like mutations
+ //
+ // Red test: documents the contract that handler_single.rs SHOULD honour.
+ // When appendfsync=always, try_send_append_durable MUST return Err on
+ // writer failure so callers can abort the mutation safely.
+ // -----------------------------------------------------------------------
+ #[test]
+ fn try_send_append_durable_always_writer_dead_returns_write_failed() {
+ // Create a pool with Always policy. The writer task is not running —
+ // we model that by draining the channel message and then dropping the
+ // ack sender, simulating a dead writer.
+ let (tx0, rx0) = channel::mpsc_bounded::(4);
+ let (tx1, _rx1) = channel::mpsc_bounded::(4);
+ let pool = AofWriterPool::per_shard_with_policy(vec![tx0, tx1], FsyncPolicy::Always);
+
+ // Spawn a thread that pulls the AppendSync off the channel but drops
+ // the ack without sending — simulating a writer crash mid-fsync.
+ let rx0_clone = rx0;
+ let handle = std::thread::spawn(move || {
+ match rx0_clone.recv() {
+ Ok(AofMessage::AppendSync { ack, .. }) => drop(ack), // writer crash
+ other => panic!("unexpected message: {:?}", other.is_ok()),
+ }
+ });
+
+ // try_send_append_durable for Always must await the ack.
+ // With the ack sender dropped, it should resolve to Err(WriteFailed).
+ let result = futures::executor::block_on(pool.try_send_append_durable(
+ 0,
+ 55,
+ Bytes::from_static(b"SWAPDB 0 1"),
+ ));
+
+ handle.join().expect("ack dropper thread");
+
+ assert!(
+ result.is_err(),
+ "try_send_append_durable with dead writer must return Err, got Ok"
+ );
+ assert_eq!(
+ result.unwrap_err(),
+ AofAck::WriteFailed,
+ "dead writer must resolve to WriteFailed"
+ );
+ }
+
+ #[test]
+ fn try_send_append_durable_everysec_is_fire_and_forget() {
+ // EverySec policy: try_send_append_durable always returns Ok — the
+ // durability policy doesn't block on fsync. handler_single.rs must
+ // use try_send_append_durable so the policy is respected.
+ let (tx0, _rx0) = channel::mpsc_bounded::(4);
+ let (tx1, _rx1) = channel::mpsc_bounded::(4);
+ let pool = AofWriterPool::per_shard_with_policy(vec![tx0, tx1], FsyncPolicy::EverySec);
+
+ let result = futures::executor::block_on(pool.try_send_append_durable(
+ 0,
+ 56,
+ Bytes::from_static(b"SWAPDB 0 1"),
+ ));
+
+ assert!(
+ result.is_ok(),
+ "EverySec policy must be fire-and-forget (Ok), got {:?}",
+ result
+ );
+ }
+}
+
/// Serialize a Frame into RESP wire format bytes.
pub fn serialize_command(frame: &Frame) -> Bytes {
let mut buf = BytesMut::with_capacity(64);
@@ -192,9 +1051,19 @@ pub async fn aof_writer_task(
let mut write_error = false;
+ // Test-only fault injection: same env var as the PerShard writer.
+ // Read once at task startup; zero cost in production (var absent).
+ let fail_fsync_for_test = std::env::var("MOON_TEST_AOF_FSYNC_FAIL").as_deref() == Ok("1");
+
loop {
match rx.recv() {
- Ok(AofMessage::Append(data)) => {
+ // TopLevel writer: legacy v1 disk format is plain RESP. The
+ // LSN is ignored — TopLevel is single-shard so per-shard merge
+ // by LSN is moot.
+ Ok(AofMessage::Append {
+ bytes: data,
+ lsn: _,
+ }) => {
if write_error {
continue; // Drop appends after persistent I/O failure
}
@@ -238,6 +1107,45 @@ pub async fn aof_writer_task(
FsyncPolicy::No => {}
}
}
+ // TopLevel writer (monoio): legacy v1 plain RESP, lsn ignored.
+ // AppendSync ALWAYS fsyncs and acks before returning, regardless
+ // of the configured policy — that's the durability contract the
+ // caller signed up for by choosing AppendSync.
+ Ok(AofMessage::AppendSync {
+ bytes: data,
+ lsn: _,
+ ack,
+ }) => {
+ if write_error {
+ let _ = ack.send(AofAck::WriteFailed);
+ continue;
+ }
+ // Test-only: return FsyncFailed immediately without touching disk.
+ if fail_fsync_for_test {
+ let _ = ack.send(AofAck::FsyncFailed);
+ continue;
+ }
+ if let Err(e) = file.write_all(&data) {
+ error!(
+ "AOF AppendSync write failed (seq {}): {}. Persistence degraded.",
+ manifest.seq, e
+ );
+ write_error = true;
+ let _ = ack.send(AofAck::WriteFailed);
+ continue;
+ }
+ let t = Instant::now();
+ if let Err(e) = file.flush().and_then(|_| file.sync_data()) {
+ error!("AOF AppendSync sync failed (seq {}): {}", manifest.seq, e);
+ write_error = true;
+ let _ = ack.send(AofAck::FsyncFailed);
+ } else {
+ crate::admin::metrics_setup::record_aof_fsync(
+ t.elapsed().as_micros() as u64
+ );
+ let _ = ack.send(AofAck::Synced);
+ }
+ }
Ok(AofMessage::Shutdown) | Err(_) => {
if !write_error {
if let Err(e) = file.flush().and_then(|_| file.sync_data()) {
@@ -287,7 +1195,8 @@ pub async fn aof_writer_task(
tokio::select! {
msg = rx.recv_async() => {
match msg {
- Ok(AofMessage::Append(data)) => {
+ // TopLevel writer (tokio): legacy v1 plain RESP, lsn ignored.
+ Ok(AofMessage::Append { bytes: data, lsn: _ }) => {
if let Err(e) = writer.write_all(&data).await {
error!("AOF write error: {}", e);
continue;
@@ -302,6 +1211,25 @@ pub async fn aof_writer_task(
}
}
}
+ // AppendSync: write + fsync + ack, regardless of policy.
+ Ok(AofMessage::AppendSync { bytes: data, lsn: _, ack }) => {
+ if let Err(e) = writer.write_all(&data).await {
+ error!("AOF AppendSync write error: {}", e);
+ let _ = ack.send(AofAck::WriteFailed);
+ continue;
+ }
+ if let Err(e) = writer.flush().await {
+ error!("AOF AppendSync flush error: {}", e);
+ let _ = ack.send(AofAck::FsyncFailed);
+ continue;
+ }
+ if let Err(e) = writer.get_ref().sync_data().await {
+ error!("AOF AppendSync sync_data error: {}", e);
+ let _ = ack.send(AofAck::FsyncFailed);
+ continue;
+ }
+ let _ = ack.send(AofAck::Synced);
+ }
Ok(AofMessage::Rewrite(db)) => {
// Flush current writer before rewrite
let _ = writer.flush().await;
@@ -369,6 +1297,486 @@ pub async fn aof_writer_task(
}
}
+/// Background per-shard AOF writer task (Option B step 2b).
+///
+/// One instance is spawned per shard in `PerShard` layout. Each instance owns
+/// `appendonlydir/shard-{shard_id}/moon.aof.{seq}.incr.aof` exclusively — no
+/// other writer touches that file, so there is no per-file locking.
+///
+/// Differences from [`aof_writer_task`] (TopLevel):
+/// - Opens `manifest.shard_incr_path(shard_id)` instead of `manifest.incr_path()`.
+/// - `Rewrite`/`RewriteSharded` variants are rejected (logged + dropped).
+/// The legacy single-writer rewrite enum has no meaning when each shard
+/// owns its own files; per-shard BGREWRITEAOF lands in RFC step 6.
+/// - Refuses to start if the loaded manifest's layout is `TopLevel` — the
+/// spawn site (step 2f) must only invoke this task body for `PerShard`
+/// layouts. Mismatch is a programmer error.
+///
+/// Wait/timeout/corruption semantics for manifest loading match the existing
+/// `aof_writer_task` (60s bounded wait, hard fail on corrupt manifest).
+pub async fn per_shard_aof_writer_task(
+ rx: channel::MpscReceiver,
+ base_dir: PathBuf,
+ shard_id: u16,
+ fsync: FsyncPolicy,
+ cancel: CancellationToken,
+) {
+ #[cfg(feature = "runtime-tokio")]
+ {
+ use crate::persistence::aof_manifest::{AofLayout, AofManifest};
+ use tokio::io::AsyncWriteExt;
+
+ // Wait for main.rs recovery to create/load the manifest.
+ let manifest_wait_start = Instant::now();
+ const MANIFEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
+ let manifest = loop {
+ if cancel.is_cancelled() {
+ info!(
+ "AOF writer shard {}: cancelled while waiting for manifest",
+ shard_id
+ );
+ return;
+ }
+ if manifest_wait_start.elapsed() > MANIFEST_TIMEOUT {
+ error!(
+ "AOF writer shard {}: manifest not found at {} after {:?}. Writer exiting.",
+ shard_id,
+ base_dir.display(),
+ MANIFEST_TIMEOUT,
+ );
+ return;
+ }
+ match AofManifest::load(&base_dir) {
+ Ok(Some(m)) => break m,
+ Ok(None) => {
+ tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+ }
+ Err(e) => {
+ error!(
+ "AOF writer shard {}: manifest corrupt at {}: {}. Persistence disabled.",
+ shard_id,
+ base_dir.display(),
+ e
+ );
+ return;
+ }
+ }
+ };
+
+ if manifest.layout != AofLayout::PerShard {
+ error!(
+ "AOF writer shard {}: layout is {:?}, expected PerShard. \
+ per_shard_aof_writer_task should only be spawned for PerShard layouts. \
+ Writer exiting.",
+ shard_id, manifest.layout
+ );
+ return;
+ }
+ if (shard_id as usize) >= manifest.shards.len() {
+ error!(
+ "AOF writer shard {}: out of range for manifest with {} shards. Writer exiting.",
+ shard_id,
+ manifest.shards.len()
+ );
+ return;
+ }
+
+ let incr_path = manifest.shard_incr_path(shard_id);
+ // Ensure shard-{N}/ exists. The manifest constructor for PerShard
+ // already creates these, but be defensive — a manual deletion or
+ // a manifest written by an older binary could leave them missing.
+ if let Some(parent) = incr_path.parent() {
+ if let Err(e) = tokio::fs::create_dir_all(parent).await {
+ error!(
+ "AOF writer shard {}: failed to create dir {}: {}",
+ shard_id,
+ parent.display(),
+ e
+ );
+ return;
+ }
+ }
+ let file: tokio::fs::File = match tokio::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&incr_path)
+ .await
+ {
+ Ok(f) => f,
+ Err(e) => {
+ error!(
+ "AOF writer shard {}: failed to open incr {}: {}",
+ shard_id,
+ incr_path.display(),
+ e
+ );
+ return;
+ }
+ };
+ info!(
+ "AOF writer shard {}: seq {}, incr={}",
+ shard_id,
+ manifest.seq,
+ incr_path.display()
+ );
+
+ let mut writer = tokio::io::BufWriter::new(file);
+ let mut last_fsync = Instant::now();
+ let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
+ interval.tick().await;
+
+ // Test-only fault injection: if MOON_TEST_AOF_FSYNC_FAIL=1 is set in
+ // the environment at writer task startup, every AppendSync ack resolves
+ // as FsyncFailed instead of Synced. This lets integration tests exercise
+ // the AOF_FSYNC_ERR response path without requiring a real disk error.
+ // The env var is read once here (not per-message) so it costs zero on the
+ // hot path in production deployments where the var is absent.
+ let fail_fsync_for_test = std::env::var("MOON_TEST_AOF_FSYNC_FAIL").as_deref() == Ok("1");
+
+ loop {
+ tokio::select! {
+ msg = rx.recv_async() => {
+ match msg {
+ // PerShard writer (tokio): per RFC § 2 Rule 1 the on-disk
+ // format is `[u64 lsn LE][u32 len LE][RESP bytes]`. Header
+ // is written sequentially with the body — both calls land
+ // in the same BufWriter so this is one syscall under load.
+ Ok(AofMessage::Append { lsn, bytes: data }) => {
+ let mut header = [0u8; 12];
+ header[..8].copy_from_slice(&lsn.to_le_bytes());
+ header[8..].copy_from_slice(&(data.len() as u32).to_le_bytes());
+ if let Err(e) = writer.write_all(&header).await {
+ error!("AOF header write error shard {}: {}", shard_id, e);
+ continue;
+ }
+ if let Err(e) = writer.write_all(&data).await {
+ error!("AOF write error shard {}: {}", shard_id, e);
+ continue;
+ }
+ if matches!(fsync, FsyncPolicy::Always) {
+ let _ = writer.flush().await;
+ let _ = writer.get_ref().sync_data().await;
+ }
+ }
+ // AppendSync (tokio + PerShard): framed write + fsync + ack.
+ Ok(AofMessage::AppendSync { lsn, bytes: data, ack }) => {
+ let mut header = [0u8; 12];
+ header[..8].copy_from_slice(&lsn.to_le_bytes());
+ header[8..].copy_from_slice(&(data.len() as u32).to_le_bytes());
+ if let Err(e) = writer.write_all(&header).await {
+ error!(
+ "AOF AppendSync header write error shard {}: {}",
+ shard_id, e
+ );
+ let _ = ack.send(AofAck::WriteFailed);
+ continue;
+ }
+ if let Err(e) = writer.write_all(&data).await {
+ error!(
+ "AOF AppendSync write error shard {}: {}",
+ shard_id, e
+ );
+ let _ = ack.send(AofAck::WriteFailed);
+ continue;
+ }
+ // Test-only: skip real fsync and return FsyncFailed
+ // immediately when the fault-injection env var is set.
+ if fail_fsync_for_test {
+ let _ = ack.send(AofAck::FsyncFailed);
+ continue;
+ }
+ if let Err(e) = writer.flush().await {
+ error!(
+ "AOF AppendSync flush error shard {}: {}",
+ shard_id, e
+ );
+ let _ = ack.send(AofAck::FsyncFailed);
+ continue;
+ }
+ if let Err(e) = writer.get_ref().sync_data().await {
+ error!(
+ "AOF AppendSync sync_data error shard {}: {}",
+ shard_id, e
+ );
+ let _ = ack.send(AofAck::FsyncFailed);
+ continue;
+ }
+ let _ = ack.send(AofAck::Synced);
+ }
+ Ok(AofMessage::Rewrite(_)) | Ok(AofMessage::RewriteSharded(_)) => {
+ warn!(
+ "AOF writer shard {}: received Rewrite/RewriteSharded — \
+ not supported in PerShard layout, dropped. \
+ Per-shard BGREWRITEAOF lands in RFC step 6.",
+ shard_id
+ );
+ }
+ Ok(AofMessage::Shutdown) | Err(_) => {
+ let _ = writer.flush().await;
+ let _ = writer.get_ref().sync_data().await;
+ info!("AOF writer shard {} shutting down", shard_id);
+ break;
+ }
+ }
+ }
+ _ = interval.tick(), if fsync == FsyncPolicy::EverySec => {
+ if last_fsync.elapsed() >= std::time::Duration::from_secs(1) {
+ let _ = writer.flush().await;
+ let _ = writer.get_ref().sync_data().await;
+ last_fsync = Instant::now();
+ }
+ }
+ _ = cancel.cancelled() => {
+ let _ = writer.flush().await;
+ let _ = writer.get_ref().sync_data().await;
+ info!("AOF writer shard {} cancelled", shard_id);
+ break;
+ }
+ }
+ }
+ }
+
+ #[cfg(feature = "runtime-monoio")]
+ {
+ use crate::persistence::aof_manifest::{AofLayout, AofManifest};
+ use std::io::Write;
+
+ let manifest_wait_start = Instant::now();
+ const MANIFEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
+ let manifest = loop {
+ if cancel.is_cancelled() {
+ info!(
+ "AOF writer shard {}: cancelled while waiting for manifest",
+ shard_id
+ );
+ return;
+ }
+ if manifest_wait_start.elapsed() > MANIFEST_TIMEOUT {
+ error!(
+ "AOF writer shard {}: manifest not found at {} after {:?}. Writer exiting.",
+ shard_id,
+ base_dir.display(),
+ MANIFEST_TIMEOUT,
+ );
+ return;
+ }
+ match AofManifest::load(&base_dir) {
+ Ok(Some(m)) => break m,
+ Ok(None) => {
+ std::thread::sleep(std::time::Duration::from_millis(50));
+ }
+ Err(e) => {
+ error!(
+ "AOF writer shard {}: manifest corrupt at {}: {}. Persistence disabled.",
+ shard_id,
+ base_dir.display(),
+ e
+ );
+ return;
+ }
+ }
+ };
+
+ if manifest.layout != AofLayout::PerShard {
+ error!(
+ "AOF writer shard {}: layout is {:?}, expected PerShard. Writer exiting.",
+ shard_id, manifest.layout
+ );
+ return;
+ }
+ if (shard_id as usize) >= manifest.shards.len() {
+ error!(
+ "AOF writer shard {}: out of range for manifest with {} shards. Writer exiting.",
+ shard_id,
+ manifest.shards.len()
+ );
+ return;
+ }
+
+ let incr_path = manifest.shard_incr_path(shard_id);
+ if let Some(parent) = incr_path.parent() {
+ if let Err(e) = std::fs::create_dir_all(parent) {
+ error!(
+ "AOF writer shard {}: failed to create dir {}: {}",
+ shard_id,
+ parent.display(),
+ e
+ );
+ return;
+ }
+ }
+ let mut file = match std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&incr_path)
+ {
+ Ok(f) => f,
+ Err(e) => {
+ error!(
+ "AOF writer shard {}: failed to open incr {}: {}",
+ shard_id,
+ incr_path.display(),
+ e
+ );
+ return;
+ }
+ };
+ info!(
+ "AOF writer shard {}: seq {}, incr={}",
+ shard_id,
+ manifest.seq,
+ incr_path.display()
+ );
+
+ let mut last_fsync = Instant::now();
+ let mut write_error = false;
+ // Test-only fault injection: if MOON_TEST_AOF_FSYNC_FAIL=1 is set in
+ // the environment at writer task startup, every AppendSync ack resolves
+ // as FsyncFailed instead of Synced. Read once before the loop so there
+ // is zero cost in production deployments where the var is absent.
+ let fail_fsync_for_test = std::env::var("MOON_TEST_AOF_FSYNC_FAIL").as_deref() == Ok("1");
+
+ loop {
+ match rx.recv() {
+ // AppendSync (monoio + PerShard): framed write + fsync + ack.
+ Ok(AofMessage::AppendSync {
+ lsn,
+ bytes: data,
+ ack,
+ }) => {
+ if write_error {
+ let _ = ack.send(AofAck::WriteFailed);
+ continue;
+ }
+ let mut header = [0u8; 12];
+ header[..8].copy_from_slice(&lsn.to_le_bytes());
+ header[8..].copy_from_slice(&(data.len() as u32).to_le_bytes());
+ if let Err(e) = file.write_all(&header) {
+ error!(
+ "AOF AppendSync header write failed shard {} (seq {}): {}",
+ shard_id, manifest.seq, e
+ );
+ write_error = true;
+ let _ = ack.send(AofAck::WriteFailed);
+ continue;
+ }
+ if let Err(e) = file.write_all(&data) {
+ error!(
+ "AOF AppendSync write failed shard {} (seq {}): {}",
+ shard_id, manifest.seq, e
+ );
+ write_error = true;
+ let _ = ack.send(AofAck::WriteFailed);
+ continue;
+ }
+ // Test-only: skip real fsync and return FsyncFailed
+ // immediately when the fault-injection env var is set.
+ if fail_fsync_for_test {
+ let _ = ack.send(AofAck::FsyncFailed);
+ continue;
+ }
+ let t = Instant::now();
+ if let Err(e) = file.flush().and_then(|_| file.sync_data()) {
+ error!(
+ "AOF AppendSync sync failed shard {} (seq {}): {}",
+ shard_id, manifest.seq, e
+ );
+ write_error = true;
+ let _ = ack.send(AofAck::FsyncFailed);
+ } else {
+ crate::admin::metrics_setup::record_aof_fsync(
+ t.elapsed().as_micros() as u64
+ );
+ let _ = ack.send(AofAck::Synced);
+ }
+ }
+ // PerShard writer (monoio): framed `[u64 lsn LE][u32 len LE][RESP]`.
+ // See the tokio twin above for format rationale.
+ Ok(AofMessage::Append { lsn, bytes: data }) => {
+ if write_error {
+ continue;
+ }
+ let mut header = [0u8; 12];
+ header[..8].copy_from_slice(&lsn.to_le_bytes());
+ header[8..].copy_from_slice(&(data.len() as u32).to_le_bytes());
+ if let Err(e) = file.write_all(&header) {
+ error!(
+ "AOF header write failed shard {} (seq {}): {}. Persistence degraded.",
+ shard_id, manifest.seq, e
+ );
+ write_error = true;
+ continue;
+ }
+ if let Err(e) = file.write_all(&data) {
+ error!(
+ "AOF write failed shard {} (seq {}): {}. Persistence degraded.",
+ shard_id, manifest.seq, e
+ );
+ write_error = true;
+ continue;
+ }
+ match fsync {
+ FsyncPolicy::Always => {
+ let t = Instant::now();
+ if let Err(e) = file.flush().and_then(|_| file.sync_data()) {
+ error!(
+ "AOF sync failed shard {} (seq {}, always): {}",
+ shard_id, manifest.seq, e
+ );
+ write_error = true;
+ } else {
+ crate::admin::metrics_setup::record_aof_fsync(
+ t.elapsed().as_micros() as u64,
+ );
+ }
+ }
+ FsyncPolicy::EverySec => {
+ if last_fsync.elapsed() >= std::time::Duration::from_secs(1) {
+ let t = Instant::now();
+ if let Err(e) = file.flush().and_then(|_| file.sync_data()) {
+ error!(
+ "AOF sync failed shard {} (seq {}, everysec): {}",
+ shard_id, manifest.seq, e
+ );
+ } else {
+ crate::admin::metrics_setup::record_aof_fsync(
+ t.elapsed().as_micros() as u64,
+ );
+ last_fsync = Instant::now();
+ }
+ }
+ }
+ FsyncPolicy::No => {}
+ }
+ }
+ Ok(AofMessage::Rewrite(_)) | Ok(AofMessage::RewriteSharded(_)) => {
+ warn!(
+ "AOF writer shard {}: received Rewrite/RewriteSharded — \
+ not supported in PerShard layout, dropped. \
+ Per-shard BGREWRITEAOF lands in RFC step 6.",
+ shard_id
+ );
+ }
+ Ok(AofMessage::Shutdown) | Err(_) => {
+ if !write_error {
+ if let Err(e) = file.flush().and_then(|_| file.sync_data()) {
+ error!(
+ "AOF final sync failed shard {} (seq {}): {}",
+ shard_id, manifest.seq, e
+ );
+ }
+ }
+ info!(
+ "AOF writer shard {} shutting down (monoio, seq {})",
+ shard_id, manifest.seq
+ );
+ break;
+ }
+ }
+ }
+ }
+}
+
/// Replay an AOF file by parsing RESP commands and dispatching them.
///
/// Returns the number of commands successfully replayed.
@@ -789,13 +2197,35 @@ fn drain_pending_appends(
let mut outcome = DrainOutcome::default();
while let Ok(msg) = rx.try_recv() {
match msg {
- AofMessage::Append(data) => {
+ // BGREWRITEAOF drain runs on the TopLevel writer (monoio) only;
+ // PerShard rewrite is RFC step 6. Legacy v1 disk format → ignore lsn.
+ AofMessage::Append {
+ bytes: data,
+ lsn: _,
+ } => {
file.write_all(&data).map_err(|e| AofError::Io {
path: PathBuf::from(""),
source: e,
})?;
outcome.drained += 1;
}
+ // AppendSync during a rewrite drain: bytes are written and counted;
+ // the post-drain fsync at the rewrite boundary covers durability,
+ // so we ack `Synced`. If the write itself fails the error is
+ // already propagated upward by the `?` and the ack is dropped —
+ // the caller observes `RecvError`, which it treats as failure.
+ AofMessage::AppendSync {
+ bytes: data,
+ lsn: _,
+ ack,
+ } => {
+ file.write_all(&data).map_err(|e| AofError::Io {
+ path: PathBuf::from(""),
+ source: e,
+ })?;
+ outcome.drained += 1;
+ let _ = ack.send(AofAck::Synced);
+ }
AofMessage::Shutdown => {
outcome.shutdown_requested = true;
}
@@ -1388,6 +2818,125 @@ mod tests {
assert!(remaining_secs > 3500);
}
+ /// Helper: build a snapshot tuple from a Database slice — mirrors the
+ /// `merged` construction in `do_rewrite_sharded` (aof.rs:2070-2090) so
+ /// tests exercise the exact same production path.
+ fn db_slice_to_snapshot(
+ dbs: &[Database],
+ ) -> Vec<(
+ Vec<(
+ crate::storage::compact_key::CompactKey,
+ crate::storage::entry::Entry,
+ )>,
+ u32,
+ )> {
+ let now_ms = crate::storage::entry::current_time_ms();
+ dbs.iter()
+ .map(|db| {
+ let base_ts = db.base_timestamp();
+ let entries: Vec<_> = db
+ .data()
+ .iter()
+ .filter(|(_, e)| !e.is_expired_at(base_ts, now_ms))
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect();
+ (entries, base_ts)
+ })
+ .collect()
+ }
+
+ /// FIX-W3-8: BGREWRITEAOF on a fresh empty database must produce a valid
+ /// RDB base and recover cleanly with 0 keys.
+ ///
+ /// Updated to call `save_snapshot_to_bytes` (the function `do_rewrite_sharded`
+ /// actually calls at aof.rs:2096) rather than `save_to_bytes` (the previous
+ /// test used the wrong function — tautological for empty input since both
+ /// produce an identical valid RDB, but would miss regressions on the
+ /// snapshot-tuple path).
+ #[test]
+ fn empty_database_rewrite_produces_valid_rdb_and_recovers() {
+ let dir = tempdir().unwrap();
+
+ // Build the snapshot tuple the same way do_rewrite_sharded does.
+ let empty_dbs: Vec = vec![Database::new()];
+ let snapshot = db_slice_to_snapshot(&empty_dbs);
+ let rdb_bytes = crate::persistence::rdb::save_snapshot_to_bytes(&snapshot)
+ .expect("save_snapshot_to_bytes must succeed for empty snapshot");
+
+ // Invariant 1: RDB is non-empty (has at least magic + version + EOF marker).
+ assert!(
+ !rdb_bytes.is_empty(),
+ "empty-database RDB must not be 0 bytes"
+ );
+
+ // Invariant 2: starts with valid MOON magic header.
+ assert!(
+ rdb_bytes.starts_with(b"MOON"),
+ "RDB bytes must start with MOON magic, got: {:?}",
+ &rdb_bytes[..rdb_bytes.len().min(8)]
+ );
+
+ // Invariant 3: recovery from this base succeeds with 0 keys loaded.
+ let base_path = dir.path().join("empty.rdb");
+ std::fs::write(&base_path, &rdb_bytes).expect("write empty rdb");
+ let mut recovery_dbs = vec![Database::new()];
+ let loaded =
+ crate::persistence::rdb::load(&mut recovery_dbs, &base_path).expect("load empty rdb");
+ assert_eq!(
+ loaded, 0,
+ "recovering from empty-database RDB yields 0 keys"
+ );
+ assert_eq!(
+ recovery_dbs[0].len(),
+ 0,
+ "database must be empty after recovering from zero-key RDB"
+ );
+ }
+
+ /// FIX-W3-8: Genuine regression guard — save_snapshot_to_bytes preserves
+ /// a 1-key database through a full serialize→file→reload cycle.
+ ///
+ /// This is the substantive test the verifier asked for: verifies the
+ /// production code path (`save_snapshot_to_bytes` via the snapshot-tuple
+ /// form) against a non-trivial database, so a future regression that
+ /// swaps back to `save_to_bytes` or breaks TTL handling in the snapshot
+ /// path will be caught.
+ #[test]
+ fn snapshot_to_bytes_round_trips_one_key_database() {
+ let dir = tempdir().unwrap();
+
+ // Build a 1-key database with a string value.
+ let mut db = Database::new();
+ db.set_string(
+ Bytes::from_static(b"rdb_key"),
+ Bytes::from_static(b"rdb_value"),
+ );
+ let dbs = vec![db];
+
+ // Serialize via the production path (save_snapshot_to_bytes).
+ let snapshot = db_slice_to_snapshot(&dbs);
+ let rdb_bytes = crate::persistence::rdb::save_snapshot_to_bytes(&snapshot)
+ .expect("save_snapshot_to_bytes must succeed for 1-key snapshot");
+
+ assert!(rdb_bytes.starts_with(b"MOON"), "must have MOON magic");
+
+ // Reload and assert the key survives.
+ let base_path = dir.path().join("one_key.rdb");
+ std::fs::write(&base_path, &rdb_bytes).expect("write rdb");
+ let mut recovery_dbs = vec![Database::new()];
+ let loaded = crate::persistence::rdb::load(&mut recovery_dbs, &base_path)
+ .expect("load rdb must succeed");
+ assert_eq!(loaded, 1, "exactly 1 key must be recovered");
+ let val = recovery_dbs[0]
+ .get(b"rdb_key")
+ .expect("rdb_key must be present");
+ assert_eq!(
+ val.value.as_bytes().expect("string value"),
+ b"rdb_value",
+ "recovered value must match written value"
+ );
+ }
+
#[test]
fn test_generate_rewrite_round_trip_preserves_state() {
let mut dbs = vec![Database::new()];
@@ -1419,4 +2968,41 @@ mod tests {
assert_eq!(list[1].as_ref(), b"y");
assert_eq!(list[2].as_ref(), b"z");
}
+
+ // -----------------------------------------------------------------------
+ // FIX-W2-4 r2: canonical AOF fsync error string
+ //
+ // Red criterion: AOF_FSYNC_ERR constant must exist and equal the canonical
+ // Redis-style ERR-prefixed string used by handler_monoio and handler_sharded.
+ // handler_single.rs previously used "WRITEFAIL aof fsync failed" which is
+ // both non-canonical (no ERR prefix, different verb) and inconsistent with
+ // the other two handlers.
+ //
+ // These tests compile-fail on the prior commit (constant absent) and pass
+ // once AOF_FSYNC_ERR is declared in this module with the correct value.
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn aof_fsync_err_constant_is_canonical() {
+ // The canonical error frame bytes sent to the client when an AOF
+ // fsync under appendfsync=always fails. Must match what
+ // handler_monoio/mod.rs and handler_sharded/mod.rs use.
+ assert_eq!(
+ AOF_FSYNC_ERR, b"ERR AOF fsync failed; write not durable",
+ "AOF_FSYNC_ERR must equal the canonical ERR-prefixed string"
+ );
+ }
+
+ #[test]
+ fn aof_fsync_err_has_err_prefix() {
+ // Redis convention: protocol-level errors must start with a word
+ // followed by a space, using `ERR` for generic errors. `WRITEFAIL`
+ // is not a standard Redis error prefix and confuses clients that
+ // pattern-match on error codes.
+ assert!(
+ AOF_FSYNC_ERR.starts_with(b"ERR "),
+ "AOF_FSYNC_ERR must start with 'ERR ' (got {:?})",
+ std::str::from_utf8(AOF_FSYNC_ERR).unwrap_or("")
+ );
+ }
}
diff --git a/src/persistence/aof_manifest.rs b/src/persistence/aof_manifest.rs
index ce1efb3b..91b16087 100644
--- a/src/persistence/aof_manifest.rs
+++ b/src/persistence/aof_manifest.rs
@@ -5,33 +5,112 @@
//! manifest framing is the canonical on-disk marker; the human-readable
//! "v1" umbrella also covers WAL v3 and RDB v2 sub-formats.
//!
-//! Implements the same directory-based AOF format as Redis 7+:
+//! Two on-disk layouts coexist (selected at manifest creation time, never mixed
+//! within one directory):
+//!
+//! **TopLevel (manifest v1, single-shard / legacy):**
//! ```text
//! appendonlydir/
//! moon.aof.1.base.rdb # RDB snapshot base
//! moon.aof.1.incr.aof # Incremental RESP since base
-//! moon.aof.manifest # This file
+//! moon.aof.manifest # v1 text format
+//! ```
+//!
+//! **PerShard (manifest v2, multi-shard durability):**
+//! ```text
+//! appendonlydir/
+//! moon.aof.manifest # v2 text format (carries shard count + max_lsn)
+//! shard-0/
+//! moon.aof.1.base.rdb
+//! moon.aof.1.incr.aof
+//! shard-1/
+//! moon.aof.1.base.rdb
+//! moon.aof.1.incr.aof
+//! …
//! ```
//!
-//! The manifest is a simple text file listing the active base and incremental
-//! files with their sequence numbers. On BGREWRITEAOF, the sequence increments,
-//! a new base + incr pair is created, and old files are deleted.
+//! The manifest text format is line-prefix based. v1 manifests have no
+//! `version` line; v2 manifests begin with `version 2`. On BGREWRITEAOF the
+//! sequence increments, a new base + incr pair is created per shard (PerShard)
+//! or at top level (TopLevel), and old files are deleted.
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::{error, info, warn};
+use crate::persistence::fsync::fsync_directory;
+
const MANIFEST_NAME: &str = "moon.aof.manifest";
const AOF_DIR_NAME: &str = "appendonlydir";
+/// Fsync the parent directory of `path` (best-effort).
+///
+/// POSIX guarantees atomicity of `rename()` but does NOT guarantee that the
+/// directory entry update is durable after a crash. On ext4 and XFS without
+/// `data=ordered`, a crash between the rename and a directory fsync can leave
+/// the old file name visible on the next boot even though the rename completed
+/// in memory. Calling this after every manifest-visible rename closes that gap.
+///
+/// Best-effort: logs on failure but does not propagate the error. A failed
+/// dir fsync means the rename may not survive a crash — the worst case is
+/// that recovery falls back to the previous manifest state, which is still
+/// consistent (the atomic rename guarantees the file is either fully old or
+/// fully new). Call sites that CAN propagate (i.e., are in a fallible fn that
+/// returns `std::io::Result`) should call `fsync_directory(parent)?` directly.
+fn fsync_parent_best_effort(path: &Path) {
+ let parent = match path.parent() {
+ Some(p) if !p.as_os_str().is_empty() => p,
+ _ => return, // root or no parent — nothing to fsync
+ };
+ if let Err(e) = fsync_directory(parent) {
+ warn!(
+ "fsync_parent_best_effort: failed to fsync dir {} after rename of {}: {}",
+ parent.display(),
+ path.display(),
+ e
+ );
+ }
+}
+
+/// On-disk layout discriminator.
+///
+/// `TopLevel` is the legacy single-shard layout from manifest v1. `PerShard`
+/// is the multi-shard layout introduced with manifest v2 — used whenever
+/// `num_shards >= 2`. A `--shards 1` deployment with an existing v1 manifest
+/// stays TopLevel until explicitly migrated.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AofLayout {
+ /// Legacy single-shard layout: `appendonlydir/moon.aof.{seq}.{base|incr}.*`.
+ TopLevel,
+ /// Per-shard layout: `appendonlydir/shard-{N}/moon.aof.{seq}.{base|incr}.*`.
+ PerShard,
+}
+
+/// Per-shard manifest entry. One per shard in `PerShard` layout.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ShardManifest {
+ /// Shard ID (0..num_shards).
+ pub shard_id: u16,
+ /// Max LSN persisted to this shard's incr file so far. Semantics defined
+ /// by step 3 (LSN tagging) of the per-shard AOF RFC — until then this is
+ /// 0 and recovery does not use it. Once step 3 ships, recovery seeds
+ /// `master_repl_offset = max(shards[*].max_lsn)` before accepting writes.
+ pub max_lsn: u64,
+}
+
/// Active AOF file set tracked by the manifest.
#[derive(Debug, Clone)]
pub struct AofManifest {
/// Base directory (parent of `appendonlydir/`)
pub dir: PathBuf,
- /// Current sequence number (incremented on each rewrite)
+ /// Current sequence number (incremented on each rewrite).
pub seq: u64,
+ /// On-disk layout. Determines path computation for base/incr files.
+ pub layout: AofLayout,
+ /// Per-shard metadata. Length is 1 for `TopLevel`, `num_shards` for
+ /// `PerShard`. Indexed by `shard_id`.
+ pub shards: Vec,
}
impl AofManifest {
@@ -46,25 +125,76 @@ impl AofManifest {
}
/// Path to the base RDB file for the current sequence.
+ ///
+ /// Layout-aware: TopLevel returns `appendonlydir/moon.aof.{seq}.base.rdb`;
+ /// PerShard routes to `appendonlydir/shard-0/moon.aof.{seq}.base.rdb`.
+ /// This single-file helper is meaningful only when there is one shard
+ /// (post-migration `--shards 1`); a multi-shard PerShard manifest has N
+ /// base files and the caller must use [`Self::shard_base_path`] instead.
+ /// In debug builds, calling this on a multi-shard PerShard manifest
+ /// asserts; in release it returns the shard-0 path so production stays
+ /// recoverable rather than panicking on a stale call site.
pub fn base_path(&self) -> PathBuf {
- self.aof_dir()
- .join(format!("moon.aof.{}.base.rdb", self.seq))
+ match self.layout {
+ AofLayout::TopLevel => self
+ .aof_dir()
+ .join(format!("moon.aof.{}.base.rdb", self.seq)),
+ AofLayout::PerShard => {
+ debug_assert!(
+ self.shards.len() == 1,
+ "base_path() called on multi-shard PerShard manifest; use shard_base_path(shard_id)",
+ );
+ self.shard_base_path_seq(0, self.seq)
+ }
+ }
}
/// Path to the incremental RESP file for the current sequence.
+ ///
+ /// Layout-aware — see [`Self::base_path`] for the same routing rules.
pub fn incr_path(&self) -> PathBuf {
- self.aof_dir()
- .join(format!("moon.aof.{}.incr.aof", self.seq))
+ match self.layout {
+ AofLayout::TopLevel => self
+ .aof_dir()
+ .join(format!("moon.aof.{}.incr.aof", self.seq)),
+ AofLayout::PerShard => {
+ debug_assert!(
+ self.shards.len() == 1,
+ "incr_path() called on multi-shard PerShard manifest; use shard_incr_path(shard_id)",
+ );
+ self.shard_incr_path_seq(0, self.seq)
+ }
+ }
}
- /// Path to the base RDB file for a given sequence.
+ /// Path to the base RDB file for a given sequence. Layout-aware — see
+ /// [`Self::base_path`].
pub fn base_path_seq(&self, seq: u64) -> PathBuf {
- self.aof_dir().join(format!("moon.aof.{}.base.rdb", seq))
+ match self.layout {
+ AofLayout::TopLevel => self.aof_dir().join(format!("moon.aof.{}.base.rdb", seq)),
+ AofLayout::PerShard => {
+ debug_assert!(
+ self.shards.len() == 1,
+ "base_path_seq() called on multi-shard PerShard manifest; use shard_base_path_seq(shard_id, seq)",
+ );
+ self.shard_base_path_seq(0, seq)
+ }
+ }
}
- /// Path to the incremental RESP file for a given sequence.
+ /// Path to the incremental RESP file for a given sequence. Layout-aware —
+ /// see [`Self::base_path`].
pub fn incr_path_seq(&self, seq: u64) -> PathBuf {
- self.aof_dir().join(format!("moon.aof.{}.incr.aof", seq))
+ match self.layout {
+ AofLayout::TopLevel => self.aof_dir().join(format!("moon.aof.{}.incr.aof", seq)),
+ AofLayout::PerShard => {
+ debug_assert!(
+ self.shards.len() == 1,
+ "incr_path_seq() called on multi-shard PerShard manifest; use shard_incr_path_seq(shard_id, seq)",
+ );
+ self.shard_incr_path_seq(0, seq)
+ }
+ }
}
/// Create the `appendonlydir/` and write the initial manifest.
@@ -82,6 +212,11 @@ impl AofManifest {
let manifest = Self {
dir: dir.to_path_buf(),
seq: 1,
+ layout: AofLayout::TopLevel,
+ shards: vec![ShardManifest {
+ shard_id: 0,
+ max_lsn: 0,
+ }],
};
std::fs::create_dir_all(manifest.aof_dir())?;
@@ -98,6 +233,7 @@ impl AofManifest {
f.sync_data()?;
}
std::fs::rename(&tmp_path, &base_path)?;
+ fsync_parent_best_effort(&base_path);
// Create the empty incr file so the writer has a target.
std::fs::File::create(manifest.incr_path())?;
@@ -119,6 +255,11 @@ impl AofManifest {
let manifest = Self {
dir: dir.to_path_buf(),
seq: 1,
+ layout: AofLayout::TopLevel,
+ shards: vec![ShardManifest {
+ shard_id: 0,
+ max_lsn: 0,
+ }],
};
std::fs::create_dir_all(manifest.aof_dir())?;
@@ -131,6 +272,7 @@ impl AofManifest {
f.sync_data()?;
}
std::fs::rename(&tmp_path, &base_path)?;
+ fsync_parent_best_effort(&base_path);
// Create empty incr file so the writer has something to append to.
std::fs::File::create(manifest.incr_path())?;
@@ -157,6 +299,53 @@ impl AofManifest {
let content = std::fs::read_to_string(&manifest_path)?;
+ // Detect format version. v1 manifests have no `version` line and use
+ // line prefixes `seq`/`base`/`incr`. v2 manifests start with `version 2`
+ // and carry per-shard records.
+ let mut format_version: u8 = 1;
+ for line in content.lines() {
+ let line = line.trim();
+ if let Some(val) = line.strip_prefix("version ") {
+ if let Ok(v) = val.parse::() {
+ format_version = v;
+ }
+ break;
+ }
+ if !line.is_empty() {
+ // First non-blank line is not a version header → v1.
+ break;
+ }
+ }
+
+ let manifest = match format_version {
+ 1 => Self::parse_v1(&content, dir, &manifest_path)?,
+ 2 => Self::parse_v2(&content, dir, &manifest_path)?,
+ other => {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!(
+ "AOF manifest at {} has unsupported format version {} (max supported: 2)",
+ manifest_path.display(),
+ other,
+ ),
+ ));
+ }
+ };
+
+ // Best-effort orphan cleanup: delete stray base/incr files from aborted
+ // rewrites. A crash between advance() steps 1-3 leaves a new base RDB on
+ // disk that the active manifest never references. Without this sweep,
+ // repeated crashes during rewrite can fill the disk with zombie files.
+ //
+ // Safe to call here: parse_* verified the manifest has all required
+ // records, so cleanup_orphans won't delete the active files.
+ manifest.cleanup_orphans();
+
+ Ok(Some(manifest))
+ }
+
+ /// Parse a v1 (TopLevel, single-shard) manifest.
+ fn parse_v1(content: &str, dir: &Path, manifest_path: &Path) -> std::io::Result {
let mut seq = 0u64;
let mut has_base_record = false;
let mut has_incr_record = false;
@@ -183,10 +372,6 @@ impl AofManifest {
));
}
- // A valid manifest must have all three records (seq, base, incr).
- // A truncated manifest with only "seq N" but no base/incr lines could
- // trigger orphan cleanup that deletes the real base RDB referenced by
- // the previous valid manifest. Require all records before proceeding.
if !has_base_record || !has_incr_record {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
@@ -200,33 +385,220 @@ impl AofManifest {
));
}
- let manifest = Self {
+ Ok(Self {
dir: dir.to_path_buf(),
seq,
- };
+ layout: AofLayout::TopLevel,
+ shards: vec![ShardManifest {
+ shard_id: 0,
+ max_lsn: 0,
+ }],
+ })
+ }
- // Best-effort orphan cleanup: delete stray base/incr files from aborted
- // rewrites. A crash between advance() steps 1-3 leaves a new base RDB on
- // disk that the active manifest never references. Without this sweep,
- // repeated crashes during rewrite can fill the disk with zombie files.
- //
- // Safe to call here: we verified the manifest has all three records
- // (seq, base, incr), so cleanup_orphans won't delete the active files.
- manifest.cleanup_orphans();
+ /// Parse a v2 (PerShard, multi-shard) manifest.
+ ///
+ /// Expected line format:
+ /// ```text
+ /// version 2
+ /// seq N
+ /// shards K
+ /// shard 0 max_lsn LSN0
+ /// shard 1 max_lsn LSN1
+ /// ...
+ /// ```
+ ///
+ /// Per-shard `base`/`incr` paths are derived from `shard-{N}/moon.aof.{seq}.*`
+ /// rather than stored explicitly — the layout is canonical, so storing
+ /// paths invites drift between the stored value and the computed one.
+ fn parse_v2(content: &str, dir: &Path, manifest_path: &Path) -> std::io::Result {
+ let mut seq = 0u64;
+ let mut num_shards: Option = None;
+ let mut shards: Vec = Vec::new();
- Ok(Some(manifest))
+ for line in content.lines() {
+ let line = line.trim();
+ if line.is_empty() || line.starts_with('#') {
+ continue;
+ }
+ if line == "version 2" {
+ continue;
+ } else if let Some(val) = line.strip_prefix("seq ") {
+ seq = val.parse::().map_err(|e| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!(
+ "AOF manifest at {} has invalid seq line `{}`: {}",
+ manifest_path.display(),
+ line,
+ e,
+ ),
+ )
+ })?;
+ } else if let Some(val) = line.strip_prefix("shards ") {
+ num_shards = Some(val.parse::().map_err(|e| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!(
+ "AOF manifest at {} has invalid shards line `{}`: {}",
+ manifest_path.display(),
+ line,
+ e,
+ ),
+ )
+ })?);
+ } else if let Some(rest) = line.strip_prefix("shard ") {
+ // Format: `shard max_lsn `
+ let mut it = rest.split_whitespace();
+ let id_str = it.next().ok_or_else(|| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!(
+ "AOF manifest at {} has shard line missing id: `{}`",
+ manifest_path.display(),
+ line,
+ ),
+ )
+ })?;
+ let id: u16 = id_str.parse().map_err(|e| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!(
+ "AOF manifest at {} has shard line invalid id `{}`: {}",
+ manifest_path.display(),
+ id_str,
+ e,
+ ),
+ )
+ })?;
+ // Expect `max_lsn `.
+ let label = it.next().unwrap_or("");
+ let val_str = it.next().unwrap_or("0");
+ if label != "max_lsn" {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!(
+ "AOF manifest at {} shard {} expected `max_lsn`, got `{}`",
+ manifest_path.display(),
+ id,
+ label,
+ ),
+ ));
+ }
+ let max_lsn: u64 = val_str.parse().map_err(|e| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!(
+ "AOF manifest at {} shard {} invalid max_lsn `{}`: {}",
+ manifest_path.display(),
+ id,
+ val_str,
+ e,
+ ),
+ )
+ })?;
+ shards.push(ShardManifest {
+ shard_id: id,
+ max_lsn,
+ });
+ }
+ // Unknown lines are tolerated (forward-compat). Strict parsers can
+ // be added at v3 if needed.
+ }
+
+ if seq == 0 {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!(
+ "AOF manifest at {} has no valid sequence number",
+ manifest_path.display()
+ ),
+ ));
+ }
+
+ let expected = num_shards.ok_or_else(|| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!(
+ "AOF manifest at {} is missing required `shards N` line",
+ manifest_path.display()
+ ),
+ )
+ })?;
+
+ if shards.len() != expected as usize {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!(
+ "AOF manifest at {} declares shards={} but has {} shard records",
+ manifest_path.display(),
+ expected,
+ shards.len(),
+ ),
+ ));
+ }
+
+ // Sort by shard_id and verify contiguous range [0, expected).
+ shards.sort_by_key(|s| s.shard_id);
+ for (i, s) in shards.iter().enumerate() {
+ if s.shard_id as usize != i {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!(
+ "AOF manifest at {} has non-contiguous shard ids (expected {} at position {}, got {})",
+ manifest_path.display(),
+ i,
+ i,
+ s.shard_id,
+ ),
+ ));
+ }
+ }
+
+ Ok(Self {
+ dir: dir.to_path_buf(),
+ seq,
+ layout: AofLayout::PerShard,
+ shards,
+ })
}
/// Delete any base/incr files in `appendonlydir/` that do not match the
/// current sequence. Best-effort — logs but does not propagate errors.
+ ///
+ /// For `PerShard` layout, also recurses into every `shard-N/` subdirectory
+ /// and removes stale/tmp files there. Aborted BGREWRITEAOF runs leave
+ /// `.rdb.tmp` files in the shard subdirs that otherwise accumulate forever.
fn cleanup_orphans(&self) {
- let aof_dir = self.aof_dir();
- let entries = match std::fs::read_dir(&aof_dir) {
+ match self.layout {
+ AofLayout::TopLevel => {
+ self.cleanup_orphans_dir(&self.aof_dir(), self.seq);
+ }
+ AofLayout::PerShard => {
+ // Top-level appendonlydir/ holds only the manifest — no data files
+ // to clean up there. All data lives in shard-N/ subdirs.
+ for shard in &self.shards {
+ self.cleanup_orphans_shard(shard.shard_id);
+ }
+ }
+ }
+ }
+
+ /// Scan a single shard's directory for orphan base/incr/tmp files that do
+ /// not correspond to the current manifest sequence. Best-effort.
+ fn cleanup_orphans_shard(&self, shard_id: u16) {
+ self.cleanup_orphans_dir(&self.shard_dir(shard_id), self.seq);
+ }
+
+ /// Core orphan sweep: scan `dir` and remove any `moon.aof.*` files whose
+ /// sequence is not `keep_seq`. Skips the manifest file itself.
+ fn cleanup_orphans_dir(&self, dir: &Path, keep_seq: u64) {
+ let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
- let current_base = format!("moon.aof.{}.base.rdb", self.seq);
- let current_incr = format!("moon.aof.{}.incr.aof", self.seq);
+ let current_base = format!("moon.aof.{}.base.rdb", keep_seq);
+ let current_incr = format!("moon.aof.{}.incr.aof", keep_seq);
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = match name.to_str() {
@@ -258,116 +630,661 @@ impl AofManifest {
}
/// Write the manifest file atomically (write tmp + rename).
+ ///
+ /// Emits v1 format for `TopLevel` and v2 for `PerShard`. The format is
+ /// selected by `self.layout`, never by callers — preserving the invariant
+ /// that one directory holds one layout.
pub fn write_manifest(&self) -> std::io::Result<()> {
let manifest_path = self.manifest_path();
let tmp_path = manifest_path.with_extension("tmp");
- let content = format!(
- "seq {}\nbase moon.aof.{}.base.rdb\nincr moon.aof.{}.incr.aof\n",
- self.seq, self.seq, self.seq
- );
+ let content = match self.layout {
+ AofLayout::TopLevel => format!(
+ "seq {}\nbase moon.aof.{}.base.rdb\nincr moon.aof.{}.incr.aof\n",
+ self.seq, self.seq, self.seq
+ ),
+ AofLayout::PerShard => {
+ let mut s = String::with_capacity(64 + self.shards.len() * 40);
+ s.push_str("version 2\n");
+ s.push_str(&format!("seq {}\n", self.seq));
+ s.push_str(&format!("shards {}\n", self.shards.len()));
+ for shard in &self.shards {
+ s.push_str(&format!(
+ "shard {} max_lsn {}\n",
+ shard.shard_id, shard.max_lsn
+ ));
+ }
+ s
+ }
+ };
let mut f = std::fs::File::create(&tmp_path)?;
f.write_all(content.as_bytes())?;
f.sync_data()?;
std::fs::rename(&tmp_path, &manifest_path)?;
+ fsync_parent_best_effort(&manifest_path);
Ok(())
}
- /// Advance to the next sequence: write new base RDB, create new incr file,
- /// update manifest, delete old files.
+ // ------------------------------------------------------------------
+ // Per-shard layout helpers
+ // ------------------------------------------------------------------
+
+ /// Directory holding a shard's AOF files.
///
- /// Returns the path to the new incremental file (caller should switch writing to it).
- pub fn advance(&mut self, rdb_bytes: &[u8]) -> Result {
- let old_seq = self.seq;
- let new_seq = old_seq + 1;
+ /// - `TopLevel`: `appendonlydir/` (the shard_id argument is asserted to be 0).
+ /// - `PerShard`: `appendonlydir/shard-{shard_id}/`.
+ pub fn shard_dir(&self, shard_id: u16) -> PathBuf {
+ match self.layout {
+ AofLayout::TopLevel => {
+ debug_assert_eq!(shard_id, 0, "TopLevel layout only has shard 0");
+ self.aof_dir()
+ }
+ AofLayout::PerShard => self.aof_dir().join(format!("shard-{}", shard_id)),
+ }
+ }
- let aof_dir = self.aof_dir();
- std::fs::create_dir_all(&aof_dir).map_err(|e| crate::error::AofError::Io {
- path: aof_dir.clone(),
- source: e,
- })?;
+ /// Path to a shard's base RDB file for the current sequence.
+ pub fn shard_base_path(&self, shard_id: u16) -> PathBuf {
+ self.shard_dir(shard_id)
+ .join(format!("moon.aof.{}.base.rdb", self.seq))
+ }
- // 1. Write new base RDB (atomic: tmp + fsync + rename).
- // Must fsync the data BEFORE renaming — a rename without prior fsync
- // can publish a file whose contents aren't durable, so a crash leaves
- // the manifest pointing at an empty/partial base RDB.
- let new_base = self.base_path_seq(new_seq);
- let tmp_base = new_base.with_extension("rdb.tmp");
- {
- let mut f =
- std::fs::File::create(&tmp_base).map_err(|e| crate::error::AofError::Io {
- path: tmp_base.clone(),
- source: e,
- })?;
- f.write_all(rdb_bytes)
- .map_err(|e| crate::error::AofError::Io {
- path: tmp_base.clone(),
- source: e,
- })?;
- f.sync_data().map_err(|e| crate::error::AofError::Io {
- path: tmp_base.clone(),
- source: e,
- })?;
- }
- std::fs::rename(&tmp_base, &new_base).map_err(|e| {
- crate::error::AofError::RewriteFailed {
- detail: format!("rename base: {}", e),
- }
- })?;
+ /// Path to a shard's incremental RESP file for the current sequence.
+ pub fn shard_incr_path(&self, shard_id: u16) -> PathBuf {
+ self.shard_dir(shard_id)
+ .join(format!("moon.aof.{}.incr.aof", self.seq))
+ }
- // 2. Create empty new incremental file
- let new_incr = self.incr_path_seq(new_seq);
- std::fs::File::create(&new_incr).map_err(|e| crate::error::AofError::Io {
- path: new_incr.clone(),
- source: e,
- })?;
+ /// Path to a shard's base RDB file for a given sequence.
+ pub fn shard_base_path_seq(&self, shard_id: u16, seq: u64) -> PathBuf {
+ self.shard_dir(shard_id)
+ .join(format!("moon.aof.{}.base.rdb", seq))
+ }
- // 3. Update manifest (atomic)
- self.seq = new_seq;
- self.write_manifest()
- .map_err(|e| crate::error::AofError::Io {
- path: self.manifest_path(),
- source: e,
- })?;
+ /// Path to a shard's incremental RESP file for a given sequence.
+ pub fn shard_incr_path_seq(&self, shard_id: u16, seq: u64) -> PathBuf {
+ self.shard_dir(shard_id)
+ .join(format!("moon.aof.{}.incr.aof", seq))
+ }
- // 4. Delete old files (best-effort)
- let old_base = self.base_path_seq(old_seq);
- let old_incr = self.incr_path_seq(old_seq);
- if old_base.exists() {
- if let Err(e) = std::fs::remove_file(&old_base) {
- warn!("Failed to delete old base {}: {}", old_base.display(), e);
+ /// Maximum LSN persisted across all shards.
+ ///
+ /// Computed (not stored) so the stored value can never drift from
+ /// the per-shard records. Returns 0 if `shards` is empty (defensive;
+ /// constructors guarantee at least one shard).
+ pub fn global_max_lsn(&self) -> u64 {
+ self.shards.iter().map(|s| s.max_lsn).max().unwrap_or(0)
+ }
+
+ /// Verify that the manifest matches the runtime shard count.
+ ///
+ /// Returns the verbatim error from RFC § 3 if the shard count differs,
+ /// for operator-facing consistency. Callers (typically `main.rs` boot)
+ /// should treat this as fatal: continuing with a mismatched shard count
+ /// silently drops data from shards that no longer exist or replays a
+ /// shard's data into the wrong DashTable.
+ pub fn verify_shard_count(&self, expected: u16) -> Result<(), String> {
+ let actual = self.shards.len() as u16;
+ if actual != expected {
+ return Err(format!(
+ "ERR shard count changed (manifest={}, config={}); refusing to start to avoid data loss. See docs/runbooks/shard-count-change.md",
+ actual, expected
+ ));
+ }
+ Ok(())
+ }
+
+ /// Returns true if the on-disk layout under `appendonlydir/` matches the
+ /// legacy TopLevel format (files at top level, no `shard-N/` subdirs).
+ ///
+ /// Used by callers to detect when a v1 single-shard deployment is being
+ /// upgraded to v2 multi-shard and needs explicit migration. Does NOT
+ /// migrate — separate from `migrate_top_level_to_per_shard` so the side
+ /// effect is opt-in, not hidden in a load path.
+ pub fn is_legacy_top_level_layout(dir: &Path) -> bool {
+ let aof_dir = dir.join(AOF_DIR_NAME);
+ if !aof_dir.exists() {
+ return false;
+ }
+
+ // Check manifest version first. If a valid v2 (PerShard) manifest exists,
+ // return false regardless of stray top-level files. Operators occasionally
+ // leave old base.rdb / incr.aof files at the top level during debugging
+ // or failed upgrades; scanning filenames without reading the manifest would
+ // produce a misleading "legacy detected" result and trigger unwanted
+ // migration on an already-upgraded deployment.
+ if let Ok(Some(m)) = Self::load(dir) {
+ if m.layout == AofLayout::PerShard {
+ return false;
}
}
- if old_incr.exists() {
- if let Err(e) = std::fs::remove_file(&old_incr) {
- warn!("Failed to delete old incr {}: {}", old_incr.display(), e);
+
+ let entries = match std::fs::read_dir(&aof_dir) {
+ Ok(e) => e,
+ Err(_) => return false,
+ };
+ for entry in entries.flatten() {
+ let name = entry.file_name();
+ let Some(name_str) = name.to_str() else {
+ continue;
+ };
+ if name_str.starts_with("moon.aof.")
+ && (name_str.ends_with(".base.rdb") || name_str.ends_with(".incr.aof"))
+ {
+ return true;
}
}
+ false
+ }
- info!(
- "AOF advanced to seq {}: base={} bytes, incr={}",
- new_seq,
- rdb_bytes.len(),
- new_incr.display()
- );
+ /// Migrate a single-shard TopLevel layout in place to a single-shard
+ /// PerShard layout.
+ ///
+ /// Moves `appendonlydir/moon.aof.{seq}.{base.rdb,incr.aof}` into
+ /// `appendonlydir/shard-0/`, then rewrites the manifest as v2 with
+ /// `shards 1`. Idempotent: a second call on an already-PerShard manifest
+ /// returns Ok with no filesystem changes.
+ ///
+ /// This is the RFC § 5 case 1 migration — zero data movement (rename only),
+ /// safe to run on first boot after upgrading from v0.1.x. Multi-shard
+ /// migrations from legacy AOF (case 2) use the `moon migrate-aof`
+ /// subcommand and are NOT handled here.
+ pub fn migrate_top_level_to_per_shard(&mut self) -> std::io::Result<()> {
+ if self.layout == AofLayout::PerShard {
+ return Ok(());
+ }
+ if self.shards.len() != 1 {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ format!(
+ "migrate_top_level_to_per_shard called with {} shards; \
+ only single-shard TopLevel can be migrated in place",
+ self.shards.len()
+ ),
+ ));
+ }
- Ok(new_incr)
- }
-}
+ // Compute paths up front. shard_dir/shard_*_path_seq for a single-
+ // shard target are pure path computations and do NOT depend on
+ // self.layout, so it is safe to derive them while layout is still
+ // TopLevel.
+ let old_base = self
+ .aof_dir()
+ .join(format!("moon.aof.{}.base.rdb", self.seq));
+ let old_incr = self
+ .aof_dir()
+ .join(format!("moon.aof.{}.incr.aof", self.seq));
+ let new_dir = self.aof_dir().join("shard-0");
+ let new_base = new_dir.join(format!("moon.aof.{}.base.rdb", self.seq));
+ let new_incr = new_dir.join(format!("moon.aof.{}.incr.aof", self.seq));
-/// Replay multi-part AOF: load base RDB then replay incremental RESP.
-///
-/// Returns total keys/commands loaded.
-pub fn replay_multi_part(
- databases: &mut [crate::storage::Database],
- manifest: &AofManifest,
- engine: &dyn crate::persistence::replay::CommandReplayEngine,
-) -> Result {
- let mut total = 0usize;
+ if !old_base.exists() {
+ // Pre-flight check: nothing moved yet, no rollback needed.
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::NotFound,
+ format!(
+ "TopLevel→PerShard migration: source base {} not found",
+ old_base.display()
+ ),
+ ));
+ }
+ std::fs::create_dir_all(&new_dir)?;
- // Load base RDB
- let base_path = manifest.base_path();
+ // Move base. If the rename itself fails, no on-disk mutation has
+ // happened yet — bail without rollback. Layout stays TopLevel until
+ // commit at the bottom.
+ std::fs::rename(&old_base, &new_base)?;
+
+ // Fsync the target directory so the base rename is durable before we
+ // proceed. A crash after rename but before dir-fsync could leave the
+ // old filename visible on the next boot.
+ //
+ // NOTE: if this fsync fails, old_base has already moved to new_base —
+ // rollback the rename before returning so the manifest stays consistent.
+ if let Err(e) = fsync_directory(&new_dir) {
+ if let Err(re) = std::fs::rename(&new_base, &old_base) {
+ error!(
+ "Migration rollback: failed to restore base {} → {} after fsync_directory failure: {}",
+ new_base.display(),
+ old_base.display(),
+ re
+ );
+ }
+ return Err(e);
+ }
+
+ // Base is now durably in shard-0/. Any subsequent error must restore it.
+ let moved_incr: bool;
+ let created_incr: bool;
+ if old_incr.exists() {
+ if let Err(e) = std::fs::rename(&old_incr, &new_incr) {
+ if let Err(re) = std::fs::rename(&new_base, &old_base) {
+ error!(
+ "Migration rollback: failed to restore base {} → {}: {}",
+ new_base.display(),
+ old_base.display(),
+ re
+ );
+ }
+ return Err(e);
+ }
+ // Fsync the shard directory to make the incr rename durable.
+ // If this fails, roll back both incr and base renames.
+ if let Err(e) = fsync_directory(&new_dir) {
+ if let Err(re) = std::fs::rename(&new_incr, &old_incr) {
+ error!(
+ "Migration rollback: failed to restore incr {} → {} after fsync_directory failure: {}",
+ new_incr.display(),
+ old_incr.display(),
+ re
+ );
+ }
+ if let Err(re) = std::fs::rename(&new_base, &old_base) {
+ error!(
+ "Migration rollback: failed to restore base {} → {} after fsync_directory failure: {}",
+ new_base.display(),
+ old_base.display(),
+ re
+ );
+ }
+ return Err(e);
+ }
+ moved_incr = true;
+ created_incr = false;
+ } else {
+ match std::fs::File::create(&new_incr) {
+ Ok(_) => {
+ moved_incr = false;
+ created_incr = true;
+ }
+ Err(e) => {
+ if let Err(re) = std::fs::rename(&new_base, &old_base) {
+ error!(
+ "Migration rollback: failed to restore base {} → {}: {}",
+ new_base.display(),
+ old_base.display(),
+ re
+ );
+ }
+ return Err(e);
+ }
+ }
+ }
+
+ // Commit: flip layout, persist as v2. If write_manifest fails, undo
+ // every filesystem mutation and restore layout so the next boot still
+ // sees a valid v1 TopLevel deployment.
+ self.layout = AofLayout::PerShard;
+ if let Err(e) = self.write_manifest() {
+ self.layout = AofLayout::TopLevel;
+ if moved_incr {
+ if let Err(re) = std::fs::rename(&new_incr, &old_incr) {
+ error!(
+ "Migration rollback: failed to restore incr {} → {}: {}",
+ new_incr.display(),
+ old_incr.display(),
+ re
+ );
+ }
+ } else if created_incr {
+ if let Err(re) = std::fs::remove_file(&new_incr) {
+ warn!(
+ "Migration rollback: failed to remove freshly created incr {}: {}",
+ new_incr.display(),
+ re
+ );
+ }
+ }
+ if let Err(re) = std::fs::rename(&new_base, &old_base) {
+ error!(
+ "Migration rollback: failed to restore base {} → {}: {}. \
+ Manifest dir {} may be in an inconsistent state.",
+ new_base.display(),
+ old_base.display(),
+ re,
+ self.dir.display()
+ );
+ }
+ return Err(e);
+ }
+
+ info!(
+ "AOF migrated: TopLevel → PerShard (single shard) at {}",
+ self.aof_dir().display()
+ );
+ Ok(())
+ }
+
+ /// Create the `appendonlydir/` and write an initial v2 manifest for the
+ /// given shard count.
+ ///
+ /// Each shard gets its own `shard-{N}/` subdirectory with an empty base
+ /// RDB and an empty incr file. Mirrors `initialize()` semantics: the
+ /// `(base + incr)` invariant holds from the first boot, so recovery can
+ /// replay incr-only state without complaint.
+ ///
+ /// **Idempotency pre-flight:** if `appendonlydir/moon.aof.manifest` already
+ /// exists, returns `Err(AlreadyExists)` without modifying any files. A
+ /// mid-loop crash followed by a retry would otherwise overwrite the already-
+ /// written shard-0 base RDB with an empty RDB, losing state. Callers that
+ /// want resume-or-skip semantics should use [`Self::try_initialize_multi`].
+ ///
+ /// **Rollback on partial failure:** if the per-shard loop fails mid-way (e.g.
+ /// shard-1 write fails after shard-0 succeeded), all already-created shard
+ /// base RDB files are deleted before returning the error.
+ pub fn initialize_multi(dir: &Path, num_shards: u16) -> std::io::Result {
+ if num_shards == 0 {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "initialize_multi requires num_shards >= 1",
+ ));
+ }
+ let manifest = Self {
+ dir: dir.to_path_buf(),
+ seq: 1,
+ layout: AofLayout::PerShard,
+ shards: (0..num_shards)
+ .map(|id| ShardManifest {
+ shard_id: id,
+ max_lsn: 0,
+ })
+ .collect(),
+ };
+ std::fs::create_dir_all(manifest.aof_dir())?;
+
+ // Pre-flight: refuse if manifest already exists to avoid overwriting
+ // already-written shard base RDB files (idempotency guard).
+ let manifest_path = manifest.manifest_path();
+ if manifest_path.exists() {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::AlreadyExists,
+ format!(
+ "initialize_multi: manifest already exists at {}; \
+ use try_initialize_multi() for idempotent initialization",
+ manifest_path.display()
+ ),
+ ));
+ }
+
+ // Per-shard empty RDB. Single Database::default() inside a 1-element
+ // slice matches `initialize()`'s empty-RDB shape for each shard.
+ let empty_dbs: [crate::storage::Database; 0] = [];
+ let empty_rdb = crate::persistence::rdb::save_to_bytes(&empty_dbs)
+ .map_err(|e| std::io::Error::other(format!("empty RDB serialize: {e}")))?;
+
+ // Track which shard directories were successfully created so we can
+ // roll them back on partial failure.
+ let mut created_shards: Vec = Vec::with_capacity(num_shards as usize);
+
+ let loop_result = (|| -> std::io::Result<()> {
+ for shard_id in 0..num_shards {
+ let shard_dir = manifest.shard_dir(shard_id);
+ std::fs::create_dir_all(&shard_dir)?;
+
+ let base_path = manifest.shard_base_path(shard_id);
+ let tmp_path = base_path.with_extension("rdb.tmp");
+ {
+ let mut f = std::fs::File::create(&tmp_path)?;
+ f.write_all(&empty_rdb)?;
+ f.sync_data()?;
+ }
+ std::fs::rename(&tmp_path, &base_path)?;
+ fsync_parent_best_effort(&base_path);
+ std::fs::File::create(manifest.shard_incr_path(shard_id))?;
+ created_shards.push(shard_id);
+ }
+ Ok(())
+ })();
+
+ if let Err(e) = loop_result {
+ // Rollback: remove base RDB files for all successfully-created shards.
+ for sid in created_shards {
+ let base = manifest.shard_base_path(sid);
+ if let Err(re) = std::fs::remove_file(&base) {
+ warn!(
+ "initialize_multi rollback: failed to remove {}: {}",
+ base.display(),
+ re
+ );
+ }
+ }
+ return Err(e);
+ }
+
+ manifest.write_manifest()?;
+ Ok(manifest)
+ }
+
+ /// Initialize a v2 multi-shard manifest only if one does not already exist.
+ ///
+ /// Returns `Ok(Some(manifest))` on successful creation, or `Ok(None)` if the
+ /// manifest file already existed (already initialized — no files modified).
+ /// Returns `Err(_)` only on actual I/O failures.
+ pub fn try_initialize_multi(dir: &Path, num_shards: u16) -> std::io::Result