Skip to content

Commit 7c87463

Browse files
committed
Protocol v6 and support for partial sync of objects
See spec ably/specification#413 Resolves AIT-38
1 parent 51167d4 commit 7c87463

5 files changed

Lines changed: 264 additions & 14 deletions

File tree

src/common/lib/util/defaults.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ const Defaults = {
9191
maxMessageSize: 65536,
9292

9393
version,
94-
protocolVersion: 5,
94+
protocolVersion: 6,
9595
agent,
9696
getPort,
9797
getHttpScheme,

src/plugins/liveobjects/realtimeobject.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export class RealtimeObject {
4343
// related to RTC10, should have a separate EventEmitter for users of the library
4444
private _eventEmitterPublic: EventEmitter;
4545
private _objectsPool: ObjectsPool; // RTO3
46-
/** An array of ObjectMessages received during the sync sequence */
47-
private _syncObjectsPool: ObjectMessage[];
46+
/** Used to accumulate object state during a sync sequence, keyed by object ID */
47+
private _syncObjectsPool: Map<string, ObjectMessage>;
4848
private _currentSyncId: string | undefined;
4949
private _currentSyncCursor: string | undefined;
5050
private _bufferedObjectOperations: ObjectMessage[];
@@ -60,7 +60,7 @@ export class RealtimeObject {
6060
this._eventEmitterInternal = new this._client.EventEmitter(this._client.logger);
6161
this._eventEmitterPublic = new this._client.EventEmitter(this._client.logger);
6262
this._objectsPool = new ObjectsPool(this);
63-
this._syncObjectsPool = [];
63+
this._syncObjectsPool = new Map<string, ObjectMessage>();
6464
this._bufferedObjectOperations = [];
6565
this._pathObjectSubscriptionRegister = new PathObjectSubscriptionRegister(this);
6666
// use server-provided objectsGCGracePeriod if available, and subscribe to new connectionDetails that can be emitted as part of the RTN24
@@ -198,7 +198,7 @@ export class RealtimeObject {
198198
// if no HAS_OBJECTS flag received on attach, we can end sync sequence immediately and treat it as no objects on a channel.
199199
// reset the objects pool to its initial state, and emit update events so subscribers to root object get notified about changes.
200200
this._objectsPool.resetToInitialPool(true); // RTO4b1, RTO4b2
201-
this._syncObjectsPool = []; // RTO4b3
201+
this._syncObjectsPool.clear(); // RTO4b3
202202
this._endSync(); // RTO4b4
203203
}
204204
}
@@ -216,7 +216,7 @@ export class RealtimeObject {
216216
case 'failed':
217217
// do not emit data update events as the actual current state of Objects data is unknown when we're in these channel states
218218
this._objectsPool.clearObjectsData(false);
219-
this._syncObjectsPool = [];
219+
this._syncObjectsPool.clear();
220220
break;
221221
}
222222
}
@@ -261,7 +261,7 @@ export class RealtimeObject {
261261
private _startNewSync(syncId?: string, syncCursor?: string): void {
262262
// need to discard all buffered object operation messages on new sync start
263263
this._bufferedObjectOperations = [];
264-
this._syncObjectsPool = [];
264+
this._syncObjectsPool.clear();
265265
this._currentSyncId = syncId;
266266
this._currentSyncCursor = syncCursor;
267267
this._stateChange(ObjectsState.syncing);
@@ -275,7 +275,7 @@ export class RealtimeObject {
275275
this._applyObjectMessages(this._bufferedObjectOperations);
276276

277277
this._bufferedObjectOperations = [];
278-
this._syncObjectsPool = []; // RTO5c4
278+
this._syncObjectsPool.clear(); // RTO5c4
279279
this._currentSyncId = undefined; // RTO5c3
280280
this._currentSyncCursor = undefined; // RTO5c3
281281
this._stateChange(ObjectsState.synced);
@@ -301,7 +301,7 @@ export class RealtimeObject {
301301
}
302302

303303
private _applySync(): void {
304-
if (this._syncObjectsPool.length === 0) {
304+
if (this._syncObjectsPool.size === 0) {
305305
return;
306306
}
307307

@@ -312,8 +312,7 @@ export class RealtimeObject {
312312
}[] = [];
313313

314314
// RTO5c1
315-
for (const objectMessage of this._syncObjectsPool) {
316-
const objectId = objectMessage.object?.objectId!;
315+
for (const [objectId, objectMessage] of this._syncObjectsPool) {
317316
receivedObjectIds.add(objectId);
318317
const existingObject = this._objectsPool.get(objectId);
319318

@@ -370,7 +369,57 @@ export class RealtimeObject {
370369
continue;
371370
}
372371

373-
this._syncObjectsPool.push(objectMessage);
372+
// RTO5b2 - partial object sync handling
373+
const objectState = objectMessage.object;
374+
const objectId = objectState.objectId;
375+
376+
if (objectState.counter) {
377+
// RTO5b2a, RTO5b2b2 - counter objects have a bounded size and will never be split
378+
// across multiple sync messages, so they can be stored directly without merging.
379+
this._syncObjectsPool.set(objectId, objectMessage);
380+
continue;
381+
}
382+
383+
if (objectState.map) {
384+
const existingEntry = this._syncObjectsPool.get(objectId);
385+
386+
if (!existingEntry) {
387+
// RTO5b2a - no ObjectState with the given objectId exists yet, store it
388+
this._syncObjectsPool.set(objectId, objectMessage);
389+
} else {
390+
// RTO5b2b, RTO5b2b1 - merge entries from the new ObjectState into the existing one
391+
this._mergeMapSyncState(existingEntry, objectMessage);
392+
}
393+
continue;
394+
}
395+
396+
this._client.Logger.logAction(
397+
this._client.logger,
398+
this._client.Logger.LOG_MAJOR,
399+
'RealtimeObject._applyObjectSyncMessages()',
400+
`received unsupported object state message during OBJECT_SYNC, expected 'counter' or 'map' to be present, skipping message; message id: ${objectMessage.id}, channel: ${this._channel.name}`,
401+
);
402+
}
403+
}
404+
405+
/**
406+
* Merges map entries from a partial sync message into an existing entry.
407+
* Other fields on the ObjectState envelope are identical across all partial messages
408+
* for the same object, so only the entries need to be merged.
409+
* @spec RTO5b2b1
410+
*/
411+
private _mergeMapSyncState(existingEntry: ObjectMessage, newObjectMessage: ObjectMessage): void {
412+
const existingObjectState = existingEntry.object!;
413+
const newObjectState = newObjectMessage.object!;
414+
415+
if (!existingObjectState.map!.entries) {
416+
existingObjectState.map!.entries = {};
417+
}
418+
419+
if (newObjectState.map?.entries) {
420+
// During partial sync, no two messages contain the same map key,
421+
// so entries can be merged directly without conflict checking.
422+
Object.assign(existingObjectState.map!.entries, newObjectState.map.entries);
374423
}
375424
}
376425

test/realtime/init.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ define(['ably', 'shared_helper', 'chai'], function (Ably, Helper, chai) {
4343
return transport.recvRequest.recvUri;
4444
})();
4545
try {
46-
expect(connectUri.indexOf('v=5') > -1, 'Check uri includes v=5').to.be.ok;
46+
expect(connectUri.indexOf('v=6') > -1, 'Check uri includes v=6').to.be.ok;
4747
} catch (err) {
4848
helper.closeAndFinish(done, realtime, err);
4949
return;

test/realtime/liveobjects.test.js

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,52 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f
804804
},
805805
},
806806

807+
{
808+
description: 'OBJECT_SYNC does not break when receiving an unknown object type',
809+
action: async (ctx) => {
810+
const { channel, objectsHelper } = ctx;
811+
812+
// first message: unknown object type (no counter or map field set)
813+
await objectsHelper.processObjectStateMessageOnChannel({
814+
channel,
815+
syncSerial: 'serial:cursor',
816+
state: [
817+
{
818+
object: {
819+
objectId: 'unknown:object123',
820+
siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) },
821+
tombstone: false,
822+
// intentionally not setting counter or map fields
823+
},
824+
},
825+
],
826+
});
827+
828+
// second message: root with a key, ends sync sequence
829+
await objectsHelper.processObjectStateMessageOnChannel({
830+
channel,
831+
syncSerial: 'serial:',
832+
state: [
833+
objectsHelper.mapObject({
834+
objectId: 'root',
835+
siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) },
836+
initialEntries: {
837+
foo: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'bar' } },
838+
},
839+
}),
840+
],
841+
});
842+
843+
const root = await channel.object.get();
844+
845+
// verify root has the expected key - SDK should not break due to unknown object type
846+
expect(root.get('foo').value()).to.equal(
847+
'bar',
848+
'Check root has correct value after unknown object type in sync',
849+
);
850+
},
851+
},
852+
807853
{
808854
description: 'OBJECT_SYNC sequence with "tombstone=true" for an object creates tombstoned object',
809855
action: async (ctx) => {
@@ -1132,6 +1178,161 @@ define(['ably', 'shared_helper', 'chai', 'liveobjects', 'liveobjects_helper'], f
11321178
).to.be.true;
11331179
},
11341180
},
1181+
1182+
{
1183+
description: 'partial OBJECT_SYNC builds object tree across multiple messages',
1184+
action: async (ctx) => {
1185+
const { channel, objectsHelper, entryPathObject } = ctx;
1186+
1187+
const counterId = objectsHelper.fakeCounterObjectId();
1188+
const mapId = objectsHelper.fakeMapObjectId();
1189+
1190+
// send three separate OBJECT_SYNC messages: one for root, one for counter, one for map
1191+
await objectsHelper.processObjectStateMessageOnChannel({
1192+
channel,
1193+
syncSerial: 'serial:cursor1',
1194+
state: [
1195+
objectsHelper.mapObject({
1196+
objectId: 'root',
1197+
siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) },
1198+
initialEntries: {
1199+
stringKey: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'hello' } },
1200+
counter: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: counterId } },
1201+
map: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: mapId } },
1202+
},
1203+
}),
1204+
],
1205+
});
1206+
1207+
await objectsHelper.processObjectStateMessageOnChannel({
1208+
channel,
1209+
syncSerial: 'serial:cursor2',
1210+
state: [
1211+
objectsHelper.counterObject({
1212+
objectId: counterId,
1213+
siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) },
1214+
initialCount: 10,
1215+
materialisedCount: 5,
1216+
}),
1217+
],
1218+
});
1219+
1220+
await objectsHelper.processObjectStateMessageOnChannel({
1221+
channel,
1222+
syncSerial: 'serial:', // end sync sequence
1223+
state: [
1224+
objectsHelper.mapObject({
1225+
objectId: mapId,
1226+
siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) },
1227+
initialEntries: {
1228+
foo: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'bar' } },
1229+
},
1230+
materialisedEntries: {
1231+
baz: { timeserial: lexicoTimeserial('bbb', 0, 0), data: { string: 'qux' } },
1232+
},
1233+
}),
1234+
],
1235+
});
1236+
1237+
expect(entryPathObject.get('stringKey').value()).to.equal('hello', 'Check root has correct string value');
1238+
expect(entryPathObject.get('counter').value()).to.equal(15, 'Check counter has correct aggregated value');
1239+
expect(entryPathObject.get('map').get('foo').value()).to.equal('bar', 'Check map has initial entries');
1240+
expect(entryPathObject.get('map').get('baz').value()).to.equal('qux', 'Check map has materialised entries');
1241+
},
1242+
},
1243+
1244+
{
1245+
description: 'partial OBJECT_SYNC merges map entries across multiple messages for the same objectId',
1246+
action: async (ctx) => {
1247+
const { channel, objectsHelper, entryPathObject } = ctx;
1248+
1249+
const mapId = objectsHelper.fakeMapObjectId();
1250+
1251+
await objectsHelper.processObjectStateMessageOnChannel({
1252+
channel,
1253+
syncSerial: 'serial:cursor1',
1254+
state: [
1255+
objectsHelper.mapObject({
1256+
objectId: 'root',
1257+
siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) },
1258+
initialEntries: {
1259+
map: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { objectId: mapId } },
1260+
},
1261+
}),
1262+
],
1263+
});
1264+
1265+
await objectsHelper.processObjectStateMessageOnChannel({
1266+
channel,
1267+
syncSerial: 'serial:cursor2',
1268+
state: [
1269+
objectsHelper.mapObject({
1270+
objectId: mapId,
1271+
siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) },
1272+
// initialEntries are the same across all partial messages
1273+
initialEntries: {
1274+
initialKey: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'initial' } },
1275+
},
1276+
// materialisedEntries are merged across partial messages
1277+
materialisedEntries: {
1278+
key1: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { number: 1 } },
1279+
key2: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'two' } },
1280+
},
1281+
}),
1282+
],
1283+
});
1284+
1285+
await objectsHelper.processObjectStateMessageOnChannel({
1286+
channel,
1287+
syncSerial: 'serial:cursor3',
1288+
state: [
1289+
objectsHelper.mapObject({
1290+
objectId: mapId,
1291+
siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) },
1292+
initialEntries: {
1293+
initialKey: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'initial' } },
1294+
},
1295+
materialisedEntries: {
1296+
key3: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { number: 3 } },
1297+
key4: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { boolean: true } },
1298+
},
1299+
}),
1300+
],
1301+
});
1302+
1303+
await objectsHelper.processObjectStateMessageOnChannel({
1304+
channel,
1305+
syncSerial: 'serial:', // end sync sequence
1306+
state: [
1307+
objectsHelper.mapObject({
1308+
objectId: mapId,
1309+
siteTimeserials: { aaa: lexicoTimeserial('aaa', 0, 0) },
1310+
initialEntries: {
1311+
initialKey: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'initial' } },
1312+
},
1313+
materialisedEntries: {
1314+
key5: { timeserial: lexicoTimeserial('aaa', 0, 0), data: { string: 'five' } },
1315+
},
1316+
}),
1317+
],
1318+
});
1319+
1320+
const map = entryPathObject.get('map');
1321+
1322+
// verify initial entries are applied
1323+
expect(map.get('initialKey').value()).to.equal(
1324+
'initial',
1325+
'Check keys from the create operations are present',
1326+
);
1327+
1328+
// verify all materialised entries were merged
1329+
expect(map.get('key1').value()).to.equal(1, 'Check key1 from first partial sync');
1330+
expect(map.get('key2').value()).to.equal('two', 'Check key2 from first partial sync');
1331+
expect(map.get('key3').value()).to.equal(3, 'Check key3 from second partial sync');
1332+
expect(map.get('key4').value()).to.equal(true, 'Check key4 from second partial sync');
1333+
expect(map.get('key5').value()).to.equal('five', 'Check key5 from third partial sync');
1334+
},
1335+
},
11351336
];
11361337

11371338
const applyOperationsScenarios = [

test/rest/http.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ define(['ably', 'shared_helper', 'chai'], function (Ably, Helper, chai) {
3434

3535
// This test should not directly validate version against Defaults.version, as
3636
// ultimately the version header has been derived from that value.
37-
expect(headers['X-Ably-Version']).to.equal('5', 'Verify current version number');
37+
expect(headers['X-Ably-Version']).to.equal('6', 'Verify current version number');
3838
helper.recordPrivateApi('read.Defaults.version');
3939
expect(headers['Ably-Agent'].indexOf('ably-js/' + Defaults.version) > -1, 'Verify agent').to.be.ok;
4040
expect(headers['Ably-Agent'].indexOf('custom-agent/0.1.2') > -1, 'Verify custom agent').to.be.ok;

0 commit comments

Comments
 (0)