Skip to content

PR: perf: serve read_state from the in-memory record maps #173 - #180

Merged
metasmile merged 18 commits into
mainfrom
173-read-state-from-memory
Aug 25, 2026
Merged

PR: perf: serve read_state from the in-memory record maps #173#180
metasmile merged 18 commits into
mainfrom
173-read-state-from-memory

Conversation

@metasmile

Copy link
Copy Markdown
Contributor

Summary

Re-expresses read_state over the in-memory application-layer record maps instead of an O(N) io scan (list + read + decode + SHA-256 per record), with a bounded content cache and a structure-only read for content-free consumers.

Primary issue: #173

  • read_state now serves from the record maps in id-sorted order (the pre-Re-express read_state over the in-memory 19-axis store (last open constraint of #170) #173 observable ordering contract is preserved), removing the per-call io record scan and the SHA-256 recompute (content_hash parses from blob_hash).
  • A content-addressed blob cache (blob_cache, FIFO-bounded at 10,000 entries) serves repeated reads from memory; evicted blobs reload from io, and the current read always resolves its own content (no eviction starvation).
  • read_state_struct returns the record structure without blob-payload materialization (facts' content and intents' descriptions empty, hints' inline content present). Wired through the protocol (read_state_struct RPC), NexClient, and the nexd scheduler, whose 100 ms heartbeat poll needs ids, workers, and timestamps only.
  • read_state_filtered and scan_partition enumerate the record maps in id order to match read_state (identical states including order), covered by storage/sim/tests/read_state_agreement.rs.
  • Restores the pre-Re-express read_state over the in-memory 19-axis store (last open constraint of #170) #173 flush side effect in read_state so the pending batch stays small and cache misses stay limited to genuinely new blobs.

Bundled in the same branch per the working instruction

Verification

  • cargo test --workspace: 416 passed
  • cargo test -p nexd --test integration -- --test-threads=1: 28 passed (includes SIGTERM graceful shutdown and the restart-on-crash supervision test)
  • cargo fmt --check and cargo clippy --all-targets clean (pre-existing EntityStore unused-import warnings in two storage/sim tests remain)
  • a_stress_parallel at 10k events: 17.7 s before this work to ~9.0 s (stable across repeated runs); 5k events at 1.96 s

Notes for review

@metasmile metasmile self-assigned this Aug 23, 2026

@metasmile metasmile left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Six negative findings, no blockers asserted; the merge decision stays with the author. The most actionable are the filtered/scan sort-before-filter regression (store.rs:1670) and the scheduler's silent protocol-mismatch degradation (main.rs:151).

Comment thread nexd/src/main.rs Outdated
let Ok(state) = client.read_state().await else { continue; };
// Structured read only: the heartbeat check needs ids,
// workers, and timestamps, not content materialization.
let Ok(state) = client.read_state_struct().await else { continue; };

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

else { continue } swallows the RPC error. In a mixed deployment (new nexd against an older nex-server that lacks read_state_struct), every tick fails with method-not-found and the scheduler silently stops releasing stale heartbeats, with no log. At minimum the error should be logged at warn level; a fallback to read_state or a protocol version check would be more robust.

Comment thread nex/fih/src/core/store.rs Outdated
let mut fact_recs: Vec<(&String, &FactRecord)> = recs.iter().collect();
fact_recs.sort_by(|a, b| a.0.cmp(b.0));
for (id, r) in fact_recs {
let content_hash = Self::hex_blob_hash(&r.blob_hash).unwrap_or(FihHash([0u8; 32]));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

An unparseable blob hash now silently yields FihHash([0u8; 32]) together with empty content. The pre-#173 read_state recomputed SHA-256 from the loaded content, which self-healed legacy or corrupt records. This change silently degrades such records to a zero hash and empty payload with no log and no error path. If the zero fallback is intentional, it should at least be logged once per offending record.

Comment thread nexd/src/manager.rs
.or_insert(0);
let (new_failures, should_respawn) = respawn_decision(*failures, rapid);
*failures = new_failures;
if should_respawn {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Once the circuit breaker trips (5 rapid exits), respawn stays disabled for the whole nexd session: the counter only resets when a child survives the rapid-exit window, but after tripping there is no child left to survive. If the crash cause is transient and gets fixed while nexd keeps running, nex-server stays down until nexd restarts. A manual reset path (an RPC) or a periodic decay would avoid the latch.

Comment thread nex/fih/src/core/store.rs Outdated
for (id, r) in fact_recs.iter() {
let recs = self.fact_records.borrow();
let mut fact_recs: Vec<(&String, &FactRecord)> = recs.iter().collect();
fact_recs.sort_by(|a, b| a.0.cmp(b.0));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sorting the full record map before applying the predicates adds O(N log N) to every filtered read, even when the filter matches a handful of records. rem's search path goes through read_state_filtered; a selective origin+creator+time query over a large store now pays the full-map sort. The sort should apply to the matching subset (filter first, then sort), keeping selective queries at O(N) scan plus O(k log k) sort.

Comment thread nex/fih/src/core/store.rs
state.intents[idx].description = String::from_utf8_lossy(&content.data).to_string();
}
for (hash, content) in loaded {
self.cache_blob(hash, content);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The FIFO cap at 10,000 entries makes workloads with a larger distinct-blob working set thrash: every read evicts the oldest entry and reloads it from io, which is precisely the cost the cache was introduced to remove, plus cache-maintenance overhead. The stress and cache tests stay under the cap; there is no measurement for the greater-than-10k regime. Consider documenting the expected crossover or making the cap configurable.

Comment thread nex/fih/src/core/store.rs
// stays small, so cache misses below are limited to genuinely
// new blobs. A failed flush is logged: the signature has no
// error channel.
if let Err(e) = self.flush_pending().await {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Restoring the flush inside read_state makes a read API perform io writes: every read_state on a non-auto-flush store flushes the pending batch (write amplification for high-frequency readers) and a failed flush degrades the read with only a warn log. This is the pre-#173 behavior, but it should be stated explicitly as a deliberate tradeoff, or moved to the session boundary now that the returned state no longer depends on io being durable.

@metasmile
metasmile merged commit 894025b into main Aug 25, 2026
5 checks passed
@metasmile
metasmile deleted the 173-read-state-from-memory branch August 25, 2026 18:28

@metasmile metasmile left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Final review after the fix commit fdf384ab. All six negative findings from the previous review are addressed:

  1. Filter-before-sort (store.rs): the sorted_matches helper filters first and sorts only the matching subset, so selective queries pay O(N) scan plus O(k log k) sort instead of a full-map sort. The id-sorted result order still matches read_state (read_state_agreement passes).
  2. Zero-hash fallback (store.rs): blob_hash_or_zero logs a warning when a persisted blob hash is unparseable instead of failing silently.
  3. Scheduler silent degradation (main.rs): transition-based logging logs the first connect/read_state_struct failure with its cause and the recovery, without per-tick spam.
  4. Cache thrash (store.rs): the cap is now a per-store field with set_blob_cache_cap; workloads with a distinct-blob working set over the default 10k can tune it.
  5. Circuit breaker latch (manager.rs): a tripped command re-arms after RESPAWN_COOLDOWN (60 s), covered by the extended respawn_decision unit tests.
  6. read_state flush side effect (store.rs): documented as a deliberate tradeoff, including the cost of the alternative (session-boundary flush would let pending grow unbounded).

Verification: workspace 417, nexd integration 28, manager 4; run.sh --core and run.sh --server pass; fmt and clippy clean.

Residual non-blocking observations:

  • blob_hash_or_zero warns per enumeration call per corrupt record; repeatedly reading a corrupt store logs repeatedly.
  • set_blob_cache_cap to a smaller value reclaims memory lazily on the next insert, not immediately.
  • respawn_tripped_at entries persist for commands no longer tracked; bounded by distinct command count.
  • The default run.sh (wasm apps + playbooks) was not run; those areas are unaffected by this PR.

No blockers remain.

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