diff --git a/lib/src/main/java/io/ably/lib/objects/LiveCounter.java b/lib/src/main/java/io/ably/lib/objects/LiveCounter.java index fd44b853c..2339fcb4f 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveCounter.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveCounter.java @@ -58,5 +58,5 @@ public interface LiveCounter { */ @NotNull @Contract(pure = true) // Indicates this method does not modify the state of the object. - Long value(); + Double value(); } diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java b/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java index 171a90347..81156d654 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java @@ -1,5 +1,6 @@ package io.ably.lib.objects; +import io.ably.lib.realtime.ChannelState; import io.ably.lib.types.ProtocolMessage; import org.jetbrains.annotations.NotNull; @@ -30,6 +31,17 @@ public interface LiveObjectsPlugin { */ void handle(@NotNull ProtocolMessage message); + /** + * Handles state changes for a specific channel. + * This method is invoked whenever a channel's state changes, allowing the implementation + * to update the LiveObjects instances accordingly based on the new state and presence of objects. + * + * @param channelName the name of the channel whose state has changed. + * @param state the new state of the channel. + * @param hasObjects flag indicates whether the channel has any associated live objects. + */ + void handleStateChange(@NotNull String channelName, @NotNull ChannelState state, boolean hasObjects); + /** * Disposes of the LiveObjects instance associated with the specified channel name. * This method removes the LiveObjects instance for the given channel, releasing any diff --git a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java index a5144f3fc..3f7062d36 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java @@ -145,6 +145,15 @@ private void setState(ChannelState newState, ErrorInfo reason, boolean resumed, this.reason = stateChange.reason; } + // cover states other than attached, ChannelState.attached already covered in setAttached + if (liveObjectsPlugin != null && newState!= ChannelState.attached) { + try { + liveObjectsPlugin.handleStateChange(name, newState, false); + } catch (Throwable t) { + Log.e(TAG, "Unexpected exception in LiveObjectsPlugin.handle", t); + } + } + if (newState != ChannelState.attaching && newState != ChannelState.suspended) { this.retryAttempt = 0; } @@ -439,6 +448,13 @@ private void setAttached(ProtocolMessage message) { } return; } + if (liveObjectsPlugin != null) { + try { + liveObjectsPlugin.handleStateChange(name, ChannelState.attached, message.hasFlag(Flag.has_objects)); + } catch (Throwable t) { + Log.e(TAG, "Unexpected exception in LiveObjectsPlugin.handle", t); + } + } if(state == ChannelState.attached) { Log.v(TAG, String.format(Locale.ROOT, "Server initiated attach for channel %s", name)); if (!message.hasFlag(Flag.resumed)) { // RTL12 diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjects.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjects.kt index ea88c5e99..45177fa94 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjects.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjects.kt @@ -1,12 +1,59 @@ package io.ably.lib.objects +import io.ably.lib.realtime.ChannelState import io.ably.lib.types.Callback import io.ably.lib.types.ProtocolMessage import io.ably.lib.util.Log +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.flow.MutableSharedFlow -internal class DefaultLiveObjects(private val channelName: String, private val adapter: LiveObjectsAdapter): LiveObjects { - private val tag = DefaultLiveObjects::class.simpleName +/** + * @spec RTO2 - enum representing objects state + */ +internal enum class ObjectsState { + INITIALIZED, + SYNCING, + SYNCED +} + +/** + * Default implementation of LiveObjects interface. + * Provides the core functionality for managing live objects on a channel. + */ +internal class DefaultLiveObjects(internal val channelName: String, internal val adapter: LiveObjectsAdapter): LiveObjects { + private val tag = "DefaultLiveObjects" + /** + * @spec RTO3 - Objects pool storing all live objects by object ID + */ + internal val objectsPool = ObjectsPool(this) + + internal var state = ObjectsState.INITIALIZED + + /** + * @spec RTO4 - Used for handling object messages and object sync messages + */ + private val objectsManager = ObjectsManager(this) + /** + * Coroutine scope for running sequential operations on a single thread, used to avoid concurrency issues. + */ + private val sequentialScope = + CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(channelName) + SupervisorJob()) + + /** + * Event bus for handling incoming object messages sequentially. + */ + private val objectsEventBus = MutableSharedFlow(extraBufferCapacity = UNLIMITED) + private val incomingObjectsHandler: Job + + init { + incomingObjectsHandler = initializeHandlerForIncomingObjectMessages() + } + + /** + * @spec RTO1 - Returns the root LiveMap object with proper validation and sync waiting + */ override fun getRoot(): LiveMap { TODO("Not yet implemented") } @@ -47,18 +94,121 @@ internal class DefaultLiveObjects(private val channelName: String, private val a TODO("Not yet implemented") } - fun handle(msg: ProtocolMessage) { - // RTL15b - msg.channelSerial?.let { - if (msg.action === ProtocolMessage.Action.`object`) { - Log.v(tag, "Setting channel serial for channelName: $channelName, value: ${msg.channelSerial}") - adapter.setChannelSerial(channelName, msg.channelSerial) + /** + * Handles a ProtocolMessage containing proto action as `object` or `object_sync`. + * @spec RTL1 - Processes incoming object messages and object sync messages + */ + internal fun handle(protocolMessage: ProtocolMessage) { + // RTL15b - Set channel serial for OBJECT messages + adapter.setChannelSerial(channelName, protocolMessage) + + if (protocolMessage.state == null || protocolMessage.state.isEmpty()) { + Log.w(tag, "Received ProtocolMessage with null or empty objects, ignoring") + return + } + + objectsEventBus.tryEmit(protocolMessage) + } + + /** + * Initializes the handler for incoming object messages and object sync messages. + * Processes the messages sequentially to ensure thread safety and correct order of operations. + * + * @spec OM2 - Populates missing fields from parent protocol message + */ + private fun initializeHandlerForIncomingObjectMessages(): Job { + return sequentialScope.launch { + objectsEventBus.collect { protocolMessage -> + // OM2 - Populate missing fields from parent + val objects = protocolMessage.state.filterIsInstance() + .mapIndexed { index, objMsg -> + objMsg.copy( + connectionId = objMsg.connectionId ?: protocolMessage.connectionId, // OM2c + timestamp = objMsg.timestamp ?: protocolMessage.timestamp, // OM2e + id = objMsg.id ?: (protocolMessage.id + ':' + index) // OM2a + ) + } + + try { + when (protocolMessage.action) { + ProtocolMessage.Action.`object` -> objectsManager.handleObjectMessages(objects) + ProtocolMessage.Action.object_sync -> objectsManager.handleObjectSyncMessages( + objects, + protocolMessage.channelSerial + ) + else -> Log.w(tag, "Ignoring protocol message with unhandled action: ${protocolMessage.action}") + } + } catch (exception: Exception) { + // Skip current message if an error occurs, don't rethrow to avoid crashing the collector + Log.e(tag, "Error handling objects message with protocolMsg id ${protocolMessage.id}", exception) + } } } } - fun dispose() { - // Dispose of any resources associated with this LiveObjects instance - // For example, close any open connections or clean up references + internal fun handleStateChange(state: ChannelState, hasObjects: Boolean) { + sequentialScope.launch { + when (state) { + ChannelState.attached -> { + Log.v(tag, "Objects.onAttached() channel=$channelName, hasObjects=$hasObjects") + + // RTO4a + val fromInitializedState = this@DefaultLiveObjects.state == ObjectsState.INITIALIZED + if (hasObjects || fromInitializedState) { + // should always start a new sync sequence if we're in the initialized state, no matter the HAS_OBJECTS flag value. + // this guarantees we emit both "syncing" -> "synced" events in that order. + objectsManager.startNewSync(null) + } + + // RTO4b + if (!hasObjects) { + // if no HAS_OBJECTS flag received on attach, we can end sync sequence immediately and treat it as no objects on a channel. + // reset the objects pool to its initial state, and emit update events so subscribers to root object get notified about changes. + objectsPool.resetToInitialPool(true) // RTO4b1, RTO4b2 + objectsManager.clearSyncObjectsDataPool() // RTO4b3 + objectsManager.clearBufferedObjectOperations() // RTO4b5 + // defer the state change event until the next tick if we started a new sequence just now due to being in initialized state. + // this allows any event listeners to process the start of the new sequence event that was emitted earlier during this event loop. + objectsManager.endSync(fromInitializedState) // RTO4b4 + } + } + ChannelState.detached, + ChannelState.failed -> { + // do not emit data update events as the actual current state of Objects data is unknown when we're in these channel states + objectsPool.clearObjectsData(false) + objectsManager.clearSyncObjectsDataPool() + } + + else -> { + // No action needed for other states + } + } + } + } + + /** + * Changes the state and emits events. + * + * @spec RTO2 - Emits state change events for syncing and synced states + */ + internal fun stateChange(newState: ObjectsState, deferEvent: Boolean) { + if (state == newState) { + return + } + + state = newState + Log.v(tag, "Objects state changed to: $newState") + + // TODO: Emit state change events + } + + // Dispose of any resources associated with this LiveObjects instance + fun dispose(reason: String) { + val cancellationError = CancellationException("Objects disposed for channel $channelName, reason: $reason") + incomingObjectsHandler.cancel(cancellationError) // objectsEventBus automatically garbage collected when collector is cancelled + objectsPool.dispose() + objectsManager.dispose() + // Don't cancel sequentialScope (needed in public methods), just cancel ongoing coroutines + sequentialScope.coroutineContext.cancelChildren(cancellationError) } } diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt index e31002a89..f3f2e71a4 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt @@ -1,5 +1,6 @@ package io.ably.lib.objects +import io.ably.lib.realtime.ChannelState import io.ably.lib.types.ProtocolMessage import java.util.concurrent.ConcurrentHashMap @@ -16,14 +17,18 @@ public class DefaultLiveObjectsPlugin(private val adapter: LiveObjectsAdapter) : liveObjects[channelName]?.handle(msg) } + override fun handleStateChange(channelName: String, state: ChannelState, hasObjects: Boolean) { + liveObjects[channelName]?.handleStateChange(state, hasObjects) + } + override fun dispose(channelName: String) { - liveObjects[channelName]?.dispose() + liveObjects[channelName]?.dispose("Channel has ben released using channels.release()") liveObjects.remove(channelName) } override fun dispose() { liveObjects.values.forEach { - it.dispose() + it.dispose("AblyClient has been closed using client.close()") } liveObjects.clear() } diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt index 09ffeb62a..35b6c3ad2 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt @@ -4,6 +4,10 @@ internal enum class ErrorCode(public val code: Int) { BadRequest(40_000), InternalError(50_000), MaxMessageSizeExceeded(40_009), + InvalidObject(92_000), + // LiveMap specific error codes + MapKeyShouldBeString(40_003), + MapValueDataTypeUnsupported(40_013), } internal enum class HttpStatusCode(public val code: Int) { diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt index 51bc7b4f3..5f17027b4 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt @@ -32,6 +32,13 @@ internal fun LiveObjectsAdapter.ensureMessageSizeWithinLimit(objectMessages: Arr } } +internal fun LiveObjectsAdapter.setChannelSerial(channelName: String, protocolMessage: ProtocolMessage) { + if (protocolMessage.action != ProtocolMessage.Action.`object`) return + val channelSerial = protocolMessage.channelSerial + if (channelSerial.isNullOrEmpty()) return + setChannelSerial(channelName, channelSerial) +} + internal class Binary(val data: ByteArray) { override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt new file mode 100644 index 000000000..d948ff32f --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt @@ -0,0 +1,62 @@ +package io.ably.lib.objects + +import io.ably.lib.objects.type.ObjectType + +internal class ObjectId private constructor( + internal val type: ObjectType, + private val hash: String, + private val timestampMs: Long +) { + /** + * Converts ObjectId to string representation. + */ + override fun toString(): String { + return "${type.value}:$hash@$timestampMs" + } + + companion object { + /** + * Creates ObjectId instance from hashed object id string. + */ + fun fromString(objectId: String): ObjectId { + if (objectId.isEmpty()) { + throw objectError("Invalid object id: $objectId") + } + + // Parse format: type:hash@msTimestamp + val parts = objectId.split(':') + if (parts.size != 2) { + throw objectError("Invalid object id: $objectId") + } + + val (typeStr, rest) = parts + + val type = when (typeStr) { + "map" -> ObjectType.Map + "counter" -> ObjectType.Counter + else -> throw objectError("Invalid object type in object id: $objectId") + } + + val hashAndTimestamp = rest.split('@') + if (hashAndTimestamp.size != 2) { + throw objectError("Invalid object id: $objectId") + } + + val hash = hashAndTimestamp[0] + + if (hash.isEmpty()) { + throw objectError("Invalid object id: $objectId") + } + + val msTimestampStr = hashAndTimestamp[1] + + val msTimestamp = try { + msTimestampStr.toLong() + } catch (e: NumberFormatException) { + throw objectError("Invalid object id: $objectId", e) + } + + return ObjectId(type, hash, msTimestamp) + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt index 684a86eab..70ea532f7 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt @@ -1,6 +1,5 @@ package io.ably.lib.objects -import com.google.gson.JsonArray import com.google.gson.JsonObject import com.google.gson.annotations.JsonAdapter @@ -18,7 +17,8 @@ internal enum class ObjectOperationAction(val code: Int) { MapRemove(2), CounterCreate(3), CounterInc(4), - ObjectDelete(5); + ObjectDelete(5), + Unknown(-1); // code for unknown value during deserialization } /** @@ -26,7 +26,8 @@ internal enum class ObjectOperationAction(val code: Int) { * Spec: OMP2 */ internal enum class MapSemantics(val code: Int) { - LWW(0); + LWW(0), + Unknown(-1); // code for unknown value during deserialization } /** @@ -50,28 +51,18 @@ internal data class ObjectData( /** * Represents a value that can be a String, Number, Boolean, Binary, JsonObject or JsonArray. - * Performs a type check on initialization. + * Provides compile-time type safety through sealed class pattern. * Spec: OD2c */ -internal data class ObjectValue( - /** - * The concrete value of the object. Can be a String, Number, Boolean, Binary, JsonObject or JsonArray. - * Spec: OD2c - */ - val value: Any, -) { - init { - require( - value is String || - value is Number || - value is Boolean || - value is Binary || - value is JsonObject || - value is JsonArray - ) { - "value must be String, Number, Boolean, Binary, JsonObject or JsonArray" - } - } +internal sealed class ObjectValue { + abstract val value: Any + + data class String(override val value: kotlin.String) : ObjectValue() + data class Number(override val value: kotlin.Number) : ObjectValue() + data class Boolean(override val value: kotlin.Boolean) : ObjectValue() + data class Binary(override val value: io.ably.lib.objects.Binary) : ObjectValue() + data class JsonObject(override val value: com.google.gson.JsonObject) : ObjectValue() + data class JsonArray(override val value: com.google.gson.JsonArray) : ObjectValue() } /** @@ -116,8 +107,8 @@ internal data class ObjectMapEntry( val tombstone: Boolean? = null, /** - * The serial value of the last operation that was applied to the map entry. - * It is optional in a MAP_CREATE operation and might be missing, in which case the client should use a nullish value for it + * The serial value of the latest operation that was applied to the map entry. + * It is optional in a MAP_CREATE operation and might be missing, in which case the client should use a null value for it * and treat it as the "earliest possible" serial for comparison purposes. * Spec: OME2b */ @@ -179,12 +170,14 @@ internal data class ObjectOperation( /** * The payload for the operation if it is an operation on a Map object type. + * i.e. MAP_SET, MAP_REMOVE. * Spec: OOP3c */ val mapOp: ObjectMapOp? = null, /** * The payload for the operation if it is an operation on a Counter object type. + * i.e. COUNTER_INC. * Spec: OOP3d */ val counterOp: ObjectCounterOp? = null, @@ -440,12 +433,15 @@ private fun ObjectData.size(): Int { * Spec: OD3* */ private fun ObjectValue.size(): Int { - return when (value) { - is Boolean -> 1 // Spec: OD3b - is Binary -> value.size() // Spec: OD3c - is Number -> 8 // Spec: OD3d - is String -> value.byteSize // Spec: OD3e - is JsonObject, is JsonArray -> value.toString().byteSize // Spec: OD3e - else -> 0 // Spec: OD3f + return when (this) { + is ObjectValue.Boolean -> 1 // Spec: OD3b + is ObjectValue.Binary -> value.size() // Spec: OD3c + is ObjectValue.Number -> 8 // Spec: OD3d + is ObjectValue.String -> value.byteSize // Spec: OD3e + is ObjectValue.JsonObject, is ObjectValue.JsonArray -> value.toString().byteSize // Spec: OD3e } } + +internal fun ObjectData?.isInvalid(): Boolean { + return this?.objectId.isNullOrEmpty() && this?.value == null +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt new file mode 100644 index 000000000..fe201e081 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt @@ -0,0 +1,227 @@ +package io.ably.lib.objects + +import io.ably.lib.objects.type.BaseLiveObject +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.util.Log + +/** + * @spec RTO5 - Processes OBJECT and OBJECT_SYNC messages during sync sequences + * @spec RTO6 - Creates zero-value objects when needed + */ +internal class ObjectsManager(private val liveObjects: DefaultLiveObjects) { + private val tag = "ObjectsManager" + /** + * @spec RTO5 - Sync objects data pool for collecting sync messages + */ + private val syncObjectsDataPool = mutableMapOf() + private var currentSyncId: String? = null + /** + * @spec RTO7 - Buffered object operations during sync + */ + private val bufferedObjectOperations = mutableListOf() // RTO7a + + /** + * Handles object messages (non-sync messages). + * + * @spec RTO8 - Buffers messages if not synced, applies immediately if synced + */ + internal fun handleObjectMessages(objectMessages: List) { + if (liveObjects.state != ObjectsState.SYNCED) { + // RTO7 - The client receives object messages in realtime over the channel concurrently with the sync sequence. + // Some of the incoming object messages may have already been applied to the objects described in + // the sync sequence, but others may not; therefore we must buffer these messages so that we can apply + // them to the objects once the sync is complete. + Log.v(tag, "Buffering ${objectMessages.size} object messages, state: $liveObjects.state") + bufferedObjectOperations.addAll(objectMessages) // RTO8a + return + } + + // Apply messages immediately if synced + applyObjectMessages(objectMessages) // RTO8b + } + + /** + * Handles object sync messages. + * + * @spec RTO5 - Parses sync channel serial and manages sync sequences + */ + internal fun handleObjectSyncMessages(objectMessages: List, syncChannelSerial: String?) { + val syncTracker = ObjectsSyncTracker(syncChannelSerial) + val isNewSync = syncTracker.hasSyncStarted(currentSyncId) + if (isNewSync) { + // RTO5a2 - new sync sequence started + startNewSync(syncTracker.syncId) + } + + // RTO5a3 - continue current sync sequence + applyObjectSyncMessages(objectMessages) // RTO5b + + // RTO5a4 - if this is the last (or only) message in a sequence of sync updates, end the sync + if (syncTracker.hasSyncEnded()) { + // defer the state change event until the next tick if this was a new sync sequence + // to allow any event listeners to process the start of the new sequence event that was emitted earlier during this event loop. + endSync(isNewSync) + } + } + + /** + * Starts a new sync sequence. + * + * @spec RTO5 - Sync sequence initialization + */ + internal fun startNewSync(syncId: String?) { + Log.v(tag, "Starting new sync sequence: syncId=$syncId") + + // need to discard all buffered object operation messages on new sync start + bufferedObjectOperations.clear() // RTO5a2b + syncObjectsDataPool.clear() // RTO5a2a + currentSyncId = syncId + liveObjects.stateChange(ObjectsState.SYNCING, false) + } + + /** + * Ends the current sync sequence. + * + * @spec RTO5c - Applies sync data and buffered operations + */ + internal fun endSync(deferStateEvent: Boolean) { + Log.v(tag, "Ending sync sequence") + applySync() + // should apply buffered object operations after we applied the sync. + // can use regular non-sync object.operation logic + applyObjectMessages(bufferedObjectOperations) // RTO5c6 + + bufferedObjectOperations.clear() // RTO5c5 + syncObjectsDataPool.clear() // RTO5c4 + currentSyncId = null // RTO5c3 + liveObjects.stateChange(ObjectsState.SYNCED, deferStateEvent) + } + + /** + * Clears the sync objects data pool. + * Used by DefaultLiveObjects.handleStateChange. + */ + internal fun clearSyncObjectsDataPool() { + syncObjectsDataPool.clear() + } + + /** + * Clears the buffered object operations. + * Used by DefaultLiveObjects.handleStateChange. + */ + internal fun clearBufferedObjectOperations() { + bufferedObjectOperations.clear() + } + + /** + * Applies sync data to objects pool. + * + * @spec RTO5c - Processes sync data and updates objects pool + */ + private fun applySync() { + if (syncObjectsDataPool.isEmpty()) { + return + } + + val receivedObjectIds = mutableSetOf() + val existingObjectUpdates = mutableListOf>() + + // RTO5c1 + for ((objectId, objectState) in syncObjectsDataPool) { + receivedObjectIds.add(objectId) + val existingObject = liveObjects.objectsPool.get(objectId) + + // RTO5c1a + if (existingObject != null) { + // Update existing object + val update = existingObject.applyObjectSync(objectState) // RTO5c1a1 + existingObjectUpdates.add(Pair(existingObject, update)) + } else { // RTO5c1b + // RTO5c1b1, RTO5c1b1a, RTO5c1b1b - Create new object and add it to the pool + val newObject = createObjectFromState(objectState) + newObject.applyObjectSync(objectState) + liveObjects.objectsPool.set(objectId, newObject) + } + } + + // RTO5c2 - need to remove LiveObject instances from the ObjectsPool for which objectIds were not received during the sync sequence + liveObjects.objectsPool.deleteExtraObjectIds(receivedObjectIds) + + // call subscription callbacks for all updated existing objects + existingObjectUpdates.forEach { (obj, update) -> + obj.notifyUpdated(update) + } + } + + /** + * Applies object messages to objects. + * + * @spec RTO9 - Creates zero-value objects if they don't exist + */ + private fun applyObjectMessages(objectMessages: List) { + // RTO9a + for (objectMessage in objectMessages) { + if (objectMessage.operation == null) { + // RTO9a1 + Log.w(tag, "Object message received without operation field, skipping message: ${objectMessage.id}") + continue + } + + val objectOperation: ObjectOperation = objectMessage.operation // RTO9a2 + if (objectOperation.action == ObjectOperationAction.Unknown) { + // RTO9a2b - object operation action is unknown, skip the message + Log.w(tag, "Object operation action is unknown, skipping message: ${objectMessage.id}") + continue + } + // RTO9a2a - we can receive an op for an object id we don't have yet in the pool. instead of buffering such operations, + // we can create a zero-value object for the provided object id and apply the operation to that zero-value object. + // this also means that all objects are capable of applying the corresponding *_CREATE ops on themselves, + // since they need to be able to eventually initialize themselves from that *_CREATE op. + // so to simplify operations handling, we always try to create a zero-value object in the pool first, + // and then we can always apply the operation on the existing object in the pool. + val obj = liveObjects.objectsPool.createZeroValueObjectIfNotExists(objectOperation.objectId) // RTO9a2a1 + obj.applyObject(objectMessage) // RTO9a2a2, RTO9a2a3 + } + } + + /** + * Applies sync messages to sync data pool. + * + * @spec RTO5b - Collects object states during sync sequence + */ + private fun applyObjectSyncMessages(objectMessages: List) { + for (objectMessage in objectMessages) { + if (objectMessage.objectState == null) { + Log.w(tag, "Object message received during OBJECT_SYNC without object field, skipping message: ${objectMessage.id}") + continue + } + + val objectState: ObjectState = objectMessage.objectState + if (objectState.counter != null || objectState.map != null) { + syncObjectsDataPool[objectState.objectId] = objectState + } else { + // RTO5c1b1c - object state must contain either counter or map data + Log.w(tag, "Object state received without counter or map data, skipping message: ${objectMessage.id}") + } + } + } + + /** + * Creates an object from object state. + * + * @spec RTO5c1b - Creates objects from object state based on type + */ + private fun createObjectFromState(objectState: ObjectState): BaseLiveObject { + return when { + objectState.counter != null -> DefaultLiveCounter.zeroValue(objectState.objectId, liveObjects) // RTO5c1b1a + objectState.map != null -> DefaultLiveMap.zeroValue(objectState.objectId, liveObjects) // RTO5c1b1b + else -> throw clientError("Object state must contain either counter or map data") // RTO5c1b1c + } + } + + internal fun dispose() { + syncObjectsDataPool.clear() + bufferedObjectOperations.clear() + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt new file mode 100644 index 000000000..fa5d19d2a --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt @@ -0,0 +1,159 @@ +package io.ably.lib.objects + +import io.ably.lib.objects.type.BaseLiveObject +import io.ably.lib.objects.type.ObjectType +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.util.Log +import kotlinx.coroutines.* +import java.util.concurrent.ConcurrentHashMap + +/** + * Constants for ObjectsPool configuration + */ +internal object ObjectsPoolDefaults { + const val GC_INTERVAL_MS = 1000L * 60 * 5 // 5 minutes + /** + * Must be > 2 minutes to ensure we keep tombstones long enough to avoid the possibility of receiving an operation + * with an earlier serial that would not have been applied if the tombstone still existed. + * + * Applies both for map entries tombstones and object tombstones. + */ + const val GC_GRACE_PERIOD_MS = 1000L * 60 * 60 * 24 // 24 hours +} + +/** + * Root object ID constant + */ +internal const val ROOT_OBJECT_ID = "root" + +/** + * ObjectsPool manages a pool of live objects for a channel. + * + * @spec RTO3 - Maintains an objects pool for all live objects on the channel + */ +internal class ObjectsPool( + private val liveObjects: DefaultLiveObjects +) { + private val tag = "ObjectsPool" + + /** + * ConcurrentHashMap for thread-safe access from public APIs in LiveMap and LiveCounter. + * @spec RTO3a - Pool storing all live objects by object ID + */ + private val pool = ConcurrentHashMap() + + /** + * Coroutine scope for garbage collection + */ + private val gcScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var gcJob: Job // Job for the garbage collection coroutine + + init { + // RTO3b - Initialize pool with root object + pool[ROOT_OBJECT_ID] = DefaultLiveMap.zeroValue(ROOT_OBJECT_ID, liveObjects) + // Start garbage collection coroutine + gcJob = startGCJob() + } + + /** + * Gets a live object from the pool by object ID. + */ + internal fun get(objectId: String): BaseLiveObject? { + return pool[objectId] + } + + /** + * Sets a live object in the pool. + */ + internal fun set(objectId: String, liveObject: BaseLiveObject) { + pool[objectId] = liveObject + } + + /** + * Removes all objects but root from the pool and clears the data for root. + * Does not create a new root object, so the reference to the root object remains the same. + */ + internal fun resetToInitialPool(emitUpdateEvents: Boolean) { + pool.entries.removeIf { (key, _) -> key != ROOT_OBJECT_ID } // only keep the root object + clearObjectsData(emitUpdateEvents) // clear the root object and emit update events + } + + + /** + * Deletes objects from the pool for which object ids are not found in the provided array of ids. + * Spec: RTO5c2 + */ + internal fun deleteExtraObjectIds(objectIds: MutableSet) { + pool.entries.removeIf { (key, _) -> key !in objectIds && key != ROOT_OBJECT_ID } // RTO5c2a - Keep root object + } + + /** + * Clears the data stored for all objects in the pool. + */ + internal fun clearObjectsData(emitUpdateEvents: Boolean) { + for (obj in pool.values) { + val update = obj.clearData() + if (emitUpdateEvents) obj.notifyUpdated(update) + } + } + + /** + * Creates a zero-value object if it doesn't exist in the pool. + * + * @spec RTO6 - Creates zero-value objects when needed + */ + internal fun createZeroValueObjectIfNotExists(objectId: String): BaseLiveObject { + val existingObject = get(objectId) + if (existingObject != null) { + return existingObject // RTO6a + } + + val parsedObjectId = ObjectId.fromString(objectId) // RTO6b + return when (parsedObjectId.type) { + ObjectType.Map -> DefaultLiveMap.zeroValue(objectId, liveObjects) // RTO6b2 + ObjectType.Counter -> DefaultLiveCounter.zeroValue(objectId, liveObjects) // RTO6b3 + }.apply { + set(objectId, this) // RTO6b4 - Add the zero-value object to the pool + } + } + + /** + * Garbage collection interval handler. + */ + private fun onGCInterval() { + pool.entries.removeIf { (_, obj) -> + if (obj.isEligibleForGc()) { true } // Remove from pool + else { + obj.onGCInterval() + false // Keep in pool + } + } + } + + /** + * Starts the garbage collection coroutine. + */ + private fun startGCJob() : Job { + return gcScope.launch { + while (isActive) { + try { + onGCInterval() + } catch (e: Exception) { + Log.e(tag, "Error during garbage collection", e) + } + delay(ObjectsPoolDefaults.GC_INTERVAL_MS) + } + } + } + + /** + * Disposes of the ObjectsPool, cleaning up resources. + * Should be called when the pool is no longer needed. + */ + fun dispose() { + gcJob.cancel() + gcScope.cancel() + pool.clear() + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt new file mode 100644 index 000000000..5c2a193d5 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt @@ -0,0 +1,63 @@ +package io.ably.lib.objects + +/** + * @spec RTO5 - SyncTracker class for tracking objects sync status + */ +internal class ObjectsSyncTracker(syncChannelSerial: String?) { + private val syncSerial: String? = syncChannelSerial + internal val syncId: String? + internal val syncCursor: String? + + init { + val parsed = parseSyncChannelSerial(syncChannelSerial) + syncId = parsed.first + syncCursor = parsed.second + } + + /** + * Checks if a new sync sequence has started. + * + * @param prevSyncId The previously stored sync ID + * @return true if a new sync sequence has started, false otherwise + * + * Spec: RTO5a5, RTO5a2 + */ + internal fun hasSyncStarted(prevSyncId: String?): Boolean { + return syncSerial.isNullOrEmpty() || prevSyncId != syncId + } + + /** + * Checks if the current sync sequence has ended. + * + * @return true if the sync sequence has ended, false otherwise + * + * Spec: RTO5a5, RTO5a4 + */ + internal fun hasSyncEnded(): Boolean { + return syncSerial.isNullOrEmpty() || syncCursor.isNullOrEmpty() + } + + companion object { + /** + * Parses sync channel serial to extract syncId and syncCursor. + * + * @param syncChannelSerial The sync channel serial to parse + * @return Pair of syncId and syncCursor, both null if parsing fails + */ + private fun parseSyncChannelSerial(syncChannelSerial: String?): Pair { + if (syncChannelSerial.isNullOrEmpty()) { + return Pair(null, null) + } + + // RTO5a1 - syncChannelSerial is a two-part identifier: : + val match = Regex("^([\\w-]+):(.*)$").find(syncChannelSerial) + return if (match != null) { + val syncId = match.groupValues[1] + val syncCursor = match.groupValues[2] + Pair(syncId, syncCursor) + } else { + Pair(null, null) + } + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt index 29989fcdf..35bd4cefa 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt @@ -34,6 +34,9 @@ internal fun clientError(errorMessage: String) = ablyException(errorMessage, Err internal fun serverError(errorMessage: String) = ablyException(errorMessage, ErrorCode.InternalError, HttpStatusCode.InternalServerError) +internal fun objectError(errorMessage: String, cause: Throwable? = null): AblyException { + return ablyException(errorMessage, ErrorCode.InvalidObject, HttpStatusCode.InternalServerError, cause) +} /** * Calculates the byte size of a string. * For non-ASCII, the byte size can be 2–4x the character count. For ASCII, there is no difference. diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt index 77f7ce3e7..e10eb7847 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt @@ -36,7 +36,8 @@ internal class EnumCodeTypeAdapter>( override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): T { val code = json.asInt - return enumValues.first { getCode(it) == code } + return enumValues.firstOrNull { getCode(it) == code } ?: enumValues.firstOrNull { getCode(it) == -1 } + ?: throw JsonParseException("Unknown enum code: $code and no Unknown fallback found") } } @@ -45,17 +46,13 @@ internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeseri val obj = JsonObject() src.objectId?.let { obj.addProperty("objectId", it) } - src.value?.let { value -> - when (val v = value.value) { - is Boolean -> obj.addProperty("boolean", v) - is String -> obj.addProperty("string", v) - is Number -> obj.addProperty("number", v.toDouble()) - is Binary -> obj.addProperty("bytes", Base64.getEncoder().encodeToString(v.data)) - // Spec: OD4c5 - is JsonObject, is JsonArray -> { - obj.addProperty("string", v.toString()) - obj.addProperty("encoding", "json") - } + src.value?.let { v -> + when (v) { + is ObjectValue.Boolean -> obj.addProperty("boolean", v.value) + is ObjectValue.String -> obj.addProperty("string", v.value) + is ObjectValue.Number -> obj.addProperty("number", v.value.toDouble()) + is ObjectValue.Binary -> obj.addProperty("bytes", Base64.getEncoder().encodeToString(v.value.data)) + is ObjectValue.JsonObject, is ObjectValue.JsonArray -> obj.addProperty("json", v.value.toString()) // Spec: OD4c5 } } return obj @@ -64,25 +61,25 @@ internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeseri override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): ObjectData { val obj = if (json.isJsonObject) json.asJsonObject else throw JsonParseException("Expected JsonObject") val objectId = if (obj.has("objectId")) obj.get("objectId").asString else null - val encoding = if (obj.has("encoding")) obj.get("encoding").asString else null val value = when { - obj.has("boolean") -> ObjectValue(obj.get("boolean").asBoolean) - // Spec: OD5b3 - obj.has("string") && encoding == "json" -> { - val jsonStr = obj.get("string").asString - val parsed = JsonParser.parseString(jsonStr) - ObjectValue( - when { - parsed.isJsonObject -> parsed.asJsonObject - parsed.isJsonArray -> parsed.asJsonArray - else -> throw JsonParseException("Invalid JSON string for encoding=json") - } - ) + obj.has("boolean") -> ObjectValue.Boolean(obj.get("boolean").asBoolean) + obj.has("string") -> ObjectValue.String(obj.get("string").asString) + obj.has("number") -> ObjectValue.Number(obj.get("number").asDouble) + obj.has("bytes") -> ObjectValue.Binary(Binary(Base64.getDecoder().decode(obj.get("bytes").asString))) + obj.has("json") -> { + val jsonElement = JsonParser.parseString(obj.get("json").asString) + when { + jsonElement.isJsonObject -> ObjectValue.JsonObject(jsonElement.asJsonObject) + jsonElement.isJsonArray -> ObjectValue.JsonArray(jsonElement.asJsonArray) + else -> throw JsonParseException("Invalid JSON structure") + } + } + else -> { + if (objectId != null) + null + else + throw JsonParseException("Since objectId is not present, at least one of the value fields must be present") } - obj.has("string") -> ObjectValue(obj.get("string").asString) - obj.has("number") -> ObjectValue(obj.get("number").asDouble) - obj.has("bytes") -> ObjectValue(Binary(Base64.getDecoder().decode(obj.get("bytes").asString))) - else -> throw JsonParseException("ObjectData must have one of the fields: boolean, string, number, or bytes") } return ObjectData(objectId, value) } diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt index 63031d21c..1253bd08b 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt @@ -1,10 +1,11 @@ package io.ably.lib.objects.serialization import com.google.gson.JsonArray -import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser +import io.ably.lib.objects.* import io.ably.lib.objects.Binary +import io.ably.lib.objects.ErrorCode import io.ably.lib.objects.MapSemantics import io.ably.lib.objects.ObjectCounter import io.ably.lib.objects.ObjectCounterOp @@ -225,8 +226,9 @@ private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { when (fieldName) { "action" -> { val actionCode = unpacker.unpackInt() - action = ObjectOperationAction.entries.find { it.code == actionCode } - ?: throw IllegalArgumentException("Unknown ObjectOperationAction code: $actionCode") + action = ObjectOperationAction.entries.firstOrNull { it.code == actionCode } + ?: ObjectOperationAction.entries.firstOrNull { it.code == -1 } + ?: throw objectError("Unknown ObjectOperationAction code: $actionCode and no Unknown fallback found") } "objectId" -> objectId = unpacker.unpackString() "mapOp" -> mapOp = readObjectMapOp(unpacker) @@ -240,7 +242,7 @@ private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { } if (action == null) { - throw IllegalArgumentException("Missing required 'action' field in ObjectOperation") + throw objectError("Missing required 'action' field in ObjectOperation") } return ObjectOperation( @@ -484,8 +486,9 @@ private fun readObjectMap(unpacker: MessageUnpacker): ObjectMap { when (fieldName) { "semantics" -> { val semanticsCode = unpacker.unpackInt() - semantics = MapSemantics.entries.find { it.code == semanticsCode } - ?: throw IllegalArgumentException("Unknown MapSemantics code: $semanticsCode") + semantics = MapSemantics.entries.firstOrNull { it.code == semanticsCode } + ?: MapSemantics.entries.firstOrNull { it.code == -1 } + ?: throw objectError("Unknown MapSemantics code: $semanticsCode and no UNKNOWN fallback found") } "entries" -> { val mapSize = unpacker.unpackMapHeader() @@ -613,9 +616,6 @@ private fun ObjectData.writeMsgpack(packer: MessagePacker) { if (objectId != null) fieldCount++ value?.let { fieldCount++ - if (it.value is JsonElement) { - fieldCount += 1 // For extra "encoding" field - } } packer.packMapHeader(fieldCount) @@ -625,30 +625,32 @@ private fun ObjectData.writeMsgpack(packer: MessagePacker) { packer.packString(objectId) } - if (value != null) { - when (val v = value.value) { - is Boolean -> { + value?.let { v -> + when (v) { + is ObjectValue.Boolean -> { packer.packString("boolean") - packer.packBoolean(v) + packer.packBoolean(v.value) } - is String -> { + is ObjectValue.String -> { packer.packString("string") - packer.packString(v) + packer.packString(v.value) } - is Number -> { + is ObjectValue.Number -> { packer.packString("number") - packer.packDouble(v.toDouble()) + packer.packDouble(v.value.toDouble()) } - is Binary -> { + is ObjectValue.Binary -> { packer.packString("bytes") - packer.packBinaryHeader(v.data.size) - packer.writePayload(v.data) + packer.packBinaryHeader(v.value.data.size) + packer.writePayload(v.value.data) } - is JsonObject, is JsonArray -> { - packer.packString("string") - packer.packString(v.toString()) - packer.packString("encoding") + is ObjectValue.JsonObject -> { packer.packString("json") + packer.packString(v.value.toString()) + } + is ObjectValue.JsonArray -> { + packer.packString("json") + packer.packString(v.value.toString()) } } } @@ -661,8 +663,6 @@ private fun readObjectData(unpacker: MessageUnpacker): ObjectData { val fieldCount = unpacker.unpackMapHeader() var objectId: String? = null var value: ObjectValue? = null - var encoding: String? = null - var stringValue: String? = null for (i in 0 until fieldCount) { val fieldName = unpacker.unpackString().intern() @@ -675,33 +675,28 @@ private fun readObjectData(unpacker: MessageUnpacker): ObjectData { when (fieldName) { "objectId" -> objectId = unpacker.unpackString() - "boolean" -> value = ObjectValue(unpacker.unpackBoolean()) - "string" -> stringValue = unpacker.unpackString() - "number" -> value = ObjectValue(unpacker.unpackDouble()) + "boolean" -> value = ObjectValue.Boolean(unpacker.unpackBoolean()) + "string" -> value = ObjectValue.String(unpacker.unpackString()) + "number" -> value = ObjectValue.Number(unpacker.unpackDouble()) "bytes" -> { val size = unpacker.unpackBinaryHeader() val bytes = ByteArray(size) unpacker.readPayload(bytes) - value = ObjectValue(Binary(bytes)) + value = ObjectValue.Binary(Binary(bytes)) + } + "json" -> { + val jsonString = unpacker.unpackString() + val parsed = JsonParser.parseString(jsonString) + value = when { + parsed.isJsonObject -> ObjectValue.JsonObject(parsed.asJsonObject) + parsed.isJsonArray -> ObjectValue.JsonArray(parsed.asJsonArray) + else -> + throw ablyException("Invalid JSON string for json field", ErrorCode.MapValueDataTypeUnsupported, HttpStatusCode.InternalServerError) + } } - "encoding" -> encoding = unpacker.unpackString() else -> unpacker.skipValue() } } - // Handle string with encoding if needed - if (stringValue != null && encoding == "json") { - val parsed = JsonParser.parseString(stringValue) - value = ObjectValue( - when { - parsed.isJsonObject -> parsed.asJsonObject - parsed.isJsonArray -> parsed.asJsonArray - else -> throw IllegalArgumentException("Invalid JSON string for encoding=json") - } - ) - } else if (stringValue != null) { - value = ObjectValue(stringValue) - } - return ObjectData(objectId = objectId, value = value) } diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/BaseLiveObject.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/BaseLiveObject.kt new file mode 100644 index 000000000..70778cfbe --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/BaseLiveObject.kt @@ -0,0 +1,193 @@ +package io.ably.lib.objects.type + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.ObjectsPoolDefaults +import io.ably.lib.objects.objectError +import io.ably.lib.util.Log + +internal enum class ObjectType(val value: String) { + Map("map"), + Counter("counter") +} + +/** + * Base implementation of LiveObject interface. + * Provides common functionality for all live objects. + * + * @spec RTLO1/RTLO2 - Base class for LiveMap/LiveCounter object + * + * This should also be included in logging + */ +internal abstract class BaseLiveObject( + internal val objectId: String, // // RTLO3a + private val objectType: ObjectType, +) { + + protected open val tag = "BaseLiveObject" + + internal val siteTimeserials = mutableMapOf() // RTLO3b + + internal var createOperationIsMerged = false // RTLO3c + + @Volatile + internal var isTombstoned = false // Accessed from public API for LiveMap/LiveCounter + + private var tombstonedAt: Long? = null + + /** + * This is invoked by ObjectMessage having updated data with parent `ProtocolMessageAction` as `object_sync` + * @return an update describing the changes + * + * @spec RTLM6/RTLC6 - Overrides ObjectMessage with object data state from sync to LiveMap/LiveCounter + */ + internal fun applyObjectSync(objectState: ObjectState): Map { + validate(objectState) + // object's site serials are still updated even if it is tombstoned, so always use the site serials received from the operation. + // should default to empty map if site serials do not exist on the object state, so that any future operation may be applied to this object. + siteTimeserials.clear() + siteTimeserials.putAll(objectState.siteTimeserials) // RTLC6a, RTLM6a + + if (isTombstoned) { + // this object is tombstoned. this is a terminal state which can't be overridden. skip the rest of object state message processing + return mapOf() + } + return applyObjectState(objectState) // RTLM6, RTLC6 + } + + /** + * This is invoked by ObjectMessage having updated data with parent `ProtocolMessageAction` as `object` + * @return an update describing the changes + * + * @spec RTLM15/RTLC7 - Applies ObjectMessage with object data operations to LiveMap/LiveCounter + */ + internal fun applyObject(objectMessage: ObjectMessage) { + validateObjectId(objectMessage.operation?.objectId) + + val msgTimeSerial = objectMessage.serial + val msgSiteCode = objectMessage.siteCode + val objectOperation = objectMessage.operation as ObjectOperation + + if (!canApplyOperation(msgSiteCode, msgTimeSerial)) { + // RTLC7b, RTLM15b + Log.v( + tag, + "Skipping ${objectOperation.action} op: op serial $msgTimeSerial <= site serial ${siteTimeserials[msgSiteCode]}; " + + "objectId=$objectId" + ) + return + } + // should update stored site serial immediately. doesn't matter if we successfully apply the op, + // as it's important to mark that the op was processed by the object + siteTimeserials[msgSiteCode!!] = msgTimeSerial!! // RTLC7c, RTLM15c + + if (isTombstoned) { + // this object is tombstoned so the operation cannot be applied + return; + } + applyObjectOperation(objectOperation, objectMessage) // RTLC7d + } + + internal fun notifyUpdated(update: Any) { + // TODO: Implement event emission for updates + Log.v(tag, "Object $objectId updated: $update") + } + + /** + * Checks if an operation can be applied based on serial comparison. + * + * @spec RTLO4a - Serial comparison logic for LiveMap/LiveCounter operations + */ + internal fun canApplyOperation(siteCode: String?, timeSerial: String?): Boolean { + if (timeSerial.isNullOrEmpty()) { + throw objectError("Invalid serial: $timeSerial") // RTLO4a3 + } + if (siteCode.isNullOrEmpty()) { + throw objectError("Invalid site code: $siteCode") // RTLO4a3 + } + val existingSiteSerial = siteTimeserials[siteCode] // RTLO4a4 + return existingSiteSerial == null || timeSerial > existingSiteSerial // RTLO4a5, RTLO4a6 + } + + internal fun validateObjectId(objectId: String?) { + if (this.objectId != objectId) { + throw objectError("Invalid object: incoming objectId=${objectId}; $objectType objectId=$objectId") + } + } + + /** + * Marks the object as tombstoned. + */ + internal fun tombstone(): Any { + isTombstoned = true + tombstonedAt = System.currentTimeMillis() + val update = clearData() + // TODO: Emit lifecycle events + return update + } + + /** + * Checks if the object is eligible for garbage collection. + */ + internal fun isEligibleForGc(): Boolean { + val currentTime = System.currentTimeMillis() + return isTombstoned && tombstonedAt?.let { currentTime - it >= ObjectsPoolDefaults.GC_GRACE_PERIOD_MS } == true + } + + /** + * Validates that the provided object state is compatible with this live object. + * Checks object ID, type-specific validations, and any included create operations. + */ + abstract fun validate(state: ObjectState) + + /** + * Applies an object state received during synchronization to this live object. + * This method should update the internal data structure with the complete state + * received from the server. + * + * @param objectState The complete state to apply to this object + * @return A map describing the changes made to the object's data + * + */ + abstract fun applyObjectState(objectState: ObjectState): Map + + /** + * Applies an operation to this live object. + * This method handles the specific operation actions (e.g., update, remove) + * by modifying the underlying data structure accordingly. + * + * @param operation The operation containing the action and data to apply + * @param message The complete object message containing the operation + * + */ + abstract fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage) + + /** + * Clears the object's data and returns an update describing the changes. + * This is called during tombstoning and explicit clear operations. + * + * This method: + * 1. Calculates a diff between the current state and an empty state + * 2. Clears all entries from the underlying data structure + * 3. Returns a map containing metadata about what was cleared + * + * The returned map is used to notifying other components about what entries were removed. + * + * @return A map representing the diff of changes made + */ + abstract fun clearData(): Map + + /** + * Called during garbage collection intervals to clean up expired entries. + * + * This method should identify and remove entries that: + * - Have been marked as tombstoned + * - Have a tombstone timestamp older than the configured grace period + * + * Implementations typically use single-pass removal techniques to + * efficiently clean up expired data without creating temporary collections. + */ + abstract fun onGCInterval() +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt new file mode 100644 index 000000000..80f6151a2 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt @@ -0,0 +1,85 @@ +package io.ably.lib.objects.type.livecounter + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.type.BaseLiveObject +import io.ably.lib.objects.type.ObjectType +import io.ably.lib.types.Callback +import java.util.concurrent.atomic.AtomicReference + +/** + * Implementation of LiveObject for LiveCounter. + * + * @spec RTLC1/RTLC2 - LiveCounter implementation extends LiveObject + */ +internal class DefaultLiveCounter private constructor( + objectId: String, + private val liveObjects: DefaultLiveObjects, +) : LiveCounter, BaseLiveObject(objectId, ObjectType.Counter) { + + override val tag = "LiveCounter" + + /** + * Thread-safe reference to hold the counter data value. + * Accessed from public API for LiveCounter and updated by LiveCounterManager. + */ + internal val data = AtomicReference(0.0) // RTLC3 + + /** + * liveCounterManager instance for managing LiveMap operations + */ + private val liveCounterManager = LiveCounterManager(this) + + private val channelName = liveObjects.channelName + private val adapter: LiveObjectsAdapter get() = liveObjects.adapter + + override fun increment() { + TODO("Not yet implemented") + } + + override fun incrementAsync(callback: Callback) { + TODO("Not yet implemented") + } + + override fun decrement() { + TODO("Not yet implemented") + } + + override fun decrementAsync(callback: Callback) { + TODO("Not yet implemented") + } + + override fun value(): Double { + TODO("Not yet implemented") + } + + override fun validate(state: ObjectState) = liveCounterManager.validate(state) + + override fun applyObjectState(objectState: ObjectState): Map { + return liveCounterManager.applyState(objectState) + } + + override fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage) { + liveCounterManager.applyOperation(operation) + } + + override fun clearData(): Map { + return mapOf("amount" to data.get()).apply { data.set(0.0) } + } + + override fun onGCInterval() { + // Nothing to GC for a counter object + return + } + + companion object { + /** + * Creates a zero-value counter object. + * @spec RTLC4 - Returns LiveCounter with 0 value + */ + internal fun zeroValue(objectId: String, liveObjects: DefaultLiveObjects): DefaultLiveCounter { + return DefaultLiveCounter(objectId, liveObjects) + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt new file mode 100644 index 000000000..0a34c530a --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt @@ -0,0 +1,114 @@ +package io.ably.lib.objects.type.livecounter + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.objectError +import io.ably.lib.util.Log + +internal class LiveCounterManager(private val liveCounter: DefaultLiveCounter) { + private val objectId = liveCounter.objectId + + private val tag = "LiveCounterManager" + + /** + * @spec RTLC6 - Overrides counter data with state from sync + */ + internal fun applyState(objectState: ObjectState): Map { + val previousData = liveCounter.data.get() + + if (objectState.tombstone) { + liveCounter.tombstone() + } else { + // override data for this object with data from the object state + liveCounter.createOperationIsMerged = false // RTLC6b + liveCounter.data.set(objectState.counter?.count ?: 0.0) // RTLC6c + + // RTLC6d + objectState.createOp?.let { createOp -> + mergeInitialDataFromCreateOperation(createOp) + } + } + + return mapOf("amount" to (liveCounter.data.get() - previousData)) + } + + /** + * @spec RTLC7 - Applies operations to LiveCounter + */ + internal fun applyOperation(operation: ObjectOperation) { + val update = when (operation.action) { + ObjectOperationAction.CounterCreate -> applyCounterCreate(operation) // RTLC7d1 + ObjectOperationAction.CounterInc -> { + if (operation.counterOp != null) { + applyCounterInc(operation.counterOp) // RTLC7d2 + } else { + throw objectError("No payload found for ${operation.action} op for LiveCounter objectId=${objectId}") + } + } + ObjectOperationAction.ObjectDelete -> liveCounter.tombstone() + else -> throw objectError("Invalid ${operation.action} op for LiveCounter objectId=${objectId}") // RTLC7d3 + } + + liveCounter.notifyUpdated(update) + } + + /** + * @spec RTLC8 - Applies counter create operation + */ + private fun applyCounterCreate(operation: ObjectOperation): Map { + if (liveCounter.createOperationIsMerged) { + // RTLC8b + // There can't be two different create operation for the same object id, because the object id + // fully encodes that operation. This means we can safely ignore any new incoming create operations + // if we already merged it once. + Log.v( + tag, + "Skipping applying COUNTER_CREATE op on a counter instance as it was already applied before; objectId=$objectId" + ) + return mapOf() + } + + return mergeInitialDataFromCreateOperation(operation) // RTLC8c + } + + /** + * @spec RTLC9 - Applies counter increment operation + */ + private fun applyCounterInc(counterOp: ObjectCounterOp): Map { + val amount = counterOp.amount ?: 0.0 + val previousValue = liveCounter.data.get() + liveCounter.data.set(previousValue + amount) // RTLC9b + return mapOf("amount" to amount) + } + + /** + * @spec RTLC10 - Merges initial data from create operation + */ + private fun mergeInitialDataFromCreateOperation(operation: ObjectOperation): Map { + // if a counter object is missing for the COUNTER_CREATE op, the initial value is implicitly 0 in this case. + // note that it is intentional to SUM the incoming count from the create op. + // if we got here, it means that current counter instance is missing the initial value in its data reference, + // which we're going to add now. + val count = operation.counter?.count ?: 0.0 + val previousValue = liveCounter.data.get() + liveCounter.data.set(previousValue + count) // RTLC10a + liveCounter.createOperationIsMerged = true // RTLC10b + return mapOf("amount" to count) + } + + internal fun validate(state: ObjectState) { + liveCounter.validateObjectId(state.objectId) + state.createOp?.let { createOp -> + liveCounter.validateObjectId(createOp.objectId) + validateCounterCreateAction(createOp.action) + } + } + + private fun validateCounterCreateAction(action: ObjectOperationAction) { + if (action != ObjectOperationAction.CounterCreate) { + throw objectError("Invalid create operation action $action for LiveCounter objectId=${objectId}") + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt new file mode 100644 index 000000000..45ccbac9f --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt @@ -0,0 +1,104 @@ +package io.ably.lib.objects.type.livemap + +import io.ably.lib.objects.* +import io.ably.lib.objects.MapSemantics +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.type.BaseLiveObject +import io.ably.lib.objects.type.ObjectType +import io.ably.lib.types.Callback +import java.util.concurrent.ConcurrentHashMap + +/** + * Implementation of LiveObject for LiveMap. + * + * @spec RTLM1/RTLM2 - LiveMap implementation extends LiveObject + */ +internal class DefaultLiveMap private constructor( + objectId: String, + private val liveObjects: DefaultLiveObjects, + internal val semantics: MapSemantics = MapSemantics.LWW +) : LiveMap, BaseLiveObject(objectId, ObjectType.Map) { + + override val tag = "LiveMap" + + /** + * ConcurrentHashMap for thread-safe access from public APIs in LiveMap and LiveMapManager. + */ + internal val data = ConcurrentHashMap() + + /** + * LiveMapManager instance for managing LiveMap operations + */ + private val liveMapManager = LiveMapManager(this) + + private val channelName = liveObjects.channelName + private val adapter: LiveObjectsAdapter get() = liveObjects.adapter + internal val objectsPool: ObjectsPool get() = liveObjects.objectsPool + + override fun get(keyName: String): Any? { + TODO("Not yet implemented") + } + + override fun entries(): MutableIterable> { + TODO("Not yet implemented") + } + + override fun keys(): MutableIterable { + TODO("Not yet implemented") + } + + override fun values(): MutableIterable { + TODO("Not yet implemented") + } + + override fun set(keyName: String, value: Any) { + TODO("Not yet implemented") + } + + override fun remove(keyName: String) { + TODO("Not yet implemented") + } + + override fun size(): Long { + TODO("Not yet implemented") + } + + override fun setAsync(keyName: String, value: Any, callback: Callback) { + TODO("Not yet implemented") + } + + override fun removeAsync(keyName: String, callback: Callback) { + TODO("Not yet implemented") + } + + override fun validate(state: ObjectState) = liveMapManager.validate(state) + + override fun applyObjectState(objectState: ObjectState): Map { + return liveMapManager.applyState(objectState) + } + + override fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage) { + liveMapManager.applyOperation(operation, message.serial) + } + + override fun clearData(): Map { + return liveMapManager.calculateUpdateFromDataDiff(data.toMap(), emptyMap()) + .apply { data.clear() } + } + + override fun onGCInterval() { + data.entries.removeIf { (_, entry) -> entry.isEligibleForGc() } + } + + companion object { + /** + * Creates a zero-value map object. + * @spec RTLM4 - Returns LiveMap with empty map data + */ + internal fun zeroValue(objectId: String, objects: DefaultLiveObjects): DefaultLiveMap { + return DefaultLiveMap(objectId, objects) + } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt new file mode 100644 index 000000000..bb0371183 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt @@ -0,0 +1,61 @@ +package io.ably.lib.objects.type.livemap + +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectsPool +import io.ably.lib.objects.ObjectsPoolDefaults + +/** + * @spec RTLM3 - Map data structure storing entries + */ +internal data class LiveMapEntry( + val isTombstoned: Boolean = false, + val tombstonedAt: Long? = null, + val timeserial: String? = null, + val data: ObjectData? = null +) + +/** + * Checks if entry is directly tombstoned or references a tombstoned object. Spec: RTLM14 + * @param objectsPool The object pool containing referenced LiveObjects + */ +internal fun LiveMapEntry.isEntryOrRefTombstoned(objectsPool: ObjectsPool): Boolean { + if (isTombstoned) { + return true // RTLM14a + } + data?.objectId?.let { refId -> // RTLM5d2f -has an objectId reference + objectsPool.get(refId)?.let { refObject -> + if (refObject.isTombstoned) { + return true + } + } + } + return false // RTLM14b +} + +/** + * Returns value as is if object data stores a primitive type or + * a reference to another LiveObject from the pool if it stores an objectId. + */ +internal fun LiveMapEntry.getResolvedValue(objectsPool: ObjectsPool): Any? { + if (isTombstoned) { return null } // RTLM5d2a + + data?.value?.let { return it.value } // RTLM5d2b, RTLM5d2c, RTLM5d2d, RTLM5d2e + + data?.objectId?.let { refId -> // RTLM5d2f -has an objectId reference + objectsPool.get(refId)?.let { refObject -> + if (refObject.isTombstoned) { + return null // tombstoned objects must not be surfaced to the end users + } + return refObject // RTLM5d2f2 + } + } + return null // RTLM5d2g, RTLM5d2f1 +} + +/** + * Extension function to check if a LiveMapEntry is expired and ready for garbage collection + */ +internal fun LiveMapEntry.isEligibleForGc(): Boolean { + val currentTime = System.currentTimeMillis() + return isTombstoned && tombstonedAt?.let { currentTime - it >= ObjectsPoolDefaults.GC_GRACE_PERIOD_MS } == true +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt new file mode 100644 index 000000000..55b660d16 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt @@ -0,0 +1,317 @@ +package io.ably.lib.objects.type.livemap + +import io.ably.lib.objects.MapSemantics +import io.ably.lib.objects.ObjectMapOp +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.isInvalid +import io.ably.lib.objects.objectError +import io.ably.lib.util.Log + +internal class LiveMapManager(private val liveMap: DefaultLiveMap) { + private val objectId = liveMap.objectId + + private val tag = "LiveMapManager" + + /** + * @spec RTLM6 - Overrides object data with state from sync + */ + internal fun applyState(objectState: ObjectState): Map { + val previousData = liveMap.data.toMap() + + if (objectState.tombstone) { + liveMap.tombstone() + } else { + // override data for this object with data from the object state + liveMap.createOperationIsMerged = false // RTLM6b + liveMap.data.clear() + + objectState.map?.entries?.forEach { (key, entry) -> + liveMap.data[key] = LiveMapEntry( + isTombstoned = entry.tombstone ?: false, + tombstonedAt = if (entry.tombstone == true) System.currentTimeMillis() else null, + timeserial = entry.timeserial, + data = entry.data + ) + } // RTLM6c + + // RTLM6d + objectState.createOp?.let { createOp -> + mergeInitialDataFromCreateOperation(createOp) + } + } + + return calculateUpdateFromDataDiff(previousData, liveMap.data.toMap()) + } + + /** + * @spec RTLM15 - Applies operations to LiveMap + */ + internal fun applyOperation(operation: ObjectOperation, messageTimeserial: String?) { + val update = when (operation.action) { + ObjectOperationAction.MapCreate -> applyMapCreate(operation) // RTLM15d1 + ObjectOperationAction.MapSet -> { + if (operation.mapOp != null) { + applyMapSet(operation.mapOp, messageTimeserial) // RTLM15d2 + } else { + throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}") + } + } + ObjectOperationAction.MapRemove -> { + if (operation.mapOp != null) { + applyMapRemove(operation.mapOp, messageTimeserial) // RTLM15d3 + } else { + throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}") + } + } + ObjectOperationAction.ObjectDelete -> liveMap.tombstone() + else -> throw objectError("Invalid ${operation.action} op for LiveMap objectId=${objectId}") // RTLM15d4 + } + + liveMap.notifyUpdated(update) + } + + /** + * @spec RTLM16 - Applies map create operation + */ + private fun applyMapCreate(operation: ObjectOperation): Map { + if (liveMap.createOperationIsMerged) { + // RTLM16b + // There can't be two different create operation for the same object id, because the object id + // fully encodes that operation. This means we can safely ignore any new incoming create operations + // if we already merged it once. + Log.v( + tag, + "Skipping applying MAP_CREATE op on a map instance as it was already applied before; objectId=${objectId}" + ) + return mapOf() + } + + validateMapSemantics(operation.map?.semantics) // RTLM16c + + return mergeInitialDataFromCreateOperation(operation) // RTLM16d + } + + /** + * @spec RTLM7 - Applies MAP_SET operation to LiveMap + */ + private fun applyMapSet( + mapOp: ObjectMapOp, // RTLM7d1 + timeSerial: String?, // RTLM7d2 + ): Map { + val existingEntry = liveMap.data[mapOp.key] + + // RTLM7a + if (existingEntry != null && !canApplyMapOperation(existingEntry.timeserial, timeSerial)) { + // RTLM7a1 - the operation's serial <= the entry's serial, ignore the operation + Log.v(tag, + "Skipping update for key=\"${mapOp.key}\": op serial $timeSerial <= entry serial ${existingEntry.timeserial};" + + " objectId=${objectId}" + ) + return mapOf() + } + + if (mapOp.data.isInvalid()) { + throw objectError("Invalid object data for MAP_SET op on objectId=${objectId} on key=${mapOp.key}") + } + + // RTLM7c + mapOp.data?.objectId?.let { + // this MAP_SET op is setting a key to point to another object via its object id, + // but it is possible that we don't have the corresponding object in the pool yet (for example, we haven't seen the *_CREATE op for it). + // we don't want to return undefined from this map's .get() method even if we don't have the object, + // so instead we create a zero-value object for that object id if it not exists. + liveMap.objectsPool.createZeroValueObjectIfNotExists(it) // RTLM7c1 + } + + if (existingEntry != null) { + // RTLM7a2 - Replace existing entry with new one instead of mutating + liveMap.data[mapOp.key] = LiveMapEntry( + isTombstoned = false, // RTLM7a2c + tombstonedAt = null, + timeserial = timeSerial, // RTLM7a2b + data = mapOp.data // RTLM7a2a + ) + } else { + // RTLM7b, RTLM7b1 + liveMap.data[mapOp.key] = LiveMapEntry( + isTombstoned = false, // RTLM7b2 + timeserial = timeSerial, + data = mapOp.data + ) + } + + return mapOf(mapOp.key to "updated") + } + + /** + * @spec RTLM8 - Applies MAP_REMOVE operation to LiveMap + */ + private fun applyMapRemove( + mapOp: ObjectMapOp, // RTLM8c1 + timeSerial: String?, // RTLM8c2 + ): Map { + val existingEntry = liveMap.data[mapOp.key] + + // RTLM8a + if (existingEntry != null && !canApplyMapOperation(existingEntry.timeserial, timeSerial)) { + // RTLM8a1 - the operation's serial <= the entry's serial, ignore the operation + Log.v( + tag, + "Skipping remove for key=\"${mapOp.key}\": op serial $timeSerial <= entry serial ${existingEntry.timeserial}; " + + "objectId=${objectId}" + ) + return mapOf() + } + + if (existingEntry != null) { + // RTLM8a2 - Replace existing entry with new one instead of mutating + liveMap.data[mapOp.key] = LiveMapEntry( + isTombstoned = true, // RTLM8a2c + tombstonedAt = System.currentTimeMillis(), + timeserial = timeSerial, // RTLM8a2b + data = null // RTLM8a2a + ) + } else { + // RTLM8b, RTLM8b1 + liveMap.data[mapOp.key] = LiveMapEntry( + isTombstoned = true, // RTLM8b2 + tombstonedAt = System.currentTimeMillis(), + timeserial = timeSerial + ) + } + + return mapOf(mapOp.key to "removed") + } + + /** + * For Lww CRDT semantics (the only supported LiveMap semantic) an operation + * Should only be applied if incoming serial is strictly greater than existing entry's serial. + * @spec RTLM9 - Serial comparison logic for map operations + */ + private fun canApplyMapOperation(existingMapEntrySerial: String?, timeSerial: String?): Boolean { + if (existingMapEntrySerial.isNullOrEmpty() && timeSerial.isNullOrEmpty()) { // RTLM9b + return false + } + if (existingMapEntrySerial.isNullOrEmpty()) { // RTLM9d - If true, means timeSerial is not empty based on previous checks + return true + } + if (timeSerial.isNullOrEmpty()) { // RTLM9c - Check reached here means existingMapEntrySerial is not empty + return false + } + return timeSerial > existingMapEntrySerial // RTLM9e - both are not empty + } + + /** + * @spec RTLM17 - Merges initial data from create operation + */ + private fun mergeInitialDataFromCreateOperation(operation: ObjectOperation): Map { + if (operation.map?.entries.isNullOrEmpty()) { // no map entries in MAP_CREATE op + return mapOf() + } + + val aggregatedUpdate = mutableMapOf() + + // RTLM17a + // in order to apply MAP_CREATE op for an existing map, we should merge their underlying entries keys. + // we can do this by iterating over entries from MAP_CREATE op and apply changes on per-key basis as if we had MAP_SET, MAP_REMOVE operations. + operation.map?.entries?.forEach { (key, entry) -> + // for a MAP_CREATE operation we must use the serial value available on an entry, instead of a serial on a message + val opTimeserial = entry.timeserial + val update = if (entry.tombstone == true) { + // RTLM17a2 - entry in MAP_CREATE op is removed, try to apply MAP_REMOVE op + applyMapRemove(ObjectMapOp(key), opTimeserial) + } else { + // RTLM17a1 - entry in MAP_CREATE op is not removed, try to set it via MAP_SET op + applyMapSet(ObjectMapOp(key, entry.data), opTimeserial) + } + + // skip noop updates + if (update.isEmpty()) { + return@forEach + } + + aggregatedUpdate.putAll(update) + } + + liveMap.createOperationIsMerged = true // RTLM17b + + return aggregatedUpdate + } + + internal fun calculateUpdateFromDataDiff(prevData: Map, newData: Map): Map { + val update = mutableMapOf() + + // Check for removed entries + for ((key, prevEntry) in prevData) { + if (!prevEntry.isTombstoned && !newData.containsKey(key)) { + update[key] = "removed" + } + } + + // Check for added/updated entries + for ((key, newEntry) in newData) { + if (!prevData.containsKey(key)) { + // if property does not exist in current map, but new data has it as non-tombstoned property - got updated + if (!newEntry.isTombstoned) { + update[key] = "updated" + } + // otherwise, if new data has this prop tombstoned - do nothing, as property didn't exist anyway + continue + } + + // properties that exist both in current and new map data need to have their values compared to decide on update type + val prevEntry = prevData[key]!! + + // compare tombstones first + if (prevEntry.isTombstoned && !newEntry.isTombstoned) { + // prev prop is tombstoned, but new is not. it means prop was updated to a meaningful value + update[key] = "updated" + continue + } + if (!prevEntry.isTombstoned && newEntry.isTombstoned) { + // prev prop is not tombstoned, but new is. it means prop was removed + update[key] = "removed" + continue + } + if (prevEntry.isTombstoned && newEntry.isTombstoned) { + // props are tombstoned - treat as noop, as there is no data to compare + continue + } + + // both props exist and are not tombstoned, need to compare values to see if it was changed + val valueChanged = prevEntry.data != newEntry.data + if (valueChanged) { + update[key] = "updated" + continue + } + } + + return update + } + + internal fun validate(state: ObjectState) { + liveMap.validateObjectId(state.objectId) + validateMapSemantics(state.map?.semantics) + state.createOp?.let { createOp -> + liveMap.validateObjectId(createOp.objectId) + validateMapCreateAction(createOp.action) + validateMapSemantics(createOp.map?.semantics) + } + } + + private fun validateMapCreateAction(action: ObjectOperationAction) { + if (action != ObjectOperationAction.MapCreate) { + throw objectError("Invalid create operation action $action for LiveMap objectId=${objectId}") + } + } + + private fun validateMapSemantics(semantics: MapSemantics?) { + if (semantics != liveMap.semantics) { + throw objectError( + "Invalid object: incoming object map semantics=$semantics; current map semantics=${MapSemantics.LWW}" + ) + } + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt index 17719b961..a91f0e9cf 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import java.lang.reflect.Method suspend fun assertWaiter(timeoutInMs: Long = 10_000, block: suspend () -> Boolean) { withContext(Dispatchers.Default) { @@ -58,3 +59,7 @@ fun Any.invokePrivateMethod(methodName: String, vararg args: Any?): T { @Suppress("UNCHECKED_CAST") return method.invoke(this, *args) as T } + +fun Class<*>.findMethod(methodName: String): Method { + return methods.find { it.name.contains(methodName) } ?: error("Method '$methodName' not found") +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt new file mode 100644 index 000000000..5723c5293 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt @@ -0,0 +1,55 @@ +package io.ably.lib.objects.unit + +import io.ably.lib.objects.ObjectId +import io.ably.lib.objects.type.ObjectType +import io.ably.lib.types.AblyException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import kotlin.test.assertTrue + +class ObjectIdTest { + + @Test + fun testValidMapObjectId() { + val objectIdString = "map:abc123@1640995200000" + val objectId = ObjectId.fromString(objectIdString) + + assertEquals(ObjectType.Map, objectId.type) + assertEquals("map:abc123@1640995200000", objectId.toString()) + } + + @Test + fun testValidCounterObjectId() { + val objectIdString = "counter:def456@1640995200000" + val objectId = ObjectId.fromString(objectIdString) + + assertEquals(ObjectType.Counter, objectId.type) + assertEquals("counter:def456@1640995200000", objectId.toString()) + } + + @Test + fun testInvalidObjectType() { + val exception = assertThrows(AblyException::class.java) { + ObjectId.fromString("invalid:abc123@1640995200000") + } + assertAblyExceptionError(exception) + } + + @Test + fun testEmptyObjectId() { + val exception1 = assertThrows(AblyException::class.java) { + ObjectId.fromString("") + } + assertAblyExceptionError(exception1) + } + + private fun assertAblyExceptionError( + exception: AblyException + ) { + assertTrue(exception.errorInfo?.message?.contains("Invalid object id:") == true || + exception.errorInfo?.message?.contains("Invalid object type in object id:") == true) + assertEquals(92_000, exception.errorInfo?.code) + assertEquals(500, exception.errorInfo?.statusCode) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt index f8c37ee7c..de90b8648 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt @@ -4,6 +4,7 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.JsonElement import com.google.gson.JsonNull +import io.ably.lib.objects.ObjectMessage import io.ably.lib.objects.unit.fixtures.* import io.ably.lib.types.ProtocolMessage import io.ably.lib.types.ProtocolMessage.ActionSerializer @@ -42,7 +43,7 @@ class ObjectMessageSerializationTest { assertNotNull(deserializedProtoMsg) deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) -> - assertEquals(expected, actual as? io.ably.lib.objects.ObjectMessage) + assertEquals(expected, actual as? ObjectMessage) } } @@ -61,7 +62,7 @@ class ObjectMessageSerializationTest { assertNotNull(deserializedProtoMsg) deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) -> - assertEquals(expected, (actual as? io.ably.lib.objects.ObjectMessage)) + assertEquals(expected, (actual as? ObjectMessage)) } } @@ -170,11 +171,11 @@ class ObjectMessageSerializationTest { // Check if gson deserialization works correctly deserializedProtoMsg = ProtocolSerializer.fromJSON(protoMsgJsonObject.toString()) - assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? io.ably.lib.objects.ObjectMessage) + assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? ObjectMessage) // Check if msgpack deserialization works correctly serializedMsgpackBytes = Serialisation.gsonToMsgpack(protoMsgJsonObject) deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedMsgpackBytes) - assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? io.ably.lib.objects.ObjectMessage) + assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? ObjectMessage) } } diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt index 8c26a1a08..295e54096 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.text.toByteArray class ObjectMessageSizeTest { @@ -47,13 +46,13 @@ class ObjectMessageSizeTest { key = "mapKey", // Size: 6 bytes (UTF-8 byte length) data = ObjectData( objectId = "ref_obj", // Not counted in data size - value = ObjectValue("sample") // Size: 6 bytes (UTF-8 byte length) + value = ObjectValue.String("sample") // Size: 6 bytes (UTF-8 byte length) ) // Total ObjectData size: 6 bytes ), // Total ObjectMapOp size: 6 + 6 = 12 bytes // CounterOp contributes to operation size counterOp = ObjectCounterOp( - amount = 10.5 // Size: 8 bytes (number is always 8 bytes) + amount = 10.0 // Size: 8 bytes (number is always 8 bytes) ), // Total ObjectCounterOp size: 8 bytes // Map contributes to operation size (for MAP_CREATE operations) @@ -64,12 +63,12 @@ class ObjectMessageSizeTest { tombstone = false, // Not counted in entry size timeserial = "ts_123", // Not counted in entry size data = ObjectData( - value = ObjectValue("value1") // Size: 6 bytes + value = ObjectValue.String("value1") // Size: 6 bytes ) // ObjectMapEntry size: 6 bytes ), // Total for this entry: 6 (key) + 6 (entry) = 12 bytes "entry2" to ObjectMapEntry( // Key size: 6 bytes data = ObjectData( - value = ObjectValue(42) // Size: 8 bytes (number) + value = ObjectValue.Number(42) // Size: 8 bytes (number) ) // ObjectMapEntry size: 8 bytes ) // Total for this entry: 6 (key) + 8 (entry) = 14 bytes ) // Total entries size: 12 + 14 = 26 bytes @@ -96,7 +95,7 @@ class ObjectMessageSizeTest { mapOp = ObjectMapOp( key = "createKey", // Size: 9 bytes data = ObjectData( - value = ObjectValue("createValue") // Size: 11 bytes + value = ObjectValue.String("createValue") // Size: 11 bytes ) // ObjectData size: 11 bytes ) // ObjectMapOp size: 9 + 11 = 20 bytes ), // Total createOp size: 20 bytes @@ -106,7 +105,7 @@ class ObjectMessageSizeTest { entries = mapOf( "stateKey" to ObjectMapEntry( // Key size: 8 bytes data = ObjectData( - value = ObjectValue("stateValue") // Size: 10 bytes + value = ObjectValue.String("stateValue") // Size: 10 bytes ) // ObjectMapEntry size: 10 bytes ) // Total: 8 + 10 = 18 bytes ) @@ -139,7 +138,7 @@ class ObjectMessageSizeTest { mapOp = ObjectMapOp( key = "", data = ObjectData( - value = ObjectValue("你😊") // 你 -> 3 bytes, 😊 -> 4 bytes + value = ObjectValue.String("你😊") // 你 -> 3 bytes, 😊 -> 4 bytes ), ), ) diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt new file mode 100644 index 000000000..3f63a2d82 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt @@ -0,0 +1,65 @@ +package io.ably.lib.objects.unit + +import io.ably.lib.objects.ObjectsSyncTracker +import org.junit.Test +import org.junit.Assert.* + +class ObjectsSyncTrackerTest { + + @Test + fun `(RTO5a, RTO5a1, RTO5a2) Should parse valid sync channel serial with syncId and cursor`() { + val syncTracker = ObjectsSyncTracker("sync-123:cursor-456") + + assertEquals("sync-123", syncTracker.syncId) + assertFalse(syncTracker.hasSyncStarted("sync-123")) + assertTrue(syncTracker.hasSyncStarted(null)) + assertTrue(syncTracker.hasSyncStarted("sync-124")) + + assertEquals("cursor-456", syncTracker.syncCursor) + assertFalse(syncTracker.hasSyncEnded()) + } + + @Test + fun `(RTO5a5) Should handle null sync channel serial`() { + val syncTracker = ObjectsSyncTracker(null) + + assertNull(syncTracker.syncId) + assertTrue(syncTracker.hasSyncStarted(null)) + + assertNull(syncTracker.syncCursor) + assertTrue(syncTracker.hasSyncEnded()) + } + + @Test + fun `(RTO5a5) Should handle empty sync channel serial`() { + val syncTracker = ObjectsSyncTracker("") + + assertNull(syncTracker.syncId) + assertTrue(syncTracker.hasSyncStarted(null)) + + assertNull(syncTracker.syncCursor) + assertTrue(syncTracker.hasSyncEnded()) + } + + @Test + fun `should handle sync channel serial with special characters`() { + val syncTracker = ObjectsSyncTracker("sync_123-456:cursor_789-012") + + assertEquals("sync_123-456", syncTracker.syncId) + + assertEquals("cursor_789-012", syncTracker.syncCursor) + assertFalse(syncTracker.hasSyncEnded()) + } + + @Test + fun `(RTO5a4) should detect sync sequence ended when sync cursor is empty`() { + val syncTracker = ObjectsSyncTracker("sync-123:") + + assertEquals("sync-123", syncTracker.syncId) + assertTrue(syncTracker.hasSyncStarted(null)) + assertTrue(syncTracker.hasSyncStarted("")) + + assertEquals("", syncTracker.syncCursor) + assertTrue(syncTracker.hasSyncEnded()) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt similarity index 91% rename from live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt rename to live-objects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt index 4c4294877..ec8824e1a 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt @@ -4,7 +4,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertNotNull -class LiveObjectTest { +class RealtimeObjectsTest { @Test fun testChannelObjectGetterTest() = runTest { val channel = getMockRealtimeChannel("test-channel") diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt index 5946e6320..a7453336f 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt @@ -1,5 +1,13 @@ package io.ably.lib.objects.unit +import io.ably.lib.objects.* +import io.ably.lib.objects.DefaultLiveObjects +import io.ably.lib.objects.ObjectsManager +import io.ably.lib.objects.type.BaseLiveObject +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livecounter.LiveCounterManager +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.type.livemap.LiveMapManager import io.ably.lib.realtime.AblyRealtime import io.ably.lib.realtime.Channel import io.ably.lib.realtime.ChannelState @@ -35,3 +43,114 @@ internal fun getMockRealtimeChannel( state = ChannelState.attached } } + +internal fun getMockLiveObjectsAdapter(): LiveObjectsAdapter { + return mockk(relaxed = true) +} + +internal fun getMockObjectsPool(): ObjectsPool { + return mockk(relaxed = true) +} + +internal fun ObjectsPool.size(): Int { + val pool = this.getPrivateField>("pool") + return pool.size +} + +/** + * ====================================== + * START - DefaultLiveObjects dep mocks + * ====================================== + */ +internal val ObjectsManager.SyncObjectsDataPool: Map + get() = this.getPrivateField("syncObjectsDataPool") + +internal val ObjectsManager.BufferedObjectOperations: List + get() = this.getPrivateField("bufferedObjectOperations") + +internal var DefaultLiveObjects.ObjectsManager: ObjectsManager + get() = this.getPrivateField("objectsManager") + set(value) = this.setPrivateField("objectsManager", value) + +internal var DefaultLiveObjects.ObjectsPool: ObjectsPool + get() = this.objectsPool + set(value) = this.setPrivateField("objectsPool", value) + +internal fun getDefaultLiveObjectsWithMockedDeps( + channelName: String = "testChannelName", + relaxed: Boolean = false +): DefaultLiveObjects { + val defaultLiveObjects = DefaultLiveObjects(channelName, getMockLiveObjectsAdapter()) + // mock objectsPool to allow verification of method calls + if (relaxed) { + defaultLiveObjects.ObjectsPool = mockk(relaxed = true) + } else { + defaultLiveObjects.ObjectsPool = spyk(defaultLiveObjects.objectsPool, recordPrivateCalls = true) + } + // mock objectsManager to allow verification of method calls + if (relaxed) { + defaultLiveObjects.ObjectsManager = mockk(relaxed = true) + } else { + defaultLiveObjects.ObjectsManager = spyk(defaultLiveObjects.ObjectsManager, recordPrivateCalls = true) + } + return defaultLiveObjects +} +/** + * ====================================== + * END - DefaultLiveObjects dep mocks + * ====================================== + */ + +/** + * ====================================== + * START - DefaultLiveCounter dep mocks + * ====================================== + */ +internal var DefaultLiveCounter.LiveCounterManager: LiveCounterManager + get() = this.getPrivateField("liveCounterManager") + set(value) = this.setPrivateField("liveCounterManager", value) + +internal fun getDefaultLiveCounterWithMockedDeps( + objectId: String = "counter:testCounter@1", + relaxed: Boolean = false +): DefaultLiveCounter { + val defaultLiveCounter = DefaultLiveCounter.zeroValue(objectId, getDefaultLiveObjectsWithMockedDeps()) + if (relaxed) { + defaultLiveCounter.LiveCounterManager = mockk(relaxed = true) + } else { + defaultLiveCounter.LiveCounterManager = spyk(defaultLiveCounter.LiveCounterManager, recordPrivateCalls = true) + } + return defaultLiveCounter +} +/** + * ====================================== + * END - DefaultLiveCounter dep mocks + * ====================================== + */ + +/** + * ====================================== + * START - DefaultLiveMap dep mocks + * ====================================== + */ +internal var DefaultLiveMap.LiveMapManager: LiveMapManager + get() = this.getPrivateField("liveMapManager") + set(value) = this.setPrivateField("liveMapManager", value) + +internal fun getDefaultLiveMapWithMockedDeps( + objectId: String = "map:testMap@1", + relaxed: Boolean = false +): DefaultLiveMap { + val defaultLiveMap = DefaultLiveMap.zeroValue(objectId, getDefaultLiveObjectsWithMockedDeps()) + if (relaxed) { + defaultLiveMap.LiveMapManager = mockk(relaxed = true) + } else { + defaultLiveMap.LiveMapManager = spyk(defaultLiveMap.LiveMapManager, recordPrivateCalls = true) + } + return defaultLiveMap +} +/** + * ====================================== + * END - DefaultLiveMap dep mocks + * ====================================== + */ diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt index 619723244..fb26af12d 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt @@ -9,19 +9,19 @@ import io.ably.lib.objects.ObjectMessage import io.ably.lib.objects.ObjectState import io.ably.lib.objects.ObjectValue -internal val dummyObjectDataStringValue = ObjectData(objectId = "object-id", ObjectValue("dummy string")) +internal val dummyObjectDataStringValue = ObjectData(objectId = "object-id", ObjectValue.String("dummy string")) -internal val dummyBinaryObjectValue = ObjectData(objectId = "object-id", ObjectValue(Binary(byteArrayOf(1, 2, 3)))) +internal val dummyBinaryObjectValue = ObjectData(objectId = "object-id", ObjectValue.Binary(Binary(byteArrayOf(1, 2, 3)))) -internal val dummyNumberObjectValue = ObjectData(objectId = "object-id", ObjectValue(42.0)) +internal val dummyNumberObjectValue = ObjectData(objectId = "object-id", ObjectValue.Number(42.0)) -internal val dummyBooleanObjectValue = ObjectData(objectId = "object-id", ObjectValue(true)) +internal val dummyBooleanObjectValue = ObjectData(objectId = "object-id", ObjectValue.Boolean(true)) val dummyJsonObject = JsonObject().apply { addProperty("foo", "bar") } -internal val dummyJsonObjectValue = ObjectData(objectId = "object-id", ObjectValue(dummyJsonObject)) +internal val dummyJsonObjectValue = ObjectData(objectId = "object-id", ObjectValue.JsonObject(dummyJsonObject)) val dummyJsonArray = JsonArray().apply { add(1); add(2); add(3) } -internal val dummyJsonArrayValue = ObjectData(objectId = "object-id", ObjectValue(dummyJsonArray)) +internal val dummyJsonArrayValue = ObjectData(objectId = "object-id", ObjectValue.JsonArray(dummyJsonArray)) internal val dummyObjectMapEntry = ObjectMapEntry( tombstone = false, diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultLiveObjectsTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultLiveObjectsTest.kt new file mode 100644 index 000000000..381ab9b47 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultLiveObjectsTest.kt @@ -0,0 +1,235 @@ +package io.ably.lib.objects.unit.objects + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectCounterOp +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.ObjectsState +import io.ably.lib.objects.ROOT_OBJECT_ID +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.type.livemap.LiveMapEntry +import io.ably.lib.objects.unit.BufferedObjectOperations +import io.ably.lib.objects.unit.ObjectsManager +import io.ably.lib.objects.unit.SyncObjectsDataPool +import io.ably.lib.objects.unit.getDefaultLiveObjectsWithMockedDeps +import io.ably.lib.objects.unit.size +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.ProtocolMessage +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import io.mockk.every + +class DefaultLiveObjectsTest { + + @Test + fun `(RTO4, RTO4a) When channel ATTACHED with HAS_OBJECTS flag true should start sync sequence`() = runTest { + val defaultLiveObjects = getDefaultLiveObjectsWithMockedDeps() + + // RTO4a - If the HAS_OBJECTS flag is 1, the server will shortly perform an OBJECT_SYNC sequence + defaultLiveObjects.handleStateChange(ChannelState.attached, true) + + assertWaiter { defaultLiveObjects.state == ObjectsState.SYNCING } + + // It is expected that the client will start a new sync sequence + verify(exactly = 1) { + defaultLiveObjects.ObjectsManager.startNewSync(null) + } + verify(exactly = 0) { + defaultLiveObjects.ObjectsManager.endSync(any()) + } + } + + @Test + fun `(RTO4, RTO4b) When channel ATTACHED with HAS_OBJECTS flag false should complete sync immediately`() = runTest { + val defaultLiveObjects = getDefaultLiveObjectsWithMockedDeps() + + // Set up some objects in objectPool that should be cleared + val rootObject = defaultLiveObjects.objectsPool.get(ROOT_OBJECT_ID) as DefaultLiveMap + rootObject.data["key1"] = LiveMapEntry(data = ObjectData("testValue1")) + defaultLiveObjects.objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", defaultLiveObjects)) + assertEquals(2, defaultLiveObjects.objectsPool.size(), "RTO4b - Should have 2 objects before state change") + + // RTO4b - If the HAS_OBJECTS flag is 0, the sync sequence must be considered complete immediately + defaultLiveObjects.handleStateChange(ChannelState.attached, false) + + // Verify expected outcomes + assertWaiter { defaultLiveObjects.state == ObjectsState.SYNCED } // RTO4b4 + + verify(exactly = 1) { + defaultLiveObjects.objectsPool.resetToInitialPool(true) + } + verify(exactly = 1) { + defaultLiveObjects.ObjectsManager.endSync(any()) + } + + assertEquals(0, defaultLiveObjects.ObjectsManager.SyncObjectsDataPool.size) // RTO4b3 + assertEquals(0, defaultLiveObjects.ObjectsManager.BufferedObjectOperations.size) // RTO4b5 + assertEquals(1, defaultLiveObjects.objectsPool.size()) // RTO4b1 - Only root remains + assertEquals(rootObject, defaultLiveObjects.objectsPool.get(ROOT_OBJECT_ID)) // points to previously created root object + assertEquals(0, rootObject.data.size) // RTO4b2 - root object must be empty + } + + @Test + fun `(RTO4) When channel ATTACHED from INITIALIZED state should always start sync`() = runTest { + val defaultLiveObjects = getDefaultLiveObjectsWithMockedDeps() + + // Ensure we're in INITIALIZED state + defaultLiveObjects.state = ObjectsState.INITIALIZED + + // RTO4a - Should start sync even with HAS_OBJECTS flag false when in INITIALIZED state + defaultLiveObjects.handleStateChange(ChannelState.attached, false) + + verify(exactly = 1) { + defaultLiveObjects.ObjectsManager.startNewSync(null) + } + verify(exactly = 1) { + defaultLiveObjects.ObjectsManager.endSync(true) // deferStateEvent = true + } + } + + @Test + fun `(RTO5, RTO7) Should delegate OBJECT and OBJECT_SYNC protocolMessage to ObjectManager`() = runTest { + val defaultLiveObjects = getDefaultLiveObjectsWithMockedDeps(relaxed = true) + + // Create test ObjectMessage for OBJECT action + val objectMessage = ObjectMessage( + id = "testId", + timestamp = 1234567890L, + connectionId = "testConnectionId", + operation = ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = "counter:testObject@1", + counterOp = ObjectCounterOp(amount = 5.0) + ), + serial = "serial1", + siteCode = "site1" + ) + // Create ProtocolMessage with OBJECT action + val objectProtocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`).apply { + id = "protocolId1" + channel = "testChannel" + channelSerial = "channelSerial1" + timestamp = 1234567890L + state = arrayOf(objectMessage) + } + // Test OBJECT action delegation + defaultLiveObjects.handle(objectProtocolMessage) + + // Verify that handleObjectMessages was called with the correct parameters + verify(exactly = 1) { + defaultLiveObjects.ObjectsManager.handleObjectMessages(listOf(objectMessage)) + } + + // Create test ObjectMessage for OBJECT_SYNC action + val objectSyncMessage = ObjectMessage( + id = "testSyncId", + timestamp = 1234567890L, + connectionId = "testSyncConnectionId", + objectState = ObjectState( + objectId = "map:testObject@1", + tombstone = false, + siteTimeserials = mapOf("site1" to "syncSerial1"), + ), + serial = "syncSerial1", + siteCode = "site1" + ) + // Create ProtocolMessage with OBJECT_SYNC action + val objectSyncProtocolMessage = ProtocolMessage(ProtocolMessage.Action.object_sync).apply { + id = "protocolId2" + channel = "testChannel" + channelSerial = "syncChannelSerial1" + timestamp = 1234567890L + state = arrayOf(objectSyncMessage) + } + // Test OBJECT_SYNC action delegation + defaultLiveObjects.handle(objectSyncProtocolMessage) + // Verify that handleObjectSyncMessages was called with the correct parameters + verify(exactly = 1) { + defaultLiveObjects.ObjectsManager.handleObjectSyncMessages(listOf(objectSyncMessage), "syncChannelSerial1") + } + } + + @Test + fun `(OM2) Populate objectMessage missing id, timestamp and connectionId from protocolMessage`() = runTest { + val defaultLiveObjects = getDefaultLiveObjectsWithMockedDeps() + + // Capture the ObjectMessages that are passed to ObjectsManager methods + var capturedObjectMessages: List? = null + var capturedObjectSyncMessages: List? = null + + // Mock the ObjectsManager to capture the messages + defaultLiveObjects.ObjectsManager.apply { + every { handleObjectMessages(any>()) } answers { + capturedObjectMessages = firstArg() + } + every { handleObjectSyncMessages(any(), any()) } answers { + capturedObjectSyncMessages = firstArg() + } + } + + // Create ObjectMessage with missing fields (id, timestamp, connectionId) + val objectMessageWithMissingFields = ObjectMessage( + id = null, // OM2a - missing id + timestamp = null, // OM2e - missing timestamp + connectionId = null, // OM2c - missing connectionId + ) + + // Create ProtocolMessage with OBJECT action and populated fields + val objectProtocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`).apply { + id = "protocolId1" + channel = "testChannel" + channelSerial = "channelSerial1" + connectionId = "protocolConnectionId" + timestamp = 1234567890L + state = arrayOf(objectMessageWithMissingFields) + } + + // Test OBJECT action - should populate missing fields + defaultLiveObjects.handle(objectProtocolMessage) + + // Verify that the captured ObjectMessage has populated fields + assertWaiter { capturedObjectMessages != null } + assertEquals(1, capturedObjectMessages!!.size) + + val populatedObjectMessage = capturedObjectMessages!![0] + assertEquals("protocolId1:0", populatedObjectMessage.id) // OM2a - id should be protocolId:index + assertEquals(1234567890L, populatedObjectMessage.timestamp) // OM2e - timestamp from protocol message + assertEquals("protocolConnectionId", populatedObjectMessage.connectionId) // OM2c - connectionId from protocol message + + + // Create ObjectMessage with missing fields for OBJECT_SYNC + val objectSyncMessageWithMissingFields = ObjectMessage( + id = null, // OM2a - missing id + timestamp = null, // OM2e - missing timestamp + connectionId = null, // OM2c - missing connectionId + ) + + // Create ProtocolMessage with OBJECT_SYNC action and populated fields + val objectSyncProtocolMessage = ProtocolMessage(ProtocolMessage.Action.object_sync).apply { + id = "protocolId2" + channel = "testChannel" + channelSerial = "syncChannelSerial1" + connectionId = "protocolConnectionId" + timestamp = 9876543210L + state = arrayOf(objectSyncMessageWithMissingFields) + } + + // Test OBJECT_SYNC action - should populate missing fields + defaultLiveObjects.handle(objectSyncProtocolMessage) + + // Verify that the captured ObjectMessage has populated fields + assertWaiter { capturedObjectSyncMessages != null } + assertEquals(1, capturedObjectSyncMessages!!.size) + + val populatedObjectSyncMessage = capturedObjectSyncMessages!![0] + assertEquals("protocolId2:0", populatedObjectSyncMessage.id) // OM2a - id should be protocolId:index + assertEquals(9876543210L, populatedObjectSyncMessage.timestamp) // OM2e - timestamp from protocol message + assertEquals("protocolConnectionId", populatedObjectSyncMessage.connectionId) // OM2c - connectionId from protocol message + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt new file mode 100644 index 000000000..2d777f3ff --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt @@ -0,0 +1,232 @@ +package io.ably.lib.objects.unit.objects + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.ObjectsState +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.unit.* +import io.ably.lib.objects.unit.getDefaultLiveObjectsWithMockedDeps +import io.mockk.* +import org.junit.Test +import kotlin.test.* + +class ObjectsManagerTest { + + @Test + fun `(RTO5) ObjectsManager should handle object sync messages`() { + val defaultLiveObjects = getDefaultLiveObjectsWithMockedDeps() + assertEquals(ObjectsState.INITIALIZED, defaultLiveObjects.state, "Initial state should be INITIALIZED") + + val objectsManager = defaultLiveObjects.ObjectsManager + + mockZeroValuedObjects() + + // Populate objectsPool with existing objects + val objectsPool = defaultLiveObjects.ObjectsPool + objectsPool.set("map:testObject@1", mockk(relaxed = true)) + objectsPool.set("counter:testObject@4", mockk(relaxed = true)) + + // Incoming object messages + val objectMessage1 = ObjectMessage( + id = "testId1", + objectState = ObjectState( + objectId = "map:testObject@1", // already exists in pool + tombstone = false, + siteTimeserials = mapOf("site1" to "syncSerial1"), + map = ObjectMap(), + ) + ) + val objectMessage2 = ObjectMessage( + id = "testId2", + objectState = ObjectState( + objectId = "counter:testObject@2", // Does not exist in pool + tombstone = false, + siteTimeserials = mapOf("site1" to "syncSerial1"), + counter = ObjectCounter(count = 20.0) + ) + ) + val objectMessage3 = ObjectMessage( + id = "testId3", + objectState = ObjectState( + objectId = "map:testObject@3", // Does not exist in pool + tombstone = false, + siteTimeserials = mapOf("site1" to "syncSerial1"), + map = ObjectMap(), + ) + ) + // Should start and end sync, apply object states, and create new objects for missing ones + objectsManager.handleObjectSyncMessages(listOf(objectMessage1, objectMessage2, objectMessage3), "sync-123:") + + verify(exactly = 1) { + objectsManager.startNewSync("sync-123") + } + verify(exactly = 1) { + objectsManager.endSync(true) // deferStateEvent = true since new sync was started + } + val newlyCreatedObjects = mutableListOf() + verify(exactly = 2) { + objectsManager["createObjectFromState"](capture(newlyCreatedObjects)) + } + assertEquals("counter:testObject@2", newlyCreatedObjects[0].objectId) + assertEquals("map:testObject@3", newlyCreatedObjects[1].objectId) + + assertEquals(ObjectsState.SYNCED, defaultLiveObjects.state, "State should be SYNCED after sync sequence") + // After sync `counter:testObject@4` will be removed from pool + assertNull(objectsPool.get("counter:testObject@4")) + assertEquals(4, objectsPool.size(), "Objects pool should contain 4 objects after sync including root") + assertNotNull(objectsPool.get(ROOT_OBJECT_ID), "Root object should still exist in pool") + val testObject1 = objectsPool.get("map:testObject@1") + assertNotNull(testObject1, "map:testObject@1 should exist in pool after sync") + verify(exactly = 1) { + testObject1.applyObjectSync(any()) + } + val testObject2 = objectsPool.get("counter:testObject@2") + assertNotNull(testObject2, "counter:testObject@2 should exist in pool after sync") + verify(exactly = 1) { + testObject2.applyObjectSync(any()) + } + val testObject3 = objectsPool.get("map:testObject@3") + assertNotNull(testObject3, "map:testObject@3 should exist in pool after sync") + verify(exactly = 1) { + testObject3.applyObjectSync(any()) + } + } + + @Test + fun `(RTO8) ObjectsManager should apply object operation when state is synced`() { + val defaultLiveObjects = getDefaultLiveObjectsWithMockedDeps() + defaultLiveObjects.state = ObjectsState.SYNCED // Ensure we're in SYNCED state + + val objectsManager = defaultLiveObjects.ObjectsManager + + mockZeroValuedObjects() + + // Populate objectsPool with existing objects + val objectsPool = defaultLiveObjects.ObjectsPool + objectsPool.set("map:testObject@1", mockk(relaxed = true)) + + // Incoming object messages with operation field instead of objectState + val objectMessage1 = ObjectMessage( + id = "testId1", + operation = ObjectOperation( + action = ObjectOperationAction.MapSet, // Assuming this is the right action for maps + objectId = "map:testObject@1", // already exists in pool + ), + serial = "serial1", + siteCode = "site1" + ) + + val objectMessage2 = ObjectMessage( + id = "testId2", + operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, // Set the counter value + objectId = "counter:testObject@2", // Does not exist in pool + ), + serial = "serial2", + siteCode = "site1" + ) + + val objectMessage3 = ObjectMessage( + id = "testId3", + operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testObject@3", // Does not exist in pool + ), + serial = "serial3", + siteCode = "site1" + ) + + // RTO8b - Apply messages immediately if synced + objectsManager.handleObjectMessages(listOf(objectMessage1, objectMessage2, objectMessage3)) + assertEquals(0, objectsManager.BufferedObjectOperations.size, "No buffer needed in SYNCED state") + + assertEquals(4, objectsPool.size(), "Objects pool should contain 4 objects including root") + assertNotNull(objectsPool.get(ROOT_OBJECT_ID), "Root object should still exist in pool") + + val testObject1 = objectsPool.get("map:testObject@1") + assertNotNull(testObject1, "map:testObject@1 should exist in pool after sync") + verify(exactly = 1) { + testObject1.applyObject(objectMessage1) + } + val testObject2 = objectsPool.get("counter:testObject@2") + assertNotNull(testObject2, "counter:testObject@2 should exist in pool after sync") + verify(exactly = 1) { + testObject2.applyObject(objectMessage2) + } + val testObject3 = objectsPool.get("map:testObject@3") + assertNotNull(testObject3, "map:testObject@3 should exist in pool after sync") + verify(exactly = 1) { + testObject3.applyObject(objectMessage3) + } + } + + @Test + fun `(RTO7) ObjectsManager should buffer operations when not in sync, apply them after synced`() { + val defaultLiveObjects = getDefaultLiveObjectsWithMockedDeps() + assertEquals(ObjectsState.INITIALIZED, defaultLiveObjects.state, "Initial state should be INITIALIZED") + + val objectsManager = defaultLiveObjects.ObjectsManager + assertEquals(0, objectsManager.BufferedObjectOperations.size, "RTO7a1 - Initial buffer should be empty") + + val objectsPool = defaultLiveObjects.ObjectsPool + assertEquals(1, objectsPool.size(), "RTO7a2 - Initial pool should contain only root object") + + mockZeroValuedObjects() + + // Set state to SYNCING + defaultLiveObjects.state = ObjectsState.SYNCING + + val objectMessage = ObjectMessage( + id = "testId", + operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "counter:testObject@1", + counterOp = ObjectCounterOp(amount = 5.0) + ), + serial = "serial1", + siteCode = "site1" + ) + + // RTO7a - Buffer operations during sync + objectsManager.handleObjectMessages(listOf(objectMessage)) + + verify(exactly = 0) { + objectsManager["applyObjectMessages"](any>()) + } + assertEquals(1, objectsManager.BufferedObjectOperations.size) + assertEquals(objectMessage, objectsManager.BufferedObjectOperations[0]) + assertEquals(1, objectsPool.size(), "Pool should still contain only root object during sync") + + // RTO7 - Apply buffered operations after sync + objectsManager.endSync(false) // End sync without new sync + verify(exactly = 1) { + objectsManager["applyObjectMessages"](any>()) + } + assertEquals(0, objectsManager.BufferedObjectOperations.size) + assertEquals(2, objectsPool.size(), "Pool should contain 2 objects after applying buffered operations") + assertNotNull(objectsPool.get("counter:testObject@1"), "Counter object should be created after sync") + assertTrue(objectsPool.get("counter:testObject@1") is DefaultLiveCounter, "Should create a DefaultLiveCounter object") + } + + private fun mockZeroValuedObjects() { + mockkObject(DefaultLiveMap.Companion) + every { + DefaultLiveMap.zeroValue(any(), any()) + } answers { + mockk(relaxed = true) + } + mockkObject(DefaultLiveCounter.Companion) + every { + DefaultLiveCounter.zeroValue(any(), any()) + } answers { + mockk(relaxed = true) + } + } + + @AfterTest + fun tearDown() { + unmockkAll() // Clean up all mockk objects after each test + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt new file mode 100644 index 000000000..1d1bcb8aa --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt @@ -0,0 +1,132 @@ +package io.ably.lib.objects.unit.objects + +import io.ably.lib.objects.DefaultLiveObjects +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ROOT_OBJECT_ID +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.type.livemap.LiveMapEntry +import io.ably.lib.objects.unit.* +import io.mockk.mockk +import io.mockk.spyk +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ObjectsPoolTest { + + @Test + fun `(RTO3, RTO3a, RTO3b) An internal ObjectsPool should be used to maintain the list of objects present on a channel`() { + val defaultLiveObjects = DefaultLiveObjects("dummyChannel", mockk(relaxed = true)) + val objectsPool = defaultLiveObjects.objectsPool + assertNotNull(objectsPool) + + // RTO3b - It must always contain a LiveMap object with id root + val rootLiveMap = objectsPool.get(ROOT_OBJECT_ID) + assertNotNull(rootLiveMap) + assertTrue(rootLiveMap is DefaultLiveMap) + assertTrue(rootLiveMap.data.isEmpty()) + assertEquals(ROOT_OBJECT_ID, rootLiveMap.objectId) + assertEquals(1, objectsPool.size(), "RTO3 - Should only contain the root object initially") + + // RTO3a - ObjectsPool is a Dict, a map of LiveObjects keyed by objectId string + val testLiveMap = DefaultLiveMap.zeroValue("map:testObject@1", mockk(relaxed = true)) + objectsPool.set("map:testObject@1", testLiveMap) + val testLiveCounter = DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true)) + objectsPool.set("counter:testObject@1", testLiveCounter) + // Assert that the objects are stored in the pool + assertEquals(testLiveMap, objectsPool.get("map:testObject@1")) + assertEquals(testLiveCounter, objectsPool.get("counter:testObject@1")) + assertEquals(3, objectsPool.size(), "RTO3 - Should have 3 objects in pool (root + testLiveMap + testLiveCounter)") + } + + @Test + fun `(RTO6) ObjectsPool should create zero-value objects if not exists`() { + val defaultLiveObjects = DefaultLiveObjects("dummyChannel", mockk(relaxed = true)) + val objectsPool = spyk(defaultLiveObjects.objectsPool) + assertEquals(1, objectsPool.size(), "RTO3 - Should only contain the root object initially") + + // Test creating zero-value map + // RTO6b1, RTO6b2 - Type is parsed from the objectId format (map:hash@timestamp) + val mapId = "map:xyz789@67890" + val map = objectsPool.createZeroValueObjectIfNotExists(mapId) + assertNotNull(map, "Should create a map object") + assertTrue(map is DefaultLiveMap, "RTO6b2 - Should create a LiveMap for map type") + assertEquals(mapId, map.objectId) + assertTrue(map.data.isEmpty(), "RTO6b2 - Should create an empty map") + assertEquals(2, objectsPool.size(), "RTO6 - root + map should be in pool after creation") + + // Test creating zero-value counter + // RTO6b1, RTO6b3 - Type is parsed from the objectId format (counter:hash@timestamp) + val counterId = "counter:abc123@12345" + val counter = objectsPool.createZeroValueObjectIfNotExists(counterId) + assertNotNull(counter, "Should create a counter object") + assertTrue(counter is DefaultLiveCounter, "RTO6b3 - Should create a LiveCounter for counter type") + assertEquals(counterId, counter.objectId) + assertEquals(0.0, counter.data.get(), "RTO6b3 - Should create a zero-value counter") + assertEquals(3, objectsPool.size(), "RTO6 - root + map + counter should be in pool after creation") + + // RTO6a - If object exists in pool, do not create a new one + val existingMap = objectsPool.createZeroValueObjectIfNotExists(mapId) + assertEquals(map, existingMap, "RTO6a - Should return existing object, not create a new one") + val existingCounter = objectsPool.createZeroValueObjectIfNotExists(counterId) + assertEquals(counter, existingCounter, "RTO6a - Should return existing object, not create a new one") + assertEquals(3, objectsPool.size(), "RTO6 - Should still have 3 objects in pool after re-creation attempt") + } + + @Test + fun `(RTO4b1, RTO4b2) ObjectsPool should reset to initial pool retaining original root map`() { + val defaultLiveObjects = DefaultLiveObjects("dummyChannel", mockk(relaxed = true)) + val objectsPool = defaultLiveObjects.objectsPool + assertEquals(1, objectsPool.size()) + val rootMap = objectsPool.get(ROOT_OBJECT_ID) as DefaultLiveMap + // add some data to the root map + rootMap.data["initialKey1"] = LiveMapEntry(data = ObjectData("testValue1")) + rootMap.data["initialKey2"] = LiveMapEntry(data = ObjectData("testValue2")) + assertEquals(2, rootMap.data.size, "RTO3 - Root map should have initial data") + + // Add some objects + objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true))) + assertEquals(2, objectsPool.size()) // root + testObject + objectsPool.set("counter:testObject@2", DefaultLiveCounter.zeroValue("counter:testObject@2", mockk(relaxed = true))) + assertEquals(3, objectsPool.size()) // root + testObject + anotherObject + objectsPool.set("map:testObject@1", DefaultLiveMap.zeroValue("map:testObject@1", mockk(relaxed = true))) + assertEquals(4, objectsPool.size()) // root + testObject + anotherObject + testMap + + // Reset to initial pool + objectsPool.resetToInitialPool(true) + + // RTO4b1 - Should only contain root object + assertEquals(1, objectsPool.size()) + assertEquals(rootMap, objectsPool.get(ROOT_OBJECT_ID)) + // RTO4b2 - RootMap should be empty after reset + assertTrue(rootMap.data.isEmpty(), "RTO3 - Root map should be empty after reset") + } + + @Test + fun `(RTO5c2, RTO5c2a) ObjectsPool should delete extra object IDs`() { + val defaultLiveObjects = DefaultLiveObjects("dummyChannel", mockk(relaxed = true)) + val objectsPool = defaultLiveObjects.objectsPool + + // Add some objects + objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true))) + objectsPool.set("counter:testObject@2", DefaultLiveCounter.zeroValue("counter:testObject@2", mockk(relaxed = true))) + objectsPool.set("counter:testObject@3", DefaultLiveCounter.zeroValue("counter:testObject@3", mockk(relaxed = true))) + assertEquals(4, objectsPool.size()) // root + 3 objects + + // Delete extra object IDs (keep only object1 and object2) + val receivedObjectIds = mutableSetOf("counter:testObject@1", "counter:testObject@2") + objectsPool.deleteExtraObjectIds(receivedObjectIds) + + // Should only contain root, object1, and object2 + assertEquals(3, objectsPool.size()) + // RTO5c2a - Should keep the root object + assertNotNull(objectsPool.get(ROOT_OBJECT_ID)) + // RTO5c2 - Should delete object3 and keep object1 and object2 + assertNotNull(objectsPool.get("counter:testObject@1")) + assertNotNull(objectsPool.get("counter:testObject@2")) + assertNull(objectsPool.get("counter:testObject@3")) // Should be deleted + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseLiveObjectTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseLiveObjectTest.kt new file mode 100644 index 000000000..550108b92 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseLiveObjectTest.kt @@ -0,0 +1,172 @@ +package io.ably.lib.objects.unit.type + +import io.ably.lib.objects.* +import io.ably.lib.objects.type.BaseLiveObject +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.unit.getDefaultLiveObjectsWithMockedDeps +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.assertFailsWith + +class BaseLiveObjectTest { + + private val defaultLiveObjects = getDefaultLiveObjectsWithMockedDeps() + + @Test + fun `(RTLO1, RTLO2) BaseLiveObject should be abstract base class for LiveMap and LiveCounter`() { + // RTLO2 - Check that BaseLiveObject is abstract + val isAbstract = java.lang.reflect.Modifier.isAbstract(BaseLiveObject::class.java.modifiers) + assertTrue(isAbstract, "BaseLiveObject should be an abstract class") + + // RTLO1 - Check that BaseLiveObject is the parent class of DefaultLiveMap and DefaultLiveCounter + assertTrue(BaseLiveObject::class.java.isAssignableFrom(DefaultLiveMap::class.java), + "DefaultLiveMap should extend BaseLiveObject") + assertTrue(BaseLiveObject::class.java.isAssignableFrom(DefaultLiveCounter::class.java), + "DefaultLiveCounter should extend BaseLiveObject") + } + + @Test + fun `(RTLO3) BaseLiveObject should have required properties`() { + val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultLiveObjects) + val liveCounter: BaseLiveObject = DefaultLiveCounter.zeroValue("counter:testObject@1", defaultLiveObjects) + // RTLO3a - check that objectId is set correctly + assertEquals("map:testObject@1", liveMap.objectId) + assertEquals("counter:testObject@1", liveCounter.objectId) + + // RTLO3b, RTLO3b1 - check that siteTimeserials is initialized as an empty map + assertEquals(emptyMap(), liveMap.siteTimeserials) + assertEquals(emptyMap(), liveCounter.siteTimeserials) + + // RTLO3c - Create operation merged flag + assertFalse(liveMap.createOperationIsMerged, "Create operation should not be merged by default") + assertFalse(liveCounter.createOperationIsMerged, "Create operation should not be merged by default") + } + + @Test + fun `(RTLO4a1, RTLO4a2) canApplyOperation should accept ObjectMessage params and return boolean`() { + // RTLO4a1a - Assert parameter types and return type based on method signature using reflection + val method = BaseLiveObject::class.java.findMethod("canApplyOperation") + + // RTLO4a1a - Verify parameter types + val parameters = method.parameters + assertEquals(2, parameters.size, "canApplyOperation should have exactly 2 parameters") + + // First parameter should be String? (siteCode) + assertEquals(String::class.java, parameters[0].type, "First parameter should be of type String?") + assertTrue(parameters[0].isVarArgs.not(), "First parameter should not be varargs") + + // Second parameter should be String? (timeSerial) + assertEquals(String::class.java, parameters[1].type, "Second parameter should be of type String?") + assertTrue(parameters[1].isVarArgs.not(), "Second parameter should not be varargs") + + // RTLO4a2 - Verify return type + assertEquals(Boolean::class.java, method.returnType, "canApplyOperation should return Boolean") + } + + @Test + fun `(RTLO4a3) canApplyOperation should throw error for null or empty incoming siteSerial`() { + val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultLiveObjects) + + // Test null serial + assertFailsWith("Should throw error for null serial") { + liveMap.canApplyOperation("site1", null) + } + + // Test empty serial + assertFailsWith("Should throw error for empty serial") { + liveMap.canApplyOperation("site1", "") + } + + // Test null siteCode + assertFailsWith("Should throw error for null site code") { + liveMap.canApplyOperation(null, "serial1") + } + + // Test empty siteCode + assertFailsWith("Should throw error for empty site code") { + liveMap.canApplyOperation("", "serial1") + } + } + + @Test + fun `(RTLO4a4, RTLO4a5) canApplyOperation should return true when existing siteSerial is null or empty`() { + val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultLiveObjects) + assertTrue(liveMap.siteTimeserials.isEmpty(), "Initial siteTimeserials should be empty") + + // RTLO4a4 - Get siteSerial from siteTimeserials map + // RTLO4a5 - Return true when siteSerial is null (no entry in map) + assertTrue(liveMap.canApplyOperation("site1", "serial1"), + "Should return true when no siteSerial exists for the site") + + // RTLO4a5 - Return true when siteSerial is empty string + liveMap.siteTimeserials["site1"] = "" + assertTrue(liveMap.canApplyOperation("site1", "serial1"), + "Should return true when siteSerial is empty string") + } + + @Test + fun `(RTLO4a6) canApplyOperation should return true when message siteSerial is greater than existing siteSerial`() { + val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultLiveObjects) + + // Set existing siteSerial + liveMap.siteTimeserials["site1"] = "serial1" + + // RTLO4a6 - Return true when message serial is greater (lexicographically) + assertTrue(liveMap.canApplyOperation("site1", "serial2"), + "Should return true when message serial 'serial2' > siteSerial 'serial1'") + + assertTrue(liveMap.canApplyOperation("site1", "serial10"), + "Should return true when message serial 'serial10' > siteSerial 'serial1'") + + assertTrue(liveMap.canApplyOperation("site1", "serialA"), + "Should return true when message serial 'serialA' > siteSerial 'serial1'") + } + + @Test + fun `(RTLO4a6) canApplyOperation should return false when message siteSerial is less than or equal to siteSerial`() { + val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultLiveObjects) + + // Set existing siteSerial + liveMap.siteTimeserials["site1"] = "serial2" + + // RTLO4a6 - Return false when message serial is less than siteSerial + assertFalse(liveMap.canApplyOperation("site1", "serial1"), + "Should return false when message serial 'serial1' < siteSerial 'serial2'") + + // RTLO4a6 - Return false when message serial equals siteSerial + assertFalse(liveMap.canApplyOperation("site1", "serial2"), + "Should return false when message serial equals siteSerial") + + // RTLO4a6 - Return false when message serial is less (lexicographically) + assertTrue(liveMap.canApplyOperation("site1", "serialA"), + "Should return false when message serial 'serialA' < siteSerial 'serial2'") + } + + @Test + fun `(RTLO4a) canApplyOperation should work with different site codes`() { + val liveMap: BaseLiveObject = DefaultLiveCounter.zeroValue("map:testObject@1", defaultLiveObjects) + + // Set serials for different sites + liveMap.siteTimeserials["site1"] = "serial1" + liveMap.siteTimeserials["site2"] = "serial5" + + // Test site1 + assertTrue(liveMap.canApplyOperation("site1", "serial2"), + "Should return true for site1 when serial2 > serial1") + assertFalse(liveMap.canApplyOperation("site1", "serial1"), + "Should return false for site1 when serial1 = serial1") + + // Test site2 + assertTrue(liveMap.canApplyOperation("site2", "serial6"), + "Should return true for site2 when serial6 > serial5") + assertFalse(liveMap.canApplyOperation("site2", "serial4"), + "Should return false for site2 when serial4 < serial5") + + // Test new site (should return true) + assertTrue(liveMap.canApplyOperation("site3", "serial1"), + "Should return true for new site with any serial") + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt new file mode 100644 index 000000000..49d90da22 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt @@ -0,0 +1,115 @@ +package io.ably.lib.objects.unit.type.livecounter + +import io.ably.lib.objects.ObjectCounter +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.unit.getDefaultLiveCounterWithMockedDeps +import io.ably.lib.types.AblyException +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class DefaultLiveCounterTest { + @Test + fun `(RTLC6, RTLC6a) DefaultLiveCounter should override serials with state serials from sync`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") + + // Set initial data + liveCounter.siteTimeserials["site1"] = "serial1" + liveCounter.siteTimeserials["site2"] = "serial2" + + val objectState = ObjectState( + objectId = "counter:testCounter@1", + siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), + tombstone = false, + ) + liveCounter.applyObjectSync(objectState) + assertEquals(mapOf("site3" to "serial3", "site4" to "serial4"), liveCounter.siteTimeserials) // RTLC6a + } + + @Test + fun `(RTLC7, RTLC7a) DefaultLiveCounter should check objectId before applying operation`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "counter:testCounter@2", // Different objectId + counter = ObjectCounter(count = 20.0) + ) + + val message = ObjectMessage( + id = "testId", + operation = operation, + serial = "serial1", + siteCode = "site1" + ) + + // RTLC7a - Should throw error when objectId doesn't match + val exception = assertFailsWith { + liveCounter.applyObject(message) + } + val errorInfo = exception.errorInfo + assertNotNull(errorInfo) + + // Assert on error codes + assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code + assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code + } + + @Test + fun `(RTLC7, RTLC7b) DefaultLiveCounter should validate site serial before applying operation`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") + + // Set existing site serial that is newer than the incoming message + liveCounter.siteTimeserials["site1"] = "serial2" // Newer than "serial1" + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "counter:testCounter@1", // Matching objectId + counter = ObjectCounter(count = 20.0) + ) + + val message = ObjectMessage( + id = "testId", + operation = operation, + serial = "serial1", // Older serial + siteCode = "site1" + ) + + // RTLC7b - Should skip operation when serial is not newer + liveCounter.applyObject(message) + + // Verify that the site serial was not updated (operation was skipped) + assertEquals("serial2", liveCounter.siteTimeserials["site1"]) + } + + @Test + fun `(RTLC7, RTLC7c) DefaultLiveCounter should update site serial if valid`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") + + // Set existing site serial that is older than the incoming message + liveCounter.siteTimeserials["site1"] = "serial1" // Older than "serial2" + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "counter:testCounter@1", // Matching objectId + counter = ObjectCounter(count = 20.0) + ) + + val message = ObjectMessage( + id = "testId", + operation = operation, + serial = "serial2", // Newer serial + siteCode = "site1" + ) + + // RTLC7c - Should update site serial when operation is valid + liveCounter.applyObject(message) + + // Verify that the site serial was updated + assertEquals("serial2", liveCounter.siteTimeserials["site1"]) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt new file mode 100644 index 000000000..133e8ba80 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt @@ -0,0 +1,256 @@ +package io.ably.lib.objects.unit.type.livecounter + +import io.ably.lib.objects.* +import io.ably.lib.objects.unit.LiveCounterManager +import io.ably.lib.objects.unit.getDefaultLiveCounterWithMockedDeps +import io.ably.lib.types.AblyException +import org.junit.Test +import kotlin.test.* + +class DefaultLiveCounterManagerTest { + + @Test + fun `(RTLC6, RTLC6b, RTLC6c) DefaultLiveCounter should override counter data with state from sync`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(10.0) + + val objectState = ObjectState( + objectId = "testCounterId", + counter = ObjectCounter(count = 25.0), + siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), + tombstone = false, + ) + + val update = liveCounterManager.applyState(objectState) + + assertFalse(liveCounter.createOperationIsMerged) // RTLC6b + assertEquals(25.0, liveCounter.data.get()) // RTLC6c + assertEquals(15.0, update["amount"]) // Difference between old and new data + } + + + @Test + fun `(RTLC6, RTLC6d) DefaultLiveCounter should merge create operation in state from sync`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(5.0) + + val createOp = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "testCounterId", + counter = ObjectCounter(count = 10.0) + ) + + val objectState = ObjectState( + objectId = "testCounterId", + counter = ObjectCounter(count = 15.0), + createOp = createOp, + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = false, + ) + + // RTLC6d - Merge initial data from create operation + val update = liveCounterManager.applyState(objectState) + + assertEquals(25.0, liveCounter.data.get()) // 15 from state + 10 from create op + assertEquals(20.0, update["amount"]) // Total change + } + + + @Test + fun `(RTLC7, RTLC7d3) LiveCounterManager should throw error for unsupported action`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, // Unsupported action for counter + objectId = "testCounterId", + map = ObjectMap(semantics = MapSemantics.LWW, entries = emptyMap()) + ) + + // RTLC7d3 - Should throw error for unsupported action + val exception = assertFailsWith { + liveCounterManager.applyOperation(operation) + } + + val errorInfo = exception.errorInfo + assertNotNull(errorInfo) + assertEquals(92000, errorInfo.code) // InvalidObject error code + assertEquals(500, errorInfo.statusCode) // InternalServerError status code + } + + @Test + fun `(RTLC7, RTLC7d1, RTLC8) LiveCounterManager should apply counter create operation`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "testCounterId", + counter = ObjectCounter(count = 20.0) + ) + + // RTLC7d1 - Apply counter create operation + liveCounterManager.applyOperation(operation) + + assertEquals(20.0, liveCounter.data.get()) // Should be set to counter count + assertTrue(liveCounter.createOperationIsMerged) // Should be marked as merged + } + + @Test + fun `(RTLC8, RTLC8b) LiveCounterManager should skip counter create operation if already merged`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + liveCounter.data.set(4.0) // Start with 4 + + // Set create operation as already merged + liveCounter.createOperationIsMerged = true + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "testCounterId", + counter = ObjectCounter(count = 20.0) + ) + + // RTLC8b - Should skip if already merged + liveCounterManager.applyOperation(operation) + + assertEquals(4.0, liveCounter.data.get()) // Should not change (still 0) + assertTrue(liveCounter.createOperationIsMerged) // Should remain merged + } + + @Test + fun `(RTLC8, RTLC8c) LiveCounterManager should apply counter create operation if not merged`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + // Set initial data + liveCounter.data.set(10.0) // Start with 10 + + // Set create operation as not merged + liveCounter.createOperationIsMerged = false + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "testCounterId", + counter = ObjectCounter(count = 20.0) + ) + + // RTLC8c - Should apply if not merged + liveCounterManager.applyOperation(operation) + assertTrue(liveCounter.createOperationIsMerged) // Should be marked as merged + + assertEquals(30.0, liveCounter.data.get()) // Should be set to counter count + assertTrue(liveCounter.createOperationIsMerged) // RTLC10b - Should be marked as merged + } + + @Test + fun `(RTLC8, RTLC10, RTLC10a) LiveCounterManager should handle null count in create operation`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(10.0) + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = "testCounterId", + counter = null // No count specified + ) + + // RTLC10a - Should default to 0 + // RTLC10b - Mark as merged + liveCounterManager.applyOperation(operation) + + assertEquals(10.0, liveCounter.data.get()) // No change (null defaults to 0) + assertTrue(liveCounter.createOperationIsMerged) // RTLC10b + } + + @Test + fun `(RTLC7, RTLC7d2, RTLC9) LiveCounterManager should apply counter increment operation`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(10.0) + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = "testCounterId", + counterOp = ObjectCounterOp(amount = 5.0) + ) + + // RTLC7d2 - Apply counter increment operation + liveCounterManager.applyOperation(operation) + + assertEquals(15.0, liveCounter.data.get()) // RTLC9b - 10 + 5 + } + + @Test + fun `(RTLC7, RTLC7d2) LiveCounterManager should throw error for missing payload for counter increment operation`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = "testCounterId", + counterOp = null // Missing payload + ) + + // RTLC7d2 - Should throw error for missing payload + val exception = assertFailsWith { + liveCounterManager.applyOperation(operation) + } + + val errorInfo = exception.errorInfo + assertNotNull(errorInfo) + assertEquals(92000, errorInfo.code) // InvalidObject error code + assertEquals(500, errorInfo.statusCode) // InternalServerError status code + } + + + @Test + fun `(RTLC9, RTLC9b) LiveCounterManager should apply counter increment operation correctly`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(10.0) + + val counterOp = ObjectCounterOp(amount = 7.0) + + // RTLC9b - Apply counter increment + liveCounterManager.applyOperation(ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = "testCounterId", + counterOp = counterOp + )) + + assertEquals(17.0, liveCounter.data.get()) // 10 + 7 + } + + @Test + fun `(RTLC9, RTLC9b) LiveCounterManager should handle null amount in counter increment`() { + val liveCounter = getDefaultLiveCounterWithMockedDeps() + val liveCounterManager = liveCounter.LiveCounterManager + + // Set initial data + liveCounter.data.set(10.0) + + val counterOp = ObjectCounterOp(amount = null) // Null amount + + // RTLC9b - Apply counter increment with null amount + liveCounterManager.applyOperation(ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = "testCounterId", + counterOp = counterOp + )) + + assertEquals(10.0, liveCounter.data.get()) // Should not change (null defaults to 0) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt new file mode 100644 index 000000000..c071f6395 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt @@ -0,0 +1,128 @@ +package io.ably.lib.objects.unit.type.livemap + +import io.ably.lib.objects.MapSemantics +import io.ably.lib.objects.ObjectMap +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.unit.* +import io.ably.lib.types.AblyException +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class DefaultLiveMapTest { + @Test + fun `(RTLM6, RTLM6a) DefaultLiveMap should override serials with state serials from sync`() { + val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") + + // Set initial data + liveMap.siteTimeserials["site1"] = "serial1" + liveMap.siteTimeserials["site2"] = "serial2" + + val objectState = ObjectState( + objectId = "map:testMap@1", + siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), + tombstone = false, + map = ObjectMap( + semantics = MapSemantics.LWW, + ) + ) + liveMap.applyObjectSync(objectState) + assertEquals(mapOf("site3" to "serial3", "site4" to "serial4"), liveMap.siteTimeserials) // RTLM6a + } + + @Test + fun `(RTLM15, RTLM15a) DefaultLiveMap should check objectId before applying operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@2", // Different objectId + map = ObjectMap( + semantics = MapSemantics.LWW, + entries = emptyMap() + ) + ) + + val message = ObjectMessage( + id = "testId", + operation = operation, + serial = "serial1", + siteCode = "site1" + ) + + // RTLM15a - Should throw error when objectId doesn't match + val exception = assertFailsWith { + liveMap.applyObject(message) + } + val errorInfo = exception.errorInfo + assertNotNull(errorInfo) + + // Assert on error codes + assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code + assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code + } + + @Test + fun `(RTLM15, RTLM15b) DefaultLiveMap should validate site serial before applying operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") + + // Set existing site serial that is newer than the incoming message + liveMap.siteTimeserials["site1"] = "serial2" // Newer than "serial1" + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", // Matching objectId + map = ObjectMap( + semantics = MapSemantics.LWW, + entries = emptyMap() + ) + ) + + val message = ObjectMessage( + id = "testId", + operation = operation, + serial = "serial1", // Older serial + siteCode = "site1" + ) + + // RTLM15b - Should skip operation when serial is not newer + liveMap.applyObject(message) + + // Verify that the site serial was not updated (operation was skipped) + assertEquals("serial2", liveMap.siteTimeserials["site1"]) + } + + @Test + fun `(RTLM15, RTLM15c) DefaultLiveMap should update site serial if valid`() { + val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") + + // Set existing site serial that is older than the incoming message + liveMap.siteTimeserials["site1"] = "serial1" // Older than "serial2" + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", // Matching objectId + map = ObjectMap( + semantics = MapSemantics.LWW, + entries = emptyMap() + ) + ) + + val message = ObjectMessage( + id = "testId", + operation = operation, + serial = "serial2", // Newer serial + siteCode = "site1" + ) + + // RTLM15c - Should update site serial when operation is valid + liveMap.applyObject(message) + + // Verify that the site serial was updated + assertEquals("serial2", liveMap.siteTimeserials["site1"]) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt new file mode 100644 index 000000000..418de2609 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt @@ -0,0 +1,819 @@ +package io.ably.lib.objects.unit.type.livemap + +import io.ably.lib.objects.* +import io.ably.lib.objects.type.livemap.LiveMapEntry +import io.ably.lib.objects.type.livemap.LiveMapManager +import io.ably.lib.objects.unit.LiveMapManager +import io.ably.lib.objects.unit.getDefaultLiveMapWithMockedDeps +import io.ably.lib.types.AblyException +import io.mockk.mockk +import org.junit.Test +import org.junit.Assert.* +import kotlin.test.* + +class LiveMapManagerTest { + + private val livemapManager = LiveMapManager(mockk(relaxed = true)) + + @Test + fun `(RTLM6, RTLM6b, RTLM6c) DefaultLiveMap should override map data with state from sync`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("oldValue")) + ) + + val objectState = ObjectState( + objectId = "map:testMap@1", + map = ObjectMap( + semantics = MapSemantics.LWW, + entries = mapOf( + "key1" to ObjectMapEntry( + data = ObjectData(value = ObjectValue.String("newValue1")), + timeserial = "serial1" + ), + "key2" to ObjectMapEntry( + data = ObjectData(value = ObjectValue.String("value2")), + timeserial = "serial2" + ) + ) + ), + siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), + tombstone = false, + ) + + val update = liveMapManager.applyState(objectState) + + assertFalse(liveMap.createOperationIsMerged) // RTLM6b + assertEquals(2, liveMap.data.size) // RTLM6c + assertEquals("newValue1", liveMap.data["key1"]?.data?.value?.value) // RTLM6c + assertEquals("value2", liveMap.data["key2"]?.data?.value?.value) // RTLM6c + + // Assert on update field - should show changes from old to new state + val expectedUpdate = mapOf( + "key1" to "updated", // key1 was updated from "oldValue" to "newValue1" + "key2" to "updated" // key2 was added + ) + assertEquals(expectedUpdate, update) + } + + @Test + fun `(RTLM6, RTLM6c) DefaultLiveMap should handle empty map entries in state`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("oldValue")) + ) + + val objectState = ObjectState( + objectId = "map:testMap@1", + map = ObjectMap( + semantics = MapSemantics.LWW, + entries = emptyMap() // Empty map entries + ), + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = false, + ) + + val update = liveMapManager.applyState(objectState) + + assertFalse(liveMap.createOperationIsMerged) // RTLM6b + assertEquals(0, liveMap.data.size) // RTLM6c - should be empty map + + // Assert on update field - should show that key1 was removed + val expectedUpdate = mapOf("key1" to "removed") + assertEquals(expectedUpdate, update) + } + + @Test + fun `(RTLM6, RTLM6c) DefaultLiveMap should handle null map in state`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("oldValue")) + ) + + val objectState = ObjectState( + objectId = "map:testMap@1", + map = null, // Null map + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = false, + ) + + val update = liveMapManager.applyState(objectState) + + assertFalse(liveMap.createOperationIsMerged) // RTLM6b + assertEquals(0, liveMap.data.size) // RTLM6c - should be empty map when map is null + + // Assert on update field - should show that key1 was removed + val expectedUpdate = mapOf("key1" to "removed") + assertEquals(expectedUpdate, update) + } + + @Test + fun `(RTLM6, RTLM6d) DefaultLiveMap should merge initial data from create operation from state in sync`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val createOp = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", + map = ObjectMap( + semantics = MapSemantics.LWW, + entries = mapOf( + "key1" to ObjectMapEntry( + data = ObjectData(value = ObjectValue.String("createValue")), + timeserial = "serial1" + ), + "key2" to ObjectMapEntry( + data = ObjectData(value = ObjectValue.String("newValue")), + timeserial = "serial2" + ) + ) + ) + ) + + val objectState = ObjectState( + objectId = "map:testMap@1", + map = ObjectMap( + semantics = MapSemantics.LWW, + entries = mapOf( + "key1" to ObjectMapEntry( + data = ObjectData(value = ObjectValue.String("stateValue")), + timeserial = "serial3" + ) + ) + ), + createOp = createOp, + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = false, + ) + + // RTLM6d - Merge initial data from create operation + val update = liveMapManager.applyState(objectState) + + assertEquals(2, liveMap.data.size) // Should have both state and create op entries + assertEquals("stateValue", liveMap.data["key1"]?.data?.value?.value) // State value takes precedence + assertEquals("newValue", liveMap.data["key2"]?.data?.value?.value) // Create op value + + // Assert on update field - should show changes from create operation + val expectedUpdate = mapOf( + "key1" to "updated", // key1 was updated from "existingValue" to "stateValue" + "key2" to "updated" // key2 was added from create operation + ) + assertEquals(expectedUpdate, update) + } + + + @Test + fun `(RTLM15, RTLM15d1, RTLM16) LiveMapManager should apply map create operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", + map = ObjectMap( + semantics = MapSemantics.LWW, + entries = mapOf( + "key1" to ObjectMapEntry( + data = ObjectData(value = ObjectValue.String("value1")), + timeserial = "serial1" + ), + "key2" to ObjectMapEntry( + data = ObjectData(value = ObjectValue.String("value2")), + timeserial = "serial2" + ) + ) + ) + ) + + // RTLM15d1 - Apply map create operation + liveMapManager.applyOperation(operation, "serial1") + + assertEquals(2, liveMap.data.size) // Should have both entries + assertEquals("value1", liveMap.data["key1"]?.data?.value?.value) // Should have value1 + assertEquals("value2", liveMap.data["key2"]?.data?.value?.value) // Should have value2 + assertTrue(liveMap.createOperationIsMerged) // Should be marked as merged + } + + @Test + fun `(RTLM15, RTLM15d2, RTLM7) LiveMapManager should apply map set operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial1", + data = ObjectData(value = ObjectValue.String("oldValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM15d2 - Apply map set operation + liveMapManager.applyOperation(operation, "serial2") + + assertEquals("newValue", liveMap.data["key1"]?.data?.value?.value) // RTLM7a2a + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM7a2b + assertFalse(liveMap.data["key1"]?.isTombstoned == true) // RTLM7a2c + } + + @Test + fun `(RTLM15, RTLM15d3, RTLM8) LiveMapManager should apply map remove operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapRemove, + objectId = "map:testMap@1", + mapOp = ObjectMapOp(key = "key1") + ) + + // RTLM15d3 - Apply map remove operation + liveMapManager.applyOperation(operation, "serial2") + + assertNull(liveMap.data["key1"]?.data) // RTLM8a2a + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM8a2b + assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM8a2c + } + + @Test + fun `(RTLM15, RTLM15d4) LiveMapManager should throw error for unsupported action`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + val operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, // Unsupported action for map + objectId = "map:testMap@1", + counter = ObjectCounter(count = 20.0) + ) + + // RTLM15d4 - Should throw error for unsupported action + val exception = assertFailsWith { + liveMapManager.applyOperation(operation, "serial1") + } + + val errorInfo = exception.errorInfo + assertNotNull(errorInfo, "Error info should not be null") + assertEquals(92000, errorInfo?.code) // InvalidObject error code + assertEquals(500, errorInfo?.statusCode) // InternalServerError status code + } + + @Test + fun `(RTLM16, RTLM16b) LiveMapManager should skip map create operation if already merged`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set create operation as already merged + liveMap.createOperationIsMerged = true + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", + map = ObjectMap( + semantics = MapSemantics.LWW, + entries = mapOf( + "key1" to ObjectMapEntry( + data = ObjectData(value = ObjectValue.String("value1")), + timeserial = "serial1" + ) + ) + ) + ) + + // RTLM16b - Should skip if already merged + liveMapManager.applyOperation(operation, "serial1") + + assertEquals(0, liveMap.data.size) // Should not change (still empty) + assertTrue(liveMap.createOperationIsMerged) // Should remain merged + } + + + + @Test + fun `(RTLM16, RTLM16d, RTLM17) LiveMapManager should merge initial data from create operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial1", + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", + map = ObjectMap( + semantics = MapSemantics.LWW, + entries = mapOf( + "key1" to ObjectMapEntry( + data = ObjectData(value = ObjectValue.String("createValue")), + timeserial = "serial2" + ), + "key2" to ObjectMapEntry( + data = ObjectData(value = ObjectValue.String("newValue")), + timeserial = "serial3" + ), + "key3" to ObjectMapEntry( + data = null, + timeserial = "serial4", + tombstone = true + ) + ) + ) + ) + + // RTLM16d - Merge initial data from create operation + liveMapManager.applyOperation(operation, "serial1") + + assertEquals(3, liveMap.data.size) // Should have all entries + assertEquals("createValue", liveMap.data["key1"]?.data?.value?.value) // RTLM17a1 - Should be updated + assertEquals("newValue", liveMap.data["key2"]?.data?.value?.value) // RTLM17a1 - Should be added + assertTrue(liveMap.data["key3"]?.isTombstoned == true) // RTLM17a2 - Should be tombstoned + assertTrue(liveMap.createOperationIsMerged) // RTLM17b - Should be marked as merged + } + + @Test + fun `(RTLM7, RTLM7b) LiveMapManager should create new entry for map set operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectMapOp( + key = "newKey", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM7b - Create new entry + liveMapManager.applyOperation(operation, "serial1") + + assertEquals(1, liveMap.data.size) // Should have one entry + assertEquals("newValue", liveMap.data["newKey"]?.data?.value?.value) // RTLM7b1 + assertEquals("serial1", liveMap.data["newKey"]?.timeserial) // Should have serial + assertFalse(liveMap.data["newKey"]?.isTombstoned == true) // RTLM7b2 + } + + @Test + fun `(RTLM7, RTLM7a) LiveMapManager should skip map set operation with lower serial`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with higher serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial2", // Higher than "serial1" + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM7a - Should skip operation with lower serial + liveMapManager.applyOperation(operation, "serial1") + + assertEquals("existingValue", liveMap.data["key1"]?.data?.value?.value) // Should not change + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial + } + + @Test + fun `(RTLM8, RTLM8b) LiveMapManager should create tombstoned entry for map remove operation`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + val operation = ObjectOperation( + action = ObjectOperationAction.MapRemove, + objectId = "map:testMap@1", + mapOp = ObjectMapOp(key = "nonExistingKey") + ) + + // RTLM8b - Create tombstoned entry for non-existing key + liveMapManager.applyOperation(operation, "serial1") + + assertEquals(1, liveMap.data.size) // Should have one entry + assertNull(liveMap.data["nonExistingKey"]?.data) // RTLM8b1 + assertEquals("serial1", liveMap.data["nonExistingKey"]?.timeserial) // Should have serial + assertTrue(liveMap.data["nonExistingKey"]?.isTombstoned == true) // RTLM8b2 + } + + @Test + fun `(RTLM8, RTLM8a) LiveMapManager should skip map remove operation with lower serial`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with higher serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial2", // Higher than "serial1" + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapRemove, + objectId = "map:testMap@1", + mapOp = ObjectMapOp(key = "key1") + ) + + // RTLM8a - Should skip operation with lower serial + liveMapManager.applyOperation(operation, "serial1") + + assertEquals("existingValue", liveMap.data["key1"]?.data?.value?.value) // Should not change + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial + assertFalse(liveMap.data["key1"]?.isTombstoned == true) // Should not be tombstoned + } + + @Test + fun `(RTLM9, RTLM9b) LiveMapManager should handle null serials correctly`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with null serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = null, + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM9b - Both null serials should be treated as equal + liveMapManager.applyOperation(operation, null) + + assertEquals("existingValue", liveMap.data["key1"]?.data?.value?.value) // Should not change + } + + @Test + fun `(RTLM9, RTLM9d) LiveMapManager should apply operation with serial when entry has null serial`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with null serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = null, + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM9d - Operation serial is greater than missing entry serial + liveMapManager.applyOperation(operation, "serial1") + + assertEquals("newValue", liveMap.data["key1"]?.data?.value?.value) // Should be updated + assertEquals("serial1", liveMap.data["key1"]?.timeserial) // Should have new serial + } + + @Test + fun `(RTLM9, RTLM9c) LiveMapManager should skip operation with null serial when entry has serial`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial1", + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM9c - Missing operation serial is lower than existing entry serial + liveMapManager.applyOperation(operation, null) + + assertEquals("existingValue", liveMap.data["key1"]?.data?.value?.value) // Should not change + assertEquals("serial1", liveMap.data["key1"]?.timeserial) // Should keep original serial + } + + @Test + fun `(RTLM9, RTLM9e) LiveMapManager should apply operation with higher serial`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with lower serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial1", + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM9e - Higher serial should be applied + liveMapManager.applyOperation(operation, "serial2") + + assertEquals("newValue", liveMap.data["key1"]?.data?.value?.value) // Should be updated + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should have new serial + } + + @Test + fun `(RTLM9, RTLM9e) LiveMapManager should skip operation with lower serial`() { + val liveMap = getDefaultLiveMapWithMockedDeps() + val liveMapManager = liveMap.LiveMapManager + + // Set initial data with higher serial + liveMap.data["key1"] = LiveMapEntry( + isTombstoned = false, + timeserial = "serial2", + data = ObjectData(value = ObjectValue.String("existingValue")) + ) + + val operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = "map:testMap@1", + mapOp = ObjectMapOp( + key = "key1", + data = ObjectData(value = ObjectValue.String("newValue")) + ) + ) + + // RTLM9e - Lower serial should be skipped + liveMapManager.applyOperation(operation, "serial1") + + assertEquals("existingValue", liveMap.data["key1"]?.data?.value?.value) // Should not change + assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial + } + + @Test + fun `(RTLM16, RTLM16c) DefaultLiveMap should throw error for mismatched semantics`() { + val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") + val liveMapManager = liveMap.LiveMapManager + + val operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "map:testMap@1", + map = ObjectMap( + semantics = MapSemantics.Unknown, // This should match, but we'll test error case + entries = emptyMap() + ) + ) + + val exception = assertFailsWith { + liveMapManager.applyOperation(operation, "serial1") + } + + val errorInfo = exception.errorInfo + kotlin.test.assertNotNull(errorInfo, "Error info should not be null") // RTLM16c + + // Assert on error codes + kotlin.test.assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code + kotlin.test.assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code + } + + @Test + fun shouldCalculateMapDifferenceCorrectly() { + // Test case 1: No changes + val prevData1 = mapOf() + val newData1 = mapOf() + val result1 = livemapManager.calculateUpdateFromDataDiff(prevData1, newData1) + assertEquals(emptyMap(), result1, "Should return empty map for no changes") + + // Test case 2: Entry added + val prevData2 = mapOf() + val newData2 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val result2 = livemapManager.calculateUpdateFromDataDiff(prevData2, newData2) + assertEquals(mapOf("key1" to "updated"), result2, "Should detect added entry") + + // Test case 3: Entry removed + val prevData3 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val newData3 = mapOf() + val result3 = livemapManager.calculateUpdateFromDataDiff(prevData3, newData3) + assertEquals(mapOf("key1" to "removed"), result3, "Should detect removed entry") + + // Test case 4: Entry updated + val prevData4 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val newData4 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "2", + data = ObjectData(value = ObjectValue.String("value2")) + ) + ) + val result4 = livemapManager.calculateUpdateFromDataDiff(prevData4, newData4) + assertEquals(mapOf("key1" to "updated"), result4, "Should detect updated entry") + + // Test case 5: Entry tombstoned + val prevData5 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val newData5 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = true, + timeserial = "2", + data = null + ) + ) + val result5 = livemapManager.calculateUpdateFromDataDiff(prevData5, newData5) + assertEquals(mapOf("key1" to "removed"), result5, "Should detect tombstoned entry") + + // Test case 6: Entry untombstoned + val prevData6 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = true, + timeserial = "1", + data = null + ) + ) + val newData6 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "2", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val result6 = livemapManager.calculateUpdateFromDataDiff(prevData6, newData6) + assertEquals(mapOf("key1" to "updated"), result6, "Should detect untombstoned entry") + + // Test case 7: Both entries tombstoned (noop) + val prevData7 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = true, + timeserial = "1", + data = null + ) + ) + val newData7 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = true, + timeserial = "2", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val result7 = livemapManager.calculateUpdateFromDataDiff(prevData7, newData7) + assertEquals(emptyMap(), result7, "Should not detect change for both tombstoned entries") + + // Test case 8: New tombstoned entry (noop) + val prevData8 = mapOf() + val newData8 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = true, + timeserial = "1", + data = null + ) + ) + val result8 = livemapManager.calculateUpdateFromDataDiff(prevData8, newData8) + assertEquals(emptyMap(), result8, "Should not detect change for new tombstoned entry") + + // Test case 9: Multiple changes + val prevData9 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value1")) + ), + "key2" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value2")) + ) + ) + val newData9 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "2", + data = ObjectData(value = ObjectValue.String("value1_updated")) + ), + "key3" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value3")) + ) + ) + val result9 = livemapManager.calculateUpdateFromDataDiff(prevData9, newData9) + val expected9 = mapOf( + "key1" to "updated", + "key2" to "removed", + "key3" to "updated" + ) + assertEquals(expected9, result9, "Should detect multiple changes correctly") + + // Test case 10: ObjectId references + val prevData10 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(objectId = "obj1") + ) + ) + val newData10 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(objectId = "obj2") + ) + ) + val result10 = livemapManager.calculateUpdateFromDataDiff(prevData10, newData10) + assertEquals(mapOf("key1" to "updated"), result10, "Should detect objectId change") + + // Test case 11: Same data, no change + val prevData11 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "1", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val newData11 = mapOf( + "key1" to LiveMapEntry( + isTombstoned = false, + timeserial = "2", + data = ObjectData(value = ObjectValue.String("value1")) + ) + ) + val result11 = livemapManager.calculateUpdateFromDataDiff(prevData11, newData11) + assertEquals(emptyMap(), result11, "Should not detect change for same data") + } +}