Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f6ac42a
feat(config): default memory guardrail — auto-cap maxmemory (G1)
TinDang97 Jun 1, 2026
5a44662
fix(eviction): enforce maxmemory as a whole-instance cap across shard…
TinDang97 Jun 2, 2026
b2ccee7
fix(persistence): seed master offset past last AOF entry (PR #129 F5)
tindangtts Jun 2, 2026
517aa67
fix(persistence): bound appendfsync=always fsync-ack await (PR #129 F2)
TinDang97 Jun 1, 2026
b4e31f9
fix(shard): bound cross-shard SPSC dispatch retry + response wait (F3)
TinDang97 Jun 1, 2026
a4a8bb3
fix(persistence): defer advance_shard old-file deletion to post-commi…
tindangtts Jun 2, 2026
c8a5cbd
feat(persistence): per-shard BGREWRITEAOF fan-out compaction (F6, mon…
tindangtts Jun 2, 2026
789c1b4
docs(persistence): prove F6 cross-thread exactly-once invariant
tindangtts Jun 2, 2026
db284fb
docs(persistence): qualify F6 exactly-once claim — channel saturation…
tindangtts Jun 2, 2026
8853ade
feat(persistence): enable multi-shard per-shard AOF on the tokio runtime
tindangtts Jun 2, 2026
f5b430d
fix(persistence): hold tokio EverySec 1s flush bound under load and w…
tindangtts Jun 2, 2026
db08ae6
fix(disk-offload): multi-page cold-spill batching removes ~70-entry m…
tindangtts Jun 2, 2026
bdc236f
fix(disk-offload): wire async cold-spill into the tokio sharded write…
tindangtts Jun 2, 2026
66a8821
fix(disk-offload): cold read-through survives crash recovery (B-2)
tindangtts Jun 2, 2026
a3b3a9a
fix(disk-offload): per-file liveness prevents cold-tier batch-deletio…
tindangtts Jun 3, 2026
f5e17ac
feat(disk-offload): crash-atomic manifest overflow uncaps cold recove…
tindangtts Jun 3, 2026
ecf1781
fix(config): lower default cold-orphan-sweep interval 300s → 60s
tindangtts Jun 3, 2026
8a914fe
fix(persistence): recover disk-offload cold tier without AOF (appendo…
tindangtts Jun 3, 2026
c6fe8b0
fix(shard): stabilize tokio multishard serving + bound disk-offload RAM
tindangtts Jun 3, 2026
8e6eb89
test(multishard): serve smoke, ServerConfig Default hygiene, flake + …
tindangtts Jun 3, 2026
bfe8b4f
fix(persistence): reattach manifest handle after compact reopen failure
tindangtts Jun 3, 2026
9f91381
fix(persistence): latch tokio per-shard AOF writer on torn write
tindangtts Jun 3, 2026
9b214c5
fix(graph): bound Cypher nesting depth to 32 to prevent parser stack-…
tindangtts Jun 4, 2026
27dd61a
docs(persistence): document multishard RAM fix, MOON_URING toggle, Cy…
tindangtts Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,81 @@ supported under per-shard AOF layout`. Tracked for v0.2.0.
`CDC.SUBSCRIBE` push channel (C3b), and the multi-shard master PSYNC
deferred from v0.1.10. Tracked in `.planning/rfcs/v02-enterprise-architecture.md`.

### Fixed — Multishard idle-RAM blowup + tokio-Linux serving hang + graph parser DoS (PR #136)

Closes the "multishard RAM zombie": a fresh multishard instance with no
`maxmemory` could commit multiple GB of RSS while idle, and the tokio
runtime could hang its accept loop on Linux. Three independent root
causes, all fixed and verified in the OrbStack VM under a cgroup memory
cap (idle RSS **3791 MB → 29 MB** for a 4-shard no-`maxmemory` instance;
~45 MB under load):

- **PageCache eager pre-allocation.** Each shard committed
`num_frames × PAGE` zeroed bytes at construction, sized to 25% of the
**whole-instance** `maxmemory` (or a large default when unset). Now the
page buffers allocate lazily and the frame budget is divided by shard
count (`per_shard_pagecache_budget`); a startup `WARN` reports the
resolved per-shard budget.
- **tokio listener bind-race.** The central accept listener bound the port
*without* `SO_REUSEPORT` while per-shard listeners bound *with* it,
producing a bind-order race that could leave the port served by a shard
that never received connections. The central listener now also binds
`SO_REUSEPORT`; `--per-shard-accept` defaults to `false` under tokio.
- **io_uring-under-tokio default-off.** The tokio→io_uring bridge floods
errors under load. It is now **opt-in via `MOON_URING=1`**; tokio shards
run plain epoll/kqueue by default. `MOON_NO_URING=1` still force-disables
io_uring everywhere. The monoio runtime is unaffected (always io_uring
unless `MOON_NO_URING`).

Two durability defects found during review were fixed in-PR with
red/green TDD:

- **Manifest compact-reopen failure no longer silently loses commits.**
`manifest.rs compact()` reopened `self.file` *after* `rename(tmp, path)`;
if that reopen failed, later commits silently wrote to an orphaned inode.
A `needs_reopen` flag now reattaches to `self.path` before any subsequent
commit (or fails loudly).
- **tokio per-shard AOF writer now latches after a torn write.** The tokio
per-shard writer lacked the `write_error` latch present on the single-file
and monoio writers; a torn write (header OK, data fails) corrupted the
frame stream. It now latches on any write failure and acks `WriteFailed`.

- **Cypher parser stack-overflow DoS bounded.** The precedence-climbing
expression grammar pushes ~9 stack frames per source nesting level but
`check_depth()` counts only one level per recursion, so the previous
limit of 64 overflowed a 2 MiB async-worker stack (SIGABRT) **before**
the guard could fire. `DEFAULT_MAX_NESTING_DEPTH` is now `32`
(~288 worst-case debug frames, ~50% margin); deep nesting returns
`NestingDepthExceeded` gracefully.

Low-severity durability edge cases filed as follow-ups: #137
(`apply_spill_completions` failed-commit cold-key window), #138
(`do_rewrite_per_shard` panic wedges `--experimental-per-shard-rewrite`),
#139 (multi-DB `SELECT >0` cold recovery restores db0 only).

### Fixed — `maxmemory` is now a whole-instance cap across shards (G2)

**Behavior change for multishard deployments.** Previously each shard
enforced eviction against the *full* `maxmemory`, so an N-shard server
tolerated ~N× the configured cap before evicting (a 4-shard server at
`--maxmemory 100mb` retained ~307 MB / 768K keys vs a 1-shard server's
124 MB / 322K — the "RAM keeps growing in multishard mode" report).

`maxmemory` is now a true **whole-instance** cap. Each shard enforces
eviction against `maxmemory / num_shards`, so aggregate RSS converges on
the configured value regardless of shard count.

- **`CONFIG GET maxmemory` / INFO are unchanged** — they report the
whole-instance value verbatim (Redis-compatible). Division happens only
at enforcement.
- **Operators running explicit `--maxmemory N --shards M (M>1)`** now get
an effective ceiling of `N` (not `N×M`). A startup log line states the
resolved per-shard budget so the change is visible:
`maxmemory <N> bytes is a whole-instance cap; each of <M> shards enforces
eviction against a per-shard budget of <N/M> bytes`.
- Single-shard servers are byte-for-byte unaffected (`num_shards == 1` ⇒
no division).

### Docs — Hash-field TTL three-way benchmark suite (PR #127)

- `scripts/bench-hash-ttl.sh` (2-way harness) + `scripts/bench-hash-ttl-3way.sh`
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ orb run -m moon-dev bash -c 'sudo apt-get update -qq && sudo apt-get install -y
## Environment Variables

- `RUST_LOG=moon=debug` — enable tracing output (uses `tracing-subscriber` with `env-filter`)
- `MOON_NO_URING=1` — disable io_uring at runtime; used in CI/containers/WSL where io_uring is unavailable
- `MOON_NO_URING=1` — force-disable io_uring everywhere (monoio runtime + tokio bridge); used in CI/containers/WSL where io_uring is unavailable
- `MOON_URING=1` — opt **into** the tokio→io_uring bridge. The bridge is **default-off under the tokio runtime** (it floods errors under load and can hang the accept loop); tokio shards run plain epoll/kqueue unless this is set. No effect on the monoio runtime, which always uses io_uring unless `MOON_NO_URING` is set.
- `RUSTFLAGS="-C target-cpu=native"` — enable CPU-specific optimizations for benchmarking

## Key Design Decisions
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,14 @@ cargo build --release
--maxmemory 8g --maxmemory-policy allkeys-lfu
```

> **`--maxmemory` is a whole-instance cap.** With `--shards M`, each
> shard enforces eviction against `maxmemory / M`, so total RSS converges
> on the value you set regardless of shard count. `CONFIG GET maxmemory`
> and `INFO` report the whole-instance value (Redis-compatible); a startup
> log line shows the resolved per-shard budget. If `--maxmemory` is omitted,
> Moon auto-caps at ~80% of detected RAM with `allkeys-lru` (pass
> `--maxmemory 0` for unlimited).

### Connect with any Redis client

```bash
Expand Down
28 changes: 28 additions & 0 deletions src/command/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,34 @@ pub fn bgrewriteaof_start_sharded(
b"ERR Background AOF rewrite already in progress",
));
}

// [F6] PerShard pools use the per-shard fan-out (synchronized seq bump +
// single manifest commit across all writers). TopLevel multi-DB pools keep
// the legacy single-writer RewriteSharded path.
if pool.layout() == crate::persistence::aof_manifest::AofLayout::PerShard {
// Per-shard fan-out for BOTH runtimes. The fold is synchronous std::fs
// IO; on monoio the writer runs it inline, on tokio the writer converts
// its `tokio::fs` handle to `std::fs` for the fold's duration (both run
// on a dedicated block_on_local thread, so blocking is safe). The fold
// body — and therefore the exactly-once invariant — is identical.
//
// try_send_rewrite_per_shard loads the manifest, builds the shared
// coordinator, and reliably fans out to every writer. The in-progress
// flag is cleared by the coordinator's final commit
// (PerShardRewriteCoord::shard_done), not here.
match pool.try_send_rewrite_per_shard(shard_databases) {
Ok(()) => {
return Frame::SimpleString(Bytes::from_static(
b"Background append only file rewriting started",
));
}
Err(e) => {
AOF_REWRITE_IN_PROGRESS.store(false, Ordering::SeqCst);
return rewrite_pool_error_frame(e);
}
}
}

match pool.try_send_rewrite(AofMessage::RewriteSharded(shard_databases)) {
Ok(()) => Frame::SimpleString(Bytes::from_static(
b"Background append only file rewriting started",
Expand Down
Loading