Skip to content

Commit 8ddb6e1

Browse files
CodeAbraclaudexnllllh
committed
perf: incremental corpus counters, sort-index inheritance, and steadier graph cache [user-impact-checked]
Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
1 parent 0ccc2a6 commit 8ddb6e1

18 files changed

Lines changed: 736 additions & 113 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.0.1] — 2026-07-10
9+
10+
### Changed
11+
12+
- **Lower idle CPU.** Corpus counters now update incrementally instead of
13+
rescanning, fresh readers inherit the writer's sort index instead of
14+
rebuilding it, and the graph cache no longer re-streams the whole corpus on
15+
ambient captures — cutting background rebuild churn substantially.
16+
817
## [2.0.0] — 2026-07-10
918

1019
### Changed

crates/lilliengine/src/conn.rs

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ pub struct PersistedTableIndex {
9494
/// admits a built one into `id_caches`.
9595
#[serde(default)]
9696
pub id_index: IdIndex,
97+
/// The built ORDER BY index, persisted under the SAME fingerprint so a cold
98+
/// process serves its first ordered scan from the sidecar instead of paying
99+
/// the population scan. Unbuilt when the writer never ran an ordered query
100+
/// on the table; the load path only admits a built one into `ordered_caches`.
101+
#[serde(default)]
102+
pub ordered_index: OrderedColIndex,
97103
}
98104

99105
/// A transferable snapshot of built index pairs (Arc-backed — cloning copies
@@ -102,7 +108,7 @@ pub struct PersistedTableIndex {
102108
/// by [`Connection::adopt_built_indexes`] on a freshly-opened reader.
103109
#[derive(Debug, Clone, Default)]
104110
pub struct IndexSnapshot {
105-
tables: Vec<(String, i64, ColIndex, IdIndex)>,
111+
tables: Vec<(String, i64, ColIndex, IdIndex, OrderedColIndex)>,
106112
}
107113

108114
/// A single result column's description, mirroring the DB-API `description`
@@ -399,7 +405,8 @@ impl Connection {
399405
// `open_read_only` (both route through `from_store`), so the daemon-down
400406
// RO recall reader loads-not-rebuilds for free. The id-index rides the
401407
// same gate, so a warm boot serves `WHERE id = ?` with no build scan.
402-
let (mut col_caches, mut id_caches) = load_col_indexes(&store, &meta, &catalog, &root_map);
408+
let (mut col_caches, mut id_caches, ordered_caches) =
409+
load_col_indexes(&store, &meta, &catalog, &root_map);
403410
// A read-write connection maintains its indexes per committed write, so
404411
// it must own private postings: materialize the copy-on-write clone HERE,
405412
// once, on the open (boot) path — never lazily on the first awake-path
@@ -459,7 +466,7 @@ impl Connection {
459466
id_caches,
460467
conflict_caches: HashMap::new(),
461468
col_caches,
462-
ordered_caches: HashMap::new(),
469+
ordered_caches,
463470
bare_count_cache: HashMap::new(),
464471
parse_cache: HashMap::new(),
465472
})
@@ -528,7 +535,15 @@ impl Connection {
528535
.get(table)
529536
.cloned()
530537
.unwrap_or_default();
531-
tables.push((table.clone(), generation, cidx.clone(), iidx));
538+
// The ordered index rides the same generation stamp: rebuilt from
539+
// scratch by every fresh reader otherwise (its population scan is
540+
// the dominant reopen cost after the col/id indexes are adopted).
541+
let oidx = self
542+
.ordered_caches
543+
.get(table)
544+
.cloned()
545+
.unwrap_or_default();
546+
tables.push((table.clone(), generation, cidx.clone(), iidx, oidx));
532547
}
533548
IndexSnapshot { tables }
534549
}
@@ -540,7 +555,7 @@ impl Connection {
540555
/// table is skipped (the lazy build stays the always-correct fallback);
541556
/// an already-built local index is never overwritten.
542557
pub fn adopt_built_indexes(&mut self, snapshot: &IndexSnapshot) {
543-
for (table, generation, cidx, iidx) in &snapshot.tables {
558+
for (table, generation, cidx, iidx, oidx) in &snapshot.tables {
544559
let already_built = self
545560
.col_caches
546561
.get(table)
@@ -568,9 +583,27 @@ impl Connection {
568583
{
569584
self.id_caches.insert(table.clone(), iidx.clone());
570585
}
586+
if oidx.is_built() && !self
587+
.ordered_caches
588+
.get(table)
589+
.map(|o| o.is_built())
590+
.unwrap_or(false)
591+
{
592+
self.ordered_caches.insert(table.clone(), oidx.clone());
593+
}
571594
}
572595
}
573596

597+
/// True when `table`'s ORDER BY index is resident and built — exposed so
598+
/// integration tests can assert a sidecar load or a snapshot adoption
599+
/// admitted it without paying the population scan.
600+
pub fn ordered_index_is_built(&self, table: &str) -> bool {
601+
self.ordered_caches
602+
.get(table)
603+
.map(|o| o.is_built())
604+
.unwrap_or(false)
605+
}
606+
574607
/// Parse `sql` into its AST, reusing a cached parse when one exists.
575608
///
576609
/// The AST depends only on the SQL text, so a hit returns a clone of the
@@ -702,6 +735,15 @@ impl Connection {
702735
} else {
703736
IdIndex::new()
704737
};
738+
// The ordered index is persisted only when the writer already
739+
// built one (it exists per ORDER BY usage, never force-built) —
740+
// an unbuilt default round-trips as "nothing to adopt".
741+
let ordered_snapshot = self
742+
.ordered_caches
743+
.get(&table)
744+
.filter(|o| o.is_built())
745+
.cloned()
746+
.unwrap_or_default();
705747
let generation = self.meta.col_generation(&self.store, &table)?;
706748
let row_count = tree.count_cells().map_err(open_err)?;
707749
tables.push(PersistedTableIndex {
@@ -711,6 +753,7 @@ impl Connection {
711753
row_count,
712754
index: col_snapshot,
713755
id_index: id_snapshot,
756+
ordered_index: ordered_snapshot,
714757
});
715758
}
716759

@@ -1801,8 +1844,12 @@ fn load_col_indexes(
18011844
meta: &MetaTable,
18021845
catalog: &Catalog,
18031846
root_map: &BTreeMap<String, u32>,
1804-
) -> (HashMap<String, ColIndex>, HashMap<String, IdIndex>) {
1805-
let empty = || (HashMap::new(), HashMap::new());
1847+
) -> (
1848+
HashMap<String, ColIndex>,
1849+
HashMap<String, IdIndex>,
1850+
HashMap<String, OrderedColIndex>,
1851+
) {
1852+
let empty = || (HashMap::new(), HashMap::new(), HashMap::new());
18061853
let sidecar = col_index_sidecar_path(store.path());
18071854
let Some(envelope) = cached_sidecar_envelope(&sidecar) else {
18081855
return empty();
@@ -1812,6 +1859,7 @@ fn load_col_indexes(
18121859
}
18131860
let mut loaded: HashMap<String, ColIndex> = HashMap::new();
18141861
let mut loaded_ids: HashMap<String, IdIndex> = HashMap::new();
1862+
let mut loaded_ordered: HashMap<String, OrderedColIndex> = HashMap::new();
18151863
for entry in &envelope.tables {
18161864
let Some(&root) = root_map.get(&entry.table) else {
18171865
continue;
@@ -1843,9 +1891,15 @@ fn load_col_indexes(
18431891
if entry.id_index.is_built() {
18441892
loaded_ids.insert(entry.table.clone(), entry.id_index.clone());
18451893
}
1894+
// The ordered index rides the SAME fingerprint gate; only a built one
1895+
// (the writer had run an ordered query on the table) is admitted, so a
1896+
// cold process serves its first ORDER BY from the sidecar scan-free.
1897+
if entry.ordered_index.is_built() {
1898+
loaded_ordered.insert(entry.table.clone(), entry.ordered_index.clone());
1899+
}
18461900
loaded.insert(entry.table.clone(), entry.index.clone());
18471901
}
1848-
(loaded, loaded_ids)
1902+
(loaded, loaded_ids, loaded_ordered)
18491903
}
18501904

18511905
/// How many distinct sidecar paths the process-wide decoded-envelope cache

crates/lilliengine/src/eval.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,7 @@ const CLASS_BLOB: u8 = 3;
608608

609609
/// A within-class sort key that orders numbers, text, and blobs correctly while
610610
/// remaining `Ord` (numbers compare by value, including int↔real).
611-
#[derive(Debug, Clone, PartialEq)]
611+
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
612612
pub enum SortKey {
613613
/// The NULL placeholder; never participates in a within-class compare.
614614
Null,

crates/lilliengine/src/exec.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2308,7 +2308,7 @@ type OrderedTag = (u8, SortKey);
23082308
/// decodes unrelated columns such as the embedding BLOB), maintained
23092309
/// incrementally on insert/update/delete (no-op when unbuilt), cleared on
23102310
/// rollback and tainted commit.
2311-
#[derive(Debug, Default, Clone)]
2311+
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
23122312
pub struct OrderedColIndex {
23132313
/// The single column this index covers (e.g. `"ts"` for events).
23142314
column: String,

crates/lilliengine/tests/boot_perf.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,3 +825,56 @@ fn persisted_id_index_serves_warm_boot_without_scan() {
825825
);
826826
}
827827
}
828+
829+
// ---------------------------------------------------------------------------
830+
// persisted_ordered_index_loads_built
831+
//
832+
// Build the ORDER BY index lazily via one ordered read, persist, reopen: the
833+
// fresh connection must hold the ordered index pre-built (no population scan)
834+
// and serve the same rows as the connection that built it.
835+
// ---------------------------------------------------------------------------
836+
#[test]
837+
fn persisted_ordered_index_loads_built() {
838+
fn probe_ordered(conn: &mut Connection) -> Vec<String> {
839+
let mut cur = conn
840+
.execute("SELECT src FROM edges ORDER BY src LIMIT 10", vec![])
841+
.unwrap();
842+
cur.fetchall()
843+
.iter()
844+
.map(|r| match r.get_index(0) {
845+
Some(Value::Text(s)) => s.clone(),
846+
other => panic!("expected text src, got {other:?}"),
847+
})
848+
.collect()
849+
}
850+
851+
let f = EdgeFixture::seeded(50);
852+
let expected: Vec<String>;
853+
{
854+
let mut conn = f.open();
855+
expected = probe_ordered(&mut conn);
856+
assert!(
857+
conn.ordered_index_is_built("edges"),
858+
"the ordered probe must have built the ORDER BY index"
859+
);
860+
conn.persist_col_indexes().unwrap();
861+
}
862+
863+
let mut loaded = f.open();
864+
assert!(
865+
loaded.ordered_index_is_built("edges"),
866+
"a fresh open over the sidecar must admit the ordered index pre-built"
867+
);
868+
assert_eq!(
869+
probe_ordered(&mut loaded),
870+
expected,
871+
"the loaded ordered index must serve the same rows as the builder"
872+
);
873+
874+
let mut loaded_ro = f.open_ro();
875+
assert!(
876+
loaded_ro.ordered_index_is_built("edges"),
877+
"a read-only open must admit the persisted ordered index too"
878+
);
879+
assert_eq!(probe_ordered(&mut loaded_ro), expected);
880+
}

src/iai_mcp/daemon/_watchdog.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def _pkg():
6464
WATCHDOG_FAILURE_DEBOUNCE_N: int = int(
6565
os.environ.get("IAI_MCP_WATCHDOG_FAILURE_DEBOUNCE_N", "3"),
6666
)
67-
# Watchdog ceiling (4.0 GiB). Calibrated against the current
67+
# Watchdog ceiling (4.0 GiB). Recalibrated 2026-06-19 against the post-v9.3
6868
# corpus (25k+ episodic records, 270 MB SQLite, 17+ MB hnsw, plus the steady-state
6969
# warm-on-WAKE cache footprint and the deferred-capture drain loop). The previous
7070
# 2.5 GiB cap was set in mid-June when the corpus + warm caches sat near 1.72 GiB;
@@ -140,16 +140,34 @@ async def _hippea_cascade_loop(
140140
store, shutdown: asyncio.Event, *, _clock=time.monotonic,
141141
) -> None:
142142
from iai_mcp import retrieve
143-
from iai_mcp.daemon_state import load_state, save_state
143+
from iai_mcp.daemon_state import daemon_state_path, load_state, save_state
144144
from iai_mcp.hippea_cascade import _install_warm, compute_and_fetch_warm
145145
from iai_mcp.lock_protocol import check_consolidation_intent
146146
# late import so the package attribute is re-fetched and a monkeypatch stays visible
147147
from iai_mcp.daemon import write_event
148148

149+
# mtime short-circuit: this loop polls a small JSON file around the clock
150+
# and the file is idle almost always — skip the read+parse when it has not
151+
# changed since the last tick and that tick carried no pending request.
152+
_last_state_mtime_ns: "int | None" = None
153+
_last_had_pending = False
149154
while not shutdown.is_set():
150155
try:
151-
state = await asyncio.to_thread(load_state)
152-
req = state.get("hippea_cascade_request") or {}
156+
try:
157+
_mtime_ns = daemon_state_path().stat().st_mtime_ns
158+
except OSError:
159+
_mtime_ns = None
160+
if (
161+
_mtime_ns is not None
162+
and _mtime_ns == _last_state_mtime_ns
163+
and not _last_had_pending
164+
):
165+
req = {}
166+
else:
167+
_last_state_mtime_ns = _mtime_ns
168+
state = await asyncio.to_thread(load_state)
169+
req = state.get("hippea_cascade_request") or {}
170+
_last_had_pending = bool(req.get("pending"))
153171
if req.get("pending"):
154172
elapsed = _clock() - _pkg()._last_cascade_completed_at
155173
if elapsed < HIPPEA_CASCADE_MIN_INTERVAL_SEC:

0 commit comments

Comments
 (0)