From 8b6ae9788f1b33c52930aae885319a4841eb84b1 Mon Sep 17 00:00:00 2001 From: JiHwan Yim Date: Mon, 26 Feb 2024 14:42:03 +0900 Subject: [PATCH] Fix errors when editing Tree due to missing insPrevID in CRDTTree (#756) When performing `CRDTTree.edit()`, the edits are reflected by deepcopying the CRDTTreeNodes for the given range. This commit adds information about `insPrevID` and `insNextID` during the `deepcopy()` process to ensure that the correct location is returned from the correct path. --------- Co-authored-by: Yourim Cha --- src/document/crdt/tree.ts | 2 + test/integration/tree_test.ts | 257 ++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) diff --git a/src/document/crdt/tree.ts b/src/document/crdt/tree.ts index 2d9c14662..4a854ea3c 100644 --- a/src/document/crdt/tree.ts +++ b/src/document/crdt/tree.ts @@ -455,6 +455,8 @@ export class CRDTTreeNode extends IndexTreeNode { childClone.parent = clone; return childClone; }); + clone.insPrevID = this.insPrevID; + clone.insNextID = this.insNextID; return clone; } diff --git a/test/integration/tree_test.ts b/test/integration/tree_test.ts index b04ac3cd1..d62336e1f 100644 --- a/test/integration/tree_test.ts +++ b/test/integration/tree_test.ts @@ -17,6 +17,7 @@ import { describe, it, assert } from 'vitest'; import yorkie, { Tree } from '@yorkie-js-sdk/src/yorkie'; import { + testRPCAddr, toDocKey, withTwoClientsAndDocuments, } from '@yorkie-js-sdk/test/integration/integration_helper'; @@ -1481,6 +1482,262 @@ describe('Tree.style', function () { unsub(); }, task.name); }); + + it('Can handle client reload case', async function ({ task }) { + type TestDoc = { t: Tree; num: number }; + const docKey = toDocKey(`${task.name}-${new Date().getTime()}`); + + const d1 = new yorkie.Document(docKey); + const d2 = new yorkie.Document(docKey); + + const c1 = new yorkie.Client(testRPCAddr); + const c2 = new yorkie.Client(testRPCAddr); + + await c1.activate(); + await c2.activate(); + + await c1.attach(d1, { isRealtimeSync: false }); + await c2.attach(d2, { isRealtimeSync: false }); + + // Perform a dummy update to apply changes up to the snapshot threshold. + const snapshotThreshold = 500; + for (let idx = 0; idx < snapshotThreshold; idx++) { + d1.update((root) => { + root.num = 0; + }); + } + + // Start scenario. + d1.update((root) => { + root.t = new Tree({ + type: 'r', + children: [ + { + type: 'c', + children: [ + { + type: 'u', + children: [ + { + type: 'p', + children: [ + { + type: 'n', + children: [], + }, + ], + }, + ], + }, + ], + }, + { + type: 'c', + children: [ + { + type: 'p', + children: [ + { + type: 'n', + children: [], + }, + ], + }, + ], + }, + ], + }); + }); + await c1.sync(); + await c2.sync(); + assert.equal( + d1.getRoot().t.toXML(), + /*html*/ `

`, + ); + assert.equal( + d2.getRoot().t.toXML(), + /*html*/ `

`, + ); + + d1.update((r) => { + r.t.editByPath([1, 0, 0, 0], [1, 0, 0, 0], { + type: 'text', + value: '1', + }); + r.t.editByPath([1, 0, 0, 1], [1, 0, 0, 1], { + type: 'text', + value: '2', + }); + r.t.editByPath([1, 0, 0, 2], [1, 0, 0, 2], { + type: 'text', + value: '3', + }); + r.t.editByPath([1, 0, 0, 2], [1, 0, 0, 2], { + type: 'text', + value: ' ', + }); + r.t.editByPath([1, 0, 0, 3], [1, 0, 0, 3], { + type: 'text', + value: '네이버랑 ', + }); + }); + await c1.sync(); + await c2.sync(); + assert.equal( + d1.getRoot().t.toXML(), + /*html*/ `

12 네이버랑 3

`, + ); + assert.equal( + d2.getRoot().t.toXML(), + /*html*/ `

12 네이버랑 3

`, + ); + + d2.update((r) => { + r.t.editByPath([1, 0, 0, 1], [1, 0, 0, 8], { + type: 'text', + value: ' 2 네이버랑 ', + }); + r.t.editByPath([1, 0, 0, 2], [1, 0, 0, 2], { + type: 'text', + value: 'ㅋ', + }); + r.t.editByPath([1, 0, 0, 2], [1, 0, 0, 3], { + type: 'text', + value: '카', + }); + r.t.editByPath([1, 0, 0, 2], [1, 0, 0, 3], { + type: 'text', + value: '캌', + }); + r.t.editByPath([1, 0, 0, 2], [1, 0, 0, 3], { + type: 'text', + value: '카카', + }); + r.t.editByPath([1, 0, 0, 3], [1, 0, 0, 4], { + type: 'text', + value: '캉', + }); + r.t.editByPath([1, 0, 0, 3], [1, 0, 0, 4], { + type: 'text', + value: '카오', + }); + r.t.editByPath([1, 0, 0, 4], [1, 0, 0, 5], { + type: 'text', + value: '올', + }); + r.t.editByPath([1, 0, 0, 4], [1, 0, 0, 5], { + type: 'text', + value: '오라', + }); + r.t.editByPath([1, 0, 0, 5], [1, 0, 0, 6], { + type: 'text', + value: '랑', + }); + r.t.editByPath([1, 0, 0, 6], [1, 0, 0, 6], { + type: 'text', + value: ' ', + }); + }); + await c2.sync(); + await c1.sync(); + assert.equal( + d1.getRoot().t.toXML(), + /*html*/ `

1 카카오랑 2 네이버랑 3

`, + ); + assert.equal( + d2.getRoot().t.toXML(), + /*html*/ `

1 카카오랑 2 네이버랑 3

`, + ); + + d1.update((r) => { + r.t.editByPath([1, 0, 0, 13], [1, 0, 0, 14]); + r.t.editByPath([1, 0, 0, 12], [1, 0, 0, 13]); + }); + await c1.sync(); + await c2.sync(); + assert.equal( + d1.getRoot().t.toXML(), + /*html*/ `

1 카카오랑 2 네이버3

`, + ); + assert.equal( + d2.getRoot().t.toXML(), + /*html*/ `

1 카카오랑 2 네이버3

`, + ); + + d2.update((r) => { + r.t.editByPath([1, 0, 0, 6], [1, 0, 0, 7]); + r.t.editByPath([1, 0, 0, 5], [1, 0, 0, 6]); + }); + await c2.sync(); + await c1.sync(); + assert.equal( + d1.getRoot().t.toXML(), + /*html*/ `

1 카카오2 네이버3

`, + ); + assert.equal( + d2.getRoot().t.toXML(), + /*html*/ `

1 카카오2 네이버3

`, + ); + + d1.update((r) => { + r.t.editByPath([1, 0, 0, 9], [1, 0, 0, 10]); + }); + await c1.sync(); + await c2.sync(); + assert.equal( + d1.getRoot().t.toXML(), + /*html*/ `

1 카카오2 네이3

`, + ); + assert.equal( + d2.getRoot().t.toXML(), + /*html*/ `

1 카카오2 네이3

`, + ); + + // A new client has been added. + const d3 = new yorkie.Document(docKey); + const c3 = new yorkie.Client(testRPCAddr); + await c3.activate(); + await c3.attach(d3, { isRealtimeSync: false }); + assert.equal( + d3.getRoot().t.toXML(), + /*html*/ `

1 카카오2 네이3

`, + ); + await c2.sync(); + + d3.update((r) => { + r.t.editByPath([1, 0, 0, 4], [1, 0, 0, 5]); + r.t.editByPath([1, 0, 0, 3], [1, 0, 0, 4]); + }); + await c3.sync(); + await c2.sync(); + assert.equal( + d3.getRoot().t.toXML(), + /*html*/ `

1 카2 네이3

`, + ); + assert.equal( + d2.getRoot().t.toXML(), + /*html*/ `

1 카2 네이3

`, + ); + + d3.update((r) => { + r.t.editByPath([1, 0, 0, 2], [1, 0, 0, 3]); + }); + + await c3.sync(); + await c2.sync(); + assert.equal( + d3.getRoot().t.toXML(), + /*html*/ `

1 2 네이3

`, + ); + assert.equal( + d2.getRoot().t.toXML(), + /*html*/ `

1 2 네이3

`, + ); + + await c1.deactivate(); + await c2.deactivate(); + await c3.deactivate(); + }); }); describe('Tree.edit(concurrent overlapping range)', () => {