Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions specifications/objects-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 `<sequence id>:`
- `(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 `<sequence id>` and a `<cursor value>` --- the client library must handle the `OBJECT_SYNC` as if the `channelSerial` were absent per [RTO5a5](#RTO5a5), and should log a warning

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems, this is what we do in case of ably-js

- `(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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in the stacked follow-up PR #515: RTLC14c/RTLM22c now use the spec-wide explicit form ("return a Live*Update object with *.noop set to true ([RTLO4b4b])"), citing RTLC9h / RTLM16b as the phrasing exemplars.


### InternalLiveMap

Expand Down Expand Up @@ -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

Expand Down
68 changes: 68 additions & 0 deletions uts/objects/unit/objects_pool.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<sequence id>` and a `<cursor value>` ---
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`
Expand Down
137 changes: 137 additions & 0 deletions uts/objects/unit/realtime_object.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading