Skip to content
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **AOF+WAL double-write eliminated for the common config (PR #211)** — new
`--wal-kv-log auto|on|off` (default `auto`). At `--appendonly yes` every
SPSC-executed KV write was logged to BOTH the per-shard AOF and the per-shard WAL
(measured 2.7× file-byte / 4.1× device write amplification at `--shards 4`), yet
startup recovery wipes WAL-replayed state and replays the AOF — the WAL copy was
pure disk wear. `auto` skips WAL KV records while the AOF is the recovery
authority and no CDC subscriber is attached (logging re-engages dynamically when
one attaches); `on` restores the pre-0.6 always-log behavior (needed for PITR /
full CDC history alongside AOF); `off` never logs KV records. FPI/checkpoint/
feature records are unaffected.
- **everysec/no AOF appends are no longer silently dropped under backpressure
(PR #211)** — a full writer channel used to `warn!` + drop the record while the
client still received `+OK` (client-acked write loss + AOF/memory divergence on
replay). Durable handler paths now await enqueue under `--aof-fsync-timeout-ms`
and surface `ChannelFull`/`WriteFailed` as an error frame; the synchronous
SPSC-drain and monoio inline-SET paths apply a bounded blocking send (ONE 5ms
budget shared across a whole pipeline/MULTI batch) and, on loss, replace the
success frame with `MOONERR AOF backpressure` instead of acking — every drop is
`error!`-logged and counted. `--wal-kv-log` also rejects unknown values at parse
time. Watch `aof_backpressure_dropped` in `INFO`.

- **Docker image build** — the multi-stage `Dockerfile` now copies the vendored
`vendor/monoio` source into the cargo-chef planner and cook stages. The v0.5.0
release introduced a `[patch.crates-io] monoio = { path = "vendor/monoio" }`
Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ All options are available as command-line flags. Run `moon --help` for the full
|------|---------|-------------|
| `--appendonly` | `yes` | Enable AOF persistence (`yes`/`no`) — Moon is durable by default |
| `--appendfsync` | `everysec` | AOF fsync policy (`always`/`everysec`/`no`) |
| `--aof-fsync-timeout-ms` | `2000` | Bound on a write's wait for its fsync barrier under `always` (0 = unbounded) |
| `--aof-fsync-timeout-ms` | `2000` | Bound on a write's wait for durability — the fsync ack under `always`, writer-queue backpressure under `everysec` (0 = unbounded) |
| `--wal-kv-log` | `auto` | KV logging into the per-shard WAL. `auto`: skipped while the AOF is the recovery authority and no CDC subscriber is attached (halves write volume at `--shards >= 2`); `on`: always log (needed for PITR / full CDC history with AOF on); `off`: never |
| `--appendfilename` | `appendonly.aof` | AOF filename |
| `--save` | *(none)* | RDB auto-save rules (e.g., `"3600 1 300 100"`) |
| `--dir` | `.` | Directory for persistence files |
Expand Down
13 changes: 13 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,25 @@ Moon is configured through command-line flags and an optional Redis-style config
| `--requirepass` | *(none)* | Require clients to authenticate with this password |
| `--check-config` | `false` | Validate configuration and exit without starting |

### Defaults are tuned for the single-shard case

Most deployments should run the defaults as-is: `--shards 1` gives the best
per-op latency for low-concurrency and non-pipelined traffic (a cross-shard hop
costs ~10µs), keeps every key co-located (no `{hash-tag}` planning), and — with
`--appendonly yes` + `--wal-kv-log auto` — writes each KV record to disk exactly
**once** (the AOF). Reach for more shards only with 8+ concurrent connections or
deep pipelines (measured 1.3–2.5× Redis at 8–64 conns on 4 shards), and see
[Tuning](tuning.md) for `--io-busy-poll-us` (a p=1 latency win **only** on
pinned/dedicated cores).

## Persistence

| Flag | Default | Description |
|------|---------|-------------|
| `--appendonly` | `yes` | Enable append-only file persistence (`yes`/`no`) — Moon is durable by default |
| `--appendfsync` | `everysec` | AOF fsync policy: `always`, `everysec`, or `no` |
| `--aof-fsync-timeout-ms` | `2000` | Max time a write may block awaiting durability (fsync ack under `always`, writer-queue backpressure under `everysec`) before it fails loudly instead of parking the connection. `0` = unbounded |
| `--wal-kv-log` | `auto` | KV command logging into the per-shard WAL. `auto`: skipped while the AOF is the recovery authority (`--appendonly yes`) and no CDC subscriber is attached — halves write volume at `--shards >= 2`; re-engages automatically when a CDC subscriber attaches. `on`: always log (pre-0.6 behavior; needed for [PITR](pitr.md) or full [CDC](cdc.md) history alongside AOF). `off`: never log KV records |
| `--appendfilename` | `appendonly.aof` | AOF filename |
| `--save` | *(none)* | RDB auto-save rules (e.g., `"3600 1 300 100"`) |
| `--dbfilename` | `dump.rdb` | RDB snapshot filename |
Expand Down
19 changes: 19 additions & 0 deletions docs/guides/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ Moon uses WAL v2 with:

The hot-path cost of WAL append is ~5ns (`buf.extend_from_slice()`), with batch `write_all` every 1ms tick and fsync on the configured schedule.

### One KV log at a time (`--wal-kv-log`)

With `--appendonly yes` the **AOF is the crash-recovery authority**: startup
replays the AOF over a wiped keyspace, discarding whatever the WAL replayed
first. Moon therefore skips the WAL copy of each KV command by default
(`--wal-kv-log auto`) — one durable KV log instead of two, halving on-disk
write volume at `--shards >= 2` with zero recovery loss. The WAL still carries
checkpoint/FPI and feature records, and KV logging re-engages automatically
when a CDC subscriber attaches. Set `--wal-kv-log on` if you need
point-in-time recovery or full CDC history alongside the AOF.

> **⚠ The WAL is not a standalone durability log.** With `--appendonly no`,
> only cross-shard (SPSC-dispatched) writes reach the WAL — writes local to a
> connection's own shard are not logged anywhere, so crash recovery loses
> roughly `1/num_shards` of writes at `--shards >= 2` (measured: 79% recovered
> at 4 shards) and **everything** at `--shards 1`. Keep `--appendonly yes`
> (the default) whenever you need KV durability; the WAL's KV stream exists
> for CDC, PITR, and disk-offload — not as an AOF replacement.

## RDB snapshots

RDB creates point-in-time snapshots of the entire dataset.
Expand Down
222 changes: 222 additions & 0 deletions scripts/gcloud-wal-aof-ab.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
# gcloud-wal-aof-ab.sh — same-instance A/B proof of the write-path durability fixes
# (--wal-kv-log gate + everysec bounded backpressure) on GCloud real SSD.
#
# Proves, per config {--wal-kv-log on (old behavior) | auto (fix) | walonly
# (--appendonly no + default disk-offload: WAL-v3 is the sole KV log)} at
# shards={1,4}, appendonly=yes everysec (defaults) except walonly:
# 1. VOLUME — on-disk bytes attributed AOF vs WAL (+ /proc/<pid>/io write_bytes).
# Expect: auto ==> WAL header-only at shards=4 (~44% total cut); shards=1 unchanged.
# 2. THROUGHPUT — SET RPS, P1 & P64, c50, median of 3 reps (fresh server per rep).
# Expect: no regression (OrbStack showed the tax is flat; real SSD may show a win).
# 3. RECOVERY — auto @ shards=4: write N keys, kill -9, restart, DBSIZE must equal N
# (AOF alone recovers everything with the WAL gate active).
# 4. BACKPRESSURE — INFO aof_backpressure_dropped must stay 0 in every run
# (bounded-backpressure path never engages on a healthy disk).
#
# Measure-only: never edits src. Patterned on gcloud-kv-scale-bench.sh (provision/
# scp/measure/teardown). The instance builds the LOCAL source snapshot (scp'd
# tarball via `git archive`), NOT the GitHub main — the fix is unpushed.
#
# Subcommands:
# --measure INNER: runs ON the instance (expects ~/moon-src + toolchain)
# --one OUTER (default): provision $GCE_MACHINE, run, teardown
set -uo pipefail

GCE_MACHINE="${GCE_MACHINE:-c3-standard-4}"
GCE_NAME="${GCE_NAME:-moon-wal-ab}"
GCE_ZONE="${GCE_ZONE:-us-central1-a}"
REQUESTS="${REQUESTS:-500000}"
KEYSPACE="${KEYSPACE:-1000000}"
REPS="${REPS:-3}"
PORT="${PORT:-6399}"

log(){ printf '%s\n' "$*" >&2; }
die(){ log "FATAL: $*"; exit 1; }

# ============================================================================ INNER
wait_port(){ # host port tries — every probe timeout-bounded (a half-dead server
# that accepts but never replies must not hang the harness)
local t=0; until timeout 2 redis-cli -p "$2" ping 2>/dev/null | grep -q PONG; do
t=$((t+1)); [[ $t -ge 100 ]] && return 1; sleep 0.2
done
}

stop_moon(){ # $1=pid — SIGKILL + pattern backstop + wait-for-port-free.
# Moon handles SIGTERM gracefully and can linger; SO_REUSEPORT then lets the
# NEXT server bind the same port alongside the old one (two servers, split
# traffic, hung pings). kill -9 is the house convention (see CLAUDE.md).
kill -9 "$1" 2>/dev/null; wait "$1" 2>/dev/null
pkill -9 -f "release/moon --port $PORT" 2>/dev/null
local t=0
while pgrep -f "release/moon --port $PORT" >/dev/null 2>&1; do
t=$((t+1)); [[ $t -ge 50 ]] && { log "WARN: moon refused to die"; return 1; }; sleep 0.2
done
return 0
}

rps_of(){ # extract last "requests per second" (redis-benchmark 8.x \r progress trap)
tr '\r' '\n' | grep -i "requests per second" | tail -1 | grep -oE '[0-9.]+' | head -1
}

dir_bytes(){ # $1=dir $2=glob-ere -> total bytes of matching files
find "$1" -type f 2>/dev/null | grep -E "$2" | xargs -r stat -c '%s' | awk '{s+=$1} END{print s+0}'
}

start_moon(){ # $1=shards $2=walkv $3=dir -> pid
if [[ "$2" == "walonly" ]]; then
# WAL-only posture: AOF off; disk-offload stays at its default (enable) so
# WAL-v3 is the sole KV log (wal-kv-log auto => keep logging, no AOF to
# defer to). NOTE: conn-LOCAL writes never enter the SPSC path and are not
# WAL-logged — the recovery check below sizes that gap empirically.
./moon-src/target/release/moon --port "$PORT" --shards "$1" \
--appendonly no --dir "$3" >"$3/server.log" 2>&1 &
else
./moon-src/target/release/moon --port "$PORT" --shards "$1" \
--appendonly yes --appendfsync everysec --wal-kv-log "$2" \
--dir "$3" >"$3/server.log" 2>&1 &
fi
echo $!
}

measure_one(){ # $1=shards $2=walkv -> prints a report block
local shards="$1" walkv="$2" dir pid rps_p1=() rps_p64=() r
echo "### shards=$shards wal-kv-log=$walkv"

# -- volume run (single, fixed workload) --
dir=$(mktemp -d /ssd-bench/walab.XXXX)
pid=$(start_moon "$shards" "$walkv" "$dir")
wait_port localhost "$PORT" || { cat "$dir/server.log"; die "moon never accepted (volume run)"; }
redis-benchmark -p "$PORT" -t set -n "$REQUESTS" -r "$KEYSPACE" -d 64 -c 50 -P 1 -q >/dev/null 2>&1
timeout 5 redis-cli -p "$PORT" set MARKERKEY_ZZZ9 MARKERVALUE_QQQ8 >/dev/null
sleep 3 # let 1ms-tick flush + everysec fsync settle
local aof_b wal_b pio drops
aof_b=$(dir_bytes "$dir" 'appendonly.*\.aof$|appendonlydir/.*\.aof$')
wal_b=$(dir_bytes "$dir" '\.wal$')
pio=$(awk '/^write_bytes/{print $2}' "/proc/$pid/io" 2>/dev/null || echo 0)
drops=$(timeout 5 redis-cli -p "$PORT" info persistence 2>/dev/null | tr -d '\r' | awk -F: '/aof_backpressure_dropped/{print $2}')
local marker_wal=0
if find "$dir" -name '*.wal' | head -5 | xargs -r strings 2>/dev/null | grep -q MARKERVALUE_QQQ8; then marker_wal=1; fi
echo "volume: aof_bytes=$aof_b wal_bytes=$wal_b proc_write_bytes=$pio backpressure_dropped=${drops:-NA} marker_in_wal=$marker_wal"

# -- recovery check (shards=4: auto proves AOF-alone recovery; walonly sizes
# WAL-alone recovery, incl. the conn-local-write gap) --
if [[ "$shards" == "4" && ( "$walkv" == "auto" || "$walkv" == "walonly" ) ]]; then
local nkeys; nkeys=$(timeout 5 redis-cli -p "$PORT" dbsize | grep -oE '[0-9]+')
stop_moon "$pid"
pid=$(start_moon "$shards" "$walkv" "$dir")
wait_port localhost "$PORT" || die "moon never accepted (recovery run)"
local nkeys2 marker2
nkeys2=$(timeout 5 redis-cli -p "$PORT" dbsize | grep -oE '[0-9]+')
marker2=$(timeout 5 redis-cli -p "$PORT" get MARKERKEY_ZZZ9)
echo "recovery: keys_before_kill9=$nkeys keys_after_restart=$nkeys2 marker=$marker2"
fi
stop_moon "$pid"
rm -rf "$dir"

# -- throughput reps (fresh server per rep; phantom-regression rule) --
for r in $(seq 1 "$REPS"); do
dir=$(mktemp -d /ssd-bench/walab.XXXX)
pid=$(start_moon "$shards" "$walkv" "$dir")
wait_port localhost "$PORT" || die "moon never accepted (rep $r)"
rps_p1+=("$(redis-benchmark -p "$PORT" -t set -n "$REQUESTS" -r "$KEYSPACE" -d 64 -c 50 -P 1 2>/dev/null | rps_of)")
rps_p64+=("$(redis-benchmark -p "$PORT" -t set -n "$REQUESTS" -r "$KEYSPACE" -d 64 -c 50 -P 64 2>/dev/null | rps_of)")
drops=$(timeout 5 redis-cli -p "$PORT" info persistence 2>/dev/null | tr -d '\r' | awk -F: '/aof_backpressure_dropped/{print $2}')
stop_moon "$pid"
rm -rf "$dir"
echo "rep$r: P1=${rps_p1[-1]:-NA} P64=${rps_p64[-1]:-NA} drops=${drops:-NA}"
done
echo "throughput: P1=[${rps_p1[*]}] P64=[${rps_p64[*]}]"
echo
}

measure(){
set -e
sudo mkdir -p /ssd-bench && sudo chmod 777 /ssd-bench
pkill -9 -f "release/moon --port $PORT" 2>/dev/null; rm -rf /ssd-bench/walab.* 2>/dev/null
command -v redis-benchmark >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq redis-tools; }
# steal gate (dedicated vCPU expected ~0)
awk '/^cpu /{print "cpu-steal-jiffies:", $9}' /proc/stat
echo "# ===== WAL/AOF A/B ($(uname -m), $(date -u +%FT%TZ)) ====="
echo "# moon: $(./moon-src/target/release/moon --version 2>/dev/null || echo unknown)"
set +e
for shards in 1 4; do
for walkv in on auto walonly; do
measure_one "$shards" "$walkv"
done
done
echo "# ===== END ====="
}

# ============================================================================ OUTER
run_one(){
command -v gcloud >/dev/null || die "gcloud CLI not found"
local imgfam
case "$GCE_MACHINE" in c4a-*|t2a-*) imgfam=ubuntu-2404-lts-arm64;; *) imgfam=ubuntu-2404-lts-amd64;; esac
local GSSH=(gcloud compute ssh "$GCE_NAME" --zone="$GCE_ZONE" --quiet
--ssh-flag=-oStrictHostKeyChecking=no --ssh-flag=-oConnectTimeout=15)

log "=== packing LOCAL source (HEAD commit) ==="
git -C "$(dirname "$0")/.." archive --format=tar.gz -o /tmp/moon-src.tar.gz --prefix=moon-src/ HEAD \
|| die "git archive failed (commit your changes first — the A/B ships HEAD)"

log "=== provisioning $GCE_NAME ($GCE_MACHINE, $GCE_ZONE, $imgfam, pd-ssd) ==="
gcloud compute instances create "$GCE_NAME" \
--machine-type="$GCE_MACHINE" --zone="$GCE_ZONE" \
--image-family="$imgfam" --image-project=ubuntu-os-cloud \
--boot-disk-size=100GB --boot-disk-type=pd-ssd --quiet || die "instance create failed"
local _inst="$GCE_NAME" _zone="$GCE_ZONE"
trap "log '=== tearing down $_inst ==='; gcloud compute instances delete '$_inst' --zone='$_zone' -q 2>/dev/null || true" EXIT INT TERM

log "=== waiting for SSH ==="
local tries=0
until "${GSSH[@]}" --command='echo up' >/dev/null 2>&1; do
tries=$((tries+1)); [[ $tries -ge 40 ]] && die "SSH never came up"; sleep 5
done

log "=== toolchain ==="
"${GSSH[@]}" --command='
set -e
sudo apt-get update -qq
sudo apt-get install -y -qq build-essential pkg-config libssl-dev git curl ca-certificates redis-tools
command -v cargo >/dev/null || curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.94.1
' || die "toolchain provisioning failed"

log "=== pushing source + harness ==="
gcloud compute scp /tmp/moon-src.tar.gz "$(dirname "$0")/gcloud-wal-aof-ab.sh" \
"$GCE_NAME":~/ --zone="$GCE_ZONE" --quiet --scp-flag=-oStrictHostKeyChecking=no || die "scp failed"

log "=== building release on instance (the long part) ==="
"${GSSH[@]}" --command='
set -e; source $HOME/.cargo/env
tar xzf moon-src.tar.gz
cd moon-src && cargo build --release 2>&1 | tail -3
file target/release/moon | grep -q ELF || { echo "NOT AN ELF"; exit 1; }
' || die "build failed"

local rawfile="tmp/wal-ab-${GCE_MACHINE}.txt"; mkdir -p tmp
log "=== running --measure -> $rawfile ==="
"${GSSH[@]}" --command="
source \$HOME/.cargo/env
REQUESTS='$REQUESTS' KEYSPACE='$KEYSPACE' REPS='$REPS' PORT='$PORT' \
bash ~/gcloud-wal-aof-ab.sh --measure
" | tee "$rawfile"
log "=== results in $rawfile; teardown follows (trap) ==="
}

measure_single(){ # $1=shards $2=walkv — one config only (chunked outer driving)
set +e
sudo mkdir -p /ssd-bench && sudo chmod 777 /ssd-bench
pkill -9 -f "release/moon --port $PORT" 2>/dev/null; rm -rf /ssd-bench/walab.* 2>/dev/null
command -v redis-benchmark >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq redis-tools; }
awk '/^cpu /{print "cpu-steal-jiffies:", $9}' /proc/stat
measure_one "$1" "$2"
}

case "${1:---one}" in
--measure) measure ;;
--measure-one) measure_single "${2:?usage: --measure-one <shards> <on|auto|walonly>}" \
"${3:?usage: --measure-one <shards> <on|auto|walonly>}" ;;
--one) run_one ;;
*) die "unknown subcommand: $1 (use --measure | --one)" ;;
esac
Comment thread
coderabbitai[bot] marked this conversation as resolved.
40 changes: 40 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,20 @@ pub struct ServerConfig {
#[arg(long = "wal-max-checkpoint-lag-ms", default_value_t = 10_000)]
pub wal_max_checkpoint_lag_ms: u64,

/// KV command logging into the per-shard WAL: auto | on | off.
///
/// `auto` (default): KV records are skipped while the AOF is the crash-
/// recovery authority (`--appendonly yes`) and no CDC subscriber is
/// attached — startup recovery wipes WAL-replayed state and replays the
/// AOF, so the WAL copy is pure write amplification (~2× disk writes at
/// shards>=2). Logging resumes dynamically when a CDC subscriber attaches.
/// `on`: always log (pre-0.6 behavior; required for point-in-time
/// recovery or full CDC history alongside `--appendonly yes`).
/// `off`: never log KV records (FPI/checkpoint/feature records still
/// written). With `--appendonly no` this leaves NO KV durability log.
#[arg(long = "wal-kv-log", default_value = "auto", value_parser = ["auto", "on", "off"])]
pub wal_kv_log: String,

// ── MoonStore v2: Vector Warm Tier ──────────────────────────────
/// mlock vector codes pages into RAM
#[arg(long = "vec-codes-mlock", default_value = "enable")]
Expand Down Expand Up @@ -648,6 +662,19 @@ fn default_data_dir() -> Option<std::path::PathBuf> {
)
}

/// Resolved `--wal-kv-log` mode (see the flag docs on `ServerConfig::wal_kv_log`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalKvLogMode {
/// Skip WAL KV records while the AOF is the recovery authority and no
/// CDC subscriber is attached; log otherwise. The per-drain decision is
/// made by the shard event loop (it owns the CDC registry).
Auto,
/// Always log KV records to the WAL (pre-0.6 behavior).
On,
/// Never log KV records to the WAL.
Off,
}

/// Outcome of persistence-directory auto-resolution (pure decision —
/// IO happens in [`ServerConfig::resolve_dir`]).
#[derive(Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -733,6 +760,19 @@ impl ServerConfig {
self.disk_offload == "enable"
}

/// Resolve `--wal-kv-log` into its mode. The CLI rejects unknown values
/// at parse time (`value_parser`, fail-fast on typos — a silent `Auto`
/// fallback could unexpectedly disable WAL KV history needed for
/// PITR/CDC); the `_ => Auto` arm below only covers programmatic
/// construction in tests.
pub fn wal_kv_log_mode(&self) -> WalKvLogMode {
match self.wal_kv_log.as_str() {
"on" => WalKvLogMode::On,
"off" => WalKvLogMode::Off,
_ => WalKvLogMode::Auto,
}
}

/// Returns true when WAL Full Page Images are enabled.
pub fn wal_fpi_enabled(&self) -> bool {
self.wal_fpi == "enable"
Expand Down
Loading
Loading