Skip to content

Commit 9e496c5

Browse files
authored
hub-client: drop redundant history() walk in attribution incremental path (#237)
updateRunListAttribution was iterating handle.history() to decide which changes to replay, even though getChanges(workDoc, doc) already returns exactly the new changes in causal order. The history walk added an O(N) topoHistoryTraversal call per debounce cycle (N = total changes in the doc) plus K decodeHeads + Map lookups, all to recompute information getChanges already provides. processedHeads / processedHistoryIndex remain in the state for the cold-start path's contract but are now pure bookkeeping in the incremental path. Adds an invariant test: incremental update must equal a from-scratch buildRunListAttribution on the merged final doc.
1 parent 8e46318 commit 9e496c5

2 files changed

Lines changed: 114 additions & 27 deletions

File tree

hub-client/src/services/attribution-runs.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,18 @@
1111
*/
1212

1313
import { describe, it, expect } from 'vitest';
14+
import { next as A } from '@automerge/automerge';
15+
import type { Doc } from '@automerge/automerge';
16+
import { encodeHeads } from '@automerge/automerge-repo';
17+
import type { DocHandle } from '@automerge/automerge-repo';
1418

1519
import {
1620
buildCharToByteMap,
21+
buildRunListAttribution,
1722
runsCharToByteOffsets,
23+
updateRunListAttribution,
1824
type AttributionRun,
25+
type ViewableHandle,
1926
} from './attribution-runs';
2027

2128
describe('buildCharToByteMap', () => {
@@ -75,3 +82,91 @@ describe('runsCharToByteOffsets', () => {
7582
expect(out).toEqual(runs);
7683
});
7784
});
85+
86+
// ---------------------------------------------------------------------------
87+
// Incremental ≡ from-scratch invariant
88+
// ---------------------------------------------------------------------------
89+
90+
// Anchor for the incremental path: whatever shortcut `updateRunListAttribution`
91+
// uses to skip work, the final run list must agree character-for-character with
92+
// what `buildRunListAttribution` produces from `init()` on the same final doc.
93+
// If a future refactor breaks this — including via something subtle at the
94+
// Automerge boundary (history-traversal ordering, getChanges semantics, etc.)
95+
// — this is the test that should catch it.
96+
97+
interface TDoc { text: string }
98+
99+
function fakeHandle(doc: Doc<TDoc>): DocHandle<unknown> {
100+
const view: ViewableHandle = {
101+
history: () => A.topoHistoryTraversal(doc).map(h => encodeHeads([h])),
102+
metadata: () => undefined,
103+
doc: () => doc,
104+
};
105+
return view as unknown as DocHandle<unknown>;
106+
}
107+
108+
describe('updateRunListAttribution invariant', () => {
109+
it('matches a from-scratch rebuild after a concurrent merge', async () => {
110+
const aliceActor = 'f'.repeat(32);
111+
const bobActor = '0'.repeat(32);
112+
113+
let alice = A.from<TDoc>({ text: '' }, { actor: aliceActor });
114+
let bob = A.load<TDoc>(A.save(alice), { actor: bobActor });
115+
116+
alice = A.change(alice, d => A.splice(d, ['text'], 0, 0, 'Hello'));
117+
alice = A.change(alice, d => A.splice(d, ['text'], 5, 0, ' World'));
118+
alice = A.change(alice, d => A.splice(d, ['text'], 11, 0, '!'));
119+
120+
const stateBefore = await buildRunListAttribution(fakeHandle(alice), 'text');
121+
expect(stateBefore).toBeTruthy();
122+
123+
bob = A.change(bob, d => A.splice(d, ['text'], 0, 0, 'X'));
124+
bob = A.change(bob, d => A.splice(d, ['text'], 1, 0, 'Y'));
125+
bob = A.change(bob, d => A.splice(d, ['text'], 2, 0, 'Z'));
126+
alice = A.merge(alice, bob);
127+
128+
const incremental = updateRunListAttribution(stateBefore!, fakeHandle(alice), 'text');
129+
const fromScratch = await buildRunListAttribution(fakeHandle(alice), 'text');
130+
131+
expect(fromScratch).toBeTruthy();
132+
expect(incremental.runs).toEqual(fromScratch!.runs);
133+
});
134+
135+
it('matches a from-scratch rebuild across interleaved local and merged remote edits', async () => {
136+
const aliceActor = 'f'.repeat(32);
137+
const bobActor = '0'.repeat(32);
138+
139+
let alice = A.from<TDoc>({ text: '' }, { actor: aliceActor });
140+
let bob = A.load<TDoc>(A.save(alice), { actor: bobActor });
141+
142+
alice = A.change(alice, d => A.splice(d, ['text'], 0, 0, 'A1'));
143+
const stateBefore = await buildRunListAttribution(fakeHandle(alice), 'text');
144+
expect(stateBefore).toBeTruthy();
145+
146+
bob = A.change(bob, d => A.splice(d, ['text'], 0, 0, 'B1'));
147+
bob = A.change(bob, d => A.splice(d, ['text'], 2, 0, 'B2'));
148+
alice = A.merge(alice, bob);
149+
alice = A.change(alice, d => A.splice(d, ['text'], 0, 0, 'A2'));
150+
alice = A.change(alice, d => A.splice(d, ['text'], 0, 0, 'A3'));
151+
152+
const incremental = updateRunListAttribution(stateBefore!, fakeHandle(alice), 'text');
153+
const fromScratch = await buildRunListAttribution(fakeHandle(alice), 'text');
154+
155+
expect(fromScratch).toBeTruthy();
156+
expect(incremental.runs).toEqual(fromScratch!.runs);
157+
});
158+
159+
it('returns state unchanged when no new changes are present', async () => {
160+
const aliceActor = 'f'.repeat(32);
161+
let alice = A.from<TDoc>({ text: '' }, { actor: aliceActor });
162+
alice = A.change(alice, d => A.splice(d, ['text'], 0, 0, 'hello'));
163+
164+
const stateBefore = await buildRunListAttribution(fakeHandle(alice), 'text');
165+
expect(stateBefore).toBeTruthy();
166+
167+
// No edits between build and update — the incremental path should
168+
// short-circuit and return the same runs.
169+
const incremental = updateRunListAttribution(stateBefore!, fakeHandle(alice), 'text');
170+
expect(incremental.runs).toEqual(stateBefore!.runs);
171+
});
172+
});

hub-client/src/services/attribution-runs.ts

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
decodeChange,
2727
getAllChanges,
2828
getChanges,
29+
getHeads,
2930
init,
3031
} from '@automerge/automerge';
3132
import type { Change, Doc, Patch } from '@automerge/automerge';
@@ -350,44 +351,35 @@ export function updateRunListAttribution(
350351
textFieldName: string,
351352
): RunListAttribution {
352353
const viewable = handle as unknown as ViewableHandle;
353-
const history = viewable.history();
354-
if (!history) throw new HistoryCompactedError();
355-
if (state.processedHistoryIndex > history.length) throw new HistoryCompactedError();
356354
if (!state._workDoc) throw new HistoryCompactedError();
357355

358-
if (state.processedHistoryIndex === history.length) {
359-
return state;
360-
}
361-
362-
// Pull just the new changes (since workDoc's heads), index by hash.
363356
const doc = viewable.doc() as Doc<unknown>;
364-
const newChanges = getChanges(state._workDoc, doc);
365-
const changeByHash = new Map<string, Change>();
366-
for (const c of newChanges) {
367-
changeByHash.set(decodeChange(c).hash, c);
357+
let newChanges: Change[];
358+
try {
359+
newChanges = getChanges(state._workDoc, doc);
360+
} catch {
361+
// `getChanges` throws if `oldState` has changes not in `newState`.
362+
// Production state machine never hits that — `_workDoc` only ever
363+
// advances by applying changes from `doc` — but guard against
364+
// hand-constructed state in tests and surface as a cold-rebuild signal.
365+
throw new HistoryCompactedError();
368366
}
367+
if (newChanges.length === 0) return state;
369368

370369
const runs = state.runs.map(r => ({ ...r }));
371-
let prevHeads = decodeHeads(state.processedHeads as Parameters<typeof decodeHeads>[0]);
372-
let lastHeads: unknown[] = state.processedHeads;
373370
let workDoc: Doc<unknown> = state._workDoc;
374-
375-
for (let i = state.processedHistoryIndex; i < history.length; i++) {
376-
const currHeads = history[i];
377-
const decodedCurr = decodeHeads(currHeads as Parameters<typeof decodeHeads>[0]);
378-
const newHash = newChangeHashAt(prevHeads, decodedCurr);
379-
const change = newHash ? changeByHash.get(newHash) : undefined;
380-
if (change) {
381-
workDoc = replayChange(workDoc, change, textFieldName, runs);
382-
}
383-
prevHeads = decodedCurr;
384-
lastHeads = Array.isArray(currHeads) ? currHeads : [currHeads];
371+
for (const change of newChanges) {
372+
workDoc = replayChange(workDoc, change, textFieldName, runs);
385373
}
386374

375+
// `processedHeads` / `processedHistoryIndex` are bookkeeping in the
376+
// incremental path — neither is read here. Updating them keeps the state
377+
// truthful for the cold-start path and for any future consumer.
378+
const history = viewable.history();
387379
return {
388380
runs,
389-
processedHeads: lastHeads as unknown[],
390-
processedHistoryIndex: history.length,
381+
processedHeads: getHeads(workDoc),
382+
processedHistoryIndex: history?.length ?? state.processedHistoryIndex,
391383
_workDoc: workDoc,
392384
};
393385
}

0 commit comments

Comments
 (0)