From dadf9daeb5456103849c7928fa52703987f39cbf Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Sun, 9 Aug 2026 19:47:15 +0530 Subject: [PATCH 1/2] objects: add RTO23c1, RTO5a6, RTO20d4, RTLC14c/RTLM22c; clarify RTO27a1 and RTO27 cross-refs --- specifications/objects-features.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 227d6f159..16e082d8e 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -20,6 +20,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO23b)` This clause has been replaced by [RTO23e](#RTO23e) - `(RTO23e)` Perform the *ensure-active-channel* procedure ([RTL33](../features#RTL33)) on the underlying `RealtimeChannel`. If the procedure fails, the `get` function must reject with the same `ErrorInfo` that caused the procedure to fail - `(RTO23c)` If the [RTO17](#RTO17) sync state is not `SYNCED`, waits for the sync state to transition to `SYNCED` + - `(RTO23c1)` If the channel enters the `DETACHED`, `SUSPENDED`, or `FAILED` state while waiting for the sync state to transition to `SYNCED`, the `get` operation must fail with an `ErrorInfo` error with `code` `92008`, a `statusCode` of `400`, a `message` stating that the object could not be retrieved due to the channel entering the respective state whilst waiting for objects sync to complete, and `cause` set to the `RealtimeChannel.errorReason` if it is set. This applies regardless of the state the channel transitioned from (for example, a channel that enters `FAILED` from `SUSPENDED` must also fail the waiting `get` operation), and mirrors [RTO20e1](#RTO20e1), the equivalent failure for `publishAndApply` - `(RTO23d)` Returns a new `PathObject` ([RTPO1](#RTPO1)) with `path` ([RTPO2a](#RTPO2a)) set to an empty list and `root` ([RTPO2b](#RTPO2b)) set to the `InternalLiveMap` with id `root` from the internal `ObjectsPool` - `(RTO11)` This clause has been replaced by [RTLMV3](#RTLMV3). - `(RTO11a)` This clause has been replaced by [RTLMV3](#RTLMV3). @@ -161,6 +162,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO5a3)` If the sequence id matches the previously received sequence id, the client library should continue the sync process - `(RTO5a4)` The objects sync sequence for that sequence identifier is considered complete once the cursor is empty; that is when the `channelSerial` looks like `:` - `(RTO5a5)` An `OBJECT_SYNC` may also be sent with no `channelSerial` attribute. In this case, the sync data is entirely contained within the `ProtocolMessage` + - `(RTO5a6)` If the `channelSerial` is present but malformed --- that is, it does not contain the `:` separator required by [RTO5a1](#RTO5a1) and so cannot be split into a `` and a `` --- the client library must handle the `OBJECT_SYNC` as if the `channelSerial` were absent per [RTO5a5](#RTO5a5), and should log a warning - `(RTO5b)` This clause has been replaced by [RTO5f](#RTO5f) - `(RTO5f)` During the sync sequence, `ObjectMessages` from incoming `OBJECT_SYNC` `ProtocolMessages` must be temporarily stored in the internal `SyncObjectsPool`, keyed by `ObjectMessage.object.objectId`. The `SyncObjectsPool` stores one `ObjectMessage` per `objectId`, which may represent merged state from multiple incoming messages. For each `ObjectMessage` in the incoming `OBJECT_SYNC` `ProtocolMessage`, let `ObjectState` be `ObjectMessage.object`: - `(RTO5f3)` If neither `ObjectState.map` nor `ObjectState.counter` is present on the incoming message, log a warning that a state message with an unsupported object type was received and skip the incoming message @@ -192,9 +194,9 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO5c5)` The `bufferedObjectOperations` list must be cleared - `(RTO5c9)` The `appliedOnAckSerials` set ([RTO7b](#RTO7b)) must be cleared. A state sync causes the channel's LiveObjects data to be replaced, so after a state sync the `appliedOnAckSerials` no longer accurately describes which operations have been applied to the channel's LiveObjects data - `(RTO5c8)` The [RTO17](#RTO17) sync state must transition to `SYNCED` -- `(RTO27)` When the channel transitions to a state other than `ATTACHED`, the client library must manage the stored objects data as follows (the `ATTACHED` transition is handled by [RTO4](#RTO4); for the effect of these transitions on an in-progress `publishAndApply`, see [RTO20e1](#RTO20e1)): +- `(RTO27)` When the channel transitions to a state other than `ATTACHED`, the client library must manage the stored objects data as follows (the `ATTACHED` transition is handled by [RTO4](#RTO4); for the effect of these transitions on an in-progress `publishAndApply` or `get`, see [RTO20e1](#RTO20e1) and [RTO23c1](#RTO23c1)): - `(RTO27a)` When the channel transitions to the `DETACHED` or `FAILED` state, the current state of the objects data can no longer be known, so the client library must: - - `(RTO27a1)` For every object in the internal `ObjectsPool`, clear its internal data, resetting it to the zero value for its type (an empty map, or a counter with value `0`), without emitting any `LiveObjectUpdate` events. The objects themselves remain in the `ObjectsPool`; only their data is cleared + - `(RTO27a1)` For every object in the internal `ObjectsPool`, clear its internal data, resetting it to that of a new empty object of its type (an empty map per [RTLM4c](#RTLM4c), or a counter with `data` `0` per [RTLC4b](#RTLC4b)), without emitting any `LiveObjectUpdate` events. The objects themselves remain in the `ObjectsPool`; only their data is cleared - `(RTO27a2)` The `SyncObjectsPool` must be cleared - `(RTO27b)` When the channel transitions to any other state (for example `SUSPENDED`, `INITIALIZED`, `ATTACHING`, or `DETACHING`), the client library must retain the stored objects data unchanged. In the `SUSPENDED` case in particular, the connection may still recover and the retained data remains a valid best-effort local copy - `(RTO6)` Certain object operations may require creating a new object if one does not already exist in the internal `ObjectsPool` for the given `objectId`. This can be done as follows: @@ -277,6 +279,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO20d2a)` `ObjectMessage.serial` to the serial from the `PublishResult` - `(RTO20d2b)` `ObjectMessage.siteCode` to the [CD2j](../features#CD2j) `ConnectionDetails.siteCode` - `(RTO20d3)` Add the synthetic `ObjectMessage` to the list + - `(RTO20d4)` If the resulting list of synthetic `ObjectMessages` is empty (for example because every serial from the `PublishResult` was `null` and thus skipped per [RTO20d1](#RTO20d1)), there is nothing to apply locally, so the `publishAndApply` operation completes successfully without performing the [RTO20e](#RTO20e) wait - `(RTO20e)` If the [RTO17](#RTO17) sync state is not `SYNCED`, wait for the sync state to transition to `SYNCED` - `(RTO20e1)` If the channel enters the `DETACHED`, `SUSPENDED`, or `FAILED` state while waiting for the sync state to transition to `SYNCED`, the `publishAndApply` operation must fail with an `ErrorInfo` error with `code` `92008`, a `statusCode` of `400`, a `message` stating that the operation could not be applied locally due to the channel entering the respective state whilst waiting for objects sync to complete, and `cause` set to the `RealtimeChannel.errorReason` if it is set - `(RTO20f)` Apply the synthetic `ObjectMessages` as described in [RTO9](#RTO9), passing `source` as `LOCAL` @@ -526,6 +529,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLC14a1)` `previousData` `Number` - the previous `data` value - `(RTLC14a2)` `newData` `Number` - the new `data` value - `(RTLC14b)` Return a `LiveCounterUpdate` object with `LiveCounterUpdate.update.amount` set to `newData - previousData` + - `(RTLC14c)` As an exception to [RTLC14b](#RTLC14b): if `newData` equals `previousData` (that is, the computed delta is `0`), the counter data did not change, so instead of returning an update return a `LiveCounterUpdate` marked as a no-op per [RTLO4b4b](#RTLO4b4b) ### InternalLiveMap @@ -801,6 +805,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM22b1)` For each key that exists in the non-tombstoned entries of `previousData` but does not exist in the non-tombstoned entries of `newData`, add the key to `LiveMapUpdate.update` with the value `removed` - `(RTLM22b2)` For each key that exists in the non-tombstoned entries of `newData` but does not exist in the non-tombstoned entries of `previousData`, add the key to `LiveMapUpdate.update` with the value `updated` - `(RTLM22b3)` For each key that exists in the non-tombstoned entries of both `previousData` and `newData`, perform a deep comparison of the `data` attributes from `previousData` and `newData`. If the data values differ, add the key to `LiveMapUpdate.update` with the value `updated` + - `(RTLM22c)` As an exception to [RTLM22b](#RTLM22b): if the `LiveMapUpdate.update` computed in [RTLM22b](#RTLM22b) contains no changed keys (it is empty), no map key actually changed, so instead of returning an update return a `LiveMapUpdate` marked as a no-op per [RTLO4b4b](#RTLO4b4b) ### LiveCounter From c6239fd454554e251f9c314fa5809ab0be56215c Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Sun, 9 Aug 2026 19:47:15 +0530 Subject: [PATCH 2/2] objects/uts: add unit cases for RTO23c1 (per channel state), RTO5a5 and RTO5a6 --- uts/objects/unit/objects_pool.md | 68 ++++++++++++++ uts/objects/unit/realtime_object.md | 137 ++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/uts/objects/unit/objects_pool.md b/uts/objects/unit/objects_pool.md index ada2c79c9..efe94f1a3 100644 --- a/uts/objects/unit/objects_pool.md +++ b/uts/objects/unit/objects_pool.md @@ -206,6 +206,74 @@ ASSERT "counter:new@1000" IN pool --- +## RTO5a5 - OBJECT_SYNC with no channelSerial is a single-message sync + +**Test ID**: `objects/unit/RTO5a5/absent-channel-serial-0` + +**Spec requirement:** An `OBJECT_SYNC` may be sent with no `channelSerial` attribute. In this case the +sync data is entirely contained within the single `ProtocolMessage`: the objects are applied and the +sync sequence completes (`SYNCED`) without waiting for a cursor-empty `channelSerial` (RTO5a4). This is +the baseline that the RTO5a6 malformed-channelSerial case defers to. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +# No channelSerial: the whole sync is contained in this one message (RTO5a5) +pool.processObjectSync(build_object_sync_message("test", null, [ + build_object_state("counter:new@1000", {"aaa": "t:0"}, { counter: { count: 99 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:new@1000" IN pool +``` + +--- + +## RTO5a6 - Malformed channelSerial is treated as absent + +**Test ID**: `objects/unit/RTO5a6/malformed-channel-serial-treated-as-absent-0` + +**Spec requirement:** If the `channelSerial` is present but malformed --- it does not contain the `:` +separator required by RTO5a1 and so cannot be split into a `` and a `` --- +the `OBJECT_SYNC` must be handled as if the `channelSerial` were absent per RTO5a5 (data applied, sync +completes `SYNCED`), and a warning should be logged. This must stay distinct from the RTO5a5 baseline. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +# "malformedserialnocolon" has no ':' separator, so it cannot be parsed per RTO5a1; RTO5a6 +# requires handling it as if the channelSerial were absent (RTO5a5). +pool.processObjectSync(build_object_sync_message("test", "malformedserialnocolon", [ + build_object_state("counter:new@1000", {"aaa": "t:0"}, { counter: { count: 99 } }) +])) +``` + +### Assertions +```pseudo +# Treated as absent (RTO5a5): the message was applied and the sync ended. +ASSERT pool.syncState == SYNCED +ASSERT "counter:new@1000" IN pool +``` + +--- + ## RTO5f2a - Partial object state merge for maps **Test ID**: `objects/unit/RTO5f2a/partial-map-merge-0` diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md index 524724033..d340c671a 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -188,6 +188,143 @@ ASSERT root.path == [] --- +## RTO23c1 - get() fails when channel enters DETACHED during sync wait + +**Test ID**: `objects/unit/RTO23c1/fails-on-channel-detached-0` + +**Spec requirement:** If the channel enters `DETACHED`/`SUSPENDED`/`FAILED` while `get` waits for the +sync state to transition to `SYNCED`, the `get` operation must fail with an `ErrorInfo` error with +`code` `92008` and `statusCode` `400`. + +This is the get()-side counterpart of RTO20e1 (which covers the same wait-failure for +`publishAndApply`). The channel is detached **client-side** while `get` waits for SYNCED — an +unsolicited server DETACHED would trigger an immediate re-attach (RTL13a) in a compliant SDK, so the +channel would never observably stay DETACHED. A solicited `channel.detach()` does not trigger RTL13a; +the shared mock answers the outbound DETACH with DETACHED. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +# Move the objects sync state back to SYNCING so a fresh get() must wait (RTO23c) +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +get_future = channel.object.get() + +# While still SYNCING the get() cannot complete — it parks in the RTO23c wait for SYNCED +ASSERT get_future IS NOT complete + +# A client-side detach then moves the channel to DETACHED +AWAIT channel.detach() + +AWAIT get_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92008 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO23c1 - get() fails when channel enters SUSPENDED during sync wait + +**Test ID**: `objects/unit/RTO23c1/fails-on-channel-suspended-0` + +**Spec requirement:** If the channel enters `DETACHED`/`SUSPENDED`/`FAILED` while `get` waits for the +sync state to transition to `SYNCED`, the `get` operation must fail with an `ErrorInfo` error with +`code` `92008` and `statusCode` `400`. + +SUSPENDED is a connection-level state that a channel-level mock cannot drive, so — exactly as the RTO27 +tests do — this case drives the RealtimeObject's internal channel-state handler directly via +`channel.object.processChannelState(SUSPENDED)` (ably-js `RealtimeObject.actOnChannelState`, ably-java +`DefaultRealtimeObject.handleStateChange(state, false)`). RTO27b retains objects *data* on SUSPENDED, +but RTO23c1 must still fail any in-flight `get` sync wait. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +# Move the objects sync state back to SYNCING so a fresh get() must wait (RTO23c) +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +get_future = channel.object.get() +ASSERT get_future IS NOT complete + +# The mock cannot drive SUSPENDED; drive the channel-state handler directly (as RTO27 does) +channel.object.processChannelState(SUSPENDED) + +AWAIT get_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92008 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO23c1 - get() fails with cause when channel enters FAILED during sync wait + +**Test ID**: `objects/unit/RTO23c1/fails-on-channel-failed-0` + +**Spec requirement:** If the channel enters `DETACHED`/`SUSPENDED`/`FAILED` while `get` waits for the +sync state to transition to `SYNCED`, the `get` operation must fail with an `ErrorInfo` error with +`code` `92008`, `statusCode` `400`, and `cause` set to the `RealtimeChannel.errorReason` when it is set. + +Mirrors the RTO20e1 FAILED case (an injected channel ERROR moves the channel to FAILED while `get` +waits for SYNCED) and additionally asserts the `cause`: RTO23c1 requires `cause` to be set to the +channel's `errorReason` when present, which here is the injected FAILED error. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +# Move the objects sync state back to SYNCING so a fresh get() must wait (RTO23c) +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +get_future = channel.object.get() +ASSERT get_future IS NOT complete + +# A channel ERROR moves the channel to FAILED and sets its errorReason +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, channel: "test", + error: { code: 90000, statusCode: 400, message: "Channel failed" } +)) + +AWAIT get_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92008 +ASSERT error.statusCode == 400 +# RTO23c1 - cause is set to the channel's errorReason (the injected FAILED error) +ASSERT error.cause.code == 90000 +``` + +--- + ## RTO15 - publish sends OBJECT ProtocolMessage **Test ID**: `objects/unit/RTO15/publish-sends-object-pm-0`