Repository navigation
Conversation
`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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
WorldVM's programmable-operations demo runs Triplex core in memory, in the browser. Committing a large account was the bottleneck. On
mainit 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:
Headline numbers (160,000 facts)
Node 24, 4 GB heap, 4 vCPU Linux VM. "Read" is 25
matchcalls (every attribute) at onevalidAt. "Memory" is the V8 heap plus array buffers after GC. Full tables for 16k, 80k and 160k facts are intest/stress/README.md; reproduce them withpnpm --filter triplex-stress benchmark:memory.main, onetransactmain, 4,000-facttransactstransacttransactsImport.run, summary journal + refAt 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
mainbaseline.recordedPositionin the publicTemporalBasis. 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.assertBatchper 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.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 }.journal: "summary". One:_tx/summary(counts, attributes, content digest) instead of one:_tx/changeper fact.Import.run. Chunked commits that yield to the host between chunks, then publish by moving the ref.Step 2: segments
Sha256.create()hasher.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 isContentId("triplex/segment", canonical JSON of the facts).encodeanddecodeproduce a single buffer whose columns are viewed in place.Triplescontract, plusImport.run(..., { store: "segments" }), tests anddocs/segments.md.Follow-ups
transactionsForEntityscopes 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,dependencyStateand Datalog see attached segments merged with transacted facts:Datalog's
?txbinds the attaching transaction.Retracting a segment fact works and stays in history:
historyshows it retracted, and the retraction's journal entry lists it;Journal.
transactions()returns one attach record (summary withsegments: [id], digest equal to the segment ID, no changes).transaction(txId)derives all per-fact changes from the segment.transactionsForEntitymerges attachments containing the entity with indexed transactions, including paging.Refs and imports (both
store: "transactions"and"segments"):Content addressing.
ContentId.hashoverCanonicalJsonof the facts (cross-checked against the generic encoder).decode(bytes, { verify: true })rejects tampered images.Gating. Attaching on SQLite fails with
WriteErrorand 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 checkis 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-sensitiveReactor"stays linear" test under load; it passes alone and on rerun.Design trade-offs
Details are in
docs/segments.md.:_tx/changeor:_tx/changed-entitydoesn't see attached facts.{ ref }field could straddle a move.TransactionResult.positionand 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.triplesrows by bulk insert while keeping the attach-shaped journal.WriteError.What didn't pan out, or isn't done
transactitself 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 fastertransact.crypto.subtle.digestwould be far faster but is asynchronous, andbuildis synchronous by design. An async build variant is the obvious next step.heapUsed, so all rows now count array buffers. That raisedmainfrom 4.6 to 5.2 KB per fact.ConfigStoretargeted reads). Whichever merges first, the others rebase cleanly.🤖 Generated with Claude Code