Skip to content
Merged
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
2 changes: 2 additions & 0 deletions specifications/objects-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
104 changes: 94 additions & 10 deletions uts/objects/unit/internal_live_map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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)
```

Expand All @@ -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`
Expand Down Expand Up @@ -775,17 +814,20 @@ 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 }
}
```

### 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
})
Expand All @@ -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`
Expand Down Expand Up @@ -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()
Expand All @@ -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)
```

Expand All @@ -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
Expand Down
52 changes: 52 additions & 0 deletions uts/objects/unit/realtime_object.md
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,58 @@ 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 |
Comment thread
sacOO7 marked this conversation as resolved.

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).

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()
{ 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`
Expand Down
Loading