Objects: explicit no-op marking, enforceable tombstone exception, RTO4b2a reset clarifications + UTS cases - #515
Conversation
…; clarify tombstone and RTO4b2a reset semantics
…ro/empty objects, and empty-root reset
There was a problem hiding this comment.
Pull request overview
This PR refines the LiveObjects/Objects specification around no-op updates and tombstone behavior (making tombstone teardown reliably enforceable), clarifies RTO4b2a reset edge cases, and adds UTS unit cases to cover previously untested branches.
Changes:
- Makes no-op marking explicit (
*.noop = true) for counter/map diffs and adds a normative carve-out so tombstone diffs are not collapsed to no-op. - Clarifies
RTO4b2abehavior for tombstoned entries and the “empty root reset” case. - Adds 7 UTS unit test cases covering empty-diff no-ops, tombstone-on-zero/empty behavior, empty synthetic list publishAndApply behavior, listener teardown, and empty-root reset behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| specifications/objects-features.md | Updates spec wording for explicit no-op marking, tombstone carve-outs, and RTO4b2a reset clarifications |
| uts/objects/unit/realtime_object.md | Adds UTS case for RTO20d4 (empty synthetic list skipping sync wait) |
| uts/objects/unit/objects_pool.md | Adds UTS case for RTO4b2a (empty-root reset emits no update) |
| uts/objects/unit/live_object_subscribe.md | Adds UTS case asserting tombstone update delivery + listener deregistration on already-zero counter |
| uts/objects/unit/internal_live_map.md | Adds UTS cases for tombstone-empty-map update and RTLM22c empty-diff no-op |
| uts/objects/unit/internal_live_counter.md | Adds UTS cases for tombstone-zero-counter update and RTLC14c zero-delta no-op |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| - `(RTO4b1)` All objects except the one with id `root` must be removed from the internal `ObjectsPool` | ||
| - `(RTO4b2)` The data for the `InternalLiveMap` with id `root` must be set to the value described in [RTLM4c](#RTLM4c). Note that the client SDK must not create a new `InternalLiveMap` instance with id `root`; it must only clear the internal data of the existing `InternalLiveMap` with id `root` | ||
| - `(RTO4b2a)` Emit a `LiveMapUpdate` object for the `InternalLiveMap` with ID `root`, with `LiveMapUpdate.update` consisting of entries for the keys that were removed, each set to `removed`, and without populating `LiveMapUpdate.objectMessage` | ||
| - `(RTO4b2a)` Emit a `LiveMapUpdate` object for the `InternalLiveMap` with ID `root`, with `LiveMapUpdate.update` consisting of entries for the keys that were removed, each set to `removed`, and without populating `LiveMapUpdate.objectMessage`. Only the keys of non-tombstoned entries are reported as `removed`, consistent with the non-tombstoned-visibility rule in [RTLM22b](#RTLM22b): entries that were already tombstoned were not part of the user-visible map data, so their removal is not reported. If no keys were removed (that is, the `root` map was already empty), the computed `LiveMapUpdate.update` contains no changed keys and is therefore a no-op per [RTLM22c](#RTLM22c) ([RTLO4b4b](#RTLO4b4b)), so no update is emitted. |
…on tail (RTO4b4/RTO5c) - Port the seven no-op-package UTS unit cases: RTLC14c/RTLM22c (zero-delta/ empty diffs are no-op updates), RTO20d4 (empty synthetic list skips the RTO20e wait), RTLO5 tombstone-of-zero/empty-object cases (the tombstone update must not be no-op-marked), RTLO4b4c3c teardown for a zero-valued counter, and RTO4b2a (reset of an already-empty root emits no update). Production already conforms at every site; the RTO20d4 port drives the real publishAndApply pipeline via a new optional ObjectsUTSCoreSDK publishHandler. - Extract the shared nosync_completeSync() completion tail used by both the ATTACHED(HAS_OBJECTS=0) path (RTO4b4) and the OBJECT_SYNC completion path (RTO5c), so the two cannot drift; add a native test proving the ATTACHED path resolves parked publishAndApply sync waiters. - Retire the native RTO5a5 twin superseded by the UTS port (bumped to two objects to keep its multi-object coverage); cross-link the counter tombstone-bypass comments to the RTLC14c zero-delta exception. Spec changes: ably/specification#515 Companion ably-js fix: ably/ably-js#2288
Port the seven no-op-package UTS cases: RTLC14c/RTLM22c (zero-delta/empty diffs are no-op updates, never delivered), RTO20d4 (empty synthetic list skips the RTO20e sync wait), the RTLO5 tombstone-of-zero/empty-object cases and the RTLO4b4c3c zero-valued-counter teardown case (covering BaseRealtimeLiveObject.tombstone()'s NoOp-synthesis branch for the first time), and RTO4b2a (reset of an already-empty root emits no update; verified with a second-pool liveness control via a backward-compatible optional target parameter on the ObjectsPoolTest processAttached helper). Production already conforms at every site; test-only change. Spec changes: ably/specification#515 Companion ably-js fix: ably/ably-js#2288
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Suppressed comments (6)
specifications/objects-features.md:150
- This mutates an established spec point’s observable semantics: the old clause reported every removed key and always emitted an update, while this version filters tombstones and suppresses empty diffs.
CONTRIBUTING.md:27-29requires core semantic changes to preserve the old ID and introduce a replacement ID; the exception only applies to very new, unimplemented clauses, whereas the PR description confirms multiple SDK implementations. Please replace RTO4b2a rather than changing its contract in place, then update its references/tests.
- `(RTO4b2a)` Emit a `LiveMapUpdate` object for the `InternalLiveMap` with ID `root`, with `LiveMapUpdate.update` consisting of entries for the keys that were removed, each set to `removed`, and without populating `LiveMapUpdate.objectMessage`. Only the keys of non-tombstoned entries are reported as `removed`, consistent with the non-tombstoned-visibility rule in [RTLM22b](#RTLM22b): entries that were already tombstoned were not part of the user-visible map data, so their removal is not reported. If no keys were removed (that is, the `root` map was already empty), the computed `LiveMapUpdate.update` contains no changed keys and is therefore a no-op per [RTLM22c](#RTLM22c) ([RTLO4b4b](#RTLO4b4b)), so no update is emitted.
specifications/objects-features.md:808
- RTLM22 declares only
previousDataandnewDataas inputs, yet this requires the same two empty-visible-state inputs to produce either a no-op or non-no-op based on undeclared caller context. That makes the diff contract non-deterministic for implementations. Move the override to RTLO4e after computing the ordinary diff, or add an explicit tombstone argument to RTLM22 and its callers.
- `(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` object with `LiveMapUpdate.noop` set to `true` ([RTLO4b4b](#RTLO4b4b)), as in [RTLM16b](#RTLM16b). This exception must not be applied when the diff is computed for a tombstone per [RTLO4e5](#RTLO4e5): the resulting tombstone update ([RTLO4b4e](#RTLO4b4e)) must not be marked as a no-op, so that it is still delivered — driving the [RTLO4b4c3c](#RTLO4b4c3c) listener teardown — even when the map already had no non-tombstoned entries.
specifications/objects-features.md:150
- The empty case is ambiguous for a root containing only tombstoned entries: physical keys are removed, so “no keys were removed”/“root was already empty” is false, but the preceding rule yields no reportable keys and therefore an empty diff. In the replacement clause, define this branch directly from
LiveMapUpdate.updatebeing empty (including tombstoned-only roots) and distinguish emitting the internal no-op object from the listener/path dispatch that RTLO4b4c1 suppresses.
- `(RTO4b2a)` Emit a `LiveMapUpdate` object for the `InternalLiveMap` with ID `root`, with `LiveMapUpdate.update` consisting of entries for the keys that were removed, each set to `removed`, and without populating `LiveMapUpdate.objectMessage`. Only the keys of non-tombstoned entries are reported as `removed`, consistent with the non-tombstoned-visibility rule in [RTLM22b](#RTLM22b): entries that were already tombstoned were not part of the user-visible map data, so their removal is not reported. If no keys were removed (that is, the `root` map was already empty), the computed `LiveMapUpdate.update` contains no changed keys and is therefore a no-op per [RTLM22c](#RTLM22c) ([RTLO4b4b](#RTLO4b4b)), so no update is emitted.
uts/objects/unit/internal_live_map.md:642
- These fixtures are not valid tombstoned
ObjectsMapEntrystates: RTLM8a2a/RTLM8b1 require tombstoning an entry to cleardatato null. Ports with invariant-enforcing entry types may be unable to construct this setup, and it does not exercise the real state produced by MAP_REMOVE. Use null data while retaining the tombstone metadata.
"name": { data: { string: "Alice" }, timeserial: "01", tombstone: true, tombstonedAt: 1600000000000 },
"age": { data: { number: 30 }, timeserial: "01", tombstone: true, tombstonedAt: 1600000000000 }
uts/objects/unit/live_object_subscribe.md:385
- This sequence cannot prove listener deregistration. RTLC7e rejects the increment because the counter is tombstoned, so no counter update would be emitted even if both listeners remained registered; the separate-map control only proves message processing completed. The test therefore passes with a listener leak. Use test instrumentation that can inspect the instance’s listener registry (or another observable way to verify deregistration) after the tombstone.
# Prove deregistration. As in the populated teardown case, a tombstoned object ignores further ops
# (RTLC7e), so neither the deregistered listeners nor a fresh listener on counter:score@1000 could
# ever fire — use a SEPARATE LIVE object (map:profile@1000) as the quiescence barrier. Messages are
# processed in order, so once the control fires, the follow-up "51" has also been processed.
uts/objects/unit/objects_pool.md:142
- The updated RTO4b2a also standardizes filtering already-tombstoned root entries, but this new case starts from
{}and only covers empty-root suppression. No UTS case exercises the newly normative filtering behavior. Add a reset with mixed live/tombstoned entries (assert only the live key is reported), or a tombstoned-only root (assert no subscriber event).
pool = ObjectsPool()
pool["counter:abc@1000"] = InternalLiveCounter(objectId: "counter:abc@1000")
# root is already empty (zero-value InternalLiveMap per RTLM4c)
pool["root"].data = {}
| - `(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) | ||
| - `(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` object with `LiveCounterUpdate.noop` set to `true` ([RTLO4b4b](#RTLO4b4b)), as in [RTLC9h](#RTLC9h). This exception must not be applied when the diff is computed for a tombstone per [RTLO4e5](#RTLO4e5): the resulting tombstone update ([RTLO4b4e](#RTLO4b4e)) must not be marked as a no-op, so that it is still delivered — driving the [RTLO4b4c3c](#RTLO4b4c3c) listener teardown — even when the counter data was already `0`. |
Note
Stacked on #514 (
objects/get-sync-wait-failure-and-sync-fixes) — review after that PR; only the two commits of this branch are new.Problem
Follow-up to #514, addressing its review feedback and the gaps found while bringing ably-js into conformance with the no-op update clauses:
RTLC14c/RTLM22csaid "return a … update marked as a no-op per RTLO4b4b", while every other no-op-producing clause in the spec (RTLC6e1, RTLC9h, RTLC16d, RTLM6e1, RTLM7h, RTLM16b, …) uses the explicit form "return aLive*Updateobject with*.noopset totrue". Divergent SDK interpretations were possible. (Raised by Copilot in Objects: specify get() sync-wait failure (RTO23c1), malformed sync serial handling (RTO5a6) and related clauses, with UTS unit cases #514 (comment).)RTO4b2a(the ATTACHED/no-objects reset update) was silent on two edges: whether already-tombstoned entries are reported asremoved, and what happens when no keys were removed (an already-empty root). ably-cocoa filters tombstoned entries and suppresses the empty update; ably-java suppresses via its diff; ably-js emitted a spurious{update: {}}.RTO20d4, tombstoning an already-zero/empty object (the branch every SDK special-cases — untested in all three), or the empty-root reset.Changes
specifications/objects-features.mdRTLC14c/RTLM22c(reworded)Live*Updateobject with*.noopset totrue([RTLO4b4b]), as in [RTLC9h] / [RTLM16b]" — matching the spec-wide convention. The tombstone exception is now normative and mechanical: the exception "must not be applied when the diff is computed for a tombstone per [RTLO4e5]: the resulting tombstone update ([RTLO4b4e]) must not be marked as a no-op", so it is delivered and drives the [RTLO4b4c3c] teardown even when the data was already zero/empty.RTO4b2a(clarified)removed(consistent with [RTLM22b]'s non-tombstoned-visibility rule), and if no keys were removed the update has no changed keys and is a no-op per [RTLM22c]/[RTLO4b4b], so nothing is emitted.uts/objects/unit/— 7 new unit casesinternal_live_counter.md:RTLC14c/zero-delta-diff-is-noop-0(identical-state override → no-op, notamount: 0);RTLO5/tombstone-zero-value-counter-emits-update-0(the exception's flip side: tombstoning a zero counter still yields a non-no-op update withtombstone == true,amount == 0).internal_live_map.md:RTLM22c/empty-diff-is-noop-0(identical non-tombstoned entries — only the timeserial differs, which RTLM22b3 does not compare);RTLO5/tombstone-empty-map-emits-update-0(all-entries-tombstoned map).realtime_object.md:RTO20d4/empty-synthetic-list-skips-sync-wait-0(all-null ACK serials while SYNCING → the operation resolves without the sync ever completing, proving the RTO20e wait was skipped).live_object_subscribe.md:RTLO4b4c3c/tombstone-zero-value-counter-tears-down-0(subscriber receives the zero-amount tombstone update and listeners are deregistered).objects_pool.md:RTO4b2a/reset-of-empty-root-emits-no-update-0(with a second-pool liveness control).SDK status
feature/liveobjects-implementationtombstone()NoOp-synthesis)