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); }); });