From efcdac9e4c58bd34062b60d08d921c94f0d3b0db Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 22 Jul 2026 20:15:11 +0530 Subject: [PATCH 1/2] Add RTLO4e10 and RTO10c1b1: the root object must never be tombstoned or GC-evicted RTO10c1b as written compelled a compliant SDK to violate RTO3b: an OBJECT_DELETE (or a tombstoned ObjectState) targeting the root object would tombstone it - clearing its data, deregistering all listeners, and (since tombstone is a terminal state) making the channel's objects permanently unrecoverable - after which the GC sweep would evict root from the ObjectsPool entirely. - RTLO4e10: LiveObject.tombstone must reject the root object with a warning and a noop LiveObjectUpdate. Placed in the shared tombstone method so it covers both the RTLO5 operation path and the RTLC6f/RTLM6f sync paths. - RTO10c1b1: the GC sweep must never remove the root object from the ObjectsPool (mirrors RTO5c2a), as a safeguard for the RTO3b invariant. UTS test specs: - internal_live_map.md: new RTLO4e10 tests for the operation and sync paths; the three existing tombstone-mechanics tests (RTLO5, RTLM6f, RTLO4e9) are repointed from objectId "root" to a tombstoneable non-root map, reconciling the spec with the ids the ably-js derived tests already used. - realtime_object.md: new channel-level RTO10c1b1 test verifying root survives a GC sweep after a rogue OBJECT_DELETE and stays functional. Raised in ably/ably-js#2219 review discussion r3558197916. --- specifications/objects-features.md | 2 + uts/objects/unit/internal_live_map.md | 104 +++++++++++++++++++++++--- uts/objects/unit/realtime_object.md | 46 ++++++++++++ 3 files changed, 142 insertions(+), 10 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index ba9564397..194703f19 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -230,6 +230,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO10c1)` For each `LiveObject` in the `ObjectsPool`: - `(RTO10c1a)` Check if the `LiveObject` needs to release any resources, see [RTLM19](#RTLM19) - `(RTO10c1b)` If `LiveObject.isTombstone` is `true`, and the difference between the current time and `LiveObject.tombstonedAt` is greater than or equal to the [grace period](#RTO10b), remove the object from the `ObjectsPool` and release resources for the corresponding object entity to allow it to be garbage collected + - `(RTO10c1b1)` The object with ID `root` must not be removed from `ObjectsPool`, as per [RTO3b](#RTO3b). Note that the `root` object can never become tombstoned per [RTLO4e10](#RTLO4e10), so this exclusion acts as an additional safeguard for the [RTO3b](#RTO3b) invariant - `(RTO13)` This clause has been deleted (redundant to [RTO11f15](#RTO11f15) and [RTO12f13](#RTO12f13)) as of specification version 6.0.0. - `(RTO13a)` This clause has been deleted as of specification version 6.0.0. - `(RTO13a1)` This clause has been deleted as of specification version 6.0.0. @@ -383,6 +384,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4e)` protected `tombstone` - a convenience method used to tombstone this `LiveObject`. The realtime system reserves the right to tombstone an object (i.e. mark it for deletion from the objects pool) by publishing an `OBJECT_DELETE` operation at any time if the object is orphaned (not a descendant of the root object) or remains uninitialized (no `*_CREATE` operation has been received) for an extended period. Only the realtime system may publish an `OBJECT_DELETE` operation; clients must never send it. This method describes the steps the client library must take when it needs to tombstone an object locally. Eventually, tombstoned objects will be garbage collected following the procedure described in [RTO10](#RTO10) - `(RTLO4e1)` Expects the following arguments: - `(RTLO4e1a)` `ObjectMessage` + - `(RTLO4e10)` If `LiveObject.objectId` is `root`, log a warning indicating that an attempt was made to tombstone the `root` object, and return a `LiveObjectUpdate` with `LiveObjectUpdate.noop` set to `true` without performing any of the subsequent steps in this clause. The `root` object must always exist in the `ObjectsPool` per [RTO3b](#RTO3b); it is never orphaned or uninitialized, so the realtime system never publishes an `OBJECT_DELETE` operation or an `ObjectState` with `tombstone` set to `true` targeting it — receiving one indicates a faulty message - `(RTLO4e2)` Set `LiveObject.isTombstone` to `true` - `(RTLO4e3)` Set `LiveObject.tombstonedAt` to the value calculated per [RTLO6](#RTLO6), using `ObjectMessage.serialTimestamp` - `(RTLO4e3a)` This clause has been replaced by [RTLO6a](#RTLO6a) diff --git a/uts/objects/unit/internal_live_map.md b/uts/objects/unit/internal_live_map.md index 7df2506f0..348601398 100644 --- a/uts/objects/unit/internal_live_map.md +++ b/uts/objects/unit/internal_live_map.md @@ -584,9 +584,12 @@ ASSERT map.data == {} | RTLO4e6 | Set tombstone flag on the update | | RTLO4e7 | Set objectMessage on the update | +Uses a non-root map: an `OBJECT_DELETE` targeting `root` is rejected per RTLO4e10 +(see `objects/unit/RTLO4e10/object-delete-root-noop-0`). + ### Setup ```pseudo -map = InternalLiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "map:test@1000", semantics: "LWW") map.data = { "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false }, "age": { data: { number: 30 }, timeserial: "01", tombstone: false } @@ -596,7 +599,7 @@ map.siteTimeserials = { "site1": "00" } ### Test Steps ```pseudo -msg = build_object_delete("root", "01", "site1", 1700000000000) +msg = build_object_delete("map:test@1000", "01", "site1", 1700000000000) update = map.applyOperation(msg, source: CHANNEL) ``` @@ -611,6 +614,42 @@ ASSERT update.objectMessage == msg --- +## RTLO4e10 - OBJECT_DELETE targeting root is rejected + +**Test ID**: `objects/unit/RTLO4e10/object-delete-root-noop-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4e10 | Tombstoning root logs a warning and returns a noop update | +| RTO3b | ObjectsPool must always contain the root object | + +The realtime system never publishes an `OBJECT_DELETE` targeting `root` (RTLO4e); if +one is received it indicates a faulty message and must not tombstone the root object. + +### Setup +```pseudo +map = InternalLiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +map.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_object_delete("root", "01", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.isTombstone == false +ASSERT map.data["name"].data.string == "Alice" // data untouched +ASSERT update.noop == true +``` + +--- + ## RTLM14, RTLM14c - Tombstoned entry check includes objectId reference **Test ID**: `objects/unit/RTLM14/tombstone-check-objectid-ref-0` @@ -775,9 +814,12 @@ ASSERT map.createOperationIsMerged == true | RTLO4e6 | Tombstone flag set on the update | | RTLO4e7 | objectMessage set on the update | +Uses a non-root map: tombstoning `root` is rejected per RTLO4e10 +(see `objects/unit/RTLO4e10/replace-data-tombstone-root-noop-0`). + ### Setup ```pseudo -map = InternalLiveMap(objectId: "root", semantics: "LWW") +map = InternalLiveMap(objectId: "map:test@1000", semantics: "LWW") map.data = { "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } } @@ -785,7 +827,7 @@ map.data = { ### Test Steps ```pseudo -state_msg = build_object_state("root", {"site1": "01"}, { +state_msg = build_object_state("map:test@1000", {"site1": "01"}, { map: { semantics: "LWW", entries: {} }, tombstone: true }) @@ -803,6 +845,45 @@ ASSERT update.objectMessage == state_msg --- +## RTLO4e10 - replaceData with tombstone flag targeting root is rejected + +**Test ID**: `objects/unit/RTLO4e10/replace-data-tombstone-root-noop-0` + +| Spec | Requirement | +|------|-------------| +| RTLM6f | ObjectState.tombstone true routes to LiveObject.tombstone | +| RTLO4e10 | Tombstoning root logs a warning and returns a noop update | + +The sync path (RTLM6f) reaches the same `LiveObject.tombstone` method as +OBJECT_DELETE, so an `ObjectState` with `tombstone: true` for `root` must be +rejected the same way. + +### Setup +```pseudo +map = InternalLiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site1": "01"}, { + map: { semantics: "LWW", entries: {} }, + tombstone: true +}) +update = map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.isTombstone == false +ASSERT map.data["name"].data.string == "Alice" // data untouched +ASSERT update.noop == true +``` + +--- + ## RTLM19 - GC removes tombstoned entries past grace period **Test ID**: `objects/unit/RTLM19/gc-tombstoned-entries-0` @@ -1300,6 +1381,9 @@ ASSERT update.objectMessage == msg Tests that when an InternalLiveMap is tombstoned (via OBJECT_DELETE), removeParentReference is called for each entry that references a LiveObject before the data is cleared. +Uses a non-root map: tombstoning `root` is rejected per RTLO4e10 +(see `objects/unit/RTLO4e10/object-delete-root-noop-0`). + ### Setup ```pseudo pool = ObjectsPool() @@ -1308,20 +1392,20 @@ child_map = InternalLiveMap(objectId: "map:child@1000", semantics: "LWW") pool["counter:child@1000"] = child_counter pool["map:child@1000"] = child_map -map = InternalLiveMap(objectId: "root", semantics: "LWW", pool: pool) +map = InternalLiveMap(objectId: "map:test@1000", semantics: "LWW", pool: pool) map.data = { "counter_ref": { data: { objectId: "counter:child@1000" }, timeserial: "01", tombstone: false }, "map_ref": { data: { objectId: "map:child@1000" }, timeserial: "01", tombstone: false }, "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } } map.siteTimeserials = { "site1": "00" } -child_counter.parentReferences = { "root": {"counter_ref"} } -child_map.parentReferences = { "root": {"map_ref"} } +child_counter.parentReferences = { "map:test@1000": {"counter_ref"} } +child_map.parentReferences = { "map:test@1000": {"map_ref"} } ``` ### Test Steps ```pseudo -msg = build_object_delete("root", "01", "site1", 1700000000000) +msg = build_object_delete("map:test@1000", "01", "site1", 1700000000000) update = map.applyOperation(msg, source: CHANNEL) ``` @@ -1330,8 +1414,8 @@ update = map.applyOperation(msg, source: CHANNEL) ASSERT map.isTombstone == true ASSERT map.data == {} // removeParentReference was called on both children -ASSERT "root" NOT IN child_counter.parentReferences OR "counter_ref" NOT IN child_counter.parentReferences["root"] -ASSERT "root" NOT IN child_map.parentReferences OR "map_ref" NOT IN child_map.parentReferences["root"] +ASSERT "map:test@1000" NOT IN child_counter.parentReferences OR "counter_ref" NOT IN child_counter.parentReferences["map:test@1000"] +ASSERT "map:test@1000" NOT IN child_map.parentReferences OR "map_ref" NOT IN child_map.parentReferences["map:test@1000"] ASSERT update.update == { "counter_ref": "removed", "map_ref": "removed", "name": "removed" } ASSERT update.tombstone == true ASSERT update.objectMessage == msg diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md index 99b54be48..3a20db817 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -1128,6 +1128,52 @@ ASSERT root.get("score").value() == null --- +## RTO10c1b1 - GC never removes the root object + +**Test ID**: `objects/unit/RTO10c1b1/gc-root-never-removed-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4e10 | OBJECT_DELETE targeting root is rejected with a warning | +| RTO10c1b1 | The root object is never removed from the pool by GC | +| RTO3b | ObjectsPool must always contain the root object | + +The realtime system never publishes an `OBJECT_DELETE` targeting `root`; receiving +one indicates a faulty message. It must not tombstone the root object (RTLO4e10), +and even after the GC grace period elapses the root object must remain in the pool +and stay functional (RTO10c1b1, safeguarding RTO3b). + +### Setup +```pseudo +enable_fake_timers() +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +// Rogue OBJECT_DELETE targeting the root object: rejected per RTLO4e10 +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("root", remote_serial(0), "remote", now()) +])) +``` + +### Test Steps +```pseudo +ASSERT root.get("name").value() == "Alice" // root not tombstoned, data untouched + +ADVANCE_TIME(86400000 + 300000) + +// root must still be live: a subsequent operation still applies to the same +// root object the client holds +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, remote_serial(1), "remote") +])) +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + ## RTO20 - Echo deduplication via appliedOnAckSerials **Test ID**: `objects/unit/RTO20/echo-dedup-0` From e1c9a95c45d4861ab0f8bd41cd42376dcdd64359 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 23 Jul 2026 09:26:24 +0530 Subject: [PATCH 2/2] Document that the RTO10c1b1 GC branch is intentionally not exercised in isolation RTLO4e10 makes a tombstoned root unreachable in a conforming implementation, so the channel-level test verifies the composed behaviour and the GC-side exclusion remains defense-in-depth verified by its spec tag rather than by fabricating an impossible state. Addresses review feedback on #509. --- uts/objects/unit/realtime_object.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md index 3a20db817..411fd9126 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -1143,6 +1143,12 @@ one indicates a faulty message. It must not tombstone the root object (RTLO4e10) and even after the GC grace period elapses the root object must remain in the pool and stay functional (RTO10c1b1, safeguarding RTO3b). +Note: this test verifies the composed behaviour. The RTO10c1b1 branch in isolation +(a tombstoned root surviving the GC sweep) is deliberately not exercised: RTLO4e10 +makes that state unreachable in a conforming implementation, so the GC-side +exclusion is defense-in-depth verified by its spec tag rather than by fabricating +an impossible state. + ### Setup ```pseudo enable_fake_timers()