Skip to content

feat(shard): proactive RSS watchdog pauses writes before kernel OOM (RSS/CPU wave 3)#218

Merged
pilotspacex-byte merged 4 commits into
mainfrom
fix/rss-cpu-oom-wave3
Jul 6, 2026
Merged

feat(shard): proactive RSS watchdog pauses writes before kernel OOM (RSS/CPU wave 3)#218
pilotspacex-byte merged 4 commits into
mainfrom
fix/rss-cpu-oom-wave3

Conversation

@pilotspacex-byte

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

Copy link
Copy Markdown
Contributor

Proactive RSS watchdog ("mem-full guard") — RSS/CPU wave 3

Review item 6 from the RSS/CPU/OOM deep review (wave 1 = #216, wave 2 = #217). Moon had a diskfull guard but no memory analogue: with --maxmemory unset (or set too high), RSS grows until the kernel OOM-killer picks Moon.

What ships

  • src/shard/mem_monitor.rs — mirrors disk_monitor.rs exactly: one atomic-backed monitor, shard-0 poll on the existing 5s tick, hot path = a single AtomicBool::load(Relaxed), hysteresis state machine (inverted vs disk: pause at rss% >= --mem-full-pct, resume at pause-5), u8-wrap clamp for RSS > detected limit (a stale/under-detected cgroup limit must read as pressure, not wrap to "healthy").
  • Fires on ACTUAL RSS vs the DETECTED system/cgroup limit (detect_memory_limit_bytes: Linux cgroup v2/v1; macOS hw.memsize since fix(shard): OOM shield — macOS maxmemory guardrail + eviction bypass closure (RSS/CPU wave 2) #217) — not on configured maxmemory, which can be an unconfigured 0. Limit resolved once at startup; one immediate poll makes the AOF-replay/segment-load startup peak visible before the first tick.
  • Wiring: third OR-term in is_any_write_stall_active(); MOONERR memfull: writes paused until memory pressure recovers in both dispatch paths (one extra Relaxed load, only evaluated when a stall is already active); --mem-full-pct CLI (default 95, 0 = disabled); INFO reclamation_mem_rss_bytes / reclamation_mem_watchdog_active (ORed into write_stall_active).
  • MOON_MEM_LIMIT_BYTES env override (test/diagnostic knob, same family as MOON_XSHARD_*) so integration tests can engage the guard deterministically.

Tests

RED-proven (all 4 integration cases fail on a pre-feature binary): engage-on-first-write, disabled-by-pct-0, no-limit-no-pause, reads-never-blocked. 15 unit tests mirror the disk monitor's coverage plus the inverted-hysteresis and wrap-clamp cases. Green on monoio + tokio matrices.

Deep-reviewed per process (manual diff review + independent test reruns by the orchestrator).

Summary by CodeRabbit

  • New Features

    • Added a new memory pressure safeguard that can pause writes when process memory usage gets too high.
    • Added a configurable --mem-full-pct setting to control when the safeguard activates.
  • Bug Fixes

    • Write-blocking now distinguishes memory pressure from disk pressure, returning a clearer error when memory limits are reached.
    • Added new status info so users can see memory usage and whether the memory watchdog is active.
  • Tests

    • Expanded automated coverage for memory-pressure behavior, including enable/disable cases and write-blocking checks.

TinDang97 added 4 commits July 6, 2026 18:07
Review finding #6 from tmp/RSS-CPU-REVIEW.md (wave 3 of the RSS/CPU/OOM
train). Moon has a diskfull guard (`MOONERR diskfull` at <5% free) but no
memory analogue — when --maxmemory is unset (or too high), RSS can grow
until the kernel OOM-killer picks Moon. This adds mem_monitor, mirroring
disk_monitor's struct + hysteresis state machine + inject() test backdoor +
OnceLock global singleton pattern exactly, inverted for direction (high RSS
is bad, not low free space): writes pause once rss% >= mem_full_pct and
resume only once rss% <= mem_full_pct - 5 (hysteresis prevents flapping).

Fires on ACTUAL RSS (via the existing zero-alloc get_rss_bytes()) vs the
DETECTED system/cgroup memory limit (detect_memory_limit_bytes, now
pub(crate) so mem_monitor can call it), not the configured --maxmemory
(which can be an unconfigured 0). limit_bytes is resolved ONCE at
init_global time and held fixed — unlike disk_monitor's per-poll statvfs,
the memory-limit probe can shell out on macOS and must never run on the hot
poll path. A hidden MOON_MEM_LIMIT_BYTES env override (documented in the
module header, same rationale as the MOON_XSHARD_* bench-diagnostic knobs)
lets tests engage the guard deterministically without controlling real
process RSS.

New --mem-full-pct CLI flag (default 95, 0 = disabled) alongside
--disk-free-min-pct in config.rs. New INFO fields
reclamation_mem_rss_bytes / reclamation_mem_watchdog_active in
info_reclamation.rs, OR'd into the existing reclamation_write_stall_active
aggregate.

Red/green: 15 mem_monitor unit tests (threshold, INVERTED exact-threshold
pause, hysteresis resume/flap, disabled via pause_pct=0 or limit_bytes=0,
u8-wrap clamp on rss >> limit, initial-optimistic, Send+Sync, real-process
smoke) + 1 new info_reclamation wiring test (default false, OR-merge into
write_stall_active).

author: Tin Dang
Wires the mem_monitor core (previous commit) into the live write path:

- segment_stall::is_any_write_stall_active() OR-merges a third source,
  mem_monitor::is_write_paused(), alongside the existing MA12 disk-pressure
  and MA1 segment-backlog bits. Hot-path cost: one additional
  AtomicBool::load(Relaxed) inside the already write-gated branch — no
  allocation, no lock.
- Both dispatch paths' try_enforce_disk_full (handler_monoio and
  handler_sharded) gain a MOONERR memfull branch in the message-selection
  chain: diskfull first, then memfull, then the segment-backlog busy
  fallback — mirrors the existing diskfull/busy distinction verbatim.
- event_loop.rs: mem_monitor::poll_global() runs on shard 0's existing 5s
  disk_monitor timer tick, at BOTH poll sites (tokio select! arm and the
  monoio tick-counter branch) — no new timer, reuses MA12's cadence.
- main.rs: mem_monitor::init_global(config.mem_full_pct) called right after
  disk_monitor::init_global, the single call site for both guards. Runs one
  immediate poll inside init_global (see previous commit) so the AOF-replay
  /segment-load startup recovery peak is visible before the first 5s tick.

author: Tin Dang
…xups

Adds tests/mem_watchdog.rs, the wire-level counterpart to mem_monitor's
unit tests: proves the --mem-full-pct CLI flag, init_global's immediate
poll, and the dispatch-level MOONERR memfull message path against a real
spawned server (pattern: tests/oom_bypass_closure.rs). Real process RSS and
the detected memory limit aren't controllable from an external test
process, so the suite drives the guard via the MOON_MEM_LIMIT_BYTES test
override documented in mem_monitor's module header:

- Case A: MOON_MEM_LIMIT_BYTES=1MB + --mem-full-pct 50 -> the FIRST SET is
  already refused with MOONERR memfull (init_global's immediate poll, no
  need to wait for the 5s tick).
- Case B: GET is never blocked while the guard is engaged (read-only
  commands are exempt via the existing is_write gate).
- Case C: control — no env override, SET succeeds normally (negative
  control against spurious engagement).
- Case D: --mem-full-pct 0 disables the guard even with a tiny forced
  limit that would otherwise engage it at any nonzero threshold.

RED confirmed by running this suite against a binary built BEFORE the
mem_monitor implementation (git stash of all impl commits, rebuild, all 4
cases fail with "server never accepted" because --mem-full-pct doesn't
parse yet); GREEN confirmed by unstashing and rebuilding — all 4 pass on
both the default (monoio) and runtime-tokio,jemalloc feature sets.

Also fixes 3 pre-existing test files that construct ServerConfig via an
EXHAUSTIVE struct literal (no ..Default::default() spread), which the new
mem_full_pct field broke at compile time: tests/mq_integration.rs,
tests/txn_kv_wiring.rs, tests/workspace_integration.rs (2 occurrences).
Each gets mem_full_pct: 0 (disabled) next to its existing
disk_free_min_pct: 5 line — these tests don't exercise the RSS watchdog and
0 preserves their prior (guard-absent) behavior exactly.

author: Tin Dang
Adds the [Unreleased] entry for the mem_monitor RSS watchdog (wave 3 of the
RSS/CPU/OOM train) — summarizes the guard's semantics, the new
--mem-full-pct flag, MOONERR memfull, and the new INFO fields, matching the
format of the existing diskfull/OOM-bypass entries in this section.

author: Tin Dang
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a proactive RSS-based write watchdog (mem_monitor) with hysteresis, gated by a new --mem-full-pct config option. Wires initialization at startup, periodic polling in the shard event loop, dispatch-level write blocking with a dedicated memfull error, new INFO metrics, and integration tests.

Changes

Memory Watchdog Feature

Layer / File(s) Summary
Config option and limit detection visibility
src/config.rs
Adds mem_full_pct: u8 (default 95) to ServerConfig and makes detect_memory_limit_bytes pub(crate) across Linux/macOS/other platform variants.
MemMonitor core and global singleton
src/shard/mem_monitor.rs, src/shard/mod.rs
Implements MemMonitor with atomic RSS/paused state, hysteresis-based poll(), a global OnceLock singleton with init_global/poll_global, hot-path is_write_paused/global_rss_bytes helpers, and unit tests; registers the new module.
Startup init and dispatch/event-loop wiring
src/main.rs, src/shard/event_loop.rs, src/shard/segment_stall.rs, src/server/conn/handler_monoio/dispatch.rs, src/server/conn/handler_sharded/dispatch.rs
Initializes the watchdog at startup, polls it in the shard event loop, ORs it into the unified write-stall check, and returns a MOONERR memfull error for blocked writes in both monoio and sharded dispatch paths.
INFO reclamation metrics
src/command/info_reclamation.rs
Adds RECL_MEM_RSS_BYTES/RECL_MEM_WATCHDOG_ACTIVE atomics, exposes reclamation_mem_rss_bytes/reclamation_mem_watchdog_active INFO fields, ORs the watchdog into reclamation_write_stall_active, and updates/adds tests.
Integration tests and changelog
tests/mem_watchdog.rs, tests/mq_integration.rs, tests/txn_kv_wiring.rs, tests/workspace_integration.rs, CHANGELOG.md
Adds a new integration test suite exercising memfull blocking/read exemption/negative control/disable cases, sets mem_full_pct: 0 in other test harnesses, and documents the feature.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Dispatch
  participant SegmentStall
  participant MemMonitor
  participant EventLoop

  EventLoop->>MemMonitor: poll_global() every 5s (shard 0)
  MemMonitor->>MemMonitor: sample RSS, update hysteresis state

  Client->>Dispatch: write command (e.g. SET)
  Dispatch->>SegmentStall: is_any_write_stall_active()
  SegmentStall->>MemMonitor: is_write_paused()
  MemMonitor-->>SegmentStall: true/false
  SegmentStall-->>Dispatch: stall active?
  alt memory watchdog active
    Dispatch-->>Client: MOONERR memfull
  else disk pressure active
    Dispatch-->>Client: MOONERR diskfull
  else other stall
    Dispatch-->>Client: MOONERR busy
  else no stall
    Dispatch-->>Client: command executed
  end
Loading

Related issues: None found in the provided context.

Related PRs: None found in the provided context.

Suggested labels: enhancement, reliability, memory-management

Suggested reviewers: None found in the provided context.

🐰 A moon-lit watchdog sniffs at RAM,
Pausing writes before the fatal jam,
Hysteresis keeps it calm and steady,
Memfull errors, tests are ready,
Wave three lands — the burrow's safe again!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description has useful detail, but it does not follow the required template and is missing Checklist, Performance Impact, and Notes sections. Rewrite the description to match the template with Summary, Checklist, Performance Impact, and Notes sections filled in.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: a new proactive RSS watchdog that pauses writes before OOM.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/rss-cpu-oom-wave3

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

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

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

1-1066: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Split src/config.rs into smaller modules src/config.rs is already 1,902 lines, past the 1,500-line limit.

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

In `@src/config.rs` around lines 1 - 1066, The config module is too large and
needs to be split into smaller pieces to satisfy the size limit. Move coherent
groups from ServerConfig and its helpers (such as resolve_dir,
apply_memory_guardrail, default_data_dir, parse_size, and
detect_memory_limit_bytes) into focused submodules under config, then re-export
or wire them back through src/config.rs so public behavior stays unchanged. Keep
ServerConfig as the entry point, but reduce src/config.rs by extracting the
persistence, memory-guardrail, and parsing logic into separate modules with
clear names.

Source: Coding guidelines

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

Inline comments:
In `@src/command/info_reclamation.rs`:
- Around line 436-462: The `info_reclamation_mem_watchdog_wirable` test mutates
`RECL_MEM_WATCHDOG_ACTIVE` without isolation, so it can race with other
atomic-mutating tests like `info_reclamation_write_stall_default_false` and
cause flaky assertions in `write_reclamation_section`. Add shared serialization
around these tests, either by guarding the body with a common `Mutex` or by
marking the relevant `info_reclamation_*` tests as serial, so the store/restore
pattern runs without parallel interference.

---

Outside diff comments:
In `@src/config.rs`:
- Around line 1-1066: The config module is too large and needs to be split into
smaller pieces to satisfy the size limit. Move coherent groups from ServerConfig
and its helpers (such as resolve_dir, apply_memory_guardrail, default_data_dir,
parse_size, and detect_memory_limit_bytes) into focused submodules under config,
then re-export or wire them back through src/config.rs so public behavior stays
unchanged. Keep ServerConfig as the entry point, but reduce src/config.rs by
extracting the persistence, memory-guardrail, and parsing logic into separate
modules with clear names.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ef3d76df-a23c-4b37-b93f-44a4cb35ad81

📥 Commits

Reviewing files that changed from the base of the PR and between f744f6f and 2e88157.

📒 Files selected for processing (14)
  • CHANGELOG.md
  • src/command/info_reclamation.rs
  • src/config.rs
  • src/main.rs
  • src/server/conn/handler_monoio/dispatch.rs
  • src/server/conn/handler_sharded/dispatch.rs
  • src/shard/event_loop.rs
  • src/shard/mem_monitor.rs
  • src/shard/mod.rs
  • src/shard/segment_stall.rs
  • tests/mem_watchdog.rs
  • tests/mq_integration.rs
  • tests/txn_kv_wiring.rs
  • tests/workspace_integration.rs

Comment on lines +436 to +462
/// Default sentinel for the new Wave-3 `mem_watchdog_active` field must
/// be `false`, and setting `RECL_MEM_WATCHDOG_ACTIVE` must both flip its
/// own field AND OR into the aggregate `write_stall_active` — mirrors
/// `info_reclamation_wal_bytes_wirable`'s store/assert/restore shape.
#[test]
fn info_reclamation_mem_watchdog_wirable() {
let mut buf = String::new();
write_reclamation_section(&mut buf);
assert!(
buf.contains("reclamation_mem_watchdog_active:false\r\n"),
"default mem_watchdog_active must be false"
);

RECL_MEM_WATCHDOG_ACTIVE.store(1, Ordering::Relaxed);
let mut buf2 = String::new();
write_reclamation_section(&mut buf2);
RECL_MEM_WATCHDOG_ACTIVE.store(0, Ordering::Relaxed); // restore default
assert!(
buf2.contains("reclamation_mem_watchdog_active:true\r\n"),
"RECL_MEM_WATCHDOG_ACTIVE store must be visible in its own field"
);
assert!(
buf2.contains("reclamation_write_stall_active:true\r\n"),
"RECL_MEM_WATCHDOG_ACTIVE must OR into the aggregate write_stall_active"
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files src/command/info_reclamation.rs
wc -l src/command/info_reclamation.rs
sed -n '1,260p' src/command/info_reclamation.rs
printf '\n---\n'
sed -n '260,520p' src/command/info_reclamation.rs
printf '\n--- search shared atomics/tests ---\n'
rg -n "RECL_MEM_WATCHDOG_ACTIVE|RECL_WRITE_STALL_ACTIVE|write_stall_active|mem_watchdog_active|serial_test|Mutex|test" src/command/info_reclamation.rs

Repository: pilotspace/moon

Length of output: 23746


Protect these atomic-mutating tests from parallel execution. info_reclamation_mem_watchdog_wirable mutates RECL_MEM_WATCHDOG_ACTIVE without isolation, so it can race with info_reclamation_write_stall_default_false and make the default reclamation_write_stall_active:false assertion flaky. Wrap the test body in a shared Mutex or mark these tests serial.

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

In `@src/command/info_reclamation.rs` around lines 436 - 462, The
`info_reclamation_mem_watchdog_wirable` test mutates `RECL_MEM_WATCHDOG_ACTIVE`
without isolation, so it can race with other atomic-mutating tests like
`info_reclamation_write_stall_default_false` and cause flaky assertions in
`write_reclamation_section`. Add shared serialization around these tests, either
by guarding the body with a common `Mutex` or by marking the relevant
`info_reclamation_*` tests as serial, so the store/restore pattern runs without
parallel interference.

@pilotspacex-byte pilotspacex-byte merged commit 9d3a60c into main Jul 6, 2026
18 of 19 checks passed
TinDang97 added a commit that referenced this pull request Jul 6, 2026
…for quickwins_red_api

Main's post-merge CI runs for PR #217/#218/#219 are all red on two
issues this branch's PR (#228) also inherits; both are test-side.

1. test_case_e_cross_db_copy_oom: 0/300 OOM on BOTH CI platforms while
   passing locally (76/300; earlier deflake N/10 -> N/30 saw 28/300).
   The single-shot statistical assert races GAP-1's elastic budget:
   one round of 300x4KB COPYs (~300KB/shard) fits inside the slack the
   elastic budget plus its 100ms-stale usage snapshots can grant, so
   the OOM share is runner-speed-dependent all the way down to zero.
   Rewritten as bounded escalation: up to 8 rounds of COPYs into fresh
   destination keys, stopping early once OOMs appear. The destination
   db's per-shard usage (~2.4MB after all rounds) provably exceeds the
   ABSOLUTE per-shard budget ceiling (compute_elastic_budget returns
   at most base + surplus <= maxmemory = 2MB, and the gate compares
   db.estimated_memory() against it under noeviction), so gate-checked
   COPYs MUST OOM on any runner speed. The RED floor stays exact and
   was re-verified: with the Gap A gate block neutered, 0 OOM through
   all 8 rounds (2400 COPYs) — the assert still discriminates.

2. tests/quickwins_red_api.rs broke the Windows CI leg at COMPILE time:
   qw1_accepted_socket_has_nodelay calls
   moon::server::socket_opts::apply_client_socket_opts, which is
   #[cfg(unix)] (takes AsFd). The test and its TcpListener/TcpStream
   imports are now unix-gated to match the helper.

Validation: oom_bypass_closure 8/8 green on monoio AND tokio+jemalloc
(MOON_NO_URING=1) locally; case E red/green toggled via the gate block.

author: Tin Dang
pilotspacex-byte added a commit that referenced this pull request Jul 6, 2026
…fix pre-existing kill-9 doc loss (#228)

* feat(vector): add BucketedKeyMap — bucketed CoW keymap (256 buckets)

VectorIndex's key_hash -> V maps were single Arc<HashMap<u64, V>>
instances. Under ft-search-off-eventloop, a live FT.SEARCH snapshot
holds an extra Arc clone of the whole map while the event loop keeps
processing writes; the next write's Arc::make_mut then sees refcount
> 1 and full-clones the entire map synchronously on the shard event
loop (~47MB @1m vectors).

BucketedKeyMap<V> shards the map into 256 fixed buckets, each an
independently-cloneable Arc<HashMap<u64, V>>, with bucket selected by
(key_hash >> 56). A concurrent snapshot now pins at most 1/256th of
the map per write instead of the whole thing. Manual Clone impl
avoids a spurious V: Clone bound that #[derive(Clone)] would add.

8 unit tests: get/insert/remove/len round-trip against a reference
HashMap under thousands of random ops, snapshot isolation, and the
key property test — after snap = map.snapshot(), one insert leaves
>= 255 buckets Arc::ptr_eq with the snapshot.

Not yet wired into VectorIndex; that follows in the next commit.

author: Tin Dang

* fix(vector): bucket-scoped CoW for keymap fields

Closes the Arc<HashMap> full-clone amplification under live search
snapshots (see previous commit for BucketedKeyMap rationale).

Converts VectorIndex's three key_hash -> V maps from
Arc<HashMap<u64, V>> to BucketedKeyMap<V>:

  key_hash_to_key:          Arc<HashMap<u64, Bytes>> -> BucketedKeyMap<Bytes>
  key_hash_to_global_id:    Arc<HashMap<u64, u32>>   -> BucketedKeyMap<u32>
  key_hash_to_vec_checksum: Arc<HashMap<u64, u64>>   -> BucketedKeyMap<u64>

and SearchSnapshot.key_hash_to_key to match. All call sites that used
Arc::make_mut(&mut idx.key_hash_to_*).insert/remove(...) now call
.insert/.remove(...) directly on the BucketedKeyMap (the bucket-scoped
CoW happens inside insert()); two SPSC insert paths use the new
get_or_insert_with() helper in place of the former
.entry(...).or_insert_with(...) pattern.

Read paths (FT.SEARCH response building, session filtering, hybrid
search, recovery) take &BucketedKeyMap<Bytes> instead of &HashMap;
these are read-only signature changes with no behavior change since
BucketedKeyMap mirrors HashMap's get/iter/len/is_empty API.

On-disk keymap.bin format is unchanged: run_snapshot_job's persistence
loop only calls .iter()/.get(), and recover_v2.rs rebuilds the map
from the same on-disk entries via a plain insert loop.

Touches: src/vector/store.rs, src/vector/segment/holder.rs,
src/command/vector_search/{ft_search/execute,ft_search/response,
session,hybrid,hybrid_multi,tests}.rs, src/shard/spsc_handler.rs,
src/shard/scatter_hybrid.rs, src/vector/persistence/recover_v2.rs.

author: Tin Dang

* perf(vector): coalescing SnapshotPool — bound the durability write queue

SnapshotPool used an unbounded flume::unbounded::<SnapshotJob>() queue
drained by a single worker. If compact/merge cadence outpaced the
worker, queued jobs pinned every submitter's Arcs (now BucketedKeyMap
buckets) indefinitely, growing unboundedly under sustained load.

Replaced the channel with a coalescing slot: Mutex<HashMap<PathBuf,
SnapshotJob>> + Condvar, keyed by the job's idx_dir (already unique
per index and present on every SnapshotJob, so no new key type is
needed). submit() inserts/overwrites the pending entry for that index
and notifies one worker; a resubmit for an index whose prior job is
still pending (not yet dequeued) replaces it in place, dropping the
stale job's data immediately, so only the newest state per index is
ever persisted. A job already dequeued and running is unaffected by a
concurrent resubmit — the worker loop only removes an entry from the
map at the moment it starts running that job. submit() never blocks
the caller (map insert + notify, no waiting on capacity).

SnapshotJob's three fields are now BucketedKeyMap<Bytes/u32/u64>
(no Arc wrapper) to match VectorIndex's field types from the previous
commit. run_snapshot_job's body is unchanged since it only calls
.iter()/.get() on the map.

3 new tests: same-index submits under a slow worker execute far fewer
than the number submitted (final persisted state is the newest job);
jobs for distinct indexes all execute; an in-flight job survives a
resubmit for the same index (the running job completes normally, the
resubmit becomes the new pending entry).

Also fixes 2 doc_lazy_continuation clippy lints in the new SnapshotPool
doc comment.

author: Tin Dang

* docs(changelog): vector keymap CoW amplification + bounded snapshot queue fix

Records the RSS/CPU wave 4 defects fixed in the previous three commits
under [Unreleased]: BucketedKeyMap replacing the single Arc<HashMap>
keymap fields, and the coalescing SnapshotPool replacing the unbounded
flume queue.

author: Tin Dang

* fix(vector): kill-9 doc loss — FT.COMPACT residue drain + B3 segment-membership gate

Two pre-existing, compounding bugs (both reproduce on main; surfaced
while validating this branch against crash_recovery_vector_durability —
the "wave-4 regression" was actually machine-load timing):

1. force_compact early-return left mutable residue unfrozen.
   When an explicit FT.COMPACT found a background auto-compaction in
   flight, it drained that build, installed it, persisted, and RETURNED
   — without compacting the docs inserted while the background build
   ran (the clone_suffix(frozen_len) mutable tail). Those docs stayed
   mutable-only with no durable segment until some future compact,
   breaking force_compact's full-drain contract (frozen == mutable on
   reply). Observed stuck durable state: segments live=1961, keymap
   entries=2000, converging never. force_compact now falls through to
   the inline drain loop after installing the in-flight build (a no-op
   when the tail is empty).

2. B3 recovery "verified" keymap entries with no backing segment doc.
   The durable keymap-<epoch>.bin covers EVERY indexed key (mutable +
   immutable) at snapshot-submit time, while the paired manifest.json
   lists only installed immutable segments — so after a kill -9 the
   on-disk keymap can be a strict SUPERSET of the on-disk segments
   (bug 1 makes that state persistent; even with bug 1 fixed, a crash
   between a segment install and its async snapshot commit still
   produces it transiently). The B3 dedup rescan treated a keymap
   checksum match as "verified unchanged" and never re-indexed those
   keys: their documents silently vanished from search (post-crash
   num_docs 1950/2000, "2000 key(s) verified unchanged, 0 re-indexed",
   wrong KNN top-1 — decoded from a preserved failing run's artifacts).
   load_segments_and_keymap now builds the union of live key_hashes
   across loaded segments (new ImmutableSegment::live_key_hashes,
   honoring install-time delete_lsn and steady-state tombstones) and
   drops any keymap entry not in it, so the rescan re-indexes those
   keys from the AOF — never wrong, only occasionally slower. A loud
   info line reports the dropped-phantom count.

Tests (each red/green verified by toggling its fix):
- unit: test_force_compact_drains_tail_inserted_during_inflight_bg_build
  stages bug 1 deterministically (in-flight bg build + 30 tail inserts,
  then force_compact; red: 30 docs left mutable, green: 0 and all 130
  segment-resident).
- unit: recover_reindexes_keymap_entries_not_backed_by_any_segment
  stages the window deterministically (rewrites the durable keymap
  with a checksum-matching phantom entry) and asserts the phantom is
  dropped at load, re-indexed on rescan, and searchable end-to-end.
- integration S6 (crash_recovery_vector_durability): kills inside the
  async-snapshot window (manifest >= 2 segments gate, no
  full-durability wait) and asserts only the crash contract:
  num_docs == N and every key answers its own exact-match query.
- S1/S2 flake fix: their re_indexed == 0 assertion silently assumed
  full durability at kill time; a new wait_for_durable_docs pre-kill
  gate (manifest'd segment live-counts AND keymap entries both == N)
  makes the precondition explicit — it is what exposed bug 1's
  never-converging state. Server stdout/stderr logs now open in append
  mode so the pre-crash generation's log survives restart for
  post-mortem diagnosis.

author: Tin Dang

* test(ci): deterministic OOM case E escalation + Windows compile gate for quickwins_red_api

Main's post-merge CI runs for PR #217/#218/#219 are all red on two
issues this branch's PR (#228) also inherits; both are test-side.

1. test_case_e_cross_db_copy_oom: 0/300 OOM on BOTH CI platforms while
   passing locally (76/300; earlier deflake N/10 -> N/30 saw 28/300).
   The single-shot statistical assert races GAP-1's elastic budget:
   one round of 300x4KB COPYs (~300KB/shard) fits inside the slack the
   elastic budget plus its 100ms-stale usage snapshots can grant, so
   the OOM share is runner-speed-dependent all the way down to zero.
   Rewritten as bounded escalation: up to 8 rounds of COPYs into fresh
   destination keys, stopping early once OOMs appear. The destination
   db's per-shard usage (~2.4MB after all rounds) provably exceeds the
   ABSOLUTE per-shard budget ceiling (compute_elastic_budget returns
   at most base + surplus <= maxmemory = 2MB, and the gate compares
   db.estimated_memory() against it under noeviction), so gate-checked
   COPYs MUST OOM on any runner speed. The RED floor stays exact and
   was re-verified: with the Gap A gate block neutered, 0 OOM through
   all 8 rounds (2400 COPYs) — the assert still discriminates.

2. tests/quickwins_red_api.rs broke the Windows CI leg at COMPILE time:
   qw1_accepted_socket_has_nodelay calls
   moon::server::socket_opts::apply_client_socket_opts, which is
   #[cfg(unix)] (takes AsFd). The test and its TcpListener/TcpStream
   imports are now unix-gated to match the helper.

Validation: oom_bypass_closure 8/8 green on monoio AND tokio+jemalloc
(MOON_NO_URING=1) locally; case E red/green toggled via the gate block.

author: Tin Dang

* test(ci): merge racing RECL_SEGMENT_STALL_ACTIVE tests into one

test_recl_segment_stall_active_atomic_exists and
test_is_segment_stall_active_helper both store/load the PROCESS-GLOBAL
RECL_SEGMENT_STALL_ACTIVE atomic while the test harness runs them on
parallel threads within the same binary — the atomic-exists test's
`store(1); load()` observed the helper test's final `store(0)`
interleaving (CI macOS: `left: 0, right: 1` at the read-back assert;
PR #228 round-2 Check (macOS) leg). Pre-existing since MA1 (wave 1);
purely a test-parallelism race, not a stall-gate defect.

All mutation of the global now lives in ONE merged test
(test_recl_segment_stall_active_atomic_and_helper) so there is nothing
left to race with; the default-0 assert is also only valid under that
single-writer arrangement. Ran 5x locally green.

author: Tin Dang

* docs(changelog): note the stall-atomic test-race merge in the CI-fix entry

author: Tin Dang

---------

Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
TinDang97 added a commit that referenced this pull request Jul 6, 2026
…SS/CPU wave 5, item C8)

`tests/sigterm_shutdown.rs`'s `wait_for_ready` was already poll-based
(100ms re-poll against a deadline, matching the pattern used elsewhere in
this repo, e.g. `crash_recovery_vector_durability`'s `wait_ready`) — but
both call sites hardcoded a fixed 15s deadline independently. PR #218
documented "server did not become ready within 15s" as an observed flake,
not a real readiness regression: a contended CI runner or the documented
OrbStack virtiofs starvation pattern (CLAUDE.md gotchas) can push a cold
server spawn + first-bind past 15s even though the process is healthy.

Introduce a single `READY_TIMEOUT` constant (60s) shared by both
`wait_for_ready` call sites (the main `assert_sigterm_clean_exit_shards`
path and the write-storm test), replacing the two independently-hardcoded
15s literals. This does not slow down the healthy-server case at all --
the poll loop still notices readiness within one 100ms tick of it
actually happening; only a genuinely-broken server (or a truly wedged
CI host) would ever wait out the full deadline. Panic messages now
interpolate the constant instead of hardcoding "15s" so they can't drift
out of sync with the actual value again.

Left the separate post-SIGTERM exit deadline (10s, `assert_sigterm_clean_exit_shards`'s
shutdown-wait loop) untouched -- it's a different flake surface not
implicated by the #218 report, and out of scope for this item.

Tests (verify-and-harden, not a traditional red/green cycle -- this
widens a timeout rather than fixing a logic bug): all 7 sigterm tests
re-run to confirm the constant swap didn't change pass/fail behavior in
the healthy-host case.

Verified: `cargo test --release --test sigterm_shutdown -- --test-threads=1`
(7/7 passed). fmt-clean. `cargo clippy -- -D warnings` (the exact
default-feature CI gate, no `--tests`) is clean; `--tests` surfaces
pre-existing, unrelated warnings across other integration-test files not
touched here (config.rs default-then-assign, doc-list indentation in
xshard_fastpath_api.rs, etc.) -- confirmed pre-existing, not introduced
by this change.

author: Tin Dang
pilotspacex-byte added a commit that referenced this pull request Jul 7, 2026
* test(shard): fix two Windows-only main-CI test failures

Main's post-merge Check (Windows) job (runs only on main pushes, so PR
CI never exercised it) failed with two lib-test failures at 841f8f2:

1. shard::mem_monitor::tests::test_poll_real_process_smoke asserted
   rss_bytes() > 0 after a real poll(), but get_rss_bytes() only has
   real implementations on Linux (/proc/self/statm) and macOS (mach
   task_info); all other platforms get the documented `0` fallback, so
   the assertion can never hold on Windows. Gate the test with
   cfg(any(target_os = "linux", target_os = "macos")).

2. shard::scatter_aggregate::tests::
   test_scatter_text_aggregate_single_shard_skips_spsc panicked with
   "ShardSlice not initialized on this thread". The single-shard fast
   path calls with_shard, which requires init_shard on the current
   thread — the test never called it. It passed on Linux/macOS purely
   by libtest thread-scheduling luck: an earlier test on the same
   harness thread had left an initialized ShardSlice behind. That is a
   latent order-dependent flake everywhere, deterministic on Windows.
   Reproduced locally on macOS by running the test in isolation
   (fresh thread -> same panic). Fix: run the test body on a fresh OS
   thread, call init_shard there with a minimal fixture, and drive the
   async fn with an explicit current-thread runtime.

   The ShardSliceInit fixture moves from slice.rs's private tests
   module to a shared cfg(test) pub(crate) slice::test_support module
   so both test modules construct slices the same way.

Verified locally on macOS:
- red: cargo test --no-default-features --features
  runtime-tokio,graph,text-index --lib single_shard_skips_spsc
  reproduced the exact Windows panic before the fix; green after.
- touched modules green under all three feature sets (default,
  runtime-tokio,graph,text-index; runtime-tokio,jemalloc).
- cargo fmt --check + clippy -D warnings clean on default and
  Windows feature sets.

author: Tin Dang

* test(ci): fix Windows integration-test failures (binary discovery, diskfull guard, CRLF offsets)

Round 2 of the Windows main-CI cleanup. After the two lib-test fixes,
the dispatched Windows run surfaced the pre-existing integration-tier
failures (present in every main run since at least 2026-06-23; the job
is main-push only so PR CI never sees them). All 12 failing test
binaries reduce to three environmental test-infra bugs:

1. Binary discovery: 8 suites' find_moon_binary() probed
   target/{release,debug}/moon, which never matches on Windows
   (missing .exe suffix) -> "No moon binary found". The probing was
   also the documented stale-binary trap: it ignored CARGO_TARGET_DIR
   and preferred a possibly-stale release binary over the one cargo
   just built. All helpers now fall back to env!("CARGO_BIN_EXE_moon")
   (compile-time path with the right profile, target dir, and platform
   suffix) after the explicit MOON_BIN override; jepsen_lite gains the
   MOON_BIN override it was missing.

2. Diskfull guard: windows-latest images run with <5% free on C:, so
   Moon's disk monitor permanently paused writes in every
   server-spawning suite (MOONERR diskfull instead of OK, seen in
   cross_shard_consistency_red, ft_search_yield_red,
   spsc_wake_floor_red, coordinator_local_leg_durability,
   durability_tests). New MOON_DISK_FREE_MIN_PCT env override for
   --disk-free-min-pct (clap "env" feature; explicit CLI flag still
   wins), exported as "0" by the Windows CI test step and inherited by
   every spawned moon process. TDD: red asserted the env var was
   ignored before the clap attribute landed; test also pins CLI>env
   precedence and the 5 default without the var.

3. CRLF offsets: shardslice_shape.rs::split_off_test_module
   accumulated line offsets as line.len() + 1, assuming a 1-byte \n
   terminator. Windows runners check out with core.autocrlf=true, so
   the split point drifted one byte per line backward and panicked
   slicing inside a multibyte box-drawing comment char. Now uses
   split_inclusive('\n') for byte-exact offsets on both line endings.
   TDD: new split_off_test_module_is_crlf_safe pins the exact split
   offset for LF and CRLF inputs (red: off by 3 on CRLF).

Verified locally on macOS: all six rewritten spawn suites green
(spsc_two_db, shardslice_live, mem_watchdog, txn_kv_wiring,
vector_del_unindex, oom_bypass_closure), shardslice_shape 6/6,
config env-override test red->green, cargo fmt --check clean,
CI-parity clippy clean on default and Windows feature sets.

author: Tin Dang

* test(sigterm): widen readiness poll deadline to tolerate host load (RSS/CPU wave 5, item C8)

`tests/sigterm_shutdown.rs`'s `wait_for_ready` was already poll-based
(100ms re-poll against a deadline, matching the pattern used elsewhere in
this repo, e.g. `crash_recovery_vector_durability`'s `wait_ready`) — but
both call sites hardcoded a fixed 15s deadline independently. PR #218
documented "server did not become ready within 15s" as an observed flake,
not a real readiness regression: a contended CI runner or the documented
OrbStack virtiofs starvation pattern (CLAUDE.md gotchas) can push a cold
server spawn + first-bind past 15s even though the process is healthy.

Introduce a single `READY_TIMEOUT` constant (60s) shared by both
`wait_for_ready` call sites (the main `assert_sigterm_clean_exit_shards`
path and the write-storm test), replacing the two independently-hardcoded
15s literals. This does not slow down the healthy-server case at all --
the poll loop still notices readiness within one 100ms tick of it
actually happening; only a genuinely-broken server (or a truly wedged
CI host) would ever wait out the full deadline. Panic messages now
interpolate the constant instead of hardcoding "15s" so they can't drift
out of sync with the actual value again.

Left the separate post-SIGTERM exit deadline (10s, `assert_sigterm_clean_exit_shards`'s
shutdown-wait loop) untouched -- it's a different flake surface not
implicated by the #218 report, and out of scope for this item.

Tests (verify-and-harden, not a traditional red/green cycle -- this
widens a timeout rather than fixing a logic bug): all 7 sigterm tests
re-run to confirm the constant swap didn't change pass/fail behavior in
the healthy-host case.

Verified: `cargo test --release --test sigterm_shutdown -- --test-threads=1`
(7/7 passed). fmt-clean. `cargo clippy -- -D warnings` (the exact
default-feature CI gate, no `--tests`) is clean; `--tests` surfaces
pre-existing, unrelated warnings across other integration-test files not
touched here (config.rs default-then-assign, doc-list indentation in
xshard_fastpath_api.rs, etc.) -- confirmed pre-existing, not introduced
by this change.

author: Tin Dang

* test(shard): gate RSS-dependent mem_watchdog cases to Linux/macOS

Round 3 of the Windows main-CI cleanup. After round 2, the Windows job
is down from 12 failing test binaries to one: mem_watchdog cases A and
B assert the memfull guard ENGAGES with a tiny fake limit — but the
guard reads real process RSS via get_rss_bytes(), which returns the
documented 0 fallback on Windows. RSS 0 means 0% used, so the guard is
structurally inert there and the engage-assertions can never pass:
the exact reasoning that already gated the lib smoke test
(test_poll_real_process_smoke) in round 1. Cases C and D assert the
guard-disabled directions, which hold trivially at RSS 0 — they keep
running on Windows.

The macOS leg's sigterm_clean_exit_default 15s-readiness flake is
fixed by the cherry-picked wave-5 item C8 commit (shared READY_TIMEOUT
60s), which lands just before this one.

Verified locally on macOS: mem_watchdog + sigterm_shutdown 11/11,
cargo fmt --check clean.

author: Tin Dang

---------

Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
pilotspacex-byte added a commit that referenced this pull request Jul 7, 2026
…F writer, hygiene batch (#232)

* perf(vector): mmap exact-rerank f16 sidecar on segment reload (RSS/CPU wave 5, item A)

A segment reloaded from disk (`read_immutable_segment`) used to `fs::read`
the entire `raw_f16.bin` exact-rerank sidecar (HQ-1) into a fresh
`Vec<u16>`, on top of the identical bytes the segment already wrote when it
was compacted/merged. That doubles resident vector memory for any
reload-heavy deployment (warm restarts, segment promotion) — the biggest
single RSS win identified in the RSS/CPU remediation review.

Introduce `RawF16Store` (`src/vector/segment/raw_f16_store.rs`): an enum
over `Owned(Vec<u16>)` (freshly-built segments keep their existing owned
buffer, zero behavior change) and `Mapped { mmap, len }` (reloaded segments
memory-map `raw_f16.bin` instead and hand out a zero-copy `&[u16]` view
backed by the kernel page cache — RSS only grows for pages the rerank path
actually touches). `ImmutableSegment::raw_f16()` keeps its exact public
signature (`Option<&[u16]>`), so every existing call site (rerank_exact,
the AE-1 adaptive-ef estimator, GraphUnion merge, FT.INFO) is unchanged.

The mmap soundness contract mirrors the existing warm-segment
`sealed_mmap` module and the CSR mmap loader (`graph::csr::mmap`) already
in this codebase: `raw_f16.bin` is written once via
`write_immutable_segment_staged`'s staged-directory -> final-directory
atomic rename, and a concurrent GC removal of a superseded segment
directory is safe even while a reader still holds an open mmap (POSIX
unlink semantics keep the inode's pages alive until the last mmap drops).

Two new `unsafe` blocks (both isolated in `raw_f16_store.rs`, each with a
SAFETY comment): `Mmap::map` (read-only file mapping) and a
`slice::from_raw_parts` reinterpret of the mapped bytes as `&[u16]`
(sound because the mmap base is always page-aligned, and the file is
written as little-endian halves on moon's only little-endian target
architectures). Flagged for user approval in tmp/wave5/SUMMARY.md.

Also lands the item-A hygiene follow-up flagged during the mmap
investigation: `src/text/posting.rs`'s `PostingList::term_freqs`/
`positions` grow to the peak document count ever seen for a term and are
never released (the `postings` HashMap entry is intentionally kept
forever, even for a term with zero live docs, per the existing
`remove_doc` contract). `remove_doc` now `shrink_to_fit()`s a posting's
buffers once its last live document is removed, reclaiming peak capacity
for terms that go idle without changing the survive-forever entry
contract or any observable tf/doc_freq/search output.

Tests (red/green TDD):
- `test_reload_raw_f16_sidecar_uses_mmap_and_matches_owned_rerank`
  (segment_io.rs): red without the mmap wiring (`raw_f16_is_mapped()` was
  always false); green after — also pins byte-identical sidecar content
  and identical `search()` output between the Owned and Mapped backing.
- `map_file_*` unit tests (raw_f16_store.rs): roundtrip, size-mismatch
  rejection, missing-file error, empty-file handling.
- `remove_doc_shrinks_now_empty_posting_capacity`,
  `remove_doc_shrinks_now_empty_posting_positions_capacity`,
  `remove_doc_does_not_shrink_still_live_posting` (posting.rs): red without
  the shrink_to_fit() calls (verified by temporarily reverting them).

Verified: full `vector::` lib test module (626 passed), text::posting
tests, and `crash_recovery_vector_durability -- --ignored s1` all green
with MOON_BIN pinned to a fresh `cargo build` debug binary. fmt-clean,
clippy-clean on both default and `runtime-tokio,jemalloc` feature sets.

author: Tin Dang

* perf(persistence): idle-adaptive AOF writer wake cadence (RSS/CPU wave 5, item B)

The 3 steady-state AOF writer loops that need a bounded channel poll to
service the EverySec proactive-fsync deadline check (TopLevel tokio,
PerShard tokio, PerShard monoio) polled at a FIXED cadence forever — 50ms
for the monoio per-shard writer, 200ms for both tokio writers — even when
the server sits completely idle. That is 5-20 wakeups per second per shard
doing nothing but re-checking "is there a message yet" and "has 1s passed
since the last fsync". TopLevel monoio needed no change: it already blocks
on an untimed `rx.recv()`, correct because idle there genuinely means
nothing buffered to protect a deadline for.

Introduce `IdleWait` (`src/persistence/aof/writer_task.rs`): a tiny state
machine tracking an escalation step (50ms -> 250ms -> 1s) and a "pending
deadline" flag. `current()` gives the wait duration for the next poll;
`on_message()` resets to the fast floor; `on_timeout()` escalates UNLESS a
deadline is pending, in which case it stays pinned at the floor.

This is safe with zero latency cost on real traffic: `recv_timeout`/
`tokio::time::timeout(..., recv_async())` race a message against the
deadline, so an incoming write always wakes the loop immediately no matter
how long the current timeout is set — only the "still idle, nothing to
do" re-poll cadence relaxes.

The one invariant that must never regress is the EverySec bound: the
oldest unflushed byte must reach disk within ~1s + one wake, exactly as
under the old fixed cadence. `mark_pending()`/`clear_pending()` enforce
this literally: escalation is refused for as long as (a) a batch was
written under `FsyncPolicy::EverySec` without an immediate fsync (relying
on the proactive deadline check), or (b) `last_fsync` was manually
back-dated by the F6 post-fold drain trick (both TopLevel-tokio Rewrite/
RewriteSharded paths and the PerShard-monoio RewritePerShard path) — the
back-dating comments explicitly promise a "~150ms total" post-fold window
assuming a floor-speed next wake, so escalating away from the floor before
that fires would have silently widened the window. `FsyncPolicy::Always`
never buffers past its own same-iteration fsync and `FsyncPolicy::No` has
no deadline at all, so neither ever calls `mark_pending` — both escalate
freely once idle, which is correct: nothing time-sensitive to protect.

Tests (red/green TDD): `idle_wait_tests` (5 unit tests) pin the state
machine directly — start-at-floor, escalate-and-cap, message-resets,
pending-blocks-escalation, clearing-resumes-escalation.

Verified (fresh `cargo build --release`, since 3 of the integration
suites below hardcode `./target/release/moon` rather than honoring
`MOON_BIN`/`find_moon_binary` — a pre-existing test-harness quirk, not
touched here):
- `crash_matrix_per_shard_aof -- --ignored` (3/3)
- `crash_matrix_per_shard_bgrewriteaof -- --ignored` (2/2)
- `wal_group_commit --include-ignored` (13/13, incl. both sigkill
  integration tests)
- `coordinator_local_leg_durability --include-ignored` (7/7)
- `crash_recovery_vector_durability -- --ignored s1` (MOON_BIN pinned)
- `cargo test --lib -- persistence::aof::` under both default and
  `runtime-tokio,jemalloc` feature sets (50 / 56 passed)

Also discovered (not caused by this change — confirmed by a 3x baseline
run on the item-A-only commit before this one, which reproduced the same
~2/3 failure rate): `aof_fsync_err_subscribe_ordering`'s
`_multi_shard` case is flaky independent of these edits. Left untouched;
noted as a follow-up in tmp/wave5/SUMMARY.md.

fmt-clean, clippy-clean (default and runtime-tokio,jemalloc feature sets).

author: Tin Dang

* perf(persistence): shrink WAL v3 write buffer after an oversized flush (RSS/CPU wave 5, item C1)

`WalWriterV3::buf` (`src/persistence/wal_v3/segment.rs`) starts at 8KB but
is a plain `Vec<u8>` — a single oversized record (e.g. a large
FullPageImage payload written through `append`) grows it via the normal
`Vec` growth policy, and `flush_write`/`rotate_segment` only ever
`.clear()` the buffer afterward. `clear()` does not release capacity, so
one big write pins that peak allocation for the writer's entire lifetime
— `resident_bytes()` (the P10 INFO memory accounting hook) would report
the high-water mark forever, not the steady-state working set.

Add a `WAL_BUF_SHRINK_THRESHOLD` (4x the 8KB default): both places that
drain the buffer (`flush_write`'s normal path and `rotate_segment`'s
end-of-segment flush) now `shrink_to(DEFAULT_WAL_BUF_CAPACITY)` once
capacity exceeds the threshold. `shrink_to` is a no-op when already at or
below the target, so steady small-record traffic (the common case) never
pays a reallocation.

Tests (red/green TDD): `test_buffer_shrinks_after_flush_following_large_record`
appends a 100KB record, confirms `resident_bytes()` exceeds the
threshold, flushes, and asserts the buffer drops back to the default
capacity — failed to compile without the new constants/shrink call
(red), passes after (green).
`test_buffer_does_not_shrink_below_threshold` pins the common case: small
records never trip the shrink path.

Verified: `cargo test --lib persistence::wal_v3::segment` (15/15 passed).
fmt-clean, clippy-clean (default features; no runtime-specific code
touched so no separate tokio-feature run needed for this file).

author: Tin Dang

* perf(shard): SmallVec the per-tick elastic-budget shard snapshot (RSS/CPU wave 5, item C3)

`ShardDatabases::recompute_elastic_budget` (`src/shard/shared_databases.rs`)
is called from every shard's 100ms eviction tick and used to `collect()` a
fresh `Vec<usize>` snapshot of all shards' published memory on every call
— one heap allocation per shard per tick (num_shards allocations every
100ms cluster-wide), just to hand a `&[usize]` to
`compute_elastic_budget`.

Switch the snapshot to `SmallVec<[usize; 16]>`: deployments with <=16
shards (the overwhelming common case) now do this entirely on the stack;
larger shard counts still spill to one heap allocation per call, same as
before — no regression, pure win for the common case.
`compute_elastic_budget`'s signature (`&[usize]`) is unchanged; `SmallVec`
derefs to a slice so the call site needs no other changes.

Tests (red/green TDD, adapted for a container-swap change with no logic
delta): added
`recompute_elastic_budget_correct_beyond_smallvec_inline_capacity`, a
20-shard scenario that exercises the SmallVec heap-spill boundary (>16
inline capacity) to pin that the swap never truncates or reorders shard
readings once it spills. Existing
`recompute_elastic_budget_hot_shard_borrows_idle_headroom` and
`recompute_elastic_budget_disabled_for_single_shard_or_unlimited` continue
to cover the inline (<=16) path unchanged.

Verified: `cargo test --lib shard::shared_databases::tests` (7/7 passed).
fmt-clean, clippy-clean on both default and `runtime-tokio,jemalloc`
feature sets (smallvec is a pre-existing workspace dependency, used
elsewhere e.g. HNSW search).

author: Tin Dang

* feat(scripting,admin): expose Lua script-cache byte estimate via INFO/MEMORY DOCTOR (RSS/CPU wave 5, item C4)

`ScriptCache` (`src/scripting/cache.rs`) is an unbounded per-shard
`HashMap<String, Bytes>` — by design, matching Redis (`SCRIPT FLUSH` is
the only eviction path; no silent eviction was ever appropriate here).
But it was also completely invisible to observability: MEMORY DOCTOR and
the Prometheus `moon_memory_bytes` gauge accounted for DashTable/HNSW/
CSR/replication-backlog but folded any Lua cache growth silently into
"allocator overhead," making a large `EVAL`/`SCRIPT LOAD` workload
undiagnosable from the outside.

Add `ScriptCache::resident_bytes()` — sum of hex-SHA1 key length + script
body length per entry (an estimate; excludes `HashMap`/`String`/`Bytes`
allocator bookkeeping, consistent with how the sibling vector/graph
estimators in this codebase already work). Wire it through the existing
C5/M4 per-shard `ShardStoreMemory` publish pattern: a new `lua: AtomicUsize`
field, refreshed on the same 100ms eviction tick that already refreshes
vector/text/graph (`run_eviction_tick` now takes the shard's
`Rc<RefCell<ScriptCache>>` to read it). `admin/metrics_setup.rs`'s
Prometheus emitter and `command/server_admin.rs`'s `MEMORY DOCTOR` text
output both gained a `lua`/`Lua scripts:` line, folded into their tracked
sum (so "allocator overhead" shrinks by exactly the amount now
attributed correctly).

Tests (red/green TDD): `test_resident_bytes_empty_cache_is_zero` and
`test_resident_bytes_grows_with_entries_and_shrinks_on_flush` pin the new
method directly (0 for an empty cache; exact byte count for 1-2 entries;
back to 0 after `flush()`) — both failed to compile before
`resident_bytes()` existed (red), pass after (green).

Verified: `cargo test --lib -- scripting::cache::tests
shard::shared_databases::tests shard::slice::tests
shard::mq_exec::tests` (29/29) under both default and
`runtime-tokio,jemalloc` feature sets. fmt-clean, clippy-clean
(`-D warnings`) on both feature sets; `cargo build`/`cargo check`
compile clean on both.

author: Tin Dang

* chore(protocol): remove dead parse_single_frame_zc parser (RSS/CPU wave 5, item C5)

`parse_single_frame_zc` (`src/protocol/parse.rs`) was a full RESP2/RESP3
frame parser, superseded by the current two-pass pipeline (`validate_frame`
then `parse_frame_zerocopy`, see `parse()`) but never deleted. It had zero
external callers -- the only references to it were its own recursive
calls for Array/Map/Set/Push nesting. Its private helper `read_decimal_zc`
was in the same boat: used exclusively inside the dead function. The
file-level `#![allow(dead_code)]` had been silently absorbing the warning
this whole time, which is exactly why the spec flagged it for a
verify-and-remove pass rather than a design change.

Verified dead (not just the originally-suspected Map arm, but the entire
150-line function): grepped the whole worktree (`src/`, `tests/`,
`fuzz/fuzz_targets/`) for `parse_single_frame_zc` and `read_decimal_zc` --
every hit was inside `parse.rs` itself, all self-recursive. The RESP fuzz
targets (`resp_parse.rs`, `resp_parse_differential.rs`) only call the
public `parse::parse()` entry point, which has routed through
`parse_frame_zerocopy` since the "defensive Frame::Null on any failure"
rewrite documented at that function's doc comment -- confirming
`parse_single_frame_zc` was leftover from before that rewrite.

Removed both functions (~160 lines). `find_crlf`/`strict_atoi` (its two
other private helpers) stay -- both are still used by the live
`parse_frame_zerocopy` and `validate_frame` paths.

Tests (verify-dead-code TDD per the wave-5 spec, in place of a
traditional red/green cycle for a pure deletion): full
`protocol::parse::tests` module re-run post-removal as the regression
pin -- 50/50 passed, unchanged behavior.

Verified: `cargo test --lib -- protocol::parse::tests` (50/50). fmt-clean,
clippy-clean (`-D warnings`) on both default and `runtime-tokio,jemalloc`
feature sets. Fuzz targets compile-check clean (grep-verified no
reference to the removed functions; `cargo-fuzz` itself needs nightly and
wasn't re-run, per repo convention).

author: Tin Dang

* docs: document jemalloc decay/arena tuning policy (RSS/CPU wave 5, item C6)

Audited `src/main.rs` for jemalloc decay/arena drift: the baked-in static
`_rjem_malloc_conf` export (used at process init) and the
`--memory-arenas-cap` re-spawn override (`maybe_respawn_with_arena_override`)
already carry the byte-identical tuning string --
`background_thread:true,metadata_thp:auto,dirty_decay_ms:1000,muzzy_decay_ms:5000,abort_conf:true`
-- with only `narenas` substituted for the operator's requested cap. No
drift, no duplicated-with-different-values config to reconcile; grepped
`config.rs`, `runtime/mod.rs`, `admin/metrics_setup.rs`, and
`command/server_admin.rs` for any second/conflicting decay definition --
none exists.

What WAS missing: none of this was documented for operators. CLAUDE.md's
Environment Variables section lists `_RJEM_MALLOC_CONF`-adjacent knobs
(`--memory-arenas-cap` is only documented in `config.rs`'s CLI help
string) but never explains the actual decay policy or the env var an
operator would need to override it. Added a `_RJEM_MALLOC_CONF` entry:
what the baked-in defaults are, why 1s dirty-page decay + a background
reclaim thread matter for RSS (freed-but-idle pages return to the OS
promptly instead of sitting in jemalloc's caches), the exec-before-init
timing constraint `--memory-arenas-cap` depends on, and the existing
"operator env wins" guard.

Docs-only change per the wave-5 spec's guidance for this item ("if no
tuning-code drift exists, add MALLOC_CONF guidance to docs only --
config hygiene only, measure nothing"). No code touched; no tests to
run.

author: Tin Dang

* docs: audit tokio 1ms shard tick idle cost, no change needed (RSS/CPU wave 5, item C7)

Audited the tokio-runtime shard event loop's 1ms `periodic_interval` tick
(`src/shard/event_loop.rs` ~1292, handler in
`spsc_handler::drain_spsc_shared`) for idle-cost waste, the same class of
problem item B fixed for the AOF writer's poll cadence.

Found the tick is already cheap when idle:
- `drain_spsc_shared`'s per-consumer drain loop is a non-blocking
  `try_pop()` that returns `None` immediately when a consumer's queue is
  empty -- O(num_consumers) pointer checks, zero allocation (scratch
  `Vec`s are thread-local and reused via `mem::take`).
- Every side effect downstream of the drain is already gated behind a
  cheap conditional: WAL re-notify only `if hit_cap`, autovacuum schedule
  persist only `if is_dirty()`, CDC registration only
  `if !pending_cdc_subscribes.is_empty()`, migrations only when
  `pending_migrations` is non-empty.
- The one unconditional per-tick cost, `cached_clock.update()`, is a
  single `clock_gettime` call by design -- its own doc comment states
  it's "the ONE place per shard that actually calls clock_gettime,"
  exactly the "Timestamp caching" design decision CLAUDE.md documents.

Unlike item B's AOF writer (which polled a channel purely to catch an
EverySec deadline with no inherent latency contract on the poll interval
itself), this 1ms cadence IS the latency contract: CLAUDE.md's Key Design
Decisions calls it out explicitly ("Low-latency append via in-memory
buffer flushed on 1ms tick"). Escalating it while idle, the way item B
escalates the AOF writer's poll, would directly widen the WAL-flush
latency bound it exists to hold -- there is no "idle" for a write-latency
SLA in the way there is for an EverySec fsync deadline.

Disposition: SKIP code change. No unsafe cheap-skip exists beyond what's
already there; a real fix (event-driven WAL flush trigger + a much
longer backstop timer, replacing the periodic tick's role entirely) is
an architectural change, not a hygiene-item-sized fix -- documented here
as a follow-up for a future wave if idle CPU from this specific tick is
ever measured as material (not attempted in this audit; no regression
possible since nothing was changed).

author: Tin Dang

* test(sigterm): widen readiness poll deadline to tolerate host load (RSS/CPU wave 5, item C8)

`tests/sigterm_shutdown.rs`'s `wait_for_ready` was already poll-based
(100ms re-poll against a deadline, matching the pattern used elsewhere in
this repo, e.g. `crash_recovery_vector_durability`'s `wait_ready`) — but
both call sites hardcoded a fixed 15s deadline independently. PR #218
documented "server did not become ready within 15s" as an observed flake,
not a real readiness regression: a contended CI runner or the documented
OrbStack virtiofs starvation pattern (CLAUDE.md gotchas) can push a cold
server spawn + first-bind past 15s even though the process is healthy.

Introduce a single `READY_TIMEOUT` constant (60s) shared by both
`wait_for_ready` call sites (the main `assert_sigterm_clean_exit_shards`
path and the write-storm test), replacing the two independently-hardcoded
15s literals. This does not slow down the healthy-server case at all --
the poll loop still notices readiness within one 100ms tick of it
actually happening; only a genuinely-broken server (or a truly wedged
CI host) would ever wait out the full deadline. Panic messages now
interpolate the constant instead of hardcoding "15s" so they can't drift
out of sync with the actual value again.

Left the separate post-SIGTERM exit deadline (10s, `assert_sigterm_clean_exit_shards`'s
shutdown-wait loop) untouched -- it's a different flake surface not
implicated by the #218 report, and out of scope for this item.

Tests (verify-and-harden, not a traditional red/green cycle -- this
widens a timeout rather than fixing a logic bug): all 7 sigterm tests
re-run to confirm the constant swap didn't change pass/fail behavior in
the healthy-host case.

Verified: `cargo test --release --test sigterm_shutdown -- --test-threads=1`
(7/7 passed). fmt-clean. `cargo clippy -- -D warnings` (the exact
default-feature CI gate, no `--tests`) is clean; `--tests` surfaces
pre-existing, unrelated warnings across other integration-test files not
touched here (config.rs default-then-assign, doc-list indentation in
xshard_fastpath_api.rs, etc.) -- confirmed pre-existing, not introduced
by this change.

author: Tin Dang

* fix(vector): mapped raw_f16 sidecar must not count as resident memory

Deep-review follow-up to item A (mmap exact-rerank sidecar):
ImmutableSegment::resident_bytes() still charged a Mapped sidecar at
its full len*2 bytes. Mapped pages are kernel page cache — reclaimable
under memory pressure, not pinned heap — so counting them as resident
made the elastic memory budget / eviction pipeline behave as if the
mmap RSS win never happened for reloaded segments (over-reporting is
the safe direction, but it silently negates the feature's benefit in
Moon's own accounting).

New RawF16Store::resident_bytes() (Owned = full buffer, Mapped = 0),
used by resident_bytes(). Pinned by a unit test (Owned=200/Mapped=0
for 100 halves) and an integration assert in the reload-parity test
(owned segment reports at least sidecar-bytes more than the reloaded
mapped one).

Also corrects three stale comments in writer_task.rs claiming the
idle ladder "starts at 200ms" — AOF_IDLE_WAIT_STEPS' floor is 50ms
(tighter than the old fixed 200ms tokio cadence right after activity,
escalating to 1s once idle).

author: Tin Dang

* docs(vector): move SAFETY markers within audit range of unsafe blocks

CI's Lint job runs scripts/audit-unsafe.sh, which requires the literal
"SAFETY:" marker within the 3 lines immediately above each unsafe
block. Both raw_f16_store.rs comments had the marker at the TOP of a
longer explanatory block (5 and 13 lines above the unsafe), so the
audit flagged them as missing despite full SAFETY documentation.

Restructured both: the detailed invariant explanation stays, and a
one-line "// SAFETY: ..." summary now sits directly above each unsafe
expression. audit-unsafe.sh passes locally (257/257); no code change.

author: Tin Dang

---------

Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants