Skip to content

feat(storage): load knowledge graphs on first use so restart reads only metadata - #357

Merged
jsam merged 5 commits into
mainfrom
fm/en-en13
Oct 5, 2026
Merged

jsam merged 5 commits into
mainfrom
fm/en-en13

Conversation

@jsam

@jsam jsam commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Intent

I want you to really hyper focus on making the symbolic engine, you know, crisp and really truly production ready. Like I really need you to, you know, put insane amount of effort into doing that.

(Context: plan ~/firstmate/data/nsdl-plan/report.md §6.1, GitHub milestone 7. Item EN-13: fast restart and measured restart time (#135 lazy loading, O(shards) startup) — why: RTO, cell rescheduling, and the pooled test engine (#135 is needed in M2))

Issue #135, "Lazy KG loading with LRU eviction; O(shards) startup": load_all_knowledge_graphs loads every KG eagerly and scans all shards once per KG (O(KGs x shards)), with JSON WAL replay. With one KG per user, startup time and RSS grow with the total data of all users. Group shards by KG once, load on first access, evict idle KGs, and load in parallel. Roadmap checkpoint: shard grouping already exists, but src/naming.rs scans known KG names per shard; measure and replace that ownership lookup rather than only parallelizing eager loading. Implement lazy activation, lifetime pinning for active requests/subscriptions, bounded safe eviction and metadata-only startup. Avoid a per-request LRU mutex; pass warm-query/delta and cold-start/RSS gates.

What Changed

  • Startup now reads only metadata, costing O(shards) however much data there is. It reads the shard metadata in parallel, replays the WAL into shard buffers, flushes them to batch files and rewrites the WAL once. Every knowledge graph then starts dormant, and no batch files are read. The new storage_engine/residency.rs loads a graph on its first use: it reads and consolidates the graph's shards in parallel and applies the graph's WAL catalog changes. Concurrent first uses wait on a single load. Lookups on the hot path take no lock.
  • Two new options can unload graphs that nothing is using, and both are off by default. storage.max_loaded_knowledge_graphs unloads the least recently used graphs once the limit is passed. storage.unload_idle_after_secs drives a background task in the HTTP server that unloads graphs left idle that long. A graph stays loaded while any of these use it: a request handle, a held snapshot, a subscription pin, session schemas, the incremental engine, vector indexes, or catalog changes whose save failed. A reload publishes the same state under the same revision, and a writer that held a retired handle retries on the reloaded copy. /metrics adds three new metrics: inputlayer_knowledge_graphs_loaded, inputlayer_knowledge_graph_loads_total and inputlayer_knowledge_graph_unloads_total.
  • Online .backup exports now capture each graph at its own revision. Graphs in memory are still captured together under a brief commit hold. A graph that isn't loaded is blocked from loading only while its revision and catalogs are read, and its facts are read from disk afterwards. The manifest's revision is now the newest revision in the export. Also adds a restart_benchmarks bench, which measures open time, first-query time and RSS after a clean shutdown and after a crash with a WAL tail. Adds durability and residency tests, and updates the config, persistence, backup, REST API and architecture docs.

Risk Assessment

✅ Low: The round-2 fix is small and closes the relation-drop gap. drop_relation sets catalog_unsaved when its schema or rule save fails. scrub_dropped_relation writes both whole catalog files (a failed RuleCatalog::save keeps dirty set, so a later save retries) and sets the flag from that result. No other in-engine path changes a catalog and saves it outside save_catalogs, replay_catalog, drop_relation or scrub_dropped_relation, so reloadable() now covers every way a catalog can be left unsaved.

Testing

I built the server, client and backup binaries at HEAD and ran a throwaway server (its own data dir, port and config) through nine live scenarios: metadata-only restart, lazy activation, the LRU cap, the idle timer with a subscription keeping its graph loaded, backup/verify/restore with dormant graphs, two failed-save cases (rule save and relation-drop save) made by removing write permission, a timed restart with 205 graphs, and a kill -9 restart. All nine passed and are recorded as CLI and metrics transcripts in the evidence directory. I also ran the targeted residency, online-checkpoint and durability tests, and all passed. Those tests cover the two fixes I couldn't trigger reliably through the server: the deadlock when one parallel query list names the same dormant graph twice, and a request to a dormant graph during a long backup. Because they were not driven live, that scenario is marked untested. One note, not caused by this change: shutdown with many graphs holding unflushed writes is slow, as described in the finding. There is no UI change, so I took no screenshots. I deleted the data directory afterwards.

  • Live validation: ✅ go - 9 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Restarting a server with several graphs on disk reports them all but loads only _internal (metadata-only startup) ✅ pass live s1_restart_metadata_only.txt: kg_register_complete knowledge_graphs=5 shards=10 elapsed_ms=1; inputlayer_knowledge_graphs 5, inputlayer_knowledge_graphs_loaded 1
First query to a dormant graph after restart loads it and returns the same facts and recursive rule results ✅ pass live s2_lazy_activation.txt: bob ?connected(1,Y) returns 2..6 including the fact inserted before restart; loaded 1→2, kg_activated kg=bob
With max_loaded_knowledge_graphs=2, using a third graph unloads the least recently used one, and querying the unloaded graph again returns the same results ✅ pass live s3_lru_cap.txt: loaded stays at 2, unloads_total 0→1→2→3, alice reloads with the same 4 rows
Idle graphs unload after unload_idle_after_secs, but a graph with an open subscription stays loaded and keeps sending deltas ✅ pass live s4_idle_unload_and_pin.txt: alice unloaded about 2 s after use; carol stayed loaded for 10 s with the subscription open, received subscription_delta inserted [[1,7]], unloaded about 1 s after the sock…
Online .backup while user graphs are dormant captures every graph without loading them; the export verifies and restores with the same data ✅ pass live s5_backup_dormant.txt: loaded stays at 1 across the backup; verify OK; restore lists alice/bob/carol with their facts and rules; restored server returns the latest edges (9, 6, 7)
Adversarial: a rule commit whose catalog-file save fails keeps the graph loaded through the idle timeout, so the rule is not lost; after a later successful save the graph unloads and reloads with both… ✅ pass live s6_catalog_save_failure_pins.txt: catalog_save_failed_wal_keeps_changes, then no kg_unloaded for alice over 8 s with a 2 s idle timeout and ?far(Y) still answers; after chmod u+w and adding near, al…
Adversarial: a relation drop whose schema save fails keeps the graph loaded, so the dropped schema does not come back after unload or restart ✅ pass live s7_reldrop_save_failure_pins.txt: relation_drop_cleanup_deferred, bob stays loaded for 8 s past the idle timeout, .rel never lists typed, and typed is still absent after restart
Measured restart with 205 graphs / 400k facts: ready quickly with low RSS; first query to a dormant graph loads it on demand ✅ pass live s8_restart_measured.txt: start→/ready 81 ms (debug), kg_register_complete 205 graphs / 210 shards in 45 ms, RSS about 48 MB; u150 loaded in 6 ms on first query
Crash (kill -9) right after a fact and rule commit, then restart: the dormant graph loads with the committed state ✅ pass live s9_crash_restart.txt: u10 ?far(Y) → 88888 and ?reach(77777,Y) → 88888 after SIGKILL and restart
Adversarial: one parallel query list naming the same dormant graph twice does not deadlock, and a request to a dormant graph during a long backup is served without waiting for other graphs ⏸️ untested no The prior payload did not establish a live result for this scenario. The server exposes no way to send one parallel query list that names the same graph twice (execute_parallel_queries_on_knowledge_gr…
Evidence: Restart reads metadata only (5 graphs on disk, 1 loaded)
## Scenario 1: restart reads metadata only (5 KGs on disk: default,_internal,alice,bob,carol)
2026-10-05T15:06:54.381758Z  INFO inputlayer::storage_engine: kg_register_complete knowledge_graphs=5 shards=10 elapsed_ms=1
2026-10-05T15:06:54.384845Z  INFO inputlayer::storage_engine::residency: kg_activated kg=_internal relations=4 tuples=5 loaded=1 elapsed_ms=1

$ prometheus right after restart (no client request yet)
inputlayer_knowledge_graphs 5
inputlayer_knowledge_graphs_loaded 1
inputlayer_knowledge_graph_loads_total 1
inputlayer_knowledge_graph_unloads_total 0
inputlayer_relations_total 10
inputlayer_tuples_total 21
Evidence: First use loads a dormant graph with the same facts and rules
## Scenario 2: first use of dormant KG 'bob' loads it with same facts+rules
$ inputlayer-client --script q_bob.iql
Connecting to server at http://127.0.0.1:18913...
Connected!

Server status: healthy
Authenticated as: admin
Current knowledge graph: default

> .kg use bob
Switched to knowledge graph: bob
> ?connected(1, Y)
┌───┬───┐
│ 1 │ Y │
├───┼───┤
│ 1 │ 2 │
│ 1 │ 3 │
│ 1 │ 4 │
│ 1 │ 5 │
│ 1 │ 6 │
└───┴───┘
5 rows
> ?name(N)
┌───────┐
│ N     │
├───────┤
│ "bob" │
└───────┘
1 rows

$ prometheus after
inputlayer_knowledge_graphs 5
inputlayer_knowledge_graphs_loaded 2
inputlayer_knowledge_graph_loads_total 2
inputlayer_knowledge_graph_unloads_total 0
inputlayer_relations_total 10
inputlayer_tuples_total 21
2026-10-05T15:07:01.390842Z  INFO inputlayer::storage_engine::residency: kg_activated kg=bob relations=2 tuples=6 loaded=2 elapsed_ms=1
Evidence: max_loaded_knowledge_graphs=2 LRU unloading
## Scenario 3: max_loaded_knowledge_graphs=2 evicts least-recently-used idle KG
$ prom at start
inputlayer_knowledge_graphs 5
inputlayer_knowledge_graphs_loaded 1
inputlayer_knowledge_graph_loads_total 1
inputlayer_knowledge_graph_unloads_total 0
inputlayer_relations_total 10
inputlayer_tuples_total 21

$ query alice
Switched to knowledge graph: alice
4 rows
inputlayer_knowledge_graphs_loaded 2
inputlayer_knowledge_graph_loads_total 2
inputlayer_knowledge_graph_unloads_total 0

$ query bob
Switched to knowledge graph: bob
5 rows
inputlayer_knowledge_graphs_loaded 2
inputlayer_knowledge_graph_loads_total 3
inputlayer_knowledge_graph_unloads_total 1

$ query carol
Switched to knowledge graph: carol
4 rows
inputlayer_knowledge_graphs_loaded 2
inputlayer_knowledge_graph_loads_total 4
inputlayer_knowledge_graph_unloads_total 2

$ query alice again (was evicted) -> same results
> ?connected(1, Y)
┌───┬───┐
│ 1 │ Y │
├───┼───┤
│ 1 │ 2 │
│ 1 │ 3 │
│ 1 │ 4 │
│ 1 │ 5 │
└───┴───┘
4 rows
inputlayer_knowledge_graphs_loaded 2
inputlayer_knowledge_graph_loads_total 5
inputlayer_knowledge_graph_unloads_total 3

$ residency log lines
2026-10-05T15:07:11.914229Z  INFO inputlayer::storage_engine::residency: kg_activated kg=_internal relations=4 tuples=5 loaded=1 elapsed_ms=2
2026-10-05T15:07:12.373473Z  INFO inputlayer::storage_engine::residency: kg_activated kg=alice relations=2 tuples=5 loaded=2 elapsed_ms=1
2026-10-05T15:07:12.444017Z  INFO inputlayer::storage_engine::residency: kg_activated kg=bob relations=2 tuples=6 loaded=3 elapsed_ms=1
2026-10-05T15:07:12.444138Z  INFO inputlayer::storage_engine::residency: kg_unloaded kg=alice relations=2 tuples=5 loaded=2 elapsed_ms=0
2026-10-05T15:07:12.514266Z  INFO inputlayer::storage_engine::residency: kg_activated kg=carol relations=2 tuples=5 loaded=3 elapsed_ms=1
2026-10-05T15:07:12.514395Z  INFO inputlayer::storage_engine::residency: kg_unloaded kg=bob relations=2 tuples=6 loaded=2 elapsed_ms=0
2026-10-05T15:07:12.585127Z  INFO inputlayer::storage_engine::residency: kg_activated kg=alice relations=2 tuples=5 loaded=3 elapsed_ms=1
2026-10-05T15:07:12.585245Z  INFO inputlayer::storage_engine::residency: kg_unloaded kg=carol relations=2 tuples=5 loaded=2 elapsed_ms=0
Evidence: Idle unload; subscription keeps its graph loaded and delivers a delta
## Scenario 4: unload_idle_after_secs=2 unloads idle KGs; a standing subscription pins its KG
$ query alice (loads it)
4 rows
t=1s inputlayer_knowledge_graphs_loaded 3 inputlayer_knowledge_graph_unloads_total 1 
t=2s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 2 
t=3s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 2 
t=4s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 2 
t=5s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 2 
t=6s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 2 
t=6s insert +edge[(5,7)] into carol: Inserted 1 fact(s) into 'edge'.
t=7s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 2 
t=8s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 2 
t=9s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 2 
t=10s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 2 
[1]+  Done                    node $W/sub.js carol 10 > $W/sub_out.txt 2>&1
t=11s inputlayer_knowledge_graphs_loaded 1 inputlayer_knowledge_graph_unloads_total 3 
t=12s inputlayer_knowledge_graphs_loaded 1 inputlayer_knowledge_graph_unloads_total 3 
t=13s inputlayer_knowledge_graphs_loaded 1 inputlayer_knowledge_graph_unloads_total 3 
t=14s inputlayer_knowledge_graphs_loaded 1 inputlayer_knowledge_graph_unloads_total 3 
t=15s inputlayer_knowledge_graphs_loaded 1 inputlayer_knowledge_graph_unloads_total 3 
t=16s inputlayer_knowledge_graphs_loaded 1 inputlayer_knowledge_graph_unloads_total 3 

$ subscription client transcript (carol)
2026-10-05T15:07:43.715Z recv {"type":"authenticated","id":"a","session_id":"2622b80a-0baa-42b4-8857-821d2840ee6e","knowledge_graph":"default","version":"0.1.0","role":"admin","protocol_version":4,"stream_epoch":"3743dbabca194c5d"}
2026-10-05T15:07:43.716Z recv {"type":"result","id":"u","columns":["message"],"rows":[["Switched to knowledge graph: carol"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"switched_kg":"carol","errors":[]}
2026-10-05T15:07:43.728Z recv {"type":"result","id":"s","columns":["1","Y"],"rows":[[1,2],[1,3],[1,4],[1,5]],"row_count":4,"total_count":4,"truncated":false,"execution_time_ms":10,"errors":[],"subscribed":{"subscription":"pin1","generation":1,"revision":2}}
2026-10-05T15:07:49.797Z recv {"type":"persistent_update","knowledge_graph":"carol","relation":"edge","operation":"insert","count":1,"timestamp_ms":1791212869797,"seq":1}
2026-10-05T15:07:49.805Z recv {"type":"subscription_delta","subscription":"pin1","generation":1,"knowledge_graph":"carol","seq":1,"revision":5,"columns":["1","Y"],"inserted":[[1,7]],"retracted":[]}
2026-10-05T15:07:53.708Z closing subscription socket

$ residency log lines
2026-10-05T15:07:31.592218Z  INFO inputlayer::storage_engine::residency: kg_activated kg=_internal relations=4 tuples=5 loaded=1 elapsed_ms=2
2026-10-05T15:07:31.743279Z  INFO ws_request: inputlayer::storage_engine::residency: kg_activated kg=carol relations=2 tuples=5 loaded=2 elapsed_ms=1 request_id=2 msg_bytes=72
2026-10-05T15:07:35.594718Z  INFO inputlayer::storage_engine::residency: kg_unloaded kg=carol relations=2 tuples=5 loaded=1 elapsed_ms=0
2026-10-05T15:07:43.650576Z  INFO inputlayer::storage_engine::residency: kg_activated kg=alice relations=2 tuples=5 loaded=2 elapsed_ms=1
2026-10-05T15:07:43.718807Z  INFO ws_request: inputlayer::storage_engine::residency: kg_activated kg=carol relations=2 tuples=5 loaded=3 elapsed_ms=1 request_id=2 msg_bytes=72
2026-10-05T15:07:45.595499Z  INFO inputlayer::storage_engine::residency: kg_unloaded kg=alice relations=2 tuples=5 loaded=2 elapsed_ms=0
2026-10-05T15:07:54.595082Z  INFO inputlayer::storage_engine::residency: kg_unloaded kg=carol relations=2 tuples=6 loaded=1 elapsed_ms=0

$ query carol after it was unloaded -> reload includes the insert made while pinned
> ?connected(1, Y)
┌───┬───┐
│ 1 │ Y │
├───┼───┤
│ 1 │ 2 │
│ 1 │ 3 │
│ 1 │ 4 │
│ 1 │ 5 │
│ 1 │ 7 │
└───┴───┘
5 rows
Evidence: Online backup with dormant graphs, then verify, restore, query
## Scenario 5: online .backup while user KGs are dormant captures every KG, restore serves same data
$ insert into alice, then wait for idle unload
Inserted 1 fact(s) into 'edge'.
inputlayer_knowledge_graphs_loaded 1
inputlayer_knowledge_graph_unloads_total 5

$ .backup snap1
> .backup snap1
Exporting the checkpoint at revision 25 to ~/.no-mistakes/worktrees/9eea230c3aae/01M46845NAJYYNJ60CV1654N8B/target/tmp/en13-live/backups/snap1.
Commits were held for 91 us; check progress with .backup status.
$ .backup status
> .backup status
Last export complete: revision 25 in ~/.no-mistakes/worktrees/9eea230c3aae/01M46845NAJYYNJ60CV1654N8B/target/tmp/en13-live/backups/snap1.
24 files, 23973 bytes; commits held 91 us, written in 37 ms.

$ prometheus after backup (backup does not activate dormant KGs)
inputlayer_knowledge_graphs_loaded 1
inputlayer_knowledge_graph_loads_total 6
inputlayer_knowledge_graph_unloads_total 5

$ inputlayer-backup verify
backup verified: ~/.no-mistakes/worktrees/9eea230c3aae/01M46845NAJYYNJ60CV1654N8B/target/tmp/en13-live/backups/snap1
         24 files, 17 directories, 23973 bytes in 3 ms
revision: 25 (online checkpoint)

$ manifest

$ inputlayer-backup restore snap1 -> data-restored
  _internal: 4 relations, 5 facts, 0 rules
  alice: 2 relations, 6 facts, 1 rules
  bob: 2 relations, 6 facts, 1 rules
  carol: 2 relations, 6 facts, 1 rules
  default: 0 relations, 0 facts, 0 rules
$ server on restored data, prometheus at start
inputlayer_knowledge_graphs 5
inputlayer_knowledge_graphs_loaded 1
$ query alice
> ?connected(1, Y)
┌───┬───┐
│ 1 │ Y │
├───┼───┤
│ 1 │ 2 │
│ 1 │ 3 │
│ 1 │ 4 │
│ 1 │ 5 │
│ 1 │ 9 │
└───┴───┘
5 rows
$ query bob
> ?connected(1, Y)
┌───┬───┐
│ 1 │ Y │
├───┼───┤
│ 1 │ 2 │
│ 1 │ 3 │
│ 1 │ 4 │
│ 1 │ 5 │
│ 1 │ 6 │
└───┴───┘
5 rows
$ query carol
> ?connected(1, Y)
┌───┬───┐
│ 1 │ Y │
├───┼───┤
│ 1 │ 2 │
│ 1 │ 3 │
│ 1 │ 4 │
│ 1 │ 5 │
│ 1 │ 7 │
└───┴───┘
5 rows
Evidence: Failed rule-catalog save keeps the graph loaded
## Scenario 6 (adversarial): rule commit whose catalog save fails keeps KG loaded through idle unload
$ chmod a-w data/alice/rules  (catalog file write will fail)
$ add rule far(Y) to alice
> +far(Y) <- connected(1, Y), Y > 3
Rule 'far' registered.
> ?far(Y)
┌───┐
│ Y │
├───┤
│ 4 │
│ 5 │
│ 9 │
└───┘
3 rows

$ server log
2026-10-05T15:08:54.548883Z  WARN inputlayer::storage_engine::catalog_change: catalog_save_failed_wal_keeps_changes kg=alice revision=29 error=Failed to write catalog: I/O error: Permission denied (os error 13)
t=2s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 0 
t=4s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 0 
t=6s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 0 
t=8s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 0 

$ residency log (alice must NOT be unloaded)
2026-10-05T15:08:54.095040Z  INFO inputlayer::storage_engine::residency: kg_activated kg=_internal relations=4 tuples=5 loaded=1 elapsed_ms=2
2026-10-05T15:08:54.543013Z  INFO inputlayer::storage_engine::residency: kg_activated kg=alice relations=2 tuples=6 loaded=2 elapsed_ms=2

$ query far(Y) after 8s idle -> rule still present
> ?far(Y)
┌───┐
│ Y │
├───┤
│ 4 │
│ 5 │
│ 9 │
└───┘
3 rows
> .rule list
Rules:
  connected (2 clause(s))
  far (1 clause(s))

## 6b: permission restored, next catalog change saves -> KG becomes unloadable, reload keeps both rules
$ chmod u+w data/alice/rules; add rule near(Y)
Rule 'near' registered.
t=2s inputlayer_knowledge_graphs_loaded 1 inputlayer_knowledge_graph_unloads_total 1 
t=4s inputlayer_knowledge_graphs_loaded 1 inputlayer_knowledge_graph_unloads_total 1 
t=6s inputlayer_knowledge_graphs_loaded 1 inputlayer_knowledge_graph_unloads_total 1 
2026-10-05T15:09:12.096975Z  INFO inputlayer::storage_engine::residency: kg_unloaded kg=alice relations=2 tuples=6 loaded=1 elapsed_ms=0
$ query far and near (alice reloaded from disk)
> ?far(Y)
┌───┐
│ Y │
├───┤
│ 4 │
│ 5 │
│ 9 │
└───┘
3 rows
> ?near(Y)
┌───┐
│ Y │
├───┤
│ 2 │
└───┘
1 rows
Evidence: Failed relation-drop save keeps the graph loaded; dropped schema stays gone
## Scenario 7 (adversarial): .rel drop whose schema catalog save fails keeps KG loaded, so the dropped schema cannot reappear
$ bob has typed(id:int,label:string) with 1 fact; bob idle-unloaded; chmod a-w data/bob data/bob/rules
$ .rel drop typed
> .rel drop typed
Relation 'typed' dropped.
> .rel
Relations:
  edge (arity: 2, columns: [col0: any, col1: any], tuples: 5)
  name (arity: 1, columns: [col0: any], tuples: 1)
2026-10-05T15:08:54.548883Z  WARN inputlayer::storage_engine::catalog_change: catalog_save_failed_wal_keeps_changes kg=alice revision=29 error=Failed to write catalog: I/O error: Permission denied (os error 13)
2026-10-05T15:09:41.263414Z  WARN inputlayer::storage_engine: relation_drop_cleanup_deferred kg=bob relation=typed error=Failed to save schema catalog: IO error: Failed to write schema catalog: I/O error: Permission denied (os error 13)
t=2s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 3 
t=4s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 3 
t=6s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 3 
t=8s inputlayer_knowledge_graphs_loaded 2 inputlayer_knowledge_graph_unloads_total 3 
$ bob residency lines since drop
2026-10-05T15:09:29.913792Z  INFO inputlayer::storage_engine::residency: kg_activated kg=bob relations=2 tuples=6 loaded=2 elapsed_ms=1
2026-10-05T15:09:31.097202Z  INFO inputlayer::storage_engine::residency: kg_unloaded kg=bob relations=3 tuples=7 loaded=1 elapsed_ms=0
2026-10-05T15:09:41.259135Z  INFO inputlayer::storage_engine::residency: kg_activated kg=bob relations=3 tuples=7 loaded=2 elapsed_ms=1
$ .rel in bob after idle period -> typed still gone
> .rel
Relations:
  edge (arity: 2, columns: [col0: any, col1: any], tuples: 5)
  name (arity: 1, columns: [col0: any], tuples: 1)
> ?typed(I, L)
No results.

$ restart server (permissions restored); .rel in bob -> dropped relation stays dropped, alice rules far/near intact
> .rel
Relations:
  edge (arity: 2, columns: [col0: any, col1: any], tuples: 5)
  name (arity: 1, columns: [col0: any], tuples: 1)
> ?typed(I, L)
No results.
> ?far(Y)
┌───┐
│ Y │
├───┤
│ 4 │
│ 5 │
│ 9 │
└───┘
3 rows
> ?near(Y)
┌───┐
│ Y │
├───┤
│ 2 │
└───┘
1 rows
Evidence: Measured restart: 205 graphs / 400k facts ready in 81 ms, 48 MB RSS
## Scenario 8: measured restart with 205 KGs / ~400k facts / 210 shards on disk (debug build)
process start -> /ready: 81 ms  (debug build)
RSS after ready: VmRSS:	   47852 kB
2026-10-05T15:13:51.340006Z  INFO inputlayer::storage_engine: kg_register_complete knowledge_graphs=205 shards=210 elapsed_ms=45
inputlayer_knowledge_graphs 205
inputlayer_knowledge_graphs_loaded 1
inputlayer_tuples_total 400023

$ first query to dormant u150 (activates it)
> ?reach(1999, Y)
┌──────┬──────┐
│ 1999 │ Y    │
├──────┼──────┤
│ 1999 │ 2000 │
└──────┴──────┘
1 rows
> ?reach(0, Y)
┌───┬───┐
│ 0 │ Y │
├───┼───┤
│ 0 │ 1 │
└───┴───┘
1 rows
client wall time incl. connect: 66 ms
2026-10-05T15:13:51.425005Z  INFO inputlayer::storage_engine::residency: kg_activated kg=u150 relations=1 tuples=2000 loaded=2 elapsed_ms=6
warm repeat client wall time: 58 ms
RSS after one activation: VmRSS:	   55060 kB
inputlayer_knowledge_graphs_loaded 2
Evidence: kill -9 then restart keeps committed fact and rule
## Scenario 9: crash (SIGKILL) right after a fact+rule commit, restart, dormant KG loads with the committed state
Inserted 1 fact(s) into 'edge'.
Rule 'far' registered.
$ kill -9 server; restart
2026-10-05T15:14:27.618258Z  INFO inputlayer::storage_engine: kg_register_complete knowledge_graphs=205 shards=210 elapsed_ms=110
inputlayer_knowledge_graphs 205
inputlayer_knowledge_graphs_loaded 1
$ query u10
> ?far(Y)
┌───────┐
│ Y     │
├───────┤
│ 88888 │
└───────┘
1 rows
> ?reach(77777, Y)
┌───────┬───────┐
│ 77777 │ Y     │
├───────┼───────┤
│ 77777 │ 88888 │
└───────┴───────┘
1 rows
Evidence: Residency test run (17 passed)
test storage_engine::residency::tests::revisions_continue_above_catalog_files_after_a_crash ... ok
test storage_engine::residency::tests::loading_clamps_drifted_multiplicities_durably ... ok
test storage_engine::residency::tests::a_request_to_a_dormant_graph_is_served_during_a_backup ... ok
test storage_engine::residency::tests::a_knowledge_graph_in_use_stays_loaded ... ok
test storage_engine::residency::tests::idle_unloading_spares_recent_and_internal_knowledge_graphs ... ok
test storage_engine::residency::tests::a_writer_holding_an_unloaded_handle_commits_to_the_reloaded_graph ... ok
test storage_engine::residency::tests::a_knowledge_graph_whose_catalog_save_failed_stays_loaded ... ok
test storage_engine::residency::tests::an_unreadable_knowledge_graph_fails_alone ... ok
test storage_engine::residency::tests::concurrent_first_use_loads_once ... ok
test storage_engine::residency::tests::a_knowledge_graph_with_state_only_in_memory_stays_loaded ... ok
test storage_engine::residency::tests::a_checkpoint_reads_dormant_knowledge_graphs_without_loading_them ... ok
test storage_engine::residency::tests::a_knowledge_graph_whose_relation_drop_save_failed_stays_loaded ... ok
test storage_engine::residency::tests::parallel_queries_naming_a_dormant_graph_twice_load_it_once ... ok
test storage_engine::residency::tests::dropping_a_dormant_knowledge_graph_removes_its_data ... ok
test storage_engine::residency::tests::restart_registers_knowledge_graphs_without_loading_them ... ok
test storage_engine::residency::tests::an_unloaded_knowledge_graph_reloads_unchanged_under_its_revision ... ok
test storage_engine::residency::tests::crash_restart_cycles_keep_exactly_the_committed_facts ... ok
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 3180 filtered out; finished in 1.89s
Evidence: Online checkpoint test run (8 passed)
test measure_capture_cost_and_hot_path_latency_during_exports ... ok
test checkpoint_revision_names_the_newest_commit ... ok
test exports_only_go_to_plain_names_outside_the_data_dir ... ok
test cancelled_export_removes_what_it_wrote ... ok
test incomplete_and_corrupt_exports_are_refused ... ok
test capture_stays_live_under_kg_churn_and_writes ... ok
test export_during_writes_restores_one_consistent_revision ... ok
test engine_shutdown_mid_export_leaves_a_complete_export_or_none ... ok
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.49s
Evidence: Node WebSocket subscription client used for scenario 4
// usage: node sub.js <kg> <hold_secs> ; subscribes to ?connected(1,Y) and prints frames
const [kg, hold] = [process.argv[2], +process.argv[3]];
const ws = new WebSocket('ws://127.0.0.1:18913/ws');
let step = 0;
const send = (o) => ws.send(JSON.stringify(o));
ws.onopen = () => send({type:'authenticate', id:'a', api_key: process.env.INPUTLAYER_API_KEY});
ws.onmessage = (m) => {
  const f = JSON.parse(m.data);
  console.log(new Date().toISOString(), 'recv', JSON.stringify(f).slice(0, 300));
  if (step === 0 && f.type === 'authenticated') { step = 1; send({type:'execute', id:'u', program:`.kg use ${kg}`}); }
  else if (step === 1 && (f.type === 'result' || f.type==='result_end' || f.type==='ok' || f.id==='u')) { step = 2; send({type:'execute', id:'s', program:'.subscribe pin1 ?connected(1, Y)'}); }
};
ws.onerror = (e) => console.log('error', e.message);
setTimeout(() => { console.log(new Date().toISOString(), 'closing subscription socket'); ws.close(); setTimeout(()=>process.exit(0), 200); }, hold*1000);
- Outcome: ⚠️ 1 info across 1 run (11m34s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed (2) ✅
  • ⚠️ src/storage_engine/residency.rs:249 - KnowledgeGraph::reloadable() does not check whether the in-memory rule and schema catalogs have been saved to the catalog files. When a durable catalog commit fails to save (install_catalog logs catalog_save_failed_wal_keeps_changes, catalog_change.rs:364, program_commit.rs:120), memory holds the new rules but the files do not. The WAL keeps the records, but only take_recovered_catalog reads them, and that runs once at startup. Then try_unload → to_dormant builds Dormant { catalog: Vec::new(), last_published: Some((R, changes)) } (residency.rs:269-273). On reload, from_disk reads the stale catalog files and publishes them under the same revision R with the same change log. Result: a rule the client committed disappears in a running server, and expect_revision preconditions pass against different state. This breaks the module's promise that a reload 'publishes the same state under the same snapshot revision'. Activation hits the same invariant at residency.rs:503: the catalog_replay_save_failed path keeps the changes in memory only, so a later unload drops them. Fix: track a catalog_unsaved flag on KnowledgeGraph. Set it when save_catalogs/replay_catalog fails, clear it when a later save succeeds, and include it in reloadable().
  • ⚠️ src/storage_engine/residency.rs:432 - activate holds the slot's non-reentrant parking_lot::Mutex while load_graph runs shards.par_iter() (residency.rs:458). The public execute_parallel_queries_on_knowledge_graphs (mod.rs:1958-1962) now calls kg_handle (which may activate) from inside queries.par_iter(). If a query list names the same dormant KG twice (e.g. after a restart), rayon worker A runs job 1, takes slot X's mutex and waits on its nested par_iter. While waiting, A work-steals job 2, which calls activate(X) and blocks on the mutex A already holds. That deadlocks permanently. Before this change, lookups were a plain map read, so this could not happen. Smallest fix: in execute_parallel_queries_on_knowledge_graphs, resolve every distinct KG's handle/snapshot sequentially before the par_iter, or avoid holding the slot mutex across rayon joins in activate.
  • ⚠️ src/storage_engine/checkpoint.rs:124 - capture_checkpoint holds the slot mutex of every dormant KG at once. It then reads them from disk one by one (checkpoint.rs:142-148), releasing each only after its own full load. With one KG per user and most KGs dormant (the target workload), a .backup makes the first request to any not-yet-read dormant KG wait until every KG sorted before it has been fully loaded from disk. That wait is O(total data), and those KGs can't be unloaded meanwhile. Before this change, a checkpoint held off commits only for O(KGs + relations) reference cloning. This is the author's deliberate consistency design, but it is an availability regression during backups that matters for the RTO/rescheduling goal. The remedy needs a design decision, not a mechanical repair, so it is listed for review. Options: read dormant shards filtered to time &lt;= revision without holding slots, or take a short hold per KG.

🔧 Fix applied.
1 warning still open:

  • ⚠️ src/storage_engine/mod.rs:2500 - This is a sibling the round-1 fix (unsaved-catalog-lost-on-unload) left behind. The fix sets catalog_unsaved only in save_catalogs (catalog_change.rs:366) and the activation replay (residency.rs:498). The relation-drop paths also change the catalogs in memory and then save them, but a failed save there never sets the flag:
  • KnowledgeGraph::drop_relation (mod.rs:2499-2504): remove_schema removes the schema from memory before save_schema_catalog (mod.rs:2692), and rule_catalog.drop removes the rule before save() (rule_catalog.rs:697-702).
  • scrub_dropped_relation (mod.rs:2531-2537).

Failing sequence:

  1. KG b has a registered schema typed and facts in typed.
  2. drop_relation_in(&#34;b&#34;,&#34;typed&#34;) hits a catalog-file write error. It logs relation_drop_cleanup_deferred and keeps the tombstone. Memory has no schema; schemas.json still has it.
  3. b goes idle. reloadable() is true, so try_unload unloads it.
  4. The next request activates b. load_graph filters the tombstoned shard but does not scrub catalogs; only startup (mod.rs:1862-1866) and settle_relation_drop before a write to that name do.
  5. from_disk reloads typed's schema (or a dropped rule) from the stale files.

Result: in a running server, the dropped relation's schema or rule comes back (has_schema_in/list_schemas, and rules depending on it) under the same published revision. This is the same 'reload publishes the same state' invariant the round-1 fix targeted.

Fix: in drop_relation and scrub_dropped_relation, set self.catalog_unsaved = true when the schema or rule save fails. Let a successful scrub_dropped_relation (it saves both full catalogs) clear it. Alternatively, have reloadable() refuse while a relation tombstone for this KG exists.

🔧 Fix applied.
✅ Re-checked - no issues remain.

⚠️ **Test** - 1 info
  • ℹ️ src/storage_engine/mod.rs:992 - Not caused by this change, but it affects restart time. StorageEngine::save_all, called at shutdown, flushes shards one by one with persist.flush, and each flush rewrites the WAL. With 205 graphs written but not yet flushed, the debug build wrote about one shard per second, so shutdown took about 4 minutes. The 30 s graceful-shutdown timeout logged a warning, but the process stayed alive and kept holding the data-dir lock, so a new server could not start on the same directory. The base commit has the same save_all loop. This branch's new flush_dirty (one WAL rewrite for all dirty shards) could probably be reused there.
  • Live validation: ✅ go - 9 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Restarting a server with several graphs on disk reports them all but loads only _internal (metadata-only startup) ✅ pass live s1_restart_metadata_only.txt: kg_register_complete knowledge_graphs=5 shards=10 elapsed_ms=1; inputlayer_knowledge_graphs 5, inputlayer_knowledge_graphs_loaded 1
First query to a dormant graph after restart loads it and returns the same facts and recursive rule results ✅ pass live s2_lazy_activation.txt: bob ?connected(1,Y) returns 2..6 including the fact inserted before restart; loaded 1→2, kg_activated kg=bob
With max_loaded_knowledge_graphs=2, using a third graph unloads the least recently used one, and querying the unloaded graph again returns the same results ✅ pass live s3_lru_cap.txt: loaded stays at 2, unloads_total 0→1→2→3, alice reloads with the same 4 rows
Idle graphs unload after unload_idle_after_secs, but a graph with an open subscription stays loaded and keeps sending deltas ✅ pass live s4_idle_unload_and_pin.txt: alice unloaded about 2 s after use; carol stayed loaded for 10 s with the subscription open, received subscription_delta inserted [[1,7]], unloaded about 1 s after the sock…
Online .backup while user graphs are dormant captures every graph without loading them; the export verifies and restores with the same data ✅ pass live s5_backup_dormant.txt: loaded stays at 1 across the backup; verify OK; restore lists alice/bob/carol with their facts and rules; restored server returns the latest edges (9, 6, 7)
Adversarial: a rule commit whose catalog-file save fails keeps the graph loaded through the idle timeout, so the rule is not lost; after a later successful save the graph unloads and reloads with both… ✅ pass live s6_catalog_save_failure_pins.txt: catalog_save_failed_wal_keeps_changes, then no kg_unloaded for alice over 8 s with a 2 s idle timeout and ?far(Y) still answers; after chmod u+w and adding near, al…
Adversarial: a relation drop whose schema save fails keeps the graph loaded, so the dropped schema does not come back after unload or restart ✅ pass live s7_reldrop_save_failure_pins.txt: relation_drop_cleanup_deferred, bob stays loaded for 8 s past the idle timeout, .rel never lists typed, and typed is still absent after restart
Measured restart with 205 graphs / 400k facts: ready quickly with low RSS; first query to a dormant graph loads it on demand ✅ pass live s8_restart_measured.txt: start→/ready 81 ms (debug), kg_register_complete 205 graphs / 210 shards in 45 ms, RSS about 48 MB; u150 loaded in 6 ms on first query
Crash (kill -9) right after a fact and rule commit, then restart: the dormant graph loads with the committed state ✅ pass live s9_crash_restart.txt: u10 ?far(Y) → 88888 and ?reach(77777,Y) → 88888 after SIGKILL and restart
Adversarial: one parallel query list naming the same dormant graph twice does not deadlock, and a request to a dormant graph during a long backup is served without waiting for other graphs ⏸️ untested no The prior payload did not establish a live result for this scenario. The server exposes no way to send one parallel query list that names the same graph twice (execute_parallel_queries_on_knowledge_gr…
  • cargo build --bin inputlayer-server --bin inputlayer-client --bin inputlayer-backup at HEAD
  • Live: seeded alice/bob/carol over WebSocket (inputlayer-client --script), SIGTERM restart, read /metrics/prometheus and the kg_register_complete/kg_activated log lines
  • Live: first query to dormant bob after restart (recursive connected/2 plus a fact inserted before restart)
  • Live: restart with max_loaded_knowledge_graphs=2, queried alice→bob→carol→alice, tracked loaded/loads/unloads metrics and kg_unloaded lines
  • Live: unload_idle_after_secs=2 plus a Node WS client holding .subscribe pin1 ?connected(1, Y) on carol for 10 s, with an insert at t=6 s; per-second metrics
  • Live: .backup snap1 with all user graphs dormant, .backup status, inputlayer-backup verify, inputlayer-backup restore, second server on the restored dir, queried all graphs
  • Live (adversarial): chmod a-w data/alice/rules, registered rule far, waited past the idle timeout; then restored permissions, added rule near, confirmed alice unloads and reloads with both rules
  • Live (adversarial): chmod a-w data/bob, .rel drop typed on a typed relation (relation_drop_cleanup_deferred), waited past the idle timeout, checked .rel; restarted and checked again
  • Live: 200 extra graphs × 2000 facts, timed process start → /ready, RSS, first-query activation of dormant u150, warm repeat
  • Live: insert plus rule into u10, kill -9, restart, query u10
  • cargo test --lib storage_engine::residency (17 tests, including parallel_queries_naming_a_dormant_graph_twice_load_it_once and a_request_to_a_dormant_graph_is_served_during_a_backup)
  • cargo test --test online_checkpoint_tests
  • cargo test --lib storage::persist::durability_tests
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@jsam

jsam commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

EN-13 measurements

Restart time

Measured at 207c0e13, before this branch merged origin/main (1addf8d4); a re-run on the merged head is pending until sam-dev-benchmarks is back. The merge brought only conflict resolutions plus carrying main's new snapshot fields (query limits, the expect_revision change log) through loading and reloading.

sam-dev-benchmarks, nproc = 32, warm page cache, data on local disk. Baseline main f8a43f96; candidate this branch 207c0e13.

Engine (RESTART_BENCH_DIR=~/bench/en13/data cargo bench --bench restart_benchmarks, median of 3 opens, each in a fresh process). Ready = StorageEngine::new returned; first query = a bound lookup on kg_0 (pays its load on the branch); all = then touching every other KG. 4 relations per KG; +crash = killed (abort) with a durable WAL tail it never flushed (5,000 facts per relation for 1 KG; 50 per relation per KG for 1,000 KGs).

Point Facts Ready, main Ready, branch First query, main First query, branch All KGs, branch RSS after first query, main / branch
1 KG 0 1.5 ms 3.3 ms 0.4 ms 0.5 ms - 7 / 8 MB
1 KG 100k 36 ms 3.7 ms 0.5 ms 11.8 ms - 26 / 26 MB
1 KG 1M 388 ms 4.5 ms 1.3 ms 109 ms - 202 / 225 MB
1 KG, crash 1.02M 465 ms 32 ms 1.3 ms 118 ms - 205 / 231 MB
1 KG 10M 4,991 ms 4.6 ms 8.8 ms 1,295 ms - 1,530 / 1,383 MB
1,000 KGs x 10k 10M 4,318 ms 187 ms 0.4 ms 2.3 ms 1,188 ms 1,428 / 16 MB
1,000 KGs x 10k, crash 10.2M 197,373 ms 2,957 ms 0.5 ms 1.7 ms 1,179 ms 1,441 / 53 MB

Server (rto.sh: kill -9, start inputlayer-server --release --all-features, time until /ready returns 200; 5 runs):

Data main branch
1 KG, 10M facts 5,108-5,156 ms (RSS 1,534 MB) 15-23 ms (RSS 17 MB)
1,000 KGs x 10k facts 4,021-4,251 ms (RSS 1,432 MB) 191-197 ms (RSS 17 MB)

Loading one 10M-fact KG on first use (1.3 s) is 3.9x faster than main's full startup load of it (5.0 s), because its shards load in parallel.

Latency

scripts/perf-gate.sh --baseline-rev f8a43f96 --server-cpus 8-31 on sam-dev-benchmarks (32 CPUs), candidate 207c0e13. No metric FAILs; the hot path adds only atomics (slot lookup, access time, ArcSwapOption load).

  • 20 rounds (runs/20261005T081800Z): 16 PASS, 6 INCONCLUSIVE, 0 FAIL. Median cost ratios of the inconclusive metrics: bound_query.queries_per_sec 0.992, delta_fanout.delta_us.p99 0.976, delta_fanout.last_agent_us.p99 0.975, insert_batch.ack_us.p50 1.041, insert_batch.facts_per_sec 1.020, insert_single.ack_us.p99 1.050 (baseline spread 15-121% on those).
  • insert_batch and insert_single alone, 30 rounds (runs/20261005T083627Z), to settle insert_batch.ack_us.p50: 1.009 (0.964..1.053), insert_batch.facts_per_sec 1.001 PASS, every insert_single metric PASS.
  • The earlier 10-round run (runs/20261005T080811Z, start load 4.1) was INCONCLUSIVE with no FAIL.

The same metrics are INCONCLUSIVE in the CI runner's A/A calibration (#198).

Post-merge note: after the measured commit, review fixes changed checkpoint capture to hold each dormant graph only while its state is captured (each graph consistent at its own revision), and kept graphs with unsaved catalog changes loaded. Neither touches the startup or first-use load path measured above; the re-run on the merged head is pending until sam-dev-benchmarks is back.

@jsam
jsam merged commit 22ceb6b into main Oct 5, 2026
21 checks passed
@jsam
jsam deleted the fm/en-en13 branch October 5, 2026 16:05
jsam added a commit that referenced this pull request Oct 5, 2026
…ckground after a restart (#373)

Since #357 a restart reads metadata only and each knowledge graph loads on
first use, so the first query after a restart paid the load. The engine now
keeps the names of the loaded graphs in metadata/resident_knowledge_graphs.json
(rewritten when a graph loads that it lacks, is unloaded or is dropped), and
the server loads those graphs in a background task right after startup.
Startup and logins do not wait for it, graphs that were not in memory stay
dormant, and storage.max_loaded_knowledge_graphs still bounds what is loaded.
jsam added a commit that referenced this pull request Oct 5, 2026
…ound after a restart (#389)

* fix(storage): load the knowledge graphs that were in memory in the background after a restart (#373)

Since #357 a restart reads metadata only and each knowledge graph loads on
first use, so the first query after a restart paid the load. The engine now
keeps the names of the loaded graphs in metadata/resident_knowledge_graphs.json
(rewritten when a graph loads that it lacks, is unloaded or is dropped), and
the server loads those graphs in a background task right after startup.
Startup and logins do not wait for it, graphs that were not in memory stay
dormant, and storage.max_loaded_knowledge_graphs still bounds what is loaded.

* fix(storage): drop graphs the warm-up leaves dormant at the loaded limit from the resident record

A graph skipped because storage.max_loaded_knowledge_graphs was reached stayed
recorded as in memory, so the next restart could warm stale names instead of
the graphs that were loaded.

* no-mistakes(review): fix(storage): stop warm-up loads unloading other knowledge graphs

* no-mistakes(document): docs: note warm-up limit skip rewrites resident record
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.

1 participant