Repository navigation
Releases
v0.17.2
GoGraph v0.17.2 — see CHANGELOG.md for the per-release entry.
0.17.2 — 2026-10-13
10 commits — 8 non-merge commits and 2 merges — counted at 700eaed6, the tip of
release/0.17.2: the back-merge of v0.17.1 into develop (2b96446d); sprint 369, Bounded
rollback latency and memory for cut write statements (5 commits, merged at 18ec0dc7); the
release-preparation commit (b87ec901); and two stabilisation commits on the release branch, a
test-only fix to a gauge sampler (3b03915a, rmp #3126) and the constraint fix below
(700eaed6). 17 files changed, 1 262 insertions, 85 deletions
(git diff --shortstat v0.17.1..700eaed6).
This is a PATCH release: no exported API change, no WAL or on-disk format change. The only
non-test Go files changed are four files of package graph/lpg (lpg.go,
mvcc_abort_reclaim.go, mvcc_abort_release.go, mvcc_abort_sides.go) and four of package
cypher (api.go, exectx.go, undo_record.go, exec/constraints.go). Every declaration they
add or change is unexported: in graph/lpg, abortWithdrawsCreation, tombstoneAbortedBatch,
idBatchBitmap, countSideStores, and the lifeWithdrawal field sizeHint with its methods
tombstone and revive; in cypher, the Engine test seams beginTxGatesHookForTest and
delPropProbedHookForTest, the releasesOwed fields of Result and ExplicitTx,
Result.applyOwedReleases, and the signature of recordDelNodeProperty. No file under store/
changed. go.mod and go.sum are unchanged from v0.17.1.
Fixed
- A write statement cut by its context deadline no longer takes many times its deadline to
roll back, nor multiplies its peak heap while doing so (rmp #3122, #3123,b1e9e21f).
Present in v0.17.1 and earlier. The rollback deleted every node the statement had created
through the ordinary delete path, which copied the whole copy-on-write tombstone bitmap once per
node and recorded bookkeeping the abort then withdrew: the rollback was quadratic. A
MATCH (a), (b), (c) CREATE ()over 600 nodes cut at 1 s returned 8.6 to 9.2 s after its deadline
and peaked at about 1.7 GB of heap; cut at 5 s it returned 64 s late and peaked at 7.2 GB. The
abort now withdraws the nodes the statement created and flips their tombstones in batches, with
the same end state. The same statement now returns about 1.4 s after a 1 s deadline with a peak
of about 0.75 GB, and about 7.1 s after a 5 s deadline with a peak of about 3 GB. The rollback is
now linear, at about 1 µs per created node. The deadline itself was always noticed within
microseconds. - A
REMOVEracing a peer's rolled-back removal can no longer commit a node without a
property itsNOT NULLconstraint requires (rmp #3127,700eaed6). Present in v0.17.1 and
earlier. The removal decided whether to check the node at commit from a probe of the present
state; when that probe read a peer's uncommitted removal that the peer then rolled back, the
removal still deleted the committed value but skipped the commit-time check. The check now
follows the removal's effect. A second, narrower window is closed in the same commit: an
explicit transaction whoseBeginTxstraddled a constraint registration between its catalogue
reads skipped both the commit-timeNOT NULLcheck and the straddler validation;BeginTxnow
reads the constraint generation first. - Readers can no longer briefly see two holders of a
UNIQUEvalue (rmp #3128,
700eaed6). Present in v0.17.1 and earlier. A transaction that freed aUNIQUEvalue
released it at its commit decision, before it was visible, so a peer could take the value and
publish first; a snapshot between the two publications held the value twice. The release is now
applied after publication, on the autocommit and the explicit-transaction path. Behaviour
change: a concurrent write of a just-freedUNIQUEvalue, inside the window between the
freeing transaction's commit decision and its visibility, is now refused with a constraint
violation; a retry succeeds.
The concurrency test that exposes both defects,TestConstraintDDLvsDML_NoViolationAdmitted,
failed 9 runs in 200 on v0.17.1 and 3 in 100 at3b03915a; after the fix it passed 1 000 of
1 000 runs, and 200 of 200 under-race.
Performance
- The abort path allocates about a third less per created node (rmp #3125,
2885d7d3):
pre-sized withdrawal lists, one bitmapOr/AndNotper batch of at least 1024 ids instead of
per-id updates, and no duplicate unborn-mark pass. Rolling back a 5 s cutCREATEallocates
197 B per id instead of 307. A largeDELETErollback was measured linear and is unchanged
(rmp #3124).
Further reading
- Narrative release notes:
release-notes/v0.17.2.md - Measured benchmark report:
docs/benchmarks/v0.17.2.md - Changelog file at this tag:
CHANGELOG.md - Commits in this release: https://github.com/FlavioCFOliveira/GoGraph/commits/v0.17.2
Reproducibility: built from commit 3d5fd8c with Go 1.26.
v0.17.1
GoGraph v0.17.1 — see CHANGELOG.md for the per-release entry.
0.17.1 — 2026-10-13
4 commits — 2 non-merge commits and 2 merges — counted at 4743de3d, the develop tip
before release preparation: the back-merge of v0.17.0 into develop (81ed1eae) and sprint
368, Rollback memory reclamation in graph.Mapper (2 commits, merged at 4743de3d). 18 files
changed, 1 870 insertions, 40 deletions (git diff --shortstat v0.17.0..4743de3d).
This is a PATCH release: no exported API change, no WAL or on-disk format change. Measured
by parsing every non-test Go file outside internal/, examples/, cmd/ and any gen/, tck/
or testdata/ directory at both v0.17.0 and 4743de3d, and diffing the rendered declarations
— a superset of the API surface docs/semver.md defines: the two renderings
are identical. The only new declarations are in internal/synthkey, which the module does not
export. No file under store/ changed. go.mod and go.sum are unchanged from v0.17.0.
Fixed
- A rolled-back write statement no longer leaves its node keys interned in
graph.Mapper
(rmp #3119, #3120,78039599). Present in v0.15.0, v0.16.0 and v0.17.0; fixed in
v0.17.1, so embedders pinned to an earlier version should move their pin. When a
transaction that created nodes aborted as a whole — an autocommit statement cut by its context
deadline, a failed or refused statement, or an explicit transaction rolled back — the anonymous
keys it minted forCREATE ()stayed in the Mapper, together with the transaction's write set,
adjacency-version entries and unborn marks. A cutMATCH (a), (b), (c) CREATE ()over 600
nodes left the live heap at 1847.5 MB afterruntime.GC(), against 2.3 MB before the
statement. The abort now releases every never-born node created under a minted key: the key no
longer resolves, its id becomes a hole, and the Mapper gives the memory back; the same scenario
now leaves 2.5 MB. Committed ids andelementId()are unchanged, ids are never issued twice,
and a statement that fails inside a transaction that later commits keeps its bindings, as
before.
(*graph.Mapper).MaxNodeIDno longer counts holes trimmed from the end of a shard. - New keys interned after a large rollback no longer re-create the released id range in memory
(rmp #3121,54a2e218). Ids are never reissued, so the first keys after a rollback land past
the released range; that range was materialised again as holes, about 16 B per released id
(1000 creates after the cut above: 124.3 MB live). A shard now keeps such a run of holes at its
end in no memory, with the new keys held in small pages allocated on demand; the same 1000
creates leave 2.9 MB. Holes in the middle of a shard — nodes committed in the same shard while a
large statement was rolled back — still cost about 16 B per id.
Performance
Figures are development-time measurements recorded in the rmp task log of sprint 368 (#3119 to
#3121) on this code. They are not additive.
- The dense Mapper paths did not regress:
Resolve+0.9 % (p=0.089, not significant),
Walk−3.5 % (benchstat, 10 interleaved rounds;54a2e218). - Cost recorded: releasing the keys of an aborted transaction adds about 1.5 s to its
rollback per about 1.6 M released ids.
Further reading
- Narrative release notes:
release-notes/v0.17.1.md - Measured benchmark report:
docs/benchmarks/v0.17.1.md - Changelog file at this tag:
CHANGELOG.md - Commits in this release: https://github.com/FlavioCFOliveira/GoGraph/commits/v0.17.1
Reproducibility: built from commit 7e71665 with Go 1.26.
v0.17.0
GoGraph v0.17.0 — see CHANGELOG.md for the per-release entry.
[0.17.0] — 2026-10-13
53 commits — 50 non-merge commits and 3 merges — counted at e91d42b2, the develop tip
before release preparation. Two sprints delivered this window: 366, Drop undirected
relations and simple graphs (6 commits, merged at c794a1ed), and 367, Performance and
Efficiency Laboratory 261009 (41 commits, merged at 4d1947e8); 3 documentation commits follow
the last merge. 1 560 files changed, 19 571 insertions, 9 450 deletions
(git diff --shortstat v0.16.0..e91d42b2).
This release breaks the exported API and changes the on-disk format. Undirected graphs and
simple (non-multigraph) graphs are no longer supported: every graph is a directed multigraph, as
in Neo4j and Memgraph (rmp #3071 to #3074). Measured by parsing every non-test Go file outside
internal/, examples/, cmd/ and any gen/, tck/ or testdata/ directory at both
v0.16.0 and e91d42b2, and diffing the rendered declarations — a superset of the API
surface docs/semver.md defines: 5 152 exported declarations before, 5 162
after. 14 were removed, 1 changed signature and 24 were added; one of the 24,
cypher/exec.(*ColumnarFilter).Close, redeclares a method the type already had through
embedding. Under
docs/semver.md a 0.y.z release absorbs breaking changes in the MINOR digit.
go.mod and go.sum are unchanged from v0.16.0.
Changed — BREAKING: directed multigraph only
- Every graph is a directed multigraph.
graph/adjlist.AdjListstores each edge as one
directed relationship, and everyAddEdgeappends a new edge with its own handle, so parallel
edges and self-loops are always kept. The zero valueadjlist.Config{}, which built an
undirected simple graph, now builds a directed multigraph. A repeatedAddEdgebetween the same
ordered pair now adds a parallel edge (fec95062). - Removed exported API:
graph/adjlist:Config.Directed,Config.Multigraph,(*AdjList).Directed()and
(*AdjList).Multigraph()(fec95062).graph/io/csv:Options.DirectedandOptions.Multigraph;DefaultOptionsno longer sets
Directed(fec95062).store/bulk:Options.DirectedandOptions.Multigraph(fec95062).store/bulkimport:Options.DirectedandOptions.Multigraph(fec95062).store/csrfile:FixtureSpec.Multigraph(fec95062).store/snapshot:GraphConfig.DirectedandGraphConfig.Multigraph(fec95062).cypher:ErrParallelEdgeInSimpleGraph; everyCREATEadds a relationship (bed4de53).cmd/gograph-import: the-undirectedand-simpleflags (fec95062).
- Re-signed:
graph/adjlist.Writer.AppendEdgereturnserrorinstead of(bool, error); the
boolean reported a simple graph's skipped duplicate (fec95062). - Interchange formats (
fec95062). The GraphML readers (ReadInto,ReadWithPropsand their
variants) read each<edge>as one directed relationship fromsourcetotarget, whatever
the<graph>edgedefaultattribute declares,"undirected"included. The GraphML writers
always emitedgedefault="directed", and the DOT writer always emits adigraph. - Upgrading code:
- Delete
DirectedandMultigraphfrom everyadjlist.Config,csv.Options,
bulk.Options,bulkimport.Optionsandcsrfile.FixtureSpecliteral, and-undirectedand
-simplefrom everygograph-importinvocation. - For undirected analytics, build the CSR and read its symmetric projection,
c.BuildSymmetric()(see Added). - For undirected Cypher semantics, write undirected patterns:
MATCH (a)-[r]-(b)matches a
relationship in either direction, andMERGE (a)-[r:T]-(b)matches either direction and,
when none exists, creates one fromatob.CREATErefuses an undirected relationship. - Code that relied on a simple graph to de-duplicate a repeated
AddEdgemust not repeat the
insertion.
- Delete
Changed — BREAKING: on-disk format
store/snapshot.ManifestVersionis 5 (was 4) and is stamped on every manifest this build
writes, including the CSR-only and mapper-less ones previously stamped 1 and 2. A version-5
graph_configcarries neither"directed"nor"multigraph". Versions 1 to 4 still load.
v0.16.0and earlier builds refuse a version-5 manifest withsnapshot.ErrManifestUnsupported
(fec95062).- Legacy stores are migrated by the first clean recovery (
fec95062). For a store whose
snapshot manifest declares"directed": falseor"multigraph": false, recovery replays the
WAL above the snapshot with the old engine's semantics, then:- Undirected: folds each edge to one relationship oriented from the lower node id to the
higher; a self-loop is kept once. When the two directions of an edge hold different values
for the same property key, or different relationship types, recovery returns an error
wrappingrecovery.ErrLegacyMirrorConflictthat names the conflicting edges, and the
directory is left unchanged. A value held in one direction only is kept. - Simple: skips each duplicate insertion the old engine skipped, keeping its edge handle
reserved. - Before
Openreturns, it publishes a current-format snapshot and folds the WAL under it. This
needs write access to the directory, the operating-system filesystem
(recovery.ErrLegacyMigrationUnsupportedFSotherwise) and a non-nilOptions.Codec
(recovery.ErrLegacyMigrationNeedsCodecotherwise). An unclean recovery does not rewrite the
directory. A crash during the rewrite leaves either the legacy store, which the next open
migrates again, or the migrated one. - A migrated store cannot be opened by
v0.16.0or any earlier build. Take a copy of the store
directory before the first open if a downgrade must remain possible.
- Undirected: folds each edge to one relationship oriented from the lower node id to the
- The legacy shape is read from the snapshot manifest only. A store directory with no
snapshot/manifest.jsonrecords no graph shape, so recovery replays its WAL with
directed-multigraph semantics and migrates nothing (store/recovery/recovery.go,
legacyReplayOf). - A directed multigraph store written by
v0.16.0opens without migration. Its version-4
manifest still loads; the first checkpoint this build takes writes a version-5 manifest, after
whichv0.16.0refuses the directory. The WAL format is unchanged in this release.
Added
graph/csr.(*CSR).BuildSymmetricreturns the undirected projection of a CSR: each non-loop
arc yields itself and its mirror with the same weight and handle, a self-loop is kept once, and
parallel arcs and reciprocal pairs are never merged (11b2ddd1).store/snapshot:LegacyShape(withAny),LoadedSnapshot.Legacy,
GraphConfig.UnmarshalJSON,VerifySnapshotReadable,SelfSufficientand
ErrPublishedLegacyShape(fec95062).store/recovery:ErrLegacyMirrorConflict,ErrLegacyMigrationUnsupportedFSand
ErrLegacyMigrationNeedsCodec(fec95062).graph/csr.BuildSymmetricFromAdjListbuilds the symmetric projection directly from an
adjacency list, without the intermediate directed CSR (rmp #3078,e7b12a60).graph/csr.(*CSR).KnownSymmetricreports in O(1) whether a CSR was returned by
BuildSymmetricorBuildSymmetricFromAdjList(rmp #3078,cfd64290).search/centrality.PageRankBudget,NewPageRankBudget,(*PageRankBudget).Capacity,
(*PageRankBudget).InUseand the fieldPageRankOptions.Budget: a caller-owned, lock-free
pool of PageRank worker slots shared by concurrent calls. A run takes the slots it can without
blocking and runs on a single worker when fewer than two are free; a nilBudgetkeeps the
previous behaviour, and ranks are bit-identical across budgets (rmp #3086,fc0f6807).graph.(*Mapper).AppendIDs,graph.MapperCursorand(MapperCursor).Done: append node
ids inWalkorder in bounded batches from a cursor, holding each shard's read lock only while
its ids are copied (rmp #3081,1a8e25ae).
Changed
cypher/exec.(*ColumnarFilter).Closeis now declared onColumnarFilteritself; before,
the method was promoted from the embeddedFilter. It returns the operator's scratch batch to
the node-scan chunk pool, then closes the child. The method set is unchanged
(rmp #3114,3e2f9a2d).graph/csr.(*CSR).BuildReversereturns the receiver itself whenKnownSymmetricreports
true, instead of a copy; both are immutable (rmp #3078,cfd64290).search.DiameterCtxcomputes the exact diameter with BoundingDiameters (Takes and Kosters,
CIKM 2011) instead of an iFUB level walk, running sweeps in batches ofGOMAXPROCSabove 1 024
vertices. The result, the seed rule and the disconnected-graph and cancellation contracts are
unchanged. Vertex-transitive shapes need one sweep per vertex: a 600-vertex cycle is 203 %
slower; the O(n(n+m)) worst case is unchanged (rmp #3085,6b399a3b).
Fixed
graph/lpg: an edge-property append never rewrites a cell a pinned reader can see. After a
WithdrawTxrollback restored a pre-image, a later append to a block with more than two
columns or a bool column could overwrite a cell a lock-free reader of the aborted entry was
still reading: a data race and a changed value. Appends now write in place only at the column's
own high-water mark (rmp #3093,ce609ce6).cypher: a Go API edge is no longer reported with a Cypher sibling's relationship type.
The stable edge handle on a row was read as a CSR slot position, so after deletes on a node pair
holding both Cypher-created and Go API edges, a Go API edge could take the type of a sibling
(rmp #3088,4e9c48bb).
Performance
Figures are development-time measurements recorded by the commit that shipped each change,
against that commit's own control. They are not a...
v0.16.0
GoGraph v0.16.0 — see CHANGELOG.md for the per-release entry.
[0.16.0] — 2026-10-09
167 commits — 164 non-merge commits and 3 sprint merges — counted at 60c90d73, the
develop tip before release preparation. Three sprints delivered this window: 363,
Performance and Efficiency Laboratory 260924 (24 commits, merged at f984222f); 364,
Correctness and Security Laboratory 260928 (76 commits, merged at c63fd1fd); and 365,
Observe, Correct and Optimize MVCC, Store and LPG Exercise (64 commits, merged at 60c90d73).
1 085 files changed, 152 515 insertions, 14 958 deletions (git diff --shortstat v0.15.0..60c90d73).
This release breaks the exported API and changes the on-disk format. Measured by parsing every
non-test Go file outside internal/, examples/, cmd/ and any gen/, tck/ or testdata/
directory at both v0.15.0 and 60c90d73, and diffing the rendered declarations — a superset
of the API surface docs/semver.md defines: 4 822 exported declarations before,
5 160 after. 74 declarations changed signature, 4 were removed and 342 were added. Of the 74,
two are parameter renames only (wal.Open, wal.OpenFS) and one widens a parameter to an
interface the old type satisfies (recovery.ReplayWAL), so 71 re-signings and 4 removals are
source-incompatible. Under docs/semver.md a 0.y.z release absorbs breaking
changes in the MINOR digit.
Security
- Toolchain pinned to
go1.27.2(go.modtoolchaindirective, wasgo1.27.0). The
vulnerability database of 2026-10-08 lists 13 standard-library vulnerabilities ingo1.27.1,
9 reachable from GoGraph (net/http,crypto/tls,net/textproto,mime/multipart), from
GO-2026-6599 to GO-2026-6617. Withgo1.27.2,make vulncheck(govulncheck v1.8.0, source mode,
symbol level, 140 packages) reports none.
Changed — BREAKING: WAL v2 on-disk format
- The write-ahead log is segmented, position-chained and bound to its store (rmp #3020,
#2195, #3014). The design is docs/design-wal-v2.md and the format is
specified in store/wal/FORMAT.md. A store's log is now a 64-byte control
filewal.controlcarrying a random, per-store id, and numbered 16 MiB segments underwal.d/.
Each frame has a 36-byte header (frame version2) carrying its logical position, the length
back to its predecessor (the prev-link) and the store id. Recovery streams the segments and
refuses, each with its own error, a frame from another store (wal.ErrForeignStore), a frame
at the wrong position (wal.ErrFramePosition), a broken prev-link (wal.ErrPrevLink), a
segment gap (wal.ErrSegmentGap), a torn non-tail segment (wal.ErrTornSegment), and a
snapshot too old for or ahead of the retained log (recovery.ErrSnapshotTooOld,
recovery.ErrSnapshotAheadOfWAL). A checkpoint reclaims space by unlinking whole segments
below the oldest retained position, without the commit lock; it no longer rewrites the log's
suffix. - One-way migration on the first writable, clean open. A store written by an earlier release
keeps its single-filewal; the first writable clean open seals it with aLegacySealcontrol
record, writes the control file and continues in segments. The sealed legacy log stays readable.
A migrated store cannot be opened byv0.15.0or any earlier build: the seal is a version-2
frame, so an older reader refuses the directory instead of ignoring the segments. A torn legacy
log followed by segment frames is refused withwal.ErrLegacyNotSealed. Take a copy of the
store directory before the first open withv0.16.0if a downgrade must remain possible. - Node ids survive a reopen exactly (rmp #3021). Every commit marker carries an annex naming
each node id the transaction created, and recovery places those ids before it applies the
operations, so a rolled-backCREATEor the commit order no longer moves a node'sid()or
elementId()across a reopen. An operation naming an unbound key, or an id held by another key,
refuses the store (recovery.ErrUnboundNodeKey,graph.ErrNodeIDMismatch,
recovery.ErrCommitAnnexCorrupt,recovery.ErrIDRecordCorrupt). - Node ids are reserved ahead of use (rmp #3021). A node id is issued only below a per-shard
limit logged in aReserveIDscontrol record (no fsync); batches double from 64 to 65 536
(txn.DefaultIDBatch,txn.MaxIDBatch). Checkpoints store the per-shard high-water marks in a
new snapshot component,nodeids.bin, and the snapshot manifest moves to version 4 with
StoreID,WALFormatandWALRedoPosfields.v0.15.0reads manifests up to version 3 and
refuses version 4. Recovery refuses an id beyond its reservation
(recovery.ErrIDBeyondReservation). - Ids assigned but never born are holes, not errors. A capture no longer refuses an instant
with an open transaction, and never writes such an id as a tombstone. This fixes a node interned
at the capture instant and created afterwards throughAddEdgealone, which recovery brought
back dead. - A truncated WAL without its snapshot is refused (rmp #2990, #2991, #3002). Before the first
prefix truncation the WAL writes a durablewal.prefix-truncatedmarker, kept current by every
checkpoint whose snapshot is self-sufficient and written by the first clean recovery that finds
it missing. A marked directory without a snapshot manifest is refused with
recovery.ErrMissingSnapshot; before, it recovered as clean and silently dropped every commit
the checkpoint had folded. - Anonymous Cypher node keys come from a per-graph sequence (
lpg.Graph.KeySequence) instead
of a process-wide counter, so identical runs write identical logs.
Changed — BREAKING: exported API
- Removed:
store/wal.(*Writer).TruncatePrefixandstore/wal.ErrPrefixTruncateUnsupported
(segments are unlinked instead);store/recovery.Result.WALTailOffset(replaced byWALEnd,
WALLastFramePos,WALTailSegmentandWALTailSegmentOffset); and
store/snapshot.ErrCaptureNotQuiesced(a capture is no longer refused at an instant with an
open transaction). - Re-signed, in addition to the lists under rmp #2748 and #2947 below:
graph/lpg.Graph:RemoveNode,RemoveNodeLabel,DelNodeProperty,Reviveand
RestoreTombstonesnow returnerror.store/snapshot.ApplyTombstonesToGraphnow returnserror.graph.(*Mapper).LoadFromtakes a second argument, the per-shard high-water marks
(*[graph.MapperShards]uint64).cypher/ast.ExistsSubquery.Queryandcypher/ast.CountSubquery.Queryare nowast.Query
(a possibly multi-branch query) instead of*ast.SingleQuery, and
cypher/ir.TranslateSubquerytakes anast.Query.cypher/exec.MergeActionEvalKeytakes the action's ordinal as its first argument.
- Widened, source-compatible:
store/recovery.ReplayWALtakes awal.FrameSource, which
*wal.Readersatisfies.
Changed — BREAKING: behaviour
- One token limit on every layer: 65535 bytes (rmp #2748). A node label, a
relationship type or a property key longer thanlpg.MaxTokenLen(65535) bytes is
refused with an error wrappinglpg.ErrTokenTooLong, before any state change, by the
in-memory engine (graph/lpg), the WAL-backed store (store/txn, whose
ErrTokenTooLongis the same value), bulk import (store/bulkimport, before it
publishes) and both Cypher engines. Before, only the WAL refused such a name, so the same
graph was durable or not depending on how it was built.lpg.CheckTokenvalidates a name
before any write. A snapshot carrying an over-long token, which only a pre-bound bulk
import could write, now fails to open with that error instead of loading silently. - Signatures changed to carry the refusal:
graph/lpg.Graph:SetEdgeLabel,RemoveEdgeLabel,DelEdgeProperty,
SetEdgeLabelAt,SetEdgeLabelByHandle,DelEdgePropertyByHandle,
SetEdgeLabelByHandleID,SetEdgePropertyByHandleIDandDelEdgePropertyByHandleID
now returnerror;SetEdgeRelTypeAtSlotByIDandAddEdgeRelTypeOverflowByIDnow
return(bool, error).graph/lpg.WriteView:RemoveNodeLabel,DelNodeProperty,SetEdgeLabel,
RemoveEdgeLabel,DelEdgeProperty,SetEdgeLabelAt,SetEdgeLabelByHandleand
DelEdgePropertyByHandlenow returnerror.graph/lpg.LabelRegistry.Internandgraph/lpg.PropertyKeyRegistry.Internnow return
(id, error).graph/lpg/schema.Schema.RegisterLabelnow returns(lpg.LabelID, error).store/snapshot.ApplyEdgeHandlesToGraphnow returnserror.cypher/exec.GraphMutator:RemoveNodeLabel,DelNodeProperty,SetEdgeLabel,
DelEdgeProperty,SetEdgeLabelAt,SetEdgeLabelByHandleand
DelEdgePropertyByHandlenow returnerror.
- A direct write is a single-operation transaction (rmp #2947). A mutator of
graph/lpg.Graphcalled outside any transaction — and aGraph.Writerview over the
zeroWriteTx— now runs as an implicit transaction of its own: it conflict-tests and
claims exactly as a statement does, commits at one instant, and on a refusal aborts
and changes nothing. While another transaction holds an uncommitted write on what it
would change — a node's properties, labels or existence, an edge's adjacency entry,
or an edge's per-handle, per-ordinal or overflow records — it returns an error
wrapping the newlpg.ErrDirectWriteConflictand a*mvcc.Conflict. The refusal is
retryable. A refusal caused by another direct write is retried internally for up to
1 s first; one caused by an explicit transaction is returned at once. Before,
such a write returnedniland was lost when that transaction rolled back, and a
direct edge or node removal could be left half applied. Separately,Graph.ApplyVersionedand
`Graph...
v0.15.0
GoGraph v0.15.0 — see CHANGELOG.md for the per-release entry.
[0.15.0] — 2026-09-17
33 commits — 21 perf, 8 docs, 1 fix, 1 build and 2 merges. Counted at 19138042,
the develop tip before release preparation; the release-preparation commits that carry this
entry are necessarily not in their own count. One sprint delivered this window — 362,
performance laboratory 20260915 — and all 33 of its tasks closed. 468 files changed,
238 586 insertions, 963 deletions (git diff --shortstat v0.14.2..19138042).
Sprint 361 is not in this window. Its twelve tasks closed at a7f06ec3, df0b1866 and
02c08e44, and all three are ancestors of v0.14.2 — verified with
git merge-base --is-ancestor. It shipped in v0.14.2 and is recorded in that entry.
238 586 insertions is not 238 586 lines of engine, and the difference is larger here than in
any previous release. Bucketed by path from git diff --numstat v0.14.2..19138042:
| bucket | files | insertions | deletions |
|---|---|---|---|
docs/benchmarks/ (raw measurement artefacts) |
373 | 217 756 | 0 |
*_test.go + testdata/ |
33 | 7 648 | 21 |
examples/ (exercise harnesses) |
13 | 4 739 | 440 |
docs/ (prose) |
10 | 3 605 | 38 |
engine *.go (non-test, shipped) |
24 | 2 270 | 381 |
scripts/ (laboratory drivers) |
9 | 2 150 | 0 |
build/CI (Makefile, .github/) |
2 | 219 | 30 |
internal/ (not shipped API) |
1 | 154 | 43 |
| root Markdown + misc | 3 | 45 | 10 |
| total | 468 | 238 586 | 963 |
91.3 % of the insertions are measurement data. The raw benchstat series, profiles and host
telemetry under docs/benchmarks/ are reproduction input for the three campaign records, not
code and not documentation; goreleaser excludes them from the release tarballs by design.
The exported API gained exactly two identifiers and lost none. Measured by parsing every
non-test Go file outside internal/, examples/, cmd/ and any gen/ or tck/ directory at
both v0.14.2 and 19138042, and diffing the rendered declarations: 4 820 exported
declarations before, 4 822 after, with two additions and zero removals, renames or
re-signings. That file set is a superset of the API surface
docs/semver.md defines — it retains all of bench/, of which the policy
counts only bench/ldbc, bench/dimacs9 and bench/rmat — so it cannot miss a change inside
the surface. No bench/ file changed in this window, so the two-addition result is the same
under either reading. Under docs/semver.md a new exported identifier is the MINOR
trigger, so 0.15.0 is a MINOR by the rule rather than by decision — unlike v0.14.1,
where a MINOR surface shipped as a PATCH deliberately.
No change is breaking. cypher/exec.ExpandConfig.EmitStoredDir defaults to false, so
every existing caller's row width and chunk schema are unchanged; graph/index/label's
Intersect and Union keep their caller-owned-result contract, and a test deliberately
mutates a single-label Intersect result to assert it. No on-disk format changed — no file
under store/ was modified in this window.
go.mod and go.sum are byte-identical to v0.14.2 — same pinned toolchain go1.27.0,
same go 1.26 directive, same dependency set at the same versions
(git diff v0.14.2..19138042 -- go.mod go.sum is empty). Nothing in the supply chain moved.
The openCypher TCK gate is unchanged at 3 897/3 897. const tckExecutionBaseline = 3897
in cypher/tck/runner_test.go is untouched and no .feature file changed in this window,
so the scenario population is the one v0.14.2 was measured against. Every campaign re-ran it
and it held throughout.
Every performance claim below rests on an interleaved A/B against a byte-identical noise
control measured in the same session. That discipline is not decoration: on this host a
same-versus-same arm has produced 22 of 36 flat-by-construction rows as statistically
significant, so an uncontrolled comparison on it means nothing. Where a figure is a share of a
profile rather than an absolute, it is labelled as such.
Seven premises were refuted on measurement and shipped no code, including two float
reassociations that were built in full, validated, measured and then discarded. They are
itemised under Notes rather than dropped, because a refuted premise is a result.
All 33 commits in this window are unsigned (git log --format=%G? returns N for every
one). docs/release.md asks for this to be re-measured per release rather
than assumed; this is that measurement, and it has not changed.
Added
graph/index/label.(*Index).BitmapShared(label uint32) *roaring64.Bitmap— returns the
memoised, immutable label bitmap image without copying it. The caller must not mutate the
result. It exists becauseIntersectandUnionpromise a caller-owned result and that
promise is kept: the new method is the opt-in way to read the shared image on a scan path that
does not mutate.*roaring64.Bitmapwas already in this package's exported signatures
(Intersect,Union), so no new third-party type enters the public API (#2863).cypher/exec.ExpandConfig.EmitStoredDir bool— opt-in, defaulting tofalse. When set,
Expandappends a fourth column to every emitted row carrying the edge's stored
orientation relative to the emitted traversal orientation:falsefor a row the forward pass
produced,truefor one the reverse pass produced. It is meaningful only forDirBoth, the
one direction under which the orientation varies row by row; aDirOutorDirInhop's
orientation is a property of the hop and is resolved at plan time. A caller that sets it
owns registering the fourth schema column, or the row andschemaWidthdisagree. The cell
is anexpr.BoolValue, read by type assertion, so a coordinate gone stale across a
projection falls back to the old ladder instead of reading a node id as a direction (#2864).- Two counters,
cypher.csr_pair_cache.bypasses_no_cacheand
cypher.csr_pair_cache.bypasses_own_writes. Together with the pre-existinghitsand
missesthey form an exact partition of every cache consultation, which is what makes a
measured hit rate readable at all. Both are documented in
docs/metrics.md (#2866). -contentionflag onexamples/internal/exprof, writing mutex, block and goroutine
profiles, off by default.examples/is explicitly outside the public API in
docs/semver.md — the module neither imports nor depends on it — so this is
recorded for completeness and is not API surface (#2856).- Three campaign records and three profiling reports under
docs/, plus two prior-art
studies read from Neo4j, Memgraph and PostgreSQL source at pinned tags:
campaign-bolt-extreme-concurrency-2026-09-16.md,campaign-cypher-parsing-2026-09-16.md,
campaign-whole-surface-2026-09-16.md,profile-bolt-concurrency-2026-09-16.mdand its
round-2 and round-3 successors,prior-art-cypher-parsing-2026-09-16.mdand
prior-art-mvcc-visibility-2026-09-16.md.
Changed
- The Bolt server's per-rejection log is bounded to one line per second, and its argument is
lazy (#2835). Refusing 500 connections now produces 1 log line where it produced 500;
bolt.server.conn.rejectedstill counts exactly 500, so the operator keeps the signal
and loses only the storm.RemoteAddris resolved 0 times atLevelErrorand 1 time
atLevelWarnacross 100 refusals. The bound is on time, not a one-in-N sample: a
count-based sample still grows linearly with the flood's rate, which is the property that had
to go.rejectLogIntervalis a fixed one-second package constant following the
handshakeTimeoutprecedent, deliberately not anOptionsfield, so the change does not
widen the public API. If you parse or alert on this log line's volume, read this entry.
Admission, the semaphore and its capacity are byte-identical. - The Bolt server writes one response per query instead of two (#2834).
sendResponseno
longer flushes; the session loop carries one obligation instead — the response buffer is
drained before the goroutine can block waiting for the client, and before teardown —
discharged in exactly two named places. Teardown flushes under a fixed deadline, so a peer
that has stopped reading can no longer pin a handler goroutine and its semaphore slot. RECORD
batching is unchanged and its three regression tests are untouched. Protocol bytes are
unchanged; what changed is how manywrite(2)calls deliver them. propMapShardsmoves from 64 to 256 (#2839), and the cost is recorded rather than
hidden: an empty graph grows 91 KiB andNewtakes 57 % longer, becauseNeweagerly
allocates one map per stripe in three of the nine shard arrays. This is a level shift, not
a change of scaling behaviour — the collapse from 64 to 1 024 connections moves only 0.660
to 0.698 of peak. 4 096 shards was rejected on measurement for penalising the
single-threaded scan (+43.8 %) and write (+41.7 %); a full-scan read at 256 stays
indistinguishable from 64, so the read path the 16→64 widening bought is not surrendered.make ciruns the module suite exactly once and fails cheap first (cd5cee02). The
coverage gate leaves the automatic gate by user decision, as benchmarks did inv0.14.2;
make cover-gate,scripts/cover_gate.shand their thresholds are untouched and are run
deliberately.CI_STAGESnow carries membership and order —shell-guard tidy fmt vet build ci-kg-verify vulncheck lint test-uninstrumented test-timing test-short— driven by the
newscripts/ci_stages.sh, which stops at the first failure, times each stage and stamps the
ones that pass.vulncheckand `ci...
v0.14.2
GoGraph v0.14.2 — see CHANGELOG.md for the per-release entry.
[0.14.2] — 2026-09-15
9 commits — 3 fixes, 4 documentation changes and 2 merges. Counted at 92c47fda, the
branch tip before release preparation; the release-preparation commits that carry this entry
are necessarily not in their own count. One sprint delivered this window — 361, bug fixing
campaign 26.09.15 — and all 12 of its tasks closed. 25 files changed, 3 235 insertions,
103 deletions (git diff --shortstat v0.14.1..92c47fda).
The 12 closed tasks and the 3 fix commits that carry them match in both directions — twelve
ids in the sprint, twelve distinct ids across the commit bodies, empty both ways, with each fix
commit's Refs footer naming exactly four. This is not a 1:1 task-to-commit mapping and is
not claimed as one: the sprint was worked as three batches of four tasks sharing a scope, which
is what the project's batching rule asks for. The remaining six commits are four documentation
commits, the sprint merge and the v0.14.1 back-merge.
What the insertion count is, because 3 235 lines is not 3 235 lines of engine. Bucketed by
path from git diff --numstat v0.14.1..92c47fda:
| bucket | files | insertions | deletions |
|---|---|---|---|
*_test.go (regression tests) |
13 | 2 254 | 10 |
engine *.go (non-test, shipped) |
5 | 354 | 30 |
internal/sim/ (the simulator's oracle, non-test) |
4 | 318 | 15 |
root Markdown (CLAUDE.md, README.md) |
2 | 255 | 46 |
docs/ (prose) |
1 | 54 | 2 |
| total | 25 | 3 235 | 103 |
The shipped engine change is 354 insertions across five files, and one of those five is
godoc. bolt/server/serve.go (+23/−4) changes comments only and has no compiled effect, so
four files carry the whole of this release's behaviour: cypher/api.go (+181/−21),
bolt/server/errors.go (+66/−2), cypher/exec/set.go (+53/−3) and cypher/exec/set_all.go
(+31/−0). Against that stand 2 254 lines of regression test across 13 files — the shape a
bug-fixing sprint should have, and the sprint's own rule: every fix closes with a test that
fails on the old behaviour and passes on the corrected one.
One defect runs through this entire release: the module said one thing and did another.
Every item below is an instance of it. A SET reported success and discarded the value
(#2816). An undirected SET reported two relationship writes and made one (#2817). A
DROP INDEX … IF EXISTS reported a removal that never happened (#2818). A statement that
failed and rolled back still reported the counters for the work it had just undone (#2823).
Three faults the client had committed reached it as "An internal error occurred" (#2819).
Three godocs asserted intent the code did not implement (#2811, #2824), and one counter
counted more than its name admits (#2791). This is a release about the gap between what
GoGraph reports and what GoGraph does, and it closes that gap in every place the sprint
found it.
Two of the twelve tasks refuted their own premise and shipped no fix (#2815, #2820). Both
are reported in full under Notes rather than quietly dropped, because a report that turns
out not to reproduce at HEAD is a result, and the regression coverage written to establish
it ships with the release.
go.mod and go.sum are byte-identical to v0.14.1 — same pinned toolchain, same
dependency set at the same versions (git diff v0.14.1..92c47fda -- go.mod go.sum is empty).
There is nothing to report under a dependency heading and nothing is invented to fill one.
The exported API is unchanged, and that is what makes this a PATCH. Five functions were
added and all five are unexported — relStorageDirection, isEntityPropertyValue,
exprValueIsEntity, errEntityPropertyValue and dropIndexCounter. No exported identifier
was added, removed or re-signed, and no exported struct gained a field. Under
docs/semver.md a new exported identifier is the MINOR trigger and there is
not one, so 0.14.2 is a PATCH by the rule rather than by decision — which is the opposite of
v0.14.1, where a MINOR surface was shipped as a PATCH deliberately. There is therefore no
Added section in this entry, and the heading is omitted because there is genuinely nothing
to put under it, not because the surface went unexamined.
No change is marked breaking — no commit in the window carries a BREAKING CHANGE footer
or a ! subject, verified over the full commit bodies rather than the subjects alone. Three
behaviours a caller can observe do change, and every one of them replaces a wrong answer with
a right one; they are itemised under Fixed and revisited in
release-notes/v0.14.2.md.
The openCypher TCK gate is unchanged at 3 897/3 897. const tckExecutionBaseline = 3897
in cypher/tck/runner_test.go is untouched and no .feature file changed in this window,
so the scenario population is the one v0.14.1 was measured against.
All 9 commits in this window are unsigned (git log --pretty=%G? returns N for every
one). docs/release.md asks for this to be re-measured per release rather
than assumed; this is that measurement, and it has not changed.
Fixed
Cypher — the effect a statement reports now equals the effect it applied
- A
SETno longer discards a node-, relationship- or path-valued property while reporting
success (#2816).isStorablePropertyrejected only maps, so five write paths each read its
ok=falseas "no value was produced" and moved on. The defect was wider than reported: on
the REPLACE forms the dropped entry still cleared the existing keys, soSET a = {other: b}
answeredokwithpropertiesRemoved=1and left the node with no properties at all —
silent destruction of data the caller never asked to remove. All five sites now raise
InvalidPropertyType. Gated bycypher/set_entity_property_value_test.go. - An undirected
SETover a reciprocal relationship pair now writes both relationships
(#2817).resolveRelBindingkeyed the edge-property mutation on the row's traversal order,
and the reverse hop of an undirected pattern is the swap of the edge's storage order — so
both rows addressed the same stored edge, one edge was stamped twice and the other never
touched.id(r)returns two distinct relationships, so under openCypher 9 relationship
isomorphism the statement owes two rows and two writes: the counter was right and the write
was missing.relStorageDirectionnormalises the target to storage order. Gated by
cypher/set_undirected_reciprocal_test.goand
cypher/exec/set_rel_storage_direction_test.go. DROP INDEX … IF EXISTSno longer counts a removal that never happened (#2818). Both
branches ofrunDropIndexbumpedIndexesRemovedunconditionally, so absorbing a missing
index reportedindexesRemoved: 1over an index listing identical before and after.
dropIndexCounternow reads for existence before the operator runs and reports the effect
actually applied. WAL behaviour is unchanged — nothing was ever written on the absorbed
path; only the report was wrong. This fix has a measured cost, stated under Performance.- A statement that failed and rolled back no longer reports the counters for the work it
undid (#2823).rollbackUnderBarriercorrectly reversed every eager mutation and then left
r.counterspointing at them, so a caller that inspected the result of a failed statement was
told about writes that no longer existed. It is not MERGE-specific as reported — a plain
multi-assignmentSETand aCREATE … WITH … SETreproduce it identically. The evidence for
the corrected behaviour is unanimous across three independent sources. The TCK corpus never
asks for counters from a statement that raised. Of the 1 615 scenario blocks in
cypher/tck/features/, 192 carry a "should be raised" step and 1 423 carry a side-effect
assertion, and the intersection is empty. The 1 423 counts both forms the TCK uses, because
both assert about side effects: 244 state them as an explicitthe side effects should be:table and 1 179 useno side effects, which asserts just as positively that the
statement changed nothing (244 + 1 179 = 1 423 exactly, so no block carries both forms). It is
still empty on the executed population, where those 1 615 blocks expand to the 3 897 scenarios
the baseline counts: 695 raising, 3 202 asserting side effects, 0 both. A BoltFAILURE
message carries no stats field, and the reference driver returns no summary at all when the
query failed. Gated by
cypher/failed_statement_counters_test.go.
Bolt — three client faults the server was masking
- Three errors a client can act on now reach it as client errors instead of
"An internal error occurred" (#2819), and they were masked for three different reasons.
txn.ErrFieldTooLonghad noFailureCodecase at all; the unsupported-DDL refusal arrives
untyped, as a wrappedfmt.Errorf; andvalidatePreExisting's NOT NULL arm wrapped only the
bare sentinel whereerrors.Asneeded the type. They are now
Neo.ClientError.Statement.ArgumentError,Neo.ClientError.Statement.SyntaxErrorand
Neo.ClientError.Schema.ConstraintValidationFailedrespectively. Each code follows
precedent already in the module rather than being invented for the occasion, and the
reasoning is recorded beside the branch that uses it:
Neo.ClientError.General.LimitExceededwas considered for the over-long field and
rejected, because rmp #2561 established that it does not appear in Neo4j's status codes
at all. Every forwarded
message was checked to carry only static text, the client's own tokens and a format
constant — no server path, no internal identifier — and that check is now asserted
permanently...
v0.14.1
GoGraph v0.14.1 — see CHANGELOG.md for the per-release entry.
[0.14.1] — 2026-09-09
50 commits — 26 fixes, 7 documentation changes, 6 test changes, 3 features, 3
performance changes, 3 build changes, 1 chore and 1 merge. Counted at efd32fb9, the last
commit before release preparation; the release-preparation commits that carry this entry
are necessarily not in their own count. One sprint delivered this window — 357, clear
every open bug and chore, and ship GoGraph v0.14.1 — and 46 of its 47 tasks closed. The
forty-seventh, #2812 (an indexed-MERGE index probe), was carried to sprint 358 and is
not in this release. 184 files changed, 33 244 insertions, 1 679 deletions
(git diff --shortstat v0.14.0..efd32fb9).
Every one of the 46 closed tasks has exactly one commit in this window, and every commit
carrying a Refs line names one of them. That is a 1:1 mapping, established by comparing
the two sets rather than asserted: 46 task ids in the sprint, 46 distinct task ids across
the commit bodies, empty in both directions.
What the insertion count is, because 33 244 lines is not 33 244 lines of engine.
Bucketed by path from git diff --numstat v0.14.0..efd32fb9:
| bucket | files | insertions | deletions |
|---|---|---|---|
*_test.go |
93 | 20 921 | 374 |
*.go (non-test) |
71 | 9 343 | 745 |
docs/ (prose) |
9 | 1 980 | 341 |
root Markdown (README.md, knowledge-model.md) |
2 | 635 | 193 |
.rmp, cmd/kgverify/baseline.json |
2 | 112 | 1 |
| build, CI and release tooling | 5 | 106 | 2 |
release-notes/v0.14.0.md (the erratum) |
1 | 95 | 9 |
examples/ (one README) |
1 | 52 | 14 |
| total | 184 | 33 244 | 1 679 |
The engine change is 9 343 lines across 71 files, and the largest single bucket is tests —
which is the shape a bug-clearing sprint should have: every fix in this window carries a
regression test that fails on the old behaviour and passes on the new, by a sprint rule
carried over from sprint 350.
Three things define this release.
A MATCH on an indexed property could return a wrong answer, and did in the shipped
v0.14.0. The equality index seek was blind to writes the statement itself had made, so
it both lost rows the transaction had written and returned rows that no longer
matched — with no explicit transaction needed, on a single statement. #2814 declines the
index access path for the coordinates the transaction has dirtied, keyed per
(label, property), which turns a wrong answer into a slower correct one. This is the most
important item in the release and the reason it exists.
Four index-build paths could put a row in an index that the graph does not contain, or
leave one out. The backfill read uncommitted mutations, so a seek could return a
fabricated row (#2778); CREATE CONSTRAINT's backfill had the same defect (#2792);
FinishBuild's replay resolved against the live property bag, so a concurrent write could
fabricate an entry (#2793); and rewindConstraintDrop read live state, where the obvious
fix traded a fabricated entry for a lost one (#2799). Because a store written by an
affected build can hold fabricated payloads on disk, #2797 stamps an index-builder
epoch into the snapshot manifest and recovery now refuses to hydrate a payload written
before the fix, rebuilding the index once instead.
The durability path stopped calling three different failures clean. The checkpoint
self-sufficiency gate matched filenames and never verified the snapshot was readable, so a
WAL prefix could be discarded against a snapshot nothing could load (#2749); the readback
that fixed it covered the recovery reader but not its applier (#2780); ReplayWAL
reported corruption inside an already-committed frame as benign, contradicting its own
documented contract (#2794); and a producer op cap above the replay cap made committed data
unreplayable rather than merely rejected (#2530).
go.mod and go.sum are byte-identical to v0.14.0 — same pinned toolchain, same
dependency set at the same versions (git diff v0.14.0..efd32fb9 -- go.mod go.sum is
empty). There is nothing to report under a dependency heading and nothing is invented to
fill one.
No change is marked breaking — no commit in the window carries a BREAKING CHANGE
footer or a ! subject, verified over the full commit bodies rather than the subjects
alone. The exported surface did grow: 31 new exported declarations and 6 new exported
struct fields, with none removed. Under docs/semver.md a new exported
identifier is the MINOR trigger, so the delta is itemised in full under
Added below rather than summarised, and the two changes a caller can actually
feel — four exported default constants that changed value, and one exported function whose
declared return type changed — are set out under
Changed below, with the evidence for each.
This release is numbered 0.14.1 — a PATCH — by explicit decision, not by oversight.
Under docs/semver.md the 31 new exported identifiers are a MINOR trigger,
and PATCH is otherwise reserved for changes that preserve every previously-documented API
contract; four exported constants changing value does not preserve them. The number was
nonetheless kept at PATCH deliberately. So do not read 0.14.1 as a drop-in upgrade:
treat the Changed section below as the checklist a MINOR bump would have signalled. The
two items that will reach a caller silently are DefaultConnTimeout, DefaultTxTimeout,
DefaultMaxTxIdleTime and DefaultStatementTimeout, every one of which is now 0 —
disabled — where three of them were 30 s, 30 s and 5 s; and NewServer, which now
returns an error for a negative timeout it previously coerced to a default in silence.
The openCypher TCK gate is unchanged at 3 897/3 897. const tckExecutionBaseline = 3897
in cypher/tck/runner_test.go is untouched and no .feature file changed in this window
(git diff --name-only v0.14.0..efd32fb9 -- 'cypher/tck/features/**' is empty), so the
scenario population is the one v0.14.0 was measured against.
Read release-notes/v0.14.1.md before upgrading. There is
no data migration, but a store written by v0.14.0 or earlier rebuilds its secondary
indexes once on first open, and the Bolt server's four timeout defaults are now disabled.
Fixed
Cypher — the index access path returned wrong answers
- A
MATCHon an indexed property no longer loses rows the statement wrote, nor returns
rows that no longer match (#2814). The equality hash-index seek and the range seek both
read the committed index while ignoring the transaction's own pending mutations. Two
wrong answers followed from one cause: a row created or updated in the same statement was
missing from the result, and a row whose indexed property had been changed away from
the sought value was still returned. It needed no explicit transaction — a single
autocommit statement was enough. The fix tracks the(label, property)coordinates a
transaction has dirtied and declines the index access path for exactly those, falling back
to the label scan; correct and slower beats fast and wrong.EngineOptions.DisableIndexSeek
is the kill switch. Gated bycypher/index_seek_uncommitted_write_test.go, mutation-tested
from both sides: disabling the guard turns all four behavioural cases red, and a blanket
decline kills the precision cases, so "never seek on a write path" could not have passed
for it. - An index build no longer reads uncommitted mutations, so a seek cannot return a
fabricated row (#2778).CREATE INDEXbackfilled from the live property bag, which
includes writes no reader should see; a row that was never committed could therefore be
indexed, and a later seek would return it. The build now runs against a snapshot. CREATE CONSTRAINT's backfill had the same defect and is fixed the same way (#2792),
and its uniqueness seed is now taken from the same read its index is built from, so the
two cannot disagree.FinishBuild's replay answers with the graph it recorded, not the graph it finds
(#2793). Concurrent writes were reconciled against live state at the end of the build, so
a write that landed mid-build could fabricate an entry. The replay now resolves against
the recorded change log, which is bounded atMaxBuildLogChangesand reports overflow as
ErrIndexBuildOverflowrather than silently truncating.rewindConstraintDroprebuilds from committed state (#2799). Reading live state
fabricated an entry; the first fix considered traded that for a lost one, and both
failure directions are now covered by tests.CREATE CONSTRAINT … IS NOT NULLno longer succeeds over committed data that violates
it (#2798). Validation ran against live state only, so a violating row that was already
durable was not seen. It is now validated against committed state as well.- An explicit transaction now takes the schema gate, so its commit-time index fan-out
cannot escape a DDL barrier (#2738), and concurrent writes are reconciled into an index
built without excluding them.
Cypher — subqueries, projections and reported counts
COUNT { … }block form counts the rows its body returns (#2675).TranslateSubquery
dropped the body's trailingRETURN, so the count was taken over the wrong row set. The
same defect inexistsSubPlanis fixed separately (#2779), where the naive fix was
measured and refuted before the working one was found; theSemiApplyinner schema is
now isolated and the body'sRETURNtranslated.- Pattern predicates and
EXISTS/COUNTsubqueries now run inside a writing statement
(#2660). The write path never wired the subquery and pattern evaluators, so a writing
statement carrying either refused to run. FiveCOUNTblock-form ...
v0.14.0
[0.14.0] — 2026-09-06
13 commits — 5 features, 4 fixes, 1 performance change, 1 documentation change, and
2 merges. Counted at 94602f46, the merge that closed sprint 355 into develop and the
commit release/0.14.0 was cut from; the release-preparation commits that carry this entry
are necessarily not in their own count. One sprint delivered this window — 355,
GoGraph execution reporting — and all 11 of its tasks closed. 158 files changed,
22 858 insertions, 661 deletions (git diff --shortstat v0.13.0..94602f46).
What that insertion count is, because 22 858 lines is not 22 858 lines of engine:
9 841 insertions across 38 _test.go files, 5 797 across 79 raw measurement files under
docs/benchmarks/*-raw/, 4 470 across 33 non-test .go files, 2 708 across 7 documentation
files, and 42 in one example README. Bucketed by path from
git diff --numstat v0.13.0..94602f46.
Three things define the release.
Execution reporting stopped claiming what it had not measured. The DbHits column could
not distinguish "counted, and it is zero" from "never counted at all" — both printed 0. It
is now tri-state: a counted figure, a counted zero, and ? for a figure nobody counted, with
an incomplete total rendering x + ? rather than silently summing across the gaps. Four
operator families that read storage and reported nothing now count it, and each is proved by
an observed arm in which the db-hits figure differs from the operator's own row count, so
the figure cannot have been derived from it. A new Removed column reports how many candidate
rows a predicate threw away.
The planner consumes its own statistics for the first time. The property statistics have
been maintained since #2097 and, until this release, were read by nothing but the EXPLAIN
renderer. They now decide the drive order of the disjoint-component join reorder, under the
pre-existing trustworthiness veto and evaluated over a certified error interval rather than at
the point estimate. Est.Rows renders beside the measured Rows with its provenance
marked, so an estimate and the measurement that tested it can be read from one table over one
plan; and how wrong the estimates turn out to be is itself observable, as a q-error metric.
The sprint's own gate found two defects, and both were fixed rather than deferred. A
declined MVCC label count was read as an empty label, which demoted the estimate, made the
planner benefit non-deterministic, and let EXPLAIN render a plan the engine did not run
(#2771). And a labelled count cloned a roaring bitmap and returned it uncorrected, purely
to have GetCardinality() called on it (#2773).
go.mod and go.sum are byte-identical to v0.13.0. Same pinned toolchain, same
dependency set at the same versions (git diff v0.13.0..94602f46 -- go.mod go.sum is empty).
There is nothing to report under a dependency heading and nothing is invented to fill one.
No change is marked breaking — no commit in the window carries a BREAKING CHANGE footer
or a ! subject — but the rendered plan output changed shape, and any consumer that parses
plan text or reads the Bolt plan map may need updating. The full list is under
Behaviour a caller can observe — the compatibility surface.
The openCypher TCK gate is unchanged at 3 897/3 897. const tckExecutionBaseline = 3897
in cypher/tck/runner_test.go is untouched and no .feature file changed in this window
(git diff --name-only v0.13.0..94602f46 -- 'cypher/tck/features/**' is empty over 220
feature files), so the scenario population is the one v0.13.0 was measured against.
Read release-notes/v0.14.0.md before upgrading. There are no
data-migration steps, but the observable plan output changed and the release's open gaps are
listed there.
Added
Cypher — the estimate reaches the physical plan
cypher/exec.PlanEstimate,exec.EstimateSourcewithEstimateAbsent,EstimateExact,
EstimateStatsandEstimateHeuristic, and the methodsEstimateSource.Known,
EstimateSource.ExactandEstimateSource.String(#2765) — the planner's cardinality
estimate and its provenance, as carried on a physical plan node.cypher/exec.PlanEstimates,exec.PlanTreeWithEstimates(#2765) — the operator-to-
estimate mapping and the plan-tree builder that applies it. Attribution is a single rule at
buildOperator, the one point holding both a logical node and the operator it lowered to:
the deepest logical node whose lowering returns an operator owns the estimate, and it is
never overwritten.cypher/exec.EstRowsCell,exec.EstRowsAnnotationandconst EstRowsUnknown = "-"
(#2765) — theEst.Rowscell renderer, the tree annotation, and the glyph for an operator
with no derivable estimate. Four plan shapes have no logical node at all — the range-seek
leaf, the min-label re-anchored scan, the parallel leaves and the columnar fusion chains —
and render-rather than borrowing a neighbour's number.cypher/exec.PlanNode.Est(#2765), the field the renderers read.
Cypher — db-hits gains an honest unknown
cypher/exec.PlanNode.DbHitsKnown,exec.DbHitsCell,exec.DbHitsTotalCelland
const DbHitsUnknown = "?"(#2760) — the explicit unknown state and its renderers. A
separate boolean was preferred to Neo4j'sOperatorProfile.NO_DATA = -1sentinel so that no
magic number can leak into arithmetic.cypher/explain.ProfileReport.TotalDbHitsUncertain(#2760), which is what makes the
Totalrow renderx + ?instead of a plain number when any contributing cell is unknown.cypher/exec.PlanRows(#2760), which reports a plan's total rows together with whether
that total is complete.
Cypher — rows removed by a filter
cypher/exec.PlanNode.RowsRemovedByFilter,PlanNode.RowsRemovedByFilterKnownand
exec.RowsRemovedCell(#2764) — the rejected-row figure and its renderer.Filter,
ColumnarFilterandExpandreport it, through arowsRemovedCountermarker with a
compile-time census.
Cypher — the estimate error is observable
cypher.Engine.StatsMisestimatedPairs(#2767) — how many(label, property)pairs a
PROFILEd plan has observed to be misestimated past the margin. It returns a count, not
the pairs themselves. It counts only what aPROFILEhas actually looked at, so a zero means
"nothing observed to be wrong", never "everything is right"; and a successful
Engine.RefreshStatisticsclears it, so a non-zero reading after a refresh is the more
interesting fact that the fresh statistics are still predicting badly. Safe for concurrent
use.
graph/lpg
graph/lpg.Graph.LabelCountAsOf(#2773) — a labelled cardinality as of a snapshot that
answers directly, never declines, and allocates nothing when nothing needs correcting.
Observability
- Seven new
cypher.stats.*metric series —cypher.stats.qerrorand
cypher.stats.qerror.high(#2767),cypher.stats.label_count.declined(#2771), and the four
fallback-reason partitionscypher.stats.lookup.fallback.empty_label,.no_count,
.no_statisticand.stale(#2771).cypher.stats.qerrorobserves
max(est, act) / min(est, act)per operator of aPROFILEd plan, and
cypher.stats.qerror.highcounts the samples past the 3× margin. All eleven
cypher.stats.*series are documented in docs/metrics.md; a set
comparison of the series names incypher/stats_metrics.goagainst that file is empty in
both directions.
Changed
Behaviour a caller can observe — the compatibility surface
These are the release's compatibility surface. Nothing here is marked breaking, but each is
observable, and the first four change bytes a consumer may be parsing.
Engine.Explaingained an(est. rows=N exact)annotation on the physical plan tree
(#2765). Where an operator has no derivable estimate, no annotation is added.Engine.Profile,Engine.ProfileTableandResult.Profile()gainedEst.Rowsand
Removedcolumns (#2764, #2765), and an uncounted db-hits cell now renders?rather
than0(#2760), with an incomplete total renderingx + ?. Each column is dropped
entirely when no operator in the plan carries that figure, so a plan with nothing to say in
a column does not grow one.exec.PlanNodegained exported fields —Est,DbHitsKnown,RowsRemovedByFilter
andRowsRemovedByFilterKnown(#2760, #2764, #2765).explain.OperatorStatsgained the
same four, andexplain.ProfileReportgainedTotalDbHitsUncertain. Code that
constructs any of these with an unkeyed composite literal will no longer compile; keyed
literals are unaffected.- Bolt
planandprofilemetadata gainedEstimatedRows,EstimatedRowsSourceand
RowsRemovedByFilter, and now OMITSdbHitsfor an uncounted operator rather than
sending0(#2760, #2764, #2765).EstimatedRowsis where Neo4j puts it, a plan argument;
EstimatedRowsSourcehas no Neo4j counterpart, because Neo4j publishes the estimate
unqualified.RowsRemovedByFilteris written for anEXPLAIN-shaped node too, unlike
dbHits. - Anything parsing plan text or the Bolt plan map may need updating. A reader that assumed
a db-hits cell is always an integer must now handle?andx + ?; a Bolt client that
assumeddbHitsis always present must now handle its absence, which is the wire-level half
of #2760. Expand's db-hits figure changed definition, not merely scale (#2761). It reported the
edges it emitted and now reports the adjacency slots it walked. On a source with 99:LIKES
and 1:KNOWSout-edge,-[:KNOWS]->previously reporteddbhits=1for the same 100-slot
walk that-->reported as100; both now report `...
v0.13.0
A performance and correctness-hardening release: sprints 352 and 353, the module's two deep-profiling and optimisation cycles. 100 commits, 86 closed tasks, 27 of them perf — against 4 in the whole of v0.12.0.
go.mod and go.sum are byte-identical to v0.12.0. Nothing in the supply chain moved.
Seven breaking changes — every one turns a silent failure loud
Pre-1.0 the minor digit absorbs them (docs/semver.md).
store/snapshot(#2743) andstore(#2742) now refuse a field they would previously have truncated or written unreadably. The snapshot case was discarding the WAL behind a snapshot no reader could parse. Newsnapshot.ErrFieldTooLong,txn.ErrFieldTooLong.graph/lpg(#2724, #2725, #2726) —WriteView.RemoveNodeandRemoveEdgenow returnbool. A refused removal used to be journalled for undo anyway, so rollback resurrected the node or the arc. A rolled-backDELETEalso no longer hides a committed node from older readers.store/txn(#2727) — a panic in the commit window no longer wedges every later committer. No exported symbol changed; the!marks an unexported signature.cypher(#2721) —EXPLAINandPROFILEare accepted as statement prefixes, so a Bolt driver can ask for a plan at all.
Two exported signatures broke without a ! marker and are documented as unmarked: cypher/exec.NewTop gained a parameter (#2509), and lpg.WriteView.RemoveAllEdgesFrom returns bool (#2694).
Measured against v0.12.0 — both trees, first-hand
Not carried forward: a worktree at the v0.12.0 tag and this candidate, each compiled once, run interleaved with the leading arm alternating, -race off, -count=6, load-gated. Identical go.mod between the arms means no toolchain or dependency change is mixed into any comparison — the first release since v0.11.0 for which that holds.
The noise floor was measured before anything was attributed: both arms on the same tree, 14 comparisons, 0 significant, largest drift 0.87 % sec/op, 0.00 % allocations.
| Block | geomean | Largest single move |
|---|---|---|
| Cypher count pushdown | −87.65 % | Count_LabelStarBig_Serial −99.97 % (≈3 ms → 880 ns) |
| Contended metric emission, 1→1024 | −92.79 % | IncCounterParallel −96.47 % at 1024 |
| Read transaction, 1→1024 | −73.04 % | ReadTx_LockFree −91.40 % at 1024 |
cypher/exec read-path operators |
−18.93 % | Scan_PerNode −69.97 %; Sort_10k −43.59 % |
| Columnar query shapes | −4.28 % | five hop shapes −7.7 % to −9.4 % |
search headline set (control) |
+0.83 % | within noise, as a control must be |
Contended metric emission changes sign, not degree: v0.12.0 anti-scaled (cost rising 5.8× from 1 to 1024 goroutines), v0.13.0 scales (falling 5.3×). Allocations collapse on two blocks — ReadTx_LockFree 249 → 37, Count_LabelStarBig_Serial 199,796 → 19 — and are constant across the whole ladder where v0.12.0's drifted with concurrency.
What got slower, and what this release cannot prove
Four regressions, none minimised. graph/index/btree Index_LookupHot +26.93 % (its own commit reports 6.66 %; no cause is attributed, because none was measured); Drain_Throughput +12.87 %; IncCounterParallel +8.32 % at one goroutine; CountAllNodes +3.09 %.
Sprint 353's headline index contention claims — 34.6× and 1.91× — cannot be verified at release level. No benchmark common to both trees exercises those indexes concurrently, because the instrument that measures them (bench/contention) is new here. They rest on each commit's own local A/B and are reported as such. Storage footprint, latency percentiles, and every non-darwin/arm64 host also remain unmeasured. docs/benchmarks/v0.13.0.md has the full list.
Gate
Read from inside the run log, not from a wrapper: MAKE_EXIT=0, 134 packages ok, 0 lint issues, govulncheck PASS over 136 packages in 11 modules, coverage 88.5 % aggregate with every package above its 75 % floor, and the openCypher TCK at 3897/3897 scenarios, 16006/16006 steps.
Two release blockers were found and fixed on the way, both in the instruments rather than the module: the read-path allocation gate was measuring a process-global counter that sibling goroutines contaminated (#2753), and cover-gate had never classified bench/entryheap as the instrument it declares itself to be.
Errata
The CHANGELOG entry for #2757 names a distribution route that does not exist. It says the false branch-protection claim "reached consumers" because .goreleaser.yaml bundles docs/**/* into every release tarball. Verified by extracting this release's own artefact: docs/release.md is not in the tarball, and neither is CONTRIBUTING.md. That sentence was written from reading the config instead of opening the artefact.
The substance is unaffected — the branch and tag protections genuinely do not exist, and the correction to both files stands. The claim did reach consumers, via GitHub and via go get, but not by the route named. Tracked as #2759.
That check also exposed a real packaging defect, tracked as #2758: docs/**/* matches only paths with a directory component, so 0 of 112 top-level docs/*.md files ship while 238 benchmark logs do. A consumer opening the tarball gets the evidence and none of the documentation. It will be fixed in the next release; v0.13.0 is already cached by the Go module proxy and cannot be recut.
What was delivered — from the project roadmap
3 sprint(s) closed in this release window (352, 353, 354), containing 91 tracked tasks. Every task in a closed sprint is COMPLETED: the roadmap tool refuses to close a sprint that still holds active work.
- Sprint 352 — GoGraph module deep profiling and optimization
- Sprint 353 — GoGraph Optimization Laboratory
- Sprint 354 — release GoGraph v0.13.0 to production
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.
Full detail: CHANGELOG.md · release notes · benchmark record
go get github.com/FlavioCFOliveira/GoGraph@v0.13.0
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 an...