Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added — memory + tier accounting spine (kernel M2 stage 2 / K4)

`resident_bytes()` is now implemented by every storage plane, and the
elastic memory budget's used-term (`ShardDatabases::recompute_elastic_budget`,
GAP-1/PR #170) folds in all of them — previously kv+vector only.

- `TextStore`/`TextIndex::resident_bytes()`: posting lists, term
dictionaries, FST fuzzy/prefix sidecars, per-document bookkeeping maps,
and TAG/NUMERIC secondary indexes. FTS memory was hard-coded 0
everywhere it was published (elastic budget, MEMORY DOCTOR, Prometheus)
until this change. **Data-size-independent incremental accumulator**
(the publish-site read sums cached per-structure totals — O(schema
field count), bounded by `FT.CREATE` definitions, never corpus size;
same contract as `ColdIndex`/graph below) — an initial version was an
O(doc-count +
vocabulary) full-recompute walk invoked unconditionally every 100ms from
the shard eviction tick regardless of `maxmemory`, measured 6.4–21.3ms/call
at 50K–200K docs (>20% of the tick budget, recurring P99 spikes for every
command on that shard). Fixed before merge: `PostingStore`/
`TermDictionary`/`TextIndex` each carry a cached total maintained
incrementally at every mutation site (index/delete/upsert/TAG/NUMERIC
update/FST rebuild), verified against a `#[cfg(test)]` ground-truth
full-walk after a mixed mutation sequence.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- `ColdIndex::resident_bytes()` (KV disk-offload bookkeeping): an O(1)
incremental accumulator (not a per-tick walk — sized for G2's "10x RAM"
scale target), charged into the shard's published KV memory.
- Graph resident bytes (already computed) now also feed the elastic
budget's used-term, not just the observability atomic.
- `moon_memory_bytes{kind="text"}` Prometheus gauge + `Text (FTS):` line
in `MEMORY DOCTOR`. Also fixes a pre-existing gap where
`moon_memory_bytes{kind="lua_scripts"}` was emitted but never primed.
- New `src/storage/tier.rs`: `ResidencyTier` (Hot/WarmReloadable/ColdStub)
+ `TierPolicy` trait skeleton — types only, no plane adoption in this
milestone (that is M4).

No eviction policy semantics changed: this widens what the existing
donor/hot formula sees, not how it decides. Verified against
`eviction_parity`/`eviction_parity_hash_disk_offload` (shards 1 and 4,
including the disk-offload `ColdIndex` path) with no behavior change.

### Fixed — Windows CI: `replication_planes` used un-gated `libc::kill`

`tests/replication_planes.rs`'s `sigkill` helper called `libc::kill`
unconditionally — `libc` is not linked on Windows, so the `Check (Windows)`
job failed to compile the suite on every `main` push since the Wave A merge.
Now cfg-gated exactly like `tests/aof_multidb_kill9.rs` (`Child::kill` on
non-unix). Test-only.

### Fixed — graph CSR segments and text/vector sidecars were not crash-durable (K3, storage-kernel M2 stage 1)

Extracted the vector engine's Stack-B temp+fsync+rename+dir-fsync
Expand Down
22 changes: 18 additions & 4 deletions src/admin/metrics_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,22 +239,31 @@ pub fn init_metrics(
}
}

/// Prime all 7 `moon_memory_bytes{kind=...}` series with `0.0` so they
/// Prime every `moon_memory_bytes{kind=...}` series with `0.0` so they
/// appear in `/metrics` output from the first scrape, even when subsystems
/// are feature-gated off or not yet initialized.
///
/// NOTE: This scrape path intentionally does NOT call `mallctl("epoch")`.
/// See the documented jemalloc leak at the `get_rss_bytes()` doc-comment
/// (~1 MB / 20 s growth). `allocator_overhead` is computed as
/// `max(0, RSS − sum(other 6))` — the same formula MEMORY DOCTOR uses.
/// `max(0, RSS − sum(other kinds))` — the same formula MEMORY DOCTOR uses.
fn prime_moon_memory_bytes() {
for kind in [
"dashtable",
"hnsw",
// K4 (kernel-m2-brief-2026-07-12 stage 2): text (FTS) resident
// bytes -- previously hard-coded 0 at the publish site, so this
// series existed nowhere until now.
"text",
"csr",
"wal",
"sealed",
"replication_backlog",
// Pre-existing gap fixed alongside the "text" addition above:
// `update_moon_memory_bytes` has emitted this kind since C4
// (wave-5 hygiene), but it was never primed, so it silently
// didn't appear in `/metrics` until the first 15s update tick.
"lua_scripts",
"allocator_overhead",
] {
gauge!("moon_memory_bytes", "kind" => kind).set(0.0);
Expand Down Expand Up @@ -1390,6 +1399,9 @@ fn update_moon_memory_bytes() {
let mut dashtable: usize = 0;
let mut hnsw: usize = 0;
let sealed: usize = 0; // combined into hnsw from vector atomic (C5)
// K4 (kernel-m2-brief-2026-07-12 stage 2): text (FTS) resident bytes,
// previously hard-coded 0 at the publish site.
let mut text: usize = 0;
let mut csr: usize = 0;
let wal: usize = 0; // WalWriterV3 is stack-owned; not reachable here
let mut backlog: usize = 0;
Expand All @@ -1400,10 +1412,11 @@ fn update_moon_memory_bytes() {
// C5 / M4: `read_memory_sum()` replaces per-shard `read_db(…)` locks.
dashtable = shard_dbs.read_memory_sum();

// Store memory: sum published per-shard vector/graph atomics.
// Store memory: sum published per-shard vector/text/graph atomics.
// Values are refreshed by each shard's 100ms tick (publish_store_memory).
for mem in shard_dbs.store_memory_per_shard.iter() {
hnsw += mem.vector.load(Ordering::Relaxed);
text += mem.text.load(Ordering::Relaxed);
// graph is cfg-gated at publish time; the atomic is always present.
csr += mem.graph.load(Ordering::Relaxed);
// C4 (wave-5 hygiene): Lua script-cache byte estimate.
Expand All @@ -1418,11 +1431,12 @@ fn update_moon_memory_bytes() {
}
}

let other_sum = dashtable + hnsw + csr + wal + sealed + backlog + lua;
let other_sum = dashtable + hnsw + text + csr + wal + sealed + backlog + lua;
let alloc_overhead = rss.saturating_sub(other_sum);

gauge!("moon_memory_bytes", "kind" => "dashtable").set(dashtable as f64);
gauge!("moon_memory_bytes", "kind" => "hnsw").set(hnsw as f64);
gauge!("moon_memory_bytes", "kind" => "text").set(text as f64);
gauge!("moon_memory_bytes", "kind" => "csr").set(csr as f64);
gauge!("moon_memory_bytes", "kind" => "wal").set(wal as f64);
gauge!("moon_memory_bytes", "kind" => "sealed").set(sealed as f64);
Expand Down
18 changes: 17 additions & 1 deletion src/command/server_admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,27 +411,34 @@ fn memory_doctor() -> Frame {
let csr_bytes: usize;
let wal_bytes: usize = 0;
let lua_bytes: usize;
// K4 (kernel-m2-brief-2026-07-12 stage 2): text (FTS) resident bytes,
// previously hard-coded 0 at the publish site.
let text_bytes: usize;

if let Some(shard_dbs) = crate::admin::metrics_setup::get_global_shard_databases() {
// KV memory: sum of per-shard published atomics. Lock-free.
dashtable_bytes = shard_dbs.read_memory_sum();

// Store memory: sum published per-shard vector/graph atomics.
// Store memory: sum published per-shard vector/text/graph atomics.
let mut vec_total = 0usize;
let mut text_total = 0usize;
let mut csr_total = 0usize;
let mut lua_total = 0usize;
for mem in shard_dbs.store_memory_per_shard.iter() {
vec_total += mem.vector.load(Ordering::Relaxed);
text_total += mem.text.load(Ordering::Relaxed);
csr_total += mem.graph.load(Ordering::Relaxed);
// C4 (wave-5 hygiene): Lua script-cache byte estimate.
lua_total += mem.lua.load(Ordering::Relaxed);
}
hnsw_bytes = vec_total;
text_bytes = text_total;
csr_bytes = csr_total;
lua_bytes = lua_total;
} else {
dashtable_bytes = 0;
hnsw_bytes = 0;
text_bytes = 0;
csr_bytes = 0;
lua_bytes = 0;
}
Expand All @@ -448,6 +455,7 @@ fn memory_doctor() -> Frame {
// ── Computed overhead ────────────────────────────────────────────────
let tracked_sum = dashtable_bytes
+ hnsw_bytes
+ text_bytes
+ csr_bytes
+ wal_bytes
+ sealed_bytes
Expand All @@ -469,6 +477,8 @@ fn memory_doctor() -> Frame {
"DashTable dominates RSS (>50%). Consider increasing --initial-keyspace-hint to reduce segment splits."
} else if hnsw_bytes > half_rss {
"HNSW (vector) dominates RSS (>50%). Consider compacting (FT.COMPACT) or reducing ef_construction."
} else if text_bytes > half_rss {
"Text (FTS) dominates RSS (>50%). Consider FT.COMPACT to build FST sidecars, or reviewing indexed field cardinality."
} else if csr_bytes > half_rss {
"CSR (graph) dominates RSS (>50%). Review graph index sizes."
} else if allocator_overhead > half_rss {
Expand Down Expand Up @@ -502,6 +512,12 @@ fn memory_doctor() -> Frame {
humanize_bytes(hnsw_bytes),
pct(hnsw_bytes, rss)
);
let _ = writeln!(
out,
" Text (FTS): {} ({:.1}%)",
humanize_bytes(text_bytes),
pct(text_bytes, rss)
);
let _ = writeln!(
out,
" CSR (graph): {} ({:.1}%)",
Expand Down
23 changes: 21 additions & 2 deletions src/shard/persistence_tick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,14 @@ pub(crate) fn run_eviction_tick(
s.store_memory
.vector
.store(mutable + immutable, Ordering::Relaxed);
s.store_memory.text.store(0, Ordering::Relaxed); // TextStore has no aggregate API yet
// K4 (kernel-m2-brief-2026-07-12 stage 2): TextStore now has a real
// resident_bytes() aggregate (posting lists, term dicts, FST
// sidecars, TAG/NUMERIC indexes) -- this was hard-coded 0, making
// FTS memory invisible to the elastic budget, MEMORY DOCTOR, and
// Prometheus.
s.store_memory
.text
.store(s.text_store.resident_bytes(), Ordering::Relaxed);
Comment thread
pilotspacex-byte marked this conversation as resolved.
#[cfg(feature = "graph")]
{
let graph_bytes = s.graph_store.resident_bytes();
Expand All @@ -351,10 +358,22 @@ pub(crate) fn run_eviction_tick(
// an O(1) accumulator read). Published unconditionally: MEMORY DOCTOR
// and the Prometheus KV gauge read this atomic even when maxmemory is
// unlimited — gating it on maxmemory > 0 left them at a permanent 0.
//
// K4: also charge each db's ColdIndex (disk-offload bookkeeping RAM
// -- see storage::tiered::cold_index::ColdIndex::resident_bytes doc
// comment). This is a per-db O(1) accumulator read, same complexity
// class as estimated_memory() itself, so folding it in here does not
// change this tick's cost -- and it is intentionally NOT folded into
// Database::estimated_memory()/resident_bytes() themselves, which
// stay untouched O(1) hot-path reads for the per-write eviction
// pre-gate (inline_write_can_skip_eviction / try_evict_if_needed).
let used = crate::shard::slice::with_shard(|s| {
s.databases
.iter()
.map(|db| db.estimated_memory())
.map(|db| {
db.estimated_memory()
+ db.cold_index.as_ref().map_or(0, |ci| ci.resident_bytes())
})
.sum::<usize>()
});
shard_databases.publish_memory(shard_id, used);
Expand Down
57 changes: 57 additions & 0 deletions src/shard/shared_databases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,13 +251,23 @@ impl ShardDatabases {
// siblings while its true footprint was already over base, and the
// pressure cascade then compared a vector-INCLUSIVE used against a
// budget inflated by that donation. Two Relaxed loads per shard.
//
// K4 (kernel-m2-brief-2026-07-12 stage 2): extend the same fix to
// text (FTS) and graph resident bytes — a text- or graph-heavy/
// KV-light shard was exactly as misclassifiable as an idle donor as
// the vector case above (same mechanism, different plane). KV's own
// ColdIndex bytes are already folded into `memory_per_shard` at the
// publish site (persistence_tick.rs), so they flow through here for
// free. Four Relaxed loads per shard.
let used: SmallVec<[usize; 16]> = self
.memory_per_shard
.iter()
.zip(self.store_memory_per_shard.iter())
.map(|(kv, store)| {
kv.load(Ordering::Relaxed)
.saturating_add(store.vector.load(Ordering::Relaxed))
.saturating_add(store.text.load(Ordering::Relaxed))
.saturating_add(store.graph.load(Ordering::Relaxed))
})
.collect();
let budget = crate::storage::eviction::compute_elastic_budget(shard_id, base, &used);
Expand Down Expand Up @@ -1411,6 +1421,53 @@ mod tests {
assert_eq!(shared.recompute_elastic_budget(2, &rt), 100);
}

/// K4 (kernel-m2-brief-2026-07-12 stage 2): the same donor/hot
/// misclassification the vector fix (A4) addressed above applies
/// identically to text (FTS) and graph resident bytes -- the elastic
/// budget's used-term counted kv+vector ONLY until this commit. Mirrors
/// `recompute_elastic_budget_vector_heavy_shard_not_donor` exactly, but
/// splits the "hidden" RAM across text on one shard and graph on
/// another, so both plane wires are exercised in one test.
#[test]
fn recompute_elastic_budget_text_and_graph_heavy_shards_not_donors() {
let shared = new_shared(4, 1);
let rt = rt_config(400, 4); // base = 100 per shard

shared.publish_memory(0, 120); // hot
shared.publish_memory(1, 10); // KV-light...
shared.store_memory_per_shard[1]
.text
.store(200, Ordering::Relaxed); // ...but 200 of FTS RAM
shared.publish_memory(2, 10); // KV-light...
shared.store_memory_per_shard[2]
.graph
.store(200, Ordering::Relaxed); // ...but 200 of graph RAM
shared.publish_memory(3, 10);

// Blind-to-text/graph math: shards 1 and 2 look idle (10 < 100) and
// each donate 90 — surplus 90+90+90=270 to the one hot shard ⇒ 370.
// Aware: shards 1 and 2 are each truly at 210 > base — HOT, not
// donors. Only shard 3 (true 10) donates 90. Three hot shards split
// the 90 surplus ⇒ 100 + 30 = 130 each.
assert_eq!(
shared.recompute_elastic_budget(0, &rt),
130,
"text/graph-heavy shards must not be classified as idle donors"
);
assert_eq!(
shared.recompute_elastic_budget(1, &rt),
130,
"the text-heavy shard itself is hot and shares the pool"
);
assert_eq!(
shared.recompute_elastic_budget(2, &rt),
130,
"the graph-heavy shard itself is hot and shares the pool"
);
// The true idle shard keeps base.
assert_eq!(shared.recompute_elastic_budget(3, &rt), 100);
}

#[test]
fn recompute_elastic_budget_correct_beyond_smallvec_inline_capacity() {
// The per-call `used` snapshot is a `SmallVec<[usize; 16]>` — pin
Expand Down
1 change: 1 addition & 0 deletions src/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod hotkey;
pub mod intset;
pub mod listpack;
pub mod stream;
pub mod tier;
pub mod tiered;

pub use db::Database;
Expand Down
Loading
Loading