Repository navigation
v0.12.0
0.12.0 — 2026-08-27
192 commits — 87 fixes, 51 features, 25 documentation, 18 test, 4 performance,
1 style, 1 chore, 1 build change, and 4 merges. Six sprints delivered work in this
window — 345–350 — and 176 tasks closed.
Three things define the release, and none of them is a new capability in the engine.
A deterministic-simulation-testing campaign drove every published surface.
Sprints 345–349 (110 tasks) took the DST harness in internal/sim from a
graph-and-storage exerciser to one that drives MVCC under true concurrency, the full
Cypher language surface, the storage/durability/MVCC substrate, the Bolt wire, and the
graph API, search and bulk-load surfaces. 50 of the release's 51 feat commits are
feat(sim), and internal/sim accounts for 127 267 of the 168 208 changed lines
(75.7 %). This is test infrastructure, not module functionality — the module's own
production code accounts for 8 564 lines across 93 files (5.1 %). What the campaign
delivers to a user is the defects it found: 26 module defects across those five
sprints, and 50 across the release once sprint 350's 24 are added. Five of the
severity-9 ones were silent — wrong or lost data with no error anywhere.
And the crash model was repaired before it was trusted. The most consequential
result of the campaign is not a scenario but a control: until #2535, SimDisk
retained unsynced file data across a simulated host crash, so the battery could not
fail an engine that stopped calling fsync at all. With a durable-length watermark
in place, deleting the WAL commit fsync now fails loudly —
checkpoint-crash-storm reports 192 violations at seed 610621185, losing exactly
half of 384 acknowledged commits, and 33 top-level tests fail, where before the
change it failed nothing. Every durability claim this release makes about the
simulator rests on that.
The bug backlog was emptied. Sprint 350 (66 tasks) resolved every known bug,
re-validating each premise at HEAD before working it. Of the 66: 26 landed a fix in
product code (24 in the module, 2 in examples/26_social_scale_bench), 7 premises
did not hold at HEAD and are recorded below as refuted or obsolete rather than
reported as fixes, and 33 changed only tests, the simulator, the gates, benchmarks or
documentation. No BUG-type task remains in the backlog.
Nothing breaks. No commit carries a ! marker or a BREAKING CHANGE footer, and
an audit of git diff v0.11.0..HEAD across the whole tree found no exported
identifier removed, no exported signature changed, and no exported struct field
dropped. 51 exported identifiers were added across 15 packages, which is what
makes this a pre-1.0 MINOR release under docs/semver.md.
Every direct dependency, and every tool and CI pin, was raised to its latest release in
this window, so go.sum moved; the other go.mod change is the pinned toolchain
directive, go1.26.5 → go1.27.0, with the go directive left at go 1.26 so the
minimum Go a consumer needs is unchanged (see Changed).
The openCypher TCK gate is unchanged at 3897/3897 — 100 % execution-level
compliance is preserved, not extended. No .feature file changed in this window, so
the scenario population is the same one v0.11.0 was measured against.
Read release-notes/v0.12.0.md before upgrading. There
are no migration steps, but a dozen observable behaviours changed and are listed
there.
Added
Deterministic simulation testing — the campaign that found the defects
- MVCC under true concurrency (sprint 345). A deterministic multi-session mode
(RunMVCCSessions) over the durable store, per-transaction MVCC workspaces over the
graph oracle, MVCC isolation checkers for snapshot stability, read-your-own-writes
and atomic visibility (rmp #2436), a lost-update and write-conflict adjudication
scenario (#2437), crash and recovery with open MVCC transactions (#2438),
concurrent-mode transactional roles with typed conflict accounting (#2439),
during-run isolation oracles (#2440), and a production-profile scenario with crash
cycles and transaction-granular durability (#2441). - The full Cypher language surface (sprint 346). Access-path parity and
plan-stability oracles viaExplain(#2447); a per-tickQueryCountersoracle over
the drained result (#2448); relationshipSET/REMOVE/DELETEover parallel-edge
instances (#2449); result-verified range, prefix andINseeks in both spellings
(#2450); pattern shapes unlocking the join/intersect planner family (#2451); grouped
aggregation,DISTINCT,UNIONand exact aggregate oracles (#2452); a
null-semantics scenario that makes the three-valued-logic probes non-tautological
(#2453);FOREACHwrite-path equivalence by oracle expansion (#2454); schema
introspection and full constraint-kind adjudication (#2455); the statistics-planning
regime throughdb.stats.refresh(#2456); temporal values stored, recovered and read
back as temporals (#2457); entity-valued and pure-function probes (#2458);
list-property predicates and entity map projection (#2459); ordering, pagination and
multi-part query structure (#2460); theMERGEsurface with its semantics pinned as
measured (#2461); theDELETEcontract, notifications,YIELDfilter and the wire
parameter matrix (#2462); and the variable-length path surface, oracle-computed
(#2463). - Storage, durability and the MVCC substrate (sprint 347). DDL across the
checkpoint and snapshot boundary (#2464); a crash inside the checkpoint publish
window (#2465); bulk-import publish parity with its uncovered faults documented
(#2466); a snapshot component-corruption fail-stop battery (#2467); typed values and
edge properties through the snapshot codec (#2468); MVCC clock and sequence recovery
across a snapshot boundary (#2469); MVCC substrate telemetry read and adjudicated
(#2470); group-commit coalescing and its fail-all path (#2471); WAL writer surface
oracles with a constructed contiguity control (#2472); a key and weight codec matrix
across crash and upgrade (#2473); transaction-size cap sentinels with the refusal
proven inert (#2474);store.DBteardown under a cancelled context and concurrent
closers (#2475); the checkpointer cadence on a fake clock (#2476); cross-release
compatibility beyond the WAL (#2477); acsrfileaccess-pattern and weight-kind
matrix with the alignment gate probed (#2478); required counters per fault scenario
(#2479); and thegraph/iocodec surface with cap provocation and reproducibility
oracles (#2480). - The Bolt wire surface (sprint 348). The authentication surface with the WAL as
witness (#2481); the transaction registry on a fake clock (#2482);
Server.Shutdown's drain andCloserordering (#2483); streaming semantics —
paging,DISCARDand theqidcontract (#2484);BEGINextras — bookmarks,
tx_timeout, metadata, mode,dbandROUTE(#2485); the protocol version matrix
with 4.4, 5.0 and 5.x side by side (#2486); and the aggregate inbound-decode budget
with its two distinct nesting caps (#2487). - The graph API, search and bulk-load surfaces (sprint 349). The bulk loader's full
contract against an independent model (#2488); every public context-accepting
searchentry point under cancellation (#2489); index contents across the snapshot
boundary (#2490); the lock-free CSR publisher's refcount lifecycle (#2491);
graph/queryas a differential read path against Cypher and an independent oracle
(#2492); the typed schema as a runtime enforcement hook (#2493); the count store held
to a model across recovery (#2494); the statefulPageRanker's two published claims
(#2495); the label index's scoped and range surface against a naive model (#2496);
and min-cost flow's negative-cost regime with the hoisted-reverse Dijkstra (#2497). - The
SimDiskcrash model was overhauled for fidelity (#2514, #2535, #2536,
#2537, #2538, #2547), after an audit recorded in
docs/audits/simdisk-crash-model-fidelity-2026-08-18.md
with 15 findings, 12 of them filed as tasks. A rename undo log with
durable-prefix semantics; adurableLenwatermark, so unsynced file data is now
discarded on a host crash;CrashHostandCrashProcesssplit apart; unlink
modelled as a non-durable directory mutation; aDirSyncfault arm; and ghost
directory entries pruned. The watermark's own cost was measured against the
alternative: +0.00 % bytes and allocations per operation, against +97.64 % time
and +99.94 % bytes for a full clone. The whole catalogue passes underSOAK_FULL=1
with 36 of 36 scenarios and zero skips. - Scenario-scoped required counters (#2479): every fault scenario declares the
counters its faults must move, so a fault that silently stopped firing fails the run
instead of passing it. - A
VACUOUS_RUNviolation family (#2614), so a run that never exercised its
subject is reported as having proved nothing rather than as anORACLE_DEVIATION.
About 178 of 818 emitter lines migrated;checkCheckpointStormNonVacuityhad to be
split, because migrating it wholesale would have relabelled a real snapshot loss
as "the run proved nothing". - Cross-release coverage now reaches the snapshot directory (#2477, #2531). The
harness previously imported only the WAL, recovery and transaction packages, so a
prior release's snapshot directory had never been opened by current code. The tag
list grows from two to four —v0.1.0as the oldest artefact in existence,v0.2.0
andv0.3.0as the bracket around the WAL-replay gap, andv0.11.0because it is
the release users actually upgrade from. All four publish a manifest-v3 snapshot
that current code opens snapshot-only, with 52 labels and 65 properties arriving from
the image, at a cost of 46 s across three arms. The forced WAL-only arm is a
required arm, not a control: publishing a checkpoint truncates the WAL
(walOps682 → 0), so the specified fix alone would have moved the prior-release
WAL-replay path out of coverage entirely.
Public API — every addition is additive
store/recoverynow builds the transactional store it recovered (#2522):
Result.NewStore,Result.NewStoreCapped, binding the recovered graph and the
derived sequence to a caller-supplied WAL writer.recovery.Optionsis unchanged, so
every existing caller compiles untouched.store/recoverysurfaces the snapshot index payloads (#2490):
Result.IndexPayloadFor,Result.WALSuffixTouchesNodeIndex,IndexPayload, and the
sentinelsErrIndexPayloadNotFound,ErrIndexPayloadStale,
ErrIndexPayloadUnreadable. On thecypherside,IndexPayloadSource,
RecoveredIndexPopulation,Engine.RecoveredIndexPopulationand
NewEngineWithStoreAndRecovery.- Weight codecs reach every durable path (#2526, #2529):
store/checkpoint.WithWeightCodec,store/bulkimport.PublishWithWeightCodec,
store/snapshot.CaptureGraphWithWeightCodec,ApplyCSRToGraphWithWeightCodec,
WriteCSRWithWeightCodec,WriteSnapshotFullWithWeightCodecCtx,
CSRReadback.CodecWeights, and the sentinelssnapshot.ErrWeightNotPersistableand
snapshot.ErrWeightCodecRequired. - New typed errors where an untyped or misattributed one used to escape:
cypher.ErrParamNestedTooDeep(#2570),graph.ErrMapperKeyNotPortable(#2528),
store/csrfile.ErrPublishedNotDurable(#2581),
bolt/server.ErrCertOutsideValidity(#2557).cypher.ErrSerializationConflictis
re-exported fromgraph/mvccso a caller need not import the substrate to test for a
retriable conflict. graph/lpg.Graph.ValidateProperty(#2602) and the three pre-validated buffering
entry pointsstore/txn.Tx.SetNodePropertyPreValidated,
SetEdgePropertyPreValidatedandSetEdgePropertyByHandlePreValidated, for the one
caller that has already validated the same value against the same graph.cypher/exec.CountRowswithNewCountRowsand theOperatormethods (#2625);
cypher/exec.Merge.WithLeadingClause,Merge.WithOuterRelCols,
MergePattern.WithLeadingClause,MergePattern.WithOuterRelColsand
MergePattern.WithSchema(#2510, #2511, #2512).cypher/ir.NameSubqueryAnonymousEntities,ir.UserNamed,ir.IsSyntheticVar
andcypher/ast.SyntheticSubqueryVarPrefix(#2508).cypher.Engine.CountSnapshot(#2494), so the count store can be held to a model
across recovery;graph/index.NodeSet.CanonicalBitmap(#2609);
bolt/server.CertReloader.SetClock(#2557) andServer.SetClock.
On-disk formats — additive, with no version step
- The snapshot manifest carries a CRC32C framing trailer (#2520): sixteen bytes of
magic, algorithm and CRC32C over every preceding byte, exposed as
snapshot.IntegrityCRC32CTrailer.ManifestVersionstays 3: old snapshots still
open, and an old decoder stops at the closing brace and never reads the trailer, which
is precisely why this shipped without a version step. - The manifest gains
indexes_commit_ts(#2490), the bounding instant that lets
recovery prove an index payload is not stale. Written only on the quiesced
checkpointer path, so every snapshot that already exists is never hydrated. csrfilegains wire values 5 and 6 for the 1- and 2-byte weight kinds (#2529).
Backward compatible in both directions that matter: 0–4 keep their meaning so every
earlier file reads unchanged, and an earlier reader meeting a 5 or 6 refuses it with
ErrUnknownWeightKindrather than misreading it.CurrentVersionstays 1.
Gates and tooling
- The per-package short-layer cost budget is now enforced on
make ci(#2599,
#2577).HARD_BUDGETdefaulted to 0 — which the script documents as disabled — and
the only target that ran the script was absent fromci, sointernal/simdrifted
from 818 tests / 460.9 s to 974 tests / 570.1 s in five days, unremarked.
test-shortnow pipes throughscripts/pkg_time_budget.sh, so one run yields both
outcomes. The global 240 s ceiling is not relaxed; two named overrides are derived
by one stated rule — worst in-suite figure ever recorded × 1.25, rounded up to the
minute:internal/sim602.9 s → 780,cypher276.4 s → 360. SHORT_TIMEOUT ?= 30m(#2584). The-racepass was the only whole-suite run in
make ciwithout an explicit-timeout, so it relied on Go's 600 s default and died
twice on machine load rather than on code, withinternal/simat 600.705 s and
601.675 s. The value is a backstop against a genuinely hung package, at 3.05× the
slowest package that completed.testlayers.RequireQuietMachineand a serialmake test-timingphase (#2517),
since retired for the three gates that no longer need it (see Changed).- A standing power control beside each timing gate — a workload that models the
defect and must make the gate fire, because the pre-fix build cannot be rebuilt and a
gate that has quietly lost its power reads exactly like one that is passing
(#2571, #2572, #2574, #2589).
Changed
Behaviour a caller can observe
UNIONinside anEXISTS { }orCOUNT { }subquery body is now refused
(#2615). It previously parsed cleanly and the second branch was silently dropped:
measured on a node with a:Wedge and no:Zedge,
EXISTS { MATCH (x)-[:Z]->() RETURN 1 UNION MATCH (x)-[:W]->() RETURN 1 }returned
falsewhere the second branch matches. Refusing rather than supporting was decided
with the user, and the divergence is stated rather than hidden: both reference
engines answer this query, read from their grammar source — Neo4j's
existsExpression/countExpressionadmitregularQuery
(Cypher5Parser.g4:33-35, 671-677) and Memgraph'sexistsSubquery/countSubquery
admitcypherQuerycarryingcypherUnion(Cypher.g4:73-81, 317-323). The
openCypher 9 TCK does not cover subquery expressions at all — zero occurrences of
EXISTS {orCOUNT {across every feature file — so neither choice can move the
conformance count. Support is filed as #2627.- Relationship-variable uniqueness is scoped to the clause, not to one path pattern
(#2252).MATCH (a)-[r]->(b), (b)-[r]->(a)was accepted and returned one row with
count 0; it is now refused withSyntaxError.RelationshipUniquenessViolation, as the
equivalent single-pattern spelling always was.OPTIONAL MATCH,CREATEand an
EXISTSsubquery pattern inherit the clause scope. Reuse across clauses stays
legal. The error text moves from "cannot be used twice in the same path pattern" to
"in the same clause". - An unrecognised Bolt
BEGINaccess mode is refused (#2564) with
Neo.ClientError.Request.Invalid, in a message naming the offending value and, for a
non-string, its Go type."R","read"and a non-string previously fell through to
the write default, so a client that asked for read-only silently received write
authority. The two canonical spellings and the absent key behave exactly as before. - A failed Bolt
LOGONterminates the connection, whichever branch it took
(#2556). See Security. - Bolt status codes changed on three paths. A per-principal quota refusal now
answersNeo.TransientError.Transaction.MaximumTransactionLimitReachedand leaves the
sessionREADY(#2561) — the previousNeo.ClientError.General.LimitExceededdoes
not appear in Neo4j's status codes at all, and itsClientErrorclass instructs a
driver not to retry, which is the opposite of what a self-freeing cap wants. An
operator-initiated termination now answers
Neo.ClientError.Transaction.Terminated(#2560) instead of
TransactionTimedOut. An over-nested parameter now answers
Neo.ClientError.Statement.ArgumentError(#2570) instead of
Neo.DatabaseError.General.UnknownError. metricTxTimedOutcounts the total-lifetime bound alone (#2560). It was
incremented unconditionally by the shared teardown, so an idle reap and an operator
termination were each also counted as a timeout, making the counter a superset of all
three events and defeatingmetricTxIdleReaped's own godoc. Measured on the unfixed
build:tx.terminated=1andtx.timedout=1for one termination. The three are
now disjoint.- A Cypher list crossing the Bolt wire is a PackStream List (#2513). Every list
previously reached clients as a String:collect(),labels(),keys(),
nodes(p),relationships(p), list literals and list-valued properties were all
affected, andnodes(p)additionally lost every node structure. Parameter binding was
never at fault. A client that parsed the stringified form must stop. - An autocommit statement's terminal
SUCCESScarries a fresh bookmark, and a
statement inside an explicit transaction carries none (#2563). It previously carried
whatevers.bookmarkheld: empty on a session that had never committed explicitly,
and the prior explicitCOMMIT's token verbatim on one that had — so a driver
chaining a causally-consistent read was pinned to a strictly older transaction than
the one it had just run. The Bolt specification puts the bookmark on theCOMMIT
SUCCESSand qualifies the stream's terminal one as "(Autocommit Transaction only)". - A schema validator refuses at buffer time, not after the fsync (#2602). A
store/txnwrite of a value the installed validator rejects now fails in
Tx.SetNodeProperty/SetEdgeProperty/SetEdgePropertyByHandlerather than
reachingCommitand returningErrCommittedNotApplied. An installed validator is
therefore a durability gate on both write paths, which
graph/lpg/validator.gonow records. A log written by an older build is outside that
guarantee. - A snapshot write fails rather than publishing a weightless image (#2526). A graph
whose weights the codec cannot encode now returns
snapshot.ErrWeightNotPersistablebefore the header is written, so the checkpoint
returns before the WAL-prefix truncation and the one surviving copy is never
destroyed. Reads are symmetric: a missing codec refuses rather than applying zeros
over weights that are on disk. csrfileaccepts the weight kinds it advertises (#2529).int,uintand
uintptrmove from an untypedbinary.Write: some values are not fixed-sizedleaking
from a dependency to persisting;int8,uint8,bool,int16anduint16move
fromunknown weight kindto persisting; an unsupported type is refused with a typed
error that names it (csrfile: unknown weight kind: string). Platform-dependent
widths are accepted deliberately, on the user's decision:int,uintand
uintptrpersist at 8 bytes to matchstore/snapshot, so such a file written on a
64-bit build would be misread by a 32-bit one. A caller needing a file that crosses
word sizes should use an explicitly-sized weight type.- Six
searchentry points now honour a cancelled context where they previously ran
to completion (#2593). A caller that relied on the old behaviour will start seeing
context errors. See Fixed. graph/queryorders and compares integers and floats in one numeric order
(#2600, #2601).WithRangewithFloat64Valuebounds now matches an
Int64Valueproperty, andWithProperty("age", Float64Value(5))now matches a node
whoseageisInt64Value(5). A query that previously returned nothing returns
matches. The comparison is exact, not a promotion:
4611686018427387905and4611686018427387900stay distinct, because both round to
the samefloat64at 2⁶². The identity between an equality and a degenerate range is
scoped to the orderable kinds — openCypher's equatability is wider than its
comparability, soBOOLEAN,BYTESandTIMEare equal to themselves while no range
over them holds. One contract widens: a boundfloat64-keyed btree covering a
(label, property)pair must now key every numeric value of that property, integers
included. The engine's companion satisfies it by construction; a hand-built
float-only index would under-return.Mapper.LoadFromnames an unportable key type (#2528). A restore that fails
because a key contains a pointer,unsafe.Pointeror channel now reports
ErrMapperKeyNotPortablewrapped insideErrMapperEntryCorrupted, naming the
offending field path (key.P,key.Inner.P,key.A[0]). It inspects the value,
not only the static type, because an interface field's dynamic type is what decides.
A portable key with a genuinely wrong recorded shard is still reported as corruption.- A
csrfilepublish failure after the rename is distinguishable (#2581).
Behaviour is unchanged; what changes is that the caller can tell the two apart.
ErrPublishedNotDurablewraps the parent-directory fsync error at the one site that
fails after the rename, and both godocs name the two sound responses (retry the
fsync, or fail the process) and the unsound one (treat it as "not published"). - Recovery may hydrate an index from the snapshot payload instead of rebuilding it
(#2490). Hydration requires a self-sufficient snapshot, a CRC-valid payload, and no
newer WAL-suffix write to that index's label and property; otherwise the index is
backfilled exactly as before. A corrupt payload falls back to a rebuild per index
rather than fail-stopping, because an index is derived state over an
already-integrity-checked graph.
Query plans — result-identical, cost changed
- The parallel scan adopts the min-cardinality label anchor instead of pre-empting
it (#2431), closing the regressionv0.11.0shipped as open. See Performance. - The range-seek population floor moves 1024 → 64 (#2367), set from measurement.
A floor is kept rather than removed, because below it no count can change the
verdict andrangeSeekBudgettherefore refuses to take one — the same "a gate must
cost less than the decision it informs" rule #2380 and #2392 imposed on the
parallel-scan gate. - The single-edge anchor-swap peephole declines any site whose endpoint name is
empty (#2603). This is the correctness fix for the anonymous-head wrong answer, and
its cost is large and is stated in Performance rather than minimised. #2604 is filed
to recover the optimisation properly. - A group-by-less, non-
DISTINCTcount(*)counts the child's rows through the new
exec.CountRows(#2625) instead of materialising one single-column row per input row
to carry a constant. It is tried after every existing count pushdown, because
those answer inO(1)from a maintained counter while this still visits every row —
it only stops building rows nobody reads.count(v)is excluded deliberately: it
counts non-null bindings, so its argument must still be evaluated per row.
Documentation corrected against the code
-
docs/release.mdno longer claims two security controls the project does not have.
Its branch- and tag-protection policy asserted, as fact, that release tags are
git tag -ssigned and that signed commits are required. Neither is in force: no
signing key is configured on the release workstation,v0.10.0andv0.11.0are
unsigned annotated tags, andgit log --pretty=%G?reportsNfor every commit in this
window. Both are now stated as intended controls that are not yet enforced, with what
adopting them would require, because a security document asserting a control it lacks is
worse than one that admits the gap. The staletoolchain go1.26.3claim on the same page
is corrected togo1.27.0. -
PageRank's L1 delta is not deterministic across worker counts (#2605). The godoc
claimed it was, on the reasoning that per-worker partials are reduced in fixed
worker-id order — which makes the reduction deterministic for a given count, while
the number of partials changes with the count and floating-point addition is not
associative. Reproduced at HEAD on a fresh 100 000-element fixture: reducing identical
values over 1, 2, 3, 4, 8 and 10 partitions gave six distinct results, up to 58 ULP
apart. A third over-claim was found while correcting the first — the Parallelism
summary asserted the result is bit-for-bit identical regardless ofGOMAXPROCS, which
inherits the same caveat because the delta drives the convergence test and therefore
the iteration count. Measurement does not contradict it — 17 280 serial-versus-
parallel comparisons across 40 seeds, 4 dampings, 9 tolerances and 12 worker counts
produced zero divergences, as did a 400-seed sweep — but an absolute statement
resting on a probabilistic argument is now stated as empirical. The reduction is
untouched; callers needing bit-reproducible scores across differing worker counts are
told to pinGOMAXPROCS. -
The
csrfilecrash-durability claim is scoped to where the barrier exists
(#2582).WriteToFileasserted unconditionally that a nil return means the published
file survives process crash, host crash andkill -9; outside the unix build set
parentDirFsyncis an unconditional no-op. The contents are fsynced before the rename
on every platform, and the rename's directory entry is made durable on
linux/darwin/freebsd/netbsd/openbsd. No claim is made about the other platforms,
which is the point rather than an omission — the mirrorparent_fsync_other.go
asserted positively that on Windows the dirent becomes durable once the filesystem
commits its log, citing LMDB, SQLite and RocksDB; that is the same unmeasured
inference with its sign flipped and it is removed. -
Graph.EnableLabelDeltasandEnablePropDeltasare no-ops on a graph fromNew
(#2623). Their godoc said the mechanism is "inert unlessEnableLabelDeltashas been
called";Graph.armMVCCsets the flags and runs by default. -
docs/test-layers.mdlost three false claims (#2566) — the pipeline did not
invoke the budget gate,HARD_BUDGETdefaults to 0, and the override variable is set
nowhere — and now states the measured short-layer budget in one owning place (#2585),
with a regression test that fails if the owner stops carrying the figures. The rule
that produced a whole family of gate defects — "assert on process CPU time, it is
load-invariant" — is corrected: CPU time was measured at 2.90× undermake ci
against 0.94× solo, because contention charges real CPU through scheduler, cache and
TLB pressure. -
docs/dst.mdanddocs/dst-feature-coverage.mdwere re-measured, not asserted
(#2498). Simulator-driven whole-module coverage moved 54.5 % → 71.4 % raw, but
-coverpkg=./...includesinternal/simitself and the harness grew from 8 619 to
30 726 statements while sitting at ~84 % covered, so the number that answers the
sprint's question is the one over the product surface on a near-stable denominator
(60 246 → 61 405 statements): 50.7 % → 65.1 %, +14.4 pp. Both
acceptance-criteria packages came off zero —graph/lpg/schema0.0 % → 61.0 % and
graph/index/stats0.0 % → 56.2 %. The baseline run reproduced the inherited 54.5 %
exactly, which is what makes the delta trustworthy.
Gates — the instrument changed, the threshold did not
-
The pinned toolchain moves
go1.26.5→go1.27.0ingo.mod. Only the
toolchaindirective changed; thegodirective stays atgo 1.26, so no new
language feature is adopted and the pre-1.0 semver-MAJOR consideration thego
directive triggers does not arise.go.sumis untouched andgo mod tidyleaves the
tree clean, so no direct or indirect dependency required a newer minor. The whole
correctness gate for this release —go test -race ./..., the TCK baseline, lint and
the coverage gate — was run undergo1.27.0, which is also what
docs/benchmarks/v0.12.0.mdwas measured on. The
consequence for comparison is stated there rather than glossed:v0.11.0was
measured ongo1.26.5, so a cross-release benchmark delta mixes a compiler change
with GoGraph's own, and none is attributed to the module on that basis. -
Every direct dependency moves to its latest release —
RoaringBitmap/roaring/v2
v2.18.2 → v2.26.0 (eight minors; backs the bitmap-intersection access path,
graph/indexandstore/snapshot),cucumber/godogv0.15.1 → v0.16.0 (the
openCypher TCK runner itself),klauspost/compressv1.18.6 → v1.19.2, and
golang.org/x/sysv0.46.0 → v0.47.0. The other five direct requirements —
antlr4-go/antlr/v4,edsrzf/mmap-go,neo4j/neo4j-go-driver/v5,go.uber.org/goleak
andpgregory.net/rapid— were already at their latest. Indirectly, the godog bump
carriescucumber/gherkinv26 → v42.0.1 andcucumber/messagesv21 → v34.2.1
and replacesgofrs/uuidwithgoogle/uuidv1.6.0;bits-and-blooms/bitset,
hashicorp/go-memdb,hashicorp/golang-lru(v0.5.4 → v1.0.2) andspf13/pflag
follow.go mod verifyreports all modules verified and the module-level CVE scan
reports no vulnerabilities.Each bump was landed individually per the dependency policy, and the two that could
have broken a Compliance Mandate were verified rather than assumed. Upgrading the TCK
runner leaves the gate at 3897/3897 — 3897 scenarios, 3897 passed, 0 failed, 0
undefined, 0 inconclusive, 16006/16006 steps. And theroaring64rationale the
#2607/#2608 range fixes rest on — thatAddRange/RemoveRangeare half-open with no
closed variant — still holds at v2.26.0, at the same source lines, so the
workaround is still necessary rather than now redundant.klauspost/compressturned
out to be reachable from exactly one file,internal/shapegen/graphalytics.go, and
not from the WAL or snapshot path, so it carries no on-disk-format risk. -
golang.org/x/expis deliberately NOT bumped, andANTLRis deliberately held at
4.13.1. Two exceptions to "latest everywhere", each for a reason.go mod whyreports
that the main module does not needx/expand no source file imports it; the newest
revision declaresgo 1.26.0, which would have forced this module's owngo
directive fromgo 1.26togo 1.26.0— the one directive
docs/release.md says not to touch, for an unused indirect. ANTLR's
latest jar is 4.13.2 but the Go runtimeantlr4-go/antlr/v4has no 4.13.2, so
bumping the generator alone would emit a parser expecting a runtime that does not exist
for Go. -
Tooling and CI pins move to their latest releases.
GOLANGCI_LINT_VERSION
v2.12.2 → v2.13.1;cyclonedx-gomodv1.10.0 → v1.12.0 in all three places that
named it (the release workflow,.goreleaser.yaml's comment anddocs/release.md's
local-fallback command), which matters because the SBOM generator's version is a
supply-chain attestation input; and the three SHA-pinned GitHub Actions —
actions/checkoutv6.0.3 → v7.0.1,actions/setup-gov6.4.0 → v7.0.0,
goreleaser/goreleaser-actionv7.2.2 → v7.2.3 — each re-pinned to the commit its
trailing comment claims, verified by resolving every SHA against its tag. Both
Action majors are packaging migrations (ESM, dependency bumps) that change no input this
workflow passes; checkout v7's one behavioural change blocks fork-PR checkout for
pull_request_target/workflow_run, and this workflow triggers on av*tag push.
goreleaseritself is already at the latest release, 2.18.0, andbenchstatand
goimportsinstall@latest. -
The delete-degradation gates measure process CPU time, then allocation volume
(#2571, #2589). The wall-clock form mademake cired on a flat engine at 3.25× with
the last cycle dipping (2.376 s → 2.074 s), a load shape no algorithmic regression
can produce: a ratio cancels constant load, not load that changes during the run,
which is exactly whatmake cidoes as sibling packages start and finish. The final
instrument is allocation volume, whose power against the actual defect was
established rather than assumed — flat workload 0.87×, degrading workload 8.66×, a
10× separation with the unchanged 2.5× threshold between them.RequireQuietMachine
is consequently removed from all three gates, which return to asserting in
test-shorton every push. -
The hub-scaling gate measures allocation volume (#2572), which is also the
faithful instrument: the defect it catches is exact-fit reallocation, whose signature
is copy volume. The prescribed process-CPU remedy was refuted by measurement. The
threshold is unchanged at 40; measured ratio 11.17/11.15/11.15 over three reps, a
0.2 % spread, and 12 of 12 runs pass under 300 named spinners against 6 of 12 failures
for the old instrument. -
The MVCC gate asserts an ordering, not a duration (#2574). Widening the margin was
impossible, not merely inadequate:holderTenureis 500 ms and a 60× ceiling of
601 ms exceeds it, so a caller that blocked for the holder's whole tenure — the #2174
defect, a 232× overrun reported aserr=nil— would have passed. The property was
never "returned inside 105 ms"; it is "abandoned the wait rather than blocking for the
tenure", which is an ordering fact with no margin to tune. -
The openCypher TCK gate no longer reports a slow host as a conformance loss
(#2568). A scenario exceeding the 10 squeryTimeoutstored
context.DeadlineExceededand lowered the counttckExecutionBaselinegates on — the
most serious false signal this project can emit. The discriminator is not elapsed
time, because no duration separates a slow host from a hung engine; it is whether
the engine honoured cancellation. A promptDeadlineExceededisINCONCLUSIVE
and is not scored as a conformance failure; aRunAnythat does not return within
queryWedgeGraceisWEDGEDand is never credited.queryTimeoutis unchanged at
10 s. The credit is bounded to scenarios the harness recorded as timing out, every one
is rendered, and it cannot excuse a real shortfall. -
internal/tmphygienerequires exact registration for coverage and keeps containment
for attribution (#2586). None of the 33 call sites was passing only by nesting, so
the defect was latent. -
scripts/cover_gate.shreclaims its temporaries when cancelled (#2549). The filed
premise was refuted by measurement — bash does run theEXITtrap on
SIGTERM/SIGHUPwhen blocked inwait; the real cause wasgo testrunning in the
foreground, where the trap is deferred until the command finishes and the harness
SIGKILLarrives first. Reclaiming the stranded 248 840 121-byte file took the tree
from 2.4 GB to 791 MB.
Removed
- No exported identifier. The public API is a strict superset of
v0.11.0's. - Two tests proven to have no failure path at all (#2617):
cypher.TestUsingIndexHint_NotImplemented, a baret.Skipwhose divergence is
already recorded indocs/tck/DIVERGENCES.md, and
internal/goldens.TestUpdateRequested_EnvVar_Unset, which discarded its result and
leaked anos.Unsetenvinto its siblings. Gate time saved: zero measurable — the
premise that the full gate is slow through accumulated vacuous tests is not supported,
and mechanical vacuity detection is declared untrustworthy: a naive scan flagged 652
candidates and per-candidate verification left 2, so final-shortlist precision was
2 of 16. The worst false positive would have deleted infrastructure —
TestBreakpointSelfKillChildMarkerhas an empty body and is the-test.run
target two crash-injection tests re-exec the binary with. - The
store/csrfileFS seam's two now-orphaned methods, verified to have no callers
first, withTruncatemoving from thefsinterface toFile(#2580). - The chain-level
primordialflag on the life store, replaced by
aliveBefore(born, died)(#2445). RequireQuietMachinefrom the three delete- and hub-scaling gates (#2589, #2572).
Fixed
Correctness — silent wrong answers, and one panic
- A bit-packed BOOL edge-property column panicked on the fused append path
(#2493).edgePropColumn.grownWithValueandgrownAbsentSharedconverted a dense
column to sparse unconditionally, but aPropBoolcolumn is packed bits and has no
sparse form. Reachable from the public API with three nodes and two calls — no
concurrency, no store, no schema:
SetEdgeProperty("a","b","flag",BoolValue(true))followed by
AddEdgeLabeledWithProperty("a","c",1,"REL","n",Int64Value(7))panicked with
index out of range [n] with length 0insidetoSparse. It was invisible because the
guard already existed in two sibling functions, so the invariant looked enforced.
Each half of the fix is proven load-bearing by controlled revert. Found because a new
DST scenario died on its first run. - The single-edge anchor swap dropped every row of an anonymous-head pattern
(#2603).MATCH (:Person)-[:KNOWS]->(:Vip) RETURN count(*)returned 0 where the
named-source spelling returned 1, on the shipped default configuration, with no error
and no notification. openCypher requires the rows:Match2.featurescenario [2] pins
MATCH (:A)-[r]->(:B) RETURN rto one row.
The mechanism is why the same pattern is correct without the swap. The written plan
enforces the head's label structurally, in the access path —NodeByLabelScan
needs no variable name to scan a label. The mirror relocates that label onto a
Selection{LabelPredicate(fromVar, …)}above the re-rooted expand, and a predicate can
only reach a node through its name.ir.matchNodeScanleaves the pattern head's
NodeVarempty while every non-head anonymous node is given a synthetic__anon_N,
so the relocated receiver resolved to no column,evalLabelPredicatereturned NULL,
andFilterdropped every row. Mis-resolution is ruled out rather than assumed: the
fixture's destination is(:Person:Vip), so hadVariable{""}fallen back to column
0 the(:Person)predicate would have been TRUE and the row kept.
Teaching the mirror to name that endpoint was rejected on two grounds, not on
effort:mirrorAnchorSiteis a pure IR function holding only the site, so it cannot
mint a provably collision-free name, and the peephole's published correctness argument
is that the mirror is byte-identical to the plan the translator emits for the
openCypher-equivalent pattern — a synthetic-name mirror is a plan the translator can
never emit, so proof-by-construction would stop covering the anonymous case.
Why nothing caught it: the TCK scenario cannot reach the peephole for two
independent reasons, each sufficient — its relationship is untyped while
matchAnchorSiterequires exactly one type, and its graph is balanced so the 2× cost
margin is unreachable (both confirmed byanchorSwapBuildCountreading 0). And the
optimisation's own 24 test patterns all used named variables and never
count(*), so their differential oracle only ever compared the spelling that was
already right. That gap is closed with anonymous-endpoint arms, which matters more
than the fix. MERGEcontradictedMATCHinside one transaction and created a duplicate
(#2365).MERGEdecided whether an existing node matched its pattern by reading that
node's labels and properties raw — bare shard reads returning the newest stored
value, including other in-flight transactions' uncommitted writes. Reachable because
conflicts are per substore, so a transaction writing the label never collides with
one reading it to make a match decision. The predicted direction (a peer's uncommitted
add makingMERGEmatch) does not reproduce, because the enumeration feeding the
filter is already view-resolved; the mirror does — a peer's uncommitted label
removal hid a node that carries the label in every committed state:
MATCH (n:Target {k:'y'}) RETURN count(n)answered 1 while
MERGE (n:Target {k:'y'})duplicated. Probing after the label half was fixed found the
property half has the identical exposure at the same three call sites, and it is
fixed here too. Where the peer's write andMERGE'sON MATCHaction touch the same
substore,MERGEnow correctly takesON MATCHand the action meets a real
serialization conflict — refused, not duplicated, which is what the ACID contract asks
for.- A
MERGEaction on an outer relationship wrote to an unrelated node (#2515).
Two identity namespaces share one representation: a relationship rides in a row as a
bare integer holding its stable handle, and the node resolver converts any integer to
a node id unconditionally. The node-only merge operator asked is this a node first,
so whenever a node existed whose id equalled the relationship handle, the action wrote
its property to that node — and reported one property set, so the counters oracle was
structurally blind. A misdirected write, not a lost one. It was never flaky: under
-countit failed three times in five hundred, always the same iterations,
byte-identical across separate processes, and a parallelism sweep from 1 to 32 gave
exactly the same three at every level, which is what ruled concurrency out. The
varying input is a process-global synthetic-key counter that decides node ids. The
regression tests construct the collision — raising the handle counter until it
reuses a decoy node id — and verify that precondition instead of hoping for it. MERGEfired when its driving clause produced no rows (#2512), writing data into
the graph. Eighteen cells: aMATCHthat matches nothing,WITHfollowed by a false
filter,UNWINDof an empty list, aWHEREexcluding everything, zero-row drivers
carryingON CREATEorON MATCH, andFOREACHover an empty list in both merge
forms. The diagnosis needed one correction: the empty-row fallback was never required
for a leading clause, because the plan builder already substitutes a single-row leaf
when the IR child is nil — so the fallback was reachable only in the defective
case, which is why the leading-clause path is byte-identical now that plan shape,
rather than row arrival, decides. No TCK scenario drives aMERGEfrom a zero-row
clause, which is why a green conformance suite never saw it.- A
MERGEaction targeting a variable bound by a preceding clause was silently
dropped (#2511): the statement committed, the counters came up short by exactly one
property, and the value read back null. Thirty of thirty-six matrix cells. A third
operator was involved that the first diagnosis missed — the node-onlyMergealready
resolved an outer node target through the full row schema but lost an outer
relationship target, because that resolver understands only node values. Two adjacent
problems are fixed in passing: a zero-row driver left the target column unset and the
node resolver dereferenced a nil interface, crashing the statement; and the counters arm
added for #2510 was unreachable because its family was never listed in the expectation
switch. - A whole-entity relationship action was discarded on the
MergePatternpath
(#2510).MERGE (a{..})-[r:T]->(b{..}) ON CREATE SET r += {...}created the
relationship and dropped its properties without error — 20 of 24 cells; only
pre-bound endpoints with an all-literal map ever worked. Two narrowings compound: the
endpoint-boundness gate routes any unbound pattern toMergePatternwhatever the
right-hand side, and a second independent check rejects any parameter even when the
endpoints are bound. Routing these shapes toMergeRelationshipwas refuted
rather than assumed: its contract requires both endpoints bound, so 16 of the 20 broken
cells can never reach it. - Reverse and undirected traversal returned another edge's data (#2504). Over a
reciprocal pair — bothA->BandB->Apresent —MATCH (b)<-[r]-(a)reported the
rightid(r)but the reciprocal edge's type, properties,startNodeand
endNode; undirected returned two rows with distinct ids carrying the same properties.
The suspected reverse-expansion mapping was not the cause:revtofwdis
handle-exact.exec.Expandemits its triple in traversal order with no direction
flag, so the hydrator re-derived storage direction by probing existence —
HasEdge(src,dst)versusHasEdge(dst,src)— which is undecidable exactly when both
directions exist.relStoredInverteddecides by handle instead. A second site, the
WITH-barrier whole-entity projection, carried an older no-labels-and-no-properties
probe that a reciprocal pair passes trivially, and was found only after reinstating the
simulator arm. Forward reads and non-reciprocal edges were always correct, which is why
3897 green TCK scenarios and every existing fixture missed it.Merge5.featurepins the
contract in-tree: an undirected match reports the stored orientation, independent of
traversal. Measured 2.13 % faster, allocations identical. - The
UNWINDpattern-comprehension route had drifted five defects from theMATCH
baseline (#2505).RETURNandWITHhoist a pattern comprehension into a
RollUpApplyso it executes on the realExpand; nothing hoists forUNWIND, where
the comprehension survives to a separate fallback evaluator. On a reverse leg the walk
advanced to the anchor instead of the neighbour, and the endpoints came back transposed
against the stored-orientation contract; independent of direction, the relationship
value carried no identity soid(r)was 0, its properties were read per pair
rather than per handle — parallel siblings reporting the last writer's values — and the
type filter was per pair too, so[r:KNOWS]over aKNOWS/LIKESpair emitted two
rows whereMATCHemits one. The simulator fixture then found two more: an incoming
comprehension over a self-looping node returned nothing, and the same skip made the
existentialWHERE (s)<-[:R]-()disagree with bothMATCHandEXISTSon the same
graph. - A projection-position
EXISTS { }/COUNT { }with an inner filter was constantly
false or zero (#2507), while the same subquery unfiltered, and theWHERE-position
form, were correct. The reported cause was wrong. Instrumenting the innerFilter
closure showed the node hydrating correctly:StripLiteralshoists STRING literals
inMATCHandWHEREonto auto-parameters, andcompileSubASTpassed a nil parameter
map tobuildOperator, so the auto-parameter evaluated to NULL and the comparison
dropped every row. Numeric literals are never hoisted — which is exactly why an age
filter always worked and a name filter never did. The nil build options were real but
explained different symptoms: an innerr.proportype(r)had no edge metadata, a
nested subquery had no sub-evaluator, and a pattern predicate had no pattern evaluator.
forSubquerybuilds the child context from an explicit allowlist and fails closed,
deliberately withholding the parallel fields because the sub- and pattern-evaluators are
not concurrency-safe. No TCK feature file uses a bracedEXISTSorCOUNTsubquery,
and none uses aWHERE-position pattern predicate with an inline string property. - The parallel-edge family: four routes reported or wrote the wrong instance's
state (#2500, #2501, #2502, #2503). RelationshipREMOVEreported
PropertiesRemoved=1only for the first removal of a(src,dst)pair, because both
mutator adapters gated the counter on the aggregate pair probe while the mutation ran
per handle (#2500); five more routes did the same forSET x = null, a literal map, a
FromParammap and two merge-action paths (#2501); the replace teardown enumerated only
the aggregate pair store's keys, so a key living only in the targeted instance's
by-handle bag survived a replace whose contract is "the resulting map equals exactly
the given map", andresolveEntityBindingdroppedrelHandlefor a
RelationshipValuethat crossed aWITHboundary (#2502); and every source-side
whole-entity copy read the aggregate per-pair map when its source was a bound instance,
so keys carried only by the twin leaked into the copy target — all five routes plus the
applyExprValueRelationshipValuearm, red-reproduced 18 of 18 on both write paths
(#2503). Graph state was correct in #2500 and #2501 — the effect report lied; in #2502
and #2503 reads returned wrong data. - A write transaction's own-writes view leaked to every reader through the CSR and
edge-type-filter caches (#2446). Both caches key on(epoch, startTS)with no
transaction identity, while the pair is built as-of(startTS, snap.TxID())— so a
write transaction querying after its own edge create cached a CSR containing its own
pending arc, and every reader at the same instant was served that private view: CSR
positions shifted, the position-keyed type filter mislanded, and committed edges lost
their types for every reader. Found by the DST multi-session mode as seed 16's
typed-count flip at the exact tick of an unrelated pending create. Pure readers carry
Snapshot.TxID() == 0and identical visibility at identical(epoch, startTS), so
viewCarriesOwnWritesgates both caches: an own-writes view builds fresh and is neither
stored nor served, and pure-reader caching — the hot path — is untouched. graph/query's index seek and its scan fallback disagreed on mixed-kind numeric
bounds (#2600). With anInt64Valueproperty underFloat64Valuebounds the seek
returned the match and the scan returned nothing, while the Cypher path and an
independent model oracle both returned it. The direction was settled from the project's
own conformance authority:Comparison2.feature's "Comparing across types yields null,
except numbers" sweeps all 90 ordered cross-type pairs and keeps exactly the four
INTEGER/FLOAT rows;Comparison1has1 = 1.0as true; CIP2016-06-14, adopted and
normative, has INTEGER × FLOAT as the only off-diagonal entry in its comparability
matrix. So the scan was the defect. Fixing the comparison alone would not have made
the arms agree, and that is the part worth reading:seekIndexablePredsmarked a served
predicate so the scan skipped it with no residual filter, and the companion index is
float64-keyed — so above 2⁵³ the seek would have started over-returning at the moment
the scan stopped under-returning. The seek now reports whether it was exact, and an
inexact one narrows the working set without discharging the predicate.
btreeRanger[int64]is removed, and not because it was dead: once the range
comparison unifies the two numeric kinds, anint64-keyed btree is a subset of the
answer, and a subset cannot be repaired by a residual filter.WithPropertyequality and a degenerateWithRangedisagreed on the same data
(#2601). After #2600 unified the range order,WithRange(5, 5)matched an
Int64Value(5)under a float bound whileWithProperty("age", Float64Value(5))did
not.equalValuenow routes a numeric pair through the same exact comparator. The
relation is equality, not equivalence — NaN is equal to nothing, including itself —
so the constraint canonical-key path is deliberately not reused.
hashLookuper[int64]andhashLookuper[float64]are removed for the #2600 reason; no
engine-created hash index is numeric, so nothing the engine builds lost a seek.count(*)'s first implementation shipped a wrong answer, and it is recorded
(#2625). It installed the post-aggregation schema through the unguarded installer
the leaf pushdowns use — safe there only because nothing is built below a leaf. Over an
arbitrary child whoseSelectionoperators hold closures overbopts.scalarCols,
tagging the output column scalar when its alias shadows a pattern variable made
thoseSelections read the bound node's column as a scalar and drop every row:
MATCH (n {name:'A'})-[:LIKES]->(m {name:'B'}) RETURN count(*) AS nreturned 0 while the
same query aliasedAS creturned 1. Three existingTestEdgeTypeFilterCache_*tests
caught it;installAggOutputSchema, which carries the alias-shadow guard, is what it
uses now.
ACID — Consistency, Isolation and Atomicity
- A value the installed schema validator refused became durable and was materialised
by recovery (#2602).Tx.Commitappended and fsynced every buffered op and only then
applied them throughlpg, where the validator hook lives, so the rejection arrived
after the frame was on disk:CommitreturnedErrCommittedNotApplied, the live
graph stayed clean, and the reopen — which installs no validator — replayed the refused
value straight in. Measured: anagedeclaredPropInt64came back from a host crash
as a STRING. That breaks the Consistency half of the ACID contract, which names
label/property typing among the invariants a committed transaction must leave satisfied.
The cost flagged when recommending the fix did bite, and finding out how is the more
interesting half: the Cypher adapter callsa.w().SetNodeProperty(which validates) and
thena.tx.SetNodeProperty, so every engine write validated twice — and a
SchemaValidatoris permitted to be stateful, so a counting validator that rejects the
second write of a key saw the wrong write refused. Worse, the adapter discarded that
call's error (_ =, annotated "ErrTxFinished impossible here" — a premise this change
falsified), so the new guard fired on the engine path and was swallowed. Both are closed
by the explicit pre-validated entry points. Measured after:refusedAtBuffer=true,
notApplied=false,resurrected=false,storedAfterRecovery=absent, andwalOpsdown
from 4 to 3 — the refused frame is gone from the log. - Uncommitted create+delete pairs leaked phantom nodes (#2443), both found by the DST
multi-session mode.NodeExistsAsOfanswered TRUE for a node whose birth and death
records were both invisible, so a create+delete inside one open transaction showed
concurrent readers a bare node with no labels or properties. AndreclaimAbortedLife
treated an aborted birth+death pair as independent directions — it tombstoned the node
for the birth and then revived it for the death, so a conflict-doomed transaction's
rolled-back creates persisted as phantoms. - The node is now the unit of write–write conflict under MVCC (#2444). A
DETACH DELETEof a node carrying another in-flight transaction's pending property
write sailed through where a second property write is refused, and its eager
present-state mutations then destroyed state a rollback could not restore: with both
transactions rolled back, the committed node was gone from the label scan
permanently. Every side now claims in its own store first and cross-checks the others
after, so whichever claim lands second sees the other and detection is race-free without
a per-node lock. Edge appends check and stamp both endpoints, directed graphs
included, as Memgraph prepares both vertices. A birth on a virgin slot is exempt from the
doomed-transaction refusal: the mapper has already interned the slot when the hook runs,
and refusing the record left a permanently visible orphan node. - Rollback is sound under MVCC — four defects (#2445), each with a minimal
deterministic reproduction that fails on the unfixed engine. The life store's
chain-level primordial flag misread a died+born pair written over committed history, so
a doomed rollback re-tombstoned the node its undo had revived, and a voluntary
rollback — which publishes its inverses — made the node invisible to readers pinned
before it. The undo replay's own-arc adjacency withdrawals were refused by the head test
when a foreign commuting append had moved the head, silently leaking the rolled-back
edge as a torn arc. Adjacency entries are immutable snapshots built from the current
slot, so a concurrent append embedded another transaction's pending arc — a permanent
leak when the owner aborted, an uncommitted-edge read when it was merely slow. And
addEdgeInfo/addEdgeHInforan the #2444 endpoint cross-check after the physical
insert and returned the refusal with the arc still in the slot; no undo is recorded for a
failed statement, so the next committed append on the node embedded and published the
phantom arc.
Durability and on-disk integrity
- A struct or named-integer edge weight was silently dropped at the first checkpoint,
permanently (#2526).store/snapshotsized weights statically and returned zero for
anything outside a fixed set of Go primitives, so it wrotehasWeights=0—
byte-identical to a deliberately weightless graph — and the checkpoint then truncated
the WAL prefix still holding the values. Nothing errored, and the recovered graph was
indistinguishable from one that never had any weights. Measured 95 of 95 weights at
the WAL boundary and zero of 191 after one checkpoint. Bulk import carried the
identical defect and worse: it bypasses the WAL, so no surviving copy existed anywhere.
A sentinel in the existing width byte now selects a variable-width section — offsets plus
payload — encoded through the weight codec; offsets rather than length prefixes because
the apply path skips slots whose endpoints cannot be resolved, so a self-delimiting
concatenation would desynchronise. The safety property matters more than the encoding:
the decision is made before the header is written and the refusal propagates, so the
one surviving copy is never destroyed. Measured after: a duration weight 0 of 3 → 3 of 3,
and the simulator's binary-marshaler arm 0 of 17 → 17 of 17 through snapshot-only
recovery — a figure that is now a gate, because a codec that survives WAL replay and
loses everything through the snapshot shows a healthy total and a zero there. manifest.jsonwas not checksummed (#2520). It recorded a CRC for every component
it listed and none for its own bytes. Being JSON, a flip inside a key name left valid
JSON whose renamed key the decoder dropped, so the field silently became zero: 360 of
1399 bytes, 25.7 %, were accepted in silence, and one character of the commit-timestamp
key dropped the recovered MVCC clock floor from 20 to 0, skipping clock restoration. The
census falls to 0 of 1450, and the clock floor holds across all 1447 flips. The shape
was chosen from prior art read at source: only the RocksDB MANIFEST achieves full byte
integrity and ignore-unknown-fields, and only by separating them — forward-compatible
tag skipping inside the payload, the checksum in the framing outside it. Two details are
load-bearing: a non-empty tail must verify, or a flip in the magic would demote a
protected file to legacy-accepted and merely relocate the defect; and an integrity marker
in the document means a manifest whose trailer was lost is refused rather than taken for
legacy, so defeating the protection needs two independent damages.- The recovered transaction sequence was derived on every open and discarded by every
caller (#2522). Its godoc said the value exists to seed the store so a sequence is
never minted twice. Measured: a store reopened on a non-empty WAL re-minted sequences
1, 2 and 3 while transactions carrying those values were still in that same WAL. The
census over the tree was total — no shipped embedder wired it. A contract that every
caller violates is mis-placed, not universally mis-implemented, and this codebase had
already reached that conclusion one field over, where recovery restores the MVCC clock
itself and its comment names this very field as the negative example. The floor never
leaves the package now and the omission is inexpressible. Returning a fully-open store
was rejected: deciding whether to append onto an unclean recovery must stay with the
caller. Three shipped examples were wrong and are fixed, one of them a long-lived
HTTP API serving writes off a reopened store. csrfilepersists every weight kind it advertises, reconciled with the snapshot
(#2529). See Changed.- The snapshot index payload was written on every checkpoint and never read back
(#2490).applySnapshotIndexesrequired the index to be already registered on the
graph'sindex.Manager, but recovery builds its own graph and nothing registers on it,
so the manager was always nil andResult.SnapshotIndexeswas provably always 0.
Every test that reachedDeserializepoked the package-private helper on a hand-built
manager; the two that went through the real entry points asserted 0. Registering the index
earlier would not fix it: there is no index fan-out belowcypher— the only
productionManager.ApplyBatchcall site is incypher/exec/index_writeback.goand
store/txndoes not importgraph/indexat all — so WAL replay cannot maintain a
registered index, and an early registration would leave it seekable but frozen at the
snapshot instant, which is a silent wrong answer rather than a slow one. Also fixed:
store.snapshot.indexes.loadedwas incremented by the write path and counted payloads
written rather than loaded, so it is renamed.written.
Bolt protocol and wire
Options.Loggernever reached the Bolt session (#2481).newSessionhard-coded
slog.Default(), so all eleven session-level log sites bypassed the configured
logger — authentication outcomes, query,BEGINandCOMMITfailures, and the
transaction-quota refusal. An embedder that configured a logger still had the majority
of the server's events written to the process default. Found by the DST authentication
scenario, and verified red without the fix.- A refused re-authentication left the connection running as the previous principal
(#2556). See Security. - An expired certificate could evict a working one, and a content-preserving rotation
was silently ignored (#2557, #2558). See Security. - An unrecognised
BEGINaccess mode granted write authority (#2564). See Security. - An operator termination reported a timeout that had not elapsed and a writer lock that
no longer exists (#2560). Three distinct server-initiated endings — the total bound, the
idle bound andServer.TerminateTransaction— funnelled through one teardown that chose
the reason itself. Both halves of the message were false: no timeout elapsed, and the
writer serialisation that clause named was retired by #2305/#2306, whichtxregistry.go
documents in as many words. The filed prescription was refuted by primary evidence:
neo4j-go-driverv5.28.4'sreclassify()(neo4j/db/errors.go:132-139) rewrites
Neo.TransientError.Transaction.Terminatedto the ClientError form and its stated job is
mapping pre-5.x errors into 5.x classifications — so ClientError is the modern
spelling, and becausereclassify()runs beforeIsRetriableTransientreads the
classification, the Transient form would not be retried either. - A quota-refused
BEGINleft the session in a state nothing had chosen (#2561). Two
adjacent failure paths inhandleBegindisagreed, and nothing asserted the difference at
any level, so it was accidental. It is chosen now — a cap is back-pressure, not a
protocol error; the slot frees when another of the principal's transactions closes, so
retrying the sameBEGINis the right response and requiring aRESETfirst would charge
the client a round trip for the server being busy. The neighbour keepsFAILED, because a
newTxfailure is a genuine failure to open a transaction. The in-flight cursor cap
keepsLimitExceeded: it is a different limit, reached inside one transaction. - An autocommit statement's bookmark was empty or stale (#2563). See Changed.
- Every Cypher list crossed the wire as a String (#2513). See Changed. The arm
recurses element-wise like the map case, so nested containers and entity elements encode
structurally. A PackStream List is itself version-independent — neither this codec nor the
reference driver's unpacker takes a version — but its elements branch, so a list of
nodes carries three fields on 4.4 and four on 5.0 and a datetime changes tag; both majors
are proven over a real socket. The fix is generalised past its own symptom: a sweep over
all sixteen concrete value types on both majors fails if anything but a string encodes to
a Go string. - An over-nested parameter was reported as an internal server bug (#2570). The
maxParamBindDepthcheck returned a barefmt.Errorfcarrying no sentinel and no
category, soFailureCodefell through toNeo.DatabaseError.General.UnknownError, whose
text the failure sanitiser replaced with "An internal error occurred. See server logs for
details" — telling an operator GoGraph had a defect over a payload that is wholly
client-supplied and wholly malformed. Measured at HEAD, the boundary is exactly 32 accepted
/ 33 refused, identical for list chains and map chains. The task proposed
Neo.ClientError.Request.Invalid; that confuses two layers — the message decoded
correctly and the statement was dispatched, so what is invalid is the statement's
argument.Request.Invalidis what the serve loop answers for a frame that will not
decode at all, and reusing it here would make one code mean two things. Session state is
leftFAILED, verified rather than assumed:Transition's one documented exception for
stayingREADYis back-pressure, "where retrying the same request can succeed", and a
depth refusal is deterministic.
graph/index — range boundaries and the serialized image
AddRangeandRemoveRangesilently dropped the whole range atMaxUint64
(#2607). Both are documented over the closed interval[from, to]but converted to
roaring's half-open API with a plainto+1; atto == math.MaxUint64that wraps to zero
and roaring returns immediately onstart >= end, so the loss was total, not off-by-one.
Reproduced exactly as filed:AddRange(max-5, max)yields cardinality 0 where the
closed interval says 6, andRemoveRange(max-3, max)over a five-member bitmap-tier set
removes nothing; bothmax-1controls are exact, so the failure is attributable to the
final id. The sharper half is the tier divergence:RemoveRange's singleton and small
branches filter on the closed interval directly with no+1and therefore no wrap, so the
identical call over an inline-tier set holding the same five members correctly leaves 1 —
the same logical operation on the same membership answered differently depending on state
the public surface does not expose. The repair was a design choice among splitting at the
boundary, saturating and refusing; split, because it is the only one that keeps the
documented contract. The dependency's own source settles that a split is required rather
than avoidable: roaring64'sAddRange/RemoveRangeare half-open overuint64with no
closed variant, while the 32-bit sibling escapes the same problem only by widening its
bound touint64so it can nameMaxUint32+1.- An inverted or empty
AddRangeminted a permanent, serialized entry for a label
carrying nothing (#2608).label.Index.AddRangestored theNodeSetback
unconditionally whileNodeSet.AddRangepromoted to the bitmap tier before looking at
the interval. Reproduced: 1000 invertedAddRangecalls on distinct labels take the
serialized image from 16 bytes to 20 016, declaring 1000 labels, whileCountreports
0 for every one — the entry is permanent and costs 20 bytes apiece on disk while being
invisible throughCount,ScanandHas.RemoveRangehas always promised the opposite
for its own direction and kept the promise;AddRangenow mirrors it. Both prescribed
repairs are applied, because they fix different halves and neither alone suffices, verified
rather than assumed. Recorded, not fixed, and out of scope:Deserializestill
re-materialises an empty-bitmap entry from a legacy image written by the defective writer. - A
Serialize/Deserializecycle changed the bytes of a small run-encoded label
(#2609). roaring picks a container encoding from construction history, not from content:
AddRangebuilds a RUN container,NodeSetFromBitmapmoves a bitmap of at most
smallSetMaxids back to the inline tier on the way in, and the inline tier
re-materialises throughAddManyas an ARRAY container. No content is lost, but a snapshot
of unchanged data was not byte-reproducible, which defeats fixture diffing and any
content-addressed comparison of images. Reproduced across widths 1 to 16: 55 B in and
64/66/68/70/72 B out at widths 4 to 8. The acceptance criteria were amended, with the
user's decision recorded: as filed they asked for byte-reproducibility at every width,
and measured that costs more than the report knew —Serializeholds only anRLockand
NodeSet.Bitmaphands back the live bitmap, so normalising an arbitrary set needs a full
Clonefirst, measured at 6.55 → 90 µs/op and 1 289 → 218 065 B/op on a sparse
100k-id label to produce a byte-identical image. Normalisation is bounded atsmallSetMax
— exactly the band the reader down-converts — and normalises down rather than up,
reaching the same canonical form asRunOptimizewith strictly less work.
Memory, resources and the mapper
-
Closing a write window never released its shard builders, so
Compactdoubled
resident memory (#2628). The contract onadjShard.buildingsays "the window end
freezes it by clearing this field"; nothing did. NeitherEndExclusiveBuildnor
EndCommittouched it, so the field was released only lazily bystoreEntry, when a write
presenting a different owner happened to touch the same shard — a shard no later
transaction touched pinned its builder for the lifetime of the graph. The cost is not the
builder, it is what the builder blocks: it keeps the shard's pre-window slot array
alive afterslotsRefhas moved on, so anything replacingslotsRefleaves both arrays
live. Measured at 3 M edges, build thenCompact, live heap afterruntime.GC():Arm Live heap Allocated no window 159.9 MiB 3.25 GiB exclusive-build window, before 361.9 MiB 1.79 GiB exclusive-build window, after 159.9 MiB 1.79 GiB Bracketing a bulk build — the documented way to make it cheaper — roughly doubled
resident adjacency once the graph was compacted, invertingCompact's own purpose. It is
now free.EndCommitis deliberately unchanged: that is the per-transaction serving path,
and anO(shards)walk per commit would be a real regression. A second, sharper hypothesis
was refuted and is not claimed — a write made afterCompactinside the same open
window is not lost, and a test asserting the edge survives passes on the unfixed code. -
An unreproducible
NodeIDwas attributed to the snapshot rather than to the key type
(#2528).mapperShardForhashes an uncovered comparable key through
fmt.Fprintf(h, "%v", v), which renders a pointer as an address, so a key carrying one
hashes to a different shard on every run — while the function's godoc promised the hash was
stable across processes, and FNV was chosen precisely to guarantee that. Reproduced
deterministically in process, which is stronger than the subprocess reopen the task
prescribed: two allocations of the same logical key already hash to different shards, so no
second address space is needed and, unlike a subprocess run, this cannot agree by
coincidence. Two findings changed what the fix should be. The loss is not silent —
Mapper.LoadFromalready enforces that an entry's packed shard equals
mapperShardFor(key)and returnsErrMapperEntryCorrupted, and
store/snapshot/apply.gopropagates it and aborts before any label or property is applied.
And no better hash could fix such a key: for every comparable Go type, "formats as an
address" and "compares by address" coincide, so a key decoded into a fresh allocation is
not the key that was written even with identical data — its identity, not merely its
shard, is unreproducible, and an address-independent hash would have made the hash agree
while the map still created a second entry, which looks fixed and is not. The real defect
is therefore misattribution. The audit for other%v-based hashing or ordering is
recorded:mapperShardForis the only%vhash in the tree.
Concurrency — cancellation and a data race
- Six exported
searchentry points could run to completion without ever observing a
cancelled context (#2593), violating the concurrency mandate's context-aware-blocking
rule. Worst case first:flow.PushRelabelMaxFlowCtxreturned the true, complete maximum
flow with a nil error under an already-cancelled context, measured atMaxNodeIDup to
1536 — a request-scoped deadline fires, the caller's context is dead, the library does the
full work and reports success, and nothing downstream can tell. One root cause behind five
of the six: increment-then-mask, so the counter is 1 on the first iteration and the mask
first trips at 4096 units of work. Measured with a pre-cancelled context at 8 nodes, every
one ofBellmanFordCtx,BellmanFordInto,KCoreCtx,KShortestPathsLooplessCtx,
KShortestPathsLooplessCtxWithOpts,EppsteinKShortestCtxandPushRelabelMaxFlowCtx
returned nil with a complete result.TopologicalSortCtxwas a different mechanism: on a
fully cyclic graph every vertex has indegree ≥ 1, so the polled Kahn loop never runs and
ErrCyclewas returned in preference to the context error, at every input size.
dischargegot its own poll, decided by measurement: one discharge reaches 60 004 inner
steps at 20k vertices and 600 004 at 200k — 146× the whole stride — so counting
discharges left the interval between polls unbounded in the input size. The sixth site was
missed by the first sweep and found by the battery it was meant to unblock:
bellmanFordVirtualSourceis a shared prologue, so neither Johnson entry point's own file
contained the defect. The count is closed by scan rather than by trust — a detector for
the shape, run over all 47 stride-gated polls in the module, leaves exactly two
increment-then-mask sites, both benign because their callers poll unconditionally first.
No live-context result changed, and that is measured, not argued: 3 622 signatures
byte-identical before and after. - Concurrent first executions of a subquery-bearing query raced on the shared cached
AST (#2508). Only the outer plan is cached; anEXISTS { }/COUNT { }subquery's
inner plan was re-translated on every execution, over the shared cached AST, and the
translation walk names anonymous entities by writing into that AST. Not benignly: each
translator mints its own names, so one plan could reference a variable the other never
bound — a wrong answer, not a duplicated identical write.
ir.NameSubqueryAnonymousEntitiesnow names those entities once, at the top ofFromAST,
while the AST is still private to the plan-cache builder. This is the discipline both LPG
reference engines follow, read from source: Neo4j'sNameAllPatternElementsruns in
AstRewriting, insideparsingPostand so before its AST cache; Memgraph fills its
anonymous identifiers at the end ofvisitSingleQuery, at parse time. Two regressions the
fix caused, both found by evidence and both fixed here: the degree-rewrite (#2232) and
labelled-hop-count (#2235) recognisers refuse a named relationship, so they silently
stopped firing and fell back to driving an inner plan per outer row — measured at 115×
the cost of the equivalent non-subquery form, with their guards now semantic
(ir.UserNamed) rather than syntactic; and the synthetic names leaked into the public
result-column name of an un-aliased projection, since that name is derived by re-rendering
the AST. The TCK cannot cover the column-name regression — it has no un-aliased
RETURN EXISTS{}/COUNT{}scenario at all, so 3897/3897 is structurally blind to it.
The simulator and the gates themselves
Twelve genuine defects were fixed in the DST harness. They change simulator fidelity,
not module behaviour, and are listed here because a harness that misreports is how a real
defect hides:
- A file handle held across a simulated crash accepted and discarded bytes; it now returns
ErrCrashedDiskwrappingfs.ErrClosed(#2544). The prescribed nil-error short write was
refuted against theio.Writercontract (#2541). - A torn trailing WAL frame was made reachable from a simulated crash (#2541); the WAL-only
layout moved under a realwaldir/waldirectory and theisRootLevelexemption was proven
a necessity of the model — forcing it false fails 16 tests (#2539);MkdirAllregisters
the components it creates (#2543); a failed fsync is permanent and a retry grants nothing,
sourced to Rebello et al., USENIX ATC 2020 and the PostgreSQL fsyncgate response (#2540); a
truncation is durable only once fsynced (#2542); andRemoveno longer silently no-ops on a
directory — the report named the wrong error, since this surface modelsos.Remove, so an
empty directory is removed and only a non-empty one returnsENOTEMPTY(#2545). - The soak layer was red for three independent reasons, not the one filed (#2620). The
filed cause was an over-eager oracle gated oncrossedBound || sweeps() > 0, so one
sweep fired a clause whose message asserts churn reached the vacuum's wake threshold —
while the failing run's high-water was 12 records against a bound of 4096. A sweep that
frees nothing is the vacuum's documented exit condition, andBacklogis by definition
versions the observed pass could not have swept, so the evidence pointed the other way. The
other two were pin inversions: #2602 and #2609 each correctly inverted their short-layer pin
and left the soak-layer sibling asserting the old behaviour. The structural cause is that
no task's acceptance gate runs the soak layer —-tags=soakappears in no acceptance
criterion in the sprint. - The
Reproduce withline omitted-scenarioentirely, so it was wrong for all 55
catalogue scenarios and ran the default workload instead (#2621). - The allocation oracle differenced two process-global readings; it now requires exact
interval containment (#2555), after measuring the old form's difference-less-injection
ranging from −135 256 to +101 320 bytes across 25 runs — a spread of 236 576 straddling
zero, with one run within 32 516 bytes of the tolerance. - The goroutine-growth slack is documented at the source (#2592); its fourth acceptance
clause could not be met, because the package's soak layer was already red, which is what
filed #2620 and #2621. - Six further harness gates stopped measuring machine load: the swarm's overlap and
pressure-density claims became structural rather than wall-clock thresholds (#2596, #2611,
#2587), aGOMAXPROCS-clamping scenario now runs alone so it stops deciding its neighbours'
regime (#2613), the cancellation-precedence table is passed in rather than raced through a
package global (#2597), and a scenario is no longer billed for its neighbours' allocations
(#2553). - Five bounded security gates stopped reporting a slow machine as a failed subject (#2567).
Run alone under-racethe two string-budget tests pass in 0.47 s and 0.48 s; under the full
race suite the ~21× slowdown pushed a half-second evaluation past the 10 s deadline its
helper imposed, and the helper scoredcontext.DeadlineExceededas proof the guard had
failed. The deadline is removed rather than widened, because it was measured to carry no
detection power. A repository-wide sweep cross-checked three ways over 277 occurrences left
roughly 148 sites alone, where the wait is genuinely unbounded and the clock is the only
possible oracle. The worst of the five had already flaked once and been "fixed" by widening
its ceiling to 120 s; measured at 17.76 s idle and 537.36 s saturated, that was a live
time bomb, not a fixed test. - The
bench/cypherRel*benchmarks failed in their own seed, becauseseedRelGraph
writes two edges between the same endpoints and needsMultigraph:true(#2068). The more
useful half:go testcompiles benchmarks but does not run them, so a benchmark broken
in its fixture gates nothing and reports nothing while still appearing in the suite. The
same class was then found ingraph/lpg's MVCC read benchmarks (#2623), where the
deltas=offarm was not a control — it ran with deltas on — so the comparison the
benchmark exists to make had never been valid, invisible in the output because a control
that is not a control still prints a number.
Performance
Full detail for this release is in
docs/benchmarks/v0.12.0.md.
What got faster
-
The
v0.11.0open plan-choice regression is closed (#2431).MATCH (n:Common:Rare)
over 100 000:Commonof which 1 000 also carry:Raretook 4.611 ms in the default
configuration against 0.186 ms for the plan the same engine already knew how to build — and
worse than the 4.280 ms legacy full-:Commonscan the re-anchor was written to replace.
The root cause was that the columnar chain's yield was inert:
tryBuildColumnarFilterChainalready declines whenpickMinLabelwould fire and states the
rule, whiletryBuildParallelScanProjectwas tried immediately after and was not making the
same argument — parallelism is also a constant factor — so it claimed every shape the yield
gave up, anchored onLabels[0]. The fix adopts the anchor rather than merely declining,
which makes the case where the smallest label is itself above the threshold faster rather
than merely unregressed. Measured interleaved A/B over 5 pairs withbenchstat:Arm sec/op B/op allocs/op P2431_Fixture−96.08 % (p=0.008) −99.28 % −98.54 % (106 293 → 1 551) P2431_MinLabelAboveThreshold−19.92 % (p=0.008) P2431_MinLabelAboveThresholdSkewed−44.16 % (p=0.008) P2431_SingleLabel~ (p=0.151/1.000/1.000) ~ ~ No shape regresses.
BenchmarkMinLabelScanSelective_MinLabelis now 204.7 µs against
_FirstLabel's 3 421 µs — 16.6× faster, the relationship its name asserts and which did not
hold before — and faster thanv0.10.0's 230.3 µs, so the regression is undone rather than
merely levelled. 106 293 allocs/op is exactly the figure the report quoted, so the
attribution is confirmed on the reported number.
(min-label-anchor-vs-parallel-scan-2026-08-26.md) -
The range-seek population floor is set from measurement (#2367): an indexed integer
equality cost 68.7 µs over a label of 1023 nodes and 5.5 µs over one of 1024 — a 12.6×
cliff at a constant, with the smaller graph the slower one. The sweep is a step at the
constant in both directions, and allocations say the same and cannot be moved by machine
load: 889 per lookup at 1023 nodes against 127 at 4096, flat at ~123 after. The shared floor
also governs the key-set path, measured rather than assumed: at 1023 nodes the seek set costs
168 allocations against 13 239 for the scan (78.8×). An earlier version of this ticket
asserted a mis-calibrated cost-based crossover; that claim was withdrawn when no such
threshold was found, and four further explanations for the surrounding anomaly were each
refuted by measurement. Not fixed, and out of scope: an integer-keyed property still
cannot use the hash path, because a CypherCREATE INDEXonly ever builds a string-keyed
hash index.
(range-seek-population-floor-2026-08-26.md) -
count(*)no longer builds a row per row to carry a constant (#2625). On a bare typed
expansion the pre-projection was 4.06 ms of an 11.30 ms query and removing it cut the query
4.52 ms → 2.71 ms (~40 %). The gain depends entirely on the child: once an endpoint label
is added, the per-rowFilterchecking the far endpoint costs ~18.2 ms of 26.1 ms and this
operator's own cost is ~2.6 ms. At scale (examples/26_social_scale_bench, 40 000 users,
interleaved A/B, five runs per arm) the median gain is modest and the variance result is
large:count_friend8.040 s → 7.504 s (+6.7 %) with spread 52 % → 4 %, andcount_like
6.331 s → 6.156 s (+2.8 %) with spread 59 % → 9 % — removing seven million per-row
allocations takes the GC out of the tail. One early pairing showed 35 %; five rounds identify
it as an outlier in the before arm and it is not claimed. The count-store fast path the
task prescribed is refuted and not built:count.Store.CountEtakes no snapshot and a
read transaction pins its view, so answering from the store would violate snapshot isolation.
(count-star-row-count-2026-08-27.md) -
Snapshot index hydration (#2490), measured with
benchstatover 50 000 nodes and 4
indexes, n=10: sec/op −69.65 %, B/op −15.88 %, at allocs/op +24.91 %. The allocation
cost is real and is tracked as #2594, and the win applies only where the WAL suffix is
empty or index-irrelevant. -
graph/query's integer-bounded numeric range is index-served (#2600). Against the path
the query actually took before the fix — a full scan, since an integer-bounded range was
never index-served — 31.8× faster selective, 44.19 ms → 1.39 ms, and 1.4 % slower at
100 % selectivity. B/op and allocs/op are identical: the residual reads properties and
allocates nothing. Measured on darwin/arm64, 10 cores, 200k nodes, ages uniform in [21,65],
-benchmem -count=6withbenchstat. -
Compactreclaims what it should (#2628): 361.9 MiB → 159.9 MiB live heap after a
bracketed 3 M-edge build, matching the unbracketed arm, while still allocating 1.79 GiB
against 3.25 GiB. See Fixed. -
The certificate-reload skip is 1.8×, not two orders of magnitude (#2558). Measured
rather than assumed, and the measurement refuted the assumption: 20.6 µs and 2.0 kB per
call against 37.8 µs and 9.4 kB for the full load (best of 5, darwin/arm64). The two file
reads dominate both paths. The skip is kept because it does not recompute and re-publish
a certificate that has not changed, and the godoc now states the real numbers.
What got slower — measured, not minimised
- The anchor-swap guard costs the declined shape a great deal, and it is not called
negligible (#2603).benchstat,-count=6, same fixture and rows: at hub degree 1601 the
declined shape costs 65× (5.644 µs → 368.8 µs) out-ward and 62× in-ward; at degree
40 000, 1705× (5.681 µs → 9.687 ms) and 1660×. Allocations go 193 → 79 566 and bytes
8.88 KiB → 627 KiB. The swapped plan is flat in the hub's degree and the written order is
linear, so the loss grows without bound on an ordinary spelling. Correctness outranks
speed, so the guard ships; #2604 is filed to recover the optimisation properly. flow.PushRelabel's discharge polling costs +3.3 % to +4.6 % on one adversarial
fan-out shape (#2593), p<0.05 over three interleaved rounds, with B/op and allocs/op
identical because the counter is threaded by value. Accepted under correct → secure → fast,
and recorded as the only measurable throughput cost in that change.- The MVCC endpoint cross-checks cost Cypher
DETACH+4–6 % and relationship creation
+3.9 % (#2444); direct-API bulk delete is unchanged. That is the attributable price of
making the node the unit of write–write conflict. - An
AddRange-built label at or below the normalisation bound moves 16 → 28 allocs/op
(#2609) — which is what anAdd-built label of the same ids already cost, so the change
makes the two paths do the same work rather than making either do more. Allocations are
identical, every sample equal, for a 100k dense label, a 100k sparse one and an
Add-built label of 8 ids. A first, sequential A/B reported a −8.57 % speedup on the
dense label; interleaving refuted it (p=0.841), so it was an artefact and is not
claimed.
Examples — not part of the module
examples/ is an exercise harness, and the module neither imports nor depends on it.
examples/26_social_scale_benchcould not run at its own advertised default (#2385).
It printed its six config lines and then nothing: at the documented default of 1 000 000
users it was killed at twelve minutes, at 150 000 at seven. The reason is memory, not
patience, which is what decides the fix: 20 000 users is 71.7 s and 1.88 GiB peak RSS,
40 000 users is 149.6 s and 3.74 GiB, so peak RSS scales 1.99× for a 2× population and a
million users projects to roughly 93 GiB against a 32 GiB machine. The default is now
50 000 users, agreed with the user because it is documented behaviour and a pinned fact, and
measured at that default rather than projected: 183.6 s and 4.81 GiB peak RSS over
16 265 613 edges. Progress telemetry goes to stderr and results to stdout, so a pipe
capturing results gets exactly the pinned facts — and it immediately produced a finding the
example could not previously show: at the default the build takes 14.2 s and the read
battery takes 153.7 s of the 183.6. This example is dominated by its queries, not its
ingest.- The social-graph build allocated 58× its resident size, and the ratio rose with scale
(#2624). The site was named by a two-scale-alloc_spacediff rather than a single
profile: query-phase sites scale 1.97–2.06× between 20k and 40k users (linear), while
main.buildis 3.11×,AddEdgeLabeledWithProperties3.16×,upsertEdgeSlotLocked3.18×
andadjlist.storeEntry3.92×. At 40 000 users total allocation went 28.02 GiB →
8.28 GiB, amplification 58× → 16.6×, and build elapsed 10.229 s → 8.044 s; across the
two scales the ratio goes from 37× → 58× rising to 16.5× → 16.6× flat. Batching the
window every 65 536 writes was measured and earned nothing (8.34 GiB against 8.28 GiB), so
the simpler single bracket is what an example should show. This is the task that uncovered
the engine defect fixed as #2628 — without that fix this change traded 3.4× allocation for
2× residency. The README's sample-output block was several sprints stale (9.12 GiB against a
real 4.14 GiB) and is regenerated from an actual run.
Security
-
CWE-59, link following, in
store/csrfile(#2580, severity 7). Files were opened with
noO_NOFOLLOWon either the write or the read path, while both sibling publish paths were
hardened against exactly this class under #1843 —store/walhaswalNoFollow,
store/snapshothasopenSnapshotComponent. The write side is the sharper half because the
temp name is fully predictable: the writer forms it asOutputPath + ".tmp", so a local
principal who can write the store directory pre-plants a symlink there aimed at any file this
process may write. Reproduced, with all three parts of the claim measured rather than
argued — with the guard disabled the publish succeeded through the symlinked temp, the
victim file went from 60 bytes to 2 244 bytes of CSR data (an arbitrary-file overwrite), and
the published path itself became a symlink, becauserename(2)moved the planted link
onto the output name, so every later write through it lands on the victim. The read path
followed a symlink too. The TOCTOU is closed separately, and it had to be: the writer
resized the temp with a path-basedos.Truncatemoments after creating it, which re-resolves
a predictable name and is a second window even withO_NOFOLLOWon the create;Truncate
moves onto the already-open descriptor, so there is no second name resolution to race. The
negatives are paired with a positive control — an ordinary publish and open still work, and
so does a publish through a symlinked parent directory, which pins the guard's scope to
the final component. -
A refused Bolt re-authentication left the connection running as the previous principal
(#2556). The non-firstAuthfailure branch was the only exit fromhandleLogonthat set
neithers.identitynors.authenticated— the assignments sit after the error return — so a
refused identity switch changed nothing, andhandleResetthen took the authenticated path
back toREADYwith full write capability. Measured end to end over a real socket:
LOGON(alice, ok),LOGON(bob, WRONG)→ FAILURE,RUN→ IGNORED,RESET→ SUCCESS,
CREATE (:Ghost)→ SUCCESS, nodes-created 1. The identity is security-relevant even
without roles: it keys the per-principal transaction quota and theSHOW TRANSACTIONS
attribution, so a refused switch left both pointing at the wrong principal. The contract comes
from the specification: the BoltLOGONsection states that a failed authentication makes
the server respond FAILURE and close the connection, and carves out no exception for
re-authentication. It makes a failed guess cost the connection, which is the point — the
officialneo4j-go-driveralways emitsLOGOFFbeforeLOGON, so the branch where
authenticatedis true on entry was reachable only by a non-conforming client, exactly an
attacker's shape, because skipping theLOGOFFwas how a failed credential guess cost
nothing. Why nothing caught it: every existing test and DST arm sendsLOGOFFfirst,
which pre-clears the flag. The new arm omits it and attempts the write after aRESET,
becauseRESETis where the recovery happened — an arm that only checked the reply to the
LOGONwould have passed against the defect. -
A certificate rotation performed to revoke a certificate could be ignored in silence
(#2558).CertReloader.Reloadshort-circuited on "neither file mtime is after the last
successful load" — a cheap heuristic for "nothing changed" and an unsound one, because mtime
is not a content hash. Every rotation that replaces content without advancing the timestamp —
a rename from another directory,cp -p, a restore from an archive, or two rotations inside
one filesystem timestamp tick — returned nil having loaded nothing, and because the call
reported successonErrornever fired, so an operator polling throughWatchhad no signal at
all.Reloadnow reads both files on every call and compares a SHA-256 digest of each
against the digests the live certificate was built from, re-parsing only when they differ and
recording the digests only on a load that succeeded — so a skip is provably a no-op and a
refused pair is always re-examined.os.Statis gone, which changes the absent-file error
fromstat key: …toread key: open …: no such file or directory. -
An expired certificate was swapped over a working one (#2557).
Reloadvalidated only
the pairing, viatls.LoadX509KeyPair, which inspects neitherNotBeforenorNotAfter.
A rotation to an expired leaf — the commonest real rotation incident, a renewal that produced
expired material or a clock skew on the renewing host — returned nil, swapped the doomed leaf
into service, and destroyed the previous working certificate: every subsequent handshake
failed. The godoc promised the opposite. Three decisions are documented onReloaditself: a
not-yet-valid leaf is refused too, and because a refusal does not stamp the bookkeeping the
Watchpoller picks the pair up on its own onceNotBeforepasses; only the leaf is
checked, never the chain, because an expired issuer kept in a bundle is a real working pattern
(Let's Encrypt DST Root CA X3) and the one surveyed implementation validating the whole chain
documents two incidents its strictness caused; and the check gates the swap, not
construction, since at construction there is no working certificate to protect and refusing
there would make a host whose clock has not yet synchronised unable to start at all. -
A fail-open Bolt access mode granted write authority to a client that asked for
read-only (#2564).handleBeginread the mode asif modeStr == "r", so"R","read"or
a non-string all fell through to the write default and the client's writes succeeded. That is
a fail-open coercion on a field this server treats as a capability restriction, and the
project's fail-stop-never-fail-silent rule forbids resolving an unknown token in the more
privileged direction. The contract was decided from evidence, and the evidence cuts against
simply copying the protocol: the Bolt specification is silent on invalid mode values and
framesmodeas a routing hint rather than as authorisation, so in Neo4j's cluster model a
read-only transaction fails on a follower because the follower is read-only, not because the
server validated the token. GoGraph gives the field the stronger meaning, so the contract is
its own to choose. Compatibility risk is low by construction: the official drivers send
exactly"r"or"w", so a refusal can only reach a client that was previously being granted
write authority it did not ask for. -
An internal bind path would have been forwarded to the client (#2570). A
ClientError
code bypasses the failure sanitiser, and the wrapped depth error accumulated one path
segment per level: 405 bytes for a 33-deep map chain, 396 of themmap["k"]:framing.
BindParamsdiscards the path for this one sentinel — the path is the depth, so it merely
restates the limit 33 times — and reports the key and the limit alone, at 108 bytes. -
The
csrfilecrash-durability claim is scoped to where the barrier exists (#2582). An
unmeasured reassurance is worse than silence, because a reader takes it for a guarantee. See
Changed. -
The mandated CVE scan had stopped running, and was reporting success. A
govulncheckbinary built against an older Go minor than the one onPATHprints
"Loading packages failed, possibly due to a mismatch between the Go version used to
build govulncheck and the Go version on PATH" and then exits 0. So after the
toolchain moved,govulncheck ./...— step 4 of the dependency policy in
CONTRIBUTING.md — performed no analysis at all
while returning success: a gate that cannot fail, of exactly the kind this project
treats as a defect rather than an inconvenience. Rebuilt againstgo1.27.0it fails
honestly instead (exit 1), becausegovulncheck@v1.3.0's own source-processing
packages are built for go1.26 and cannot parse go1.27 source. The scan is therefore run
asgovulncheck -scan=module, which readsgo.modwithout loading source and
reportsNo vulnerabilities found.across every pinned version. The policy now
documents both traps and requires the run to print a finding or that literal string —
empty output is a failed scan, never a clean one. Symbol-level reachability
scanning is unavailable until an upstream release built for the pinned Go minor
exists, and that limitation is stated rather than left implied.
Compliance
- 100 % openCypher TCK-compliant at the execution level, preserved.
const tckExecutionBaseline = 3897incypher/tck/runner_test.gois unchanged, no
.featurefile changed in this window, and the sprint-close gate reports 3897 scenarios,
3897 passed, 0 failed, 0 undefined, 0 inconclusive, with the error-type fidelity ratchet at
122/695. Three changes in this release were checked against the TCK before shipping and each
is recorded as unable to move the count: subquery expressions are not covered at all (#2615),
the comma-pattern relationship-uniqueness form does not appear (#2252), andgraph/queryis
absent fromgo list -deps -test ./cypher/tck/...(#2600, #2601). - 100 % ACID-compliant, and strengthened. Four Consistency, Isolation and Atomicity
defects were fixed this cycle — a validator-refused value made durable and resurrected by
recovery (#2602), phantom nodes from uncommitted create+delete pairs (#2443), a
DETACH DELETEthat destroyed state a rollback could not restore (#2444), and four rollback
defects including a torn arc leaked by the undo replay (#2445). - Extreme / massive concurrent ready — no new certification. The most recent production
certification isdocs/certification-2026-08-13.md, taken
at thev0.11.0release commit. This release is 192 commits past it and carries no
certification of its own. The envelope that certification states — latency percentiles at
the published concurrency levels unmeasured, the whole-tree soak layer unrun, no container ever
run, a single host and architecture — is unchanged, andsoak-artefacts/is still from
2026-05-30 at commitb5453b9. See
release-notes/v0.12.0.md. - Ultra efficient by design — held, with costs stated.
Compactreclaims 2.26× the
adjacency it used to (#2628); an indexed integer equality below 1024 nodes stops allocating
889 per lookup (#2367); acount(*)over seven million rows stops allocating a row per row
(#2625). Against that: the anchor-swap guard's declined shape,PushRelabel's poll, and the
MVCC cross-checks, all quantified above.
Notes
- Pre-1.0 stability. This is a
0.y.zrelease. The public Go API may change without a
major-version bump until1.0.0; pin the exact version you depend on. 1.0.0gates: 3 of 4 met, unchanged. Execution-level TCK is at 100 % against a ≥ 95 %
requirement, everyT-divergence is resolved, and the local gate is green. Gate 4 — a soak
report insoak-artefacts/reflecting a run against the release commit — remains open for a
fifth consecutive cycle. Theinternal/simsoak layer was brought green in this window and
verified twice consecutively (#2620); the whole-tree soak was not run.
What was delivered — from the project roadmap
7 sprint(s) closed in this release window (345, 346, 347, 348, 349, 350, 351), containing 187 tracked tasks. Every task in a closed sprint is COMPLETED: the roadmap tool refuses to close a sprint that still holds active work.
- Sprint 345 — the DST simulator exercises MVCC and true concurrent production scenarios
- Sprint 346 — the DST exercises the full Cypher language surface
- Sprint 347 — the DST exercises storage, durability and MVCC substrate to full extent
- Sprint 348 — the DST exercises the Bolt wire surface to full extent
- Sprint 349 — the DST exercises the graph API, search and bulk-load surface to full extent
- Sprint 350 — resolve every known bug, re-validating each premise at HEAD first
- … and 1 further sprints in the same window
Source: the project roadmap (rmp), not the commit log. Sprints are attributed to a release when their close date falls after the previous tag and on or before this one. Task counts are the sprints' own membership counts, read back from the tool.
Further reading
- Narrative release notes:
release-notes/v0.12.0.md - Measured benchmark report:
docs/benchmarks/v0.12.0.md - Changelog file at this tag:
CHANGELOG.md
Reproducibility: built from commit f97bbfe with Go 1.26.
Commits
The per-commit list is omitted here: this entry plus the list exceeds GitHub's release-body limit. Browse it at the tag: https://github.com/FlavioCFOliveira/GoGraph/commits/v0.12.0