Skip to content

Commit 9c46f0c

Browse files
authored
Merge pull request #721 from streamich/patch-ops
Patch compaction
2 parents b361694 + 35b4a70 commit 9c46f0c

File tree

4 files changed

+293
-2
lines changed

4 files changed

+293
-2
lines changed

src/json-crdt-patch/Patch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export class Patch implements Printable {
6262
/**
6363
* A list of operations in the patch.
6464
*/
65-
public readonly ops: JsonCrdtPatchOperation[] = [];
65+
public ops: JsonCrdtPatchOperation[] = [];
6666

6767
/**
6868
* Arbitrary metadata associated with the patch, which is not used by the
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import {equal, LogicalClock, tick, ts} from '../clock';
2+
import {combine, compact} from '../compaction';
3+
import {InsStrOp, NopOp} from '../operations';
4+
import {Patch} from '../Patch';
5+
import {PatchBuilder} from '../PatchBuilder';
6+
7+
describe('.combine()', () => {
8+
test('can combine two adjacent patches', () => {
9+
const builder = new PatchBuilder(new LogicalClock(123456789, 1));
10+
const objId = builder.json({
11+
str: 'hello',
12+
num: 123,
13+
tags: ['a', 'b', 'c'],
14+
});
15+
builder.root(objId);
16+
const patch = builder.flush();
17+
const str1 = patch + '';
18+
for (let i = 1; i < patch.ops.length - 2; i++) {
19+
const ops1 = patch.ops.slice(0, i);
20+
const ops2 = patch.ops.slice(i);
21+
const patch1 = new Patch();
22+
patch1.ops = patch1.ops.concat(ops1);
23+
const patch2 = new Patch();
24+
patch2.ops = patch2.ops.concat(ops2);
25+
combine([patch1, patch2]);
26+
const str2 = patch1 + '';
27+
expect(str2).toBe(str1);
28+
}
29+
});
30+
31+
test('can combine three adjacent patches', () => {
32+
const builder = new PatchBuilder(new LogicalClock(123456789, 1));
33+
const objId = builder.json({
34+
str: 'hello',
35+
num: 123,
36+
tags: ['a', 'b', 'c'],
37+
});
38+
builder.root(objId);
39+
const patch = builder.flush();
40+
const str1 = patch + '';
41+
const ops1 = patch.ops.slice(0, 2);
42+
const ops2 = patch.ops.slice(2, 4);
43+
const ops3 = patch.ops.slice(4);
44+
const patch1 = new Patch();
45+
patch1.ops = patch1.ops.concat(ops1);
46+
const patch2 = new Patch();
47+
patch2.ops = patch2.ops.concat(ops2);
48+
const patch3 = new Patch();
49+
patch3.ops = patch3.ops.concat(ops3);
50+
combine([patch1, patch2, patch3]);
51+
const str2 = patch1 + '';
52+
expect(str2).toBe(str1);
53+
});
54+
55+
test('can combine two patches with gap', () => {
56+
const builder1 = new PatchBuilder(new LogicalClock(123456789, 1));
57+
const builder2 = new PatchBuilder(new LogicalClock(123456789, 100));
58+
builder1.str();
59+
builder2.const(123);
60+
const patch1 = builder1.flush();
61+
const patch2 = builder2.flush();
62+
combine([patch1, patch2]);
63+
expect(patch1.ops.length).toBe(3);
64+
expect(patch1.ops[1]).toBeInstanceOf(NopOp);
65+
const nop = patch1.ops[1] as NopOp;
66+
expect(nop.id.sid).toBe(123456789);
67+
expect(nop.id.time).toBe(2);
68+
expect(nop.len).toBe(98);
69+
});
70+
71+
test('can combine four patches with gap', () => {
72+
const builder1 = new PatchBuilder(new LogicalClock(123456789, 1));
73+
const builder2 = new PatchBuilder(new LogicalClock(123456789, 100));
74+
const builder3 = new PatchBuilder(new LogicalClock(123456789, 110));
75+
const builder4 = new PatchBuilder(new LogicalClock(123456789, 220));
76+
builder1.str();
77+
builder2.const(123);
78+
builder3.obj();
79+
builder4.bin();
80+
const patch1 = builder1.flush();
81+
const patch2 = builder2.flush();
82+
const patch3 = builder3.flush();
83+
const patch4 = builder4.flush();
84+
combine([patch1, patch2, patch3, patch4]);
85+
expect(patch1.ops.length).toBe(7);
86+
expect(patch1.ops[1]).toBeInstanceOf(NopOp);
87+
expect(patch1.ops[3]).toBeInstanceOf(NopOp);
88+
expect(patch1.ops[5]).toBeInstanceOf(NopOp);
89+
});
90+
91+
test('throws on mismatching sessions', () => {
92+
const builder1 = new PatchBuilder(new LogicalClock(1111111, 1));
93+
const builder2 = new PatchBuilder(new LogicalClock(2222222, 100));
94+
builder1.str();
95+
builder2.const(123);
96+
const patch1 = builder1.flush();
97+
const patch2 = builder2.flush();
98+
expect(() => combine([patch1, patch2])).toThrow(new Error('SID_MISMATCH'));
99+
});
100+
101+
test('first patch can be empty', () => {
102+
const builder2 = new PatchBuilder(new LogicalClock(2222222, 100));
103+
builder2.const(123);
104+
const patch1 = new Patch();
105+
const patch2 = builder2.flush();
106+
combine([patch1, patch2]);
107+
expect(patch1 + '').toBe(patch2 + '');
108+
});
109+
110+
test('second patch can be empty', () => {
111+
const builder2 = new PatchBuilder(new LogicalClock(2222222, 100));
112+
builder2.const(123);
113+
const patch1 = new Patch();
114+
const patch2 = builder2.flush();
115+
const str1 = patch2 + '';
116+
combine([patch2, patch1]);
117+
const str2 = patch2 + '';
118+
expect(str2).toBe(str1);
119+
expect(patch1.getId()).toBe(undefined);
120+
});
121+
122+
test('throws if first patch has higher logical time', () => {
123+
const builder1 = new PatchBuilder(new LogicalClock(123456789, 1));
124+
const builder2 = new PatchBuilder(new LogicalClock(123456789, 100));
125+
builder1.str();
126+
builder2.const(123);
127+
const patch1 = builder1.flush();
128+
const patch2 = builder2.flush();
129+
expect(() => combine([patch2, patch1])).toThrow(new Error('TIMESTAMP_CONFLICT'));
130+
combine([patch1, patch2]);
131+
});
132+
});
133+
134+
describe('.compact()', () => {
135+
test('can combine two consecutive string inserts', () => {
136+
const builder = new PatchBuilder(new LogicalClock(123456789, 1));
137+
const strId = builder.str();
138+
const ins1Id = builder.insStr(strId, strId, 'hello');
139+
builder.insStr(strId, tick(ins1Id, 'hello'.length - 1), ' world');
140+
builder.root(strId);
141+
const patch = builder.flush();
142+
const patch2 = patch.clone();
143+
compact(patch);
144+
expect(equal(patch.ops[0].id, patch2.ops[0].id)).toBe(true);
145+
expect(equal(patch.ops[1].id, patch2.ops[1].id)).toBe(true);
146+
expect(equal(patch.ops[2].id, patch2.ops[3].id)).toBe(true);
147+
expect((patch.ops[1] as any).data).toBe('hello world');
148+
});
149+
150+
test('can combine two consecutive string inserts - 2', () => {
151+
const builder = new PatchBuilder(new LogicalClock(123456789, 1));
152+
const strId = builder.str();
153+
const ins1Id = builder.insStr(strId, strId, 'a');
154+
builder.insStr(strId, tick(ins1Id, 'a'.length - 1), 'b');
155+
builder.root(strId);
156+
const patch = builder.flush();
157+
const patch2 = patch.clone();
158+
compact(patch);
159+
expect(equal(patch.ops[0].id, patch2.ops[0].id)).toBe(true);
160+
expect(equal(patch.ops[1].id, patch2.ops[1].id)).toBe(true);
161+
expect(equal(patch.ops[2].id, patch2.ops[3].id)).toBe(true);
162+
expect((patch.ops[1] as any).data).toBe('ab');
163+
});
164+
165+
test('can combine two consecutive string inserts - 3', () => {
166+
const patch = new Patch();
167+
patch.ops.push(new InsStrOp(ts(123, 30), ts(123, 10), ts(123, 20), 'a'));
168+
patch.ops.push(new InsStrOp(ts(123, 31), ts(123, 10), ts(123, 30), 'b'));
169+
compact(patch);
170+
expect(patch.ops.length).toBe(1);
171+
expect((patch.ops[0] as InsStrOp).data).toBe('ab');
172+
});
173+
174+
test('does not combine inserts, if they happen into different strings', () => {
175+
const patch = new Patch();
176+
patch.ops.push(new InsStrOp(ts(123, 30), ts(123, 10), ts(123, 20), 'a'));
177+
patch.ops.push(new InsStrOp(ts(123, 31), ts(123, 99), ts(123, 30), 'b'));
178+
compact(patch);
179+
expect(patch.ops.length).toBe(2);
180+
});
181+
182+
test('does not combine inserts, if time is not consecutive', () => {
183+
const patch = new Patch();
184+
patch.ops.push(new InsStrOp(ts(123, 30), ts(123, 10), ts(123, 20), 'a'));
185+
patch.ops.push(new InsStrOp(ts(123, 99), ts(123, 10), ts(123, 30), 'b'));
186+
compact(patch);
187+
expect(patch.ops.length).toBe(2);
188+
});
189+
190+
test('does not combine inserts, if the second operation is not an append', () => {
191+
const patch = new Patch();
192+
patch.ops.push(new InsStrOp(ts(123, 30), ts(123, 10), ts(123, 20), 'a'));
193+
patch.ops.push(new InsStrOp(ts(123, 31), ts(123, 10), ts(123, 22), 'b'));
194+
compact(patch);
195+
expect(patch.ops.length).toBe(2);
196+
});
197+
198+
test('does not combine inserts, if the second operation is not an append - 2', () => {
199+
const patch = new Patch();
200+
patch.ops.push(new InsStrOp(ts(123, 30), ts(123, 10), ts(123, 20), 'a'));
201+
patch.ops.push(new InsStrOp(ts(123, 31), ts(123, 10), ts(999, 30), 'b'));
202+
compact(patch);
203+
expect(patch.ops.length).toBe(2);
204+
});
205+
206+
test('returns a patch as-is', () => {
207+
const builder = new PatchBuilder(new LogicalClock(123456789, 1));
208+
builder.root(builder.json({str: 'hello'}));
209+
const patch = builder.flush();
210+
const str1 = patch + '';
211+
compact(patch);
212+
const str2 = patch + '';
213+
expect(str2).toBe(str1);
214+
});
215+
});

src/json-crdt-patch/compaction.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* @description Operations for combining patches together, combining operations
3+
* together, and cleaning up operations.
4+
*/
5+
6+
import {equal, Timestamp} from './clock';
7+
import {InsStrOp, NopOp} from './operations';
8+
import type {JsonCrdtPatchOperation, Patch} from './Patch';
9+
10+
/**
11+
* Combines two or more patches together. The first patch is modified in place.
12+
* Operations from the second patch are appended to the first patch as is
13+
* (without cloning).
14+
*
15+
* The patches must have the same `sid`. The first patch must have lower logical
16+
* time than the second patch, and the logical times must not overlap.
17+
*
18+
* @param patches The patches to combine.
19+
*/
20+
export const combine = (patches: Patch[]): void => {
21+
const firstPatch = patches[0];
22+
const firstPatchId = firstPatch.getId();
23+
const patchesLength = patches.length;
24+
for (let i = 1; i < patchesLength; i++) {
25+
const currentPatch = patches[i];
26+
const currentPatchId = currentPatch.getId();
27+
if (!firstPatchId) {
28+
if (!currentPatchId) return;
29+
firstPatch.ops = firstPatch.ops.concat(currentPatch.ops);
30+
return;
31+
}
32+
if (!currentPatchId) return;
33+
if (!firstPatchId || !currentPatchId) throw new Error('EMPTY_PATCH');
34+
const sidA = firstPatchId.sid;
35+
if (sidA !== currentPatchId.sid) throw new Error('SID_MISMATCH');
36+
const timeA = firstPatchId.time;
37+
const nextTick = timeA + firstPatch.span();
38+
const timeB = currentPatchId.time;
39+
const timeDiff = timeB - nextTick;
40+
if (timeDiff < 0) throw new Error('TIMESTAMP_CONFLICT');
41+
const needsNoop = timeDiff > 0;
42+
if (needsNoop) firstPatch.ops.push(new NopOp(new Timestamp(sidA, nextTick), timeDiff));
43+
firstPatch.ops = firstPatch.ops.concat(currentPatch.ops);
44+
}
45+
};
46+
47+
/**
48+
* Compacts operations within a patch. Combines consecutive string inserts.
49+
* Mutates the operations in place. (Use `patch.clone()` to avoid mutating the
50+
* original patch.)
51+
*
52+
* @param patch The patch to compact.
53+
*/
54+
export const compact = (patch: Patch): void => {
55+
const ops = patch.ops;
56+
const length = ops.length;
57+
let lastOp: JsonCrdtPatchOperation = ops[0];
58+
const newOps: JsonCrdtPatchOperation[] = [lastOp];
59+
for (let i = 1; i < length; i++) {
60+
const op = ops[i];
61+
if (lastOp instanceof InsStrOp && op instanceof InsStrOp) {
62+
const lastOpNextTick = lastOp.id.time + lastOp.span();
63+
const isTimeConsecutive = lastOpNextTick === op.id.time;
64+
const isInsertIntoSameString = equal(lastOp.obj, op.obj);
65+
const opRef = op.ref;
66+
const isAppend = lastOpNextTick === opRef.time + 1 && lastOp.ref.sid === opRef.sid;
67+
if (isTimeConsecutive && isInsertIntoSameString && isAppend) {
68+
lastOp.data = lastOp.data + op.data;
69+
continue;
70+
}
71+
}
72+
newOps.push(op);
73+
lastOp = op;
74+
}
75+
patch.ops = newOps;
76+
};

src/json-crdt-patch/operations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ export class InsStrOp implements IJsonCrdtPatchEditOperation {
262262
public readonly id: ITimestampStruct,
263263
public readonly obj: ITimestampStruct,
264264
public readonly ref: ITimestampStruct,
265-
public readonly data: string,
265+
public data: string,
266266
) {}
267267

268268
public span(): number {

0 commit comments

Comments
 (0)