Skip to content

perf: build shallow root state by forward replay instead of reverse checkout - #1091

Open
zxch3n wants to merge 13 commits into
test/shallow-snapshot-concurrencyfrom
perf/shallow-export
Open

perf: build shallow root state by forward replay instead of reverse checkout#1091
zxch3n wants to merge 13 commits into
test/shallow-snapshot-concurrencyfrom
perf/shallow-export

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 3, 2026

Copy link
Copy Markdown
Member

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:

  • A non-shallow source whose state is at latest.
  • At least 65,536 retained operation atoms.
  • Pre-root operation count at most 16 times the retained count and at most 1,000,000.
  • Estimated pre-root decoded content at most 32 MiB, checked before copying payloads into the temporary change store.

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

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

WASM Size Report

  • Original size: 3144.36 KB
  • Gzipped size: 1042.45 KB
  • Brotli size: 731.06 KB

…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
zxch3n force-pushed the perf/shallow-export branch from ca2d2f3 to cf8ee1b Compare September 5, 2026 05:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant