Skip to content

grust-turso: durable group commit, parallel MVCC loads, FK-off loads, and a WAL/MVCC head-to-head - #6

Merged
alexy merged 42 commits into
mainfrom
turso-mvcc-concurrency
Sep 18, 2026
Merged

alexy merged 42 commits into
mainfrom
turso-mvcc-concurrency

Conversation

@alexy

@alexy alexy commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

grust-turso: durable group commit, parallel MVCC loads, FK-off loads, and a WAL/MVCC head-to-head

Pinned engine: Turso 0.7.2 (crates.io max_stable_version; every 0.8.0 is a
pre-release). Several claims below are version-scoped against upstream main
(0.8.0-pre.11, 1,859 commits ahead), measured the same day. Where a number is
from main it says so.

What this does

  1. Client-side group commit (with_group_commit) batches concurrent
    single-statement writes into one transaction: one fsync per batch, every
    acknowledged write durable. GROUP_COMMIT_MAX_STATEMENTS = 256.
    Worth 6.6x on 0.7.2; costs 19% on Turso 0.8, which has engine-level
    group commit on by default -- see "Version scoping".
  2. Parallel MVCC bulk loads (set_mvcc_load_parallelism, put_mvcc_parallel):
    N writers under BEGIN CONCURRENT on disjoint slices, checkpointing between
    rounds (MVCC_PARALLEL_ROUND_GROUPS = 5) so peak memory is bounded.
  3. Loads run with foreign keys off on every path (3f1ed19). Before this,
    WAL and single-writer MVCC loads enforced REFERENCES nodes(id) and parallel
    MVCC writers (from connect_shared) did not, so whether a dangling edge was
    accepted depended on the writer count. Now every path matches the Memory
    reference (an endpoint may exist only as an edge end); ON DELETE CASCADE
    still fires on the deleting connection, and a single put_edge to a
    missing node is still refused. tests/dangling_edges.rs checks all three
    paths and the restored strictness. This also roughly doubled WAL loads
    (below).
  4. Supporting surface: connect_shared(), TursoSynchronous/set_synchronous(),
    set_mvcc_group_commit() (Turso >= 0.8 pragma, errors on 0.7.2; lets the
    client committer be switched off when the pin moves), a Busy backoff in
    all three concurrent-retry loops (acac24e; loads measured zero retries in
    eight full-graph runs, so this removes a latent hot loop, not throughput),
    query_scalar_i64(), and an opt-in WAL load path (set_bulk_load_via_wal)
    with live WAL<->MVCC conversion, test-verified on 0.7.2 and on main.

Results (Turso 0.7.2, harness pin f6d3391 -- see Provenance)

A4 = 16 writers x 200 edges to one hot node, synchronous=FULL.

host dataset backend edges/s peak RSS A4 accepted A4 wall
quegee (16c) sx-stackoverflow turso-mvcc 18,504 19.2 GB 3,200/3,200 6.54 s
quegee soc-LiveJournal1 turso-mvcc 19,749 19.8 GB 3,200/3,200 4.81 s
quegee com-Orkut turso-mvcc 16,046 22.5 GB 3,200/3,200 1.82 s
quegee com-Orkut turso-wal 24,666 12.4 GB 61 1.05 s
quegee soc-LiveJournal1 turso-wal 31,256 10.3 GB 119 1.29 s
quegee sx-stackoverflow turso-wal 31,243 9.9 GB 50 1.46 s
quegee GAP-road turso-wal 38,656 10.8 GB 37 0.33 s
quegee cit-Patents turso-wal 38,076 6.8 GB 32 0.24 s
quegee soc-Pokec turso-wal 38,286 4.4 GB 98 0.80 s
quegee web-Google turso-wal 46,905 3.3 GB 79 0.20 s
grust (2xl) web-Google turso-mvcc 30,985 7.1 GB 3,200/3,200 3.99 s
grust cit-Patents turso-mvcc 26,661 10.7 GB 3,200/3,200 4.31 s
grust soc-Pokec turso-mvcc 26,385 9.9 GB 3,200/3,200 4.35 s
grust GAP-road turso-mvcc 27,708 23.8 GB 3,200/3,200 4.41 s
grust sx-stackoverflow turso-mvcc 13,464 13.2 GB 3,200/3,200 5.28 s
eigen (2xl) soc-LiveJournal1 turso-wal 28,844 11.3 GB 120 1.58 s
eigen com-Orkut turso-wal 22,781 12.4 GB 20 1.16 s

All cells pass; duplicate_durable_mutation = 0; quegee is a c5n.4xlarge
(no CPU steal); grust/eigen are t2.2xlarge and these particular cells ran at
<0.1% steal (checked in host_steal_us).

The headline is not the load rate. Same host (quegee), same binary, the
three largest graphs:

dataset MVCC e/s WAL e/s WAL load MVCC A4 WAL A4
com-Orkut (117 M edges) 16,046 24,666 1.54x 3,200/3,200 in 1.82 s 61/3,200
soc-LiveJournal1 (69 M) 19,749 31,256 1.58x 3,200/3,200 in 4.81 s 119/3,200
sx-stackoverflow (63 M) 18,504 31,243 1.69x 3,200/3,200 in 6.54 s 50/3,200

WAL loads 1.5-1.7x faster at roughly half the memory, and then drops 96-98%
of concurrent hot-node writes. MVCC with group commit accepts every one. NB:
these WAL rows enforced foreign keys and the parallel-MVCC rows did not
(item 3), so the load ratio understates WAL. Measured after 3f1ed19 (eigen,
web-Google, 0 s steal, alternating): FK-off alone lifts WAL loads ~60%
(0.7.2 42.9k -> 70.3k / 69.7k e/s; main 80.7k / 82.0k), and mimalloc adds
9-16% on top (0.7.2 76.7k; main 93.4k). Parallel MVCC is unchanged by the
FK commit, since its writers were already unchecked.

Against the pre-change single-writer lane, same host (grust, 4 writers),
parallel MVCC loads are 1.9-9.5x faster: cit-Patents 5,526 -> 26,661 e/s,
sx-stackoverflow 2,929 -> 13,464, GAP-road 2,928 -> 27,708, soc-Pokec
4,698 -> 26,385, web-Google 16,013 -> 30,985. Round checkpoints cut peak load
memory from ~15 GB to 3-7 GB. Group commit took A4 from ~22 s to 3.9-4.2 s.

Neo4j, same host (quegee), for scale: com-Orkut -- Neo4j 16,800 e/s /
4.9 GB / A4 2.90 s; MVCC matches its load (-4.5%) and beats its A4 (1.82 s).
soc-LiveJournal1 -- Neo4j 28,062 e/s / 6.7 GB / A4 2.90 s; Neo4j loads 1.42x
faster than MVCC and wins A4 (4.81 s). Turso wins A1/A2 (2.3-3.1x) and passes
A7 (Neo4j: unsupported) on both; Neo4j is 3-4.6x leaner in memory.

Version scoping: what changes on Turso main (0.8.0-pre.11)

Engine SHA asserted in every probe and CPU steal recorded per run. The
hot-node, WAL, mimalloc and via-WAL numbers are alternating A/B pairs on one
host with the same Grust head. The at-scale MVCC rows are sequential lanes on
quegee (c5n.4xlarge, no burst credits, <=0.5 s steal per cell), main at
Grust e5d7b4e against 0.7.2 at the harness pin f6d3391 -- the loader is
unchanged between those for parallel MVCC writers.

  • Hot-node writes (16x200, FULL): 0.7.2 with our committer 3.2 s, without
    21.1 s; main with our committer 3.2 s, without 2.62 s. main has
    engine group commit (PRAGMA mvcc_group_commit, default on); our committer
    becomes a second layer that fights it. Keep with_group_commit on 0.7.2;
    switch it off (and flip its default) when the pin moves to 0.8.

  • MVCC loads: main is ~5% slower at 1 writer and ~15% slower at 4
    writers than 0.7.2 on web-Google (5.1 M edges), ~26% slower on
    cit-Patents (16.5 M)
    , and on quegee (same host, 4 writers, NORMAL, glibc,
    <=0.5 s steal per cell):

    graph main e/s 0.7.2 e/s load A1 A2
    sx-stackoverflow (63.5 M) 9,912 12,344 -20% 23% faster 4% faster
    soc-LiveJournal1 (69 M) 9,973 12,660 -21% 9% faster 4% faster
    com-Orkut (117 M) 8,707 11,240 -23% 24% faster 19% faster

    Loads regressed; reads (A1 fan-out, A2 deep paths) are faster on main on
    every graph. Peak RSS runs 16-21% higher on main at these sizes (no
    difference at 5.1 M edges). A4 at synchronous=NORMAL is not cited: it
    spans 1.4-5.2 s on both engines with no consistent direction (it measures
    conflict-retry luck, not the commit path); the FULL-sync A/B above is the
    write-contention comparison. The load regression is not group commit, not
    checkpointing, not the allocator (each tested); perf puts it in
    types::cmp_in_column + read_value_serial_type under
    SortableIndexKey::compare. Upstream PR #8385 rewrites exactly that path:
    measured against its own base it is +29-30% on this load, and under it the
    ~31% index-key share collapses to a single 12.2% symbol.

  • WAL loads: main is +22% faster (B-tree path improved).

  • mimalloc: +14% on main, +16% on 0.7.2 for parallel MVCC loads,
    +9-16% on WAL loads; off in every harness number above. Now reachable from
    the facade as grust-graph/turso-mimalloc (abea518, mirrors
    turso-sync). Off by default -- a global allocator is the application's
    decision -- and recommended on in any deployment that owns its binary.
    Not yet used by the adversarial-graph harness: its rows carry no allocator
    tag, so a mimalloc build would produce cells indistinguishable from the
    glibc cells above; that needs a turso_alloc tag first.

  • Fastest fill of an MVCC store on main: set_bulk_load_via_wal --
    94k edges/s on web-Google and 79.5k on cit-Patents (eigen, FK off,
    mimalloc), identical to plain WAL, 2.65x and 3.1x parallel MVCC
    respectively, and +19-22% over 0.7.2 on the same path; then serve with
    engine group commit.

Ideas measured and rejected

  • Deferred secondary indexes during load: slower (383ca18, reverted 9fdbdb9).
  • Plain INSERT for loads into empty tables: ~4% slower; removed in e5d7b4e.
  • Writer page cache + GC disabled: flat. Round/batch size tuning: memory, not time.
  • identity_key schema probe: a real 2-4%, not worth a stateful dialect.
  • PASSIVE automatic checkpoints: a regression on 0.7.2 (it looked like a
    2.35x win only against a branch head that already carried it; measured
    against the harness pin it is slower; reverted 5dff9ac). main reworked
    passive checkpoints; not re-measured there.

The Amdahl fit over 1/2/4/8 writers on 0.7.2 gives ~35% serialized. That
fraction is in the commit path but is not solely the global
pager_commit_lock: 0.7.2's per-insert rowid write lock and spinning waiters
are in it too, and both are gone on main.

Provenance

The harness rows above were produced at Grust pin f6d3391, four commits
behind the original PR head e5d7b4e (91f6e3f measured flat, 48b4252
later reverted, 618caa0 tunables). The FK, backoff, pragma and facade-feature
commits (3f1ed19, acac24e, c8ec310, abea518) postdate them; their
effect is measured in "Version scoping" and not yet in the harness rows.
abea518 is manifest-only (a forwarded feature) and does not change any test
outcome; the gate below is at c8ec310.

Verification

  • cargo fmt --all -- --check: clean.
  • cargo clippy -p grust-turso --all-targets -- -D warnings and
    cargo clippy --workspace --exclude grust-ladybug --all-targets -- -D warnings
    at c8ec310: clean.
  • cargo test --workspace --exclude grust-ladybug --no-fail-fast at c8ec310:
    1,551 passed, 0 failed, 47 ignored, 105 suites (1,550 at e5d7b4e plus
    tests/dangling_edges.rs).
  • grust-turso alone: 80 passed, 0 failed, on Turso 0.7.2 and on main.
  • Earlier, at 5dff9ac with the lbug git pin (ladybug-rust 47bfd12e, PR Accounting: child executions, per-query views of cached projections, and every work charge inlined again #33)
    and LBUG_LOCALIZE_BUNDLED_SYMBOLS=1: --workspace --all-features 1,677
    passed / 0 failed / 49 ignored, Clippy clean.

grust-ladybug is excluded only because an unpinned checkout cannot link it:
~20 duplicate symbol: ZSTD_* between liblbug.a and libzstd_sys (via
arrow-ipc 58.3 -> lance -> lancedb), pre-existing on main (reproduced at
merge-base a134e05) and already solved by the lbug pin above.

Also on this branch: LadybugDB bulk loads (PR #7, merged)

PR #7 landed here as a stacked PR: grust-ladybug bulk writes now go through a temporary CSV and the engine's COPY t FROM 'file' instead of COPY … FROM (MATCH …) over a registered Arrow table, which is superlinear in the engine on relationship tables (4.1 k edges/s at 500 k, unfinished after two hours at 5 M — why cit-Patents never loaded). Same schema, 1.28 M edges/s; an empty target table skips the read-back of existing keys; upsert semantics unchanged; lbug 0.20.4. Details and measurements in #7.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ

alexy and others added 23 commits September 14, 2026 23:35
Add grust_datafusion::cypher::RoutedGraph: one graph captured as a typed
index and an Arrow snapshot, one bounded-read admission per query, and
DataFusion execution for single-node scan plans whose every physical
operator charges candidate work and intermediate bytes against the
caller's ReadQueryPolicy. Joins, unsupported shapes, graphs under 3,000
nodes and plans over a limit stay on the reference executor through the
new run_prepared_read_query_indexed, which keeps the original deadline.

Split large single-batch Arrow tables into zero-copy partitions so no
round-robin repartition charges each queued slice its shared parent
buffers; that intermittently exhausted a 256 MiB pool at one million
nodes.

Add LadybugConfig::max_db_bytes and cap unit-test databases at 16 GiB;
parallel tests exhausted x86-64 address space with 8 TiB reservations.

Workspace gate on quegee: 1,675 passed, 0 failed, 49 ignored; Clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WZ7SjQFbe58VDZs6xR3nCo
…RUNCATE

Profiling an MVCC bulk load (pprof, bulk_load example, 1M edges over 50k
nodes) put 60% of the time in the upserts' index work in the MVCC store and
31% in the automatic checkpoints that write the same rows into the B-tree.
The edge table carried three indexes: the primary key (from_id, label,
to_id, identity_key), to_idx, and from_idx on from_id alone. Turso's planner
never reads from_idx: source lookups and every traversal hop already use the
primary key's from_id prefix, with or without from_idx present (EXPLAIN
QUERY PLAN). The index only cost a write per edge.

Turso's dialect now reports edge_source_index() = false (a new
GraphSqlDialect method, default true, so Postgres keeps its index), the
universal bootstrap omits the index for it, and bootstrap drops it from
existing Turso stores. Query plans and results are unchanged.

MVCC loads now end in PRAGMA wal_checkpoint(TRUNCATE) inside put_graph, as
WAL loads already did, so a load returns with its rows in the database file
and the in-memory store drained. Under the default blocking automatic
checkpoints this costs nothing measurable.

Optional mimalloc feature: Turso's own default allocator (turso/mimalloc),
which installs it as the binary's global allocator; MVCC loads ran about 9%
faster with it. Off by default, because it changes the allocator of every
backend in a binary.

1M edges, 4-core host: MVCC 12.7k -> 15.9k edges/s, WAL 43.6k -> 51.5k.

New tests: an MVCC bulk load with checkpoints mid-load stores exactly the
rows of the WAL load (identity_key included), survives a reopen and takes
concurrent BEGIN CONCURRENT writers afterwards; bootstrap omits the index
and drops a legacy one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MVCC connections open with turso::Builder::
experimental_mvcc_passive_checkpoint(true), so the automatic checkpoints
taken every mvcc_checkpoint_threshold bytes of logical log (4,120,000 by
default in Turso 0.7.2) run PASSIVE instead of TRUNCATE and do not block
the store's other readers and writers. put_graph still ends in one TRUNCATE
checkpoint, so a load returns durable in the database file. WAL connections
are unchanged; the option only affects MVCC checkpoints.

Measured cost on a single-writer bulk load (bulk_load example, 4-core host,
no edge-source index): 5M edges 14.4k edges/s blocking -> 8.7k passive,
peak RSS 0.9 GB -> 2.8 GB. PASSIVE reclaims emptied version chains lazily,
so the in-memory store and each checkpoint's scan grow during the load,
and each put_graph pays its deferred work in the final TRUNCATE (~11 s per
1M-edge call). The option is experimental in Turso 0.7.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
put_graph on an MVCC store sets PRAGMA mvcc_checkpoint_threshold = -1
for the load, so no automatic checkpoint writes the rows committed so
far into the B-tree mid-load (profiled at about a third of an MVCC
load). The load still ends in one TRUNCATE checkpoint, and the store's
previous threshold is restored whether or not the load succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
Turning automatic checkpoints off for an MVCC put_graph kept every row
version of the load resident: 13.4 GB at 5M edges against 11.4 GB for
Turso's 4 MB default, heading past a 32 GB host well before com-Orkut.
Loads now raise the threshold to MVCC_LOAD_CHECKPOINT_BYTES (64 MiB)
and restore the store's own threshold afterwards.

bulk_load example on quegee, MVCC: 1M edges 11.8k -> 15.4k edges/s
(2.31 -> 2.35 GB peak); 5M edges 995 s -> 423 s, 11.4 GB both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
…example

connect_shared opens another connection on a store's own turso::Database
instead of building a second database object on the same path. The
concurrent_writes example reproduces the adversarial-graph A4 shape
(N writers, put_node then put_edge onto one hub) with separate or shared
handles, in WAL or MVCC mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
Under Turso's default synchronous = FULL every MVCC commit fsyncs the
logical log while holding the engine's global commit lock, with no
group commit. An MVCC put_graph now runs at NORMAL, which skips the
per-commit fsync, and restores the connection's previous setting
afterwards; its closing TRUNCATE checkpoint still fsyncs, so the load is
durable when put_graph returns.

TursoGraphStore::set_synchronous sets PRAGMA synchronous on a handle's
connection for callers that choose NORMAL or OFF for ordinary writes
(a crash can then lose the commits since the last checkpoint). The
concurrent_writes example gains --sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
Under synchronous = FULL every MVCC commit fsyncs the logical log while
holding Turso's global commit lock, so concurrent writers pay one fsync
each, one after another (A4-shaped load on eigen's disk: 16 writers x
200 node+edge writes, 40.8 s, 78 writes/s; the same at NORMAL: 0.12 s).

TursoGraphStore::with_group_commit starts a committer task on its own
connection. Single-statement writes from the store and from every
handle opened with connect_shared go to it; it commits whatever is
waiting as one BEGIN CONCURRENT ... COMMIT (one fsync per batch, up to
256 statements) and answers each caller only after that commit. A batch
that fails is replayed one statement at a time with the usual conflict
retry, so a failing write fails only its own caller. WAL stores and
bulk loads are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
… once

MVCC keeps every index entry of every row version in an in-memory skip
list as well as in the B-tree; a perf profile of an MVCC bulk load put
16% of its time in that skip list's key comparisons (SortableIndexKey::
compare), which WAL loads do not pay. When an MVCC put_graph starts on an
empty edge (node) table, it drops <prefix>_edges_to_idx
(<prefix>_nodes_label_idx) for the load and rebuilds it with one
CREATE INDEX at the end, whether or not the load succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
…ry index once"

Measured on lakecat's disk, web-Google MVCC bulk_load: 349.6 s with the
secondary indexes maintained during the load, 437.9 s with them dropped
and rebuilt by CREATE INDEX at the end. Building the index afterwards
under MVCC costs more than maintaining it row by row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
An MVCC load keeps every index entry of every row version in an
in-memory skip list as well as the B-tree (16% of an MVCC load's time in
SortableIndexKey::compare alone), so it runs several times slower than a
WAL load. Turso 0.7.2 switches a live database between journal modes
(probe tests: a WAL-loaded file reopens as MVCC; a live MVCC store with a
group committer and a shared handle switches to WAL and back, with rows
and concurrent writers intact).

TursoGraphStore::set_bulk_load_via_wal(true) makes an MVCC store's
put_graph switch to WAL, load in one transaction, checkpoint, and switch
back to MVCC, also when the load fails. Journal mode is database-wide, so
callers must not write concurrently during such a put_graph; a bulk load
before serving is the intended use. The TursoJournalMode doc that said a
WAL database cannot be converted is corrected. bulk_load gains
--wal-load on|off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
MVCC admits concurrent writers where WAL admits one, and the dominant
cost of an MVCC load (the in-memory index work per row version) is done
inside each transaction before its commit. set_mvcc_load_parallelism(n)
makes an MVCC put_graph load its nodes, then its edges, as n contiguous
slices on n shared connections and tasks (BEGIN CONCURRENT groups at
synchronous = NORMAL, as the single-writer path runs); the first error is
returned after every writer finishes. 1, the default, is the single-writer
path. bulk_load gains --load-threads and runs on a multi-threaded runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
Free-running parallel writers kept every row version resident, because
an automatic checkpoint cannot run while another writer's transaction is
open: web-Google (5.1 M edges) on eigen peaked at 14.6-15.3 GB with 2-8
writers against 1.7 GB for one. Writers now advance in rounds of
MVCC_PARALLEL_ROUND_GROUPS (5) commit groups each, and after every round,
with nothing in flight, one TRUNCATE checkpoint folds it into the file.
tests/parallel_load.rs checks that a 4-writer load stores every row once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
Each connection inherits PAGE_CACHE_KIB (1 GiB), so eight load writers
budgeted 8 GiB of page cache alone; a writer streams rows in and re-reads
almost nothing, so its connection now takes MVCC_LOAD_WRITER_CACHE_KIB
(128 MiB). Turso also runs an inline garbage-collection pass on the commit
path every mvcc_gc_threshold new row versions; a bulk load leaves nothing
worth collecting and the round checkpoints drain the versions, so the load
sets the threshold to -1 and restores the store's own value afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
…ause

Every load row went in as an upsert, whose ON CONFLICT probes the index
for a conflict that cannot exist on a table that was empty when the load
began; index seeks are 22% of a WAL load's profile and 8% of an MVCC
load's. put_graph now checks each side once (SELECT EXISTS, not COUNT),
and while that load runs its batches insert without the clause. A batch
that collides anyway -- duplicate keys inside one load -- is replayed as
an upsert, so the stored rows are unchanged; the flag is cleared on every
exit, and shared with the parallel writers' handles.

tests/fresh_load_duplicates.rs loads 300 node keys and 299 edge keys
twice in one call, in both journal modes, and checks one row per key with
the last write winning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
set_mvcc_load_round_groups overrides MVCC_PARALLEL_ROUND_GROUPS (0 keeps
it), so a sweep can trade checkpoint count against resident row versions
without a rebuild. bulk_load gains --round-groups and --batch (the
store's batch_size, previously hard-coded at 500 in the example).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
Alternated against the upsert path on lakecat (web-Google, on disk):
WAL 133.9 s and 134.9 s with the plain INSERT against 128.0 s and 130.2 s
with the upsert, and MVCC with 4 writers 177.4 s against 179.4 s -- about
4% slower on WAL and a wash on MVCC. A plain INSERT still pays the same
B-tree insert, so skipping the conflict clause buys nothing, and the
22% of a WAL load's samples in index seeks is that insert rather than the
upsert's probe.

Removed with it: the loading_empty_tables flag shared across handles, the
put_graph_rows wrapper, both prepared_insert_* builders, is_key_collision
and table_has_rows, whose only callers were the removed check.
tests/fresh_load_duplicates.rs stays: one row per key on a fresh load
with duplicate keys is worth asserting on whichever path runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
@alexy

alexy commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Carried over from #5 (closed as superseded; grust-best head 5dff9ac is an
ancestor of this branch, with 14 commits on top). Three evidence comments by
@alexy, verbatim:


1. Harness confirmation on lakecat (4 cores, 15 GB), adversarial-graph ag run --backend turso-mvcc, same host and same harness for both pins:

dataset pin LOAD edges/s client peak RSS gates
web-Google (5.1 M edges) 0176718 (previous) pass, 570 s 8,950 3.9 GB pass
web-Google 5dff9ac (this branch) pass, 431 s 11,835 3.85 GB pass
cit-Patents (16.5 M edges) 0176718 pass 3,046 7.2 GB A1 A2 A4 A7 A12 pass
cit-Patents 5dff9ac pass, 3,470 s 4,760 7.1 GB A1 A2 A4 A7 A12 pass

The interim pins with PASSIVE checkpoints did not finish web-Google within 1,800 s (eigen, lakecat), and cit-Patents was OOM-killed at a 13 GiB cap.


2. turso-wal through the full strain ladder on grust (2xlarge class, 32 GB; harness pin a26fdff, whose WAL code is identical to this branch's). Every graph passed LOAD, A1, A2, A4, A7 and A12; A3 is unsupported as before.

dataset edges LOAD edges/s load s client peak RSS
web-Google 5.1 M 52,585 97 3.3 GB
soc-Pokec 30.6 M 41,397 740 2.8 GB
cit-Patents 16.5 M 42,632 387 7.0 GB
GAP-road 57.7 M 45,525 1,268 11.3 GB
sx-stackoverflow 63.5 M 34,954 1,817 7.7 GB
soc-LiveJournal1 69.0 M 33,992 2,030 10.4 GB
com-Orkut 117.2 M 26,197 4,473 12.8 GB

The previously published strain summary had Turso WAL passing every strict gate only up to GAP-road.


3. Full workspace gate on 5dff9ac (grust host, 8 cores, 4 jobs under a 24 GiB cgroup cap): cargo test --workspace --all-features --no-fail-fast passed 1,677 tests with 0 failed and 49 ignored across 101 test binaries, and cargo clippy --workspace --all-features --all-targets -- -D warnings is clean. It ran with the uncommitted lbug git pin (ladybug-rust 47bfd12e, which contains PR #33) and LBUG_LOCALIZE_BUNDLED_SYMBOLS=1, because no crates.io lbug release has that fix yet.


This corrects the Verification section of this PR. That section reports
1,550 tests with --exclude grust-ladybug, and calls the duplicate symbol: ZSTD_* link failure pre-existing on main — which it is, reproduced at the
merge-base a134e05. But comment 3 shows it is also already solved: with
the lbug git pin (ladybug-rust 47bfd12e / PR #33) and
LBUG_LOCALIZE_BUNDLED_SYMBOLS=1, the full --workspace --all-features gate
passes 1,677 tests and Clippy is clean. My gate ran on a clean checkout without
that pin, so it hit the collision and I excluded the crate.

So the accurate statement is: the workspace gate passes in full once lbug is
pinned to a build that localizes its bundled symbols, and the exclusion in the
Verification section reflects my unpinned environment, not an outstanding
defect. Landing that pin (or a crates.io lbug release carrying PR #33) removes
the need for --exclude grust-ladybug entirely.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ

alexy and others added 3 commits September 16, 2026 21:38
Whether a dangling edge was accepted depended on the writer count: WAL and
single-writer MVCC loads ran on the bootstrap connection with
PRAGMA foreign_keys = ON and rejected an edge to an unstored node, while
parallel MVCC writers (connect_shared handles, which never set the pragma)
accepted it. Same store, same put_graph, different result.

Load with foreign keys off on the bootstrap connection for the duration of
put_graph, restored afterwards whether or not the load succeeds, so every
path agrees and matches the Memory reference, where a vertex can exist only
as an edge endpoint. The schema's REFERENCES nodes(id) ON DELETE CASCADE
stays in force for every other write, and cascade fires on the connection
that deletes, so serving semantics are unchanged. WAL and single-writer
loads now also skip the two endpoint probes per edge that parallel loads
already skipped, so their rates rise and the WAL-vs-MVCC comparison stops
being biased.

Tests: tests/dangling_edges.rs loads a dangling edge through WAL, one MVCC
writer and four MVCC writers, reads it back identically, and then checks a
single put_edge to a missing node is still refused. 80 grust-turso tests
pass against turso 0.7.2 and against turso main (0.8.0-pre.11).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
…tion

A Busy or conflict on BEGIN CONCURRENT means another writer holds the
commit or checkpoint lock; retrying at once spins against it. All three
retry loops now yield to the runtime once per attempt so far before the
next attempt. The workspace tokio has no timer feature, so this is a yield
rather than a sleep. Loads measured zero such retries across eight
full-graph runs, so this removes a latent hot loop rather than changing
throughput.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
PRAGMA mvcc_group_commit exists on Turso 0.8 (upstream main), store-wide
and on by default; 0.7.2 has no such pragma and the call returns an error.
Same host, same binary, 16 writers x 200 hot-node writes at
synchronous=FULL: on 0.8 the engine's group commit alone takes 2.62 s and
adding with_group_commit on top costs ~19% (3.2 s); on 0.7.2
with_group_commit is the only group commit and is worth ~6.6x (21.1 s ->
3.2 s). Bulk loads are unaffected by the pragma either way. Exposed so the
client-side committer can be switched off, and its default flipped, when
the pinned engine moves to 0.8.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
@alexy alexy changed the title grust-turso: durable group commit, parallel MVCC loads, and a WAL/MVCC head-to-head grust-turso: durable group commit, parallel MVCC loads, FK-off loads, and a WAL/MVCC head-to-head Sep 16, 2026
…facade

The facade forwarded `turso` and `turso-sync` but not `grust-turso/mimalloc`,
so a crate depending on `grust-graph` could not enable Turso's allocator
without depending on the adapter directly. `turso-mimalloc` mirrors
`turso-sync`. Off by default: a global allocator is the application's
decision. Measured on 4-writer MVCC bulk loads of web-Google, same host,
alternating pairs: +14% on Turso 0.8 (31.3k -> 35.5k edges/s) and +16% on
0.7.2 (35.7k -> 41.6k); +9-16% on WAL loads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
alexy and others added 14 commits September 17, 2026 15:19
Add a book chapter covering the Turso adapter on this branch: WAL and MVCC
contracts and live conversion, parallel MVCC loads with round checkpoints, the
fill through WAL, foreign keys off on every load path, durable group commit
and its scoping to Turso 0.7.2 versus the engine group commit on 0.8, the
turso-mimalloc feature, the strain-harness results against WAL and Neo4j on
one host, the Turso main findings (WAL +22%, MVCC loads -15 to -26%, reads
faster, upstream #8385), the methodology, and the limitations.

Point the manuscript's Turso section and the compact-snapshots chapter at the
chapter, and replace the stale note that MVCC is a fresh-database-only mode.

Add a "Turso under strain" section to the Brine post, framed as branch work
measured against the 0.20.0 crates and not part of them, with links to the
pull request and the strain results.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
The MVCC rows in the Neo4j and WAL tables ran eight writers at
synchronous = NORMAL, so their hot-node-write times are not comparable with
Neo4j's durable commits; say so, and state the durable same-host result the
strain page counts instead (Neo4j wins every pair against MVCC).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
Neo4j's A7 row reads "backend does not implement GraphCommitStore", a Grust
trait the harness's own Cypher-over-Bolt driver never implemented. Calling
that a Turso capability win was unfair to Neo4j; say what it is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
…ns it off

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…p the read-back

The registered-Arrow COPY FROM (MATCH …) path is superlinear in the engine on
relationship tables: 4,100 edges/s at 500 k, unfinished after two hours at
5 M (cit-Patents never loaded). The engine's file COPY takes the same rows,
STRING keys and JSON props included, at 1.3 M edges/s and 2.2 M nodes/s, so
nodes, edges and the metadata index are written through a temporary CSV
(Arrow's writer, RFC 4180 quoting, ESCAPE='"'). A count query skips the
read-back of existing ids and pairs when the target table is empty. Upsert
semantics unchanged; the round-trip test covers commas, quotes, newlines,
backslashes and nested JSON, then a second put over the same ids.

lbug 0.20.4 (crates.io, 2026-09-10; no Rust-API change from 0.20.2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sak2FQcdgs5pkL2ruKrSUZ
grust-ladybug: bulk loads COPY from a temporary CSV; lbug 0.20.4
alexy added a commit that referenced this pull request Sep 18, 2026
Brings in PR #6 (durable group commit, parallel MVCC loads, FK-off loads and
the WAL/MVCC comparison) and PR #8 (Ladybug bulk COPY loads and fresh-row
mode) ahead of the Tadpole release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@alexy
alexy merged commit da0ddea into main Sep 18, 2026
alexy added a commit that referenced this pull request Sep 20, 2026
Documentation only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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