Skip to content

Commit 15f8798

Browse files
Implement MAP_CLEAR operation (RTLM24) and fill in tests
Implement applyMapClearOperation which checks clearTimeserial (RTLM24c), updates it (RTLM24d), then removes entries from the internal data whose timeserial is nil or <= the clear serial (RTLM24e). Returns a LiveMapUpdate with the removed keys (RTLM24f). Note: this follows the WIP spec change in 91dd01c where MAP_CLEAR removes entries from data rather than tombstoning them, since the clearTimeserial guards on MAP_SET and MAP_REMOVE make tombstoning redundant. TODO: bring in line with the final spec once Andrii has finalised his PR. Tests: - RTLM24c: parameterised clearTimeserial check (4 cases) - RTLM24: operation application with older, equal, newer, and nil timeserial entries - RTLM15d8: routing through nosync_apply with subscriber emission - RTO9a2a: routing through handleObjectProtocolMessage - mapClearOperationMessage test factory Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6edbe8f commit 15f8798

4 files changed

Lines changed: 241 additions & 7 deletions

File tree

Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -913,7 +913,33 @@ internal final class InternalDefaultLiveMap: Sendable {
913913
internal mutating func applyMapClearOperation(
914914
serial: String?,
915915
) -> LiveObjectUpdate<DefaultLiveMapUpdate> {
916-
fatalError("TODO: Not yet implemented")
916+
guard let serial else {
917+
return .noop
918+
}
919+
920+
// RTLM24c
921+
if let clearTimeserial, serial <= clearTimeserial {
922+
return .noop
923+
}
924+
925+
// RTLM24d
926+
clearTimeserial = serial
927+
928+
// RTLM24e, RTLM24e1: entry timeserial is nil, or serial >= entry timeserial
929+
let keysToRemove = data.filter { _, entry in
930+
guard let entryTimeserial = entry.timeserial else {
931+
return true
932+
}
933+
return serial >= entryTimeserial
934+
}.keys
935+
936+
for key in keysToRemove {
937+
data.removeValue(forKey: key)
938+
}
939+
940+
// RTLM24e1b, RTLM24f
941+
let removedKeys = Dictionary(uniqueKeysWithValues: keysToRemove.map { ($0, LiveMapUpdateAction.removed) })
942+
return .update(DefaultLiveMapUpdate(update: removedKeys))
917943
}
918944

919945
/// Resets the map's data and emits a `removed` event for the existing keys, per RTO4b2 and RTO4b2a. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map.

Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,23 @@ struct TestFactories {
603603
)
604604
}
605605

606+
/// Creates an InboundObjectMessage with a MAP_CLEAR operation
607+
static func mapClearOperationMessage(
608+
objectId: String = "map:test@123",
609+
serial: String = "ts1",
610+
siteCode: String = "site1",
611+
) -> InboundObjectMessage {
612+
inboundObjectMessage(
613+
operation: objectOperation(
614+
action: .known(.mapClear),
615+
objectId: objectId,
616+
mapClear: WireMapClear(),
617+
),
618+
serial: serial,
619+
siteCode: siteCode,
620+
)
621+
}
622+
606623
/// Creates an InboundObjectMessage with a MAP_CREATE operation
607624
static func mapCreateOperationMessage(
608625
objectId: String = "map:test@123",

Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift

Lines changed: 153 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1383,10 +1383,108 @@ struct InternalDefaultLiveMapTests {
13831383

13841384
/// Tests for `MAP_CLEAR` operations, covering RTLM24 specification points
13851385
struct MapClearOperationTests {
1386+
// MARK: - RTLM24c Tests (clearTimeserial check)
1387+
1388+
// @spec RTLM24c
1389+
@Test(arguments: [
1390+
// serial < clearTimeserial: discard
1391+
(operationSerial: "ts4" as String?, clearTimeserial: "ts5", expectedApplied: false),
1392+
// serial == clearTimeserial: discard
1393+
(operationSerial: "ts5" as String?, clearTimeserial: "ts5", expectedApplied: false),
1394+
// serial > clearTimeserial: allow
1395+
(operationSerial: "ts6" as String?, clearTimeserial: "ts5", expectedApplied: true),
1396+
// serial is nil: discard
1397+
(operationSerial: nil as String?, clearTimeserial: "ts5", expectedApplied: false),
1398+
] as [(operationSerial: String?, clearTimeserial: String, expectedApplied: Bool)])
1399+
func checksClearTimeserialBeforeApplying(operationSerial: String?, clearTimeserial: String, expectedApplied: Bool) throws {
1400+
let logger = TestLogger()
1401+
let internalQueue = TestFactories.createInternalQueue()
1402+
let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)
1403+
let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue)
1404+
1405+
// Given: a map with an existing entry and the specified clearTimeserial
1406+
let map = InternalDefaultLiveMap(
1407+
testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts1", data: ObjectData(string: "existing"))],
1408+
objectID: "arbitrary",
1409+
logger: logger,
1410+
internalQueue: internalQueue,
1411+
userCallbackQueue: .main,
1412+
clock: MockSimpleClock(),
1413+
)
1414+
1415+
var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())
1416+
internalQueue.ably_syncNoDeadlock {
1417+
_ = map.nosync_replaceData(
1418+
using: TestFactories.objectState(
1419+
map: TestFactories.objectsMap(
1420+
entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "existing").entry],
1421+
clearTimeserial: clearTimeserial,
1422+
),
1423+
),
1424+
objectMessageSerialTimestamp: nil,
1425+
objectsPool: &pool,
1426+
)
1427+
}
1428+
1429+
// When: applying a MAP_CLEAR operation with the specified serial
1430+
let update = map.testsOnly_applyMapClearOperation(serial: operationSerial)
1431+
1432+
// Then: the operation is applied or discarded as expected
1433+
#expect(update.isNoop == !expectedApplied)
1434+
if expectedApplied {
1435+
#expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil)
1436+
} else {
1437+
#expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing")
1438+
}
1439+
}
1440+
1441+
// MARK: - RTLM24 Tests (MAP_CLEAR operation application)
1442+
13861443
// @spec RTLM24
1444+
// @spec RTLM24d
1445+
// @spec RTLM24e
1446+
// @spec RTLM24e1
1447+
// @spec RTLM24e1a
1448+
// @spec RTLM24e1b
1449+
// @spec RTLM24f
13871450
@Test
1388-
func appliesMapClearOperation() {
1389-
Issue.record("TODO: Add tests for MAP_CLEAR operation application")
1451+
func appliesMapClearOperation() throws {
1452+
let logger = TestLogger()
1453+
let internalQueue = TestFactories.createInternalQueue()
1454+
let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)
1455+
let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue)
1456+
1457+
// Given: a map with multiple entries at different timeserials, including one with nil timeserial
1458+
let map = InternalDefaultLiveMap(
1459+
testsOnly_data: [
1460+
"olderThanClear": TestFactories.internalMapEntry(timeserial: "ts1", data: ObjectData(string: "value1")),
1461+
"equalToClear": TestFactories.internalMapEntry(timeserial: "ts3", data: ObjectData(string: "value2")),
1462+
"newerThanClear": TestFactories.internalMapEntry(timeserial: "ts5", data: ObjectData(string: "value3")),
1463+
"nilTimeserial": TestFactories.internalMapEntry(timeserial: nil, data: ObjectData(string: "value4")),
1464+
],
1465+
objectID: "arbitrary",
1466+
logger: logger,
1467+
internalQueue: internalQueue,
1468+
userCallbackQueue: .main,
1469+
clock: MockSimpleClock(),
1470+
)
1471+
1472+
// When: applying a MAP_CLEAR operation with serial "ts3"
1473+
let update = map.testsOnly_applyMapClearOperation(serial: "ts3")
1474+
1475+
// Then: entries with timeserial <= "ts3" or nil are removed from internal data, others remain
1476+
#expect(Set(map.testsOnly_data.keys) == ["newerThanClear"])
1477+
1478+
// RTLM24f: update contains exactly the removed keys
1479+
let mapUpdate = try #require(update.update)
1480+
#expect(mapUpdate.update == [
1481+
"olderThanClear": .removed,
1482+
"equalToClear": .removed,
1483+
"nilTimeserial": .removed,
1484+
])
1485+
1486+
// RTLM24d: clearTimeserial should be set
1487+
#expect(map.testsOnly_clearTimeserial == "ts3")
13901488
}
13911489
}
13921490

@@ -1607,9 +1705,60 @@ struct InternalDefaultLiveMapTests {
16071705
// @spec RTLM15d8
16081706
// @spec RTLM15d8a
16091707
// @spec RTLM15d8b
1708+
@available(iOS 17.0.0, tvOS 17.0.0, *)
16101709
@Test
1611-
func appliesMapClearOperation() {
1612-
Issue.record("TODO: Add test for MAP_CLEAR operation routing (copy structure from the above existing tests for other operations; should also test that siteTimeserials is updated per RTLM15c)")
1710+
func appliesMapClearOperation() async throws {
1711+
let logger = TestLogger()
1712+
let internalQueue = TestFactories.createInternalQueue()
1713+
let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)
1714+
let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue)
1715+
let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())
1716+
1717+
let subscriber = Subscriber<DefaultLiveMapUpdate, SubscribeResponse>(callbackQueue: .main)
1718+
try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK)
1719+
1720+
// Set initial data
1721+
var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())
1722+
let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil)
1723+
internalQueue.ably_syncNoDeadlock {
1724+
_ = map.nosync_replaceData(
1725+
using: TestFactories.mapObjectState(
1726+
siteTimeserials: [:],
1727+
entries: [key1: entry1],
1728+
),
1729+
objectMessageSerialTimestamp: nil,
1730+
objectsPool: &pool,
1731+
)
1732+
}
1733+
#expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing")
1734+
1735+
let operation = TestFactories.objectOperation(
1736+
action: .known(.mapClear),
1737+
mapClear: WireMapClear(),
1738+
)
1739+
1740+
// Apply MAP_CLEAR operation
1741+
let applied = internalQueue.ably_syncNoDeadlock {
1742+
map.nosync_apply(
1743+
operation,
1744+
source: .channel,
1745+
objectMessageSerial: "ts1",
1746+
objectMessageSiteCode: "site1",
1747+
objectMessageSerialTimestamp: nil,
1748+
objectsPool: &pool,
1749+
)
1750+
}
1751+
#expect(applied)
1752+
1753+
// Verify the operation was applied (the full logic of RTLM24 is tested elsewhere; we just check for some of its side effects here)
1754+
#expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil)
1755+
#expect(map.testsOnly_clearTimeserial == "ts1")
1756+
// Verify RTLM15c side-effect: site timeserial was updated
1757+
#expect(map.testsOnly_siteTimeserials == ["site1": "ts1"])
1758+
1759+
// Verify update was emitted per RTLM15d8a
1760+
let subscriberInvocations = await subscriber.getInvocations()
1761+
#expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .removed])])
16131762
}
16141763

16151764
// @specOneOf(5/5) RTLM15c - Tests that siteTimeserials is NOT updated when source is LOCAL

Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,8 +1217,50 @@ struct InternalDefaultRealtimeObjectsTests {
12171217

12181218
// @specOneOf(6/6) RTO9a2a3 - Tests MAP_CLEAR operation application
12191219
@Test
1220-
func appliesMapClearOperation() {
1221-
Issue.record("TODO: Add test for MAP_CLEAR operation application (copy structure from the above existing tests for other operations)")
1220+
func appliesMapClearOperation() throws {
1221+
let internalQueue = TestFactories.createInternalQueue()
1222+
let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue)
1223+
let objectId = "map:test@123"
1224+
1225+
// Create a map object in the pool first
1226+
let (entryKey, entry) = TestFactories.stringMapEntry(key: "existingKey", value: "existingValue")
1227+
internalQueue.ably_syncNoDeadlock {
1228+
realtimeObjects.nosync_handleObjectSyncProtocolMessage(
1229+
objectMessages: [
1230+
TestFactories.mapObjectMessage(
1231+
objectId: objectId,
1232+
siteTimeserials: ["site1": "ts1"],
1233+
entries: [entryKey: entry],
1234+
),
1235+
],
1236+
protocolMessageChannelSerial: nil,
1237+
)
1238+
}
1239+
1240+
// Verify the object exists and has initial data
1241+
let map = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.mapValue)
1242+
let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue)
1243+
let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue)
1244+
#expect(initialValue == "existingValue")
1245+
1246+
// Create a MAP_CLEAR operation message
1247+
let operationMessage = TestFactories.mapClearOperationMessage(
1248+
objectId: objectId,
1249+
serial: "ts2", // Higher than existing "ts1"
1250+
siteCode: "site1",
1251+
)
1252+
1253+
// Handle the object protocol message
1254+
internalQueue.ably_syncNoDeadlock {
1255+
realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [operationMessage])
1256+
}
1257+
1258+
// Verify the operation was applied by checking for side effects
1259+
// The full logic of applying the operation is tested in RTLM15; we just check for some of its side effects here
1260+
let finalValue = try map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)
1261+
#expect(finalValue == nil) // Key should be removed by MAP_CLEAR
1262+
#expect(map.testsOnly_clearTimeserial == "ts2")
1263+
#expect(map.testsOnly_siteTimeserials["site1"] == "ts2")
12221264
}
12231265
}
12241266

0 commit comments

Comments
 (0)