README
¶
Example 37 — MVCC under concurrent WRITERS
Scenario
An order-processing service ingests from several producers at once. Each producer writes to its own customer records, but every order also touches a small set of shared inventory nodes — so most transactions are independent and a predictable fraction genuinely collide. Readers run throughout, answering queries while the ingest continues.
That shape is deliberate. A workload with no contention cannot demonstrate that conflict detection works; one where everything contends cannot demonstrate that independent writers scale. This example has both, in a ratio the operator sets.
Objective
Exercise and measure the write side of GoGraph's MVCC, which is the half the two existing MVCC examples do not reach:
- example 35 measures reader latency under a mixed workload;
- example 36 checks snapshot isolation on the topology dimension.
Neither drives concurrent writers, so neither can observe a write-write conflict, a retriable serialization error, a logical abort, read-your-own-writes inside an open transaction, or a commit timestamp surviving a restart. Every one of those is a property sprint 334 added.
Purpose
Produce evidence, not a demonstration. The example must let a reader answer, from its output alone:
- Does write throughput scale with writer count? commits/s at 1, 2, 4, 8, 16 writers, with the ratio against one writer stated. This is the number the sprint exists to move.
- What does contention actually cost? conflict rate by store, retry count, and retry success — measured, with the hot-spot fraction that produced it.
- Are aborts correct? a rolled-back transaction must leave no trace. Asserted by a conservation invariant that no external oracle can supply: the sum of a quantity that every transaction preserves.
- What do writers cost readers? reader latency percentiles taken beside the writers, not after them.
- What does versioning retain? version-chain depth distribution and version count against the bound, sampled while the workload runs.
- Does it survive a restart? the data AND the MVCC commit clock, so a post-restart transaction cannot re-mint an instant a previous process published.
Why it must be able to FAIL
The standing rule in this project is that an instrument which cannot fail on the defective build proves nothing — three of GoGraph's own instruments have been caught reporting a number they could only ever have produced.
So this example is validated in both directions, and the result is recorded here:
- run against a worktree at the pre-sprint head, or with the property deliberately broken, it must FAIL;
- the failure output is reproduced in this README so a reader can see what a broken build looks like, not merely be told it would differ.
The self-contradictory check
The bracket-invariant discipline that made example 36 able to detect — bracketing each observation between acknowledged commits — is structurally blind to a read of the wrong legal instant: a stale-but-valid snapshot passes every bracket.
So this example also carries at least one check that needs no external oracle: a statement whose two halves must agree with each other within one transaction, so a disagreement is self-evidently a defect regardless of which instant was read. The conservation invariant in (3) is that check — a transfer that debits one node and credits another cannot change the total, whatever instant observes it.
Its cost is stated, and it is sized for the gate that has to run it. A check nobody can afford to run is not a check.
Evidence collected
Per the project's examples mandate, every relevant indicator is measured:
| Vector | How |
|---|---|
| CPU | runtime/pprof CPU profile over the workload |
| Heap | runtime/pprof heap profile plus runtime.MemStats sampling |
| Scheduling | runtime/trace, to see writers blocking and GC pauses |
| Latency | per-operation histograms, reported as percentiles |
| Storage | on-disk store size before and after, and its growth |
| Coverage | go build -cover + GOCOVERDIR, reported with go tool covdata |
Running it
go run ./examples/37_mvcc_write_contention \
-producers 8 -ops-per-producer 200 -customers 1000 -readers 4 \
-profile-dir /tmp/ex37 -trace /tmp/ex37/trace.out
Bare lines carry deterministic facts — invariant verdicts and counts that hold on
any machine, and which example_test.go asserts. Lines prefixed with # carry
volatile telemetry that varies per run and per machine, and which no test pins: a
gate that asserted on throughput would fail on a loaded machine and teach the next
reader to ignore a red test.
A representative run (Apple M4, 10 cores):
## phase 1 — writer scaling
# scaling writers=1 commits=1600 commits_per_sec=834891 ratio_vs_1=1.00x
# scaling writers=2 commits=1600 commits_per_sec=1109313 ratio_vs_1=1.33x
# scaling writers=8 commits=1600 commits_per_sec=1675540 ratio_vs_1=2.01x
scaling.levels=5
## phase 2 — contention, reader latency, version retention
# contention commits=1600 hot_orders=410 hot_pct_actual=25.6
# contention retried_orders=20 retry_succeeded=20 unrecovered=0
# conflicts.total=20 aborts=20 commits=2604
# conflicts.by_store.node properties=20
# versions.samples=25 versions.max_retained=2331 versions.bound=4096 versions.ceiling=16384 max_chain_depth=10 max_concurrent_writers=8
# reader.samples=8606 reader.p50=416ns reader.p95=1.083µs reader.p99=10.208µs
contention.accounted=true
versions.sampled=true
versions.writer_peak_consistent=true
## phase 3 — conservation under concurrent transfers
# conservation transfers=1483 refused=0 observations=25842
conservation.torn_observations=0
conservation.final_total_correct=true
## phase 4 — restart: the data and the MVCC clock
# restart nodes=200 store_bytes=25536 clock_before=200 clock_after_reopen=400 clock_after_write=401
restart.all_nodes_recovered=true
restart.clock_not_rewound=true
restart.post_restart_instant_is_new=true
Reading it against the six questions: writers scale (2.01× at eight, on a workload where a quarter of orders contend); contention costs 20 conflicts over 2604 transactions, every one recovered by the retry loop and every one attributed to the node-property store; readers see p99 10.2 µs beside the writers; versioning retained 1854 versions against a 4096 bound with a deepest chain of 7; and the clock came back at 400 after a restart, above the 200 the previous process published.
Validated in both directions — and it caught a real defect
The check earned its place before the example was finished.
The first draft read balances with the present-time g.GetNodeProperty inside the
transfer, rather than through the transaction's own view — a textbook lost update:
two transfers read the same balance, both write, and the second silently overwrites
the first. The conservation invariant reported it immediately:
# conservation transfers=221 refused=0 observations=109
conservation.torn_observations=97
conservation.final_total_correct=false
# conservation.first_torn_total=15999019 want=16000000
981 cents gone, from a run that reported no errors and no refused transactions. The check caught the example's own bug before it could be mistaken for the engine's, which is exactly what a self-contradictory check is for: no external oracle was consulted, and none was needed, because the two halves of one transaction must agree with each other.
TestConservationCheckCanFail keeps that ability under test. It injects the same
shape into a real graph — a debit with no matching credit — and asserts the real
observer reports it, with a positive arm that fails if the observer is simply wrong
about an untouched fixture. Asserting the arithmetic instead would be a tautology
that passes however broken the observer became.
The version sampler could not answer question 5, and said nothing about it
Question 5 — what does versioning retain? — is answered by sampling
MVCCStats while the workload runs. The sampler ticked every 2 ms, and phase 2
takes 2 ms. It therefore got between zero and one observation, and published
whichever it happened to get.
Measured over twelve runs of the documented default shape above:
| runs | max_retained |
max_chain_depth |
max_concurrent_writers |
|---|---|---|---|
| 7 of 12 | 0 | 0 | 0 |
| 5 of 12 | 568 – 3983 | 0, 5, 5, 5, 20 | 0, 2, 5, 8 |
Every one of the seven zero runs had between 18 and 192 conflicts. A write-write
conflict is two writers overlapping, so max_concurrent_writers=0 beside a
non-zero conflict count is a self-contradiction: all three zeros were a sampling
miss published as a measurement. The representative run in this README — 1854
retained, depth 7 — was one draw from that distribution, not a typical result.
This is the defect class the project keeps rediscovering, and the one the rest of
this example already defends against: contention.readers_sampled and
conservation.observed_any exist precisely so a phase that observed nothing cannot
pass vacuously. The version sampler had no such guard.
Three changes, and the third is the one that matters:
- the tick is 100 µs, so a 2 ms phase yields ~20 observations rather than one;
- the phase is bracketed — one sample taken on the calling goroutine before the writers start, one after they stop — so even a phase shorter than a tick observes something;
versions.sampledandversions.writer_peak_consistentare deterministic fact lines the gate asserts, so a miss now fails the test instead of printing zeros.
After the change, twelve runs of the same shape sampled 17–65 times each, reported
max_concurrent_writers=8 — the true producer count — in all twelve, and gave
max_retained in a 2278–4023 band with a non-zero chain depth in nine. The guard is
validated in the failing direction too: with the sampler's tick set beyond the run's
lifetime and the brackets removed, TestRun fails on the missing
versions.sampled=true line rather than passing on zeros.
Note that max_retained may legitimately exceed versions.bound. Bound is the
settled-churn threshold at which the vacuum is woken, not a cap; Ceiling (16384) is
the instantaneous bound. A maximum-contention run measured 10184 retained — above the
bound, well under the ceiling, which is exactly the documented catch-up state.
Examples 35 and 36 under non-serialising writers
Both still pass, and both remain meaningful now that writers overlap rather than serialise — verified rather than assumed:
- 36 brackets every observation between the ingest's acknowledged-commit counter
before and after the query, which is precisely a construction that does not assume
serialisation, and it refuses to report success when
observations == 0or when the self-contradiction query never ran. Its verdicts are unaffected. - 35 measures throughput collapse and latency amplification — ratios whose whole subject is concurrency — and guards the zero-sample case. Nothing in it assumed a single writer.
Neither needed a change. What they did need was checking, because a verdict that was sound under exclusion is not automatically sound without it.
Status
IMPLEMENTED (rmp #2313). main.go holds the configuration, the workload
primitives and the telemetry; phases.go holds the four measurement phases;
example_test.go is the short-layer regression gate.
The specification above was committed before any code, so the acceptance criteria were fixed in advance rather than fitted to whatever the implementation produced.
Documentation
¶
Overview ¶
Command 37_mvcc_write_contention exercises and MEASURES the write side of GoGraph's MVCC under concurrent writers.
See README.md for the scenario, the objective, and the six questions the output must answer. This file implements that specification; the specification was committed first, so the numbers below are not fitted to whatever the code happens to produce.
Output contract ¶
Bare lines carry DETERMINISTIC facts — counts, ratios that must hold, invariant verdicts — which a test asserts on. Lines prefixed with "# " carry VOLATILE telemetry: throughputs, latencies, heap figures and durations that vary per run and per machine, and which a test must not pin.
That split is what lets one program be both the operator's evidence and the regression gate. A test that asserted on throughput would fail on a loaded machine and teach the next reader to ignore it.