Skip to content

Commit 42a6093

Browse files
authored
Merge pull request #2288 from ably/liveobjects/noop-diffs-and-empty-synthetic-list
LiveObjects: suppress no-op diff updates (RTLC14c/RTLM22c) and skip the sync wait for an empty synthetic list (RTO20d4)
2 parents 5149a7c + 27b464f commit 42a6093

9 files changed

Lines changed: 411 additions & 11 deletions

File tree

src/plugins/liveobjects/livecounter.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,12 @@ export class LiveCounter extends LiveObject<LiveCounterData, LiveCounterUpdate>
235235

236236
// update will contain the diff between previous value and new value from object state
237237
const update = this._updateFromDataDiff(previousDataRef, this._dataRef);
238+
// RTLC14c - _updateFromDataDiff collapses a zero-delta diff (unchanged counter data) to a noop.
239+
// pass it straight through without stamping the object message, mirroring the terminal noop
240+
// return above (RTLC6e).
241+
if (this._isNoopUpdate(update)) {
242+
return update;
243+
}
238244
update.objectMessage = objectMessage;
239245

240246
return update;
@@ -253,11 +259,27 @@ export class LiveCounter extends LiveObject<LiveCounterData, LiveCounterUpdate>
253259
return { data: 0 };
254260
}
255261

256-
protected _updateFromDataDiff(prevDataRef: LiveCounterData, newDataRef: LiveCounterData): LiveCounterUpdate {
262+
protected _updateFromDataDiff(
263+
prevDataRef: LiveCounterData,
264+
newDataRef: LiveCounterData,
265+
): LiveCounterUpdate | LiveObjectUpdateNoop {
257266
const counterDiff = newDataRef.data - prevDataRef.data;
267+
// RTLC14c - as an exception to RTLC14b: if newData equals previousData (the computed delta is 0)
268+
// the counter data did not change, so instead of returning an update return a LiveCounterUpdate
269+
// object with noop set to true (RTLO4b4b), as in RTLC9h. This exception must not be applied when
270+
// the diff is computed for a tombstone per RTLO4e5; LiveObject.tombstone re-synthesizes a
271+
// non-noop update via _createNoChangeUpdate() so the RTLO4b4c3c listener teardown still fires.
272+
if (counterDiff === 0) {
273+
return { noop: true };
274+
}
258275
return { update: { amount: counterDiff }, _type: 'LiveCounterUpdate' };
259276
}
260277

278+
protected _createNoChangeUpdate(): LiveCounterUpdate {
279+
// RTLO4e5 tombstone carve-out (RTLC14c) - a zero-delta no-change update for an already-zero counter
280+
return { update: { amount: 0 }, _type: 'LiveCounterUpdate' };
281+
}
282+
261283
protected _mergeInitialDataFromCreateOperation(
262284
objectOperation: ObjectOperation<ObjectData>,
263285
msg: ObjectMessage,

src/plugins/liveobjects/livemap.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,12 @@ export class LiveMap<T extends Record<string, Value> = Record<string, Value>>
462462

463463
// update will contain the diff between previous value and new value from object state
464464
const update = this._updateFromDataDiff(previousDataRef, this._dataRef);
465+
// RTLM22c - _updateFromDataDiff collapses an empty key-diff (no map key changed) to a noop.
466+
// pass it straight through without stamping the object message, mirroring the terminal noop
467+
// return above (RTLM6e).
468+
if (this._isNoopUpdate(update)) {
469+
return update;
470+
}
465471
update.objectMessage = objectMessage;
466472

467473
return update;
@@ -488,7 +494,7 @@ export class LiveMap<T extends Record<string, Value> = Record<string, Value>>
488494
*
489495
* @internal
490496
*/
491-
clearData(): LiveMapUpdate<T> {
497+
clearData(): LiveMapUpdate<T> | LiveObjectUpdateNoop {
492498
// Remove all parent references for objects this map was referencing
493499
for (const [key, entry] of this._dataRef.data.entries()) {
494500
if (entry.data && 'objectId' in entry.data) {
@@ -600,7 +606,10 @@ export class LiveMap<T extends Record<string, Value> = Record<string, Value>>
600606
return { data: new Map<string, LiveMapEntry>() };
601607
}
602608

603-
protected _updateFromDataDiff(prevDataRef: LiveMapData, newDataRef: LiveMapData): LiveMapUpdate<T> {
609+
protected _updateFromDataDiff(
610+
prevDataRef: LiveMapData,
611+
newDataRef: LiveMapData,
612+
): LiveMapUpdate<T> | LiveObjectUpdateNoop {
604613
const update: LiveMapUpdate<T> = { update: {}, _type: 'LiveMapUpdate' };
605614

606615
for (const [key, currentEntry] of prevDataRef.data.entries()) {
@@ -653,9 +662,24 @@ export class LiveMap<T extends Record<string, Value> = Record<string, Value>>
653662
}
654663
}
655664

665+
// RTLM22c - as an exception to RTLM22b: if the computed update contains no changed keys (it is
666+
// empty) no map key actually changed, so instead of returning an update return a LiveMapUpdate
667+
// object with noop set to true (RTLO4b4b), as in RTLM16b. This exception must not be applied when
668+
// the diff is computed for a tombstone per RTLO4e5; LiveObject.tombstone re-synthesizes a
669+
// non-noop update via _createNoChangeUpdate() so the RTLO4b4c3c listener teardown still fires.
670+
if (Object.keys(update.update).length === 0) {
671+
return { noop: true };
672+
}
673+
656674
return update;
657675
}
658676

677+
protected _createNoChangeUpdate(): LiveMapUpdate<T> {
678+
// RTLO4e5 tombstone carve-out (RTLM22c) - an empty no-change update for a map with no
679+
// non-tombstoned entries
680+
return { update: {}, _type: 'LiveMapUpdate' };
681+
}
682+
659683
protected _mergeInitialDataFromCreateOperation(
660684
objectOperation: ObjectOperation<ObjectData>,
661685
msg: ObjectMessage,

src/plugins/liveobjects/liveobject.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,18 @@ export abstract class LiveObject<
134134
'LiveObject.tombstone()',
135135
`objectId=${this.getObjectId()}`,
136136
); // RTLO4e3
137-
const update = this.clearData(); // RTLO4e4
138-
update.objectMessage = objectMessage;
139-
update.tombstone = true;
140-
141-
return update;
137+
// RTLO4e5 - compute the diff between the pre-clear data and the zero value. Per the RTLC14c /
138+
// RTLM22c tombstone carve-out, that noop exception "must not be applied when the diff is
139+
// computed for a tombstone": tombstoning an already-empty object yields a noop diff, but the
140+
// resulting tombstone update (RTLO4b4e) must still be delivered so it drives the RTLO4b4c3c
141+
// listener teardown. So when the diff collapses to a noop, synthesize the typed no-change
142+
// update instead, leaving a real (non-noop) update to stamp.
143+
const diff = this.clearData(); // RTLO4e4
144+
const update: TUpdate = this._isNoopUpdate(diff) ? this._createNoChangeUpdate() : diff;
145+
update.objectMessage = objectMessage; // RTLO4e7
146+
update.tombstone = true; // RTLO4e6
147+
148+
return update; // RTLO4e8
142149
}
143150

144151
/**
@@ -158,7 +165,7 @@ export abstract class LiveObject<
158165
/**
159166
* @internal
160167
*/
161-
clearData(): TUpdate {
168+
clearData(): TUpdate | LiveObjectUpdateNoop {
162169
const previousDataRef = this._dataRef;
163170
this._dataRef = this._getZeroValueData();
164171
return this._updateFromDataDiff(previousDataRef, this._dataRef);
@@ -350,7 +357,7 @@ export abstract class LiveObject<
350357
}
351358
}
352359

353-
private _isNoopUpdate(update: TUpdate | LiveObjectUpdateNoop): update is LiveObjectUpdateNoop {
360+
protected _isNoopUpdate(update: TUpdate | LiveObjectUpdateNoop): update is LiveObjectUpdateNoop {
354361
return (update as LiveObjectUpdateNoop).noop === true;
355362
}
356363

@@ -383,8 +390,16 @@ export abstract class LiveObject<
383390
protected abstract _getZeroValueData(): TData;
384391
/**
385392
* Calculate the update object based on the current LiveObject data and incoming new data.
393+
*
394+
* Returns a noop update when the data is unchanged (RTLC14c / RTLM22c).
395+
*/
396+
protected abstract _updateFromDataDiff(prevDataRef: TData, newDataRef: TData): TUpdate | LiveObjectUpdateNoop;
397+
/**
398+
* Returns a typed update that represents "no change" (e.g. a counter delta of 0, or an empty
399+
* map key-diff), used by {@link LiveObject.tombstone} to synthesize a deliverable tombstone
400+
* update when the tombstone diff itself collapsed to a noop per the RTLC14c / RTLM22c carve-out.
386401
*/
387-
protected abstract _updateFromDataDiff(prevDataRef: TData, newDataRef: TData): TUpdate;
402+
protected abstract _createNoChangeUpdate(): TUpdate;
388403
/**
389404
* Merges the initial data from the create operation into the LiveObject.
390405
*

src/plugins/liveobjects/realtimeobject.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,12 @@ export class RealtimeObject {
396396
);
397397
}
398398

399+
// RTO20d4 - if the synthetic messages list is empty (e.g. every serial was null and skipped per
400+
// RTO20d1) there is nothing to apply locally, so complete without performing the RTO20e sync wait.
401+
if (syntheticMessages.length === 0) {
402+
return;
403+
}
404+
399405
// RTO20e - Wait for sync to complete if not synced
400406
if (this._state !== ObjectsState.synced) {
401407
this._client.Logger.logAction(

test/uts/objects/unit/live_counter.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,43 @@ describe('uts/objects/unit/live_counter', function () {
511511
expect(update.objectMessage).to.equal(msg);
512512
});
513513

514+
// UTS: objects/unit/RTLO5/tombstone-zero-value-counter-emits-update-0
515+
// Complements object-delete-tombstones-0 (which tombstones a populated counter). Here the
516+
// counter data is already 0, so the tombstone diff (previousData 0, newData 0) is a zero delta.
517+
// Per the RTLC14c tombstone carve-out (RTLO4e5) this update must NOT be marked as a no-op — it
518+
// must still be delivered so the RTLO4b4c3c listener teardown runs.
519+
it('RTLO5 - OBJECT_DELETE on an already-zero counter emits a non-noop tombstone update', async function () {
520+
const { channel, client } = await setupSyncedChannel('test-RTLO5-zero');
521+
522+
const counter = createZeroCounter(channel, 'counter:abc@1000');
523+
const capture = captureNotifyUpdated(counter);
524+
(counter as any)._dataRef.data = 0;
525+
(counter as any)._siteTimeserials = { site1: '00' };
526+
527+
const msg = makeObjectMessage(client, {
528+
serial: '01',
529+
siteCode: 'site1',
530+
serialTimestamp: 1700000000000,
531+
operation: {
532+
action: OBJ_OP.OBJECT_DELETE,
533+
objectId: 'counter:abc@1000',
534+
objectDelete: {},
535+
},
536+
});
537+
538+
const result = counter.applyOperation(msg.operation!, msg, ObjectsOperationSource.channel);
539+
540+
expect(counter.isTombstoned()).to.equal(true);
541+
expect((counter as any)._dataRef.data).to.equal(0);
542+
expect(result).to.equal(true);
543+
const update = capture.getUpdate();
544+
// RTLC14c carve-out: the zero-delta tombstone update is NOT a no-op
545+
expect((update as any).noop).to.not.equal(true);
546+
expect(update.tombstone).to.equal(true);
547+
expect(update.update.amount).to.equal(0);
548+
expect(update.objectMessage).to.equal(msg);
549+
});
550+
514551
// =========================================================================
515552
// RTLC7e - Operations on tombstoned counter are rejected
516553
// =========================================================================
@@ -798,6 +835,29 @@ describe('uts/objects/unit/live_counter', function () {
798835
expect((update as any).objectMessage).to.equal(stateMsg);
799836
});
800837

838+
// UTS: objects/unit/RTLC14c/zero-delta-diff-is-noop-0
839+
it('RTLC14c - Zero-delta diff is a no-op', async function () {
840+
const { channel, client } = await setupSyncedChannel('test-RTLC14c');
841+
842+
const counter = createZeroCounter(channel, 'counter:abc@1000');
843+
(counter as any)._dataRef.data = 100;
844+
845+
const stateMsg = makeObjectMessage(client, {
846+
object: {
847+
objectId: 'counter:abc@1000',
848+
siteTimeserials: { site1: '01' },
849+
tombstone: false,
850+
counter: { count: 100 },
851+
},
852+
});
853+
854+
const update = counter.overrideWithObjectState(stateMsg);
855+
856+
// RTLC14c - the computed delta is 0, so the diff collapses to a no-op update
857+
expect((update as any).noop).to.equal(true);
858+
expect((counter as any)._dataRef.data).to.equal(100);
859+
});
860+
801861
// =========================================================================
802862
// RTLC8, RTLC16 - COUNTER_CREATE then COUNTER_INC accumulates
803863
// =========================================================================

test/uts/objects/unit/live_map.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,56 @@ describe('uts/objects/unit/live_map', function () {
753753
expect(update.objectMessage).to.equal(msg);
754754
});
755755

756+
// UTS: objects/unit/RTLO5/tombstone-empty-map-emits-update-0
757+
// Complements object-delete-tombstones-map-0 (which tombstones a map with live entries). Here
758+
// every entry is already tombstoned, so the map has no non-tombstoned entries and the tombstone
759+
// diff (per RTLM22b, which considers only non-tombstoned entries) contains no changed keys. Per
760+
// the RTLM22c tombstone carve-out (RTLO4e5) this empty update must NOT be marked as a no-op — it
761+
// must still be delivered so the RTLO4b4c3c listener teardown runs. Uses a non-root map: an
762+
// OBJECT_DELETE targeting root is rejected per RTLO4e10.
763+
it('RTLO5 - OBJECT_DELETE on an all-tombstoned map emits a non-noop tombstone update', async function () {
764+
const { channel, client } = await setupSyncedChannel('test-RTLO5-empty');
765+
766+
const map = createZeroMap(channel, 'map:test@1000');
767+
const capture = captureNotifyUpdated(map);
768+
getDataMap(map).set('name', {
769+
data: { string: 'Alice' },
770+
timeserial: '01',
771+
tombstone: true,
772+
tombstonedAt: 1600000000000,
773+
});
774+
getDataMap(map).set('age', {
775+
data: { number: 30 },
776+
timeserial: '01',
777+
tombstone: true,
778+
tombstonedAt: 1600000000000,
779+
});
780+
(map as any)._siteTimeserials = { site1: '00' };
781+
782+
const msg = makeObjectMessage(client, {
783+
serial: '01',
784+
siteCode: 'site1',
785+
serialTimestamp: 1700000000000,
786+
operation: {
787+
action: OBJ_OP.OBJECT_DELETE,
788+
objectId: 'map:test@1000',
789+
objectDelete: {},
790+
},
791+
});
792+
793+
const result = map.applyOperation(msg.operation!, msg, ObjectsOperationSource.channel);
794+
795+
expect(map.isTombstoned()).to.equal(true);
796+
expect(getDataMap(map).size).to.equal(0); // data cleared
797+
expect(result).to.equal(true);
798+
const update = capture.getUpdate();
799+
// RTLM22c carve-out: the empty tombstone update is NOT a no-op
800+
expect((update as any).noop).to.not.equal(true);
801+
expect(update.tombstone).to.equal(true);
802+
expect(update.update).to.deep.equal({});
803+
expect(update.objectMessage).to.equal(msg);
804+
});
805+
756806
// =====================================================================
757807
// RTLO4e10 - OBJECT_DELETE targeting root is rejected
758808
// =====================================================================
@@ -1036,6 +1086,41 @@ describe('uts/objects/unit/live_map', function () {
10361086
expect(diff).to.not.have.property('now_dead');
10371087
});
10381088

1089+
// UTS: objects/unit/RTLM22c/empty-diff-is-noop-0
1090+
it('RTLM22c - empty diff is a no-op', async function () {
1091+
const { channel, client } = await setupSyncedChannel('test-RTLM22c');
1092+
1093+
const map = createZeroMap(channel, 'root');
1094+
getDataMap(map).set('name', {
1095+
data: { string: 'alice' },
1096+
timeserial: '01',
1097+
tombstone: false,
1098+
tombstonedAt: undefined,
1099+
});
1100+
1101+
// The non-tombstoned entries before and after are identical under the RTLM22b
1102+
// comparison rules (same key, same data; only timeserial differs, which is not compared).
1103+
const stateMsg = makeObjectMessage(client, {
1104+
object: {
1105+
objectId: 'root',
1106+
siteTimeserials: { site1: '02' },
1107+
tombstone: false,
1108+
map: {
1109+
semantics: MAP_SEMANTICS_LWW,
1110+
entries: {
1111+
name: { data: { string: 'alice' }, timeserial: '02' },
1112+
},
1113+
},
1114+
},
1115+
});
1116+
1117+
const update = map.overrideWithObjectState(stateMsg);
1118+
1119+
// RTLM22c - the computed update contains no changed keys, so the diff collapses to a no-op
1120+
expect((update as any).noop).to.equal(true);
1121+
expect(getDataMap(map).get('name')!.data).to.deep.equal({ string: 'alice' });
1122+
});
1123+
10391124
// =====================================================================
10401125
// RTLM15d4 - Unsupported action is discarded
10411126
// =====================================================================

0 commit comments

Comments
 (0)