Skip to content

ConfigStore: read only what each operation needs - #48

Draft
bjacobso wants to merge 2 commits into
mainfrom
conductor/config-store-snapshot-cache
Draft

bjacobso wants to merge 2 commits into
mainfrom
conductor/config-store-snapshot-cache

Conversation

@bjacobso

@bjacobso bjacobso commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Why

Every ConfigStore.resolveRef, snapshotById, commit and setRef called load(). That reads every config record (objects, types, revisions, snapshots, refs) and rebuilds every snapshot's node tree. ConfigRuntime.evaluate resolves a ref on every call, so its cost grew with the whole config history. On the in-memory store, each of load()'s five entity-type reads also scanned every fact. WorldVM works around this by caching ref resolutions itself.

Config staying in triples is not the problem. It keeps releases, ref moves and application facts in one transaction and one journal, with bitemporal ref history and Datalog impact queries. The access pattern was the problem.

Change

  • resolveRef reads the ref entity, then snapshotById.

  • snapshotById reads the snapshot record and only the objects reachable from its root, one batched entities read per tree level.

  • Caching.

    • Immutable records are cached by identity for the life of the service: object bodies and type expressions by content ID, and snapshot records by ID.
    • Decoded trees are cached only until :triplex/valid-under changes. A node's type comes from validUnder, which later releases can extend. One dependencyState call checks this; it's an indexed query on SQL, and it stays correct when another process commits.
  • commit hands the pure InMemoryConfigStore.commit a partial store:

    • the input subtrees' stored objects and schemas;
    • those objects' revision histories;
    • the latest snapshot.

    It checks objects the transition mints outside the inputs (the snapshot root) before persisting them.

  • setRef and reverseDependencies read single records.

  • load() is unchanged. The CLI and recheck still use it for the whole-store view.

Breaking (pre-1.0, minor changeset): ConfigStore.commit no longer returns store. Nothing in the repo used it; call load() instead.

The first commit is PR #46, cherry-picked unchanged: entity-type matches at a temporal basis read the type index instead of scanning. The targeted reads still do a few typed reads, and this PR shouldn't depend on #46 landing first. If #46 merges first, this rebases cleanly.

Numbers

In-memory KV, Node 24. Each release has 21 config nodes. Each figure is the median of five warm ConfigRuntime.evaluate calls on one rule.

Releases Facts in store main without #46 main + #46 This PR
10 0 10 ms 1.6 ms 0.46 ms
200 0 125 ms 7.8 ms 1.4 ms
50 16,000 — 2.4 ms 0.6 ms
50 160,000 474 ms — 0.5 ms

Tests

test/config/ConfigStoreReads.test.ts:

  • Differential: a five-release sequence produces the same snapshot IDs, sequence numbers, parents and minted revisions as the pure InMemoryConfigStore. That includes a release that only proves an existing body valid under a wider schema, and a revert to an earlier release. Afterwards the full load's revisions, objects, schemas and seq match.
  • Cross-instance: a second ConfigStore over the same triples commits, moves refs, and extends validUnder on a node already in a cached snapshot. Every resolveRef and snapshotById on the first instance still deep-equals what load() reconstructs, including node types. With the validUnder freshness check disabled, this test fails.
  • No scans: resolving a ref (twice) and reverseDependencies issue no entity-type reads.

All existing config, CLI, HTTP, dashboard, example and integration tests pass unchanged. pnpm check is green.

🤖 Generated with Claude Code

bjacobso and others added 2 commits October 30, 2026 22:00
Public Triples reads always resolve a temporal basis before reaching the
KV layer, so the entity-type fast path (basis === undefined) never ran
for them: every match({ entityType }) became a temporal scan of the
whole store, journal included. ConfigStore.load issues five of these,
so each ConfigRuntime.evaluate scanned every fact five times.

Add getByEntityTypeTemporal (and its async twin) to KvTripleStore: read
the TYPE index, then keep the datoms visible at the basis. Retraction
only rewrites META, so the index still names every datom asserted with
the type and visibleAt decides recorded and valid time as before.

On an in-memory store with 16,000 facts, ConfigRuntime.evaluate drops
from 480-985 ms to 1-3 ms. Found while running Triplex in the browser
behind WorldVM's programmable-operations demo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every `resolveRef`, `snapshotById`, `commit` and `setRef` called `load()`, which reads every
config record and rebuilds every snapshot's node tree. `ConfigRuntime.evaluate` therefore grew
with the whole config history, and on a large in-memory store each of its five entity-type reads
scanned every fact.

- `resolveRef` reads the ref entity, then `snapshotById` reads the snapshot record and only the
  objects reachable from its root, one batched `entities` read per tree level.
- Object bodies, type expressions and snapshot records are content-addressed or immutable, so they
  are cached by identity for the life of the service. A decoded node's `type` comes from
  `validUnder`, which later releases can extend, so decoded trees are cached only until
  `:triplex/valid-under` changes. One `dependencyState` call checks that, which stays correct when
  another process commits.
- `commit` gives the pure transition a partial store: the input subtrees' stored objects and
  schemas, those objects' revision histories, and the latest snapshot. It checks objects the
  transition mints outside the inputs (the snapshot root) before persisting them.
- `setRef` and `reverseDependencies` read single records instead of loading.

`ConfigStore.commit` no longer returns a `store`; `load()` is unchanged for whole-store views.

Tests pin the new reads to the full-load semantics: a release sequence produces the same snapshot
IDs and revisions as the pure store, resolved refs and snapshots equal the full load while a second
instance commits, moves refs and extends `validUnder`, and resolving a ref issues no entity-type
scans.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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