Why NornicDB Uses Its Own Monotonic Counter for MVCC Ordering #174
orneryd
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
TL;DR
NornicDB's MVCC layer assigns each committed write a
(CommitTimestamp, CommitSequence)pair, whereCommitTimestampcomes fromtime.Now().UnixNano()andCommitSequencecomes from a process-wide atomicuint64counter. Snapshot-isolation conflict detection orders versions by sequence first, not timestamp. We did this because:clock_gettime(CLOCK_REALTIME)can step backward under NTP correction, and even between adjacent reads on different goroutines.MATCH (n) RETURN nparses+validates in 39 ns with zero allocations. Multiple commits routinely land inside the sameUnixNano()bucket.time.Time, not global. It is stripped byUnixNano()and is undefined acrosstime.Timevalues produced by independenttime.Now()calls.A
uint64counter incremented atomically per commit gives us a total order that nothing in the operating system can perturb. At one billion commits per second sustained, it overflows in ~584 years.The Bug We Were Hunting
The regression that drove this work was an intermittent CI failure in
TestExecuteCypher_SetInvalidatesManagedEmbeddings:The failure was a phantom conflict. No second writer existed. The transaction reading the node had been opened after the commit it was racing against — there should have been nothing to conflict with. But the snapshot-isolation check disagreed.
The check is, at its core, a comparison between two
MVCCVersionrecords: the version at which a transaction began its read, and the version at which a row was last committed. IfcommittedVersion > readVersion, the SI machinery flags the row as having been written after the transaction started.MVCCVersion.Compare()ordered by timestamp first:That ordering is only sound if
CommitTimestampis monotonic across the entire process. It isn't.Why
time.Now().UnixNano()Cannot Be Used as a Global OrderWall-Clock Drift, Concretely
time.Now()on Linux ultimately callsclock_gettime(CLOCK_REALTIME).CLOCK_REALTIMEis the wall clock and is subject to:adjtime): the kernel slows or speeds the clock by up to 500 ppm to converge on the reference time.settimeofday): if the offset exceeds the panic threshold (~128 ms by default), the clock jumps — possibly backward.CLOCK_REALTIMEsnaps to the destination host's clock on resume.clock_gettimereads a per-CPU TSC and converts it. If two goroutines are scheduled on different cores, their reads can disagree by tens to hundreds of nanoseconds — and the disagreement is not guaranteed to be in any particular direction.A concrete sequence that breaks timestamp ordering:
There was no concurrent writer. The reader simply sampled a clock that had moved backward in the interim.
The Parser Is Faster Than the Clock Tick
time.Now()claims nanosecond resolution, but the underlying TSC tick is the actual quantum, and the kernel's vDSO + syscall path has its own latency floor. On a typical x86_64 Linux host, two back-to-backtime.Now()calls return identicalUnixNano()values an appreciable fraction of the time — anywhere from one in a few to one in a few hundred, depending on hardware.Our parser benchmarks make this concrete:
A
MATCH (n) RETURN nparses and validates in 39 nanoseconds. That is ~25.6 million queries per second on a single goroutine, with zero heap allocations. Throughput across query shapes is 450–550 MB/s of source text.Compare to ANTLR on the same machine, same queries:
ANTLR is ~120× slower and allocates per parse. The fact that our parser is fast is not incidental — it is the entire reason
UnixNano()cannot order our writes. Slow parsers naturally space commits apart by microseconds, and microsecond gaps swamp clock skew. We don't have that luxury.Same-Tick Math
Suppose
clock_gettimehas an effective resolution ofRnanoseconds (typical:R ∈ [1, 40]) and we are sustainingQcommits per second. The probability that two commits land in the same tick is approximately:At
Q = 1,000,000commits/sec andR = 20 ns,P ≈ 1 - e^(-0.02) ≈ 1.98%. Roughly one collision every fifty commits. Over a 10-second ingestion burst, that's hundreds of unordered pairs — and we still need a total order to reason about snapshot isolation correctly.Why
time.Now()'s Monotonic Reading Doesn't HelpGo's
time.Now()does include a monotonic reading fromCLOCK_MONOTONIC. It is real, and it is genuinely monotonic. But:UnixNano(). Per the Go docs: "Because the monotonic clock reading has no meaning outside the current process, serializing at.UnixNano()value and parsing it back loses the monotonic reading."time.Timevalues that share a wall+monotonic pair, used bySub,After,Before,Equal. We persist a singleint64to disk.time.Time, not a process-global counter. Two independenttime.Now()calls produce two independent monotonic samples that are not guaranteed to be totally ordered with respect to each other once you reduce them to scalars.There is no public Go API that returns a single
int64of monotonic nanoseconds suitable for storage and cross-goroutine comparison. You can hack one withruntime.nanotimevia//go:linkname, but it has the same per-process scope astime.Now()'s monotonic reading and ties us to runtime internals.The Fix: A Process-Global Atomic Counter
pkg/storage/badger.gocarries two atomic fields on the engine:Every commit calls
allocateMVCCVersion(), which:mvccSeqand reads the new value.time.Now().UnixNano().mvccHighWaterNanostomax(highWater, now).MVCCVersion{CommitTimestamp: now, CommitSequence: seq}.BeginTransaction()callscurrentMVCCReadVersion(), which clampsnowupward tomvccHighWaterNanos. A backward NTP step cannot make a new transaction observe a read timestamp earlier than something already committed.Snapshot-isolation conflict detection in
pkg/storage/badger_transaction.gothen compares by sequence first:Because
mvccSeqis a single atomic that no commit can skip, the sequence is a true total order. The wall-clock timestamp is retained for two reasons: (a) it's human-readable in dumps and admin UIs, and (b) it's a fallback total order if and only if the sequence saturates.Tests We Wrote
TestCurrentMVCCReadVersion_ClampsToHighWater— read timestamp never precedes the high-water mark.TestAllocateMVCCVersion_AdvancesHighWater— high-water is monotonic even when wall-clock samples drift backward.TestBeginTransaction_DoesNotConflictAfterClockSkew— the original regression: bump high-water two seconds ahead, then commit; must not raise a phantom conflict.TestSnapshotIsolationConflict_UsesTimestampWhenSequenceSaturated— sequence-first ordering, with timestamp fallback only at saturation.TestAllocateMVCCVersion_FallsBackToTimestampOrderingWhenSequenceExhausted— whenmvccSeq == ^uint64(0), advance the high-water timestamp by 1 ns rather than wrapping the counter to zero.Time Until Overflow
mvccSeqis auint64. Its maximum value is:At a sustained commit rate of
Qcommits/sec, time-to-saturation is:For reference, the Earth's projected remaining time before the Sun renders the surface uninhabitable is on the order of 10⁹ years. At 1 billion commits/sec — three orders of magnitude beyond what any single-machine database currently sustains —
mvccSeqwould still outlast a human civilization several times over. The saturation fallback exists for completeness, not because we expect anyone to hit it.For a more defensible upper-bound argument: even if every Cypher query in our parser benchmark were a single committing write at peak parser throughput (~25.6 M qps for
simple_match),T ≈ 22,800 years.Why Not Other Techniques
A few alternatives we considered and rejected:
Hybrid Logical Clocks (HLC). HLC pairs a wall-clock with a logical counter and bumps the logical part on causality violations. It works well for distributed systems where you need wall-clock-aligned timestamps that also respect causality. For a single-node MVCC ordering, an atomic counter does the job with one-third the code and no max-skew tuning knob.
TrueTime / interval clocks. Spanner's TrueTime exposes
[earliest, latest]bounds and waits out the uncertainty. This requires a hardware time source we don't have and introduces commit latency proportional to clock uncertainty. Overkill for single-node ordering.runtime.nanotimevia//go:linkname. Gives us a process-monotonicint64of nanoseconds. Functionally close to what we want, but: (a) ties us to runtime internals that have changed in past Go releases, (b) is still per-process — useless for the eventual cross-node case where we'll want a counter that can be partitioned and merged.time.Now()with a "monotonic clamp" only, no counter. This is what the high-water-mark mechanism does on its own. It prevents backward sampling, but it does not solve same-nanosecond ties. Two commits that legitimately land in the sameUnixNano()bucket are unordered, and SI requires a total order.The atomic counter is the smallest mechanism that provides the guarantee we need.
Closing Note
A useful heuristic: any time you find yourself reasoning about wall-clock ordering in code that runs faster than the wall clock can resolve, you have a logic bug waiting for an NTP correction to expose it. The fact that our Cypher parser executes in 39 ns isn't just a performance number — it's a correctness constraint on every other system in the database that wants to observe its output in order.
All reactions