Skip to content

In-memory bulk loads: segments attached in one transaction, position refs, faster KV writes - #47

Draft
bjacobso wants to merge 13 commits into
mainfrom
conductor/large-incremental-commits-in-memory
Draft

bjacobso wants to merge 13 commits into
mainfrom
conductor/large-incremental-commits-in-memory

Conversation

@bjacobso

@bjacobso bjacobso commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Why

WorldVM's programmable-operations demo runs Triplex core in memory, in the browser. Committing a large account was the bottleneck. On main it costs 230–550 µs and about 5.2 KB of memory per fact. Loading 10,000 workers (160k facts) in one transaction runs out of a 4 GB heap. In chunks it takes 41 s.

This PR measures that path, makes ordinary writes faster, and then prototypes the git-shaped design:

  • build an immutable, indexed, content-addressed segment in bulk;
  • attach it in one small transaction;
  • publish by moving a named ref to a commit position.

Headline numbers (160,000 facts)

Node 24, 4 GB heap, 4 vCPU Linux VM. "Read" is 25 match calls (every attribute) at one validAt. "Memory" is the V8 heap plus array buffers after GC. Full tables for 16k, 80k and 160k facts are in test/stress/README.md; reproduce them with pnpm --filter triplex-stress benchmark:memory.

Path Build (can run off-thread) Commit First read at ref Read Memory/fact
main, one transact out of memory
main, 4,000-fact transacts 40.7 s 186 ms 1,495 ms 5.2 KB
this PR, one transact 9.7 s 172 ms 1,699 ms 5.3 KB
this PR, 4,000-fact transacts 6.8 s 147 ms 1,545 ms 5.0 KB
Import.run, summary journal + ref 2.8 s 105 ms 1,450 ms 2.5 KB
Segments, built and attached + ref 650 ms 34 ms 7.5 ms 93 ms 80 B
Prebuilt segment image + ref 13 ms 7.0 ms 85 ms 79 B

At 16k facts, segments build in 100 ms, attach in 13 ms and read everything in 17 ms. Memory per fact is about 65 times lower, and reads are about 16 times faster, because segment reads binary-search typed arrays instead of decoding META records.

Commits

Step 1: ordinary writes, read cuts, refs

  1. Benchmark with a main baseline.
  2. recordedPosition in the public TemporalBasis. A read at a commit position sees exactly the transactions committed at or before it, which stays exact when commits share a millisecond. Paged reads clamp it to the latest commit, and derivation runs persist it.
  3. Batched KV transaction writes. No semantic change: one assertBatch per transaction (flushed before anything that reads), string-keyed bulk buffering, and an incrementally merged sorted key index. This is the 41 s → 6.8 s row.
  4. PositionRef (@triplex-build/triplex/operational). A named pointer to a committed position. Each move is one journaled transaction with compare-and-retract, an optional expected old position, and an optional manifest. Moves past the latest commit are rejected. basis() resolves it once into { recordedPosition, validAt }.
  5. journal: "summary". One :_tx/summary (counts, attributes, content digest) instead of one :_tx/change per fact.
  6. Import.run. Chunked commits that yield to the host between chunks, then publish by moving the ref.

Step 2: segments

  1. Faster SHA-256 (2.7×, about 55 MB/s) plus an incremental Sha256.create() hasher.
  2. Segment. A pure, worker-safe build of a sorted dictionary, typed-array columns in EAVT order, and permutation indexes (AEVT, AVET, VAET, entity type). The ID is ContentId("triplex/segment", canonical JSON of the facts). encode and decode produce a single buffer whose columns are viewed in place.
  3. Attach and merge-on-read behind the unchanged Triples contract, plus Import.run(..., { store: "segments" }), tests and docs/segments.md.
  4. Benchmark modes for segments and prebuilt images.

Follow-ups

  1. kv: answer entity-type matches at a temporal basis from the TYPE index #46 cherry-picked (entity-type matches at a temporal basis read the TYPE index). Segments join that path through their type index, with the same visibility check. A segment fact retracted later stays visible at an earlier basis; a new test fails without this.
  2. Sorted-key merges use binary search when a commit adds few keys to a large partition, copying the runs between them without comparing. A read after a small write at 160k facts was spending about 35 ms per touched index on string comparisons.
  3. transactionsForEntity scopes attach records to the entity. An attach record lists only that entity's facts from the segment, so history pages stay small however large the segment is. transaction(txId) still derives every change.

Semantics and tests

  • Segment reads. match, entity, entities, entityPage, get, history, dependencyState and Datalog see attached segments merged with transacted facts:

    • at recorded instants and positions before and after the attach;
    • at valid times either side of closed intervals;
    • by attribute, attribute+value, ref target and entity type;
    • in joins with transacted facts, with Datalog binding the attaching transaction.

    Datalog's ?tx binds the attaching transaction.

  • Retracting a segment fact works and stays in history:

    • it is visible before the retraction's position, hidden after;
    • history shows it retracted, and the retraction's journal entry lists it;
    • retracting it twice is a no-op.
  • Journal.

    • transactions() returns one attach record (summary with segments: [id], digest equal to the segment ID, no changes).
    • transaction(txId) derives all per-fact changes from the segment.
    • transactionsForEntity merges attachments containing the entity with indexed transactions, including paging.
    • Command IDs are idempotent across attaches.
  • Refs and imports (both store: "transactions" and "segments"):

    • readers at the ref see nothing partial, checked after every chunk of a second generation;
    • moving the ref is exactly one transaction;
    • concurrent and stale moves conflict;
    • reading at the previous ref position is unchanged.
  • Content addressing.

    • The same facts in any order give the same ID.
    • The ID equals ContentId.hash over CanonicalJson of the facts (cross-checked against the generic encoder).
    • decode(bytes, { verify: true }) rejects tampered images.
    • Randomized lookups match a naive filter on every access path.
  • Gating. Attaching on SQLite fails with WriteError and commits nothing. Reserved namespaces are rejected.

The step 1 cases are in the shared conformance corpus, so they run on in-memory KV, SQLite, Cloudflare (Node harness and workerd) and PostgreSQL in CI. Segment tests are in-memory only, as the feature is.

Checks: pnpm check is green, and every commit typechecks and passes the core suite on its own.

Not run locally:

  • pnpm test:postgres:integration (no Docker here; CI runs it);
  • pnpm pack:check;
  • pnpm test:e2e.

Timeouts and flakes. I raised the whole-corpus conformance tests' timeouts to 30 s, as the testkit already does: the corpus grew, and those tests crossed Vitest's 5 s default under pnpm check's parallel load. One run hit the existing timing-sensitive Reactor "stays linear" test under load; it passes alone and on rerun.

Design trade-offs

Details are in docs/segments.md.

  • Segment format. Columns over a sorted dictionary make comparisons integer comparisons, and keep a fact at about 60 bytes of columns plus its share of the dictionary. Indexes are permutations, so they're derivable and rebuildable. The binary image is the columns 8-byte aligned, so attaching a shipped dataset costs a decode of the dictionary JSON and no sorting.
  • Recorded time comes from the attach, not the segment. The same segment attached twice is two attachments with different positions and triple IDs. Valid time stays data.
  • Merge-on-read cost is O(attachments × log n) plus the matching facts. Segment facts are decoded per read (no cache, so memory stays flat). Chunked imports create one attachment per chunk; repacking (not built) would merge them while keeping each fact's attachment as a column.
  • Journal shape. The attach envelope keeps actor, command, correlation, causation and position, plus a summary naming the segment.
    • Per-fact changes and the entity index are derived from the segment on demand rather than stored.
    • What's lost: the journal alone can't rebuild the facts (the body must survive), and Datalog over :_tx/change or :_tx/changed-entity doesn't see attached facts.
    • Derivation relevance uses the summary's attributes; emission and snapshots use the derived changes.
  • Ref semantics.
    • Resolve once, read many: a per-read { ref } field could straddle a move.
    • Positions are a total commit order, so a published position is a consistent cut, and it includes other writers' commits up to that point.
    • Moving backwards is a rollback.
  • What a position basis exposes. Commit positions were already public, as TransactionResult.position and the feed cursor. A position past the latest commit reads the latest state, so only committed positions are stable; refs only ever hold committed ones.
  • Other backends.
    • FoundationDB/KV: chunked body keys plus the same attachment record and overlays.
    • SQL: a segments table, with the attach expanding into triples rows by bulk insert while keeping the attach-shaped journal.
    • Both are designed only; they currently fail with WriteError.

What didn't pan out, or isn't done

  • Making transact itself cheap enough. Batching gave 6× on time, but memory stays about 5 KB per fact with the full journal (2.5 KB with the summary journal). Per-fact string keys, serialized META and the decoded-datom cache are the floor of the KV emulation. Segments are the answer for bulk data, not a faster transact.
  • Content hashing dominates segment build time (about half of the 650 ms). crypto.subtle.digest would be far faster but is asynchronous, and build is synchronous by design. An async build variant is the obvious next step.
  • Not built:
    • a worker helper (the build is worker-safe, but hosts own the plumbing);
    • repacking;
    • segment storage for FoundationDB/SQL;
    • constraint enforcement on attaches (rejected explicitly).
  • The heap figures in my first PR description undercounted. Typed-array backing stores sit outside heapUsed, so all rows now count array buffers. That raised main from 4.6 to 5.2 KB per fact.
  • PR kv: answer entity-type matches at a temporal basis from the TYPE index #46 is cherry-picked here, and in ConfigStore: read only what each operation needs #48 (ConfigStore targeted reads). Whichever merges first, the others rebase cleanly.
  • WorldVM pins Effect 4.0.0-rc.112 and won't consume this until its Effect upgrade lands.

🤖 Generated with Claude Code

bjacobso and others added 10 commits October 29, 2026 18:12
`pnpm --filter triplex-stress benchmark:memory` loads about 16k, 80k and 160k scalar facts with
valid-time intervals into in-memory `KvTriples` (25 attributes, 16 facts per subject, some closed
intervals), then reads every attribute at one `validAt`. Each size runs in a fresh Node process
with a 4 GB heap, so an out-of-memory load is reported rather than crashing the run.

The README records the baseline from `main`: 247-675 µs and about 4.6 KB of heap per fact, and
160k facts in one transaction run out of a 4 GB heap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`TemporalBasis` gains `recordedPosition`. A read at a commit position sees exactly the
transactions committed at or before it, which is exact even when commits share a millisecond;
with `recordedAt` as well, a fact must be visible under both. Every backend already honoured the
field internally for snapshot-stable pagination, so this exposes and validates it.

Paged Datalog and entity pages clamp a requested position to the latest commit and keep it in
their cursor. Derivation runs persist it, and journal-based freshness and valid-time scans skip
transactions after it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Inside `KvTriplesLive.transact`, assertions are collected and written with one `assertBatch`
(flushed before any operation that reads, so a later retraction still sees them), instead of one
`Effect` per index entry. `KvTransaction` gains optional `setAll` and `setAllStr`; the in-memory
backend buffers string-keyed batches as given and hashes each key once, at commit.

The in-memory backend no longer re-sorts a whole partition after every commit: new keys are
merged into the sorted array on the next range scan, overwrites don't change the order, and only
a delete forces a rebuild. Partitions are indexed by array, ASCII strings encode without
per-character concatenation, and batch entries are no longer pre-allocated.

In-memory benchmark, `main` → this commit (Node 24, 4 GB heap):

| Facts   | One transaction        | 4,000-op chunks      |
| ------- | ---------------------- | -------------------- |
| 16,000  | 4.6 s → 0.99 s         | 4.0 s → 0.78 s       |
| 80,000  | 54.0 s → 5.5 s         | 22.9 s → 3.5 s       |
| 160,000 | out of memory → 9.2 s  | 45.2 s → 7.1 s       |

Heap per fact is unchanged at about 4.6 KB; reads are 0-15% faster.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`PositionRef` in `@triplex-build/triplex/operational` stores a named pointer to a committed
position as a reserved entity. `move` points it somewhere in one journaled transaction, with
compare-and-retract against concurrent moves, an optional expected old position (`null` for
"not created yet", claimed atomically), and an optional JSON manifest. Positions past the latest
commit are rejected. `basis` resolves a ref once into `{ recordedPosition, validAt }`, and `get`
reads a ref as it stood at an earlier recorded instant or position.

This is the read side of publishing a multi-transaction load: readers at the ref see the
previous publication until the ref moves.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`TransactionMeta.journal: "summary"` records one `:_tx/summary` instead of one `:_tx/change` per
fact: assertion and retraction counts, the sorted changed attributes, and a `ContentId` digest of
the changes that excludes generated IDs, so the same input gives the same digest. The envelope,
commit position, command receipt and every `:_tx/changed-entity` reference are kept, so the feed
and `transactionsForEntity` are unchanged. The record has an empty `changes` list and a `summary`,
which the transaction result also carries.

What is lost is replaying the journal alone to rebuild those facts; the facts themselves keep their
full bitemporal history. Derivation relevance uses the summary's attributes, journal-based
valid-time scans read those attributes through `dependencyState`, and change emission and entity
snapshots fall back to the submitted operations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`Import.run(triples, facts, { ref, chunkSize })` in `@triplex-build/triplex/operational` commits
a dataset (an iterable or a `Stream`) as one transaction per chunk, yielding to the host's event
loop between chunks so a browser keeps painting. Chunks default to the summary journal and share
the import ID as their correlation ID. With `ref`, one final transaction moves the ref to the last
chunk's position and stores the import manifest: counts, position range, and a digest over the
chunk digests. Readers at the ref never see a partial import; readers at the latest state see
chunks as they commit, and a failed import leaves its chunks unpublished.

The benchmark gains an `import` mode (4,000-fact chunks, read at the published ref):

| Facts   | `main`, chunked | chunked transact | import  | heap/fact          |
| ------- | --------------- | ---------------- | ------- | ------------------ |
| 16,000  | 4.0 s           | 0.78 s           | 0.39 s  | 4.7 KB → 2.5 KB    |
| 80,000  | 22.9 s          | 3.5 s            | 1.8 s   | 4.5 KB → 2.3 KB    |
| 160,000 | 45.2 s          | 7.1 s            | 3.5 s   | 4.5 KB → 2.3 KB    |

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The browser-safe SHA-256 compressed each block through a `DataView` and a spread copy of the
state. It now reads words with shifts and keeps the state in locals with signed 32-bit
arithmetic, which keeps V8 on its integer path: about 55 MB/s instead of 20 MB/s. `Sha256.create`
hashes text and bytes incrementally, so large content (an import segment) doesn't have to become
one string. `Sha256.hex` is unchanged and still checked against `node:crypto`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`segment/Segment.ts` builds a batch of facts into one immutable segment in a single pass: a
sorted string dictionary, typed-array columns in canonical (entity, attribute, value) order, and
permutation indexes for the attribute, attribute-value, ref and entity-type access paths. Valid
time stays data; recorded time is supplied later, by the transaction that attaches it.

A segment's ID is a `ContentId` over the canonical JSON of its facts in segment order, so the same
facts in any input order give the same ID, and `verify` recomputes it. `encode` writes one
transferable buffer and `decode` views its columns in place, without copying or re-sorting.
`build` is pure, so a host can run it in a worker.

This commit is the data structure only; attaching segments to a store follows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`Segment.attach(triples, segment)` (`@triplex-build/triplex/operational`) adds a segment's facts
in one ordinary transaction: it allocates a position, stores the content-addressed body in the
backend's new `SegmentStore`, writes one attachment record, and journals one envelope whose
summary names the segment. Every fact takes the attachment's recorded position, instant and
transaction ID; its triple ID encodes the attachment and ordinal.

Reads merge attached segments with indexed facts on every path: match, entity, entities,
entityPage, history, temporal scans, dependencyState and Datalog. They binary-search each
segment's narrowest index and skip attachments and facts outside the basis before decoding.
Retracting a segment fact writes its datom to META, which overrides the immutable copy, so the
retraction is ordinary history.

The journal stores one attach record. `transactions()` returns it with the summary and no
changes; `transaction(txId)` derives the per-fact changes from the segment on demand; and
`transactionsForEntity` finds attachments through their segments. Change emission and entity
snapshots now use derived changes when a summary record has them.

`Import.run(..., { store: "segments" })` builds and attaches one segment per chunk, yielding
between chunks, then moves the ref. Only the in-memory KV backend has a segment store; other
KV backends and the SQL backends fail an attachment with `WriteError`. docs/segments.md covers
the format, merge-on-read cost, journal shape, what audit keeps and loses, and how the other
backends would store a segment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The memory benchmark gains `segments` (build and attach a segment per 4,000 facts, then publish
the ref) and `image` (decode, attach and publish one prebuilt segment), and reports build time,
commit time and time to the first read at the ref separately. Memory now counts array buffers as
well as the V8 heap, since typed-array backing stores live outside it; that raises every row,
including `main`'s, to about 5.2 KB per fact.

At 160,000 facts, against `main`'s 40.7 s and 5.2 KB per fact for chunked transactions:
segments build in 0.65 s (off-thread work), attach in 34 ms, read everything in 93 ms instead of
1.5 s, and use 80 bytes per fact. A prebuilt image decodes, attaches and publishes in 13 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bjacobso bjacobso changed the title In-memory bulk commits: faster KV writes, position basis, refs, summary journal, incremental import In-memory bulk loads: segments attached in one transaction, position refs, faster KV writes Oct 29, 2026
bjacobso and others added 3 commits October 30, 2026 22:13
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>
After a small commit, the next range scan merged its new keys into the partition's sorted array
with one string comparison per existing key. Keys share long prefixes and new ULID-suffixed keys
sort near the end, so a read after each small write cost about 35 ms per touched index at 160k
facts. When the new keys are fewer than a sixteenth of the partition, find each one's place by
binary search and copy the runs between them without comparing.

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

Entity-type matches at a temporal basis now read the TYPE index (the cherry-picked fix from
#46). Attached segments join that path through their own type index, with the same visibility
check, so a segment fact retracted later is still visible at an earlier basis.

`transactionsForEntity` hydrated an attachment's record with every fact in its segment. It now
lists only that entity's facts, found by binary search, so an entity's history page stays small
however large the segment is. `transaction(txId)` still derives the complete change list.

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