Skip to content

Commit 9b2fcc5

Browse files
Implement find-or-create for createMap / createCounter
TODO check code and tests TODO expose semantics so that we don't have to fish it out (minor)
1 parent d19d39c commit 9b2fcc5

2 files changed

Lines changed: 254 additions & 0 deletions

File tree

Sources/AblyLiveObjects/Internal/ObjectsPool.swift

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,97 @@ internal struct ObjectsPool {
306306
logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug)
307307
}
308308

309+
/// Gets or creates a counter object in the pool, implementing the "find or create zero-value" behavior of RTO12h1.
310+
///
311+
/// - Parameters:
312+
/// - creationOperation: The ObjectCreationOperation containing the object ID and operation to merge
313+
/// - logger: The logger to use for any created LiveObject
314+
/// - userCallbackQueue: The callback queue to use for any created LiveObject
315+
/// - clock: The clock to use for any created LiveObject
316+
/// - Returns: The existing or newly created counter object
317+
internal mutating func getOrCreateCounter(
318+
creationOperation: ObjectCreationHelpers.ObjectCreationOperation,
319+
logger: AblyPlugin.Logger,
320+
userCallbackQueue: DispatchQueue,
321+
clock: SimpleClock,
322+
) -> InternalDefaultLiveCounter {
323+
// RTO12h1: While waiting for the publish operation to complete, the client library may have already received the echoed ObjectMessage operation
324+
// RTO12h2: If an object with the ObjectMessage.operation.objectId exists in the internal ObjectsPool, return it
325+
if let existingEntry = entries[creationOperation.objectID] {
326+
switch existingEntry {
327+
case let .counter(counter):
328+
return counter
329+
case .map:
330+
// TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/36
331+
preconditionFailure("Expected counter object with ID \(creationOperation.objectID) but found map object")
332+
}
333+
}
334+
335+
// RTO12h3: Otherwise, if the object does not exist in the internal ObjectsPool:
336+
// RTO12h3a: Create a zero-value LiveCounter, set its objectId to ObjectMessage.operation.objectId, and merge the initial value
337+
let counter = InternalDefaultLiveCounter.createZeroValued(
338+
objectID: creationOperation.objectID,
339+
logger: logger,
340+
userCallbackQueue: userCallbackQueue,
341+
clock: clock,
342+
)
343+
344+
// Merge the initial value from the creation operation
345+
_ = counter.mergeInitialValue(from: creationOperation.operation)
346+
347+
// RTO12h3b: Add the created LiveCounter instance to the internal ObjectsPool
348+
entries[creationOperation.objectID] = .counter(counter)
349+
350+
// RTO12h3c: Return the created LiveCounter instance
351+
return counter
352+
}
353+
354+
/// Gets or creates a map object in the pool, implementing the "find or create zero-value" behavior of RTO11h1.
355+
///
356+
/// - Parameters:
357+
/// - creationOperation: The ObjectCreationOperation containing the object ID and operation to merge
358+
/// - logger: The logger to use for any created LiveObject
359+
/// - userCallbackQueue: The callback queue to use for any created LiveObject
360+
/// - clock: The clock to use for any created LiveObject
361+
/// - Returns: The existing or newly created map object
362+
internal mutating func getOrCreateMap(
363+
creationOperation: ObjectCreationHelpers.ObjectCreationOperation,
364+
logger: AblyPlugin.Logger,
365+
userCallbackQueue: DispatchQueue,
366+
clock: SimpleClock,
367+
) -> InternalDefaultLiveMap {
368+
// RTO11h1: While waiting for the publish operation to complete, the client library may have already received the echoed ObjectMessage operation
369+
// RTO11h2: If an object with the ObjectMessage.operation.objectId exists in the internal ObjectsPool, return it
370+
if let existingEntry = entries[creationOperation.objectID] {
371+
switch existingEntry {
372+
case let .map(map):
373+
return map
374+
case .counter:
375+
// TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/36
376+
preconditionFailure("Expected map object with ID \(creationOperation.objectID) but found counter object")
377+
}
378+
}
379+
380+
// RTO11h3: Otherwise, if the object does not exist in the internal ObjectsPool:
381+
// RTO11h3a: Create a zero-value LiveMap, set its objectId to ObjectMessage.operation.objectId, set its semantics to ObjectMessage.operation.map.semantics, and merge the initial value
382+
let map = InternalDefaultLiveMap.createZeroValued(
383+
objectID: creationOperation.objectID,
384+
semantics: creationOperation.operation.map?.semantics,
385+
logger: logger,
386+
userCallbackQueue: userCallbackQueue,
387+
clock: clock,
388+
)
389+
390+
// Merge the initial value from the creation operation
391+
_ = map.mergeInitialValue(from: creationOperation.operation, objectsPool: &self)
392+
393+
// RTO11h3b: Add the created LiveMap instance to the internal ObjectsPool
394+
entries[creationOperation.objectID] = .map(map)
395+
396+
// RTO11h3c: Return the created LiveMap instance
397+
return map
398+
}
399+
309400
/// Removes all entries except the root, and clears the root's data. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map, per RTO4b.
310401
internal mutating func reset() {
311402
let root = root

Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,4 +367,167 @@ struct ObjectsPoolTests {
367367
#expect(pool.entries["map:toremove@1"] == nil)
368368
}
369369
}
370+
371+
/// Tests for the `getOrCreateCounter` and `getOrCreateMap` methods
372+
struct GetOrCreateTests {
373+
@Test
374+
func getOrCreateCounter_returnsExistingCounter() {
375+
let logger = TestLogger()
376+
let userCallbackQueue = DispatchQueue.main
377+
let clock = MockSimpleClock()
378+
379+
// Create a counter in the pool first
380+
let existingCounter = InternalDefaultLiveCounter.createZeroValued(
381+
objectID: "counter:test@123",
382+
logger: logger,
383+
userCallbackQueue: userCallbackQueue,
384+
clock: clock,
385+
)
386+
var pool = ObjectsPool(
387+
logger: logger,
388+
userCallbackQueue: userCallbackQueue,
389+
clock: clock,
390+
testsOnly_otherEntries: ["counter:test@123": .counter(existingCounter)],
391+
)
392+
393+
// Create a creation operation with the same object ID as the existing counter
394+
let creationOperation = ObjectCreationHelpers.ObjectCreationOperation(
395+
objectID: "counter:test@123",
396+
operation: ObjectOperation(
397+
action: .known(.counterCreate),
398+
objectId: "counter:test@123",
399+
counter: WireObjectsCounter(count: NSNumber(value: 42.0)),
400+
nonce: "testnonce",
401+
initialValue: "{\"action\":3,\"counter\":{\"count\":42}}",
402+
),
403+
objectMessage: OutboundObjectMessage(
404+
operation: ObjectOperation(
405+
action: .known(.counterCreate),
406+
objectId: "counter:test@123",
407+
counter: WireObjectsCounter(count: NSNumber(value: 42.0)),
408+
nonce: "testnonce",
409+
initialValue: "{\"action\":3,\"counter\":{\"count\":42}}",
410+
),
411+
),
412+
)
413+
414+
// Should return the existing counter
415+
let result = pool.getOrCreateCounter(
416+
creationOperation: creationOperation,
417+
logger: logger,
418+
userCallbackQueue: userCallbackQueue,
419+
clock: clock,
420+
)
421+
422+
#expect(result === existingCounter)
423+
}
424+
425+
@Test
426+
func getOrCreateCounter_createsNewCounterWhenNotExists() {
427+
let logger = TestLogger()
428+
let userCallbackQueue = DispatchQueue.main
429+
let clock = MockSimpleClock()
430+
var pool = ObjectsPool(logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)
431+
432+
// Create a creation operation
433+
let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter(
434+
count: 42.0,
435+
timestamp: Date(),
436+
)
437+
438+
// Should create a new counter
439+
let result = pool.getOrCreateCounter(
440+
creationOperation: creationOperation,
441+
logger: logger,
442+
userCallbackQueue: userCallbackQueue,
443+
clock: clock,
444+
)
445+
446+
#expect(result.objectID == creationOperation.objectID)
447+
#expect(pool.entries[creationOperation.objectID]?.counterValue === result)
448+
}
449+
450+
@Test
451+
func getOrCreateMap_returnsExistingMap() {
452+
let logger = TestLogger()
453+
let userCallbackQueue = DispatchQueue.main
454+
let clock = MockSimpleClock()
455+
456+
// Create a map in the pool first
457+
let existingMap = InternalDefaultLiveMap.createZeroValued(
458+
objectID: "map:test@123",
459+
logger: logger,
460+
userCallbackQueue: userCallbackQueue,
461+
clock: clock,
462+
)
463+
var pool = ObjectsPool(
464+
logger: logger,
465+
userCallbackQueue: userCallbackQueue,
466+
clock: clock,
467+
testsOnly_otherEntries: ["map:test@123": .map(existingMap)],
468+
)
469+
470+
// Create a creation operation with the same object ID as the existing map
471+
let creationOperation = ObjectCreationHelpers.ObjectCreationOperation(
472+
objectID: "map:test@123",
473+
operation: ObjectOperation(
474+
action: .known(.mapCreate),
475+
objectId: "map:test@123",
476+
map: ObjectsMap(
477+
semantics: .known(.lww),
478+
entries: [:],
479+
),
480+
nonce: "testnonce",
481+
initialValue: "{\"action\":0,\"map\":{\"semantics\":0,\"entries\":{}}}",
482+
),
483+
objectMessage: OutboundObjectMessage(
484+
operation: ObjectOperation(
485+
action: .known(.mapCreate),
486+
objectId: "map:test@123",
487+
map: ObjectsMap(
488+
semantics: .known(.lww),
489+
entries: [:],
490+
),
491+
nonce: "testnonce",
492+
initialValue: "{\"action\":0,\"map\":{\"semantics\":0,\"entries\":{}}}",
493+
),
494+
),
495+
)
496+
497+
// Should return the existing map
498+
let result = pool.getOrCreateMap(
499+
creationOperation: creationOperation,
500+
logger: logger,
501+
userCallbackQueue: userCallbackQueue,
502+
clock: clock,
503+
)
504+
505+
#expect(result === existingMap)
506+
}
507+
508+
@Test
509+
func getOrCreateMap_createsNewMapWhenNotExists() {
510+
let logger = TestLogger()
511+
let userCallbackQueue = DispatchQueue.main
512+
let clock = MockSimpleClock()
513+
var pool = ObjectsPool(logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)
514+
515+
// Create a creation operation
516+
let creationOperation = ObjectCreationHelpers.creationOperationForLiveMap(
517+
entries: [:],
518+
timestamp: Date(),
519+
)
520+
521+
// Should create a new map
522+
let result = pool.getOrCreateMap(
523+
creationOperation: creationOperation,
524+
logger: logger,
525+
userCallbackQueue: userCallbackQueue,
526+
clock: clock,
527+
)
528+
529+
#expect(result.objectID == creationOperation.objectID)
530+
#expect(pool.entries[creationOperation.objectID]?.mapValue === result)
531+
}
532+
}
370533
}

0 commit comments

Comments
 (0)