From 212468964213b9289f4b64aba8b8535694a335f5 Mon Sep 17 00:00:00 2001 From: JOOHOJANG <46807540+JOOHOJANG@users.noreply.github.com> Date: Wed, 23 Oct 2024 15:46:10 +0900 Subject: [PATCH] Introducing version vector to solve GC problem (#899) This change introduces Lamport Synced Version Vector to resolve defects in the existing garbage collection system that used syncedSeq. Key improvements include: - Added Version Vector implementation with Lamport timestamp support - Implemented database storage and update mechanisms for version vectors - Created min version vector computation for safe garbage collection - Added handling for detached client's version vectors to prevent memory leaks - Updated change ID generation to incorporate version vector information The Version Vector ensures all changes are properly synchronized across replicas before garbage collection occurs, improving system reliability and reducing memory waste from detached clients. --------- Co-authored-by: Youngteac Hong --- packages/sdk/src/api/converter.ts | 59 + .../sdk/src/api/yorkie/v1/resources.proto | 6 + .../sdk/src/api/yorkie/v1/resources_pb.ts | 49 + packages/sdk/src/document/change/change_id.ts | 93 +- .../sdk/src/document/change/change_pack.ts | 18 +- packages/sdk/src/document/crdt/root.ts | 12 +- packages/sdk/src/document/document.ts | 211 ++-- .../sdk/src/document/time/version_vector.ts | 151 +++ packages/sdk/test/bench/document.bench.ts | 7 +- packages/sdk/test/bench/text.bench.ts | 12 +- packages/sdk/test/bench/tree.bench.ts | 12 +- packages/sdk/test/helper/helper.ts | 41 + packages/sdk/test/integration/array_test.ts | 5 +- packages/sdk/test/integration/client_test.ts | 1 + packages/sdk/test/integration/gc_test.ts | 1082 ++++++++++++++++- .../sdk/test/unit/document/crdt/root_test.ts | 5 +- .../sdk/test/unit/document/document_test.ts | 10 +- packages/sdk/test/unit/document/gc_test.ts | 70 +- 18 files changed, 1664 insertions(+), 180 deletions(-) create mode 100644 packages/sdk/src/document/time/version_vector.ts diff --git a/packages/sdk/src/api/converter.ts b/packages/sdk/src/api/converter.ts index 04f54e6e6..23a672056 100644 --- a/packages/sdk/src/api/converter.ts +++ b/packages/sdk/src/api/converter.ts @@ -44,6 +44,7 @@ import { RGATreeList } from '@yorkie-js-sdk/src/document/crdt/rga_tree_list'; import { CRDTElement } from '@yorkie-js-sdk/src/document/crdt/element'; import { CRDTObject } from '@yorkie-js-sdk/src/document/crdt/object'; import { CRDTArray } from '@yorkie-js-sdk/src/document/crdt/array'; +import { VersionVector } from '@yorkie-js-sdk/src/document/time/version_vector'; import { CRDTTreePos } from './../document/crdt/tree'; import { RGATreeSplit, @@ -78,6 +79,7 @@ import { TreeNodes as PbTreeNodes, TreePos as PbTreePos, TreeNodeID as PbTreeNodeID, + VersionVector as PbVersionVector, ValueType as PbValueType, JSONElement_Tree as PbJSONElement_Tree, JSONElement_Text as PbJSONElement_Text, @@ -161,6 +163,7 @@ function toChangeID(changeID: ChangeID): PbChangeID { clientSeq: changeID.getClientSeq(), lamport: changeID.getLamport(), actorId: toUint8Array(changeID.getActorID()), + versionVector: toVersionVector(changeID.getVersionVector()), }); } @@ -179,6 +182,21 @@ function toTimeTicket(ticket?: TimeTicket): PbTimeTicket | undefined { }); } +/** + * `toVersionVector` converts the given model to Protobuf format. + */ +function toVersionVector(vector?: VersionVector): PbVersionVector | undefined { + if (!vector) { + return; + } + + const pbVector = new PbVersionVector(); + for (const [actorID, lamport] of vector) { + pbVector.vector[actorID] = BigInt(lamport.toString()); + } + return pbVector; +} + /** * `toValueType` converts the given model to Protobuf format. */ @@ -780,6 +798,7 @@ function toChangePack(pack: ChangePack): PbChangePack { isRemoved: pack.getIsRemoved(), changes: toChanges(pack.getChanges()), snapshot: pack.getSnapshot(), + versionVector: toVersionVector(pack.getVersionVector()), minSyncedTicket: toTimeTicket(pack.getMinSyncedTicket()), }); } @@ -810,10 +829,28 @@ function fromChangeID(pbChangeID: PbChangeID): ChangeID { pbChangeID.clientSeq, BigInt(pbChangeID.lamport), toHexString(pbChangeID.actorId), + fromVersionVector(pbChangeID.versionVector)!, BigInt(pbChangeID.serverSeq), ); } +/** + * `fromVersionVector` converts the given Protobuf format to model format. + */ +function fromVersionVector( + pbVersionVector?: PbVersionVector, +): VersionVector | undefined { + if (!pbVersionVector) { + return; + } + + const vector = new VersionVector(); + Object.entries(pbVersionVector.vector).forEach(([key, value]) => { + vector.set(key, BigInt(value.toString())); + }); + return vector; +} + /** * `fromTimeTicket` converts the given Protobuf format to model format. */ @@ -1324,6 +1361,7 @@ function fromChangePack

( fromCheckpoint(pbPack.checkpoint!), pbPack.isRemoved, fromChanges(pbPack.changes), + fromVersionVector(pbPack.versionVector), pbPack.snapshot, fromTimeTicket(pbPack.minSyncedTicket), ); @@ -1470,6 +1508,25 @@ function bytesToSnapshot

( }; } +/** + * `versionVectorToHex` converts the given VersionVector to bytes. + */ +function versionVectorToHex(vector: VersionVector): string { + const pbVersionVector = toVersionVector(vector)!; + + return bytesToHex(pbVersionVector.toBinary()); +} + +/** + * `hexToVersionVector` creates a VersionVector from the given bytes. + */ +function hexToVersionVector(hex: string): VersionVector { + const bytes = hexToBytes(hex); + const pbVersionVector = PbVersionVector.fromBinary(bytes); + + return fromVersionVector(pbVersionVector)!; +} + /** * `bytesToObject` creates an JSONObject from the given byte array. */ @@ -1602,4 +1659,6 @@ export const converter = { PbChangeID, bytesToChangeID, bytesToOperation, + versionVectorToHex, + hexToVersionVector, }; diff --git a/packages/sdk/src/api/yorkie/v1/resources.proto b/packages/sdk/src/api/yorkie/v1/resources.proto index 9f31d95b0..547ec6c55 100644 --- a/packages/sdk/src/api/yorkie/v1/resources.proto +++ b/packages/sdk/src/api/yorkie/v1/resources.proto @@ -46,6 +46,7 @@ message ChangePack { repeated Change changes = 4; TimeTicket min_synced_ticket = 5; bool is_removed = 6; + VersionVector version_vector = 7; } message Change { @@ -60,6 +61,11 @@ message ChangeID { int64 server_seq = 2; int64 lamport = 3; bytes actor_id = 4; + VersionVector version_vector = 5; +} + +message VersionVector { + map vector = 1; } message Operation { diff --git a/packages/sdk/src/api/yorkie/v1/resources_pb.ts b/packages/sdk/src/api/yorkie/v1/resources_pb.ts index 3601b2e34..9297e1fb0 100644 --- a/packages/sdk/src/api/yorkie/v1/resources_pb.ts +++ b/packages/sdk/src/api/yorkie/v1/resources_pb.ts @@ -229,6 +229,11 @@ export class ChangePack extends Message { */ isRemoved = false; + /** + * @generated from field: yorkie.v1.VersionVector version_vector = 7; + */ + versionVector?: VersionVector; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -243,6 +248,7 @@ export class ChangePack extends Message { { no: 4, name: "changes", kind: "message", T: Change, repeated: true }, { no: 5, name: "min_synced_ticket", kind: "message", T: TimeTicket }, { no: 6, name: "is_removed", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 7, name: "version_vector", kind: "message", T: VersionVector }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ChangePack { @@ -341,6 +347,11 @@ export class ChangeID extends Message { */ actorId = new Uint8Array(0); + /** + * @generated from field: yorkie.v1.VersionVector version_vector = 5; + */ + versionVector?: VersionVector; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -353,6 +364,7 @@ export class ChangeID extends Message { { no: 2, name: "server_seq", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, { no: 3, name: "lamport", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, { no: 4, name: "actor_id", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, + { no: 5, name: "version_vector", kind: "message", T: VersionVector }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ChangeID { @@ -372,6 +384,43 @@ export class ChangeID extends Message { } } +/** + * @generated from message yorkie.v1.VersionVector + */ +export class VersionVector extends Message { + /** + * @generated from field: map vector = 1; + */ + vector: { [key: string]: bigint } = {}; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "yorkie.v1.VersionVector"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "vector", kind: "map", K: 9 /* ScalarType.STRING */, V: {kind: "scalar", T: 3 /* ScalarType.INT64 */} }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): VersionVector { + return new VersionVector().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): VersionVector { + return new VersionVector().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): VersionVector { + return new VersionVector().fromJsonString(jsonString, options); + } + + static equals(a: VersionVector | PlainMessage | undefined, b: VersionVector | PlainMessage | undefined): boolean { + return proto3.util.equals(VersionVector, a, b); + } +} + /** * @generated from message yorkie.v1.Operation */ diff --git a/packages/sdk/src/document/change/change_id.ts b/packages/sdk/src/document/change/change_id.ts index 142a52817..fb7c53ae6 100644 --- a/packages/sdk/src/document/change/change_id.ts +++ b/packages/sdk/src/document/change/change_id.ts @@ -19,28 +19,35 @@ import { InitialActorID, } from '@yorkie-js-sdk/src/document/time/actor_id'; import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; +import { InitialVersionVector, VersionVector } from '../time/version_vector'; /** * `ChangeID` is for identifying the Change. This is immutable. */ export class ChangeID { + // `clientSeq` is the sequence number of the client that created this change. private clientSeq: number; - // `serverSeq` is optional and only present for changes stored on the server. private serverSeq?: bigint; - + // `lamport` and `actor` are the lamport clock and the actor of this change. + // This is used to determine the order of changes in logical time. private lamport: bigint; private actor: ActorID; + // `versionVector` is the vector clock of this change. This is used to + // determine the relationship is causal or not between changes. + private versionVector: VersionVector; constructor( clientSeq: number, lamport: bigint, actor: ActorID, + vector: VersionVector, serverSeq?: bigint, ) { this.clientSeq = clientSeq; this.serverSeq = serverSeq; this.lamport = lamport; + this.versionVector = vector; this.actor = actor; } @@ -51,29 +58,56 @@ export class ChangeID { clientSeq: number, lamport: bigint, actor: ActorID, + vector: VersionVector, serverSeq?: bigint, ): ChangeID { - return new ChangeID(clientSeq, lamport, actor, serverSeq); + return new ChangeID(clientSeq, lamport, actor, vector, serverSeq); } /** * `next` creates a next ID of this ID. */ public next(): ChangeID { - return new ChangeID(this.clientSeq + 1, this.lamport + 1n, this.actor); + const vector = this.versionVector.deepcopy(); + vector.set(this.actor, this.lamport + 1n); + + return new ChangeID( + this.clientSeq + 1, + this.lamport + 1n, + this.actor, + vector, + ); } /** - * `syncLamport` syncs lamport timestamp with the given ID. - * - * {@link https://en.wikipedia.org/wiki/Lamport_timestamps#Algorithm} + * `syncClocks` syncs logical clocks with the given ID. */ - public syncLamport(otherLamport: bigint): ChangeID { - if (otherLamport > this.lamport) { - return new ChangeID(this.clientSeq, otherLamport, this.actor); - } + public syncClocks(other: ChangeID): ChangeID { + const lamport = + other.lamport > this.lamport ? other.lamport + 1n : this.lamport + 1n; + const maxVersionVector = this.versionVector.max(other.versionVector); + + const newID = new ChangeID( + this.clientSeq, + lamport, + this.actor, + maxVersionVector, + ); + newID.versionVector.set(this.actor, lamport); + return newID; + } - return new ChangeID(this.clientSeq, this.lamport + 1n, this.actor); + /** + * `setClocks` sets the given clocks to this ID. This is used when the snapshot + * is given from the server. + */ + public setClocks(otherLamport: bigint, vector: VersionVector): ChangeID { + const lamport = + otherLamport > this.lamport ? otherLamport : this.lamport + 1n; + const maxVersionVector = this.versionVector.max(vector); + maxVersionVector.set(this.actor, lamport); + + return ChangeID.of(this.clientSeq, lamport, this.actor, maxVersionVector); } /** @@ -87,7 +121,26 @@ export class ChangeID { * `setActor` sets the given actor. */ public setActor(actorID: ActorID): ChangeID { - return new ChangeID(this.clientSeq, this.lamport, actorID, this.serverSeq); + return new ChangeID( + this.clientSeq, + this.lamport, + actorID, + this.versionVector, + this.serverSeq, + ); + } + + /** + * `setVersionVector` sets the given version vector. + */ + public setVersionVector(versionVector: VersionVector): ChangeID { + return new ChangeID( + this.clientSeq, + this.lamport, + this.actor, + versionVector, + this.serverSeq, + ); } /** @@ -128,6 +181,13 @@ export class ChangeID { return this.actor; } + /** + * `getVersionVector` returns the version vector of this ID. + */ + public getVersionVector(): VersionVector { + return this.versionVector; + } + /** * `toTestString` returns a string containing the meta data of this ID. */ @@ -142,4 +202,9 @@ export class ChangeID { * `InitialChangeID` represents the initial state ID. Usually this is used to * represent a state where nothing has been edited. */ -export const InitialChangeID = new ChangeID(0, 0n, InitialActorID); +export const InitialChangeID = new ChangeID( + 0, + 0n, + InitialActorID, + InitialVersionVector, +); diff --git a/packages/sdk/src/document/change/change_pack.ts b/packages/sdk/src/document/change/change_pack.ts index e2fd3144c..15a0e104c 100644 --- a/packages/sdk/src/document/change/change_pack.ts +++ b/packages/sdk/src/document/change/change_pack.ts @@ -18,6 +18,7 @@ import { Indexable } from '@yorkie-js-sdk/src/document/document'; import { Checkpoint } from '@yorkie-js-sdk/src/document/change/checkpoint'; import { Change } from '@yorkie-js-sdk/src/document/change/change'; import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; +import { VersionVector } from '../time/version_vector'; /** * `ChangePack` is a unit for delivering changes in a document to the remote. @@ -52,11 +53,17 @@ export class ChangePack

{ */ private minSyncedTicket?: TimeTicket; + /** + * `versionVector` is the version vector current document + */ + private versionVector?: VersionVector; + constructor( key: string, checkpoint: Checkpoint, isRemoved: boolean, changes: Array>, + versionVector?: VersionVector, snapshot?: Uint8Array, minSyncedTicket?: TimeTicket, ) { @@ -66,8 +73,8 @@ export class ChangePack

{ this.changes = changes; this.snapshot = snapshot; this.minSyncedTicket = minSyncedTicket; + this.versionVector = versionVector; } - /** * `create` creates a new instance of ChangePack. */ @@ -76,6 +83,7 @@ export class ChangePack

{ checkpoint: Checkpoint, isRemoved: boolean, changes: Array>, + versionVector?: VersionVector, snapshot?: Uint8Array, minSyncedTicket?: TimeTicket, ): ChangePack

{ @@ -84,6 +92,7 @@ export class ChangePack

{ checkpoint, isRemoved, changes, + versionVector, snapshot, minSyncedTicket, ); @@ -151,4 +160,11 @@ export class ChangePack

{ public getMinSyncedTicket(): TimeTicket | undefined { return this.minSyncedTicket; } + + /** + * `getVersionVector` returns the document's version vector of this pack + */ + public getVersionVector(): VersionVector | undefined { + return this.versionVector; + } } diff --git a/packages/sdk/src/document/crdt/root.ts b/packages/sdk/src/document/crdt/root.ts index fe962f4b4..246d87d41 100644 --- a/packages/sdk/src/document/crdt/root.ts +++ b/packages/sdk/src/document/crdt/root.ts @@ -27,6 +27,7 @@ import { GCPair } from '@yorkie-js-sdk/src/document/crdt/gc'; import { CRDTText } from '@yorkie-js-sdk/src/document/crdt/text'; import { CRDTTree } from '@yorkie-js-sdk/src/document/crdt/tree'; import { Code, YorkieError } from '@yorkie-js-sdk/src/util/error'; +import { VersionVector } from '../time/version_vector'; /** * `CRDTElementPair` is a structure that represents a pair of element and its @@ -268,15 +269,14 @@ export class CRDTRoot { /** * `garbageCollect` purges elements that were removed before the given time. */ - public garbageCollect(ticket: TimeTicket): number { + public garbageCollect(minSyncedVersionVector: VersionVector): number { let count = 0; for (const createdAt of this.gcElementSetByCreatedAt) { const pair = this.elementPairMapByCreatedAt.get(createdAt)!; - if ( - pair.element.getRemovedAt() && - ticket.compare(pair.element.getRemovedAt()!) >= 0 - ) { + const removedAt = pair.element.getRemovedAt(); + + if (removedAt && minSyncedVersionVector?.afterOrEqual(removedAt)) { pair.parent!.purge(pair.element); count += this.deregisterElement(pair.element); } @@ -284,7 +284,7 @@ export class CRDTRoot { for (const [, pair] of this.gcPairMap) { const removedAt = pair.child.getRemovedAt(); - if (removedAt !== undefined && ticket.compare(removedAt) >= 0) { + if (removedAt && minSyncedVersionVector?.afterOrEqual(removedAt)) { pair.parent.purge(pair.child); this.gcPairMap.delete(pair.child.toIDString()); diff --git a/packages/sdk/src/document/document.ts b/packages/sdk/src/document/document.ts index c2fb51d9d..b054918d7 100644 --- a/packages/sdk/src/document/document.ts +++ b/packages/sdk/src/document/document.ts @@ -55,7 +55,6 @@ import { Checkpoint, InitialCheckpoint, } from '@yorkie-js-sdk/src/document/change/checkpoint'; -import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { OpSource, OperationInfo, @@ -77,6 +76,7 @@ import { import { History, HistoryOperation } from '@yorkie-js-sdk/src/document/history'; import { setupDevtools } from '@yorkie-js-sdk/src/devtools'; import * as Devtools from '@yorkie-js-sdk/src/devtools/types'; +import { VersionVector } from './time/version_vector'; /** * `BroadcastOptions` are the options to create a new document. @@ -321,7 +321,11 @@ export interface SnapshotEvent extends BaseDocEvent { */ type: DocEventType.Snapshot; source: OpSource.Remote; - value: { snapshot?: string; serverSeq: string }; + value: { + snapshot: string | undefined; + serverSeq: string; + snapshotVector: string; + }; } /** @@ -466,14 +470,14 @@ export type DocumentKey = string; type OperationInfoOfElement = TElement extends Text ? TextOperationInfo : TElement extends Counter - ? CounterOperationInfo - : TElement extends Tree - ? TreeOperationInfo - : TElement extends BaseArray - ? ArrayOperationInfo - : TElement extends BaseObject - ? ObjectOperationInfo - : OperationInfo; + ? CounterOperationInfo + : TElement extends Tree + ? TreeOperationInfo + : TElement extends BaseArray + ? ArrayOperationInfo + : TElement extends BaseObject + ? ObjectOperationInfo + : OperationInfo; /** * `OperationInfoOfInternal` represents the type of the operation info of the @@ -494,24 +498,24 @@ type OperationInfoOfInternal< > = TDepth extends 0 ? TElement : TKeyOrPath extends `${infer TFirst}.${infer TRest}` - ? TFirst extends keyof TElement - ? TElement[TFirst] extends BaseArray - ? OperationInfoOfInternal< - TElement[TFirst], - number, - DecreasedDepthOf - > - : OperationInfoOfInternal< - TElement[TFirst], - TRest, - DecreasedDepthOf - > - : OperationInfo - : TKeyOrPath extends keyof TElement - ? TElement[TKeyOrPath] extends BaseArray - ? ArrayOperationInfo - : OperationInfoOfElement - : OperationInfo; + ? TFirst extends keyof TElement + ? TElement[TFirst] extends BaseArray + ? OperationInfoOfInternal< + TElement[TFirst], + number, + DecreasedDepthOf + > + : OperationInfoOfInternal< + TElement[TFirst], + TRest, + DecreasedDepthOf + > + : OperationInfo + : TKeyOrPath extends keyof TElement + ? TElement[TKeyOrPath] extends BaseArray + ? ArrayOperationInfo + : OperationInfoOfElement + : OperationInfo; /** * `DecreasedDepthOf` represents the type of the decreased depth of the given depth. @@ -519,24 +523,24 @@ type OperationInfoOfInternal< type DecreasedDepthOf = Depth extends 10 ? 9 : Depth extends 9 - ? 8 - : Depth extends 8 - ? 7 - : Depth extends 7 - ? 6 - : Depth extends 6 - ? 5 - : Depth extends 5 - ? 4 - : Depth extends 4 - ? 3 - : Depth extends 3 - ? 2 - : Depth extends 2 - ? 1 - : Depth extends 1 - ? 0 - : -1; + ? 8 + : Depth extends 8 + ? 7 + : Depth extends 7 + ? 6 + : Depth extends 6 + ? 5 + : Depth extends 5 + ? 4 + : Depth extends 4 + ? 3 + : Depth extends 3 + ? 2 + : Depth extends 2 + ? 1 + : Depth extends 1 + ? 0 + : -1; /** * `PathOfInternal` represents the type of the path of the given element. @@ -548,29 +552,29 @@ type PathOfInternal< > = Depth extends 0 ? Prefix : TElement extends Record - ? { - [TKey in keyof TElement]: TElement[TKey] extends LeafElement - ? `${Prefix}${TKey & string}` - : TElement[TKey] extends BaseArray - ? - | `${Prefix}${TKey & string}` - | `${Prefix}${TKey & string}.${number}` - | PathOfInternal< - TArrayElement, - `${Prefix}${TKey & string}.${number}.`, - DecreasedDepthOf - > - : - | `${Prefix}${TKey & string}` - | PathOfInternal< - TElement[TKey], - `${Prefix}${TKey & string}.`, - DecreasedDepthOf - >; - }[keyof TElement] - : Prefix extends `${infer TRest}.` - ? TRest - : Prefix; + ? { + [TKey in keyof TElement]: TElement[TKey] extends LeafElement + ? `${Prefix}${TKey & string}` + : TElement[TKey] extends BaseArray + ? + | `${Prefix}${TKey & string}` + | `${Prefix}${TKey & string}.${number}` + | PathOfInternal< + TArrayElement, + `${Prefix}${TKey & string}.${number}.`, + DecreasedDepthOf + > + : + | `${Prefix}${TKey & string}` + | PathOfInternal< + TElement[TKey], + `${Prefix}${TKey & string}.`, + DecreasedDepthOf + >; + }[keyof TElement] + : Prefix extends `${infer TRest}.` + ? TRest + : Prefix; /** * `OperationInfoOf` represents the type of the operation info of the given @@ -1154,10 +1158,13 @@ export class Document { * @internal */ public applyChangePack(pack: ChangePack

): void { - if (pack.hasSnapshot()) { + const hasSnapshot = pack.hasSnapshot(); + + if (hasSnapshot) { this.applySnapshot( pack.getCheckpoint().getServerSeq(), - pack.getSnapshot(), + pack.getVersionVector()!, + pack.getSnapshot()!, ); } else if (pack.hasChanges()) { this.applyChanges(pack.getChanges(), OpSource.Remote); @@ -1176,7 +1183,7 @@ export class Document { // them after applying the snapshot. We need to treat the local changes // as remote changes because the application should apply the local // changes to their own document. - if (pack.hasSnapshot()) { + if (hasSnapshot) { this.applyChanges(this.localChanges, OpSource.Remote); } @@ -1184,7 +1191,14 @@ export class Document { this.checkpoint = this.checkpoint.forward(pack.getCheckpoint()); // 04. Do Garbage collection. - this.garbageCollect(pack.getMinSyncedTicket()!); + if (!hasSnapshot) { + this.garbageCollect(pack.getVersionVector()!); + } + + // 05. Filter detached client's lamport from version vector + if (!hasSnapshot) { + this.filterVersionVector(pack.getVersionVector()!); + } // 05. Update the status. if (pack.getIsRemoved()) { @@ -1246,7 +1260,13 @@ export class Document { public createChangePack(): ChangePack

{ const changes = Array.from(this.localChanges); const checkpoint = this.checkpoint.increaseClientSeq(changes.length); - return ChangePack.create(this.key, checkpoint, false, changes); + return ChangePack.create( + this.key, + checkpoint, + false, + changes, + this.getVersionVector(), + ); } /** @@ -1318,15 +1338,15 @@ export class Document { * * @internal */ - public garbageCollect(ticket: TimeTicket): number { + public garbageCollect(minSyncedVersionVector: VersionVector): number { if (this.opts.disableGC) { return 0; } if (this.clone) { - this.clone.root.garbageCollect(ticket); + this.clone.root.garbageCollect(minSyncedVersionVector); } - return this.root.garbageCollect(ticket); + return this.root.garbageCollect(minSyncedVersionVector); } /** @@ -1381,11 +1401,15 @@ export class Document { /** * `applySnapshot` applies the given snapshot into this document. */ - public applySnapshot(serverSeq: bigint, snapshot?: Uint8Array) { + public applySnapshot( + serverSeq: bigint, + snapshotVector: VersionVector, + snapshot?: Uint8Array, + ) { const { root, presences } = converter.bytesToSnapshot

(snapshot); this.root = new CRDTRoot(root); this.presences = presences; - this.changeID = this.changeID.syncLamport(serverSeq); + this.changeID = this.changeID.setClocks(serverSeq, snapshotVector); // drop clone because it is contaminated. this.clone = undefined; @@ -1395,10 +1419,11 @@ export class Document { type: DocEventType.Snapshot, source: OpSource.Remote, value: { + serverSeq: serverSeq.toString(), snapshot: this.isEnableDevtools() ? converter.bytesToHex(snapshot) : undefined, - serverSeq: serverSeq.toString(), + snapshotVector: converter.versionVectorToHex(snapshotVector), }, }, ]); @@ -1498,7 +1523,7 @@ export class Document { } const { opInfos } = change.execute(this.root, this.presences, source); - this.changeID = this.changeID.syncLamport(change.getID().getLamport()); + this.changeID = this.changeID.syncClocks(change.getID()); if (opInfos.length > 0) { const rawChange = this.isEnableDevtools() ? change.toStruct() : undefined; event.push( @@ -1651,9 +1676,13 @@ export class Document { } if (event.type === DocEventType.Snapshot) { - const { snapshot, serverSeq } = event.value; + const { snapshot, serverSeq, snapshotVector } = event.value; if (!snapshot) return; - this.applySnapshot(BigInt(serverSeq), converter.hexToBytes(snapshot)); + this.applySnapshot( + BigInt(serverSeq), + converter.hexToVersionVector(snapshotVector), + converter.hexToBytes(snapshot), + ); return; } @@ -1860,6 +1889,15 @@ export class Document { return this.internalHistory.hasUndo() && !this.isUpdating; } + /** + * 'filterVersionVector' filters detached client's lamport from version vector. + */ + private filterVersionVector(minSyncedVersionVector: VersionVector) { + const versionVector = this.changeID.getVersionVector(); + const filteredVersionVector = versionVector.filter(minSyncedVersionVector); + + this.changeID = this.changeID.setVersionVector(filteredVersionVector); + } /** * `canRedo` returns whether there are any operations to redo. */ @@ -2092,4 +2130,11 @@ export class Document { this.publish([broadcastEvent]); } + + /** + * `getVersionVector` returns the version vector of document + */ + public getVersionVector() { + return this.changeID.getVersionVector(); + } } diff --git a/packages/sdk/src/document/time/version_vector.ts b/packages/sdk/src/document/time/version_vector.ts new file mode 100644 index 000000000..97da9ae3b --- /dev/null +++ b/packages/sdk/src/document/time/version_vector.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2024 The Yorkie Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TimeTicket } from './ticket'; + +/** + * `VersionVector` is a vector clock that is used to detect the relationship + * between changes whether they are causally related or concurrent. It is + * similar to vector clocks, but it is synced with lamport timestamp of the + * change. + */ +export class VersionVector { + private vector: Map; + + constructor(vector?: Map) { + this.vector = vector || new Map(); + } + + /** + * `set` sets the lamport timestamp of the given actor. + */ + public set(actorID: string, lamport: bigint): void { + this.vector.set(actorID, lamport); + } + + /** + * `get` gets the lamport timestamp of the given actor. + */ + public get(actorID: string): bigint | undefined { + return this.vector.get(actorID); + } + + /** + * `maxLamport` returns max lamport value from vector + */ + public maxLamport() { + let max = BigInt(0); + + for (const [, lamport] of this) { + if (lamport > max) { + max = lamport; + } + } + + return max; + } + + /** + * `max` returns new version vector which consists of max value of each vector + */ + public max(other: VersionVector): VersionVector { + const maxVector = new Map(); + + for (const [actorID, lamport] of other) { + const currentLamport = this.vector.get(actorID); + const maxLamport = currentLamport + ? currentLamport > lamport + ? currentLamport + : lamport + : lamport; + + maxVector.set(actorID, maxLamport); + } + + for (const [actorID, lamport] of this) { + const otherLamport = other.get(actorID); + const maxLamport = otherLamport + ? otherLamport > lamport + ? otherLamport + : lamport + : lamport; + + maxVector.set(actorID, maxLamport); + } + + return new VersionVector(maxVector); + } + + /** + * `afterOrEqual` returns vector[other.actorID] is greaterOrEqual than given ticket's lamport + */ + public afterOrEqual(other: TimeTicket) { + const lamport = this.vector.get(other.getActorID()); + + if (lamport === undefined) { + return false; + } + + return lamport >= other.getLamport(); + } + + /** + * `deepcopy` returns a deep copy of this `VersionVector`. + */ + public deepcopy(): VersionVector { + const copied = new Map(); + for (const [key, value] of this.vector) { + copied.set(key, value); + } + return new VersionVector(copied); + } + + /** + * `filter` returns new version vector consist of filter's actorID. + */ + public filter(versionVector: VersionVector) { + const filtered = new Map(); + + for (const [actorID] of versionVector) { + const lamport = this.vector.get(actorID); + + if (lamport !== undefined) { + filtered.set(actorID, lamport); + } + } + + return new VersionVector(filtered); + } + + /** + * `size` returns size of version vector + */ + public size(): number { + return this.vector.size; + } + + // eslint-disable-next-line jsdoc/require-jsdoc + public *[Symbol.iterator](): IterableIterator<[string, bigint]> { + for (const [key, value] of this.vector) { + yield [key, value]; + } + } +} + +/** + * `InitialVersionVector` is the initial version vector. + */ +export const InitialVersionVector = new VersionVector(new Map()); diff --git a/packages/sdk/test/bench/document.bench.ts b/packages/sdk/test/bench/document.bench.ts index 4fc11a38c..17f53231c 100644 --- a/packages/sdk/test/bench/document.bench.ts +++ b/packages/sdk/test/bench/document.bench.ts @@ -1,8 +1,8 @@ import { Document, JSONArray } from '@yorkie-js-sdk/src/yorkie'; -import { MaxTimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { InitialCheckpoint } from '@yorkie-js-sdk/src/document/change/checkpoint'; import { DocumentStatus } from '@yorkie-js-sdk/src/document/document'; import { describe, bench, assert } from 'vitest'; +import { MaxVersionVector } from '../helper/helper'; const benchmarkObject = (size: number) => { const doc = new Document<{ k1: number }>('test-doc'); @@ -40,7 +40,10 @@ const benchmarkArrayGC = (size: number) => { delete root.k1; }); - assert.equal(size + 1, doc.garbageCollect(MaxTimeTicket)); + assert.equal( + size + 1, + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + ); }; describe('Document', () => { diff --git a/packages/sdk/test/bench/text.bench.ts b/packages/sdk/test/bench/text.bench.ts index 2f94ef4a7..9b91bbaca 100644 --- a/packages/sdk/test/bench/text.bench.ts +++ b/packages/sdk/test/bench/text.bench.ts @@ -1,6 +1,6 @@ import { Document, Text } from '@yorkie-js-sdk/src/yorkie'; -import { MaxTimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { assert, bench, describe } from 'vitest'; +import { MaxVersionVector } from '../helper/helper'; const benchmarkTextEditGC = (size: number) => { const doc = new Document<{ text: Text }>('test-doc'); @@ -22,7 +22,10 @@ const benchmarkTextEditGC = (size: number) => { }, `modify ${size} nodes`); // 03. GC assert.equal(size, doc.getGarbageLen()); - assert.equal(size, doc.garbageCollect(MaxTimeTicket)); + assert.equal( + size, + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + ); const empty = 0; assert.equal(empty, doc.getGarbageLen()); }; @@ -45,7 +48,10 @@ const benchmarkTextSplitGC = (size: number) => { }, 'Modify one node multiple times'); // 03. GC assert.equal(size, doc.getGarbageLen()); - assert.equal(size, doc.garbageCollect(MaxTimeTicket)); + assert.equal( + size, + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + ); const empty = 0; assert.equal(empty, doc.getGarbageLen()); }; diff --git a/packages/sdk/test/bench/tree.bench.ts b/packages/sdk/test/bench/tree.bench.ts index b8bb2f8ce..c03b3577e 100644 --- a/packages/sdk/test/bench/tree.bench.ts +++ b/packages/sdk/test/bench/tree.bench.ts @@ -1,6 +1,6 @@ import { converter, Document, Tree, TreeNode } from '@yorkie-js-sdk/src/yorkie'; -import { MaxTimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { describe, bench, assert } from 'vitest'; +import { MaxVersionVector } from '../helper/helper'; const benchmarkTreeEdit = (size: number) => { const doc = new Document<{ tree: Tree }>('test-doc'); @@ -56,7 +56,10 @@ const benchmarkTreeSplitGC = (size: number) => { }, `modify ${size} nodes`); // 03. GC assert.equal(size, doc.getGarbageLen()); - assert.equal(size, doc.garbageCollect(MaxTimeTicket)); + assert.equal( + size, + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + ); const empty = 0; assert.equal(empty, doc.getGarbageLen()); }; @@ -83,7 +86,10 @@ const benchmarkTreeEditGC = (size: number) => { }, `modify ${size} nodes`); // 03. GC assert.equal(size, doc.getGarbageLen()); - assert.equal(size, doc.garbageCollect(MaxTimeTicket)); + assert.equal( + size, + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + ); const empty = 0; assert.equal(empty, doc.getGarbageLen()); }; diff --git a/packages/sdk/test/helper/helper.ts b/packages/sdk/test/helper/helper.ts index 041ed557f..168f4eee1 100644 --- a/packages/sdk/test/helper/helper.ts +++ b/packages/sdk/test/helper/helper.ts @@ -28,6 +28,7 @@ import { } from '@yorkie-js-sdk/src/document/operation/operation'; import { InitialTimeTicket as ITT, + MaxLamport, TimeTicket, } from '@yorkie-js-sdk/src/document/time/ticket'; import { HistoryOperation } from '@yorkie-js-sdk/src/document/history'; @@ -37,6 +38,8 @@ import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; import { CRDTObject } from '@yorkie-js-sdk/src/document/crdt/object'; import { ElementRHT } from '@yorkie-js-sdk/src/document/crdt/element_rht'; import { Code, YorkieError } from '@yorkie-js-sdk/src/util/error'; +import { InitialActorID } from '@yorkie-js-sdk/src/document/time/actor_id'; +import { VersionVector } from '@yorkie-js-sdk/src/document/time/version_vector'; export type Indexable = Record; @@ -285,3 +288,41 @@ export function posT(offset = 0): CRDTTreeNodeID { export function timeT(): TimeTicket { return dummyContext.issueTimeTicket(); } + +// MaxVersionVector return the SyncedVectorMap that contains the given actors as key and Max Lamport. +export function MaxVersionVector(actors: Array) { + if (!actors.length) { + actors = [InitialActorID]; + } + + const vector = new Map(); + + actors.forEach((actor) => { + vector.set(actor, MaxLamport); + }); + + return new VersionVector(vector); +} + +export function versionVectorHelper( + versionVector: VersionVector, + actorData: Array<{ actor: string; lamport: bigint }>, +) { + if (versionVector.size() !== actorData.length) { + return false; + } + + for (const { actor, lamport } of actorData) { + const vvLamport = versionVector.get(actor); + + if (!vvLamport) { + return false; + } + + if (vvLamport !== lamport) { + return false; + } + } + + return true; +} diff --git a/packages/sdk/test/integration/array_test.ts b/packages/sdk/test/integration/array_test.ts index 829eee0ab..5ba077f73 100644 --- a/packages/sdk/test/integration/array_test.ts +++ b/packages/sdk/test/integration/array_test.ts @@ -1,7 +1,5 @@ import { describe, it, assert } from 'vitest'; import { Document } from '@yorkie-js-sdk/src/document/document'; -import { MaxTimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; - import { withTwoClientsAndDocuments } from '@yorkie-js-sdk/test/integration/integration_helper'; import { JSONArray, @@ -9,6 +7,7 @@ import { Primitive, TimeTicket, } from '@yorkie-js-sdk/src/yorkie'; +import { MaxVersionVector } from '../helper/helper'; describe('Array', function () { it('should handle delete operations', function () { @@ -723,7 +722,7 @@ describe('Array', function () { assert.equal('{"list":[0,1]}', doc.toSortedJSON()); - doc.garbageCollect(MaxTimeTicket); + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])); doc.update((root) => { const elem = root.list.getElementByID!(targetID); assert.isUndefined(elem); diff --git a/packages/sdk/test/integration/client_test.ts b/packages/sdk/test/integration/client_test.ts index 33cb5952a..e4f9cedd4 100644 --- a/packages/sdk/test/integration/client_test.ts +++ b/packages/sdk/test/integration/client_test.ts @@ -893,6 +893,7 @@ describe.sequential('Client', function () { await cli.attach(doc); // broadcast unserializable payload + // eslint-disable-next-line @typescript-eslint/no-empty-function const payload = () => {}; const broadcastTopic = 'test'; const broadcastErrMessage = 'payload is not serializable'; diff --git a/packages/sdk/test/integration/gc_test.ts b/packages/sdk/test/integration/gc_test.ts index 1da621683..99d5b88dd 100644 --- a/packages/sdk/test/integration/gc_test.ts +++ b/packages/sdk/test/integration/gc_test.ts @@ -1,10 +1,10 @@ import { describe, it, assert } from 'vitest'; -import { MaxTimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import yorkie, { Text, Tree, SyncMode } from '@yorkie-js-sdk/src/yorkie'; import { testRPCAddr, toDocKey, } from '@yorkie-js-sdk/test/integration/integration_helper'; +import { MaxVersionVector, versionVectorHelper } from '../helper/helper'; describe('Garbage Collection', function () { it('getGarbageLen should return the actual number of elements garbage-collected', async function ({ @@ -48,8 +48,18 @@ describe('Garbage Collection', function () { assert.equal(doc2.getGarbageLen(), gcNodeLen); // Actual garbage-collected nodes - assert.equal(doc1.garbageCollect(MaxTimeTicket), gcNodeLen); - assert.equal(doc2.garbageCollect(MaxTimeTicket), gcNodeLen); + assert.equal( + doc1.garbageCollect( + MaxVersionVector([client1.getID()!, client2.getID()!]), + ), + gcNodeLen, + ); + assert.equal( + doc2.garbageCollect( + MaxVersionVector([client1.getID()!, client2.getID()!]), + ), + gcNodeLen, + ); await client1.deactivate(); await client2.deactivate(); @@ -69,7 +79,20 @@ describe('Garbage Collection', function () { await client2.activate(); await client1.attach(doc1, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client2.attach(doc2, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + { actor: client2.getID()!, lamport: BigInt(2) }, + ]), + true, + ); doc1.update((root) => { root.t = new Tree({ @@ -91,44 +114,100 @@ describe('Garbage Collection', function () { ], }); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 0); - // (0, 0) -> (1, 0): syncedseqs:(0, 0) await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(3) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); - // (1, 0) -> (1, 1): syncedseqs:(0, 0) await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(3) }, + ]), + true, + ); doc2.update((root) => { root.t.editByPath([0, 0, 0], [0, 0, 2], { type: 'text', value: 'gh' }); }, 'removes 2'); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 2); // (1, 1) -> (1, 2): syncedseqs:(0, 1) await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 2); - // (1, 2) -> (2, 2): syncedseqs:(1, 1) await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 2); assert.equal(doc2.getGarbageLen(), 2); - // (2, 2) -> (2, 2): syncedseqs:(1, 2) await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 2); assert.equal(doc2.getGarbageLen(), 2); - // (2, 2) -> (2, 2): syncedseqs:(2, 2): meet GC condition await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 2); - // (2, 2) -> (2, 2): syncedseqs:(2, 2): meet GC condition await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 0); @@ -154,51 +233,120 @@ describe('Garbage Collection', function () { await client2.activate(); await client1.attach(doc1, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client2.attach(doc2, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + { actor: client2.getID()!, lamport: BigInt(2) }, + ]), + true, + ); doc1.update((root) => { root['1'] = 1; root['2'] = [1, 2, 3]; root['3'] = 3; }, 'sets 1, 2, 3'); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 0); - // (0, 0) -> (1, 0): syncedseqs:(0, 0) await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(3) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); - // (1, 0) -> (1, 1): syncedseqs:(0, 0) await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(3) }, + ]), + true, + ); doc2.update((root) => { delete root['2']; }, 'removes 2'); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 4); - // (1, 1) -> (1, 2): syncedseqs:(0, 1) await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 4); - // (1, 2) -> (2, 2): syncedseqs:(1, 1) await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 4); assert.equal(doc2.getGarbageLen(), 4); - // (2, 2) -> (2, 2): syncedseqs:(1, 2) await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 4); assert.equal(doc2.getGarbageLen(), 4); - // (2, 2) -> (2, 2): syncedseqs:(2, 2): meet GC condition await client1.sync(); + await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 4); - // (2, 2) -> (2, 2): syncedseqs:(2, 2): meet GC condition await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 0); @@ -222,7 +370,20 @@ describe('Garbage Collection', function () { await client2.activate(); await client1.attach(doc1, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client2.attach(doc2, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + { actor: client2.getID()!, lamport: BigInt(2) }, + ]), + true, + ); doc1.update((root) => { root.text = new Text(); @@ -230,46 +391,100 @@ describe('Garbage Collection', function () { root.textWithAttr = new Text(); root.textWithAttr.edit(0, 0, 'Hello World'); }, 'sets text'); - + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 0); - // (0, 0) -> (1, 0): syncedseqs:(0, 0) await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(3) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); - // (1, 0) -> (1, 1): syncedseqs:(0, 0) await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(3) }, + ]), + true, + ); doc2.update((root) => { root.text.edit(0, 1, 'a'); root.text.edit(1, 2, 'b'); root.textWithAttr.edit(0, 1, 'a', { b: '1' }); }, 'edit text type elements'); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 3); - // (1, 1) -> (1, 2): syncedseqs:(0, 1) await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 3); - // (1, 2) -> (2, 2): syncedseqs:(1, 1) await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 3); assert.equal(doc2.getGarbageLen(), 3); - // (2, 2) -> (2, 2): syncedseqs:(1, 2) await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 3); assert.equal(doc2.getGarbageLen(), 3); - // (2, 2) -> (2, 2): syncedseqs:(2, 2): meet GC condition await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 3); - // (2, 2) -> (2, 2): syncedseqs:(2, 2): meet GC condition await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 0); @@ -301,7 +516,20 @@ describe('Garbage Collection', function () { await client2.activate(); await client1.attach(doc1, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client2.attach(doc2, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + { actor: client2.getID()!, lamport: BigInt(2) }, + ]), + true, + ); doc1.update((root) => { root['1'] = 1; @@ -312,38 +540,73 @@ describe('Garbage Collection', function () { root['5'] = new Text(); root['5'].edit(0, 0, 'hi'); }, 'sets 1, 2, 3, 4, 5'); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 0); - // (0, 0) -> (1, 0): syncedseqs:(0, 0) await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(3) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); - // (1, 0) -> (1, 1): syncedseqs:(0, 0) await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(3) }, + ]), + true, + ); doc1.update((root) => { delete root['2']; root['4'].edit(0, 1, 'h'); root['5'].edit(0, 1, 'h', { b: '1' }); }, 'removes 2 and edit text type elements'); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(4) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 6); assert.equal(doc2.getGarbageLen(), 0); - // (1, 1) -> (2, 1): syncedseqs:(1, 0) await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(4) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 6); assert.equal(doc2.getGarbageLen(), 0); await client2.detach(doc2); - // (2, 1) -> (2, 2): syncedseqs:(1, x) await client2.sync(); assert.equal(doc1.getGarbageLen(), 6); assert.equal(doc2.getGarbageLen(), 6); - // (2, 2) -> (2, 2): syncedseqs:(2, x): meet GC condition await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 0); assert.equal(doc2.getGarbageLen(), 6); @@ -363,15 +626,39 @@ describe('Garbage Collection', function () { await cli.activate(); await cli.attach(doc, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc.getVersionVector(), [ + { actor: cli.getID()!, lamport: BigInt(1) }, + ]), + true, + ); doc.update((root) => { root.point = { x: 0, y: 0 }; }); + assert.equal( + versionVectorHelper(doc.getVersionVector(), [ + { actor: cli.getID()!, lamport: BigInt(2) }, + ]), + true, + ); doc.update((root) => { root.point = { x: 1, y: 1 }; }); + assert.equal( + versionVectorHelper(doc.getVersionVector(), [ + { actor: cli.getID()!, lamport: BigInt(3) }, + ]), + true, + ); doc.update((root) => { root.point = { x: 2, y: 2 }; }); + assert.equal( + versionVectorHelper(doc.getVersionVector(), [ + { actor: cli.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc.getGarbageLen(), 6); assert.equal(doc.getGarbageLenFromClone(), 6); }); @@ -383,21 +670,45 @@ describe('Garbage Collection', function () { const docKey = toDocKey(`${task.name}-${new Date().getTime()}`); const doc = new yorkie.Document(docKey); const cli = new yorkie.Client(testRPCAddr); - await cli.activate(); + await cli.activate(); await cli.attach(doc, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc.getVersionVector(), [ + { actor: cli.getID()!, lamport: BigInt(1) }, + ]), + true, + ); doc.update((root) => { root.list = [0, 1, 2]; root.list.push([3, 4, 5]); }); + assert.equal( + versionVectorHelper(doc.getVersionVector(), [ + { actor: cli.getID()!, lamport: BigInt(2) }, + ]), + true, + ); assert.equal('{"list":[0,1,2,[3,4,5]]}', doc.toJSON()); doc.update((root) => { delete root.list[1]; }); + assert.equal( + versionVectorHelper(doc.getVersionVector(), [ + { actor: cli.getID()!, lamport: BigInt(3) }, + ]), + true, + ); assert.equal('{"list":[0,2,[3,4,5]]}', doc.toJSON()); doc.update((root) => { delete (root.list[2] as Array)[1]; }); + assert.equal( + versionVectorHelper(doc.getVersionVector(), [ + { actor: cli.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal('{"list":[0,2,[3,5]]}', doc.toJSON()); assert.equal(doc.getGarbageLen(), 2); @@ -419,32 +730,107 @@ describe('Garbage Collection', function () { await client2.activate(); await client1.attach(doc1, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + ]), + true, + ); doc1.update((root) => (root.point = { x: 0, y: 0 })); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + ]), + true, + ); doc1.update((root) => (root.point.x = 1)); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(3) }, + ]), + true, + ); assert.equal(doc1.getGarbageLen(), 1); await client1.sync(); + assert.equal(doc1.getGarbageLen(), 0); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(3) }, + ]), + true, + ); await client2.attach(doc2, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(3) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); assert.equal(doc2.getGarbageLen(), 1); doc2.update((root) => (root.point.x = 2)); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(3) }, + { actor: client2.getID()!, lamport: BigInt(5) }, + ]), + true, + ); assert.equal(doc2.getGarbageLen(), 2); doc1.update((root) => (root.point = { x: 3, y: 3 })); - assert.equal(doc1.getGarbageLen(), 4); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(4) }, + ]), + true, + ); + assert.equal(doc1.getGarbageLen(), 3); await client1.sync(); - assert.equal(doc1.getGarbageLen(), 4); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); + assert.equal(doc1.getGarbageLen(), 3); await client1.sync(); - assert.equal(doc1.getGarbageLen(), 4); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); + assert.equal(doc1.getGarbageLen(), 3); await client2.sync(); - assert.equal(doc1.getGarbageLen(), 4); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(4) }, + { actor: client2.getID()!, lamport: BigInt(6) }, + ]), + true, + ); + assert.equal(doc1.getGarbageLen(), 3); await client1.sync(); - assert.equal(doc1.getGarbageLen(), 5); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(6) }, + { actor: client2.getID()!, lamport: BigInt(5) }, + ]), + true, + ); + assert.equal(doc1.getGarbageLen(), 4); await client2.sync(); - assert.equal(doc1.getGarbageLen(), 5); + assert.equal(doc1.getGarbageLen(), 4); await client1.sync(); assert.equal(doc1.getGarbageLen(), 0); + await client2.sync(); + assert.equal(doc2.getGarbageLen(), 0); await client1.deactivate(); await client2.deactivate(); @@ -460,7 +846,10 @@ describe('Garbage Collection', function () { delete root.shape; }); assert.equal(doc.getGarbageLen(), 4); // shape, point, x, y - assert.equal(doc.garbageCollect(MaxTimeTicket), 4); // The number of GC nodes must also be 4. + assert.equal( + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + 4, + ); // The number of GC nodes must also be 4. }); it('Should work properly when there are multiple nodes to be collected in text type', async function ({ @@ -475,23 +864,74 @@ describe('Garbage Collection', function () { await client1.activate(); await client2.activate(); await client1.attach(doc1, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client2.attach(doc2, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + { actor: client2.getID()!, lamport: BigInt(2) }, + ]), + true, + ); doc1.update((root) => { root.t = new yorkie.Text(); root.t.edit(0, 0, 'z'); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + ]), + true, + ); doc1.update((root) => { root.t.edit(0, 1, 'a'); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(3) }, + ]), + true, + ); doc1.update((root) => { root.t.edit(1, 1, 'b'); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(4) }, + ]), + true, + ); doc1.update((root) => { root.t.edit(2, 2, 'd'); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + ]), + true, + ); await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(6) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + { actor: client2.getID()!, lamport: BigInt(6) }, + ]), + true, + ); assert.equal(doc1.getRoot().t.toString(), 'abd'); assert.equal(doc2.getRoot().t.toString(), 'abd'); assert.equal(doc1.getGarbageLen(), 1); // z @@ -499,23 +939,82 @@ describe('Garbage Collection', function () { doc1.update((root) => { root.t.edit(2, 2, 'c'); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(7) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(7) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client2.sync(); - await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(7) }, + { actor: client2.getID()!, lamport: BigInt(8) }, + ]), + true, + ); + await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(7) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); + assert.equal(doc1.getRoot().t.toString(), 'abcd'); assert.equal(doc2.getRoot().t.toString(), 'abcd'); doc1.update((root) => { root.t.edit(1, 3, ''); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(8) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(8) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); assert.equal(doc1.getRoot().t.toString(), 'ad'); assert.equal(doc1.getGarbageLen(), 2); // b,c await client2.sync(); - await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(8) }, + { actor: client2.getID()!, lamport: BigInt(9) }, + ]), + true, + ); await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(8) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); + await client2.sync(); + assert.equal(doc2.getGarbageLen(), 0); assert.equal(doc2.getRoot().t.toString(), 'ad'); + await client1.sync(); assert.equal(doc1.getGarbageLen(), 0); await client1.deactivate(); @@ -534,7 +1033,20 @@ describe('Garbage Collection', function () { await client1.activate(); await client2.activate(); await client1.attach(doc1, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client2.attach(doc2, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + { actor: client2.getID()!, lamport: BigInt(2) }, + ]), + true, + ); doc1.update((root) => { root.t = new yorkie.Tree({ @@ -547,26 +1059,64 @@ describe('Garbage Collection', function () { ], }); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + ]), + true, + ); doc1.update((root) => { root.t.editByPath([0], [1], { type: 'text', value: 'a', }); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(3) }, + ]), + true, + ); doc1.update((root) => { root.t.editByPath([1], [1], { type: 'text', value: 'b', }); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(4) }, + ]), + true, + ); doc1.update((root) => { root.t.editByPath([2], [2], { type: 'text', value: 'd', }); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + ]), + true, + ); await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(6) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + { actor: client2.getID()!, lamport: BigInt(6) }, + ]), + true, + ); assert.equal(doc1.getRoot().t.toXML(), 'abd'); assert.equal(doc2.getRoot().t.toXML(), 'abd'); assert.equal(doc1.getGarbageLen(), 1); // z @@ -577,24 +1127,474 @@ describe('Garbage Collection', function () { value: 'c', }); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(7) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(7) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client2.sync(); - await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(7) }, + { actor: client2.getID()!, lamport: BigInt(8) }, + ]), + true, + ); assert.equal(doc1.getRoot().t.toXML(), 'abcd'); assert.equal(doc2.getRoot().t.toXML(), 'abcd'); doc1.update((root) => { root.t.editByPath([1], [3]); }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(8) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(8) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); assert.equal(doc1.getRoot().t.toXML(), 'ad'); assert.equal(doc1.getGarbageLen(), 2); // b,c await client2.sync(); - await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(8) }, + { actor: client2.getID()!, lamport: BigInt(9) }, + ]), + true, + ); await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(8) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); assert.equal(doc2.getRoot().t.toXML(), 'ad'); + assert.equal(doc1.getGarbageLen(), 2); + + await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(8) }, + { actor: client2.getID()!, lamport: BigInt(9) }, + ]), + true, + ); + assert.equal(doc2.getRoot().t.toXML(), 'ad'); + assert.equal(doc2.getGarbageLen(), 0); + + await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(8) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); + assert.equal(doc1.getRoot().t.toXML(), 'ad'); + assert.equal(doc1.getGarbageLen(), 0); + + await client1.deactivate(); + await client2.deactivate(); + }); + + it('concurrent garbage collection test', async function ({ task }) { + type TestDoc = { t: Text }; + const docKey = toDocKey(`${task.name}-${new Date().getTime()}`); + const doc1 = new yorkie.Document(docKey); + const doc2 = new yorkie.Document(docKey); + const client1 = new yorkie.Client(testRPCAddr); + const client2 = new yorkie.Client(testRPCAddr); + await client1.activate(); + await client2.activate(); + + await client1.attach(doc1, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + ]), + true, + ); + + await client2.attach(doc2, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + { actor: client2.getID()!, lamport: BigInt(2) }, + ]), + true, + ); + + doc1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'a'); + root.t.edit(1, 1, 'b'); + root.t.edit(2, 2, 'c'); + }, 'sets text'); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + ]), + true, + ); + + await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(3) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); + + await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(3) }, + ]), + true, + ); + + doc2.update((root) => { + root.t.edit(2, 2, 'c'); + }, 'insert c'); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); + + doc1.update((root) => { + root.t.edit(1, 3, ''); + }, 'delete bd'); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(4) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); + + assert.equal(doc1.getGarbageLen(), 2); + assert.equal(doc2.getGarbageLen(), 0); + + await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(4) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); + + await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(4) }, + { actor: client2.getID()!, lamport: BigInt(5) }, + ]), + true, + ); + + assert.equal(doc1.getGarbageLen(), 2); + assert.equal(doc2.getGarbageLen(), 2); + + doc2.update((root) => { + root.t.edit(2, 2, '1'); + }, 'insert 1'); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(4) }, + { actor: client2.getID()!, lamport: BigInt(6) }, + ]), + true, + ); + + await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(4) }, + { actor: client2.getID()!, lamport: BigInt(6) }, + ]), + true, + ); + + assert.equal(doc1.getGarbageLen(), 2); + assert.equal(doc2.getGarbageLen(), 0); + + await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(7) }, + { actor: client2.getID()!, lamport: BigInt(6) }, + ]), + true, + ); + + assert.equal(doc1.getGarbageLen(), 0); + assert.equal(doc2.getGarbageLen(), 0); + + await client1.deactivate(); + await client2.deactivate(); + }); + + it('concurrent garbage collection test(with pushonly)', async function ({ + task, + }) { + type TestDoc = { t: Text }; + const docKey = toDocKey(`${task.name}-${new Date().getTime()}`); + const doc1 = new yorkie.Document(docKey); + const doc2 = new yorkie.Document(docKey); + const client1 = new yorkie.Client(testRPCAddr); + const client2 = new yorkie.Client(testRPCAddr); + await client1.activate(); + await client2.activate(); + + await client1.attach(doc1, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + ]), + true, + ); + + await client2.attach(doc2, { syncMode: SyncMode.Manual }); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(1) }, + { actor: client2.getID()!, lamport: BigInt(2) }, + ]), + true, + ); + + // d1/vv = [c1:2] + doc1.update((root) => { + root.t = new Text(); + root.t.edit(0, 0, 'a'); + root.t.edit(1, 1, 'b'); + }, 'insert ab'); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + ]), + true, + ); + + await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(3) }, + { actor: client2.getID()!, lamport: BigInt(1) }, + ]), + true, + ); + + await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(3) }, + ]), + true, + ); + + // d2/vv = [c1:2, c2:4] + doc2.update((root) => { + root.t.edit(2, 2, 'd'); + }, 'insert d'); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); + + await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); + + await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(5) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); + + // d2/vv = [c1:2, c2:5] + doc2.update((root) => { + root.t.edit(2, 2, 'c'); + }, 'insert c'); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(5) }, + ]), + true, + ); + + // c1/vv = [c1:6, c2:4] + doc1.update((root) => { + root.t.edit(1, 3, ''); + }, 'remove ac'); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(6) }, + { actor: client2.getID()!, lamport: BigInt(4) }, + ]), + true, + ); + + // Sync with PushOnly + await client2.changeSyncMode(doc2, SyncMode.RealtimePushOnly); + await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(5) }, + ]), + true, + ); + + await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(7) }, + { actor: client2.getID()!, lamport: BigInt(5) }, + ]), + true, + ); + + // d2/vv = [c1:2, c2:6] + doc2.update((root) => { + root.t.edit(2, 2, '1'); + }, 'insert 1 (pushonly)'); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(6) }, + ]), + true, + ); + + await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(6) }, + ]), + true, + ); + + await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(8) }, + { actor: client2.getID()!, lamport: BigInt(6) }, + ]), + true, + ); + + assert.equal(doc1.getGarbageLen(), 2); + assert.equal(doc2.getGarbageLen(), 0); + + // c2/vv = [c1:2, c2:7] + doc2.update((root) => { + root.t.edit(2, 2, '2'); + }, 'insert 2 (pushonly)'); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(7) }, + ]), + true, + ); + + await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(2) }, + { actor: client2.getID()!, lamport: BigInt(7) }, + ]), + true, + ); + + await client2.changeSyncMode(doc2, SyncMode.Manual); + await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(6) }, + { actor: client2.getID()!, lamport: BigInt(8) }, + ]), + true, + ); + + await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(9) }, + { actor: client2.getID()!, lamport: BigInt(7) }, + ]), + true, + ); + + assert.equal(doc1.getGarbageLen(), 2); + assert.equal(doc2.getGarbageLen(), 2); + + await client2.sync(); + assert.equal( + versionVectorHelper(doc2.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(6) }, + { actor: client2.getID()!, lamport: BigInt(8) }, + ]), + true, + ); + + assert.equal(doc1.getGarbageLen(), 2); + assert.equal(doc2.getGarbageLen(), 0); + + await client1.sync(); + assert.equal( + versionVectorHelper(doc1.getVersionVector(), [ + { actor: client1.getID()!, lamport: BigInt(9) }, + { actor: client2.getID()!, lamport: BigInt(7) }, + ]), + true, + ); + assert.equal(doc1.getGarbageLen(), 0); + assert.equal(doc2.getGarbageLen(), 0); await client1.deactivate(); await client2.deactivate(); diff --git a/packages/sdk/test/unit/document/crdt/root_test.ts b/packages/sdk/test/unit/document/crdt/root_test.ts index 6e94776a0..042076750 100644 --- a/packages/sdk/test/unit/document/crdt/root_test.ts +++ b/packages/sdk/test/unit/document/crdt/root_test.ts @@ -13,6 +13,7 @@ import { CRDTArray } from '@yorkie-js-sdk/src/document/crdt/array'; import { CRDTText } from '@yorkie-js-sdk/src/document/crdt/text'; import { RGATreeSplit } from '@yorkie-js-sdk/src/document/crdt/rga_tree_split'; import { Text } from '@yorkie-js-sdk/src/yorkie'; +import { MaxVersionVector } from '@yorkie-js-sdk/test/helper/helper'; describe('ROOT', function () { it('basic test', function () { @@ -103,7 +104,7 @@ describe('ROOT', function () { assert.equal(0, arrJs2?.[0]); assert.equal(2, arrJs2?.[1]); - assert.equal(1, root.garbageCollect(MaxTimeTicket)); + assert.equal(1, root.garbageCollect(MaxVersionVector([]))); assert.equal(0, root.getGarbageLen()); }); @@ -129,7 +130,7 @@ describe('ROOT', function () { text.edit(0, 6, ''); assert.equal(2, root.getGarbageLen()); - assert.equal(2, root.garbageCollect(MaxTimeTicket)); + assert.equal(2, root.garbageCollect(MaxVersionVector([]))); assert.equal('[0:00:0:0 ][0:00:3:0 Yorkie]', text.toTestString()); assert.equal(0, root.getGarbageLen()); }); diff --git a/packages/sdk/test/unit/document/document_test.ts b/packages/sdk/test/unit/document/document_test.ts index e91b9286c..e0c3bac6e 100644 --- a/packages/sdk/test/unit/document/document_test.ts +++ b/packages/sdk/test/unit/document/document_test.ts @@ -15,9 +15,11 @@ */ import { describe, it, assert, vi, afterEach } from 'vitest'; -import { EventCollector } from '@yorkie-js-sdk/test/helper/helper'; +import { + EventCollector, + MaxVersionVector, +} from '@yorkie-js-sdk/test/helper/helper'; -import { MaxTimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { Document, DocEventType } from '@yorkie-js-sdk/src/document/document'; import { OperationInfo } from '@yorkie-js-sdk/src/document/operation/operation'; import { JSONArray, Text, Counter, Tree } from '@yorkie-js-sdk/src/yorkie'; @@ -1233,7 +1235,7 @@ describe.sequential('Document', function () { assert.equal('{}', doc.toSortedJSON()); assert.equal(2, doc.getGarbageLen()); - doc.garbageCollect(MaxTimeTicket); + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])); assert.equal('{}', doc.toSortedJSON()); assert.equal(0, doc.getGarbageLen()); }); @@ -1249,7 +1251,7 @@ describe.sequential('Document', function () { doc.update((root) => root.k1.edit(1, 3, '')); assert.equal(doc.getRoot().k1.getTreeByID().size(), 3); - doc.garbageCollect(MaxTimeTicket); + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])); assert.equal(doc.getRoot().k1.getTreeByID().size(), 2); }); diff --git a/packages/sdk/test/unit/document/gc_test.ts b/packages/sdk/test/unit/document/gc_test.ts index fa0aa2ab5..597b70d28 100644 --- a/packages/sdk/test/unit/document/gc_test.ts +++ b/packages/sdk/test/unit/document/gc_test.ts @@ -16,10 +16,9 @@ import { CRDTArray } from '@yorkie-js-sdk/src/document/crdt/array'; import { CRDTTreeNode } from '@yorkie-js-sdk/src/document/crdt/tree'; -import { MaxTimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { IndexTreeNode } from '@yorkie-js-sdk/src/util/index_tree'; import yorkie, { Tree, Text } from '@yorkie-js-sdk/src/yorkie'; -import { timeT } from '@yorkie-js-sdk/test/helper/helper'; +import { MaxVersionVector } from '@yorkie-js-sdk/test/helper/helper'; import { describe, it, assert } from 'vitest'; // `getNodeLength` returns the number of nodes in the given tree. @@ -58,7 +57,10 @@ describe('Garbage Collection', function () { }, 'deletes 2'); assert.equal(doc.toSortedJSON(), '{"1":1,"3":3}'); assert.equal(doc.getGarbageLen(), 4); - assert.equal(doc.garbageCollect(MaxTimeTicket), 4); + assert.equal( + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + 4, + ); assert.equal(doc.getGarbageLen(), 0); }); @@ -81,7 +83,10 @@ describe('Garbage Collection', function () { }, 'deletes 2'); assert.equal(doc.toSortedJSON(), '{"1":1,"3":3}'); assert.equal(doc.getGarbageLen(), 4); - assert.equal(doc.garbageCollect(MaxTimeTicket), 0); + assert.equal( + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + 0, + ); assert.equal(doc.getGarbageLen(), 4); }); @@ -96,7 +101,10 @@ describe('Garbage Collection', function () { delete root['1']; }, 'deletes the array'); - assert.equal(doc.garbageCollect(MaxTimeTicket), size + 1); + assert.equal( + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + size + 1, + ); }); it('should collect garbage for nested elements', function () { @@ -114,7 +122,10 @@ describe('Garbage Collection', function () { assert.equal(doc.toSortedJSON(), '{"list":[1,3]}'); assert.equal(doc.getGarbageLen(), 1); - assert.equal(doc.garbageCollect(MaxTimeTicket), 1); + assert.equal( + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + 1, + ); assert.equal(doc.getGarbageLen(), 0); const root = (doc.getRootObject().get('list') as CRDTArray) @@ -139,7 +150,7 @@ describe('Garbage Collection', function () { ); assert.equal(doc.getGarbageLen(), 1); - doc.garbageCollect(MaxTimeTicket); + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])); assert.equal(doc.getGarbageLen(), 0); assert.equal( @@ -174,7 +185,10 @@ describe('Garbage Collection', function () { assert.equal(doc.toSortedJSON(), '{"k1":[{"val":"c"},{"val":"d"}]}'); assert.equal(doc.getGarbageLen(), 2); - assert.equal(doc.garbageCollect(MaxTimeTicket), 2); + assert.equal( + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + 2, + ); assert.equal(doc.getGarbageLen(), 0); }); @@ -208,7 +222,10 @@ describe('Garbage Collection', function () { const expectedGarbageLen = 4; assert.equal(doc.getGarbageLen(), expectedGarbageLen); - assert.equal(doc.garbageCollect(MaxTimeTicket), expectedGarbageLen); + assert.equal( + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + expectedGarbageLen, + ); const empty = 0; assert.equal(doc.getGarbageLen(), empty); @@ -249,7 +266,10 @@ describe('Garbage Collection', function () { doc.getRoot().t.getIndexTree().getRoot(), ); assert.equal(doc.getGarbageLen(), 2); - assert.equal(doc.garbageCollect(MaxTimeTicket), 2); + assert.equal( + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + 2, + ); assert.equal(doc.getGarbageLen(), 0); let nodeLengthAfterGC = getNodeLength( doc.getRoot().t.getIndexTree().getRoot(), @@ -266,7 +286,10 @@ describe('Garbage Collection', function () { doc.getRoot().t.getIndexTree().getRoot(), ); assert.equal(doc.getGarbageLen(), 1); - assert.equal(doc.garbageCollect(MaxTimeTicket), 1); + assert.equal( + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + 1, + ); assert.equal(doc.getGarbageLen(), 0); nodeLengthAfterGC = getNodeLength(doc.getRoot().t.getIndexTree().getRoot()); assert.equal(nodeLengthBeforeGC - nodeLengthAfterGC, 1); @@ -284,7 +307,10 @@ describe('Garbage Collection', function () { doc.getRoot().t.getIndexTree().getRoot(), ); assert.equal(doc.getGarbageLen(), 5); - assert.equal(doc.garbageCollect(MaxTimeTicket), 5); + assert.equal( + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + 5, + ); assert.equal(doc.getGarbageLen(), 0); nodeLengthAfterGC = getNodeLength(doc.getRoot().t.getIndexTree().getRoot()); assert.equal(nodeLengthBeforeGC - nodeLengthAfterGC, 5); @@ -325,7 +351,10 @@ describe('Garbage Collection', function () { assert.equal(doc.getRoot().t.toXML(), `

`); assert.equal(doc.getGarbageLen(), 3); - assert.equal(doc.garbageCollect(MaxTimeTicket), 3); + assert.equal( + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + 3, + ); assert.equal(doc.getGarbageLen(), 0); }); @@ -338,7 +367,10 @@ describe('Garbage Collection', function () { delete root.shape; }); assert.equal(doc.getGarbageLen(), 4); // shape, point, x, y - assert.equal(doc.garbageCollect(MaxTimeTicket), 4); // The number of GC nodes must also be 4. + assert.equal( + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])), + 4, + ); // The number of GC nodes must also be 4. }); }); @@ -506,14 +538,16 @@ describe('Garbage Collection for tree', () => { } else if (code === OpCode.DeleteNode) { root.t.edit(0, 2, undefined, 0); } else if (code === OpCode.GC) { - doc.garbageCollect(MaxTimeTicket); + doc.garbageCollect( + MaxVersionVector([doc.getChangeID().getActorID()]), + ); } }); assert.equal(doc.getRoot().t.toXML(), expectXML); assert.equal(doc.getGarbageLen(), garbageLen); } - doc.garbageCollect(MaxTimeTicket); + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])); assert.equal(doc.getGarbageLen(), 0); }); }); @@ -597,14 +631,14 @@ describe('Garbage Collection for text', () => { } else if (code === OpCode.DeleteNode) { root.t.edit(0, 2, ''); } else if (code === OpCode.GC) { - doc.garbageCollect(timeT()); + doc.garbageCollect(doc.getChangeID().getVersionVector()); } }); assert.equal(doc.getRoot().t.toJSON(), expectXML); assert.equal(doc.getGarbageLen(), garbageLen); } - doc.garbageCollect(MaxTimeTicket); + doc.garbageCollect(MaxVersionVector([doc.getChangeID().getActorID()])); assert.equal(doc.getGarbageLen(), 0); }); });