Skip to content

Commit 92c8f11

Browse files
committed
Surface live objects to the end users only when they become valid
Valid live objects are those for which the realtime client has seen the corresponding CREATE operation. Resolves DTP-1104
1 parent 74b34e0 commit 92c8f11

File tree

7 files changed

+424
-45
lines changed

7 files changed

+424
-45
lines changed

src/plugins/liveobjects/livecounter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export class LiveCounter extends LiveObject<LiveCounterData, LiveCounterUpdate>
123123

124124
const previousDataRef = this._dataRef;
125125
// override all relevant data for this object with data from the state object
126-
this._createOperationIsMerged = false;
126+
this._setCreateOperationIsMerged(false);
127127
this._dataRef = { data: stateObject.counter?.count ?? 0 };
128128
// should default to empty map if site timeserials do not exist on the state object, so that any future operation can be applied to this object
129129
this._siteTimeserials = stateObject.siteTimeserials ?? {};
@@ -149,7 +149,7 @@ export class LiveCounter extends LiveObject<LiveCounterData, LiveCounterUpdate>
149149
// if we got here, it means that current counter instance is missing the initial value in its data reference,
150150
// which we're going to add now.
151151
this._dataRef.data += stateOperation.counter?.count ?? 0;
152-
this._createOperationIsMerged = true;
152+
this._setCreateOperationIsMerged(true);
153153

154154
return { update: { inc: stateOperation.counter?.count ?? 0 } };
155155
}

src/plugins/liveobjects/livemap.ts

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import deepEqual from 'deep-equal';
22

33
import type * as API from '../../../ably';
4-
import { LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject';
4+
import { BufferedOperation, LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject';
55
import { LiveObjects } from './liveobjects';
66
import {
77
MapSemantics,
@@ -77,10 +77,12 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
7777

7878
/**
7979
* Returns the value associated with the specified key in the underlying Map object.
80-
* If no element is associated with the specified key, undefined is returned.
81-
* If the value that is associated to the provided key is an objectId string of another Live Object,
82-
* then you will get a reference to that Live Object if it exists in the local pool, or undefined otherwise.
83-
* If the value is not an objectId, then you will get that value.
80+
*
81+
* - If no entry is associated with the specified key, `undefined` is returned.
82+
* - If map entry is tombstoned (deleted), `undefined` is returned.
83+
* - If the value associated with the provided key is an objectId string of another Live Object, a reference to that Live Object
84+
* is returned, provided it exists in the local pool and is valid. Otherwise, `undefined` is returned.
85+
* - If the value is not an objectId, then that value is returned.
8486
*/
8587
// force the key to be of type string as we only allow strings as key in a map
8688
get<TKey extends keyof T & string>(key: TKey): T[TKey] {
@@ -94,14 +96,26 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
9496
return undefined as T[TKey];
9597
}
9698

97-
// data exists for non-tombstoned elements
99+
// data always exists for non-tombstoned elements
98100
const data = element.data!;
99101

100102
if ('value' in data) {
103+
// map entry has a primitive type value, just return it as is.
101104
return data.value as T[TKey];
102-
} else {
103-
return this._liveObjects.getPool().get(data.objectId) as T[TKey];
104105
}
106+
107+
// map entry points to another object, get it from the pool
108+
const refObject: LiveObject | undefined = this._liveObjects.getPool().get(data.objectId);
109+
if (!refObject) {
110+
return undefined as T[TKey];
111+
}
112+
113+
if (!refObject.isValid()) {
114+
// non-valid objects must not be surfaced to the end users
115+
return undefined as T[TKey];
116+
}
117+
118+
return refObject as API.LiveObject as T[TKey];
105119
}
106120

107121
size(): number {
@@ -112,6 +126,13 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
112126
continue;
113127
}
114128

129+
// data always exists for non-tombstoned elements
130+
const data = value.data!;
131+
if ('objectId' in data && !this._liveObjects.getPool().get(data.objectId)?.isValid()) {
132+
// should not count non-valid objects
133+
continue;
134+
}
135+
115136
size++;
116137
}
117138

@@ -145,6 +166,16 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
145166
// as it's important to mark that the op was processed by the object
146167
this._siteTimeserials[opSiteCode] = opOriginTimeserial;
147168

169+
if (msg.isMapSetWithObjectIdReference() && !this._liveObjects.getPool().get(op.mapOp?.data?.objectId!)?.isValid()) {
170+
// invalid objects must not be surfaced to the end users, so we cannot apply this MAP_SET operation on the map yet,
171+
// as it will set the key to point to the invalid object. we also can't just update the key on a map right now, as
172+
// that would require us to send an update event for the key, and the user will end up with a key on map which got
173+
// updated to return undefined, which is undesired. instead we should buffer the MAP_SET operation until referenced
174+
// object becomes valid
175+
this._handleMapSetWithInvalidObjectReference(op.mapOp!, opOriginTimeserial);
176+
return;
177+
}
178+
148179
let update: LiveMapUpdate | LiveObjectUpdateNoop;
149180
switch (op.action) {
150181
case StateOperationAction.MAP_CREATE:
@@ -231,7 +262,7 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
231262

232263
const previousDataRef = this._dataRef;
233264
// override all relevant data for this object with data from the state object
234-
this._createOperationIsMerged = false;
265+
this._setCreateOperationIsMerged(false);
235266
this._dataRef = this._liveMapDataFromMapEntries(stateObject.map?.entries ?? {});
236267
// should default to empty map if site timeserials do not exist on the state object, so that any future operation can be applied to this object
237268
this._siteTimeserials = stateObject.siteTimeserials ?? {};
@@ -331,7 +362,7 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
331362
Object.assign(aggregatedUpdate.update, update.update);
332363
});
333364

334-
this._createOperationIsMerged = true;
365+
this._setCreateOperationIsMerged(true);
335366

336367
return aggregatedUpdate;
337368
}
@@ -344,6 +375,38 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
344375
);
345376
}
346377

378+
private _handleMapSetWithInvalidObjectReference(op: StateMapOp, opOriginTimeserial: string | undefined): void {
379+
const refObjectId = op?.data?.objectId!;
380+
// ensure referenced object always exist so we can subscribe to it becoming valid
381+
this._liveObjects.getPool().createZeroValueObjectIfNotExists(refObjectId);
382+
383+
// wait until the referenced object becomes valid, then apply MAP_SET operation,
384+
// as it will now point to the existing valid object
385+
const { off } = this._liveObjects
386+
.getPool()
387+
.get(refObjectId)!
388+
.onceValid(() => {
389+
try {
390+
const update = this._applyMapSet(op, opOriginTimeserial);
391+
this.notifyUpdated(update);
392+
} catch (error) {
393+
this._client.Logger.logAction(
394+
this._client.logger,
395+
this._client.Logger.LOG_ERROR,
396+
`LiveMap._handleMapSetWithInvalidObjectReference()`,
397+
`error applying buffered MAP_SET operation: ${this._client.Utils.inspectError(error)}`,
398+
);
399+
} finally {
400+
this._bufferedOperations.delete(bufferedOperation);
401+
}
402+
});
403+
404+
const bufferedOperation: BufferedOperation = {
405+
cancel: () => off(),
406+
};
407+
this._bufferedOperations.add(bufferedOperation);
408+
}
409+
347410
private _applyMapCreate(op: StateOperation): LiveMapUpdate | LiveObjectUpdateNoop {
348411
if (this._createOperationIsMerged) {
349412
// There can't be two different create operation for the same object id, because the object id
@@ -395,11 +458,6 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
395458
let liveData: StateData;
396459
if (!Utils.isNil(op.data.objectId)) {
397460
liveData = { objectId: op.data.objectId } as ObjectIdStateData;
398-
// this MAP_SET op is setting a key to point to another object via its object id,
399-
// but it is possible that we don't have the corresponding object in the pool yet (for example, we haven't seen the *_CREATE op for it).
400-
// we don't want to return undefined from this map's .get() method even if we don't have the object,
401-
// so instead we create a zero-value object for that object id if it not exists.
402-
this._liveObjects.getPool().createZeroValueObjectIfNotExists(op.data.objectId);
403461
} else {
404462
liveData = { encoding: op.data.encoding, value: op.data.value } as ValueStateData;
405463
}

src/plugins/liveobjects/liveobject.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { StateMessage, StateObject, StateOperation } from './statemessage';
55

66
enum LiveObjectEvents {
77
Updated = 'Updated',
8+
Valid = 'Valid',
89
}
910

1011
export interface LiveObjectData {
@@ -25,6 +26,17 @@ export interface SubscribeResponse {
2526
unsubscribe(): void;
2627
}
2728

29+
export interface OnEventResponse {
30+
off(): void;
31+
}
32+
33+
/**
34+
* Provides an interface for a buffered operation with the ability to cancel it, regardless of the buffering mechanism used
35+
*/
36+
export interface BufferedOperation {
37+
cancel(): void;
38+
}
39+
2840
export abstract class LiveObject<
2941
TData extends LiveObjectData = LiveObjectData,
3042
TUpdate extends LiveObjectUpdate = LiveObjectUpdate,
@@ -39,6 +51,7 @@ export abstract class LiveObject<
3951
protected _dataRef: TData;
4052
protected _siteTimeserials: Record<string, string>;
4153
protected _createOperationIsMerged: boolean;
54+
protected _bufferedOperations: Set<BufferedOperation>;
4255

4356
protected constructor(
4457
protected _liveObjects: LiveObjects,
@@ -51,6 +64,7 @@ export abstract class LiveObject<
5164
this._objectId = objectId;
5265
// use empty timeserials vector by default, so any future operation can be applied to this object
5366
this._siteTimeserials = {};
67+
this._bufferedOperations = new Set();
5468
}
5569

5670
subscribe(listener: (update: TUpdate) => void): SubscribeResponse {
@@ -99,6 +113,42 @@ export abstract class LiveObject<
99113
this._eventEmitter.emit(LiveObjectEvents.Updated, update);
100114
}
101115

116+
/**
117+
* Object is considered a "valid object" if we have seen the create operation for that object.
118+
*
119+
* Non-valid objects should be treated as though they don't exist from the perspective of the public API for the end users,
120+
* i.e. the public access API that would return this object instead should return an `undefined`. In other words, non-valid
121+
* objects are not surfaced to the end users and they're not able to interact with it.
122+
*
123+
* Once the create operation for the object has been seen and merged, the object becomes valid and can be exposed to the end users.
124+
*
125+
* @internal
126+
*/
127+
isValid(): boolean {
128+
return this._createOperationIsMerged;
129+
}
130+
131+
/**
132+
* @internal
133+
*/
134+
onceValid(listener: () => void): OnEventResponse {
135+
this._eventEmitter.once(LiveObjectEvents.Valid, listener);
136+
137+
const off = () => {
138+
this._eventEmitter.off(LiveObjectEvents.Valid, listener);
139+
};
140+
141+
return { off };
142+
}
143+
144+
/**
145+
* @internal
146+
*/
147+
cancelBufferedOperations(): void {
148+
this._bufferedOperations.forEach((x) => x.cancel());
149+
this._bufferedOperations.clear();
150+
}
151+
102152
/**
103153
* Returns true if the given origin timeserial indicates that the operation to which it belongs should be applied to the object.
104154
*
@@ -118,6 +168,16 @@ export abstract class LiveObject<
118168
return !siteTimeserial || opOriginTimeserial > siteTimeserial;
119169
}
120170

171+
protected _setCreateOperationIsMerged(createOperationIsMerged: boolean): void {
172+
const shouldNotifyValid =
173+
createOperationIsMerged === true && this._createOperationIsMerged !== createOperationIsMerged;
174+
this._createOperationIsMerged = createOperationIsMerged;
175+
176+
if (shouldNotifyValid) {
177+
this._eventEmitter.emit(LiveObjectEvents.Valid);
178+
}
179+
}
180+
121181
private _createObjectId(): string {
122182
// TODO: implement object id generation based on live object type and initial value
123183
return Math.random().toString().substring(2);

src/plugins/liveobjects/liveobjects.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ export class LiveObjects {
149149
private _startNewSync(syncId?: string, syncCursor?: string): void {
150150
// need to discard all buffered state operation messages on new sync start
151151
this._bufferedStateOperations = [];
152+
// cancel any buffered operations for all objects in the pool, as we're overriding the current state and they will no longer be valid
153+
this._liveObjectsPool.cancelBufferedOperations();
152154
this._syncLiveObjectsDataPool.reset();
153155
this._currentSyncId = syncId;
154156
this._currentSyncCursor = syncCursor;

src/plugins/liveobjects/liveobjectspool.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ export class LiveObjectsPool {
6262
this.set(objectId, zeroValueObject);
6363
}
6464

65+
cancelBufferedOperations(): void {
66+
this._pool.forEach((x) => x.cancelBufferedOperations());
67+
}
68+
6569
private _getInitialPool(): Map<string, LiveObject> {
6670
const pool = new Map<string, LiveObject>();
6771
const root = LiveMap.zeroValue(this._liveObjects, ROOT_OBJECT_ID);

src/plugins/liveobjects/statemessage.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,13 @@ export class StateMessage {
318318
};
319319
}
320320

321+
/**
322+
* Returns true if this state message is a state operation with `MAP_SET` action and it sets a map entry to point to another objectId.
323+
*/
324+
isMapSetWithObjectIdReference(): boolean {
325+
return this.operation?.action === StateOperationAction.MAP_SET && this.operation.mapOp?.data?.objectId != null;
326+
}
327+
321328
/**
322329
* Overload toJSON() to intercept JSON.stringify()
323330
* @return {*}

0 commit comments

Comments
 (0)