Skip to content

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

Merged
zxch3n merged 11 commits into
mainfrom
perf/shallow-export
Sep 6, 2026
Merged

zxch3n merged 11 commits into
mainfrom
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: 3151.71 KB
  • Gzipped size: 1044.28 KB
  • Brotli size: 732.67 KB

@zxch3n
zxch3n force-pushed the perf/shallow-export branch from 51b202f to 31e9ad7 Compare September 4, 2026 04:18
@zxch3n
zxch3n changed the base branch from main to test/shallow-snapshot-concurrency September 4, 2026 04:19
@zxch3n
zxch3n force-pushed the perf/shallow-export branch from 1ef09dc to 6d401fc Compare September 4, 2026 11:22
@zxch3n
zxch3n force-pushed the perf/shallow-export branch from 6d401fc to 7baf818 Compare September 4, 2026 12:17
@zxch3n
zxch3n force-pushed the perf/shallow-export branch from 6dde492 to 26c91d2 Compare September 4, 2026 16:38
@zxch3n
zxch3n force-pushed the perf/shallow-export branch from ca2d2f3 to cf8ee1b Compare September 5, 2026 05:15
zxch3n added a commit that referenced this pull request Sep 6, 2026
Backport the dependency-boundary fix from PR #1091 so the guarantees and tests in PR #1090 hold independently. Verify that valid post-root imports still succeed after rejection.

Model: gpt-6
@zxch3n
zxch3n force-pushed the perf/shallow-export branch from 523cf54 to 2db2ee9 Compare September 6, 2026 05:27
Base automatically changed from test/shallow-snapshot-concurrency to main September 6, 2026 05:42
zxch3n added a commit that referenced this pull request Sep 6, 2026
* test: pin down shallow snapshot merge semantics with concurrent peers

* fix: reject shallow updates concurrent with the root

Backport the dependency-boundary fix from PR #1091 so the guarantees and tests in PR #1090 hold independently. Verify that valid post-root imports still succeed after rejection.

Model: gpt-6
…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.
@zxch3n
zxch3n force-pushed the perf/shallow-export branch from 2db2ee9 to f39b5bd Compare September 6, 2026 05:42
@zxch3n
zxch3n merged commit f03d283 into main Sep 6, 2026
1 check passed
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