How the transactor's single-writer commit path was scaled, what it measures today, and the next steps. Companion to log-and-transactor.md; that document is the authoritative description of the pipeline, this one records the performance arc and the open work.
The transactor is one logical writer per database (the serialization point
that defines transaction order t). Every commit ran the whole critical
section — validate, durable append, ownership fence — serially, so
per-database write throughput was bounded by the sum of those stages on one
core plus the I/O wait, and offered concurrency only queued behind it.
Three changes, each independently measured (all through
crates/corium-transactor/examples/tx_throughput.rs, see
tx-throughput.md):
PostgreSQL/Turso/S3 log stopped
rewriting a growing chunk object per commit (a read-modify-write whose cost
grew with the chunk) and now writes one create-only object per
transaction — a row insert on SQL, a create-only PUT on S3. Append went
from O(chunk) to O(1). Older chunk-format logs are still read read-only, so
existing databases replay after an upgrade with no migration.t, report, and ack).
A batch is one atomic log object, so a takeover's cutoff keeps all or none
of it. The prepare step stays serial (each transaction validates against a
staging value that already includes its predecessors), so semantics are
unchanged.NodeConfig::max_commit_batch (count, default 256) and
max_commit_batch_bytes (default 4 MiB). The pre-append lease check was
dropped from the common path — the post-append fence is the safety-critical
one — removing a lease round trip per batch; only a keyword-interning batch
re-checks ownership first (it publishes the unfenced metadata root).Relative baseline from one setup: a laptop client driving a LAN PostgreSQL
(and a MinIO S3 endpoint) on a small home server, so the remote round trips
are real network + disk, not loopback. Numbers are hardware/network specific
and single-shot — treat the shape as the result, not the absolute figures.
Re-record on a change of machines.
Peak write throughput (transactions/second):
| stage | PostgreSQL | S3 (MinIO) |
|---|---|---|
| original (chunk-rewrite log, no batching) | ~7 (flat) | ~4.8 (flat) |
| + per-transaction log | 26 (conc 1) | 38 (conc 1) |
| + group commit (cap 64) | 1,598 (conc 64) | 912 (conc 32) |
| + configurable cap, dropped pre-check | ~6,500 (conc 256) | (not re-swept) |
The latency story matters as much as throughput. Originally, latency grew
linearly with offered concurrency (queueing): PostgreSQL p50 at concurrency
32 was ~4,700 ms. With group commit, p50 stays roughly flat as concurrency
rises (~25–40 ms) because a batch amortizes the durability cost across its
transactions — throughput scales with concurrency while per-commit latency
does not. Under no contention a batch is size 1, so low-load latency is
unchanged.
Two rules the sweep established:
max_commit_batch ≥ expected peak concurrency. A cap below the
offered concurrency splits the queued work into serialized batches, doubling
latency and cutting throughput; above it, the batch is bounded by offered
load anyway.Throughput has gone sublinear at high concurrency (on the reference setup, concurrency 128→256 was ~1.3× rather than 2×) and p50 rises with batch size. A batch of 256 commits in ~32 ms → a theoretical ~8,000 tx/s, but ~6,500 is observed. Two costs inside the leader's serial section account for the gap:
batch_size / flush_time, not
concurrency / op_latency.:db/fn enabled (the
default), every transaction in a batch runs its own
spawn_blocking(expander.expand) — N blocking-pool hops per batch, even
for transaction data containing no function call — inflating the serial
prepare loop as batches grow.Roughly in increasing effort / decreasing certainty of payoff:
spawn_blocking (or skip the expander for
transactions with no :db/fn), removing the N blocking-pool hops per batch.
Contained, directly attacks the large-batch prepare cost above. Likely the
quickest remaining win.concurrency / op_latency rather than batch_size / flush_time. This is
the structural ceiling-lifter, but the larger change: transaction-number
assignment stays serial while durable writes overlap, and the ownership
fence and in-memory install must still apply in t order. Depends on a
backend that supports concurrent writes (natural for S3's independent
PUTs; a small connection pool for SQL).NativeVersionedLog batches on the native
backends; the filesystem VersionedLog still fsyncs per record under the
default append_batch_async. A batch override (write all framed records,
one fsync) would extend group commit's win to the fs backend.log-root chunks and reclaim the small
objects, bounding replay and list cost (see the object-store log layout in
log-and-transactor.md).Measurement note: the write-throughput comparison is only meaningful across
independent machines (client separate from store) with the store's real
durability (synchronous_commit=on for PostgreSQL, enforced conditional
writes for S3). Record the round-trip latency alongside each run.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |