Skip to content

Commit 5149a7c

Browse files
authored
Merge pull request #2284 from ably/liveobjects/rto23c1-get-sync-wait-failure
[AIT-1274] LiveObjects: fail `get()`'s sync wait when the channel enters DETACHED/SUSPENDED/FAILED (RTO23c1)
2 parents 812a39f + c12f795 commit 5149a7c

4 files changed

Lines changed: 289 additions & 36 deletions

File tree

src/common/lib/client/realtimechannel.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -928,7 +928,9 @@ class RealtimeChannel extends EventEmitter {
928928
this._presence.actOnChannelState(state, hasPresence, reason);
929929
}
930930
if (this._object) {
931-
this._object.actOnChannelState(state, hasObjects);
931+
// RTO23c1/RTO20e1 - pass `reason` explicitly: this runs before `this.errorReason` is assigned
932+
// below, so the plugin cannot read it off the channel to set the sync-wait failure cause.
933+
this._object.actOnChannelState(state, hasObjects, reason);
932934
}
933935
if (state === 'suspended' && this.connectionManager.state.sendEvents) {
934936
this.startRetryTimer();

src/plugins/liveobjects/realtimeobject.ts

Lines changed: 106 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type BaseClient from 'common/lib/client/baseclient';
22
import type RealtimeChannel from 'common/lib/client/realtimechannel';
3+
import type ErrorInfo from 'common/lib/types/errorinfo';
34
import type EventEmitter from 'common/lib/util/eventemitter';
45
import type * as API from '../../../ably';
56
import type { ChannelState, StatusSubscription } from '../../../ably';
@@ -19,6 +20,17 @@ export enum ObjectsEvent {
1920
synced = 'synced',
2021
}
2122

23+
/**
24+
* Internal-only signals emitted on `_eventEmitterInternal` (never on `_eventEmitterPublic`), so they
25+
* are not observable through the public `RealtimeObject#on` API.
26+
*/
27+
enum ObjectsInternalEvent {
28+
// RTO23c1 / RTO20e1 - emitted when the channel transitions into DETACHED/SUSPENDED/FAILED, so that
29+
// parked objects-sync waiters (get()/publishAndApply) can fail. Carries the channel state and its
30+
// errorReason as emit arguments.
31+
syncWaitFailed = 'syncWaitFailed',
32+
}
33+
2234
/** @spec RTO22 */
2335
export enum ObjectsOperationSource {
2436
local = 'local',
@@ -39,6 +51,35 @@ const StateToEventsMap: Record<ObjectsState, ObjectsEvent | undefined> = {
3951

4052
export type ObjectsEventCallback = () => void;
4153

54+
/**
55+
* Remediation for a `get()` sync wait failing (RTO23c1). The rejection is recoverable, but the
56+
* recovery differs per state: `ensureAttached` at `get()` entry re-attaches a DETACHED channel
57+
* itself and proceeds through SUSPENDED (the SDK re-attaches when the connection recovers), so a
58+
* plain retry suffices for those two states, whereas from FAILED `get()` rejects at entry (90001)
59+
* until the channel is explicitly re-attached.
60+
*/
61+
function getSyncWaitFailureRemediation(state: ChannelState): string {
62+
switch (state) {
63+
case 'detached':
64+
return 'Retry channel.object.get(). The retried call re-attaches the channel and waits for a fresh objects sync.';
65+
case 'suspended':
66+
return 'Retry channel.object.get() once the channel re-attaches. The SDK re-attaches suspended channels automatically when the connection recovers, or call channel.attach() to retry now.';
67+
default:
68+
return 'Inspect the cause for the underlying failure. Call channel.attach() to recover the channel, then retry channel.object.get(). Calling channel.object.get() on a failed channel without re-attaching first rejects immediately.';
69+
}
70+
}
71+
72+
/**
73+
* Remediation for a `publishAndApply` sync wait failing (RTO20e1), reached via the public mutation
74+
* APIs (`LiveMap.set`/`remove`, `LiveCounter.increment`/`decrement`, batch). The operation was
75+
* published and ACKed before the wait started, so it is persisted server-side and must not be
76+
* retried; only the local optimistic apply failed, and the local object converges on the next
77+
* successful attach and objects sync. State-independent, unlike the `get()` remediation.
78+
*/
79+
function publishSyncWaitFailureRemediation(): string {
80+
return 'Do not retry the operation. It was already published and acknowledged by Ably, so retrying would apply it twice. The local object converges automatically on the next successful attach and objects sync. Inspect the cause and channel.errorReason for why the channel left the attached state.';
81+
}
82+
4283
export class RealtimeObject {
4384
gcGracePeriod: number;
4485

@@ -91,9 +132,9 @@ export class RealtimeObject {
91132
// implicit attach before proceeding
92133
await this._channel.ensureAttached();
93134

94-
// if we're not synced yet, wait for sync sequence to finish before returning root
135+
// RTO23c - if we're not synced yet, wait for sync sequence to finish before returning root
95136
if (this._state !== ObjectsState.synced) {
96-
await this._eventEmitterInternal.once(ObjectsEvent.synced); // RTO1c
137+
await this._waitForSyncedOrChannelFailure('the object could not be retrieved', getSyncWaitFailureRemediation); // RTO23c1
97138
}
98139

99140
const pathObject = new DefaultPathObject(this, this._objectsPool.getRoot(), []);
@@ -234,24 +275,32 @@ export class RealtimeObject {
234275
* @spec RTO4 - handling of the `ATTACHED` transition
235276
* @spec RTO27 - manage the stored objects data across non-`ATTACHED` transitions
236277
*/
237-
actOnChannelState(state: ChannelState, hasObjects?: boolean): void {
278+
actOnChannelState(state: ChannelState, hasObjects?: boolean, reason?: ErrorInfo | null): void {
238279
switch (state) {
239280
case 'attached':
240281
// RTO4 - ATTACHED is handled by onAttached (the sync lifecycle); it is outside RTO27's scope
241282
this.onAttached(hasObjects);
242283
break;
243284

244285
case 'detached':
286+
case 'suspended':
245287
case 'failed':
246-
// RTO27a - the actual current state of Objects data is unknown in these states, so clear it
247-
// without emitting update events (RTO27a1); the objects themselves remain in the pool.
248-
this._objectsPool.clearObjectsData(false); // RTO27a1
249-
this._syncObjectsPool.clear(); // RTO27a2
288+
// RTO23c1 / RTO20e1 - fail any parked objects-sync waiters (get()/publishAndApply) before the
289+
// RTO27a data clearing below (drain-then-clear, matching ably-cocoa). The channel's errorReason
290+
// is not yet assigned when notifyState invokes this handler, so it is passed in as `reason`.
291+
// Emitted unconditionally; a no-op when no waiter is parked.
292+
this._eventEmitterInternal.emit(ObjectsInternalEvent.syncWaitFailed, state, reason);
293+
294+
if (state !== 'suspended') {
295+
// RTO27a - the actual current state of Objects data is unknown in DETACHED/FAILED, so clear it
296+
// without emitting update events (RTO27a1); the objects themselves remain in the pool.
297+
this._objectsPool.clearObjectsData(false); // RTO27a1
298+
this._syncObjectsPool.clear(); // RTO27a2
299+
}
300+
// RTO27b - SUSPENDED (and every unlisted state: INITIALIZED, ATTACHING, DETACHING) retains the
301+
// objects data unchanged. For SUSPENDED in particular the connection may still recover, so the
302+
// retained data remains a valid best-effort local copy.
250303
break;
251-
252-
// RTO27b - every other state (SUSPENDED, INITIALIZED, ATTACHING, DETACHING) is intentionally
253-
// not handled here: the objects data is retained unchanged. For SUSPENDED in particular, the
254-
// connection may still recover, so the retained data remains a valid best-effort local copy.
255304
}
256305
}
257306

@@ -356,30 +405,10 @@ export class RealtimeObject {
356405
`waiting for sync to complete before applying ${syntheticMessages.length} message(s); channel=${this._channel.name}`,
357406
);
358407

359-
await new Promise<void>((resolve, reject) => {
360-
const cleanup = () => {
361-
this._eventEmitterInternal.off(onSynced);
362-
this._channel.internalStateChanges.off(onChannelState);
363-
};
364-
const onSynced = () => {
365-
cleanup();
366-
resolve();
367-
};
368-
// RTO20e1
369-
const onChannelState = () => {
370-
cleanup();
371-
reject(
372-
new this._client.ErrorInfo(
373-
`the operation could not be applied locally due to the channel entering the ${this._channel.state} state whilst waiting for objects sync to complete`,
374-
92008,
375-
400,
376-
this._channel.errorReason || undefined,
377-
),
378-
);
379-
};
380-
this._eventEmitterInternal.once(ObjectsEvent.synced, onSynced);
381-
this._channel.internalStateChanges.once(['detached', 'suspended', 'failed'], onChannelState);
382-
});
408+
await this._waitForSyncedOrChannelFailure(
409+
'the operation could not be applied locally',
410+
publishSyncWaitFailureRemediation,
411+
); // RTO20e1
383412
}
384413

385414
// RTO20f - Apply synthetic messages
@@ -597,6 +626,48 @@ export class RealtimeObject {
597626
}
598627
}
599628

629+
/**
630+
* Waits for the objects sync state to reach SYNCED, rejecting with a 92008 error if the channel
631+
* first transitions into DETACHED/SUSPENDED/FAILED (signalled by `actOnChannelState` via the
632+
* internal `syncWaitFailed` event). Shared by `get()` (RTO23c1) and `publishAndApply` (RTO20e1),
633+
* which differ in the error message's `failureDescription` prefix and in their caller-specific
634+
* `remediation` (resolved per failure state, since the recovery advice depends on it); the error's
635+
* code (92008), statusCode (400), and cause (the channel's errorReason) are mandated identically
636+
* by both spec points, which say nothing about remediation. The cause is the state-change
637+
* `reason` — the same error `notifyState` assigns to `RealtimeChannel.errorReason`; on a
638+
* reason-less transition (e.g. a clean detach) the cause is deliberately absent rather than a
639+
* stale prior errorReason. Both listeners are removed on either outcome (no leaks).
640+
*/
641+
private _waitForSyncedOrChannelFailure(
642+
failureDescription: string,
643+
remediation: (state: ChannelState) => string,
644+
): Promise<void> {
645+
return new Promise<void>((resolve, reject) => {
646+
const cleanup = () => {
647+
this._eventEmitterInternal.off(ObjectsEvent.synced, onSynced);
648+
this._eventEmitterInternal.off(ObjectsInternalEvent.syncWaitFailed, onChannelFailure);
649+
};
650+
const onSynced = () => {
651+
cleanup();
652+
resolve();
653+
};
654+
const onChannelFailure = (state: ChannelState, reason?: ErrorInfo | null) => {
655+
cleanup();
656+
reject(
657+
new this._client.ErrorInfo({
658+
message: `${failureDescription} due to the channel entering the ${state} state whilst waiting for objects sync to complete`,
659+
code: 92008,
660+
statusCode: 400,
661+
cause: reason || undefined,
662+
remediation: remediation(state),
663+
}),
664+
);
665+
};
666+
this._eventEmitterInternal.once(ObjectsEvent.synced, onSynced);
667+
this._eventEmitterInternal.once(ObjectsInternalEvent.syncWaitFailed, onChannelFailure);
668+
});
669+
}
670+
600671
private _stateChange(state: ObjectsState): void {
601672
if (this._state === state) {
602673
return;

test/uts/objects/unit/objects_pool.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,69 @@ describe('uts/objects/unit/objects_pool', function () {
360360
expect(rto._objectsPool.get('counter:new@1000')).to.exist;
361361
});
362362

363+
// UTS: objects/unit/RTO5a5/absent-channel-serial-0
364+
it('RTO5a5 - OBJECT_SYNC with no channelSerial is a single-message sync', async function () {
365+
const { channel, mockWs } = await setupManualChannel('test-RTO5a5', {
366+
onMessageFromClient: (msg, ws) => {
367+
if (msg.action === PM_ACTION.ATTACH) {
368+
ws.active_connection!.send_to_client({
369+
action: PM_ACTION.ATTACHED,
370+
channel: msg.channel,
371+
channelSerial: 'sync1:cursor',
372+
flags: HAS_OBJECTS,
373+
});
374+
// RTO5a5 - no channelSerial: the whole sync is contained in this one message, so the
375+
// objects are applied and the sync completes (SYNCED) without waiting for a cursor-empty
376+
// channelSerial (RTO5a4)
377+
ws.active_connection!.send_to_client(
378+
buildObjectSyncMessage(msg.channel, null as any, [
379+
buildObjectState('counter:new@1000', { aaa: 't:0' }, { counter: { count: 99 } }),
380+
]),
381+
);
382+
}
383+
},
384+
});
385+
386+
// get() waits for SYNCED, so it doubles as the synchronization point for the applied sync
387+
await channel.object.get();
388+
389+
const rto = getRealtimeObject(channel);
390+
expect(rto._state).to.equal('synced');
391+
expect(rto._objectsPool.get('counter:new@1000')).to.exist;
392+
});
393+
394+
// UTS: objects/unit/RTO5a6/malformed-channel-serial-treated-as-absent-0
395+
it('RTO5a6 - malformed channelSerial is treated as absent', async function () {
396+
const { channel, mockWs } = await setupManualChannel('test-RTO5a6', {
397+
onMessageFromClient: (msg, ws) => {
398+
if (msg.action === PM_ACTION.ATTACH) {
399+
ws.active_connection!.send_to_client({
400+
action: PM_ACTION.ATTACHED,
401+
channel: msg.channel,
402+
channelSerial: 'sync1:cursor',
403+
flags: HAS_OBJECTS,
404+
});
405+
// RTO5a6 - "malformedserialnocolon" has no ':' separator, so it cannot be parsed per
406+
// RTO5a1; it must be handled as if the channelSerial were absent (RTO5a5): the objects
407+
// are applied and the sync completes (SYNCED)
408+
ws.active_connection!.send_to_client(
409+
buildObjectSyncMessage(msg.channel, 'malformedserialnocolon', [
410+
buildObjectState('counter:new@1000', { aaa: 't:0' }, { counter: { count: 99 } }),
411+
]),
412+
);
413+
}
414+
},
415+
});
416+
417+
// get() waits for SYNCED, so it doubles as the synchronization point for the applied sync
418+
await channel.object.get();
419+
420+
const rto = getRealtimeObject(channel);
421+
// Treated as absent (RTO5a5): the message was applied and the sync ended
422+
expect(rto._state).to.equal('synced');
423+
expect(rto._objectsPool.get('counter:new@1000')).to.exist;
424+
});
425+
363426
// UTS: objects/unit/RTO5f2a/partial-map-merge-0
364427
it('RTO5f2a - partial object state merge for maps', async function () {
365428
const { channel, mockWs } = await setupManualChannel('test-RTO5f2a', {

0 commit comments

Comments
 (0)