27_concurrent_txn

command
v0.11.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 13, 2026 License: MIT Imports: 23 Imported by: 0

README

Example 27 — Concurrent Transaction Isolation

What it demonstrates

Transactional isolation, atomicity, and consistency of the WAL-backed Cypher engine, certified under concurrency and the race detector. Many writer goroutines move money between accounts while many reader goroutines continuously observe an invariant that can only hold if the engine isolates in-flight transactions from readers and never loses a concurrent update. It is the only example whose runtime exercises cypher.Engine.BeginTx (multi-statement explicit transactions), cypher.Engine.RunInTx (single-statement autocommit writes), and cypher.Engine.BeginReadTx (read-only transactions) together under contention.

Domain / scenario

A bank clearing-ledger. Each account is a (:ACCOUNT {id, balance}) node whose integer balance is held in cents and keyed by a string account number backed by a range index. A transfer debits one account and credits another by the same amount, so the sum of all balances is invariant — money is neither created nor destroyed. That conserved total is the observable the readers pin.

A seeded generator fixes the whole workload for a given -seed: the accounts and their initial balances, and every transfer (source, destination, amount) assigned to each writer. The ledger is fully capitalised — initial balances are validated to exceed the largest possible aggregate debit on any single account — so no account can ever go negative and every planned transfer commits. That keeps the committed set, and therefore the final per-account state, deterministic: because a transfer is a commutative delta on two accounts, replaying the committed transfers in any order yields the same final balances, which the run computes up front and asserts against after the concurrent phase.

How to run

go run ./examples/27_concurrent_txn                 # small deterministic default
go run ./examples/27_concurrent_txn \
    -accounts 5000 -writers 16 -readers 32 \
    -ops-per-writer 5000 -max-amount 1000 -seed 7   # observable-scale run

Run it under the race detector to use it as a data-race + isolation certification:

go test -race ./examples/27_concurrent_txn/...

Scale and flags

Flag Meaning Default Large
-accounts number of :ACCOUNT nodes 32 5000
-writers concurrent writer goroutines 4 16
-readers concurrent reader goroutines 4 32
-ops-per-writer transfers each writer commits 150 5000
-min-initial minimum initial balance (cents) 1000000000
-max-initial maximum initial balance (cents) 2000000000
-max-amount maximum transfer amount (cents; min is 1) 1000000 1000
-sweep-ops transfers per writer-scaling-sweep level (0 disables) 120
-seed RNG seed (fixes the data shape) 1 7

min-initial must be at least writers × ops-per-writer × max-amount so the no-overdraft invariant holds; validate rejects a configuration that violates it. At larger scales keep -max-amount small (as in the example above) so the guarantee is easy to satisfy.

Expected output

Bare lines are deterministic facts pinned by the regression test; lines prefixed with # are volatile telemetry that varies per run and machine.

config.accounts=32
config.writers=4
config.readers=4
config.ops_per_writer=150
config.seed=1
accounts=32
transfers.planned=600
transfers.multi_statement=300
transfers.single_statement=300
initial_total=46625986168
# plan.debit_index_seek=true
transfers.committed=600
final_total=46625986168
conservation.holds=1
lost_updates=0
no_negative_balances=1
total_balance_invariant_holds=1
# run.elapsed=2.28s
# writer.transfers_per_s=262
# writer.mean_acquire_wait=11.45ms
# reader.observations=2414
# reader.observations_per_s=1056
# mem.heap_alloc=1.69 MiB
# scale.writers_1.transfers_per_s=264
# scale.writers_2.transfers_per_s=267
# scale.writers_4.transfers_per_s=265

The headline facts are the ACID certification:

  • total_balance_invariant_holds=1 — every reader observation equalled the seeded total; no reader ever saw a debit without its matching credit (isolation).
  • conservation.holds=1 and final_total == initial_total — money was neither created nor destroyed; every transfer applied atomically (atomicity).
  • lost_updates=0 — the final per-account state matched the deterministic replay; no concurrent read-modify-write interleaving lost a write (consistency / serialisability of writers). Under MVCC this is the fact that certifies write-write conflict DETECTION together with the physical rollback of a refused transaction: writers overlap, collisions happen (the telemetry counts them), and a colliding transfer must leave nothing behind.
  • no_negative_balances=1 — the fully-capitalised ledger stayed non-negative.

A single torn observation, lost update, or conservation failure makes run return an error naming the violated property rather than reporting success — the example surfaces a module isolation bug, it never hides one.

Evidence it collects

For a concurrency subject (see docs/examples-standard.md):

  • Writer throughput (# writer.transfers_per_s) and reader throughput (# reader.observations_per_s) of the mixed workload.
  • Contention (# writer.mean_acquire_wait) — the mean time a writer blocked acquiring a MULTI-STATEMENT write transaction, which still takes the graph's schema barrier exclusively for its whole lifetime (retiring that is rmp #2305) and therefore still rises with the writer count. An autocommit statement does not appear here: it holds the barrier shared and queues behind nobody.
  • Write-write conflicts (# writer.conflicts_retried, # writer.conflict_retries_max, # writer.conflict_wait_max) — how many transfer attempts were refused with mvcc.ErrSerializationConflict and retried, the deepest retry chain one transfer needed, and the longest WALL TIME any one transfer spent retrying. This is the observable cost of concurrent writers, and it is the load-bearing evidence that they genuinely overlap: a single-writer engine reports zero because the conflict cannot arise. The retry backoff is sized to a WAL fsync, not to a scheduler yield — a yield loop was measured spinning five attempts inside one fsync, all against the same in-flight version and all with the same stale snapshot.
  • Scaling across worker counts (# scale.writers_N.transfers_per_s) — the identical workload at 1, 2, 4 … writers on a fresh store, each level asserting conservation on its own ledger.
  • Index seek (# plan.debit_index_seek) — evidence the keyed lookup plans as a NodeByIndexSeek rather than a full label scan.
  • Live heap (# mem.heap_alloc).

When scaling up, watch two things. Reader throughput and observation count grow with -readers and are never blocked by a writer at all: a read takes a snapshot and no lock. Writer throughput is bounded by the shape of the transfer — half the transfers are multi-statement and still serialise on the exclusive barrier — and by the conflict rate, since every refused attempt pays a retry. Raising -accounts spreads the transfers over more keys and drives # writer.conflicts_retried down.

The retry bound is a clock, and it says why it fired

The retry chain is bounded so that a persistent conflict fails loudly instead of spinning forever. That bound used to be an attempt count — 24 — and it was the wrong instrument, for the reason rmp #2330 made a project rule after finding the same mistake five times in one sprint:

a bound on waiting for another goroutine, process or network peer must be sized to catch a HANG, never to assert a latency.

An attempt count asserts a latency. The 24 waits summed to roughly 96 ms, while what a transfer actually waits for is the writers ahead of it clearing their fsyncs. #2330's own sweep for siblings covered bolt/server and stopped there, so this bound survived it — and was then caught exactly as the rule predicts. Running examples 04, 17, 20, 21, 27, 35, 36 and 37 together under go test -race, this example failed 2 runs in 6 with 25 serialization conflicts on one transfer, while passing 8 of 8 alone and 8 of 8 under fourteen CPU burners. CPU starvation does not reproduce it; competing fsyncs do, because the start timestamp a retry takes is the contiguous commit frontier (rmp #2298) and that frontier advances only as commits become durable.

The bound is now a wall-clock budget of 30 s, sized from measurement: under the same parallel-WAL load, with the bound lifted, the worst chain observed over eight runs was 20 attempts / 167 ms (# writer.conflict_wait_max reports it every run). The budget therefore sits roughly two orders of magnitude above contention and still catches a wedged object immediately — and a wedged object is not hypothetical: before rmp #2318 gave the vacuum an unconditional wake, "the FIRST transaction to abort on an object made that object permanently unwritable", and this example's writers were what exhausted their chain on it.

Exhausting the budget now prints the conflict chain rather than a bare count, because a bound that fires without evidence is what sent rmp #2333 hunting through 245 million observations for one unattributable number. Everything needed was already in the error — mvcc.Conflict carries the blocking version's timestamp, the losing transaction's snapshot and its id — and the loop used to discard all three. Keeping them classifies the exhaustion into four outcomes:

Chain Verdict What it means
head stuck at mvcc.AbortedTS ABORTED an aborted version the vacuum never withdrew — the object is unwritable (rmp #2318)
head stuck on one transaction id HANG one writer held the head for the whole budget without committing or aborting
snapshot never advanced stalled frontier no attempt after the first was a retry; suspect a commit stuck in fsync
head and snapshot both moved STARVATION the engine was live and this writer never got a turn

The first two are engine liveness defects. The last is a property of first-updater-wins itself: it has no queue and no age priority, so a loser's backoff grows while every fresh arrival starts at the floor. retry_test.go holds all four verdicts under test and — the direction that matters — proves a wedged head still fails while a chain of 40 progressing refusals no longer does, which is the case the old attempt count could not pass whatever the engine was doing.

The torn-total gate, and how it explains itself

The conserved total is the example's headline oracle, and a violation of it is rare enough that the run which produces one may be the only run that ever does. It is therefore built to be attributable, not merely loud.

  • One instant per observation. A reader takes exactly one snapshot, runs exactly one aggregate, and compares it to the seeded total — a constant. It never samples the graph twice and compares the two, which is the unsound shape that made the rmp #2332 gate report violations that existed at no instant.
  • A tear diagnoses itself. When a reader holding an explicit read transaction sees a wrong total, it re-reads every account balance inside the same transaction — therefore at the same pinned instant the aggregate was computed over — and the failure names a verdict:
    • the per-account state is consistent → the aggregate disagreed with the rows it summed, which is an execution defect and not an isolation one;
    • the per-account state is itself inconsistent → a genuine isolation violation, and the deviating accounts are listed with their deltas. A reader using Engine.Run holds no pinned instant to re-read, so it reports UNATTRIBUTED rather than guessing.
  • A NULL is an error, not a zero, and an ungrouped sum() must return exactly one row. Both used to be absorbed silently — a NULL became a total of 0, and a multi-row result kept its last row — and both would then have been reported as an isolation violation the engine had not committed.
  • The gate is validated on a deliberately broken build. TestTornGate_CatchesADeliberateTear sets an internal negative-control seam that commits a multi-statement transfer's debit and credit as two separate transactions. That is a real tear, and the test asserts the gate both catches it and attributes it to isolation rather than to the aggregate. A gate that has never been shown to fire is not evidence that anything passed.

Key APIs

  • cypher.NewEngineWithStore — a WAL-backed engine over a txn.Store.
  • cypher.Engine.BeginTx / cypher.ExplicitTx.Exec / Commit / Rollback — multi-statement explicit write transactions (the debit-then-credit transfer).
  • cypher.Engine.RunInTx (via RunAny) — single-statement autocommit writes.
  • cypher.Engine.BeginReadTx — read-only transactions for the invariant reads.
  • cypher.Engine.Run — the concurrent read path used by the other readers.

Further reading

Documentation

Overview

Example 27_concurrent_txn — transactional ISOLATION and ATOMICITY of the WAL-backed Cypher engine, certified under concurrency and the race detector.

A realistic bank clearing-ledger is opened over a write-ahead log. Many writer goroutines move money between accounts while many reader goroutines continuously observe a global invariant that can only hold if the engine isolates in-flight transactions from readers. The whole run doubles as a data-race certification: it is meant to be run under `go test -race`.

Model

(:ACCOUNT {id, balance})            // id is a string account number,
                                    // balance is an integer (cents)

Each account is a node carrying an integer balance in minor currency units, keyed by a string account number backed by a range index for O(log n) lookup. A transfer moves an amount from one account to another: it debits the source and credits the destination by the same amount, so the SUM of all balances is invariant — money is neither created nor destroyed. That conserved total is the observable the readers pin.

The ledger is fully capitalised: initial balances are chosen (and validated) to exceed the largest possible aggregate debit on any single account, so no account can ever go negative. Overdraft protection is therefore not needed and no transfer is ever rejected — every planned transfer commits — which keeps the committed set, and hence the final per-account state, deterministic.

What it certifies

The example exercises, and asserts, three ACID properties under contention:

  • ISOLATION (the headline). Readers repeatedly compute `sum(balance)` over all accounts, via both cypher.Engine.Run and a read-only cypher.Engine.BeginReadTx transaction. Under correct isolation this sum ALWAYS equals the seeded total: a reader must never observe a debit without its matching credit. A single torn observation is a module isolation bug — the run surfaces it as an error rather than hiding it, and the fact line total_balance_invariant_holds flips to 0.

  • ATOMICITY. Half the transfers run as MULTI-STATEMENT explicit transactions (cypher.Engine.BeginTx: a debit statement, then a credit statement, then one Commit); the other half run as SINGLE-STATEMENT autocommit writes (cypher.Engine.RunInTx) that debit and credit in one statement. In both shapes a concurrent reader can never slip between the debit and the credit — it sees the whole transaction or none of it. Atomicity also has to survive a REFUSED transfer: a transaction that loses a write-write conflict must leave nothing behind, so its physical rollback is exercised on every one of the conflicts the telemetry counts.

  • CONSISTENCY / no lost updates. Because every transfer is a commutative delta on two accounts, replaying the committed transfers in any order yields the same final per-account balances. The run computes that expected state deterministically up front and, after the concurrent phase, asserts every account matches it. A single mismatch means a read-modify-write interleaving lost an update (a serialisation failure); lost_updates counts them and must be 0.

Isolation model (verified against cypher/exectx.go and graph/lpg)

Concurrency control is MVCC and nothing else (rmp #2320). An autocommit statement applies under lpg.Graph.ApplyVersioned, which holds the schema barrier SHARED, so writers overlap instead of queueing: what makes a transaction atomically visible is that every version it writes points at one shared commit record, published with a single atomic store. A reader therefore observes a transaction entirely or not at all, whichever writers are in flight. cypher.Engine.BeginTx still holds the barrier exclusively for the transaction's lifetime (retiring that is rmp #2305), so a multi-statement transfer serialises against other writers where a single-statement one does not. cypher.Engine.BeginReadTx is the read-only path: it takes no lock at all and reads through a snapshot, so it is never blocked by, and never blocks, a writer.

Overlapping writers make WRITE-WRITE CONFLICTS real, and this example is where they first became observable in the module. Two transfers touching the same account collide, and the second to reach the version-chain head is REFUSED with mvcc.ErrSerializationConflict rather than silently overwriting the first (first-updater-wins). The writers therefore RETRY, which is the client's half of the MVCC contract — see [retryOnConflict] for why the backoff is sized to a WAL fsync and not to a scheduler yield — and the run reports the conflict rate as telemetry (writer.conflicts_retried, writer.conflict_retries_max) so the cost of the write concurrency is visible rather than hidden. A single-writer engine reports zero there, because a conflict cannot arise.

Scale

The default is small, deterministic, and fast (a few hundred transfers over a few dozen accounts) so the regression test stays well under the 60 s short-layer budget. Every dimension is a flag, so the same binary scales up to where write serialisation and reader throughput are worth observing:

go run ./examples/27_concurrent_txn -accounts 5000 -writers 16 -readers 32 \
    -ops-per-writer 5000 -max-amount 1000 -seed 7

(-max-amount is kept small at this scale so the fully-capitalised no-overdraft invariant still holds: min-initial must be >= writers*ops-per-writer*max-amount.)

The deterministic facts (counts, the seeded total, the conservation and no-lost-update invariants) reproduce for a fixed -seed; only the telemetry (lines prefixed with "# ") and the temp directory path vary per run and machine.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL