perf: build shallow root state by forward replay instead of reverse checkout - #1091
Open
zxch3n wants to merge 13 commits into
Open
perf: build shallow root state by forward replay instead of reverse checkout#1091zxch3n wants to merge 13 commits into
zxch3n wants to merge 13 commits into
Conversation
Contributor
WASM Size Report
|
zxch3n
force-pushed
the
perf/shallow-export
branch
from
September 4, 2026 04:18
51b202f to
31e9ad7
Compare
zxch3n
changed the base branch from
main
to
test/shallow-snapshot-concurrency
September 4, 2026 04:19
This was referenced Sep 4, 2026
Merged
zxch3n
force-pushed
the
perf/shallow-export
branch
from
September 4, 2026 11:22
1ef09dc to
6d401fc
Compare
zxch3n
force-pushed
the
perf/shallow-export
branch
from
September 4, 2026 12:17
6d401fc to
7baf818
Compare
zxch3n
force-pushed
the
perf/shallow-export
branch
from
September 4, 2026 16:38
6dde492 to
26c91d2
Compare
…oots - Mirror the live doc's deleted_root_containers into the replay doc so a root deleted before the shallow root is dropped at flush instead of being resurrected as an empty entry. - Mirror root containers via a root-only key scan (existing_retention_roots) instead of iter_all_container_ids, which called load_all and defeated lazy imports. - Only use forward replay when >= 65536 ops are retained since the root; below that the checkout path ties in time and peaks at ~4x less memory (measured on the 66k-container fixture at F = 50%..100% of history). - Extend the path-equivalence test past the overlay threshold with full metadata and retained-history comparison, add deleted-root parity tests, and add a lazy-import benchmark entry.
The retained-ops gate alone could select the forward-replay path for a doc whose pre-root history is huge but unrelated to the tail (e.g. millions of same-key Map overwrites before the root, one 65k-atom Text insert after it), re-encoding and replaying the whole prefix while the checkout path only walks the tail. Cap the prefix absolutely (1M ops) and relative to the tail (16x; measured crossover: forward wins at ratio 9, loses at 19). Add a scalar-prefix-heavy benchmark entry as a regression guard: 280ms un-gated vs 56ms gated on a 2M-op prefix + 70k-atom tail fixture.
Op-atom counts miss value sizes: a Map write is one atom regardless of how large its Binary/String payload is, so a byte-heavy low-op prefix could bypass the op-count gates and be fully re-encoded and replayed into the temp doc. Encode the (cheap, block-copied) prefix blob first, then drop it when it exceeds 32 MiB. The gate logic is extracted into a pure predicate with unit tests, plus a byte-heavy low-op prefix export-correctness test and a byte-prefix benchmark entry. Also assert the op-less root container's existence with has_container before materializing it in the path-equivalence test.
… values The encoded-byte filter ran only after export_fast_updates_in_range had already slice-copied every prefix value into a fresh ChangeStore, so the cap could not prevent the large allocation it was meant to avoid. Replace it with a decoded-size estimate that walks op payloads by reference (new SharedArena::with_values) and short-circuits past the cap, so a rejected prefix costs one bounded walk and zero payload copies. Add an estimator unit test.
…-aware
The estimator counted only top-level String/Binary payloads; nested
LoroValue::List/Map were charged a flat 16 bytes while the encoder recurses
into them, so { payload: <huge String> } still bypassed the byte cap. Count
nested values recursively, include StyleStart values and commit messages, and
give every counting step a remaining budget so the walk short-circuits past
the cap. The byte-heavy prefix regression test now nests its payload one
level down.
…timate The estimator missed fields the block encoder copies: StyleStart keys (which go into the block's key register), TreeOp fractional indexes, root container names (copied into the block's container arena), and unknown-future payload bytes. All are now counted with the same cap-aware budget, covering the huge-style-key-on-deleted-pre-root-text scenario.
…imate owned_value_bytes_capped folded every other OwnedValue variant to a flat 16 bytes, but the encoder writes MarkStart keys into the block key register and recurses into MarkStart/ListSet values, so unknown-container ops (whose decoder accepts any Value) could still smuggle a huge payload past the byte cap. Count both, with the same cap-aware recursion. TreeMove/ListMove keep a flat charge: they carry only fixed-size indices.
Such a change is concurrent with the root frontier op: its causal past is covered by the root state, so the boundary shortcut in frontiers_to_vv resolved it to the shallow vv and the import check let it through. But the dep ids are trimmed from the DAG, so the change was parked as pending and then panicked in calc_unknown_lamport_change (unwrap on Err). Reject it with ImportUpdatesThatDependsOnOutdatedVersion like any other pre-root update. Adds a dag-level unit test and an import-level test that also locks the 'dropped, not pending' guarantee by asserting pending_changes_len() stays 0.
zxch3n
force-pushed
the
perf/shallow-export
branch
from
September 5, 2026 05:15
ca2d2f3 to
cf8ee1b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem and behavior
Shallow snapshot export previously checked the live document backwards from latest to its root, then forwards again. For many small text/list containers, reverse checkout rebuilds full CRDT trackers and dominates export time.
This PR builds the root by forward replay into a temporary document when that is cheaper, reads the latest overlay from the live store, and retains the existing checkout path for other inputs. It is stacked on #1090, which now contains the shallow-import dependency-boundary correctness fix; #1087 provides the corrected streaming JSON APIs below both branches.
Replay selection and state preservation
The production fast path requires:
The estimator follows arena values by reference, counts nested values, keys, style data, tree indexes, root names, unknown-op payloads and commit messages, and stops once its budget is exceeded. Expensive prefixes and small tails use checkout instead.
Root reconstruction pre-encodes updates under the oplog lock without re-entering the document barrier, mirrors accessed-but-op-less roots using a root-only key scan, and copies the deleted-root configuration. Export preserves the live document's version and attachment state. Both paths share overlay filtering, redaction and section encoding.
Performance and scope
Recorded synthetic benchmark (~66k containers, ~720k ops, ~393k retained): shallow export 3227 ms to 206 ms, with full snapshot export about 1.5–1.6 ms. The lazy-at-latest and large-prefix benchmarks guard against regressions where forward replay spends more CPU/memory than checkout. Run
cargo bench --bench shallow_export -p loro-internal; results depend on history shape, and the speedup is not universal.This optimizes snapshot construction, not Mirror initialization or JSON conversion. Path-equivalence tests compare imported values, metadata, retained history, shallow boundaries and empty/deleted roots, including the latest-state overlay.
Known pre-existing behavior: with an overlay, a root deleted after the shallow root can retain its at-root content at the imported latest version, and checkout can double that content. This behavior exists on both paths and is not introduced or repaired by the forward-replay optimization.
Validation
git diff --checkpassed. No force-push or history rewrite is required to update the stack.