Skip to content

Commit 7c7033b

Browse files
committed
Use server-provided timestamp to sweep old tombstones
Use `serialTimestamp` field from ObjectMessage to know when a tombstone was created for an object or a map entry. Resolves PUB-1837
1 parent 29f4423 commit 7c7033b

File tree

9 files changed

+393
-57
lines changed

9 files changed

+393
-57
lines changed

src/plugins/objects/livecounter.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
ObjectOperation,
88
ObjectOperationAction,
99
ObjectsCounterOp,
10-
ObjectState,
1110
} from './objectmessage';
1211
import { Objects } from './objects';
1312

@@ -37,9 +36,9 @@ export class LiveCounter extends LiveObject<LiveCounterData, LiveCounterUpdate>
3736
*
3837
* @internal
3938
*/
40-
static fromObjectState(objects: Objects, objectState: ObjectState<ObjectData>): LiveCounter {
41-
const obj = new LiveCounter(objects, objectState.objectId);
42-
obj.overrideWithObjectState(objectState);
39+
static fromObjectState(objects: Objects, objectMessage: ObjectMessage): LiveCounter {
40+
const obj = new LiveCounter(objects, objectMessage.object!.objectId);
41+
obj.overrideWithObjectState(objectMessage);
4342
return obj;
4443
}
4544

@@ -216,7 +215,7 @@ export class LiveCounter extends LiveObject<LiveCounterData, LiveCounterUpdate>
216215
break;
217216

218217
case ObjectOperationAction.OBJECT_DELETE:
219-
update = this._applyObjectDelete();
218+
update = this._applyObjectDelete(msg);
220219
break;
221220

222221
default:
@@ -234,7 +233,12 @@ export class LiveCounter extends LiveObject<LiveCounterData, LiveCounterUpdate>
234233
* @internal
235234
* @spec RTLC6
236235
*/
237-
overrideWithObjectState(objectState: ObjectState<ObjectData>): LiveCounterUpdate | LiveObjectUpdateNoop {
236+
overrideWithObjectState(objectMessage: ObjectMessage): LiveCounterUpdate | LiveObjectUpdateNoop {
237+
const objectState = objectMessage.object;
238+
if (objectState == null) {
239+
throw new this._client.ErrorInfo(`Missing object state; LiveCounter objectId=${this.getObjectId()}`, 92000, 500);
240+
}
241+
238242
if (objectState.objectId !== this.getObjectId()) {
239243
throw new this._client.ErrorInfo(
240244
`Invalid object state: object state objectId=${objectState.objectId}; LiveCounter objectId=${this.getObjectId()}`,
@@ -274,7 +278,7 @@ export class LiveCounter extends LiveObject<LiveCounterData, LiveCounterUpdate>
274278
const previousDataRef = this._dataRef;
275279
if (objectState.tombstone) {
276280
// tombstone this object and ignore the data from the object state message
277-
this.tombstone();
281+
this.tombstone(objectMessage);
278282
} else {
279283
// override data for this object with data from the object state
280284
this._createOperationIsMerged = false; // RTLC6b

src/plugins/objects/livemap.ts

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
ObjectsMapEntry,
1515
ObjectsMapOp,
1616
ObjectsMapSemantics,
17-
ObjectState,
1817
} from './objectmessage';
1918
import { Objects } from './objects';
2019

@@ -42,9 +41,6 @@ export type LiveMapObjectData = ObjectIdObjectData | ValueObjectData;
4241

4342
export interface LiveMapEntry {
4443
tombstone: boolean;
45-
/**
46-
* Can't use serial from the operation that deleted the entry for the same reason as for {@link LiveObject} tombstones, see explanation there.
47-
*/
4844
tombstonedAt: number | undefined;
4945
timeserial: string | undefined;
5046
data: LiveMapObjectData | undefined;
@@ -84,12 +80,9 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
8480
*
8581
* @internal
8682
*/
87-
static fromObjectState<T extends API.LiveMapType>(
88-
objects: Objects,
89-
objectState: ObjectState<ObjectData>,
90-
): LiveMap<T> {
91-
const obj = new LiveMap<T>(objects, objectState.map?.semantics!, objectState.objectId);
92-
obj.overrideWithObjectState(objectState);
83+
static fromObjectState<T extends API.LiveMapType>(objects: Objects, objectMessage: ObjectMessage): LiveMap<T> {
84+
const obj = new LiveMap<T>(objects, objectMessage.object!.map!.semantics!, objectMessage.object!.objectId);
85+
obj.overrideWithObjectState(objectMessage);
9386
return obj;
9487
}
9588

@@ -455,12 +448,12 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
455448
// leave an explicit return here, so that TS knows that update object is always set after the switch statement.
456449
return;
457450
} else {
458-
update = this._applyMapRemove(op.mapOp, opSerial);
451+
update = this._applyMapRemove(op.mapOp, opSerial, msg.serialTimestamp);
459452
}
460453
break;
461454

462455
case ObjectOperationAction.OBJECT_DELETE:
463-
update = this._applyObjectDelete();
456+
update = this._applyObjectDelete(msg);
464457
break;
465458

466459
default:
@@ -478,7 +471,12 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
478471
* @internal
479472
* @spec RTLM6
480473
*/
481-
overrideWithObjectState(objectState: ObjectState<ObjectData>): LiveMapUpdate<T> | LiveObjectUpdateNoop {
474+
overrideWithObjectState(objectMessage: ObjectMessage): LiveMapUpdate<T> | LiveObjectUpdateNoop {
475+
const objectState = objectMessage.object;
476+
if (objectState == null) {
477+
throw new this._client.ErrorInfo(`Missing object state; LiveMap objectId=${this.getObjectId()}`, 92000, 500);
478+
}
479+
482480
if (objectState.objectId !== this.getObjectId()) {
483481
throw new this._client.ErrorInfo(
484482
`Invalid object state: object state objectId=${objectState.objectId}; LiveMap objectId=${this.getObjectId()}`,
@@ -534,7 +532,7 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
534532
const previousDataRef = this._dataRef;
535533
if (objectState.tombstone) {
536534
// tombstone this object and ignore the data from the object state message
537-
this.tombstone();
535+
this.tombstone(objectMessage);
538536
} else {
539537
// override data for this object with data from the object state
540538
this._createOperationIsMerged = false; // RTLM6b
@@ -644,7 +642,7 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
644642
let update: LiveMapUpdate<T> | LiveObjectUpdateNoop;
645643
if (entry.tombstone === true) {
646644
// RTLM6d1b - entry in MAP_CREATE op is removed, try to apply MAP_REMOVE op
647-
update = this._applyMapRemove({ key }, opSerial);
645+
update = this._applyMapRemove({ key }, opSerial, entry.serialTimestamp);
648646
} else {
649647
// RTLM6d1a - entry in MAP_CREATE op is not removed, try to set it via MAP_SET op
650648
update = this._applyMapSet({ key, data: entry.data }, opSerial);
@@ -726,7 +724,7 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
726724
Utils.isNil(op.data.string))
727725
) {
728726
throw new ErrorInfo(
729-
`Invalid object data for MAP_SET op on objectId=${this.getObjectId()} on key=${op.key}`,
727+
`Invalid object data for MAP_SET op on objectId=${this.getObjectId()} on key="${op.key}"`,
730728
92000,
731729
500,
732730
);
@@ -779,6 +777,7 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
779777
private _applyMapRemove(
780778
op: ObjectsMapOp<ObjectData>,
781779
opSerial: string | undefined,
780+
opTimestamp: number | undefined,
782781
): LiveMapUpdate<T> | LiveObjectUpdateNoop {
783782
const existingEntry = this._dataRef.data.get(op.key);
784783
// RTLM8a
@@ -793,17 +792,30 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
793792
return { noop: true };
794793
}
795794

795+
let tombstonedAt: number;
796+
if (opTimestamp != null) {
797+
tombstonedAt = opTimestamp;
798+
} else {
799+
this._client.Logger.logAction(
800+
this._client.logger,
801+
this._client.Logger.LOG_MINOR,
802+
'LiveMap._applyMapRemove()',
803+
`map key has been removed but no "serialTimestamp" found in the message, using local clock instead; key="${op.key}", objectId=${this.getObjectId()}`,
804+
);
805+
tombstonedAt = Date.now(); // best-effort estimate since no timestamp provided by the server
806+
}
807+
796808
if (existingEntry) {
797809
// RTLM8a2
798810
existingEntry.tombstone = true; // RTLM8a2c
799-
existingEntry.tombstonedAt = Date.now();
811+
existingEntry.tombstonedAt = tombstonedAt;
800812
existingEntry.timeserial = opSerial; // RTLM8a2b
801813
existingEntry.data = undefined; // RTLM8a2a
802814
} else {
803815
// RTLM8b, RTLM8b1
804816
const newEntry: LiveMapEntry = {
805817
tombstone: true, // RTLM8b2
806-
tombstonedAt: Date.now(),
818+
tombstonedAt: tombstonedAt,
807819
timeserial: opSerial,
808820
data: undefined,
809821
};
@@ -869,12 +881,27 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
869881
}
870882
}
871883

884+
let tombstonedAt: number | undefined;
885+
if (entry.tombstone === true) {
886+
if (entry.serialTimestamp != null) {
887+
tombstonedAt = entry.serialTimestamp;
888+
} else {
889+
this._client.Logger.logAction(
890+
this._client.logger,
891+
this._client.Logger.LOG_MINOR,
892+
'LiveMap._liveMapDataFromMapEntries()',
893+
`map key is removed but no "serialTimestamp" found, using local clock instead; key="${key}", objectId=${this.getObjectId()}`,
894+
);
895+
tombstonedAt = Date.now(); // best-effort estimate since no timestamp provided by the server
896+
}
897+
}
898+
872899
const liveDataEntry: LiveMapEntry = {
873900
timeserial: entry.timeserial,
874901
data: liveData,
875902
// consider object as tombstoned only if we received an explicit flag stating that. otherwise it exists
876903
tombstone: entry.tombstone === true,
877-
tombstonedAt: entry.tombstone === true ? Date.now() : undefined,
904+
tombstonedAt,
878905
};
879906

880907
liveMapData.data.set(key, liveDataEntry);

src/plugins/objects/liveobject.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type BaseClient from 'common/lib/client/baseclient';
22
import type EventEmitter from 'common/lib/util/eventemitter';
3-
import { ObjectData, ObjectMessage, ObjectOperation, ObjectState } from './objectmessage';
3+
import { ObjectData, ObjectMessage, ObjectOperation } from './objectmessage';
44
import { Objects } from './objects';
55

66
export enum LiveObjectSubscriptionEvent {
@@ -51,14 +51,6 @@ export abstract class LiveObject<
5151
protected _siteTimeserials: Record<string, string>;
5252
protected _createOperationIsMerged: boolean;
5353
private _tombstone: boolean;
54-
/**
55-
* Even though the {@link ObjectMessage.serial} value from the operation that deleted the object contains the timestamp value,
56-
* the serial should be treated as an opaque string on the client, meaning we should not attempt to parse it.
57-
*
58-
* Therefore, we need to set our own timestamp using local clock when the object is deleted client-side.
59-
* Strictly speaking, this does make an assumption about the client clock not being too heavily skewed behind the server,
60-
* but it is an acceptable compromise for the time being, as the likelihood of encountering a race here is pretty low given the grace periods we use.
61-
*/
6254
private _tombstonedAt: number | undefined;
6355

6456
protected constructor(
@@ -159,9 +151,19 @@ export abstract class LiveObject<
159151
*
160152
* @internal
161153
*/
162-
tombstone(): TUpdate {
154+
tombstone(objectMessage: ObjectMessage): TUpdate {
163155
this._tombstone = true;
164-
this._tombstonedAt = Date.now();
156+
if (objectMessage.serialTimestamp != null) {
157+
this._tombstonedAt = objectMessage.serialTimestamp;
158+
} else {
159+
this._client.Logger.logAction(
160+
this._client.logger,
161+
this._client.Logger.LOG_MINOR,
162+
'LiveObject.tombstone()',
163+
`object has been tombstoned but no "serialTimestamp" found in the message, using local clock instead; objectId=${this.getObjectId()}`,
164+
);
165+
this._tombstonedAt = Date.now(); // best-effort estimate since no timestamp provided by the server
166+
}
165167
const update = this.clearData();
166168
this._lifecycleEvents.emit(LiveObjectLifecycleEvent.deleted);
167169

@@ -210,8 +212,8 @@ export abstract class LiveObject<
210212
return !siteSerial || opSerial > siteSerial;
211213
}
212214

213-
protected _applyObjectDelete(): TUpdate {
214-
return this.tombstone();
215+
protected _applyObjectDelete(objectMessage: ObjectMessage): TUpdate {
216+
return this.tombstone(objectMessage);
215217
}
216218

217219
/**
@@ -221,7 +223,7 @@ export abstract class LiveObject<
221223
*/
222224
abstract applyOperation(op: ObjectOperation<ObjectData>, msg: ObjectMessage): void;
223225
/**
224-
* Overrides internal data for this LiveObject with data from the given object state.
226+
* Overrides internal data for this LiveObject with object state from the given object message.
225227
* Provided object state should hold a valid data for current LiveObject, e.g. counter data for LiveCounter, map data for LiveMap.
226228
*
227229
* Object states are received during sync sequence, and sync sequence is a source of truth for the current state of the objects,
@@ -232,7 +234,7 @@ export abstract class LiveObject<
232234
*
233235
* @internal
234236
*/
235-
abstract overrideWithObjectState(objectState: ObjectState<ObjectData>): TUpdate | LiveObjectUpdateNoop;
237+
abstract overrideWithObjectState(objectMessage: ObjectMessage): TUpdate | LiveObjectUpdateNoop;
236238
/**
237239
* @internal
238240
*/

src/plugins/objects/objectmessage.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ export interface ObjectsMapEntry<TData> {
9999
* and treat it as the "earliest possible" serial for comparison purposes.
100100
*/
101101
timeserial?: string; // OME2b
102+
/** A timestamp from the {@link timeserial} field. Only present if {@link tombstone} is `true` */
103+
serialTimestamp?: number; // OME2d
102104
/** The data that represents the value of the map entry. */
103105
data?: TData; // OME2c
104106
}
@@ -344,6 +346,7 @@ function copyMsg(
344346
connectionId: msg.connectionId,
345347
timestamp: msg.timestamp,
346348
serial: msg.serial,
349+
serialTimestamp: msg.serialTimestamp,
347350
siteCode: msg.siteCode,
348351
};
349352

@@ -385,6 +388,8 @@ export class ObjectMessage {
385388
object?: ObjectState<ObjectData>; // OM2g
386389
/** An opaque string that uniquely identifies this object message. */
387390
serial?: string; // OM2h
391+
/** A timestamp from the {@link serial} field. */
392+
serialTimestamp?: number; // OM2j
388393
/** An opaque string used as a key to update the map of serial values on an object. */
389394
siteCode?: string; // OM2i
390395

@@ -479,6 +484,8 @@ export class WireObjectMessage {
479484
object?: ObjectState<WireObjectData>; // OM2g
480485
/** An opaque string that uniquely identifies this object message. */
481486
serial?: string; // OM2h
487+
/** A timestamp from the {@link serial} field. */
488+
serialTimestamp?: number; // OM2j
482489
/** An opaque string used as a key to update the map of serial values on an object. */
483490
siteCode?: string; // OM2i
484491

src/plugins/objects/objects.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ export class Objects {
393393

394394
// RTO5c1a
395395
if (existingObject) {
396-
const update = existingObject.overrideWithObjectState(entry.objectState); // RTO5c1a1
396+
const update = existingObject.overrideWithObjectState(entry.objectMessage); // RTO5c1a1
397397
// store updates to call subscription callbacks for all of them once the sync sequence is completed.
398398
// this will ensure that clients get notified about the changes only once everything has been applied.
399399
existingObjectUpdates.push({ object: existingObject, update });
@@ -406,11 +406,11 @@ export class Objects {
406406
const objectType = entry.objectType;
407407
switch (objectType) {
408408
case 'LiveCounter':
409-
newObject = LiveCounter.fromObjectState(this, entry.objectState); // RTO5c1b1a
409+
newObject = LiveCounter.fromObjectState(this, entry.objectMessage); // RTO5c1b1a
410410
break;
411411

412412
case 'LiveMap':
413-
newObject = LiveMap.fromObjectState(this, entry.objectState); // RTO5c1b1b
413+
newObject = LiveMap.fromObjectState(this, entry.objectMessage); // RTO5c1b1b
414414
break;
415415

416416
default:

src/plugins/objects/syncobjectsdatapool.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type BaseClient from 'common/lib/client/baseclient';
22
import type RealtimeChannel from 'common/lib/client/realtimechannel';
3-
import { ObjectData, ObjectMessage, ObjectState } from './objectmessage';
3+
import { ObjectMessage } from './objectmessage';
44
import { Objects } from './objects';
55

66
export interface LiveObjectDataEntry {
7-
objectState: ObjectState<ObjectData>;
7+
objectMessage: ObjectMessage;
88
objectType: 'LiveMap' | 'LiveCounter';
99
}
1010

@@ -64,9 +64,9 @@ export class SyncObjectsDataPool {
6464
const objectState = objectMessage.object;
6565

6666
if (objectState.counter) {
67-
this._pool.set(objectState.objectId, this._createLiveCounterDataEntry(objectState));
67+
this._pool.set(objectState.objectId, this._createLiveCounterDataEntry(objectMessage));
6868
} else if (objectState.map) {
69-
this._pool.set(objectState.objectId, this._createLiveMapDataEntry(objectState));
69+
this._pool.set(objectState.objectId, this._createLiveMapDataEntry(objectMessage));
7070
} else {
7171
this._client.Logger.logAction(
7272
this._client.logger,
@@ -78,18 +78,18 @@ export class SyncObjectsDataPool {
7878
}
7979
}
8080

81-
private _createLiveCounterDataEntry(objectState: ObjectState<ObjectData>): LiveCounterDataEntry {
81+
private _createLiveCounterDataEntry(objectMessage: ObjectMessage): LiveCounterDataEntry {
8282
const newEntry: LiveCounterDataEntry = {
83-
objectState,
83+
objectMessage,
8484
objectType: 'LiveCounter',
8585
};
8686

8787
return newEntry;
8888
}
8989

90-
private _createLiveMapDataEntry(objectState: ObjectState<ObjectData>): LiveMapDataEntry {
90+
private _createLiveMapDataEntry(objectMessage: ObjectMessage): LiveMapDataEntry {
9191
const newEntry: LiveMapDataEntry = {
92-
objectState,
92+
objectMessage,
9393
objectType: 'LiveMap',
9494
};
9595

0 commit comments

Comments
 (0)