032. Versioned Document Snapshots and Atomic Edit Batches
- Status: Implemented
- Author: @ornew
- Date: 2026-10-11
Summary
Add explicit text versions, atomic edit batches, and immutable parse snapshots to
Document. Versioned edit batches and the owned Snapshot API are implemented.
Existing Document.Edit, Document.Parse, mutable Node fields, and
Node.Clone retain their ownership contracts.
Snapshots expose an immutable graph. Exact sharing of immutable snapshot records and eligible child-ID runs across versions is selected for default adoption; relative position overlays were measured as a private candidate and were not adopted. Mutable parser Nodes and input buffers are never shared with snapshots. Chunked input storage is a separate mechanism under design 019.
Motivation
Editor consumers need to keep the displayed result while applying further edits, associate diagnostics with the exact text that produced them, and apply one editor transaction without partially accepting invalid changes. Today, callers can deep-clone a tree before reparsing, but must separately manage text, errors, versions, and batch rollback.
Source inspection at d16f33e04d9f71605682166a4dccddbc072e4c51 establishes these
facts, rather than current performance estimates:
Documentis single-owner mutable state. Several edits may precede one parse; construction does not parse the input.Document.Parsereuses memo results and repetition records.moveResultandmoveTreemove reused nonempty Nodes in place, so earlier returned trees can change. Empty nodes can require copies because their placement is contextual.Node.Clonecopies children and node-valued fields with an identity map. Shared descendants stay shared in the clone; captured strings and scalar fields are immutable values. Clone does not capture document text, a version, or diagnostics.input.replacemoves contiguous byte/rune storage and rebuilds a source string; code-point edits update an offset suffix. Tree sharing alone does not remove these input costs.- Ordinary failures and recovered errors are completed outcomes; interrupted action/depth failures and trace panics discard reusable parse state. Text and options survive. Existing interruption tests establish retry behavior.
Edit-batch, Snapshot capture, materialization, and retained-version measurements are recorded below. Older incremental-guide measurements are not a baseline for this revision.
Goals
- Associate every snapshot, span, and diagnostic with one document version and position unit; preserve it across later edits, reparses, and caller mutations of separately returned legacy Nodes.
- Validate a whole edit batch before publishing any change. Reject stale versions, invalid ordering/ranges, and invalid byte boundaries without changing text, version, caches, statistics, or prior results.
- Give readers concurrent access to completed snapshots while one goroutine owns the mutable Document. Expose no mutable backing slices or Nodes.
- Match existing parse values, graph sharing, terminal text, errors, and reuse dependencies on all core Document backends and both units.
- Separate correctness from representation tuning, with independently measured snapshot capture, edit, reparse, materialization, and retained-version costs.
Non-Goals
This record does not change PEG choices, actions, recovery, Node serialization,
Node.Clone, or the mutable Document contract. It does not add generated Go/TS
Documents, typed snapshot conversion, streams, automatic concurrent Document
editing, undo/redo, exact change notifications, tree queries, or cross-document
persistent caches. Input ropes/piece tables and caller-owned storage belong to
the separate input-storage experiment. Wider position representations are not
part of this feature.
Design
Public API and Implementation State
Version tokens, Unit, and atomic EditBatch are the integrated first stage.
The Snapshot API and independently owned graph capture are implemented. Document
construction keeps its existing functional ParseOption arguments.
// DocumentVersion is opaque and comparable. Its zero value is invalid.
type DocumentVersion struct { /* private document identity and sequence */ }
func (v DocumentVersion) Sequence() uint64
// TextEdit uses the document's position unit and the supplied base version.
type TextEdit struct {
Start, End int
Text string
}
func (d *Document) Version() DocumentVersion
func (d *Document) Unit() Unit
func (d *Document) EditBatch(base DocumentVersion, edits ...TextEdit) error
func (d *Document) Snapshot(opts ...ParseOption) (*Snapshot, error)
// SnapshotCaptureError reports a result graph that cannot be frozen safely.
type SnapshotCaptureError struct {
Path string
ValueType reflect.Type // type metadata only; never the unsupported value
ParseError error // owned copy of this capture's parse error, if any
}
func (e *SnapshotCaptureError) Error() string
func (e *SnapshotCaptureError) Unwrap() error // returns ParseError
// Snapshot has private immutable state; callers retain it through ordinary GC.
type Snapshot struct { /* private state */ }
func (s *Snapshot) Version() DocumentVersion
func (s *Snapshot) Unit() Unit
func (s *Snapshot) Text() string
func (s *Snapshot) Root() SnapshotNode
func (s *Snapshot) Error() error
func (s *Snapshot) Stats() ParseStats
func (s *Snapshot) Materialize() *Node
// SnapshotNode is an opaque, comparable handle scoped to one snapshot.
// The zero handle denotes a nil Node.
type SnapshotNode struct { /* private view identity and location */ }
func (n SnapshotNode) Valid() bool
func (n SnapshotNode) Type() string
func (n SnapshotNode) Rule() string
func (n SnapshotNode) Start() int32
func (n SnapshotNode) End() int32
func (n SnapshotNode) Text() string
func (n SnapshotNode) IsTerminal() bool
func (n SnapshotNode) ChildCount() int
func (n SnapshotNode) Child(i int) SnapshotNode
func (n SnapshotNode) Field(name string) (any, bool)
func (n SnapshotNode) Fields() []SnapshotField
type SnapshotField struct {
Name string
Value any // SnapshotNode, int, string, bool, or nil; never *Node
}
A valid field whose value is a typed nil *Node uses a zero SnapshotNode;
an untyped nil field stays nil. Field distinguishes an absent field from a
present nil value using its boolean result. Child includes nil child slots;
ChildCount counts those slots. Fields preserves nil versus allocated-empty
state and returns an owned slice in original field order. Modifying the slice
or its entries changes no snapshot state. Child indices and nil child slots
match Node.Children; invalid indices panic as ordinary slice indexing does.
The zero handle returns empty metadata, zero positions/counts, and absent fields.
Its Child operation has no valid index. Handles compare equal within a snapshot
exactly when they denote the same source Node/view. No equality across snapshots
or stable external node ID is promised. A retained handle stays valid even if the
Snapshot variable is released; it may retain that entire version's immutable
storage, but must not retain the live Document or its caches.
Start and End preserve the current Node's int32 spans, with an exclusive
end. Batch positions remain int, as with Edit; no UTF-16 conversion is
introduced. Materialize copies the whole graph into independently mutable
Nodes, preserving shared identities, cycles, nil slots, field order and values,
and nil versus allocated-empty collections. Each call returns a separate graph.
It is the adapter for consumers that need mutable Nodes or existing Node JSON.
Versions and Pending Edits
Version is a text revision, not a parse counter or the internal edit-log generation. Construction starts at sequence 1. Each successful single Edit and each successful nonempty EditBatch advances once, even for an identity replacement or empty insertion. An empty batch validates its base token but changes nothing. Failed edits and parsing never advance the version. Edit-log compaction/reset must not reset the public sequence. Sequence overflow is rejected before editing rather than wrapping.
The opaque token includes a distinct document identity: a token from another Document is invalid even when both have the same sequence and text. Its identity storage must not keep the Document, text, or parse caches alive. Tokens alone therefore do not retain old versions. Document methods remain unsafe for concurrent use; version tokens do not implement compare-and-swap synchronization.
Snapshot parses the current version, including every edit pending since the last parse. Construction settings provide defaults; invocation options are resolved once with the same restrictions and order as Document.Parse in design 033. Context, work limits, depth and tracing may be overridden; start rule, unit, backend and recognition mode retain the constructed Document contract. A fresh per-call context permits capture after cancellation of a previous task without retaining its controller. Parsing and freezing the text, tree, and diagnostics form one controlled invocation: they use the same context and controller, and input-work and execution-step counters accumulate across both phases. Freezing costs therefore count against the configured limits. It then freezes that exact parse outcome before returning. It does not silently return the previous successful result when current text fails. Taking another snapshot of the same version may reparse and change statistics, but not text or version; snapshots themselves are immutable. No promise is made to reuse an outcome solely because a version is unchanged, especially with tracing. Materialize and snapshot getters run after this invocation and do not accept or inherit its context or work limits.
Atomic Batch Semantics
All edits are simultaneous replacements in the text identified by base. They
are not a sequence of coordinates adjusted after each replacement. Require
strictly increasing Start values and nonoverlap (previous.End <= next.Start).
Reject repeated starts, including multiple insertions at one point. Adjacent
nonempty ranges are allowed. An insertion at the end of the preceding replacement
is allowed; insertion and replacement at the same Start are rejected. This avoids
an implicit ordering policy for ambiguous edits. Callers can combine such edits
into one replacement. Do not sort or modify the caller's slice.
Validate, in order: document identity/version, ordered ranges and bounds, byte-unit endpoints against the original decoded boundaries, and checked final length arithmetic. Preserve existing input/position support; do not introduce a new size limit implicitly through a snapshot representation. Error reporting identifies the offending edit index and range. Replacement text follows Edit semantics: bytes remain exact in Bytes mode; malformed text is normalized to replacement code points in CodePoints mode. Empty text deletes; zero-length ranges insert. Build the final text by concatenating unchanged original ranges with replacements.
Validation and preparation publish no partial state. An invalid batch preserves text, version, memo/repetition caches, input tables, stats, and prior result Nodes. Commit publishes one new text version after preparation succeeds. It does not parse; a later parse error does not undo an accepted text transaction. Fatal process failures such as unrecoverable allocation exhaustion are outside the ordinary error rollback contract.
Do not implement this by calling public Edit repeatedly. Original byte boundaries can change after insertion completes malformed UTF-8, and an intermediate error would violate atomicity. The first correctness implementation may prepare the new input and conservatively discard memo/run state for a nonempty batch. This is an explicit reuse cost, to be measured. A later transaction can prepare the mapped edit log and invalidation state privately, then publish them together. Edits already pending before the batch belong to the base text; batch validation uses that text, not the text of the last parse.
Parse Outcomes, Diagnostics, and Ownership
After safe graph capture, Snapshot and its returned error describe the same version and are available for all normally returned parse outcomes:
| Outcome | Snapshot root | Error |
|---|---|---|
| success with value | immutable value graph | nil |
| successful nil value | zero handle | nil |
| ordinary syntax failure | zero handle, as current Parse | copied SyntaxError |
| consuming recovery | exact current root, possibly nil | copied SyntaxErrors |
| action/depth/module error returned by Parse | zero handle, as current Parse | corresponding error |
| work-limit exhaustion or cancellation during Parse or freezing | no snapshot | interruption error |
| unsupported legacy field value during capture | no snapshot | SnapshotCaptureError |
| trace callback panic | no newly returned snapshot | original panic propagates |
Ordinary syntax and recovered errors do not make the snapshot pointer nil. Callers
can display the failed version's text and diagnostics; they separately retain a
previous successful snapshot if desired. Cancellation or work-limit exhaustion
in either controlled phase returns no snapshot and publishes no partial text,
tree, or diagnostic capture. If invocation execution has begun, clear reusable
parser state as for an interrupted parse; preserve current text and version for
retry. Cleanup is not charged to the exhausted work budget. Cancellation and
deadline errors retain their normal errors.Is identity, and a trace callback
panic propagates as the original panic after cleanup. A subsequent retry must
match fresh parsing.
Legacy NodeField.Value is exported as any, so a caller can put unsupported values into cached Nodes before capture. Accept only untyped nil and the exact dynamic Go types *Node, int, string, and bool. A typed nil *Node is valid. Slices, maps, custom named values, other pointers, functions, and channels are rejected even when their value is nil; do not silently retain, drop, stringify, or shallow-copy them. A Go type alias of an accepted type has the same dynamic type and is accepted.
Reject the first unsupported field deterministically, using depth-first traversal from the root, Children in index order followed by Fields in stored order, and visiting each Node identity once. Snapshot returns nil and *SnapshotCaptureError. Path identifies the first edge path, including child indices and field indices and quoted names (for example, root.Children[0].Fields[1]); ValueType is reflect.Type metadata sufficient to inspect the Go type and named package path. The error never retains the unsupported value or its mutable backing. ParseError is a defensive copy of any parse-outcome error from this call, and Unwrap preserves its existing errors.As classification. No partially captured graph is exposed. Text/version stay unchanged; normal parse effects on stats and reusable state have already occurred and are not rolled back by a capture error.
Store diagnostic/error data independently of mutable SyntaxError pointers and Expected/Messages slices. Error returns a defensive copy; the error returned by Snapshot also cannot mutate stored data. Preserve existing error messages and public errors.As classifications. Current fatal errors have immutable messages; The work-limit diagnostics proposed in design 033 are copied by value, including kind, limit, used and requested counters. Context cancellation and deadline identity remain observable through errors.Is; capture does not retain a context, controller or cancellation token. Custom context.Cause remains caller-owned and is not stored by the snapshot. Future wrapped errors need an explicit immutable copy or a documented immutable cause; do not replace classification with text. Stats is a value copy of this capture's parse, not a live alias to Document.Stats. Text and terminal strings are immutable, but substring retention can keep an entire source allocation alive; include it in retained-version measurements.
Snapshots and their handles are safe for concurrent reads, independently of subsequent Document edits/parses. Lazy lookup/materialization state, if used, requires synchronized publication or invocation-local storage; reads may not race by initializing shared caches. Materialized Nodes are caller-owned mutable graphs. Snapshot freezing must detach from both parser-owned mutable Nodes and legacy Nodes returned before capture. Mutation of a legacy graph after capture, or of a materialized graph, cannot alter an existing snapshot. Freezing observes the actual legacy parse result at capture time; it cannot retroactively erase caller changes made before capture or promise fresh equivalence for such changes.
Representation and Reuse Invariants
The reference path freezes the result graph iteratively with an identity map, including child and node-field edges. Register each destination identity before following its outgoing edges. This preserves shared descendants and cycles, including self-cycles or multi-node cycles added by callers to cached legacy Nodes before capture, without recursion or exponential path expansion. Materialize uses the same iterative identity-map discipline and preserves those cycles in its independent mutable graph. It need not change the recursive public Clone implementation. No mutable parser scratch or Document input arrays are retained as snapshot data.
Snapshot readers must account for cycles when traversing the graph; a unique- identity walk can use comparable handles as its visited keys. Capturing a cycle does not repair the mutable legacy graph. Document.Parse after edits keeps its existing ownership and reuse behavior, with no new guarantee to validate or repair arbitrary caller mutations; snapshot capture promises only to freeze a graph from a Parse call that returns. Existing Node JSON/string consumers retain their current behavior on cyclic input.
The selected sharing path freezes the mutable parse graph first, then compares records at the same traversal index with the previous snapshot graph. It shares a prior immutable record only when its kind, absolute start/end, text, flags, nil-versus-empty slices, ordered child IDs, and ordered field names/values match exactly. Hashes may not replace equality checks. Unmatched records are copied; graphs with fewer than 128 records or fewer than 25% exact matches use the contiguous copy path. The Document keeps only a weak reference to the previous snapshot graph, and snapshot text remains separately owned. This preserves absolute positions and within-version graph identity without retaining mutable Nodes or input buffers.
Relative spans must cover all actual graph edges: action fields can point outside a parent's span, and zero-width/lookahead captures can need different placement contexts. Absolute scalar values produced by position-dependent actions cannot be repaired by shifting spans. Any such design needs measured access bounds and exact results without walking an unbounded edit history. A private relative-position overlay was measured and remains off by default; its measured read and retention tradeoffs are recorded in optimization 100 and summarized by mechanism R160.
Absolute scalar values produced by position-dependent actions cannot be repaired by shifting spans. Preserve the compiler's positional flags, input/examined-range dependencies, left-recursion provisional-result invalidation, and repetition eligibility rules. Recovered errors embed positions in diagnostics and cannot be shifted past edits using the existing reuse path. Actions and predicates reading fields/variables still determine reuse eligibility. UTF-8 joins at batch seams must invalidate decoding dependencies, including truncated prefixes at EOF.
Legacy exported Nodes are writable without interception, so pointer/generation keyed shadows cannot safely assume unchanged content. Exact record sharing freezes and compares content before reuse; hashes alone cannot establish equality. It reduces retained duplication while still requiring whole-graph capture work. Cheap capture from immutable parser storage may instead need a separate exclusively read-only Document surface; that is a later public contract decision, not an implicit change to Document.Parse sharing.
The adopted child-run representation shares unchanged 256-ID runs of a changed record with at least 1,024 children when at least three quarters of the runs match. The sidecar descriptor is allocated only for such records. Other records stay flat, so there is no balancing tree or unbounded traversal history. List order, nil slots, capture sharing, and constant-time indexing are preserved; materialization still builds a full independent public Children slice. A copied Fields slice is not part of this child-run mechanism. Shared runs may retain an older flat child array, but not a live Document, memo table, full edit log, parser scratch, or discarded provisional graph. Opaque handles cache their record and optional run descriptor to avoid repeated sidecar lookups; this grows a handle from 16 to 24 bytes on arm64. The snapshot graph stores records rather than one handle per node. Materialization expands runs directly, and the measured access/materialization tradeoffs are recorded in optimization 099.
Compatibility and Implementation Sequence
Core Closure, Bytecode, and BytecodeIterative Documents are the initial scope, including AST-omitted compiled grammars on available bytecode backends. The generated Go Document has no snapshot API, whether used for Node or typed parsing; TypeScript generation has no Document API. Stream and recognition-only rejection remains unchanged. Public Node JSON and saved module formats remain unchanged; snapshot handles introduce no serialized format.
- Add an opaque, comparable, non-retaining
DocumentVersionwith a document identity and sequence, plusDocument.Version,Document.Unit,TextEdit, and atomicDocument.EditBatch. Start at sequence 1. Each successful legacyEditadvances once, including a no-op; each successful nonempty batch also advances once, while a valid empty batch does not. Reject overflow before mutation. Interpret all batch ranges in the supplied base text; reject foreign or stale versions, repeated or unordered starts, overlapping/out-of-bounds ranges, invalid byte boundaries, and final-length overflow. Validate and prepare privately, then publish the new text and clear reusable parse state without parsing. Any rejected batch preserves observable Document state and previously returned Nodes. Verify fresh-parse equivalence on supported core backends and both position units. - Implement independently owned read-only snapshots, diagnostic copying, concurrent reads, unsupported-value rejection, cycle preservation, and explicit materialization. This stage is implemented and measured against Parse plus Clone while holding text/error/version separately.
- Use exact immutable snapshot-record sharing, preserving the copy path for small or low-reuse graphs. Keep input representation constant in comparisons.
- Share eligible immutable child-ID runs within changed high-fanout records. Keep a same-source flat reference and measure retained history, capture, access, and materialization separately.
- Evaluate relative position overlays separately from exact-record and child-run sharing. The measured private overlay candidate remains off by default; a separate read-only Document API remains a distinct design.
The versioned edit API is the first independently useful stage. Owned snapshots, exact immutable record sharing, and eligible child-run sharing are selected for adoption. Relative position overlays were measured but not adopted; the reference representation continues to store absolute spans. Chunked input storage is a separate mechanism and must be measured independently when combined with snapshots.
Alternatives Considered
- Continue Parse plus Node.Clone. Stable, compatible baseline; copies unique graph nodes and collections and keeps immutable scalar/string values. Consumers still assemble their own version/text/error bundle and batch rollback. Keep it as the correctness and resource reference.
- Return a cloned mutable Node as the snapshot root. Small API surface and existing consumers work directly. Concurrent immutability depends on caller discipline, and shared snapshots cannot safely expose writable fields/slices. Prefer a read-only facade with explicit Materialize for mutable consumers.
- Share current Node pointers behind getters. Avoids an initial copy but later Document parses and external writes still change them. Getters alone do not create immutability; reject this ownership model.
- Replace all Nodes with immutable relative storage immediately. Could support cheap snapshots naturally, but changes action access, legacy sharing, exported fields, and materialization costs at once. Defer a separate read-only Document mode unless compatibility-preserving sharing proves insufficient.
- Deep-copy Document and replay edits for transaction rollback. Easy reference model but copies input, memo, and results that do not belong to a snapshot. Prevalidate and prepare an unpublished commit instead.
- Sequential batch coordinates or automatic sorting. Familiar for some editor protocols, but mixed ranges and repeated insertions need more mapping policy. Prefer explicit pre-batch coordinates and ordered, nonoverlapping replacements; adapters can translate editor-protocol changes before calling the API.
Testing
These are proposed gates, not completed checks:
- Version identity, zero/foreign/stale tokens, no-op edits, empty batches, pending edits, sequence overflow, edit-log reset, unchanged parses, and successful/failing captures. Verify invalid edits alter no observable state, including stats and previously returned Nodes.
- Independently assemble batch text from original ranges. Differential replay of multiple batches/undo sequences against fresh parsing on every backend/unit; abutting replacements, insertion boundaries, unsorted/overlapping/repeated starts, final-length arithmetic overflow, and empty input.
- UTF-8 scalar and malformed-byte boundaries, astral/combining text, invalid bytes, byte-prefix completion across neighboring replacements and at EOF, normalized code-point editing, newline anchors, and line/column diagnostics.
- Position-dependent action scalars, field/text predicates, action errors, cuts, indirect LR/provisional seeds, Operators Rule, recovered errors, nil results and scalars inside fields, deep lists, DAGs, and repetition resumption after multiple edits.
- Freeze/materialize versus actual Parse plus Clone: exact spans, text, field order, nil/empty collections, error classifications and DAG identities. Deep controls must avoid recursive snapshot conversion and exponential graph traversal.
- Before capture, mutate cached legacy fields to slices/maps/custom mutable values, typed nil unsupported values, and custom named scalars. Assert nil snapshot, SnapshotCaptureError path/type information, deterministic first-field selection through shared/cyclic graphs, copied parse error classification, and no retained unsupported backing or partial snapshot. Valid scalar/type-alias and typed nil *Node controls still capture correctly.
- Before capture, add self-cycles and multi-node cycles through Children and node-valued Fields. Verify snapshot/materialization terminate, preserve cycle and shared identities, and detach from later legacy/materialized mutations. Use identity-aware traversal; do not require ordinary JSON serialization of cycles or repair by a subsequent edited legacy Parse.
- Retain old snapshots across edits, failed/recovered/interrupted parses and trace panics. Mutate old legacy Nodes, defensive diagnostic/field copies, and separate materialized results after capture; snapshots stay unchanged.
- Race tests for concurrent read/traversal/error copies/materialization of one and multiple snapshots while a sole owner reparses the Document. Check release of old versions, scratch/memo detachment, and large-to-small edits with forced GC.
Performance and Results
Snapshot capture and retained-version costs have been measured for the
independent immutable graph and the exact-record-sharing implementation. Results
below are scoped to the tested workloads; traversal and broader workloads remain
unmeasured, so no general performance benefit is claimed.
The stage-1 implementation of version tokens and atomic edit batches has
preliminary measurements against the
source-equivalent baseline
69af68e.
Those measurements cover edit and batch behavior only.
Existing Edit and Document-creation measurements used three alternating
500 ms pairs. Three 500 ms complete-transaction samples compared batch edits
with sequential edits. The runs used Go 1.27.1 on darwin/arm64 (Apple M3 Max),
with GOMAXPROCS=16, GOGC=100, and GOMEMLIMIT=off. Existing single-edit
allocation counts were unchanged; timing was mostly noisy, with a 2.7% increase
for a 1 MiB CodePoints edit. Document creation added 33 B and one allocation. Bytes-mode creation was 12.7–16.5%
slower in the tested cases, while CodePoints creation was noisy. For three
equal-length sparse edits followed by Parse, the conservative batch path took
2.6–3.5× the time of sequential Edit-plus-Parse on 4 KiB and 64 KiB abc/newline
inputs across Closure and iterative Bytecode in both units. It also used 66–333
allocations for the batch cases versus 57–86 for sequential edits; these counts
are specific to the measured cases. This cost reflects the stage-1 path's full
cache reset and fresh parse after a successful batch. It is a stage-1 result,
not a snapshot or sharing result and not a general performance claim.
Reproduce the stage-1 transaction and creation cases with
go test ./internal/engine -run '^$' -bench '^BenchmarkDocument(EditTransaction|OwnerCreation)$' -benchmem -benchtime=500ms -count=3.
The existing-edit control uses BenchmarkDocumentInputEdit at 1 KiB, 4 KiB and
1 MiB, middle replacements and both units. Inject the identical creation-only
benchmark into the baseline when comparing revisions; compilation and parsing
are excluded from that cold-owner measurement. The transaction cases include
validation, commit and reparse after a warmed initial parse. Routine raw results
remain outside the repository.
For stage 2, BenchmarkDocumentSnapshotCapture compares capture with a
Document.Parse plus Node.Clone bundle containing text, version, diagnostics,
unit, and statistics. It uses a warmed document, 4 KiB and 64 KiB inputs,
Closure/Bytecode/iterative Bytecode backends, and both input units. Three
sequential 300 ms runs per case on the same Go 1.27.1/M3 Max configuration gave
Snapshot-to-bundle median time ratios of 1.33–1.45 and allocation-byte ratios
of 2.47 (4 KiB) and 3.44 (64 KiB). Snapshot used only 1–2% as many allocation
events. Materialize alone took roughly 0.13 ms (4 KiB) and 1.7 ms (64 KiB)
in these cases. B/op includes transient copies and is not retained heap.
A separate forced-GC retention harness, go run ./bench/snapshot-retained,
warms and parses the Document, forces GC, and records baseline HeapAlloc.
After capturing versions it forces GC again and reports the HeapAlloc delta
over that baseline, divided by the version count. With -retain=true, it keeps
64 versions of a 4 KiB input or 16 versions of a 64 KiB input alive through the
measurement. These are additional post-GC bytes per version, not total live
heap. Across three processes per mode, Snapshot used 1.057× and 1.133× as many
additional bytes per version, respectively, as the Parse-plus-Clone bundle.
Five alternating baseline/candidate pairs of the existing cached Document.Parse benchmark
showed unchanged allocation counts and no consistent timing regression; the
per-pair timings were noisy. These measurements describe the independent-copy
path, not the benefit of exact record sharing.
Exact record sharing was compared with the copying path after a same-length
one-character edit on Go 1.27.1, Apple M3 Max, with GOMAXPROCS=16 and
GOGC=100. In three alternating retention-process pairs, 64 retained 4 KiB
versions used 455.2–455.6 KB per version in copy mode and 84.6–84.7 KB with
sharing; for 16 retained 64 KiB versions, the figures were 7.792–7.794 MB and
1.540–1.541 MB per version. Three alternating 300 ms Closure/CodePoints capture
pairs showed about 12% higher median time for 4 KiB inputs (paired results
10.9–14.8% higher) and about 6% for 64 KiB inputs (3.7–14.4% higher), with
about 2% higher B/op; Materialize was broadly similar. These capture
measurements cover only that backend, unit, and edit shape.
The low-reuse fallback was checked with a length-changing prefix insertion.
For 16 retained 64 KiB versions, both copy and sharing modes measured an
additional 8.386–8.388 MB per version over the warmed-Document baseline. Three
alternating 300 ms Snapshot-plus-prefix-edit pairs on 4 KiB and 64 KiB inputs
showed 0–3% timing differences, within the
observed noise, and similar B/op. With -retain=false, each captured
snapshot is discarded immediately; the post-GC delta over baseline was only
tens of KB in both modes. Five alternating 300 ms pairs of ordinary Document
creation/Parse with sharing disabled versus main had equal
allocation counts and B/op, with no consistent timing difference. Other
workloads and backends remain unmeasured.
The selected mechanism, copy fallback, and focused measurements are summarized in optimization 097. Additional sharing of child-ID runs inside changed records is measured in optimization 099.
Relative position overlays were evaluated in a private build-tag candidate
against the no-tag absolute-position path from the same source. The candidate
stores per-version start/end deltas and generation metadata for shared records;
the no-tag graph keeps its existing 64-byte graph state and 24-byte
SnapshotNode handle. A shared graph needing changed metadata allocates 12 bytes per
record and grows each arm64 handle from 24 to 32 bytes. It preserves the exact
record/child graph and changes only how a handle resolves its absolute
positions. This representation remains private and off by default.
Three serialized ABBA blocks used Go 1.27.1 on Apple M3 Max, with 500 ms samples. Closure/CodePoints prefix capture was 6.5–9.1% slower; indexed reads were 25–38% slower after middle edits and 4–9% slower after prefix edits. Closure/Bytes full-history heap fell about 64–72%, while a latest-only window used 4.4–7.6% more. Heap totals include the live Document and memo state. Keep the candidate default-off: the measured access and latest-only costs do not justify general adoption. Method, allocation counts, ranges and retention windows are in optimization 100.
Reproduce capture and materialization with
go test ./internal/engine -run '^$' -bench '^BenchmarkDocumentSnapshotCapture$' -benchmem -benchtime=300ms -count=3.
Use the retention harness with matching -mode, -size, -versions,
-backend, and -unit flags in separate processes. Keep timed runs serialized.
Pin baseline/candidate revisions, toolchains, backend/unit, grammar/input hashes, retained-version count, and control flags. Alternate paired processes; keep routine raw logs outside the repository.
Measure separately: initial parse; single Edit and EditBatch preparation/commit; reparse with evaluations/reuse; Parse plus Clone reference; snapshot capture; read-only indexed/full traversal; public Node materialization; and downstream serialization. Report ns/op, B/op, allocs/op, source/input size, peak live heap, post-GC live heap with 0/1/10/100 retained versions, and heap after releasing all old versions. Include full text allocations and substring retention. Do not count only the shared payload while excluding version directories, child chunks, handles, cache maps, diagnostics, or simultaneous materialized Nodes.
Use small, medium, and multi-megabyte ordinary trees plus action-built shared DAGs, long child sequences, capture-heavy fields, position actions, recovery, and malformed UTF-8. Edit beginning/middle/end, replace a large document with a small one, apply sparse and adjacent batches, and compare no-edit captures. Keep ordinary Parser.Parse/Recognize and legacy Document.Parse controls to detect abstraction costs. Control DAG traversal by unique identities; naive JSON path expansion is not a snapshot-allocation measurement.
Test copy facade, exact-verified sharing, and any separately scoped immutable parser representation on the same revision. Retain contiguous input during these comparisons so input-storage changes do not masquerade as snapshot gains. Measure materialization both alone and in the realistic edit-to-consumer pipeline. Record adopted optimization methods/results in the optimization catalog with code; update this record with actual outcomes and remaining gates.
Limitations and Open Questions
The implemented contract combines versioned batches with owned read-only snapshots, including ordinary failed parse outcomes. It preserves legacy APIs, and batch commit currently discards reuse state. Exact snapshot-record sharing and eligible child-run sharing reduce retained memory in measured same-length edit workloads. Exact sharing raised capture time in the tested Closure/CodePoints cases; child-run sharing had no clear ordinary-path timing change in the measured Closure/CodePoints cases. Relative position overlays reduce full-history retention in the tested prefix-edit fixtures but increase indexed read cost and latest-only retained heap, so remain off by default. Other backends, units, graph shapes and edit patterns remain unmeasured.
Further measurements can establish whether a separate read-only Document mode is worth its additional API. That tradeoff does not justify silently changing mutable Node behavior.