Skip to content

Commit e99520c

Browse files
committed
GC tombstoned map entries for LiveMap and objects in the global pool
Resolves DTP-1024
1 parent f370f62 commit e99520c

File tree

8 files changed

+255
-2
lines changed

8 files changed

+255
-2
lines changed

src/plugins/liveobjects/defaults.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const DEFAULTS = {
2+
gcInterval: 1000 * 60 * 5, // 5 minutes
3+
/**
4+
* Must be > 2 minutes to ensure we keep tombstones long enough to avoid the possibility of receiving an operation
5+
* with an earlier origin timeserial that would not have been applied if the tombstone still existed.
6+
*
7+
* Applies both for map entries tombstones and object tombstones.
8+
*/
9+
gcGracePeriod: 1000 * 60 * 2.5, // 2.5 minutes
10+
};

src/plugins/liveobjects/livecounter.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ export class LiveCounter extends LiveObject<LiveCounterData, LiveCounterUpdate>
157157
return this._updateFromDataDiff(previousDataRef, this._dataRef);
158158
}
159159

160+
/**
161+
* @internal
162+
*/
163+
onGCInterval(): void {
164+
// nothing to GC for a counter object
165+
return;
166+
}
167+
160168
protected _getZeroValueData(): LiveCounterData {
161169
return { data: 0 };
162170
}

src/plugins/liveobjects/livemap.ts

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

33
import type * as API from '../../../ably';
4+
import { DEFAULTS } from './defaults';
45
import { LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject';
56
import { LiveObjects } from './liveobjects';
67
import {
@@ -33,6 +34,10 @@ export type StateData = ObjectIdStateData | ValueStateData;
3334

3435
export interface MapEntry {
3536
tombstone: boolean;
37+
/**
38+
* Can't use timeserial from the operation that deleted the entry for the same reason as for {@link LiveObject} tombstones, see explanation there.
39+
*/
40+
tombstonedAt: number | undefined;
3641
timeserial: string | undefined;
3742
data: StateData | undefined;
3843
}
@@ -291,6 +296,22 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
291296
return this._updateFromDataDiff(previousDataRef, this._dataRef);
292297
}
293298

299+
/**
300+
* @internal
301+
*/
302+
onGCInterval(): void {
303+
// should remove any tombstoned entries from the underlying map data that have exceeded the GC grace period
304+
305+
const keysToDelete: string[] = [];
306+
for (const [key, value] of this._dataRef.data.entries()) {
307+
if (value.tombstone === true && Date.now() - value.tombstonedAt! >= DEFAULTS.gcGracePeriod) {
308+
keysToDelete.push(key);
309+
}
310+
}
311+
312+
keysToDelete.forEach((x) => this._dataRef.data.delete(x));
313+
}
314+
294315
protected _getZeroValueData(): LiveMapData {
295316
return { data: new Map<string, MapEntry>() };
296317
}
@@ -455,11 +476,13 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
455476

456477
if (existingEntry) {
457478
existingEntry.tombstone = false;
479+
existingEntry.tombstonedAt = undefined;
458480
existingEntry.timeserial = opOriginTimeserial;
459481
existingEntry.data = liveData;
460482
} else {
461483
const newEntry: MapEntry = {
462484
tombstone: false,
485+
tombstonedAt: undefined,
463486
timeserial: opOriginTimeserial,
464487
data: liveData,
465488
};
@@ -486,11 +509,13 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
486509

487510
if (existingEntry) {
488511
existingEntry.tombstone = true;
512+
existingEntry.tombstonedAt = Date.now();
489513
existingEntry.timeserial = opOriginTimeserial;
490514
existingEntry.data = undefined;
491515
} else {
492516
const newEntry: MapEntry = {
493517
tombstone: true,
518+
tombstonedAt: Date.now(),
494519
timeserial: opOriginTimeserial,
495520
data: undefined,
496521
};
@@ -544,9 +569,10 @@ export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData,
544569

545570
const liveDataEntry: MapEntry = {
546571
timeserial: entry.timeserial,
547-
// true only if we received explicit true. otherwise always false
548-
tombstone: entry.tombstone === true,
549572
data: liveData,
573+
// consider object as tombstoned only if we received an explicit flag stating that. otherwise it exists
574+
tombstone: entry.tombstone === true,
575+
tombstonedAt: entry.tombstone === true ? Date.now() : undefined,
550576
};
551577

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

src/plugins/liveobjects/liveobject.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ export abstract class LiveObject<
4040
protected _siteTimeserials: Record<string, string>;
4141
protected _createOperationIsMerged: boolean;
4242
private _tombstone: boolean;
43+
/**
44+
* Even though the `timeserial` from the operation that deleted the object contains the timestamp value,
45+
* the `timeserial` should be treated as an opaque string on the client, meaning we should not attempt to parse it.
46+
*
47+
* Therefore, we need to set our own timestamp when the object is deleted client-side. Strictly speaking, this is
48+
* slightly less precise, as we will GC the object later than the server, but it is an acceptable compromise.
49+
*/
50+
private _tombstonedAt: number | undefined;
4351

4452
protected constructor(
4553
protected _liveObjects: LiveObjects,
@@ -108,6 +116,7 @@ export abstract class LiveObject<
108116
*/
109117
tombstone(): void {
110118
this._tombstone = true;
119+
this._tombstonedAt = Date.now();
111120
this._dataRef = this._getZeroValueData();
112121
// TODO: emit "deleted" event so that end users get notified about this object getting deleted
113122
}
@@ -119,6 +128,13 @@ export abstract class LiveObject<
119128
return this._tombstone;
120129
}
121130

131+
/**
132+
* @internal
133+
*/
134+
tombstonedAt(): number | undefined {
135+
return this._tombstonedAt;
136+
}
137+
122138
/**
123139
* Returns true if the given origin timeserial indicates that the operation to which it belongs should be applied to the object.
124140
*
@@ -168,6 +184,11 @@ export abstract class LiveObject<
168184
* @internal
169185
*/
170186
abstract overrideWithStateObject(stateObject: StateObject): TUpdate | LiveObjectUpdateNoop;
187+
/**
188+
* @internal
189+
*/
190+
abstract onGCInterval(): void;
191+
171192
protected abstract _getZeroValueData(): TData;
172193
/**
173194
* Calculate the update object based on the current Live Object data and incoming new data.

src/plugins/liveobjects/liveobjects.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type BaseClient from 'common/lib/client/baseclient';
22
import type RealtimeChannel from 'common/lib/client/realtimechannel';
33
import type EventEmitter from 'common/lib/util/eventemitter';
44
import type * as API from '../../../ably';
5+
import { DEFAULTS } from './defaults';
56
import { LiveCounter } from './livecounter';
67
import { LiveMap } from './livemap';
78
import { LiveObject, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject';
@@ -26,6 +27,9 @@ export class LiveObjects {
2627
private _currentSyncCursor: string | undefined;
2728
private _bufferedStateOperations: StateMessage[];
2829

30+
// Used by tests
31+
static _DEFAULTS = DEFAULTS;
32+
2933
constructor(channel: RealtimeChannel) {
3034
this._channel = channel;
3135
this._client = channel.client;

src/plugins/liveobjects/liveobjectspool.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type BaseClient from 'common/lib/client/baseclient';
2+
import { DEFAULTS } from './defaults';
23
import { LiveCounter } from './livecounter';
34
import { LiveMap } from './livemap';
45
import { LiveObject } from './liveobject';
@@ -13,10 +14,16 @@ export const ROOT_OBJECT_ID = 'root';
1314
export class LiveObjectsPool {
1415
private _client: BaseClient;
1516
private _pool: Map<string, LiveObject>;
17+
private _gcInterval: ReturnType<typeof setInterval>;
1618

1719
constructor(private _liveObjects: LiveObjects) {
1820
this._client = this._liveObjects.getClient();
1921
this._pool = this._getInitialPool();
22+
this._gcInterval = setInterval(() => {
23+
this._onGCInterval();
24+
}, DEFAULTS.gcInterval);
25+
// call nodejs's Timeout.unref to not require Node.js event loop to remain active due to this interval. see https://nodejs.org/api/timers.html#timeoutunref
26+
this._gcInterval.unref?.();
2027
}
2128

2229
get(objectId: string): LiveObject | undefined {
@@ -68,4 +75,21 @@ export class LiveObjectsPool {
6875
pool.set(root.getObjectId(), root);
6976
return pool;
7077
}
78+
79+
private _onGCInterval(): void {
80+
const toDelete: string[] = [];
81+
for (const [objectId, obj] of this._pool.entries()) {
82+
// tombstoned objects should be removed from the pool if they have been tombstoned for longer than grace period.
83+
// by removing them from the local pool, LiveObjects plugin no longer keeps a reference to those objects, allowing JS's
84+
// Garbage Collection to eventually free the memory for those objects, provided the user no longer references them either.
85+
if (obj.isTombstoned() && Date.now() - obj.tombstonedAt()! >= DEFAULTS.gcGracePeriod) {
86+
toDelete.push(objectId);
87+
continue;
88+
}
89+
90+
obj.onGCInterval();
91+
}
92+
93+
toDelete.forEach((x) => this._pool.delete(x));
94+
}
7195
}

test/common/modules/private_api_recorder.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths)
1616
'call.Defaults.getPort',
1717
'call.Defaults.normaliseOptions',
1818
'call.EventEmitter.emit',
19+
'call.LiveObject.isTombstoned',
20+
'call.LiveObjects._liveObjectsPool._onGCInterval',
21+
'call.LiveObjects._liveObjectsPool.get',
1922
'call.Message.decode',
2023
'call.Message.encode',
2124
'call.Platform.Config.push.storage.clear',
@@ -72,6 +75,7 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths)
7275
'pass.clientOption.webSocketSlowTimeout',
7376
'pass.clientOption.wsConnectivityCheckUrl', // actually ably-js public API (i.e. it’s in the TypeScript typings) but no other SDK has it. At the same time it's not entirely clear if websocket connectivity check should be considered an ably-js-specific functionality (as for other params above), so for the time being we consider it as private API
7477
'read.Defaults.version',
78+
'read.LiveMap._dataRef.data',
7579
'read.EventEmitter.events',
7680
'read.Platform.Config.push',
7781
'read.Realtime._transports',
@@ -112,6 +116,7 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths)
112116
'read.transport.params.mode',
113117
'read.transport.recvRequest.recvUri',
114118
'read.transport.uri',
119+
'replace.LiveObjects._liveObjectsPool._onGCInterval',
115120
'replace.channel.attachImpl',
116121
'replace.channel.processMessage',
117122
'replace.channel.sendMessage',
@@ -128,6 +133,8 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths)
128133
'serialize.recoveryKey',
129134
'write.Defaults.ENVIRONMENT',
130135
'write.Defaults.wsConnectivityCheckUrl',
136+
'write.LiveObjects._DEFAULTS.gcGracePeriod',
137+
'write.LiveObjects._DEFAULTS.gcInterval',
131138
'write.Platform.Config.push', // This implies using a mock implementation of the internal IPlatformPushConfig interface. Our mock (in push_channel_transport.js) then interacts with internal objects and private APIs of public objects to implement this interface; I haven’t added annotations for that private API usage, since there wasn’t an easy way to pass test context information into the mock. I think that for now we can just say that if we wanted to get rid of this private API usage, then we’d need to remove this mock entirely.
132139
'write.auth.authOptions.requestHeaders',
133140
'write.auth.key',

0 commit comments

Comments
 (0)