diff --git a/.changeset/loro-js-atomic-import.md b/.changeset/loro-js-atomic-import.md new file mode 100644 index 000000000..87230d842 --- /dev/null +++ b/.changeset/loro-js-atomic-import.md @@ -0,0 +1,5 @@ +--- +"loro.js": patch +--- + +`import`, `importBatch` and `importJsonUpdates` are atomic: when an import fails, history, pending changes, version, containers and state are restored, and no event is emitted, so subscribers and `UndoManager` see nothing of it. A multi-blob `importBatch` imports every blob or none. diff --git a/context/import-batch-atomicity.md b/context/import-batch-atomicity.md index 784478775..2183d98e5 100644 --- a/context/import-batch-atomicity.md +++ b/context/import-batch-atomicity.md @@ -111,3 +111,37 @@ regression on out-of-order batches, where every blob parks and is later unlocked `import_batch_failure_leaves_doc_attached_and_unchanged`. - `crates/loro-internal/src/oplog/pending_changes.rs`: the `import_batch_*` regressions that assert the doc is attached after a batch. + +## loro.js + +Verified against code 2026-09-30. `loro-js/src/runtime/document.ts` gives +`import`, `importBatch` and `importJsonUpdates` the same all-or-nothing contract +through `LoroDoc.#atomically`: + +- While an import runs, every in-place change to history structures (records, + per-peer arrays and ends, merged records, Map/Tree operation indexes, + containers and roots) and to the snapshot-sequence entries + (`#journalSnapshotSequence`) pushes an undo closure onto `#importUndo`. The + snapshot-sequence table can hold one entry per hydrated Text/List/MovableList, + so copying it made every import O(hydrated containers). Fields the import + replaces wholesale (frontiers, pending changes, deferred snapshot + history/state, shallow root and its entry index, counters) are saved by + reference or copied, since they are small. +- On failure the closures run in reverse and the saved fields are restored. + If the import had started to change container state (`#applyRecords`, + `#hydrateState` or `#rebuildFromHistory` ran), state is rebuilt from the + restored history at the previous version. The rebuild only happens on this + failure path. The per-container record index is invalidated. A snapshot + container that the import completed from its own history first + (`#prepareSnapshotImport`) keeps that replay, which equals its snapshot + state, and gets its snapshot entry back. +- Importing into a pristine document skips the journal: a failure empties it + again. +- `import` and `importBatch` emit their event only after the import succeeded, + so subscribers and `UndoManager` never see a partial import. A multi-blob + batch fails as a whole. +- `checkout`/`attach` that fail (for example on a forged op a detached import + recorded) restore the previous version and attachment instead of leaving the + document half switched (`#transitionTo`, from loro-dev/loro#1126). + +Tests: `loro-js/tests/import-atomicity.test.ts`. diff --git a/context/loro-js-movable-list.md b/context/loro-js-movable-list.md index 48626f2a4..a367f7635 100644 --- a/context/loro-js-movable-list.md +++ b/context/loro-js-movable-list.md @@ -8,8 +8,8 @@ reproduce, the loro.js model that implements them, and how the differential suite checks the two against each other. Complexity rules still come from [loro-js-performance.md](loro-js-performance.md). -Status: the model below is implemented. Import validation and atomic rollback -("Validation and import atomicity") land in follow-up changes. +Status: the model below and atomic imports are implemented. Import validation +("Validation and import atomicity") lands in a follow-up change. ## Rust semantics (the contract) @@ -211,7 +211,8 @@ Rust's rule for `from_move` inserts that transforms leave behind ## Validation and import atomicity -Planned; not implemented yet. Import validation follows Rust #1125. A `Move`/`Set` whose element is unknown, +Atomicity is implemented ([import-batch-atomicity.md](import-batch-atomicity.md), +"loro.js"). Validation is planned. It follows Rust #1125. A `Move`/`Set` whose element is unknown, lives in another container, is outside the op's causal history, or (on a shallow doc) was deleted before the root is rejected with an error. So is a `from`/`to` outside the op-index range of its causal view. A `Move`/`Set` of a diff --git a/context/loro-js-performance.md b/context/loro-js-performance.md index adedb741a..674c3b790 100644 --- a/context/loro-js-performance.md +++ b/context/loro-js-performance.md @@ -91,6 +91,10 @@ JavaScript constant factor. produce the final event. - A pending transaction stores its accumulated operation length and causal version incrementally. Never recover either by reducing all pending ops. +- An import journals its history changes (a few undo closures per imported + change) so a failure can roll back; importing into an empty document skips + the journal. Only a failure after state changed pays for a history replay + ([import-batch-atomicity.md](import-batch-atomicity.md)). - Plain Text/List elements allocate delete, value, and move metadata only when an operation needs it; Text style metadata lives in the range index. A multi-scalar Text insertion stores its string and UTF-16 boundaries once in a diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index 323093a77..16967e14e 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -342,6 +342,14 @@ export class LoroDoc = Record(); + /** + * Undo log of in-place history mutations while an import runs, so a failed + * import can be rolled back; see `#atomically`. `null` while importing into a + * pristine document, which a failure simply empties again. + */ + #importUndo: (() => void)[] | null | undefined; + /** Whether the running import has started to change container state. */ + #importTouchedState = false; #textStyles = new Map([ ["bold", "after"], ["italic", "after"], @@ -685,6 +693,12 @@ export class LoroDoc = Record this.#importBlob(bytes)); + emit(); + return status; + } + + #importBlob(bytes: Uint8Array): { status: ImportStatus; emit: () => void } { const before = this.#frontiersCodec(); const beforeVersion = this.#historyVersion(); const parsed = decodeDocument(bytes); @@ -941,22 +955,33 @@ export class LoroDoc = Record formatContainerId(op.container)), ), )); - this.#emit( - "import", - undefined, - before, - this.#frontiersCodec(), - changed, - beforeValues, - preparedDiffs, - ); - return deferredStatus ?? importStatus(integration.added, integration.pending); + const after = this.#frontiersCodec(); + return { + status: deferredStatus ?? importStatus(integration.added, integration.pending), + emit: () => + this.#emit( + "import", + undefined, + before, + after, + changed, + beforeValues, + preparedDiffs, + ), + }; } + /** Imports every blob or, when one fails, none of them. */ importBatch(blobs: readonly Uint8Array[]): ImportStatus { if (blobs.length === 0) return { success: new Map(), pending: null }; if (blobs.length === 1) return this.import(blobs[0]!); this.#commit({}, true); + const { status, emit } = this.#atomically(() => this.#importBlobs(blobs)); + emit(); + return status; + } + + #importBlobs(blobs: readonly Uint8Array[]): { status: ImportStatus; emit: () => void } { this.#materializeDeferredHistory(); const before = this.#frontiersCodec(); const beforeVersion = this.#historyVersion(); @@ -1048,16 +1073,20 @@ export class LoroDoc = Record() : changedContainerIds(integration.added); - this.#emit( - "import", - undefined, - before, - this.#frontiersCodec(), - changed, - beforeValues, - preparedDiffs, - ); - return importStatus(integration.added, integration.pending); + const after = this.#frontiersCodec(); + return { + status: importStatus(integration.added, integration.pending), + emit: () => + this.#emit( + "import", + undefined, + before, + after, + changed, + beforeValues, + preparedDiffs, + ), + }; } importUpdateBatch(blobs: readonly Uint8Array[]): ImportStatus { @@ -4432,8 +4461,17 @@ export class LoroDoc = Record containers.delete(key)); if (id.kind === "root" && !isMergeableContainerId(id)) { - this.#roots.set(id.name, container); + const roots = this.#roots; + const name = id.name; + const previous = roots.get(name); + roots.set(name, container); + this.#importUndo?.push(() => { + if (previous === undefined) roots.delete(name); + else roots.set(name, previous); + }); } if (hydrate) this._ensureContainerHydrated(container); return container; @@ -5290,6 +5328,108 @@ export class LoroDoc = Record(run: () => T): T { + if (this.#importUndo !== undefined) return run(); + const pristine = + this.#history.size === 0 && + this.#pendingHistory.size === 0 && + this.#deferredSnapshotHistory === undefined && + this.#shallowRootStore === undefined; + const checkpoint = { + history: this.#history, + historyOrder: this.#historyOrder, + historyByPeer: this.#historyByPeer, + historyEndByPeer: this.#historyEndByPeer, + historyOperationCount: this.#historyOperationCount, + historyFrontiers: new Map(this.#historyFrontiers), + dependencyVersionCache: this.#dependencyVersionCache, + mapOperationHistory: this.#mapOperationHistory, + treeOperationHistory: this.#treeOperationHistory, + containersWithOperations: this.#containersWithOperations, + pendingHistory: new Map(this.#pendingHistory), + seenCommittedPeers: new Set(this.#seenCommittedPeers), + deferredSnapshotHistory: this.#deferredSnapshotHistory, + deferredSnapshotState: this.#deferredSnapshotState, + // Journaled per write (`#journalSnapshotSequence`): a document can hold + // one entry per hydrated sequence, too many to copy on every import. + snapshotSequences: pristine + ? new Map(this.#snapshotSequences) + : this.#snapshotSequences, + shallowRootEntries: this.#shallowRootEntries, + shallowStartVersion: this.#shallowStartVersion, + shallowRootVersion: this.#shallowRootVersion, + shallowRootFrontiers: this.#shallowRootFrontiers, + shallowRootStore: this.#shallowRootStore, + nextCounter: this.#nextCounter, + containers: pristine ? new Map(this.#containers) : this.#containers, + roots: pristine ? new Map(this.#roots) : this.#roots, + }; + this.#importUndo = pristine ? null : []; + this.#importTouchedState = false; + try { + const result = run(); + return result; + } catch (error) { + const undo = this.#importUndo; + const touchedState = this.#importTouchedState; + this.#importUndo = undefined; + if (undo === null) { + // Nothing existed before; drop everything the import added in place. + checkpoint.history.clear(); + checkpoint.historyOrder = new OrderedIndex(compareHistoryRecords); + checkpoint.historyByPeer.clear(); + checkpoint.historyEndByPeer.clear(); + checkpoint.mapOperationHistory.clear(); + checkpoint.treeOperationHistory.clear(); + checkpoint.containersWithOperations.clear(); + } else { + for (let index = undo.length - 1; index >= 0; index -= 1) undo[index]!(); + } + this.#history = checkpoint.history; + this.#historyOrder = checkpoint.historyOrder; + this.#historyByPeer = checkpoint.historyByPeer; + this.#historyEndByPeer = checkpoint.historyEndByPeer; + this.#historyOperationCount = checkpoint.historyOperationCount; + this.#historyFrontiers = checkpoint.historyFrontiers; + this.#sortedHistoryCache = undefined; + this.#dependencyVersionCache = checkpoint.dependencyVersionCache; + this.#mapOperationHistory = checkpoint.mapOperationHistory; + this.#treeOperationHistory = checkpoint.treeOperationHistory; + this.#containersWithOperations = checkpoint.containersWithOperations; + this.#pendingHistory = checkpoint.pendingHistory; + this.#seenCommittedPeers = checkpoint.seenCommittedPeers; + this.#deferredSnapshotHistory = checkpoint.deferredSnapshotHistory; + this.#deferredSnapshotState = checkpoint.deferredSnapshotState; + this.#snapshotSequences = checkpoint.snapshotSequences; + this.#shallowRootEntries = checkpoint.shallowRootEntries; + // The per-container record index may hold the rolled-back records. + this.#historyRevision += 1; + this.#containerHistoryIndex = undefined; + this.#shallowStartVersion = checkpoint.shallowStartVersion; + this.#shallowRootVersion = checkpoint.shallowRootVersion; + this.#shallowRootFrontiers = checkpoint.shallowRootFrontiers; + this.#shallowRootStore = checkpoint.shallowRootStore; + this.#nextCounter = checkpoint.nextCounter; + this.#containers = checkpoint.containers; + this.#roots = checkpoint.roots; + // State was changed in place; rebuild it from the restored history. + if (touchedState) { + this.#rebuildFromHistory(this.#detached ? this.#checkoutVersion : undefined); + } + throw error; + } finally { + this.#importUndo = undefined; + this.#importTouchedState = false; + } + } + #readChangeBlock(bytes: Uint8Array): HistoryRecord[] { const block = decodeChangeBlock(bytes); return block.changes.map((change) => ({ change, keys: block.keys })); @@ -5413,6 +5553,7 @@ export class LoroDoc = Record = Record = Record = Record = Record { + if (entry === undefined) sequences.delete(key); + else sequences.set(key, entry); + }); + } + /** * Readies snapshot-hydrated containers for importing `records` on top of * the state at `current`. That state has no tombstones and no MovableList @@ -6475,6 +6632,7 @@ export class LoroDoc = Record = Record (applied.get(peer) ?? 0)) applied.set(peer, counter); } + this.#journalSnapshotSequence(key); this.#snapshotSequences.set(key, { ...entry, applied }); this.#dirtySnapshotContainers.add(key); } @@ -6745,32 +6904,53 @@ export class LoroDoc = Record { + order.delete(record); + if (previous === undefined) { + history.delete(key); + } else { + history.set(key, previous); + order.add(previous); + } + }); this.#historyOperationCount += changeLength(record.change) - (previous === undefined ? 0 : changeLength(previous.change)); this.#sortedHistoryCache = undefined; this.#historyRevision += 1; - let peerRecords = this.#historyByPeer.get(record.change.id.peer); + const byPeer = this.#historyByPeer; + const peer = record.change.id.peer; + let peerRecords = byPeer.get(peer); if (peerRecords === undefined) { peerRecords = []; - this.#historyByPeer.set(record.change.id.peer, peerRecords); + byPeer.set(peer, peerRecords); + this.#importUndo?.push(() => byPeer.delete(peer)); } if (previous !== undefined) { const previousIndex = lowerBoundHistory(peerRecords, previous.change.id.counter); - if (peerRecords[previousIndex] === previous) peerRecords.splice(previousIndex, 1); + if (peerRecords[previousIndex] === previous) { + peerRecords.splice(previousIndex, 1); + const records = peerRecords; + this.#importUndo?.push(() => records.splice(previousIndex, 0, previous)); + } } const index = lowerBoundHistory(peerRecords, record.change.id.counter); peerRecords.splice(index, 0, record); + const records = peerRecords; + this.#importUndo?.push(() => records.splice(index, 1)); + this.#journalHistoryEnd(peer); const last = peerRecords.at(-1); if (last === undefined) { - this.#historyEndByPeer.delete(record.change.id.peer); + this.#historyEndByPeer.delete(peer); } else { this.#historyEndByPeer.set( - record.change.id.peer, + peer, last.change.id.counter + changeLength(last.change), ); } @@ -6794,6 +6974,30 @@ export class LoroDoc = Record { + if (keyIndices === undefined) { + record.keys = keys; + delete record.keyIndices; + } else { + const grown = record.keys as string[]; + for (const key of grown.slice(keyCount)) { + if (keyIndices.get(key)! >= keyCount) keyIndices.delete(key); + } + grown.length = keyCount; + } + operations.length = operationCount; + changeLengthCache.set(record.change, length); + }); + } + #appendMergedHistoryRecord( record: HistoryRecord, appended: HistoryRecord, @@ -6802,6 +7006,7 @@ export class LoroDoc = Record = Record { + if (end === undefined) ends.delete(peer); + else ends.set(peer, end); + }); + } + #indexHistoryOperations(record: HistoryRecord): void { + const undo = this.#importUndo; for (const operation of record.change.operations) { - this.#containersWithOperations.add(this.#containerKey(operation.container)); + const containerKey = this.#containerKey(operation.container); + if (undo && !this.#containersWithOperations.has(containerKey)) { + const withOperations = this.#containersWithOperations; + undo.push(() => withOperations.delete(containerKey)); + } + this.#containersWithOperations.add(containerKey); const content = operation.content; const indexed = { record, @@ -6837,10 +7058,12 @@ export class LoroDoc = Record histories.delete(container)); } subject = content.key; } else if ( @@ -6849,10 +7072,12 @@ export class LoroDoc = Record histories.delete(container)); } subject = idKey(content.subject); treeOperation = true; @@ -6868,23 +7093,36 @@ export class LoroDoc = Record subjects.delete(key)); + } + const subjectHistory = history; + subjectHistory.byWriter.add(indexed); + undo?.push(() => subjectHistory.byWriter.delete(indexed)); + const peer = record.change.id.peer; + let peerOperations = subjectHistory.byPeer.get(peer); if (peerOperations === undefined) { peerOperations = []; - history.byPeer.set(record.change.id.peer, peerOperations); + subjectHistory.byPeer.set(peer, peerOperations); + undo?.push(() => subjectHistory.byPeer.delete(peer)); } const peerIndex = lowerBoundIndexedOperation(peerOperations, operation.counter); peerOperations.splice(peerIndex, 0, indexed); + const operations = peerOperations; + undo?.push(() => operations.splice(peerIndex, 1)); if (content.type === "tree-create" || content.type === "tree-move") { - let placements = history.placementsByPeer!.get(record.change.id.peer); + const placementsByPeer = subjectHistory.placementsByPeer!; + let placements = placementsByPeer.get(peer); if (placements === undefined) { placements = []; - history.placementsByPeer!.set(record.change.id.peer, placements); + placementsByPeer.set(peer, placements); + undo?.push(() => placementsByPeer.delete(peer)); } const placementIndex = lowerBoundIndexedOperation(placements, operation.counter); placements.splice(placementIndex, 0, indexed); + const placed = placements; + undo?.push(() => placed.splice(placementIndex, 1)); } } } @@ -8138,6 +8376,7 @@ export class LoroDoc = Record + left.localeCompare(right), + ), + frontiers: target.oplogFrontiers(), + changes: target.changeCount(), + ops: target.opCount(), + shallow: JSON.stringify(target.getShallowValue()), + }; +} + +/** Peer 1's list [a, b, c] and a later change by peer 1 that moves `a`. */ +function base(): { readonly source: LoroDoc; readonly update: Uint8Array } { + const source = doc(1); + const list = source.getMovableList("list"); + for (const value of ["a", "b", "c"]) list.push(value); + source.commit(); + return { source, update: source.export({ mode: "update" }) }; +} + +/** A JSON change whose move op names `from`, which may be out of range. */ +function forgedMove(source: LoroDoc, from: number): JsonSchema { + const version = source.oplogVersion(); + source.getMovableList("list").move(0, 2); + source.commit(); + const json = source.exportJsonUpdates(version); + const content = json.changes[0]!.ops[0]!.content as { from: number }; + content.from = from; + return json; +} + +function assertUsable(target: LoroDoc): void { + const list = target.getMovableList("list"); + const length = list.length; + list.push("local"); + target.commit(); + expect(list.length).toBe(length + 1); + const remote = new LoroDoc(); + remote.setPeerId(9); + remote.import(target.export({ mode: "snapshot" })); + remote.getMovableList("list").push("remote"); + remote.commit(); + target.import(remote.export({ mode: "update", from: target.oplogVersion() })); + expect(target.toJSON()).toEqual(remote.toJSON()); +} + +describe("import is atomic", () => { + test("an op that fails while it is applied rolls back history and state", () => { + const { source, update } = base(); + const target = doc(2); + target.import(update); + target.getMovableList("list").push("d"); + target.commit(); + const before = observe(target); + const events: LoroEventBatch[] = []; + target.subscribe((event) => events.push(event)); + + expect(() => target.importJsonUpdates(forgedMove(source, 9))).toThrow( + /out of range/u, + ); + expect(observe(target)).toEqual(before); + expect(events).toEqual([]); + assertUsable(target); + }); + + test("importBatch applies no blob when one fails", () => { + const { source, update } = base(); + const target = doc(2); + target.import(update); + const before = observe(target); + const valid = doc(3); + valid.import(update); + valid.getMovableList("list").push("valid"); + valid.commit(); + const validUpdate = valid.export({ mode: "update", from: target.oplogVersion() }); + // A detached import only records history, which lets it carry the forged op. + const forged = new LoroDoc(); + forged.import(update); + forged.detach(); + forged.importJsonUpdates(forgedMove(source, 9)); + const forgedUpdate = forged.export({ + mode: "updates-in-range", + spans: [{ id: { peer: "1", counter: 3 }, len: 1 }], + }); + + expect(() => target.importBatch([validUpdate, forgedUpdate])).toThrow( + /out of range/u, + ); + expect(observe(target)).toEqual(before); + expect(() => target.importBatch([validUpdate, new Uint8Array([1, 2, 3])])).toThrow( + /too short/u, + ); + expect(observe(target)).toEqual(before); + assertUsable(target); + }); + + test("a failed import keeps the pending changes it would have unlocked", () => { + const { source, update } = base(); + const target = doc(2); + target.import(update); + const version = source.oplogVersion(); + // The forged move (counter 3) and a valid change after it (counter 4). + const forged = forgedMove(source, 9); + source.getMovableList("list").push("later"); + source.commit(); + const later = source.export({ + mode: "updates-in-range", + spans: [{ id: { peer: "1", counter: 4 }, len: 1 }], + }); + const pendingStatus = target.import(later); + expect(pendingStatus.pending).not.toBeNull(); + const before = observe(target); + + expect(() => target.importJsonUpdates(forged)).toThrow(/out of range/u); + expect(observe(target)).toEqual(before); + // The pending change is still pending and unlocks once its dependency is valid. + const honest = doc(1); + honest.import(update); + honest.getMovableList("list").move(0, 2); + honest.commit(); + target.import( + honest.export({ mode: "update", from: new VersionVector(version.toJSON()) }), + ); + expect(target.getMovableList("list").toJSON()).toEqual(["b", "c", "a", "later"]); + }); + + test("a checkout that fails keeps the document where it was", () => { + const { source, update } = base(); + const target = doc(2); + target.import(update); + target.detach(); + const before = observe(target); + // A detached import only touches history; the reattaching checkout applies + // it and fails. Rust panics here (context/movable-list-op-validation.md). + target.importJsonUpdates(forgedMove(source, 9)); + expect(() => target.attach()).toThrow(/out of range/u); + expect(target.isDetached()).toBe(true); + expect(target.toJSON()).toEqual(before.value); + expect(target.frontiers()).toEqual(before.frontiers); + }); + + test("a failed snapshot import leaves an empty document empty", () => { + const { source } = base(); + const snapshot = source.export({ mode: "snapshot" }); + const corrupted = snapshot.slice(0, snapshot.length - 8); + const target = new LoroDoc(); + const list = target.getMovableList("list"); + expect(() => target.import(corrupted)).toThrow(/checksum/u); + expect(target.toJSON()).toEqual({ list: [] }); + expect(target.changeCount()).toBe(0); + expect(list.length).toBe(0); + target.import(snapshot); + expect(target.toJSON()).toEqual(source.toJSON()); + }); + + test("a failed import into a snapshot-loaded document rolls back its completion", () => { + const { source, update } = base(); + source.getMovableList("list").push("d"); + source.commit(); + const hydrated = LoroDoc.fromSnapshot(source.export({ mode: "snapshot" })); + const before = observe(hydrated); + // Peer 2 has not seen "d", so its move is concurrent with the snapshot and + // importing it first rebuilds the list from its history + // (#prepareSnapshotImport). + const concurrent = doc(2); + concurrent.import(update); + concurrent.getMovableList("list").move(2, 0); + concurrent.commit(); + const concurrentUpdate = concurrent.export({ mode: "update" }); + const forged = new LoroDoc(); + forged.import(source.export({ mode: "update" })); + forged.detach(); + forged.importJsonUpdates(forgedMove(source, 9)); + const forgedUpdate = forged.export({ + mode: "updates-in-range", + spans: [{ id: { peer: "1", counter: 4 }, len: 1 }], + }); + + expect(() => hydrated.importBatch([concurrentUpdate, forgedUpdate])).toThrow( + /out of range/u, + ); + expect(observe(hydrated)).toEqual(before); + hydrated.import(concurrentUpdate); + const expected = doc(3); + expected.import(update); + expected.import(hydrated.export({ mode: "update" })); + expect(hydrated.toJSON()).toEqual(expected.toJSON()); + expect(hydrated.toJSON()).toEqual({ list: ["c", "a", "b", "d"] }); + assertUsable(hydrated); + }); + + test("undo is unaffected by a failed import", () => { + const { source, update } = base(); + const target = doc(2); + target.import(update); + const undo = new UndoManager(target, { mergeInterval: 0 }); + target.getMovableList("list").push("mine"); + target.commit(); + expect(() => target.importJsonUpdates(forgedMove(source, 9))).toThrow( + /out of range/u, + ); + expect(undo.undo()).toBe(true); + expect(target.getMovableList("list").toJSON()).toEqual(["a", "b", "c"]); + }); + + test("an import costs the same however many texts a snapshot hydrated", () => { + /** Best of three runs of 200 one-character imports into a document with n hydrated texts. */ + function importsMs(size: number): number { + const source = doc(1); + const items = source.getList("items"); + for (let index = 0; index < size; index += 1) { + const item = items.insertContainer(index, new LoroMap()); + item.setContainer("title", new LoroText()).insert(0, "t"); + } + source.commit(); + const target = new LoroDoc(); + // A subscriber hydrates every text, and each becomes a snapshot sequence. + target.subscribe(() => {}); + target.import(source.export({ mode: "snapshot" })); + const title = (items.get(0) as LoroMap).get("title") as LoroText; + const updates: Uint8Array[] = []; + for (let step = 0; step < 600; step += 1) { + const version = source.oplogVersion(); + title.insert(0, "x"); + source.commit(); + updates.push(source.export({ mode: "update", from: version })); + } + let best = Infinity; + for (let run = 0; run < 3; run += 1) { + const started = performance.now(); + for (const update of updates.slice(run * 200, run * 200 + 200)) { + target.import(update); + } + best = Math.min(best, performance.now() - started); + } + expect(target.toJSON()).toEqual(source.toJSON()); + return best; + } + // Each import copied the table of snapshot sequences to roll it back + // (16x the texts took about 10x the time). + const ratio = importsMs(8_000) / importsMs(500); + expect(ratio).toBeLessThan(4); + }, 60_000); +});