Skip to content

Commit c4fd87f

Browse files
zxch3nclaude
andauthored
fix(loro.js): root shallow snapshots at the latest critical version (#1144)
loro.js rooted a shallow snapshot at the requested version, or at the meet of its heads. A branch merged after that version but forked below it is concurrent with it, so a retained op straddled the root. Checking out retained versions and returning could then reorder concurrent Text/List elements, and the root differed from a Rust export. Port Rust's latest_single_head_critical_version (dag.rs) and use it with the latest frontiers, as calc_shallow_doc_start does. The StyleStart nudge and the clamp to an existing shallow root are unchanged. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 238988f commit c4fd87f

3 files changed

Lines changed: 324 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"loro.js": patch
3+
---
4+
5+
`loro.js` shallow snapshots now pick the same root as `loro-crdt`: the latest single-head critical version of the requested frontiers and the latest version (loro-dev/loro#1095). Before, `loro.js` used the requested version (or the meet of its heads) even when a retained op was concurrent with it. Checking out retained versions of such a snapshot and returning to the latest version could reorder concurrent Text/List elements, and the snapshot disagreed with a Rust export of the same document.

‎loro-js/src/runtime/document.ts‎

Lines changed: 133 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7713,18 +7713,15 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
77137713
}
77147714
}
77157715

7716-
let start = parsed;
7717-
if (start.length > 1) {
7718-
const versions = start.map((frontier) => this.#causalVersionAt([frontier]));
7719-
const peers = new Set(versions.flatMap((version) => [...version.keys()]));
7720-
const common = new VersionVector();
7721-
for (const peer of peers) {
7722-
const counter = Math.min(...versions.map((version) => version.get(peer) ?? 0));
7723-
if (counter > 0) common.set(peer, counter);
7724-
}
7725-
const commonFrontiers = this.#frontiersForVersion(common);
7726-
start = commonFrontiers.length === 1 ? commonFrontiers : [];
7727-
}
7716+
// Every retained op must be causally before or after the root, so a
7717+
// branch merged after `requested` that forked below it moves the root
7718+
// down (loro-dev/loro#1095).
7719+
let start =
7720+
parsed.length === 0
7721+
? []
7722+
: this.#latestSingleHeadCriticalVersion(parsed, [
7723+
...this.#historyFrontiers.values(),
7724+
]);
77287725

77297726
if (start.length === 1) {
77307727
const operation = this.#operationAt(start[0]!);
@@ -7743,6 +7740,130 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
77437740
return start;
77447741
}
77457742

7743+
/**
7744+
* Port of Rust's `latest_single_head_critical_version` (`dag.rs`): the
7745+
* newest op `v` such that every other op in the union of both histories is
7746+
* an ancestor of `v` or causally after it, or the empty version when no
7747+
* such op exists. The heap is always a cut of the unexplored region, so the
7748+
* first time it narrows to one span, that span's last op is the answer.
7749+
*/
7750+
#latestSingleHeadCriticalVersion(
7751+
left: readonly CodecId[],
7752+
right: readonly CodecId[],
7753+
): CodecId[] {
7754+
interface Span {
7755+
readonly peer: bigint;
7756+
readonly counter: number;
7757+
readonly lamport: number;
7758+
len: number;
7759+
readonly deps: readonly CodecId[];
7760+
}
7761+
const spanOf = (id: CodecId): Span | undefined => {
7762+
const record = this.#recordContaining(id);
7763+
if (record === undefined) return undefined;
7764+
const change = record.change;
7765+
return {
7766+
peer: change.id.peer,
7767+
counter: change.id.counter,
7768+
lamport: change.lamport,
7769+
len: id.counter - change.id.counter + 1,
7770+
deps: change.dependencies,
7771+
};
7772+
};
7773+
const lastCounter = (span: Span): number => span.counter + span.len - 1;
7774+
const lastLamport = (span: Span): number => span.lamport + span.len - 1;
7775+
const contains = (span: Span, peer: bigint, counter: number): boolean =>
7776+
span.peer === peer && span.counter <= counter && counter <= lastCounter(span);
7777+
// Max-heap order: last lamport, then peer; a shorter span with the same
7778+
// last op is greater.
7779+
const greater = (a: Span, b: Span): boolean =>
7780+
lastLamport(a) !== lastLamport(b)
7781+
? lastLamport(a) > lastLamport(b)
7782+
: a.peer !== b.peer
7783+
? a.peer > b.peer
7784+
: a.len < b.len;
7785+
const heap: Span[] = [];
7786+
const push = (span: Span): void => {
7787+
heap.push(span);
7788+
for (let index = heap.length - 1; index > 0; ) {
7789+
const parent = (index - 1) >> 1;
7790+
if (!greater(heap[index]!, heap[parent]!)) break;
7791+
[heap[index], heap[parent]] = [heap[parent]!, heap[index]!];
7792+
index = parent;
7793+
}
7794+
};
7795+
const pop = (): Span => {
7796+
const top = heap[0]!;
7797+
const last = heap.pop()!;
7798+
if (heap.length > 0) {
7799+
heap[0] = last;
7800+
for (let index = 0; ; ) {
7801+
const leftChild = index * 2 + 1;
7802+
const rightChild = leftChild + 1;
7803+
let largest = index;
7804+
if (leftChild < heap.length && greater(heap[leftChild]!, heap[largest]!)) {
7805+
largest = leftChild;
7806+
}
7807+
if (rightChild < heap.length && greater(heap[rightChild]!, heap[largest]!)) {
7808+
largest = rightChild;
7809+
}
7810+
if (largest === index) break;
7811+
[heap[index], heap[largest]] = [heap[largest]!, heap[index]!];
7812+
index = largest;
7813+
}
7814+
}
7815+
return top;
7816+
};
7817+
for (const id of [...left, ...right]) {
7818+
const span = spanOf(id);
7819+
if (span === undefined) return [];
7820+
push(span);
7821+
}
7822+
while (heap.length > 0) {
7823+
const node = pop();
7824+
while (
7825+
heap.length > 0 &&
7826+
heap[0]!.peer === node.peer &&
7827+
lastCounter(heap[0]!) === lastCounter(node)
7828+
) {
7829+
pop();
7830+
}
7831+
if (heap.length === 0) return [{ peer: node.peer, counter: lastCounter(node) }];
7832+
const other = heap[0]!;
7833+
if (contains(node, other.peer, lastCounter(other))) {
7834+
node.len = lastCounter(other) - node.counter + 1;
7835+
push(node);
7836+
continue;
7837+
}
7838+
if (node.len > 1) {
7839+
node.len =
7840+
lastLamport(other) >= node.lamport
7841+
? Math.min(lastLamport(other) - node.lamport + 1, node.len - 1)
7842+
: 1;
7843+
push(node);
7844+
continue;
7845+
}
7846+
const deps: Span[] = [];
7847+
for (const dependency of node.deps) {
7848+
const span = spanOf(dependency);
7849+
if (span === undefined) return [];
7850+
deps.push(span);
7851+
}
7852+
if (node.counter > 0) {
7853+
const previous = spanOf({ peer: node.peer, counter: node.counter - 1 });
7854+
if (
7855+
previous !== undefined &&
7856+
!deps.some((dep) => contains(dep, previous.peer, lastCounter(previous)))
7857+
) {
7858+
deps.push(previous);
7859+
}
7860+
}
7861+
if (deps.length === 0) return [];
7862+
for (const dep of deps) push(dep);
7863+
}
7864+
return [];
7865+
}
7866+
77467867
#causalVersionForKnownFrontiers(frontiers: readonly CodecId[]): VersionVector {
77477868
const version = new VersionVector();
77487869
for (const [peer, counter] of this.#causalVersionAt(frontiers)) {
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { describe, expect, test } from "vitest";
2+
3+
import { LoroDoc } from "../src/index";
4+
import type { Frontiers } from "../src/index";
5+
6+
// Ports of crates/loro/tests/shallow_root_critical.rs. A shallow snapshot keeps
7+
// every op from its root to the latest version, so the root must be a critical
8+
// version: every retained op is causally after it, never concurrent with it
9+
// (loro-dev/loro#1095). The roots below are the ones loro-crdt picks.
10+
11+
function shallowOf(doc: LoroDoc, frontiers: Frontiers): LoroDoc {
12+
const shallow = new LoroDoc();
13+
shallow.import(doc.export({ mode: "shallow-snapshot", frontiers }));
14+
return shallow;
15+
}
16+
17+
/** Every op of `doc` is in the root's causal past or has the root in its own. */
18+
function expectCritical(doc: LoroDoc, root: Frontiers): void {
19+
if (root.length === 0) return;
20+
const rootVersion = doc.frontiersToVV(root);
21+
for (const [peer, end] of doc.oplogVersion().toJSON()) {
22+
for (let counter = rootVersion.get(peer) ?? 0; counter < end; counter += 1) {
23+
const opVersion = doc.frontiersToVV([{ peer, counter }]);
24+
const order = rootVersion.compare(opVersion);
25+
if (order !== -1 && order !== 0) {
26+
throw new Error(`${counter}@${peer} is concurrent with the root`);
27+
}
28+
}
29+
}
30+
}
31+
32+
describe("shallow snapshot root", () => {
33+
test("keeps the full history for independent heads", () => {
34+
for (let peers = 2; peers <= 4; peers += 1) {
35+
const doc = new LoroDoc();
36+
doc.setPeerId(1);
37+
doc.getText("t").insert(0, "a");
38+
doc.commit();
39+
for (let peer = 2; peer <= peers; peer += 1) {
40+
const other = new LoroDoc();
41+
other.setPeerId(peer);
42+
other.getText("t").insert(0, "b");
43+
other.commit();
44+
doc.import(other.export({ mode: "update" }));
45+
}
46+
const shallow = shallowOf(doc, doc.oplogFrontiers());
47+
expect(shallow.shallowSinceFrontiers()).toEqual([]);
48+
expect(shallow.toJSON()).toEqual(doc.toJSON());
49+
}
50+
});
51+
52+
test("still trims to a root shared by every head", () => {
53+
const base = new LoroDoc();
54+
base.setPeerId(100);
55+
base.getText("t").insert(0, "root");
56+
base.commit();
57+
const snapshot = base.export({ mode: "snapshot" });
58+
for (let peer = 1; peer <= 5; peer += 1) {
59+
const fork = new LoroDoc();
60+
fork.setPeerId(peer);
61+
fork.import(snapshot);
62+
fork.getText("t").insert(0, "x");
63+
fork.commit();
64+
base.import(fork.export({ mode: "update" }));
65+
}
66+
const shallow = shallowOf(base, base.oplogFrontiers());
67+
expect(shallow.shallowSinceFrontiers()).toEqual([{ peer: "100", counter: 3 }]);
68+
expect(shallow.toJSON()).toEqual(base.toJSON());
69+
});
70+
71+
test("moves below a past version when a later branch forked below it", () => {
72+
const doc = new LoroDoc();
73+
doc.setPeerId(1);
74+
doc.getText("t").insert(0, "0");
75+
doc.commit();
76+
const atA0 = doc.export({ mode: "snapshot" });
77+
doc.getText("t").insert(0, "1");
78+
doc.commit();
79+
doc.getText("t").insert(0, "2");
80+
doc.commit();
81+
const target = doc.oplogFrontiers();
82+
const fork = new LoroDoc();
83+
fork.setPeerId(2);
84+
fork.import(atA0);
85+
fork.getText("t").insert(1, "B");
86+
fork.commit();
87+
doc.import(fork.export({ mode: "update" }));
88+
89+
const shallow = shallowOf(doc, target);
90+
// The requested version 2@1 is concurrent with 0@2; loro-crdt uses 0@1.
91+
expect(shallow.shallowSinceFrontiers()).toEqual([{ peer: "1", counter: 0 }]);
92+
expectCritical(doc, shallow.shallowSinceFrontiers());
93+
shallow.checkout(target);
94+
expect(shallow.getText("t").toString()).toBe("210");
95+
shallow.checkoutToLatest();
96+
expect(shallow.getText("t").toString()).toBe("210B");
97+
});
98+
99+
test("keeps concurrent list order through checkouts of the retained range", () => {
100+
const p2 = new LoroDoc();
101+
p2.setPeerId(2);
102+
const p4 = new LoroDoc();
103+
p4.setPeerId(4);
104+
p4.getList("l").insert(0, false);
105+
p4.commit();
106+
p2.getList("l").insert(0, 45);
107+
p2.commit();
108+
p2.getMap("m").set("k", 1);
109+
p2.commit();
110+
p2.getMap("m").set("k", 2);
111+
p2.commit();
112+
p2.import(p4.export({ mode: "update" }));
113+
expect(p2.getList("l").toArray()).toEqual([45, false]);
114+
115+
// 4@0 is concurrent with 2@1, so the root cannot be 2@1.
116+
const shallow = shallowOf(p2, [{ peer: "2", counter: 1 }]);
117+
expect(shallow.shallowSinceFrontiers()).toEqual([]);
118+
for (const frontiers of [
119+
[
120+
{ peer: "4", counter: 0 },
121+
{ peer: "2", counter: 2 },
122+
],
123+
[{ peer: "2", counter: 2 }],
124+
[{ peer: "2", counter: 1 }],
125+
] as Frontiers[]) {
126+
shallow.checkout(frontiers);
127+
const full = new LoroDoc();
128+
full.import(p2.export({ mode: "update" }));
129+
full.checkout(frontiers);
130+
expect(shallow.toJSON()).toEqual(full.toJSON());
131+
}
132+
shallow.checkoutToLatest();
133+
// Before the fix the retained concurrent insert came back as [false, 45].
134+
expect(shallow.getList("l").toArray()).toEqual([45, false]);
135+
});
136+
137+
test("is critical on random multi-peer histories", () => {
138+
let random = 0x2c1b_3c6d;
139+
const next = (limit: number): number => {
140+
random ^= random << 13;
141+
random ^= random >>> 17;
142+
random ^= random << 5;
143+
return (random >>> 0) % limit;
144+
};
145+
for (let round = 0; round < 40; round += 1) {
146+
const docs = [1, 2, 3].map((peer) => {
147+
const doc = new LoroDoc();
148+
doc.setPeerId(peer);
149+
return doc;
150+
});
151+
const targets: Frontiers[] = [];
152+
for (let step = 0; step < 24; step += 1) {
153+
const doc = docs[next(docs.length)]!;
154+
if (next(4) === 0) {
155+
const other = docs[next(docs.length)]!;
156+
if (other !== doc) doc.import(other.export({ mode: "update" }));
157+
} else {
158+
const text = doc.getText("t");
159+
text.insert(next(text.length + 1), String.fromCharCode(97 + next(26)));
160+
doc.commit();
161+
if (next(3) === 0) targets.push(doc.oplogFrontiers());
162+
}
163+
}
164+
const doc = docs[0]!;
165+
for (const other of docs.slice(1)) doc.import(other.export({ mode: "update" }));
166+
for (const target of targets) {
167+
const shallow = shallowOf(doc, target);
168+
const root = shallow.shallowSinceFrontiers();
169+
expectCritical(doc, root);
170+
expect(shallow.toJSON()).toEqual(doc.toJSON());
171+
const rootVersion = doc.frontiersToVV(root);
172+
for (const version of targets) {
173+
const order = rootVersion.compare(doc.frontiersToVV(version));
174+
if (order !== -1 && order !== 0) continue;
175+
const full = new LoroDoc();
176+
full.import(doc.export({ mode: "update" }));
177+
full.checkout(version);
178+
shallow.checkout(version);
179+
expect(shallow.toJSON()).toEqual(full.toJSON());
180+
}
181+
shallow.checkoutToLatest();
182+
expect(shallow.toJSON()).toEqual(doc.toJSON());
183+
}
184+
}
185+
});
186+
});

0 commit comments

Comments
 (0)