Post #6 · Understand TGMS

Why TGMS Needed a Native Storage Engine

Bi-temporal corrections, immutable segments and deterministic operators required a storage layout designed around TGMS semantics.
In one sentence: TGMS keeps history in files written once and never edited, with corrections recorded separately and folded in later — because no backend we could adopt met all of what TGMS promises at once.

Why this matters

TGMS makes four promises at once, and the conjunction is the hard part:

Most systems deliver any three. We shipped on DuckDB and Kùzu for months and each broke on a different one: versioning was emulated above the storage layer, making the belief clock a query convention rather than a property of the data; identity was re-derived per row; memory was whatever the engine chose. Kùzu's acquisition settled the timing, not the question.

The idea in plain language

The design rests on one choice: finished files are never edited. An accountant correcting a closed ledger does not overwrite the page — the closed book is evidence of what was believed then. They write an adjusting entry superseding the old figure, and later issue a consolidated statement folding the adjustments in.

TGMS stores history that way. A segment is a closed page: relationship versions written once in a compressed columnar layout, never modified. A correction record is the adjusting entry — an append-only note that some version stopped being believed at a given moment, original untouched. A manifest is the index: which segments and correction records constitute the store at one point in its history. A commit is a new manifest; a reader pins one, and because everything it names is immutable, its view cannot shift however many writes land meanwhile.

event log append-only immutable segments correction records manifest one generation operators pinned, lock-free compaction folds patches into fresh segments, later, off the write path Nothing left of the manifest is ever rewritten in place.
How a correction reaches a reader without editing anything. Compaction is a space optimisation, not a correctness step — answers are identical either way, and that equality is checked. Storage layout only.

What immutability buys

"Easier to reason about" is a slogan. The specific list, each item otherwise its own subsystem:

What we measured

Evidence. Question: what does this layout cost in stored bytes, and where does a large scan's time go? Workload: one synthetic bi-temporal event log containing corrections, replayed identically into every system, at 1,000,000 events (1,000,269 relationship versions) and 10,000,000. Systems: TGMS native, TGMS-on-DuckDB, PostgreSQL 16.14, ClickHouse 26.8 — one 40-core host, tuned, warm. Metrics: whole store on disk ÷ relationship versions in bytes, and median latency in ms; lower is better for both. Held constant: the event log, so identifiers and transaction times are identical everywhere. Latency cells reproduce to about ±20% between days, so smaller differences are ties. Snapshots registry-v2-1m-4sys and registry-v2-10m-3sys.

The storage result

The denominator matters more than the numerator. Bytes per relationship version is the whole store directory on disk — segments, manifests, correction records, dictionary — divided by the 1,000,269 relationship versions it holds.

TGMS native ClickHouse DuckDB PostgreSQL 25.1 B 78.4 187.7 549.7 B/row 1M event log, 1,000,269 relationship versions · whole store on disk ÷ rows Bars start at zero; lower is better. Compaction takes native to 24.6 B/row. These systems do not store equivalent things — see caption.
TGMS native holds the same logical content in 25.1 bytes per relationship version against ClickHouse's 78.4, DuckDB's 187.7 and PostgreSQL's 549.7 (registry-v2-1m-4sys, one run, one host). The comparison does not hold compression or indexes equivalent: all four are durable, but ClickHouse is lz4 with no secondary indexes, DuckDB is uncompressed, two thirds of PostgreSQL's total is covering indexes, and TGMS keeps its query indexes in memory rather than on disk — so its number buys the least query readiness, paying for that in a slow first query and a tax on later scans (post #10).

One case study, and it is a null result

Performance work produced a long table of hypotheses that measurement refuted; it lives in engine_lessons.md, because a chronology of misdiagnoses is a reference, not an explanation. One entry earns space here.

A full-window scan at ten million events was slow, diagnosed as bound by materialisation — copying selected rows out of the columnar layout — so materialisation was parallelised across threads. The number went from 811 ms to 819: nothing.

A fifteen-minute stage probe explained why. The parallel path could run only when selected row ranges from different segments did not overlap. That check was all-or-nothing, and it never passed: a correction writes a superseding version into a segment whose key range overlaps the original's, so one correction anywhere fails the check for the whole scan. The fast path was not underperforming; it was unreachable on any store containing a correction — which is every store TGMS is built for.

We had optimised for a store with no corrections. The system exists because real stores have corrections.

The fix was to stop treating disjointness as global: group selections into clusters of overlapping key ranges, materialise clusters in parallel, sort-merge only within one. At ten million events 370 of 371 clusters are singletons. The scan fell to 330 ms, and the parallel code that had measured as worthless became the thing doing the work.

before + parallel materialise + cluster-wise merge 811 ms 819 ms 330 ms ▲ the fast path fired 0 times: one overlapping correction segment fails an all-or-nothing check 10M events, 40 cores, scan stage of the bucketed-count query.
The middle bar is the finding: correct parallel code that changed nothing because its precondition was never met on a corrected store. Amber marks the measurement that refuted its own hypothesis; red the failing condition, not a slow result. Engine stage timings from one probe run — a waypoint, since later projection work took the query above this scan to 84.7 ms end-to-end.

What this means in practice

What you gain. Corrections are cheap and never destroy history (4.66 ms against a 100k-row store). Readers are lock-free and see a consistent store without coordinating with writers — a reader opens with read_only=True, pins the generation it opened, and a live writer costs it 0-3% of query latency. Answers reproduce across backends and thread counts, which is what makes post #7's six-system comparison possible. A 10M-event suite fits under a 2 GB cap (1.76 GB peak).

What you still pay — three structural costs:

The commitment that is not a feature. A custom storage engine is not finished when it is fast. It means owning crash recovery and its testing, on-disk format compatibility for every future version that must open old stores, the observability a mature engine ships for free, and the tooling around compaction and backup. None of that appears in a benchmark table; over a multi-year horizon it is the larger number.

What this result does not show

Takeaway

The engine exists because bi-temporal correction, deterministic replay, temporal operators and bounded embedded memory are one requirement rather than four, and nothing available satisfied the conjunction. Immutable segments plus append-only correction records makes it cheap: consistency becomes an integer, a correction becomes an append. The bill arrives as commit latency, a slow first query, and a maintenance commitment measured in years.

Evidence and reproduction

Receipts. Storage accounting and the scan stage probe: eval_phase0.md; write path, corrections and compaction: eval_writes.md; warm-up and memory: eval_resources.md; the refuted-hypothesis table: engine_lessons.md. Raw records in benchmarks/results-v1/. The engine is validated against a 500-case brute-force oracle it was never allowed to modify.

Continue reading