From 111e15a5c09d6122139ecc265d7ea2f48a711dc9 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 15 Jul 2025 17:56:13 +0530 Subject: [PATCH 01/19] [ECO-5076] Implemented code for getRoot method 1. Added getChannelModes and getChannelState methods to adapter 2. Added extension helper methods to adapter to ensure channel config. is valid --- .../java/io/ably/lib/objects/Adapter.java | 31 +++++++++++++++++++ .../ably/lib/objects/LiveObjectsAdapter.java | 22 +++++++++++++ .../io/ably/lib/realtime/ChannelBase.java | 4 +++ .../io/ably/lib/objects/DefaultLiveObjects.kt | 26 +++++++++++----- .../kotlin/io/ably/lib/objects/ErrorCodes.kt | 3 ++ .../kotlin/io/ably/lib/objects/Helpers.kt | 23 ++++++++++++++ .../kotlin/io/ably/lib/objects/ObjectsPool.kt | 14 ++------- .../main/kotlin/io/ably/lib/objects/Utils.kt | 25 +++++++++++++++ 8 files changed, 128 insertions(+), 20 deletions(-) diff --git a/lib/src/main/java/io/ably/lib/objects/Adapter.java b/lib/src/main/java/io/ably/lib/objects/Adapter.java index de6afbe3d..804fa59c8 100644 --- a/lib/src/main/java/io/ably/lib/objects/Adapter.java +++ b/lib/src/main/java/io/ably/lib/objects/Adapter.java @@ -1,8 +1,11 @@ package io.ably.lib.objects; import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.ChannelState; import io.ably.lib.realtime.CompletionListener; import io.ably.lib.types.AblyException; +import io.ably.lib.types.ChannelMode; +import io.ably.lib.types.ChannelOptions; import io.ably.lib.types.ProtocolMessage; import io.ably.lib.util.Log; import org.jetbrains.annotations.NotNull; @@ -34,4 +37,32 @@ public void send(@NotNull ProtocolMessage msg, @NotNull CompletionListener liste public int maxMessageSizeLimit() { return ably.connection.connectionManager.maxMessageSize; } + + @Override + public ChannelMode[] getChannelModes(@NotNull String channelName) { + if (ably.channels.containsKey(channelName)) { + // RTO2a - channel.modes is only populated on channel attachment, so use it only if it is set + ChannelMode[] modes = ably.channels.get(channelName).getModes(); + if (modes != null) { + return modes; + } + // RTO2b - otherwise as a best effort use user provided channel options + ChannelOptions options = ably.channels.get(channelName).getOptions(); + if (options != null && options.hasModes()) { + return options.modes; + } + return null; + } + Log.e(TAG, "getChannelMode(): channel not found: " + channelName); + return null; + } + + @Override + public ChannelState getChannelState(@NotNull String channelName) { + if (ably.channels.containsKey(channelName)) { + return ably.channels.get(channelName).state; + } + Log.e(TAG, "getChannelState(): channel not found: " + channelName); + return null; + } } diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java b/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java index e6b1f2204..690bc7495 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java @@ -1,9 +1,12 @@ package io.ably.lib.objects; +import io.ably.lib.realtime.ChannelState; import io.ably.lib.realtime.CompletionListener; import io.ably.lib.types.AblyException; +import io.ably.lib.types.ChannelMode; import io.ably.lib.types.ProtocolMessage; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; public interface LiveObjectsAdapter { /** @@ -31,5 +34,24 @@ public interface LiveObjectsAdapter { * @return the maximum message size limit in bytes. */ int maxMessageSizeLimit(); + + /** + * Retrieves the channel modes for a specific channel. + * This method returns the modes that are set for the specified channel. + * + * @param channelName the name of the channel for which to retrieve the modes + * @return the array of channel modes for the specified channel, or null if the channel is not found + * Spec: RTO2a, RTO2b + */ + @Nullable ChannelMode[] getChannelModes(@NotNull String channelName); + + /** + * Retrieves the current state of a specific channel. + * This method returns the state of the specified channel, which indicates its connection status. + * + * @param channelName the name of the channel for which to retrieve the state + * @return the current state of the specified channel, or null if the channel is not found + */ + @Nullable ChannelState getChannelState(@NotNull String channelName); } 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 3f7062d36..132450973 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java @@ -1289,6 +1289,10 @@ public ChannelMode[] getModes() { return modes.toArray(new ChannelMode[modes.size()]); } + public ChannelOptions getOptions() { + return options; + } + /************************************ * internal general * @throws AblyException 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 66682f793..5fbd70530 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 @@ -41,6 +41,11 @@ internal class DefaultLiveObjects(private val channelName: String, internal val private val sequentialScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(channelName) + SupervisorJob()) + /** + * Coroutine scope for handling callbacks asynchronously. + */ + private val callbackScope = CoroutineScope(Dispatchers.Default + CoroutineName("LiveObjectsCallback-$channelName")) + /** * Event bus for handling incoming object messages sequentially. */ @@ -51,11 +56,12 @@ internal class DefaultLiveObjects(private val channelName: String, internal val incomingObjectsHandler = initializeHandlerForIncomingObjectMessages() } - /** - * @spec RTO1 - Returns the root LiveMap object with proper validation and sync waiting - */ override fun getRoot(): LiveMap { - TODO("Not yet implemented") + return runBlocking { getRootAsync() } + } + + override fun getRootAsync(callback: Callback) { + callbackScope.with(callback) { getRootAsync() } } override fun createMap(liveMap: LiveMap): LiveMap { @@ -70,10 +76,6 @@ internal class DefaultLiveObjects(private val channelName: String, internal val TODO("Not yet implemented") } - override fun getRootAsync(callback: Callback) { - TODO("Not yet implemented") - } - override fun createMapAsync(liveMap: LiveMap, callback: Callback) { TODO("Not yet implemented") } @@ -94,6 +96,14 @@ internal class DefaultLiveObjects(private val channelName: String, internal val TODO("Not yet implemented") } + private suspend fun getRootAsync(): LiveMap { + return sequentialScope.async { + adapter.throwIfInvalidAccessApiConfiguration(channelName) + // TODO - wait for state in synced state + objectsPool.get(ROOT_OBJECT_ID) as LiveMap + }.await() + } + /** * Handles a ProtocolMessage containing proto action as `object` or `object_sync`. * @spec RTL1 - Processes incoming object messages and object sync messages 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 35b6c3ad2..5608491a3 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 @@ -8,6 +8,9 @@ internal enum class ErrorCode(public val code: Int) { // LiveMap specific error codes MapKeyShouldBeString(40_003), MapValueDataTypeUnsupported(40_013), + // Channel mode and state validation error codes + ChannelModeRequired(40_024), + ChannelStateError(90_001), } 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 30cb3ed38..1557e46cb 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 @@ -1,6 +1,8 @@ package io.ably.lib.objects +import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.CompletionListener +import io.ably.lib.types.ChannelMode import io.ably.lib.types.ErrorInfo import io.ably.lib.types.ProtocolMessage import kotlinx.coroutines.suspendCancellableCoroutine @@ -39,6 +41,27 @@ internal fun LiveObjectsAdapter.setChannelSerial(channelName: String, protocolMe setChannelSerial(channelName, channelSerial) } +internal fun LiveObjectsAdapter.throwIfInvalidAccessApiConfiguration(channelName: String) { + throwIfMissingChannelMode(channelName, ChannelMode.object_subscribe) + throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed)) +} + +// Spec: RTO2 +internal fun LiveObjectsAdapter.throwIfMissingChannelMode(channelName: String, channelMode: ChannelMode) { + val channelModes = getChannelModes(channelName) + if (channelModes == null || !channelModes.contains(channelMode)) { + // Spec: RTO2a2, RTO2b2 + throw ablyException("\"${channelMode.name}\" channel mode must be set for this operation", ErrorCode.ChannelModeRequired) + } +} + +internal fun LiveObjectsAdapter.throwIfInChannelState(channelName: String, channelStates: Array) { + val currentState = getChannelState(channelName) + if (currentState == null || channelStates.contains(currentState)) { + throw ablyException("Channel is in invalid state: $currentState", ErrorCode.ChannelStateError) + } +} + internal enum class ProtocolMessageFormat(private val value: String) { Msgpack("msgpack"), Json("json"); 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 index 16eb1da65..2bf8ae421 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt @@ -48,22 +48,12 @@ internal class ObjectsPool( private var gcJob: Job // Job for the garbage collection coroutine init { - // Initialize pool with root object - createInitialPool() + // RTO3b - Initialize pool with root object + pool[ROOT_OBJECT_ID] = DefaultLiveMap.zeroValue(ROOT_OBJECT_ID, adapter, this) // Start garbage collection coroutine gcJob = startGCJob() } - /** - * Creates the initial pool with root object. - * - * @spec RTO3b - Creates root LiveMap object - */ - private fun createInitialPool() { - val root = DefaultLiveMap.zeroValue(ROOT_OBJECT_ID, adapter, this) - pool[ROOT_OBJECT_ID] = root - } - /** * Gets a live object from the pool by object ID. */ 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 35bd4cefa..e472d73d0 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 @@ -1,7 +1,9 @@ package io.ably.lib.objects import io.ably.lib.types.AblyException +import io.ably.lib.types.Callback import io.ably.lib.types.ErrorInfo +import kotlinx.coroutines.* internal fun ablyException( errorMessage: String, @@ -44,3 +46,26 @@ internal fun objectError(errorMessage: String, cause: Throwable? = null): AblyEx */ internal val String.byteSize: Int get() = this.toByteArray(Charsets.UTF_8).size + +/** + * Executes a suspend function within a coroutine and handles the result via a callback. + * + * This utility bridges between coroutine-based implementation code and callback-based APIs. + * It launches a coroutine in the current scope to execute the provided suspend block, + * then routes the result or any error to the appropriate callback method. + * + * @param T The type of result expected from the operation + * @param callback The callback to invoke with the operation result or error + * @param block The suspend function to execute that returns a value of type T + */ +internal fun CoroutineScope.with(callback: Callback, block: suspend () -> T) { + launch { + try { + val result = block() + callback.onSuccess(result) + } catch (throwable: Throwable) { + val exception = throwable as? AblyException + callback.onError(exception?.errorInfo) + } + } +} From 40c793606d702505dd5ad1731c7e29586f987234 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 15 Jul 2025 20:25:41 +0530 Subject: [PATCH 02/19] [ECO-5076] Moved ObjectsState to separate file 1. Added ObjectsEvents and emitter for the same 2. Implemented stateChange mechanism for objectsState changes 3. updated getRootAsync to ensure objects are synced --- .../io/ably/lib/objects/DefaultLiveObjects.kt | 27 +--------- .../io/ably/lib/objects/ObjectsManager.kt | 52 ++++++++++++++++++- .../io/ably/lib/objects/ObjectsState.kt | 33 ++++++++++++ 3 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt 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 5fbd70530..ae3827176 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 @@ -8,15 +8,6 @@ import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.flow.MutableSharedFlow -/** - * @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. @@ -99,7 +90,7 @@ internal class DefaultLiveObjects(private val channelName: String, internal val private suspend fun getRootAsync(): LiveMap { return sequentialScope.async { adapter.throwIfInvalidAccessApiConfiguration(channelName) - // TODO - wait for state in synced state + objectsManager.ensureSynced() objectsPool.get(ROOT_OBJECT_ID) as LiveMap }.await() } @@ -186,22 +177,6 @@ internal class DefaultLiveObjects(private val channelName: String, internal val } } - /** - * 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() { incomingObjectsHandler.cancel() // objectsEventBus automatically garbage collected when collector is cancelled 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 index b9d189453..c29cfec86 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt @@ -4,6 +4,7 @@ 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 +import kotlinx.coroutines.* /** * @spec RTO5 - Processes OBJECT and OBJECT_SYNC messages during sync sequences @@ -21,6 +22,13 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects) { */ private val bufferedObjectOperations = mutableListOf() // RTO7a + // composition over inheritance, used to handle object state changes internally + private val internalObjectStateEmitter = ObjectsStateEmitter() + // related to RTC10, should have a separate EventEmitter for users of the library + private val publicObjectStateEmitter = ObjectsStateEmitter() + // Coroutine scope for running sequential operations on a single thread, used to avoid concurrency issues. + private val emitterScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob()) + /** * Handles object messages (non-sync messages). * @@ -77,7 +85,7 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects) { bufferedObjectOperations.clear() // RTO5a2b syncObjectsDataPool.clear() // RTO5a2a currentSyncId = syncId - liveObjects.stateChange(ObjectsState.SYNCING, false) + stateChange(ObjectsState.SYNCING, false) } /** @@ -95,7 +103,7 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects) { bufferedObjectOperations.clear() // RTO5c5 syncObjectsDataPool.clear() // RTO5c4 currentSyncId = null // RTO5c3 - liveObjects.stateChange(ObjectsState.SYNCED, deferStateEvent) + stateChange(ObjectsState.SYNCED, deferStateEvent) } /** @@ -215,8 +223,48 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects) { } } + /** + * Suspends the current coroutine until objects are synchronized. + * Returns immediately if state is already SYNCED, otherwise waits for the SYNCED event. + */ + internal suspend fun ensureSynced() { + if (liveObjects.state != ObjectsState.SYNCED) { + val deferred = CompletableDeferred() + internalObjectStateEmitter.once(ObjectsEvent.SYNCED) { + Log.v(tag, "Objects state changed to SYNCED, resuming ensureSynced") + deferred.complete(Unit) + } + deferred.await() + } + } + + /** + * Changes the state and emits events. + * + * @spec RTO2 - Emits state change events for syncing and synced states + */ + private fun stateChange(newState: ObjectsState, deferEvent: Boolean) { + if (liveObjects.state == newState) { + return + } + + liveObjects.state = newState + Log.v(tag, "Objects state changed to: $newState") + + val event = objectsStateToEventMap[newState] + event?.let { + // Use of deferEvent not needed, since emit method is synchronized amongst different threads + // emitterScope makes sure, next launch can only start when previous launch finishes processing of all events + emitterScope.launch { + internalObjectStateEmitter.emit(it) + publicObjectStateEmitter.emit(it) + } + } + } + internal fun dispose() { syncObjectsDataPool.clear() bufferedObjectOperations.clear() + emitterScope.cancel("ObjectsManager disposed") } } diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt new file mode 100644 index 000000000..d0b6f6ef0 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt @@ -0,0 +1,33 @@ +package io.ably.lib.objects + +import io.ably.lib.util.EventEmitter + +/** + * @spec RTO2 - enum representing objects state + */ +internal enum class ObjectsState { + INITIALIZED, + SYNCING, + SYNCED +} + +public enum class ObjectsEvent { + SYNCING, + SYNCED +} + +internal val objectsStateToEventMap = mapOf( + ObjectsState.INITIALIZED to null, + ObjectsState.SYNCING to ObjectsEvent.SYNCING, + ObjectsState.SYNCED to ObjectsEvent.SYNCED +) + +public fun interface ObjectsStateListener { + public fun onStateChanged(state: ObjectsEvent) +} + +internal class ObjectsStateEmitter : EventEmitter() { + override fun apply(listener: ObjectsStateListener?, event: ObjectsEvent?, vararg args: Any?) { + listener?.onStateChanged(event!!) + } +} From 21703e41d28f3833765e3e0a9805e21aeb48bc7a Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 16 Jul 2025 13:35:03 +0530 Subject: [PATCH 03/19] [ECO-5457] feat: Add Live Objects state change handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Add ObjectsStateEvent enum for tracking Live Objects synchronization states (SYNCING/SYNCED) • Implement ObjectsStateListener interface for receiving state change notifications • Create ObjectsStateSubscription interface for managing listener subscriptions and cleanup • Update LiveObjects, DefaultLiveObjects, ObjectsManager, ObjectsState, and Utils classes to support state management --- .../java/io/ably/lib/objects/LiveObjects.java | 37 +++++++++++++++++++ .../lib/objects/state/ObjectsStateEvent.java | 19 ++++++++++ .../objects/state/ObjectsStateListener.java | 16 ++++++++ .../state/ObjectsStateSubscription.java | 20 ++++++++++ .../io/ably/lib/objects/DefaultLiveObjects.kt | 20 +++++++++- .../io/ably/lib/objects/ObjectsManager.kt | 9 +++-- .../io/ably/lib/objects/ObjectsState.kt | 19 +++------- .../main/kotlin/io/ably/lib/objects/Utils.kt | 2 +- 8 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java create mode 100644 lib/src/main/java/io/ably/lib/objects/state/ObjectsStateListener.java create mode 100644 lib/src/main/java/io/ably/lib/objects/state/ObjectsStateSubscription.java diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjects.java b/lib/src/main/java/io/ably/lib/objects/LiveObjects.java index adf05df6e..3e53fdb27 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjects.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjects.java @@ -1,5 +1,8 @@ package io.ably.lib.objects; +import io.ably.lib.objects.state.ObjectsStateEvent; +import io.ably.lib.objects.state.ObjectsStateListener; +import io.ably.lib.objects.state.ObjectsStateSubscription; import io.ably.lib.types.Callback; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NonBlocking; @@ -148,4 +151,38 @@ public interface LiveObjects { */ @NonBlocking void createCounterAsync(@NotNull Long initialValue, @NotNull Callback<@NotNull LiveCounter> callback); + + /** + * Subscribes to a specific Live Objects synchronization state event. + * + *

This method registers the provided listener to be notified when the specified + * synchronization state event occurs. The returned subscription can be used to + * unsubscribe later when the notifications are no longer needed. + * + * @param event the synchronization state event to subscribe to (SYNCING or SYNCED) + * @param listener the listener that will be called when the event occurs + * @return a subscription object that can be used to unsubscribe from the event + */ + @NonBlocking + ObjectsStateSubscription on(@NotNull ObjectsStateEvent event, @NotNull ObjectsStateListener listener); + + /** + * Unsubscribes the specified listener from all synchronization state events. + * + *

After calling this method, the provided listener will no longer receive + * any synchronization state event notifications. + * + * @param listener the listener to unregister from all events + */ + @NonBlocking + void off(@NotNull ObjectsStateListener listener); + + /** + * Unsubscribes all listeners from all synchronization state events. + * + *

After calling this method, no listeners will receive any synchronization + * state event notifications until new listeners are registered. + */ + @NonBlocking + void offAll(); } diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java new file mode 100644 index 000000000..8b220219a --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java @@ -0,0 +1,19 @@ +package io.ably.lib.objects.state; + +/** + * Represents the synchronization state of Ably Live Objects. + *

+ * This enum is used to notify listeners about state changes in the synchronization process. + * Clients can register an {@link ObjectsStateListener} to receive these events. + */ +public enum ObjectsStateEvent { + /** + * Indicates that synchronization between local and remote objects is in progress. + */ + SYNCING, + + /** + * Indicates that synchronization has completed successfully and objects are in sync. + */ + SYNCED +} diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateListener.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateListener.java new file mode 100644 index 000000000..78c0a8ee9 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateListener.java @@ -0,0 +1,16 @@ +package io.ably.lib.objects.state; + +/** + * Interface for receiving notifications about Live Objects synchronization state changes. + *

+ * Implement this interface and register it with an ObjectsStateEmitter to be notified + * when synchronization state transitions occur. + */ +public interface ObjectsStateListener { + /** + * Called when the synchronization state changes. + * + * @param objectsStateEvent The new state event (SYNCING or SYNCED) + */ + void onStateChanged(ObjectsStateEvent objectsStateEvent); +} diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateSubscription.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateSubscription.java new file mode 100644 index 000000000..943611611 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateSubscription.java @@ -0,0 +1,20 @@ +package io.ably.lib.objects.state; + +/** + * Represents a subscription that can be unsubscribed from. + * This interface provides a way to clean up and remove subscriptions when they + * are no longer needed. + * Example usage: + * ```java + * ObjectsStateSubscription s = objects.subscribe(ObjectsStateEvent.SYNCING, new ObjectsStateListener() {}); + * // Later when done with the subscription + * s.unsubscribe(); + */ +public interface ObjectsStateSubscription { + /** + * This method should be called when the subscription is no longer needed, + * it will make sure no further events will be sent to the subscriber and + * that references to the subscriber are cleaned up. + */ + void unsubscribe(); +} 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 ae3827176..2f5b336ab 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,5 +1,8 @@ package io.ably.lib.objects +import io.ably.lib.objects.state.ObjectsStateEvent +import io.ably.lib.objects.state.ObjectsStateListener +import io.ably.lib.objects.state.ObjectsStateSubscription import io.ably.lib.realtime.ChannelState import io.ably.lib.types.Callback import io.ably.lib.types.ProtocolMessage @@ -52,7 +55,7 @@ internal class DefaultLiveObjects(private val channelName: String, internal val } override fun getRootAsync(callback: Callback) { - callbackScope.with(callback) { getRootAsync() } + callbackScope.launchWithCallback(callback) { getRootAsync() } } override fun createMap(liveMap: LiveMap): LiveMap { @@ -87,6 +90,21 @@ internal class DefaultLiveObjects(private val channelName: String, internal val TODO("Not yet implemented") } + override fun on(event: ObjectsStateEvent, listener: ObjectsStateListener): ObjectsStateSubscription { + objectsManager.publicObjectStateEmitter.on(event, listener) + return ObjectsStateSubscription { + objectsManager.publicObjectStateEmitter.off(event, listener) + } + } + + override fun off(listener: ObjectsStateListener) { + objectsManager.publicObjectStateEmitter.off(listener) + } + + override fun offAll() { + objectsManager.publicObjectStateEmitter.off() + } + private suspend fun getRootAsync(): LiveMap { return sequentialScope.async { adapter.throwIfInvalidAccessApiConfiguration(channelName) 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 index c29cfec86..055692798 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt @@ -1,5 +1,6 @@ package io.ably.lib.objects +import io.ably.lib.objects.state.ObjectsStateEvent import io.ably.lib.objects.type.BaseLiveObject import io.ably.lib.objects.type.livecounter.DefaultLiveCounter import io.ably.lib.objects.type.livemap.DefaultLiveMap @@ -25,7 +26,7 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects) { // composition over inheritance, used to handle object state changes internally private val internalObjectStateEmitter = ObjectsStateEmitter() // related to RTC10, should have a separate EventEmitter for users of the library - private val publicObjectStateEmitter = ObjectsStateEmitter() + internal val publicObjectStateEmitter = ObjectsStateEmitter() // Coroutine scope for running sequential operations on a single thread, used to avoid concurrency issues. private val emitterScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob()) @@ -230,7 +231,7 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects) { internal suspend fun ensureSynced() { if (liveObjects.state != ObjectsState.SYNCED) { val deferred = CompletableDeferred() - internalObjectStateEmitter.once(ObjectsEvent.SYNCED) { + internalObjectStateEmitter.once(ObjectsStateEvent.SYNCED) { Log.v(tag, "Objects state changed to SYNCED, resuming ensureSynced") deferred.complete(Unit) } @@ -253,8 +254,8 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects) { val event = objectsStateToEventMap[newState] event?.let { - // Use of deferEvent not needed, since emit method is synchronized amongst different threads - // emitterScope makes sure, next launch can only start when previous launch finishes processing of all events + // Use of deferEvent not needed since emitterScope makes sure next launch can only start when previous launch + // finishes processing of all events. Also, emit method is synchronized amongst different threads emitterScope.launch { internalObjectStateEmitter.emit(it) publicObjectStateEmitter.emit(it) diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt index d0b6f6ef0..8f457bf82 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt @@ -1,5 +1,7 @@ package io.ably.lib.objects +import io.ably.lib.objects.state.ObjectsStateEvent +import io.ably.lib.objects.state.ObjectsStateListener import io.ably.lib.util.EventEmitter /** @@ -11,23 +13,14 @@ internal enum class ObjectsState { SYNCED } -public enum class ObjectsEvent { - SYNCING, - SYNCED -} - internal val objectsStateToEventMap = mapOf( ObjectsState.INITIALIZED to null, - ObjectsState.SYNCING to ObjectsEvent.SYNCING, - ObjectsState.SYNCED to ObjectsEvent.SYNCED + ObjectsState.SYNCING to ObjectsStateEvent.SYNCING, + ObjectsState.SYNCED to ObjectsStateEvent.SYNCED ) -public fun interface ObjectsStateListener { - public fun onStateChanged(state: ObjectsEvent) -} - -internal class ObjectsStateEmitter : EventEmitter() { - override fun apply(listener: ObjectsStateListener?, event: ObjectsEvent?, vararg args: Any?) { +internal class ObjectsStateEmitter : EventEmitter() { + override fun apply(listener: ObjectsStateListener?, event: ObjectsStateEvent?, vararg args: Any?) { listener?.onStateChanged(event!!) } } 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 e472d73d0..6430f8213 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 @@ -58,7 +58,7 @@ internal val String.byteSize: Int * @param callback The callback to invoke with the operation result or error * @param block The suspend function to execute that returns a value of type T */ -internal fun CoroutineScope.with(callback: Callback, block: suspend () -> T) { +internal fun CoroutineScope.launchWithCallback(callback: Callback, block: suspend () -> T) { launch { try { val result = block() From 33ff9e2988d2833ccab79d37816e93b4bc48adbd Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 16 Jul 2025 14:39:48 +0530 Subject: [PATCH 04/19] [ECO-5457] Created separate interface for ObjectsStateChange to avoid polluting LiveObjects - Added impl. for the same in ObjectsState.kt - Extended the impl. in objectsmanager --- .../java/io/ably/lib/objects/LiveObjects.java | 40 +------- .../lib/objects/state/ObjectsStateChange.java | 55 +++++++++++ .../lib/objects/state/ObjectsStateEvent.java | 2 +- .../objects/state/ObjectsStateListener.java | 16 ---- .../io/ably/lib/objects/DefaultLiveObjects.kt | 20 ++-- .../io/ably/lib/objects/ObjectsManager.kt | 42 +-------- .../io/ably/lib/objects/ObjectsState.kt | 93 ++++++++++++++++++- 7 files changed, 158 insertions(+), 110 deletions(-) create mode 100644 lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java delete mode 100644 lib/src/main/java/io/ably/lib/objects/state/ObjectsStateListener.java diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjects.java b/lib/src/main/java/io/ably/lib/objects/LiveObjects.java index 3e53fdb27..fff0344ca 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjects.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjects.java @@ -1,8 +1,6 @@ package io.ably.lib.objects; -import io.ably.lib.objects.state.ObjectsStateEvent; -import io.ably.lib.objects.state.ObjectsStateListener; -import io.ably.lib.objects.state.ObjectsStateSubscription; +import io.ably.lib.objects.state.ObjectsStateChange; import io.ably.lib.types.Callback; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NonBlocking; @@ -19,7 +17,7 @@ *

Implementations of this interface must be thread-safe as they may be accessed * from multiple threads concurrently. */ -public interface LiveObjects { +public interface LiveObjects extends ObjectsStateChange { /** * Retrieves the root LiveMap object. @@ -151,38 +149,4 @@ public interface LiveObjects { */ @NonBlocking void createCounterAsync(@NotNull Long initialValue, @NotNull Callback<@NotNull LiveCounter> callback); - - /** - * Subscribes to a specific Live Objects synchronization state event. - * - *

This method registers the provided listener to be notified when the specified - * synchronization state event occurs. The returned subscription can be used to - * unsubscribe later when the notifications are no longer needed. - * - * @param event the synchronization state event to subscribe to (SYNCING or SYNCED) - * @param listener the listener that will be called when the event occurs - * @return a subscription object that can be used to unsubscribe from the event - */ - @NonBlocking - ObjectsStateSubscription on(@NotNull ObjectsStateEvent event, @NotNull ObjectsStateListener listener); - - /** - * Unsubscribes the specified listener from all synchronization state events. - * - *

After calling this method, the provided listener will no longer receive - * any synchronization state event notifications. - * - * @param listener the listener to unregister from all events - */ - @NonBlocking - void off(@NotNull ObjectsStateListener listener); - - /** - * Unsubscribes all listeners from all synchronization state events. - * - *

After calling this method, no listeners will receive any synchronization - * state event notifications until new listeners are registered. - */ - @NonBlocking - void offAll(); } diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java new file mode 100644 index 000000000..e18a5c8e5 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java @@ -0,0 +1,55 @@ +package io.ably.lib.objects.state; + +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.NotNull; + +public interface ObjectsStateChange { + /** + * Subscribes to a specific Live Objects synchronization state event. + * + *

This method registers the provided listener to be notified when the specified + * synchronization state event occurs. The returned subscription can be used to + * unsubscribe later when the notifications are no longer needed. + * + * @param event the synchronization state event to subscribe to (SYNCING or SYNCED) + * @param listener the listener that will be called when the event occurs + * @return a subscription object that can be used to unsubscribe from the event + */ + @NonBlocking + ObjectsStateSubscription on(@NotNull ObjectsStateEvent event, @NotNull ObjectsStateChange.Listener listener); + + /** + * Unsubscribes the specified listener from all synchronization state events. + * + *

After calling this method, the provided listener will no longer receive + * any synchronization state event notifications. + * + * @param listener the listener to unregister from all events + */ + @NonBlocking + void off(@NotNull ObjectsStateChange.Listener listener); + + /** + * Unsubscribes all listeners from all synchronization state events. + * + *

After calling this method, no listeners will receive any synchronization + * state event notifications until new listeners are registered. + */ + @NonBlocking + void offAll(); + + /** + * Interface for receiving notifications about Live Objects synchronization state changes. + *

+ * Implement this interface and register it with an ObjectsStateEmitter to be notified + * when synchronization state transitions occur. + */ + interface Listener { + /** + * Called when the synchronization state changes. + * + * @param objectsStateEvent The new state event (SYNCING or SYNCED) + */ + void onStateChanged(ObjectsStateEvent objectsStateEvent); + } +} diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java index 8b220219a..4fa01a173 100644 --- a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java +++ b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java @@ -4,7 +4,7 @@ * Represents the synchronization state of Ably Live Objects. *

* This enum is used to notify listeners about state changes in the synchronization process. - * Clients can register an {@link ObjectsStateListener} to receive these events. + * Clients can register an {@link ObjectsStateChange.Listener} to receive these events. */ public enum ObjectsStateEvent { /** diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateListener.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateListener.java deleted file mode 100644 index 78c0a8ee9..000000000 --- a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateListener.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.ably.lib.objects.state; - -/** - * Interface for receiving notifications about Live Objects synchronization state changes. - *

- * Implement this interface and register it with an ObjectsStateEmitter to be notified - * when synchronization state transitions occur. - */ -public interface ObjectsStateListener { - /** - * Called when the synchronization state changes. - * - * @param objectsStateEvent The new state event (SYNCING or SYNCED) - */ - void onStateChanged(ObjectsStateEvent objectsStateEvent); -} 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 2f5b336ab..a9d078708 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,7 +1,7 @@ package io.ably.lib.objects +import io.ably.lib.objects.state.ObjectsStateChange import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.objects.state.ObjectsStateListener import io.ably.lib.objects.state.ObjectsStateSubscription import io.ably.lib.realtime.ChannelState import io.ably.lib.types.Callback @@ -90,25 +90,17 @@ internal class DefaultLiveObjects(private val channelName: String, internal val TODO("Not yet implemented") } - override fun on(event: ObjectsStateEvent, listener: ObjectsStateListener): ObjectsStateSubscription { - objectsManager.publicObjectStateEmitter.on(event, listener) - return ObjectsStateSubscription { - objectsManager.publicObjectStateEmitter.off(event, listener) - } - } + override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsStateSubscription = + objectsManager.on(event, listener) - override fun off(listener: ObjectsStateListener) { - objectsManager.publicObjectStateEmitter.off(listener) - } + override fun off(listener: ObjectsStateChange.Listener) = objectsManager.off(listener) - override fun offAll() { - objectsManager.publicObjectStateEmitter.off() - } + override fun offAll() = objectsManager.offAll() private suspend fun getRootAsync(): LiveMap { return sequentialScope.async { adapter.throwIfInvalidAccessApiConfiguration(channelName) - objectsManager.ensureSynced() + objectsManager.ensureSynced(state) objectsPool.get(ROOT_OBJECT_ID) as LiveMap }.await() } 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 index 055692798..25d92184e 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt @@ -1,17 +1,15 @@ package io.ably.lib.objects -import io.ably.lib.objects.state.ObjectsStateEvent 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 -import kotlinx.coroutines.* /** * @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) { +internal class ObjectsManager(private val liveObjects: DefaultLiveObjects): ObjectsStateCoordinator() { private val tag = "ObjectsManager" /** * @spec RTO5 - Sync objects data pool for collecting sync messages @@ -23,13 +21,6 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects) { */ private val bufferedObjectOperations = mutableListOf() // RTO7a - // composition over inheritance, used to handle object state changes internally - private val internalObjectStateEmitter = ObjectsStateEmitter() - // related to RTC10, should have a separate EventEmitter for users of the library - internal val publicObjectStateEmitter = ObjectsStateEmitter() - // Coroutine scope for running sequential operations on a single thread, used to avoid concurrency issues. - private val emitterScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob()) - /** * Handles object messages (non-sync messages). * @@ -224,21 +215,6 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects) { } } - /** - * Suspends the current coroutine until objects are synchronized. - * Returns immediately if state is already SYNCED, otherwise waits for the SYNCED event. - */ - internal suspend fun ensureSynced() { - if (liveObjects.state != ObjectsState.SYNCED) { - val deferred = CompletableDeferred() - internalObjectStateEmitter.once(ObjectsStateEvent.SYNCED) { - Log.v(tag, "Objects state changed to SYNCED, resuming ensureSynced") - deferred.complete(Unit) - } - deferred.await() - } - } - /** * Changes the state and emits events. * @@ -248,24 +224,16 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects) { if (liveObjects.state == newState) { return } - + Log.v(tag, "Objects state changed to: $newState from ${liveObjects.state}") liveObjects.state = newState - Log.v(tag, "Objects state changed to: $newState") - val event = objectsStateToEventMap[newState] - event?.let { - // Use of deferEvent not needed since emitterScope makes sure next launch can only start when previous launch - // finishes processing of all events. Also, emit method is synchronized amongst different threads - emitterScope.launch { - internalObjectStateEmitter.emit(it) - publicObjectStateEmitter.emit(it) - } - } + // deferEvent not needed since objectsStateChanged processes events in a sequential coroutine scope + objectsStateChanged(newState) } internal fun dispose() { syncObjectsDataPool.clear() bufferedObjectOperations.clear() - emitterScope.cancel("ObjectsManager disposed") + disposeObjectsStateListeners() } } diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt index 8f457bf82..377cc1154 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt @@ -1,8 +1,11 @@ package io.ably.lib.objects +import io.ably.lib.objects.state.ObjectsStateChange import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.objects.state.ObjectsStateListener +import io.ably.lib.objects.state.ObjectsStateSubscription import io.ably.lib.util.EventEmitter +import io.ably.lib.util.Log +import kotlinx.coroutines.* /** * @spec RTO2 - enum representing objects state @@ -13,14 +16,96 @@ internal enum class ObjectsState { SYNCED } -internal val objectsStateToEventMap = mapOf( +/** + * Maps internal ObjectsState values to their corresponding public ObjectsStateEvent values. + * Used to determine which events should be emitted when state changes occur. + * INITIALIZED maps to null (no event), while SYNCING and SYNCED map to their respective events. + */ +private val objectsStateToEventMap = mapOf( ObjectsState.INITIALIZED to null, ObjectsState.SYNCING to ObjectsStateEvent.SYNCING, ObjectsState.SYNCED to ObjectsStateEvent.SYNCED ) -internal class ObjectsStateEmitter : EventEmitter() { - override fun apply(listener: ObjectsStateListener?, event: ObjectsStateEvent?, vararg args: Any?) { +/** + * An interface for managing and communicating changes in the synchronization state of live objects. + * + * Implementations should ensure thread-safe event emission and proper synchronization + * between state change notifications. + */ +internal interface HandlesObjectsStateChange { + /** + * Handles changes in the state of live objects by notifying all registered listeners. + * Implementations should ensure thread-safe event emission to both internal and public listeners. + * Makes sure every event is processed in the order they were received. + * @param newState The new state of the objects, SYNCING or SYNCED. + */ + fun objectsStateChanged(newState: ObjectsState) + + /** + * Suspends the current coroutine until objects are synchronized. + * Returns immediately if state is already SYNCED, otherwise waits for the SYNCED event. + * + * @param currentState The current state of objects to determine if waiting is necessary + */ + suspend fun ensureSynced(currentState: ObjectsState) + + /** + * Disposes all registered state change listeners and cancels any pending operations. + * Should be called when the associated LiveObjects instance is no longer needed. + */ + fun disposeObjectsStateListeners() +} + + +internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObjectsStateChange { + private val tag = "ObjectsStateCoordinator" + private val internalObjectStateEmitter = ObjectsStateEmitter() + // related to RTC10, should have a separate EventEmitter for users of the library + private val externalObjectStateEmitter = ObjectsStateEmitter() + + private val emitterScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob()) + + override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsStateSubscription { + externalObjectStateEmitter.on(event, listener) + return ObjectsStateSubscription { + externalObjectStateEmitter.off(event, listener) + } + } + + override fun off(listener: ObjectsStateChange.Listener) = externalObjectStateEmitter.off(listener) + + override fun offAll() = externalObjectStateEmitter.off() + + override fun objectsStateChanged(newState: ObjectsState) { + objectsStateToEventMap[newState]?.let { objectsStateEvent -> + // emitterScope makes sure next launch can only start when previous launch finishes + emitterScope.launch { + internalObjectStateEmitter.emit(objectsStateEvent) + externalObjectStateEmitter.emit(objectsStateEvent) + } + } + } + + override suspend fun ensureSynced(currentState: ObjectsState) { + if (currentState != ObjectsState.SYNCED) { + val deferred = CompletableDeferred() + internalObjectStateEmitter.once(ObjectsStateEvent.SYNCED) { + Log.v(tag, "Objects state changed to SYNCED, resuming ensureSynced") + deferred.complete(Unit) + } + deferred.await() + } + } + + override fun disposeObjectsStateListeners() { + offAll() + emitterScope.cancel("ObjectsManager disposed") + } +} + +private class ObjectsStateEmitter : EventEmitter() { + override fun apply(listener: ObjectsStateChange.Listener?, event: ObjectsStateEvent?, vararg args: Any?) { listener?.onStateChanged(event!!) } } From 67c2b62faab54bf96c64600463fe82bd52320445 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 17 Jul 2025 14:17:03 +0530 Subject: [PATCH 05/19] [ECO-5457] Refactored ObjectsStateSubscription to ObjectsSubscription - Updated usage doc for the same --- ...ubscription.java => ObjectsSubscription.java} | 16 +++++++++------- .../lib/objects/state/ObjectsStateChange.java | 3 ++- .../io/ably/lib/objects/DefaultLiveObjects.kt | 6 +++--- .../kotlin/io/ably/lib/objects/ObjectsState.kt | 12 ++++++++---- .../src/main/kotlin/io/ably/lib/objects/Utils.kt | 5 ++++- 5 files changed, 26 insertions(+), 16 deletions(-) rename lib/src/main/java/io/ably/lib/objects/{state/ObjectsStateSubscription.java => ObjectsSubscription.java} (56%) diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateSubscription.java b/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java similarity index 56% rename from lib/src/main/java/io/ably/lib/objects/state/ObjectsStateSubscription.java rename to lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java index 943611611..d6d007ecd 100644 --- a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateSubscription.java +++ b/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java @@ -1,16 +1,18 @@ -package io.ably.lib.objects.state; +package io.ably.lib.objects; /** - * Represents a subscription that can be unsubscribed from. - * This interface provides a way to clean up and remove subscriptions when they - * are no longer needed. + * Represents a objects subscription that can be unsubscribed from. + * This interface provides a way to clean up and remove subscriptions when they are no longer needed. * Example usage: - * ```java - * ObjectsStateSubscription s = objects.subscribe(ObjectsStateEvent.SYNCING, new ObjectsStateListener() {}); + *

+ * {@code
+ * ObjectsSubscription s = objects.subscribe(ObjectsStateEvent.SYNCING, new ObjectsStateListener() {});
  * // Later when done with the subscription
  * s.unsubscribe();
+ * }
+ * 
*/ -public interface ObjectsStateSubscription { +public interface ObjectsSubscription { /** * This method should be called when the subscription is no longer needed, * it will make sure no further events will be sent to the subscriber and diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java index e18a5c8e5..7b3a7e1e3 100644 --- a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java +++ b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java @@ -1,5 +1,6 @@ package io.ably.lib.objects.state; +import io.ably.lib.objects.ObjectsSubscription; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; @@ -16,7 +17,7 @@ public interface ObjectsStateChange { * @return a subscription object that can be used to unsubscribe from the event */ @NonBlocking - ObjectsStateSubscription on(@NotNull ObjectsStateEvent event, @NotNull ObjectsStateChange.Listener listener); + ObjectsSubscription on(@NotNull ObjectsStateEvent event, @NotNull ObjectsStateChange.Listener listener); /** * Unsubscribes the specified listener from all synchronization state events. 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 a9d078708..ea07ba300 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 @@ -2,7 +2,6 @@ package io.ably.lib.objects import io.ably.lib.objects.state.ObjectsStateChange import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.objects.state.ObjectsStateSubscription import io.ably.lib.realtime.ChannelState import io.ably.lib.types.Callback import io.ably.lib.types.ProtocolMessage @@ -38,7 +37,8 @@ internal class DefaultLiveObjects(private val channelName: String, internal val /** * Coroutine scope for handling callbacks asynchronously. */ - private val callbackScope = CoroutineScope(Dispatchers.Default + CoroutineName("LiveObjectsCallback-$channelName")) + private val callbackScope = + CoroutineScope(Dispatchers.Default + CoroutineName("LiveObjectsCallback-$channelName") + SupervisorJob()) /** * Event bus for handling incoming object messages sequentially. @@ -90,7 +90,7 @@ internal class DefaultLiveObjects(private val channelName: String, internal val TODO("Not yet implemented") } - override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsStateSubscription = + override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription = objectsManager.on(event, listener) override fun off(listener: ObjectsStateChange.Listener) = objectsManager.off(listener) diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt index 377cc1154..d503e2ddb 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt @@ -2,7 +2,6 @@ package io.ably.lib.objects import io.ably.lib.objects.state.ObjectsStateChange import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.objects.state.ObjectsStateSubscription import io.ably.lib.util.EventEmitter import io.ably.lib.util.Log import kotlinx.coroutines.* @@ -66,9 +65,9 @@ internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObj private val emitterScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob()) - override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsStateSubscription { + override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription { externalObjectStateEmitter.on(event, listener) - return ObjectsStateSubscription { + return ObjectsSubscription { externalObjectStateEmitter.off(event, listener) } } @@ -105,7 +104,12 @@ internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObj } private class ObjectsStateEmitter : EventEmitter() { + private val tag = "ObjectsStateEmitter" override fun apply(listener: ObjectsStateChange.Listener?, event: ObjectsStateEvent?, vararg args: Any?) { - listener?.onStateChanged(event!!) + try { + listener?.onStateChanged(event!!) + } catch (t: Throwable) { + Log.e(tag, "Error occurred while executing listener callback for event: $event", t) + } } } 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 6430f8213..c050ceb1d 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 @@ -3,6 +3,7 @@ package io.ably.lib.objects import io.ably.lib.types.AblyException import io.ably.lib.types.Callback import io.ably.lib.types.ErrorInfo +import io.ably.lib.util.Log import kotlinx.coroutines.* internal fun ablyException( @@ -62,7 +63,9 @@ internal fun CoroutineScope.launchWithCallback(callback: Callback, block: launch { try { val result = block() - callback.onSuccess(result) + try { callback.onSuccess(result) } catch (t: Throwable) { + Log.e("asyncCallback", "Error occurred while executing callback's onSuccess handler", t) + } // catch and don't rethrow error from callback } catch (throwable: Throwable) { val exception = throwable as? AblyException callback.onError(exception?.errorInfo) From 38a3feee4a859a713873aef084258f1dc75c1be6 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 18 Jul 2025 11:08:44 +0530 Subject: [PATCH 06/19] [ECO-5457] Moved callbackScope at global level to be shared amongst all public api methods --- .../ably/lib/objects/LiveObjectsPlugin.java | 2 + .../io/ably/lib/objects/DefaultLiveObjects.kt | 30 ++++++--------- .../lib/objects/DefaultLiveObjectsPlugin.kt | 4 +- .../main/kotlin/io/ably/lib/objects/Utils.kt | 38 +++++++++---------- .../unit/objects/DefaultLiveObjectsTest.kt | 5 ++- 5 files changed, 38 insertions(+), 41 deletions(-) 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 81156d654..392b9f1df 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java @@ -46,6 +46,7 @@ public interface LiveObjectsPlugin { * Disposes of the LiveObjects instance associated with the specified channel name. * This method removes the LiveObjects instance for the given channel, releasing any * resources associated with it. + * This is invoked when ablyRealtimeClient.channels.release(channelName) is called * * @param channelName the name of the channel whose LiveObjects instance is to be removed. */ @@ -53,6 +54,7 @@ public interface LiveObjectsPlugin { /** * Disposes of the plugin instance and all underlying resources. + * This is invoked when ablyRealtimeClient.close() is called */ void dispose(); } 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 ea07ba300..9e35474f6 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 @@ -9,6 +9,7 @@ import io.ably.lib.util.Log import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.flow.MutableSharedFlow +import java.util.concurrent.CancellationException /** * Default implementation of LiveObjects interface. @@ -34,12 +35,6 @@ internal class DefaultLiveObjects(private val channelName: String, internal val private val sequentialScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(channelName) + SupervisorJob()) - /** - * Coroutine scope for handling callbacks asynchronously. - */ - private val callbackScope = - CoroutineScope(Dispatchers.Default + CoroutineName("LiveObjectsCallback-$channelName") + SupervisorJob()) - /** * Event bus for handling incoming object messages sequentially. */ @@ -50,12 +45,10 @@ internal class DefaultLiveObjects(private val channelName: String, internal val incomingObjectsHandler = initializeHandlerForIncomingObjectMessages() } - override fun getRoot(): LiveMap { - return runBlocking { getRootAsync() } - } + override fun getRoot(): LiveMap = runBlocking { getRootAsync() } override fun getRootAsync(callback: Callback) { - callbackScope.launchWithCallback(callback) { getRootAsync() } + GlobalCallbackScope.launchWithCallback(callback) { getRootAsync() } } override fun createMap(liveMap: LiveMap): LiveMap { @@ -97,12 +90,10 @@ internal class DefaultLiveObjects(private val channelName: String, internal val override fun offAll() = objectsManager.offAll() - private suspend fun getRootAsync(): LiveMap { - return sequentialScope.async { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - objectsManager.ensureSynced(state) - objectsPool.get(ROOT_OBJECT_ID) as LiveMap - }.await() + private suspend fun getRootAsync(): LiveMap = withContext(sequentialScope.coroutineContext) { + adapter.throwIfInvalidAccessApiConfiguration(channelName) + objectsManager.ensureSynced(state) + objectsPool.get(ROOT_OBJECT_ID) as LiveMap } /** @@ -188,9 +179,12 @@ internal class DefaultLiveObjects(private val channelName: String, internal val } // Dispose of any resources associated with this LiveObjects instance - fun dispose() { - incomingObjectsHandler.cancel() // objectsEventBus automatically garbage collected when collector is cancelled + 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 e0a82e9cf..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 @@ -22,13 +22,13 @@ public class DefaultLiveObjectsPlugin(private val adapter: LiveObjectsAdapter) : } 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/Utils.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt index c050ceb1d..e0a817f14 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 @@ -49,26 +49,26 @@ internal val String.byteSize: Int get() = this.toByteArray(Charsets.UTF_8).size /** - * Executes a suspend function within a coroutine and handles the result via a callback. - * - * This utility bridges between coroutine-based implementation code and callback-based APIs. - * It launches a coroutine in the current scope to execute the provided suspend block, - * then routes the result or any error to the appropriate callback method. - * - * @param T The type of result expected from the operation - * @param callback The callback to invoke with the operation result or error - * @param block The suspend function to execute that returns a value of type T + * A global coroutine scope for executing callbacks asynchronously. + * Provides safe execution of suspend functions with results delivered via callbacks, + * with proper error handling for both the execution and callback invocation. */ -internal fun CoroutineScope.launchWithCallback(callback: Callback, block: suspend () -> T) { - launch { - try { - val result = block() - try { callback.onSuccess(result) } catch (t: Throwable) { - Log.e("asyncCallback", "Error occurred while executing callback's onSuccess handler", t) - } // catch and don't rethrow error from callback - } catch (throwable: Throwable) { - val exception = throwable as? AblyException - callback.onError(exception?.errorInfo) +internal object GlobalCallbackScope { + private const val TAG = "GlobalCallbackScope" + private val scope = + CoroutineScope(Dispatchers.Default + CoroutineName("LiveObjects-GlobalCallbackScope") + SupervisorJob()) + + internal fun launchWithCallback(callback: Callback, block: suspend () -> T) { + scope.launch { + try { + val result = block() + try { callback.onSuccess(result) } catch (t: Throwable) { + Log.e(TAG, "Error occurred while executing callback's onSuccess handler", t) + } // catch and don't rethrow error from callback + } catch (throwable: Throwable) { + val exception = throwable as? AblyException + callback.onError(exception?.errorInfo) + } } } } 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 index c8d2a4930..6f4c78fc0 100644 --- 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 @@ -57,6 +57,9 @@ class DefaultLiveObjectsTest { // 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) } @@ -64,8 +67,6 @@ class DefaultLiveObjectsTest { defaultLiveObjects.ObjectsManager.endSync(any()) } - // Verify expected outcomes - assertWaiter { defaultLiveObjects.state == ObjectsState.SYNCED } // RTO4b4 assertEquals(0, defaultLiveObjects.ObjectsManager.SyncObjectsDataPool.size) // RTO4b3 assertEquals(0, defaultLiveObjects.ObjectsManager.BufferedObjectOperations.size) // RTO4b5 assertEquals(1, defaultLiveObjects.objectsPool.size()) // RTO4b1 - Only root remains From f4cde35ed957d0102547f543990c44a36e4fdf81 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 18 Jul 2025 16:16:38 +0530 Subject: [PATCH 07/19] [ECO-5457] Refactored LiveMap to accept DefaultLiveObjects in constructor param 1. Created separate class for LiveMapEntry, added helper methods for the same 2. Added missing impl. for LiveMap accessor methods, get, entries, keys, values and size 3. updated LiveMap data field to be ConcurrentMap --- .../java/io/ably/lib/objects/LiveMap.java | 5 ++ .../io/ably/lib/objects/DefaultLiveObjects.kt | 4 +- .../io/ably/lib/objects/ObjectsManager.kt | 4 +- .../kotlin/io/ably/lib/objects/ObjectsPool.kt | 8 +- .../ably/lib/objects/type/BaseLiveObject.kt | 3 +- .../type/livecounter/DefaultLiveCounter.kt | 8 +- .../objects/type/livemap/DefaultLiveMap.kt | 85 +++++++++++-------- .../lib/objects/type/livemap/LiveMapEntry.kt | 63 ++++++++++++++ .../unit/objects/ObjectsManagerTest.kt | 4 +- .../objects/unit/objects/ObjectsPoolTest.kt | 4 +- .../objects/unit/type/BaseLiveObjectTest.kt | 15 ++-- 11 files changed, 142 insertions(+), 61 deletions(-) create mode 100644 live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt diff --git a/lib/src/main/java/io/ably/lib/objects/LiveMap.java b/lib/src/main/java/io/ably/lib/objects/LiveMap.java index 7a964dc90..cc297a401 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveMap.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveMap.java @@ -24,6 +24,7 @@ public interface LiveMap { * If the value associated with the provided key is an objectId string of another LiveObject, a reference to that LiveObject * is returned, provided it exists in the local pool and is not tombstoned. Otherwise, null is returned. * If the value is not an objectId, then that value is returned. + * Spec: RTLM5, RTLM5a * * @param keyName the key whose associated value is to be returned. * @return the value associated with the specified key, or null if the key does not exist. @@ -33,6 +34,7 @@ public interface LiveMap { /** * Retrieves all entries (key-value pairs) in the map. + * Spec: RTLM11, RTLM11a * * @return an iterable collection of all entries in the map. */ @@ -42,6 +44,7 @@ public interface LiveMap { /** * Retrieves all keys in the map. + * Spec: RTLM12, RTLM12a * * @return an iterable collection of all keys in the map. */ @@ -51,6 +54,7 @@ public interface LiveMap { /** * Retrieves all values in the map. + * Spec: RTLM13, RTLM13a * * @return an iterable collection of all values in the map. */ @@ -85,6 +89,7 @@ public interface LiveMap { /** * Retrieves the number of entries in the map. + * Spec: RTLM10, RTLM10a * * @return the size of the map. */ 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 9e35474f6..f8e3d2ad0 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 @@ -15,12 +15,12 @@ import java.util.concurrent.CancellationException * Default implementation of LiveObjects interface. * Provides the core functionality for managing live objects on a channel. */ -internal class DefaultLiveObjects(private val channelName: String, internal val adapter: LiveObjectsAdapter): LiveObjects { +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(adapter) + internal val objectsPool = ObjectsPool(this) internal var state = ObjectsState.INITIALIZED 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 index 25d92184e..a85bf2368 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt @@ -209,8 +209,8 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects): Obje */ private fun createObjectFromState(objectState: ObjectState): BaseLiveObject { return when { - objectState.counter != null -> DefaultLiveCounter.zeroValue(objectState.objectId, liveObjects.adapter) // RTO5c1b1a - objectState.map != null -> DefaultLiveMap.zeroValue(objectState.objectId, liveObjects.adapter, liveObjects.objectsPool) // RTO5c1b1b + 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 } } 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 index 2bf8ae421..4317ee1ae 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt @@ -32,7 +32,7 @@ internal const val ROOT_OBJECT_ID = "root" * @spec RTO3 - Maintains an objects pool for all live objects on the channel */ internal class ObjectsPool( - private val adapter: LiveObjectsAdapter + private val liveObjects: DefaultLiveObjects ) { private val tag = "ObjectsPool" @@ -49,7 +49,7 @@ internal class ObjectsPool( init { // RTO3b - Initialize pool with root object - pool[ROOT_OBJECT_ID] = DefaultLiveMap.zeroValue(ROOT_OBJECT_ID, adapter, this) + pool[ROOT_OBJECT_ID] = DefaultLiveMap.zeroValue(ROOT_OBJECT_ID, liveObjects) // Start garbage collection coroutine gcJob = startGCJob() } @@ -109,8 +109,8 @@ internal class ObjectsPool( val parsedObjectId = ObjectId.fromString(objectId) // RTO6b return when (parsedObjectId.type) { - ObjectType.Map -> DefaultLiveMap.zeroValue(objectId, adapter, this) // RTO6b2 - ObjectType.Counter -> DefaultLiveCounter.zeroValue(objectId, adapter) // RTO6b3 + 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 } 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 index eccc99043..ea4b9e6d6 100644 --- 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 @@ -24,7 +24,6 @@ internal enum class ObjectType(val value: String) { internal abstract class BaseLiveObject( internal val objectId: String, // // RTLO3a private val objectType: ObjectType, - private val adapter: LiveObjectsAdapter ) { protected open val tag = "BaseLiveObject" @@ -33,7 +32,7 @@ internal abstract class BaseLiveObject( internal var createOperationIsMerged = false // RTLO3c - private var isTombstoned = false + internal var isTombstoned = false private var tombstonedAt: Long? = null /** 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 index 8926ea3d0..656bd74cc 100644 --- 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 @@ -14,8 +14,8 @@ import io.ably.lib.types.Callback */ internal class DefaultLiveCounter private constructor( objectId: String, - adapter: LiveObjectsAdapter, -) : LiveCounter, BaseLiveObject(objectId, ObjectType.Counter, adapter) { + private val liveObjects: DefaultLiveObjects, +) : LiveCounter, BaseLiveObject(objectId, ObjectType.Counter) { override val tag = "LiveCounter" @@ -71,8 +71,8 @@ internal class DefaultLiveCounter private constructor( * Creates a zero-value counter object. * @spec RTLC4 - Returns LiveCounter with 0 value */ - internal fun zeroValue(objectId: String, adapter: LiveObjectsAdapter): DefaultLiveCounter { - return DefaultLiveCounter(objectId, adapter) + 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/livemap/DefaultLiveMap.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt index 4e4dea861..45710954e 100644 --- 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 @@ -1,34 +1,16 @@ package io.ably.lib.objects.type.livemap import io.ably.lib.objects.* -import io.ably.lib.objects.ObjectsPool -import io.ably.lib.objects.ObjectsPoolDefaults import io.ably.lib.objects.MapSemantics -import io.ably.lib.objects.ObjectData 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.AbstractMap +import java.util.concurrent.ConcurrentHashMap -/** - * @spec RTLM3 - Map data structure storing entries - */ -internal data class LiveMapEntry( - var isTombstoned: Boolean = false, - var tombstonedAt: Long? = null, - var timeserial: String? = null, - var data: ObjectData? = null -) - -/** - * Extension function to check if a LiveMapEntry is expired and ready for garbage collection - */ -private fun LiveMapEntry.isEligibleForGc(): Boolean { - val currentTime = System.currentTimeMillis() - return isTombstoned && tombstonedAt?.let { currentTime - it >= ObjectsPoolDefaults.GC_GRACE_PERIOD_MS } == true -} /** * Implementation of LiveObject for LiveMap. @@ -37,48 +19,77 @@ private fun LiveMapEntry.isEligibleForGc(): Boolean { */ internal class DefaultLiveMap private constructor( objectId: String, - adapter: LiveObjectsAdapter, - internal val objectsPool: ObjectsPool, + objects: DefaultLiveObjects, internal val semantics: MapSemantics = MapSemantics.LWW -) : LiveMap, BaseLiveObject(objectId, ObjectType.Map, adapter) { +) : LiveMap, BaseLiveObject(objectId, ObjectType.Map) { override val tag = "LiveMap" /** * Map of key to LiveMapEntry */ - internal val data = mutableMapOf() + internal val data = ConcurrentHashMap() /** * LiveMapManager instance for managing LiveMap operations */ private val liveMapManager = LiveMapManager(this) + private val adapter = objects.adapter + internal val objectsPool = objects.objectsPool + private val channelName = objects.channelName override fun get(keyName: String): Any? { - TODO("Not yet implemented") + adapter.throwIfInvalidAccessApiConfiguration(channelName) + if (isTombstoned) { + return null + } + data[keyName]?.let { liveMapEntry -> + return liveMapEntry.getResolvedValue(objectsPool) + } + return null // RTLM5d1 } - override fun entries(): MutableIterable> { - TODO("Not yet implemented") + override fun entries(): Iterable> { + adapter.throwIfInvalidAccessApiConfiguration(channelName) + + return sequence> { + for ((key, entry) in data.entries) { + val value = entry.getResolvedValue(objectsPool) + value?.let { + yield(AbstractMap.SimpleImmutableEntry(key, it)) + } + } + }.asIterable() } - override fun keys(): MutableIterable { - TODO("Not yet implemented") + override fun keys(): Iterable { + val iterableEntries = entries() + return sequence { + for (entry in iterableEntries) { + yield(entry.key) + } + }.asIterable() } - override fun values(): MutableIterable { - TODO("Not yet implemented") + override fun values(): Iterable { + val iterableEntries = entries() + return sequence { + for (entry in iterableEntries) { + yield(entry.value) + } + }.asIterable() } - override fun set(keyName: String, value: Any) { - TODO("Not yet implemented") + override fun size(): Long { + adapter.throwIfInvalidAccessApiConfiguration(channelName) + return data.values.count { !it.isEntryOrRefTombstoned(objectsPool) }.toLong() // RTLM10d } - override fun remove(keyName: String) { + override fun set(keyName: String, value: Any) { TODO("Not yet implemented") } - override fun size(): Long { + override fun remove(keyName: String) { TODO("Not yet implemented") } @@ -112,8 +123,8 @@ internal class DefaultLiveMap private constructor( * Creates a zero-value map object. * @spec RTLM4 - Returns LiveMap with empty map data */ - internal fun zeroValue(objectId: String, adapter: LiveObjectsAdapter, objectsPool: ObjectsPool): DefaultLiveMap { - return DefaultLiveMap(objectId, adapter, objectsPool) + 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..75c9db256 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt @@ -0,0 +1,63 @@ +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( + var isTombstoned: Boolean = false, + var tombstonedAt: Long? = null, + var timeserial: String? = null, + var 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 { primitiveValue -> + return primitiveValue // 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/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt index 858df6560..da580b5a3 100644 --- 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 @@ -213,13 +213,13 @@ class ObjectsManagerTest { private fun mockZeroValuedObjects() { mockkObject(DefaultLiveMap.Companion) every { - DefaultLiveMap.zeroValue(any(), any(), any()) + DefaultLiveMap.zeroValue(any(), any()) } answers { mockk(relaxed = true) } mockkObject(DefaultLiveCounter.Companion) every { - DefaultLiveCounter.zeroValue(any(), any()) + DefaultLiveCounter.zeroValue(any(), any()) } answers { mockk(relaxed = true) } 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 index cd71aad9f..5515ea502 100644 --- 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 @@ -32,7 +32,7 @@ class ObjectsPoolTest { 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) + 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) @@ -92,7 +92,7 @@ class ObjectsPoolTest { 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), objectsPool)) + 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 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 index e7c5d8878..8321b6d37 100644 --- 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 @@ -4,6 +4,7 @@ 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 io.mockk.mockk import org.junit.Test import kotlin.test.assertEquals @@ -13,6 +14,8 @@ 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 @@ -28,8 +31,8 @@ class BaseLiveObjectTest { @Test fun `(RTLO3) BaseLiveObject should have required properties`() { - val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", mockk(), mockk()) - val liveCounter: BaseLiveObject = DefaultLiveCounter.zeroValue("counter:testObject@1", mockk()) + 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) @@ -66,7 +69,7 @@ class BaseLiveObjectTest { @Test fun `(RTLO4a3) canApplyOperation should throw error for null or empty incoming siteSerial`() { - val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", mockk(), mockk()) + val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultLiveObjects) // Test null serial assertFailsWith("Should throw error for null serial") { @@ -91,7 +94,7 @@ class BaseLiveObjectTest { @Test fun `(RTLO4a4, RTLO4a5) canApplyOperation should return true when existing siteSerial is null or empty`() { - val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", mockk(), mockk()) + val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultLiveObjects) assertTrue(liveMap.siteTimeserials.isEmpty(), "Initial siteTimeserials should be empty") // RTLO4a4 - Get siteSerial from siteTimeserials map @@ -107,7 +110,7 @@ class BaseLiveObjectTest { @Test fun `(RTLO4a6) canApplyOperation should return true when message siteSerial is greater than existing siteSerial`() { - val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", mockk(), mockk()) + val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultLiveObjects) // Set existing siteSerial liveMap.siteTimeserials["site1"] = "serial1" @@ -125,7 +128,7 @@ class BaseLiveObjectTest { @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", mockk(), mockk()) + val liveMap: BaseLiveObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultLiveObjects) // Set existing siteSerial liveMap.siteTimeserials["site1"] = "serial2" From 621878cded1d68f2b120554e4356500047905e70 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 18 Jul 2025 16:51:41 +0530 Subject: [PATCH 08/19] [ECO-5457] Refactored LiveCounter, implemented value method 1. Updated LiveCounter#data type to AtomicLong to make it thread safe 2. Updated tests with respective deps 3. Marked objectpool type to ConcurrentHashmap for thread safety --- .../kotlin/io/ably/lib/objects/ObjectsPool.kt | 3 ++- .../type/livecounter/DefaultLiveCounter.kt | 13 +++++++++---- .../type/livecounter/LiveCounterManager.kt | 12 +++++++----- .../lib/objects/type/livemap/DefaultLiveMap.kt | 18 +++++++++--------- .../unit/objects/DefaultLiveObjectsTest.kt | 2 +- .../objects/unit/objects/ObjectsPoolTest.kt | 2 +- .../objects/unit/type/BaseLiveObjectTest.kt | 2 +- 7 files changed, 30 insertions(+), 22 deletions(-) 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 index 4317ee1ae..b46d10090 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt @@ -6,6 +6,7 @@ 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 @@ -39,7 +40,7 @@ internal class ObjectsPool( /** * @spec RTO3a - Pool storing all live objects by object ID */ - private val pool = mutableMapOf() + private val pool = ConcurrentHashMap() /** * Coroutine scope for garbage collection 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 index 656bd74cc..4abbfaf2a 100644 --- 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 @@ -6,6 +6,7 @@ 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.AtomicLong /** * Implementation of LiveObject for LiveCounter. @@ -14,7 +15,7 @@ import io.ably.lib.types.Callback */ internal class DefaultLiveCounter private constructor( objectId: String, - private val liveObjects: DefaultLiveObjects, + liveObjects: DefaultLiveObjects, ) : LiveCounter, BaseLiveObject(objectId, ObjectType.Counter) { override val tag = "LiveCounter" @@ -22,13 +23,16 @@ internal class DefaultLiveCounter private constructor( /** * Counter data value */ - internal var data: Long = 0 // RTLC3 + internal var data = AtomicLong(0)// RTLC3 /** * liveCounterManager instance for managing LiveMap operations */ private val liveCounterManager = LiveCounterManager(this) + private val channelName = liveObjects.channelName + private val adapter = liveObjects.adapter + override fun increment() { TODO("Not yet implemented") } @@ -46,7 +50,8 @@ internal class DefaultLiveCounter private constructor( } override fun value(): Long { - TODO("Not yet implemented") + adapter.throwIfInvalidAccessApiConfiguration(channelName) + return data.get() } override fun applyObjectState(objectState: ObjectState): Map { @@ -58,7 +63,7 @@ internal class DefaultLiveCounter private constructor( } override fun clearData(): Map { - return mapOf("amount" to data).apply { data = 0 } + return mapOf("amount" to data.get()).apply { data.set(0) } } override fun onGCInterval() { 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 index 5bf26f6d5..ba77f53cf 100644 --- 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 @@ -16,14 +16,14 @@ internal class LiveCounterManager(private val liveCounter: DefaultLiveCounter) { * @spec RTLC6 - Overrides counter data with state from sync */ internal fun applyState(objectState: ObjectState): Map { - val previousData = liveCounter.data + 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 = objectState.counter?.count?.toLong() ?: 0 // RTLC6c + liveCounter.data.set(objectState.counter?.count?.toLong() ?: 0) // RTLC6c // RTLC6d objectState.createOp?.let { createOp -> @@ -31,7 +31,7 @@ internal class LiveCounterManager(private val liveCounter: DefaultLiveCounter) { } } - return mapOf("amount" to (liveCounter.data - previousData)) + return mapOf("amount" to (liveCounter.data.get() - previousData)) } /** @@ -78,7 +78,8 @@ internal class LiveCounterManager(private val liveCounter: DefaultLiveCounter) { */ private fun applyCounterInc(counterOp: ObjectCounterOp): Map { val amount = counterOp.amount?.toLong() ?: 0 - liveCounter.data += amount // RTLC9b + val previousValue = liveCounter.data.get() + liveCounter.data.set(previousValue + amount) // RTLC9b return mapOf("amount" to amount) } @@ -91,7 +92,8 @@ internal class LiveCounterManager(private val liveCounter: DefaultLiveCounter) { // 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?.toLong() ?: 0 - liveCounter.data += count // RTLC10a + val previousValue = liveCounter.data.get() + liveCounter.data.set(previousValue + count) // RTLC10a liveCounter.createOperationIsMerged = true // RTLC10b return mapOf("amount" to count) } 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 index 45710954e..05f65b9ae 100644 --- 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 @@ -19,7 +19,7 @@ import java.util.concurrent.ConcurrentHashMap */ internal class DefaultLiveMap private constructor( objectId: String, - objects: DefaultLiveObjects, + liveObjects: DefaultLiveObjects, internal val semantics: MapSemantics = MapSemantics.LWW ) : LiveMap, BaseLiveObject(objectId, ObjectType.Map) { @@ -34,12 +34,12 @@ internal class DefaultLiveMap private constructor( */ private val liveMapManager = LiveMapManager(this) - private val adapter = objects.adapter - internal val objectsPool = objects.objectsPool - private val channelName = objects.channelName + private val channelName = liveObjects.channelName + private val adapter = liveObjects.adapter + internal val objectsPool = liveObjects.objectsPool override fun get(keyName: String): Any? { - adapter.throwIfInvalidAccessApiConfiguration(channelName) + adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM5b, RTLM5c if (isTombstoned) { return null } @@ -50,11 +50,11 @@ internal class DefaultLiveMap private constructor( } override fun entries(): Iterable> { - adapter.throwIfInvalidAccessApiConfiguration(channelName) + adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM11b, RTLM11c return sequence> { for ((key, entry) in data.entries) { - val value = entry.getResolvedValue(objectsPool) + val value = entry.getResolvedValue(objectsPool) // RTLM11d, RTLM11d2 value?.let { yield(AbstractMap.SimpleImmutableEntry(key, it)) } @@ -66,7 +66,7 @@ internal class DefaultLiveMap private constructor( val iterableEntries = entries() return sequence { for (entry in iterableEntries) { - yield(entry.key) + yield(entry.key) // RTLM12b } }.asIterable() } @@ -75,7 +75,7 @@ internal class DefaultLiveMap private constructor( val iterableEntries = entries() return sequence { for (entry in iterableEntries) { - yield(entry.value) + yield(entry.value) // RTLM13b } }.asIterable() } 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 index 6f4c78fc0..9b1f2bdc9 100644 --- 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 @@ -51,7 +51,7 @@ class DefaultLiveObjectsTest { // 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", mockk())) + 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 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 index 5515ea502..5f756af4e 100644 --- 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 @@ -65,7 +65,7 @@ class ObjectsPoolTest { assertNotNull(counter, "Should create a counter object") assertTrue(counter is DefaultLiveCounter, "RTO6b3 - Should create a LiveCounter for counter type") assertEquals(counterId, counter.objectId) - assertEquals(0L, counter.data, "RTO6b3 - Should create a zero-value counter") + assertEquals(0L, 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 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 index 8321b6d37..bc68ef617 100644 --- 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 @@ -148,7 +148,7 @@ class BaseLiveObjectTest { @Test fun `(RTLO4a) canApplyOperation should work with different site codes`() { - val liveMap: BaseLiveObject = DefaultLiveCounter.zeroValue("map:testObject@1", mockk()) + val liveMap: BaseLiveObject = DefaultLiveCounter.zeroValue("map:testObject@1", defaultLiveObjects) // Set serials for different sites liveMap.siteTimeserials["site1"] = "serial1" From 922309af2470632454025c1f603a921b7bd59469 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 18 Jul 2025 19:23:23 +0530 Subject: [PATCH 09/19] [ECO-5457] Refactored LiveMapEntry and LiveObject#isTombstoned to be thread safe --- .../ably/lib/objects/type/BaseLiveObject.kt | 6 +++-- .../lib/objects/type/livemap/LiveMapEntry.kt | 8 +++---- .../objects/type/livemap/LiveMapManager.kt | 24 +++++++++++-------- 3 files changed, 22 insertions(+), 16 deletions(-) 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 index ea4b9e6d6..06231f618 100644 --- 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 @@ -32,7 +32,9 @@ internal abstract class BaseLiveObject( internal var createOperationIsMerged = false // RTLO3c - internal var isTombstoned = false + @Volatile + internal var isTombstoned = false // Accessed from public API for LiveMap/LiveCounter + private var tombstonedAt: Long? = null /** @@ -46,7 +48,7 @@ internal abstract class BaseLiveObject( throw objectError("Invalid object state: object state objectId=${objectState.objectId}; $objectType objectId=$objectId") } - if (objectType == ObjectType.Map && objectState.map?.semantics != MapSemantics.LWW){ + if (objectType == ObjectType.Map && objectState.map?.semantics != MapSemantics.LWW) { throw objectError( "Invalid object state: object state map semantics=${objectState.map?.semantics}; " + "$objectType semantics=${MapSemantics.LWW}") 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 index 75c9db256..e92d78df2 100644 --- 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 @@ -8,10 +8,10 @@ import io.ably.lib.objects.ObjectsPoolDefaults * @spec RTLM3 - Map data structure storing entries */ internal data class LiveMapEntry( - var isTombstoned: Boolean = false, - var tombstonedAt: Long? = null, - var timeserial: String? = null, - var data: ObjectData? = null + val isTombstoned: Boolean = false, + val tombstonedAt: Long? = null, + val timeserial: String? = null, + val data: ObjectData? = null ) /** 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 index e5a15e372..6a738d08b 100644 --- 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 @@ -130,11 +130,13 @@ internal class LiveMapManager(private val liveMap: DefaultLiveMap) { } if (existingEntry != null) { - // RTLM7a2 - existingEntry.isTombstoned = false // RTLM7a2c - existingEntry.tombstonedAt = null - existingEntry.timeserial = timeSerial // RTLM7a2b - existingEntry.data = mapOp.data // RTLM7a2a + // 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( @@ -168,11 +170,13 @@ internal class LiveMapManager(private val liveMap: DefaultLiveMap) { } if (existingEntry != null) { - // RTLM8a2 - existingEntry.isTombstoned = true // RTLM8a2c - existingEntry.tombstonedAt = System.currentTimeMillis() - existingEntry.timeserial = timeSerial // RTLM8a2b - existingEntry.data = null // RTLM8a2a + // 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( From c29f9db042d3d8463d7be70fae8b4f283a540fdd Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Sun, 20 Jul 2025 16:06:43 +0530 Subject: [PATCH 10/19] [ECO-5457] Added integration tests for objects accessors methods --- .../io/ably/lib/realtime/ChannelBase.java | 3 + .../serialization/JsonSerialization.kt | 7 +- .../type/livecounter/DefaultLiveCounter.kt | 4 +- .../objects/type/livemap/DefaultLiveMap.kt | 6 +- .../lib/objects/type/livemap/LiveMapEntry.kt | 10 +- .../integration/DefaultLiveObjectsTest.kt | 125 ++++++++++++ .../lib/objects/integration/LiveObjectTest.kt | 17 -- .../integration/helpers/DataFixtures.kt | 84 ++++++++ .../integration/helpers/PayloadBuilder.kt | 139 +++++++++++++ .../integration/helpers/RestObjects.kt | 182 ++++++++++++++++++ .../integration/setup/IntegrationTest.kt | 3 + .../lib/objects/integration/setup/Sandbox.kt | 24 ++- 12 files changed, 566 insertions(+), 38 deletions(-) create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt delete mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/DataFixtures.kt create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt 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 132450973..4dd78c8eb 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java @@ -1333,6 +1333,9 @@ else if(stateChange.current.equals(failureState)) { state = ChannelState.initialized; this.decodingContext = new DecodingContext(); this.liveObjectsPlugin = liveObjectsPlugin; + if (liveObjectsPlugin != null) { + liveObjectsPlugin.getInstance(name); // Make objects instance ready to process sync messages + } this.annotations = new RealtimeAnnotations( this, new RestAnnotations(name, ably.http, ably.options, options) 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 c60cbee9c..3c1a41a18 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 @@ -82,7 +82,12 @@ internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeseri 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") + else -> { + if (objectId != null) + null + else + throw JsonParseException("Since objectId is not present, at least one of the value fields must be present") + } } return ObjectData(objectId, value) } 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 index 4abbfaf2a..c40fda469 100644 --- 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 @@ -15,7 +15,7 @@ import java.util.concurrent.atomic.AtomicLong */ internal class DefaultLiveCounter private constructor( objectId: String, - liveObjects: DefaultLiveObjects, + private val liveObjects: DefaultLiveObjects, ) : LiveCounter, BaseLiveObject(objectId, ObjectType.Counter) { override val tag = "LiveCounter" @@ -31,7 +31,7 @@ internal class DefaultLiveCounter private constructor( private val liveCounterManager = LiveCounterManager(this) private val channelName = liveObjects.channelName - private val adapter = liveObjects.adapter + private val adapter: LiveObjectsAdapter get() = liveObjects.adapter override fun increment() { TODO("Not yet implemented") 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 index 05f65b9ae..c900a2e39 100644 --- 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 @@ -19,7 +19,7 @@ import java.util.concurrent.ConcurrentHashMap */ internal class DefaultLiveMap private constructor( objectId: String, - liveObjects: DefaultLiveObjects, + private val liveObjects: DefaultLiveObjects, internal val semantics: MapSemantics = MapSemantics.LWW ) : LiveMap, BaseLiveObject(objectId, ObjectType.Map) { @@ -35,8 +35,8 @@ internal class DefaultLiveMap private constructor( private val liveMapManager = LiveMapManager(this) private val channelName = liveObjects.channelName - private val adapter = liveObjects.adapter - internal val objectsPool = liveObjects.objectsPool + private val adapter: LiveObjectsAdapter get() = liveObjects.adapter + internal val objectsPool: ObjectsPool get() = liveObjects.objectsPool override fun get(keyName: String): Any? { adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM5b, RTLM5c 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 index e92d78df2..bb0371183 100644 --- 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 @@ -37,12 +37,10 @@ internal fun LiveMapEntry.isEntryOrRefTombstoned(objectsPool: ObjectsPool): Bool * 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 { primitiveValue -> - return primitiveValue // RTLM5d2b, RTLM5d2c, RTLM5d2d, RTLM5d2e - } + 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) { diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt new file mode 100644 index 000000000..85c4d323a --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt @@ -0,0 +1,125 @@ +package io.ably.lib.objects.integration + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import io.ably.lib.objects.Binary +import io.ably.lib.objects.LiveCounter +import io.ably.lib.objects.LiveMap +import io.ably.lib.objects.integration.helpers.initializeRootMap +import io.ably.lib.objects.integration.setup.IntegrationTest +import io.ably.lib.objects.size +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.text.toByteArray + +class DefaultLiveObjectsTest : IntegrationTest() { + + @Test + fun testChannelObjects() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + val objects = channel.objects + assertNotNull(objects) + } + + /** + * This will test objects sync process when the root map is initialized before channel attach. + * This includes checking the initial values of counters, maps, and other data types. + */ + @Test + fun testObjectsSync() = runTest { + val channelName = generateChannelName() + + // Initialize the root map on the channel with initial data + restObjects.initializeRootMap(channelName) + + val channel = getRealtimeChannel(channelName) + val objects = channel.objects + assertNotNull(objects) + + val rootMap = objects.root + assertNotNull(rootMap) + + // Assert Counter Objects + // Test emptyCounter - should have initial value of 0 + val emptyCounter = rootMap.get("emptyCounter") as LiveCounter + assertNotNull(emptyCounter) + assertEquals(0L, emptyCounter.value()) + + // Test initialValueCounter - should have initial value of 10 + val initialValueCounter = rootMap.get("initialValueCounter") as LiveCounter + assertNotNull(initialValueCounter) + assertEquals(10L, initialValueCounter.value()) + + // Test referencedCounter - should have initial value of 20 + val referencedCounter = rootMap.get("referencedCounter") as LiveCounter + assertNotNull(referencedCounter) + assertEquals(20L, referencedCounter.value()) + + // Assert Map Objects + // Test emptyMap - should be an empty map + val emptyMap = rootMap.get("emptyMap") as LiveMap + assertNotNull(emptyMap) + assertEquals(0L, emptyMap.size()) + + // Test referencedMap - should contain one key "counterKey" pointing to referencedCounter + val referencedMap = rootMap.get("referencedMap") as LiveMap + assertNotNull(referencedMap) + assertEquals(1L, referencedMap.size()) + val referencedMapCounter = referencedMap.get("counterKey") as LiveCounter + assertNotNull(referencedMapCounter) + assertEquals(20L, referencedMapCounter.value()) // Should point to the same counter with value 20 + + // Test valuesMap - should contain all primitive data types and one map reference + val valuesMap = rootMap.get("valuesMap") as LiveMap + assertNotNull(valuesMap) + assertEquals(13L, valuesMap.size()) // Should have 13 entries + + // Assert string values + assertEquals("stringValue", valuesMap.get("string")) + assertEquals("", valuesMap.get("emptyString")) + + // Assert binary values + val bytesValue = valuesMap.get("bytes") as Binary + assertNotNull(bytesValue) + val expectedBinary = Binary("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray()) + assertEquals(expectedBinary, bytesValue) // Should contain encoded JSON data + + val emptyBytesValue = valuesMap.get("emptyBytes") as Binary + assertNotNull(emptyBytesValue) + assertEquals(0, emptyBytesValue.size()) // Should be empty byte array + + // Assert numeric values + assertEquals(99999999.0, valuesMap.get("maxSafeNumber")) + assertEquals(-99999999.0, valuesMap.get("negativeMaxSafeNumber")) + assertEquals(1.0, valuesMap.get("number")) + assertEquals(0.0, valuesMap.get("zero")) + + // Assert boolean values + assertEquals(true, valuesMap.get("true")) + assertEquals(false, valuesMap.get("false")) + + // Assert JSON object value - should contain {"foo": "bar"} + val jsonObjectValue = valuesMap.get("object") as JsonObject + assertNotNull(jsonObjectValue) + assertEquals("bar", jsonObjectValue.get("foo").asString) + + // Assert JSON array value - should contain ["foo", "bar", "baz"] + val jsonArrayValue = valuesMap.get("array") as JsonArray + assertNotNull(jsonArrayValue) + assertEquals(3, jsonArrayValue.size()) + assertEquals("foo", jsonArrayValue[0].asString) + assertEquals("bar", jsonArrayValue[1].asString) + assertEquals("baz", jsonArrayValue[2].asString) + + // Assert map reference - should point to the same referencedMap + val mapRefValue = valuesMap.get("mapRef") as LiveMap + assertNotNull(mapRefValue) + assertEquals(1L, mapRefValue.size()) + val mapRefCounter = mapRefValue.get("counterKey") as LiveCounter + assertNotNull(mapRefCounter) + assertEquals(20L, mapRefCounter.value()) // Should point to the same counter with value 20 + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt deleted file mode 100644 index 7e672e178..000000000 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.ably.lib.objects.integration - -import io.ably.lib.objects.integration.setup.IntegrationTest -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertNotNull - -class LiveObjectTest : IntegrationTest() { - - @Test - fun testChannelObjectGetterTest() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - assertNotNull(objects) - } -} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/DataFixtures.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/DataFixtures.kt new file mode 100644 index 000000000..c70fea41a --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/DataFixtures.kt @@ -0,0 +1,84 @@ +package io.ably.lib.objects.integration.helpers + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import io.ably.lib.objects.Binary +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectValue + +internal object DataFixtures { + + /** Test fixture for string value ("stringValue") data type */ + internal val stringData = ObjectData(value = ObjectValue("stringValue")) + + /** Test fixture for empty string data type */ + internal val emptyStringData = ObjectData(value = ObjectValue("")) + + /** Test fixture for binary data containing encoded JSON */ + internal val bytesData = ObjectData( + value = ObjectValue(Binary("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray()))) + + /** Test fixture for empty binary data (zero-length byte array) */ + internal val emptyBytesData = ObjectData(value = ObjectValue(Binary(ByteArray(0)))) + + /** Test fixture for maximum safe integer value (Long.MAX_VALUE) */ + internal val maxSafeNumberData = ObjectData(value = ObjectValue(99999999)) + + /** Test fixture for minimum safe integer value (Long.MIN_VALUE) */ + internal val negativeMaxSafeNumberData = ObjectData(value = ObjectValue(-99999999)) + + /** Test fixture for positive integer value (1) */ + internal val numberData = ObjectData(value = ObjectValue(1)) + + /** Test fixture for zero integer value */ + internal val zeroData = ObjectData(value = ObjectValue(0)) + + /** Test fixture for boolean true value */ + internal val trueData = ObjectData(value = ObjectValue(true)) + + /** Test fixture for boolean false value */ + internal val falseData = ObjectData(value = ObjectValue(false)) + + /** Test fixture for JSON object value with single property */ + internal val objectData = ObjectData(value = ObjectValue(JsonObject().apply { addProperty("foo", "bar")})) + + /** Test fixture for JSON array value with three string elements */ + internal val arrayData = ObjectData( + value = ObjectValue(JsonArray().apply { + add("foo") + add("bar") + add("baz") + }) + ) + + /** + * Creates an ObjectData instance that references another map object. + * @param referencedMapObjectId The object ID of the referenced map + */ + internal fun mapRef(referencedMapObjectId: String) = ObjectData(objectId = referencedMapObjectId) + + /** + * Creates a test fixture map containing all supported data types and values. + * @param referencedMapObjectId The object ID to be used for the map reference entry + */ + internal fun mapWithAllValues(referencedMapObjectId: String? = null): Map { + val baseMap = mapOf( + "string" to stringData, + "emptyString" to emptyStringData, + "bytes" to bytesData, + "emptyBytes" to emptyBytesData, + "maxSafeNumber" to maxSafeNumberData, + "negativeMaxSafeNumber" to negativeMaxSafeNumberData, + "number" to numberData, + "zero" to zeroData, + "true" to trueData, + "false" to falseData, + "object" to objectData, + "array" to arrayData + ) + referencedMapObjectId?.let { + return baseMap + ("mapRef" to mapRef(it)) + } + return baseMap + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt new file mode 100644 index 000000000..df98df648 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt @@ -0,0 +1,139 @@ +package io.ably.lib.objects.integration.helpers + +import com.google.gson.JsonObject +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.serialization.gson +import java.util.* + +internal object PayloadBuilder { + /** + * Action strings for REST API operations. + * Maps ObjectOperationAction enum values to their string representations. + */ + private val ACTION_STRINGS = mapOf( + ObjectOperationAction.MapCreate to "MAP_CREATE", + ObjectOperationAction.MapSet to "MAP_SET", + ObjectOperationAction.MapRemove to "MAP_REMOVE", + ObjectOperationAction.CounterCreate to "COUNTER_CREATE", + ObjectOperationAction.CounterInc to "COUNTER_INC", + ObjectOperationAction.ObjectDelete to "OBJECT_DELETE" + ) + + /** + * Generates a random nonce string for object creation operations. + */ + private fun nonce(): String = UUID.randomUUID().toString().replace("-", "") + + /** + * Creates a MAP_CREATE operation payload for REST API. + * + * @param objectId Optional specific object ID + * @param data Optional initial data for the map + * @param nonce Optional nonce for deterministic object ID generation + */ + internal fun mapCreateRestOp( + objectId: String? = null, + data: Map? = null, + nonce: String? = null, + ): JsonObject { + val opBody = JsonObject().apply { + addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapCreate]) + } + + if (data != null) { + opBody.add("data", gson.toJsonTree(data)) + } + + if (objectId != null) { + opBody.addProperty("objectId", objectId) + opBody.addProperty("nonce", nonce ?: nonce()) + } + + return opBody + } + + + /** + * Creates a MAP_SET operation payload for REST API. + */ + internal fun mapSetRestOp(objectId: String, key: String, value: ObjectData): JsonObject { + val opBody = JsonObject().apply { + addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapSet]) + addProperty("objectId", objectId) + } + + val dataObj = JsonObject().apply { + addProperty("key", key) + add("value", gson.toJsonTree(value)) + } + opBody.add("data", dataObj) + + return opBody + } + + /** + * Creates a MAP_REMOVE operation payload for REST API. + */ + internal fun mapRemoveRestOp(objectId: String, key: String): JsonObject { + val opBody = JsonObject().apply { + addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapRemove]) + addProperty("objectId", objectId) + } + + val dataObj = JsonObject().apply { + addProperty("key", key) + } + opBody.add("data", dataObj) + + return opBody + } + + /** + * Creates a COUNTER_CREATE operation payload for REST API. + * + * @param objectId Optional specific object ID + * @param nonce Optional nonce for deterministic object ID generation + * @param number Optional initial counter value + */ + internal fun counterCreateRestOp( + objectId: String? = null, + number: Long? = null, + nonce: String? = null, + ): JsonObject { + val opBody = JsonObject().apply { + addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterCreate]) + } + + if (number != null) { + val dataObj = JsonObject().apply { + addProperty("number", number) + } + opBody.add("data", dataObj) + } + + if (objectId != null) { + opBody.addProperty("objectId", objectId) + opBody.addProperty("nonce", nonce ?: nonce()) + } + + return opBody + } + + /** + * Creates a COUNTER_INC operation payload for REST API. + */ + internal fun counterIncRestOp(objectId: String, number: Long): JsonObject { + val opBody = JsonObject().apply { + addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterInc]) + addProperty("objectId", objectId) + } + + val dataObj = JsonObject().apply { + addProperty("number", number) + } + opBody.add("data", dataObj) + + return opBody + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt new file mode 100644 index 000000000..4b45bcd70 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt @@ -0,0 +1,182 @@ +package io.ably.lib.objects.integration.helpers + +import com.google.gson.JsonObject +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectValue +import io.ably.lib.rest.AblyRest +import io.ably.lib.http.HttpUtils +import io.ably.lib.types.ClientOptions + +/** + * Helper class to create pre-determined objects and modify them on channels using rest api. + */ +internal class RestObjects(options: ClientOptions) { + + private val ablyRest: AblyRest = AblyRest(options) + + /** + * Creates a new map object on the channel with optional initial data. + * @return The object ID of the created map + */ + internal fun createMap(channelName: String, data: Map? = null): String { + val mapCreateOp = PayloadBuilder.mapCreateRestOp(data = data) + return operationRequest(channelName, mapCreateOp).objectId ?: + throw Exception("Failed to create map: no objectId returned") + } + + /** + * Sets a value (primitives, JsonObject, JsonArray, etc.) at the specified key in an existing map. + */ + internal fun setMapValue(channelName: String, mapObjectId: String, key: String, value: ObjectValue) { + val data = ObjectData(value = value) + val mapCreateOp = PayloadBuilder.mapSetRestOp(mapObjectId, key, data) + operationRequest(channelName, mapCreateOp) + } + + /** + * Sets an object reference at the specified key in an existing map. + */ + internal fun setMapRef(channelName: String, mapObjectId: String, key: String, refMapObjectId: String) { + val data = ObjectData(objectId = refMapObjectId) + val mapCreateOp = PayloadBuilder.mapSetRestOp(mapObjectId, key, data) + operationRequest(channelName, mapCreateOp) + } + + /** + * Removes a key-value pair from an existing map. + */ + internal fun removeMapValue(channelName: String, mapObjectId: String, key: String) { + val mapRemoveOp = PayloadBuilder.mapRemoveRestOp(mapObjectId, key) + operationRequest(channelName, mapRemoveOp) + } + + /** + * Creates a new counter object with an optional initial value (defaults to 0). + * @return The object ID of the created counter + */ + internal fun createCounter(channelName: String, initialValue: Long? = null): String { + val counterCreateOp = PayloadBuilder.counterCreateRestOp(number = initialValue) + return operationRequest(channelName, counterCreateOp).objectId + ?: throw Exception("Failed to create counter: no objectId returned") + } + + /** + * Increments an existing counter by the specified amount. + */ + internal fun incrementCounter(channelName: String, counterObjectId: String, incrementBy: Long) { + val counterIncrementOp = PayloadBuilder.counterIncRestOp(counterObjectId, incrementBy) + operationRequest(channelName, counterIncrementOp) + } + + /** + * Decrements an existing counter by the specified amount. + */ + internal fun decrementCounter(channelName: String, counterObjectId: String, decrementBy: Long) { + val counterDecrementOp = PayloadBuilder.counterIncRestOp(counterObjectId, -decrementBy) + operationRequest(channelName, counterDecrementOp) + } + + /** + * Core method that executes object operations by sending POST requests to Ably's Objects REST API. + * All public methods delegate to this for actual API communication. + */ + private fun operationRequest(channelName: String, opBody: JsonObject): OperationResult { + try { + val path = "/channels/$channelName/objects" + val requestBody = HttpUtils.requestBodyFromGson(opBody, ablyRest.options.useBinaryProtocol) + + val response = ablyRest.request("POST", path, null, requestBody, null) + + if (!response.success) { + throw Exception("REST operation failed: HTTP ${response.statusCode} - ${response.errorMessage}") + } + + val responseItems = response.items() + if (responseItems.isEmpty()) { + return OperationResult(null, null, success = true) + } + + // Process first response item + responseItems[0].asJsonObject.let { firstItem -> + val objectIds = firstItem.get("objectIds")?.let { element -> + if (element.isJsonArray) element.asJsonArray.map { it.asString } else null + } + return OperationResult(objectIds?.firstOrNull(), objectIds, success = true) + } + } catch (e: Exception) { + throw Exception("Failed to execute operation request: ${e.message}", e) + } + } + + /** + * Result class for operation requests containing the response data and extracted object ID. + */ + private data class OperationResult( + val objectId: String?, + val objectIds: List? = null, // Seems only used for batch operations + val success: Boolean = true + ) +} + +/** + * Initializes a comprehensive test fixture object tree on the specified channel. + * + * This method creates a predetermined object hierarchy rooted at a "root" map object, + * establishing references between different types of live objects to enable comprehensive testing. + * + * **Object Tree Structure:** + * ``` + * root (Map) + * ├── emptyCounter → Counter(value=0) + * ├── initialValueCounter → Counter(value=10) + * ├── referencedCounter → Counter(value=20) + * ├── emptyMap → Map{} + * ├── referencedMap → Map{ + * │ └── "counterKey" → referencedCounter + * │ } + * └── valuesMap → Map{ + * ├── "string" → "stringValue" + * ├── "emptyString" → "" + * ├── "bytes" → + * ├── "emptyBytes" → + * ├── "maxSafeInteger" → Long.MAX_VALUE + * ├── "negativeMaxSafeInteger" → Long.MIN_VALUE + * ├── "number" → 1 + * ├── "zero" → 0 + * ├── "true" → true + * ├── "false" → false + * ├── "object" → {"foo": "bar"} + * ├── "array" → ["foo", "bar", "baz"] + * └── "mapRef" → referencedMap + * } + * ``` + * + * @param channelName The channel where the object tree will be created + */ +internal fun RestObjects.initializeRootMap(channelName: String) { + // Create counters + val emptyCounterObjectId = createCounter(channelName) + setMapRef(channelName, "root", "emptyCounter", emptyCounterObjectId) + + val initialValueCounterObjectId = createCounter(channelName, 10) + setMapRef(channelName, "root", "initialValueCounter", initialValueCounterObjectId) + + val referencedCounterObjectId = createCounter(channelName, 20) + setMapRef(channelName, "root", "referencedCounter", referencedCounterObjectId) + + // Create maps + val emptyMapObjectId = createMap(channelName) + setMapRef(channelName, "root", "emptyMap", emptyMapObjectId) + + val referencedMapObjectId = createMap( + channelName, + data = mapOf("counterKey" to DataFixtures.mapRef(referencedCounterObjectId)) + ) + setMapRef(channelName, "root", "referencedMap", referencedMapObjectId) + + val valuesMapObjectId = createMap( + channelName, + data = DataFixtures.mapWithAllValues(referencedMapObjectId) + ) + setMapRef(channelName, "root", "valuesMap", valuesMapObjectId) +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt index ea323124b..988b6b0b5 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt @@ -1,5 +1,6 @@ package io.ably.lib.objects.integration.setup +import io.ably.lib.objects.integration.helpers.RestObjects import io.ably.lib.realtime.AblyRealtime import io.ably.lib.realtime.Channel import io.ably.lib.types.ChannelMode @@ -73,6 +74,7 @@ abstract class IntegrationTest { companion object { private lateinit var sandbox: Sandbox + internal lateinit var restObjects: RestObjects @JvmStatic @Parameterized.Parameters(name = "{0}") @@ -86,6 +88,7 @@ abstract class IntegrationTest { fun setUpBeforeClass() { runBlocking { sandbox = Sandbox.createInstance() + restObjects = sandbox.createRestObjects() } } diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt index 7d2b05586..f38009450 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt @@ -3,20 +3,17 @@ package io.ably.lib.objects.integration.setup import com.google.gson.JsonElement import com.google.gson.JsonParser import io.ably.lib.objects.ablyException +import io.ably.lib.objects.integration.helpers.RestObjects import io.ably.lib.realtime.* import io.ably.lib.types.ClientOptions -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.network.sockets.ConnectTimeoutException -import io.ktor.client.network.sockets.SocketTimeoutException -import io.ktor.client.plugins.HttpRequestRetry -import io.ktor.client.plugins.HttpRequestTimeoutException +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.network.sockets.* +import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.contentType -import io.ktor.http.isSuccess +import io.ktor.http.* import kotlinx.coroutines.CompletableDeferred private val client = HttpClient(CIO) { @@ -69,6 +66,15 @@ internal fun Sandbox.createRealtimeClient(options: ClientOptions.() -> Unit): Ab return AblyRealtime(clientOptions) } +internal fun Sandbox.createRestObjects(): RestObjects { + val options = ClientOptions().apply { + key = apiKey + environment = "sandbox" + useBinaryProtocol = false + } + return RestObjects(options) +} + internal suspend fun AblyRealtime.ensureConnected() { if (this.connection.state == ConnectionState.connected) { return From 6667b4de217d19d0b7a1c7b974dbba596869c91d Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 21 Jul 2025 15:20:37 +0530 Subject: [PATCH 11/19] [ECO-5457] Added integration tests LiveMap accessors, created fixtures for the same --- .../objects/integration/DefaultLiveMapTest.kt | 213 ++++++++++++++++++ .../integration/DefaultLiveObjectsTest.kt | 78 ++++++- .../integration/helpers/PayloadBuilder.kt | 1 - .../integration/helpers/RestObjects.kt | 62 ----- .../lib/objects/integration/helpers/Utils.kt | 14 ++ .../helpers/{ => fixtures}/DataFixtures.kt | 2 +- .../helpers/fixtures/MapFixtures.kt | 157 +++++++++++++ .../integration/setup/IntegrationTest.kt | 8 +- 8 files changed, 460 insertions(+), 75 deletions(-) create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt rename live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/{ => fixtures}/DataFixtures.kt (98%) create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt new file mode 100644 index 000000000..fabfa3f12 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt @@ -0,0 +1,213 @@ +package io.ably.lib.objects.integration + +import io.ably.lib.objects.* +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectValue +import io.ably.lib.objects.integration.helpers.fixtures.createUserMapObject +import io.ably.lib.objects.integration.setup.IntegrationTest +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class DefaultLiveMapTest: IntegrationTest() { + /** + * Tests the synchronization process when a user map object is initialized before channel attach. + * This includes checking the initial values of all nested maps, counters, and primitive data types + * in the comprehensive user map object structure. + */ + @Test + fun testLiveMapSync() = runTest { + val channelName = generateChannelName() + val userMapObjectId = restObjects.createUserMapObject(channelName) + restObjects.setMapRef(channelName, "root", "user", userMapObjectId) + + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + + // Get the user map object from the root map + val userMap = rootMap.get("user") as LiveMap + assertNotNull(userMap, "User map should be synchronized") + assertEquals(5L, userMap.size(), "User map should contain 5 top-level entries") + + // Assert Counter Objects + // Test loginCounter - should have initial value of 5 + val loginCounter = userMap.get("loginCounter") as LiveCounter + assertNotNull(loginCounter, "Login counter should exist") + assertEquals(5L, loginCounter.value(), "Login counter should have initial value of 5") + + // Test sessionCounter - should have initial value of 0 + val sessionCounter = userMap.get("sessionCounter") as LiveCounter + assertNotNull(sessionCounter, "Session counter should exist") + assertEquals(0L, sessionCounter.value(), "Session counter should have initial value of 0") + + // Assert User Profile Map + val userProfile = userMap.get("userProfile") as LiveMap + assertNotNull(userProfile, "User profile map should exist") + assertEquals(6L, userProfile.size(), "User profile should contain 6 entries") + + // Assert user profile primitive values + assertEquals("user123", userProfile.get("userId"), "User ID should match expected value") + assertEquals("John Doe", userProfile.get("name"), "User name should match expected value") + assertEquals("john@example.com", userProfile.get("email"), "User email should match expected value") + assertEquals(true, userProfile.get("isActive"), "User should be active") + + // Assert Preferences Map (nested within user profile) + val preferences = userProfile.get("preferences") as LiveMap + assertNotNull(preferences, "Preferences map should exist") + assertEquals(4L, preferences.size(), "Preferences should contain 4 entries") + assertEquals("dark", preferences.get("theme"), "Theme preference should be dark") + assertEquals(true, preferences.get("notifications"), "Notifications should be enabled") + assertEquals("en", preferences.get("language"), "Language should be English") + assertEquals(3.0, preferences.get("maxRetries"), "Max retries should be 3") + + // Assert Metrics Map (nested within user profile) + val metrics = userProfile.get("metrics") as LiveMap + assertNotNull(metrics, "Metrics map should exist") + assertEquals(4L, metrics.size(), "Metrics should contain 4 entries") + assertEquals("2024-01-01T08:30:00Z", metrics.get("lastLoginTime"), "Last login time should match") + assertEquals(42.0, metrics.get("profileViews"), "Profile views should be 42") + + // Test counter references within metrics map + val totalLoginsCounter = metrics.get("totalLogins") as LiveCounter + assertNotNull(totalLoginsCounter, "Total logins counter should exist") + assertEquals(5L, totalLoginsCounter.value(), "Total logins should reference login counter with value 5") + + val activeSessionsCounter = metrics.get("activeSessions") as LiveCounter + assertNotNull(activeSessionsCounter, "Active sessions counter should exist") + assertEquals(0L, activeSessionsCounter.value(), "Active sessions should reference session counter with value 0") + + // Assert direct references to maps from top-level user map + val preferencesMapRef = userMap.get("preferencesMap") as LiveMap + assertNotNull(preferencesMapRef, "Preferences map reference should exist") + assertEquals(4L, preferencesMapRef.size(), "Referenced preferences map should have 4 entries") + assertEquals("dark", preferencesMapRef.get("theme"), "Referenced preferences should match nested preferences") + + val metricsMapRef = userMap.get("metricsMap") as LiveMap + assertNotNull(metricsMapRef, "Metrics map reference should exist") + assertEquals(4L, metricsMapRef.size(), "Referenced metrics map should have 4 entries") + assertEquals("2024-01-01T08:30:00Z", metricsMapRef.get("lastLoginTime"), "Referenced metrics should match nested metrics") + + // Verify that references point to the same objects + assertEquals(preferences.get("theme"), preferencesMapRef.get("theme"), "Preference references should point to same data") + assertEquals(metrics.get("profileViews"), metricsMapRef.get("profileViews"), "Metrics references should point to same data") + } + + /** + * Tests sequential map operations including creation with initial data, updating existing fields, + * adding new fields, and removing fields. Validates the resulting data after each operation. + */ + @Test + fun testLiveMapOperations() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + + // Step 1: Create a new map with initial data + val testMapObjectId = restObjects.createMap( + channelName, + data = mapOf( + "name" to ObjectData(value = ObjectValue("Alice")), + "age" to ObjectData(value = ObjectValue(30)), + "isActive" to ObjectData(value = ObjectValue(true)) + ) + ) + restObjects.setMapRef(channelName, "root", "testMap", testMapObjectId) + + // wait for updated testMap to be available in the root map + assertWaiter { rootMap.get("testMap") != null } + + // Assert initial state after creation + val testMap = rootMap.get("testMap") as LiveMap + assertNotNull(testMap, "Test map should be created and accessible") + assertEquals(3L, testMap.size(), "Test map should have 3 initial entries") + assertEquals("Alice", testMap.get("name"), "Initial name should be Alice") + assertEquals(30.0, testMap.get("age"), "Initial age should be 30") + assertEquals(true, testMap.get("isActive"), "Initial active status should be true") + + // Step 2: Update an existing field (name from "Alice" to "Bob") + restObjects.setMapValue(channelName, testMapObjectId, "name", ObjectValue("Bob")) + // Wait for the map to be updated + assertWaiter { testMap.get("name") == "Bob" } + + // Assert after updating existing field + assertEquals(3L, testMap.size(), "Map size should remain the same after update") + assertEquals("Bob", testMap.get("name"), "Name should be updated to Bob") + assertEquals(30.0, testMap.get("age"), "Age should remain unchanged") + assertEquals(true, testMap.get("isActive"), "Active status should remain unchanged") + + // Step 3: Add a new field (email) + restObjects.setMapValue(channelName, testMapObjectId, "email", ObjectValue("bob@example.com")) + // Wait for the map to be updated + assertWaiter { testMap.get("email") == "bob@example.com" } + + // Assert after adding new field + assertEquals(4L, testMap.size(), "Map size should increase after adding new field") + assertEquals("Bob", testMap.get("name"), "Name should remain Bob") + assertEquals(30.0, testMap.get("age"), "Age should remain unchanged") + assertEquals(true, testMap.get("isActive"), "Active status should remain unchanged") + assertEquals("bob@example.com", testMap.get("email"), "Email should be added successfully") + + // Step 4: Add another new field with different data type (score as number) + restObjects.setMapValue(channelName, testMapObjectId, "score", ObjectValue(85)) + // Wait for the map to be updated + assertWaiter { testMap.get("score") == 85.0 } + + // Assert after adding second new field + assertEquals(5L, testMap.size(), "Map size should increase to 5 after adding score") + assertEquals("Bob", testMap.get("name"), "Name should remain Bob") + assertEquals(30.0, testMap.get("age"), "Age should remain unchanged") + assertEquals(true, testMap.get("isActive"), "Active status should remain unchanged") + assertEquals("bob@example.com", testMap.get("email"), "Email should remain unchanged") + assertEquals(85.0, testMap.get("score"), "Score should be added as numeric value") + + // Step 5: Update the boolean field + restObjects.setMapValue(channelName, testMapObjectId, "isActive", ObjectValue(false)) + // Wait for the map to be updated + assertWaiter { testMap.get("isActive") == false } + + // Assert after updating boolean field + assertEquals(5L, testMap.size(), "Map size should remain 5 after boolean update") + assertEquals("Bob", testMap.get("name"), "Name should remain Bob") + assertEquals(30.0, testMap.get("age"), "Age should remain unchanged") + assertEquals(false, testMap.get("isActive"), "Active status should be updated to false") + assertEquals("bob@example.com", testMap.get("email"), "Email should remain unchanged") + assertEquals(85.0, testMap.get("score"), "Score should remain unchanged") + + // Step 6: Remove a field (age) + restObjects.removeMapValue(channelName, testMapObjectId, "age") + // Wait for the map to be updated + assertWaiter { testMap.get("age") == null } + + // Assert after removing field + assertEquals(4L, testMap.size(), "Map size should decrease to 4 after removing age") + assertEquals("Bob", testMap.get("name"), "Name should remain Bob") + assertNull(testMap.get("age"), "Age should be removed and return null") + assertEquals(false, testMap.get("isActive"), "Active status should remain false") + assertEquals("bob@example.com", testMap.get("email"), "Email should remain unchanged") + assertEquals(85.0, testMap.get("score"), "Score should remain unchanged") + + // Step 7: Remove another field (score) + restObjects.removeMapValue(channelName, testMapObjectId, "score") + // Wait for the map to be updated + assertWaiter { testMap.get("score") == null } + + // Assert final state after second removal + assertEquals(3L, testMap.size(), "Map size should decrease to 3 after removing score") + assertEquals("Bob", testMap.get("name"), "Name should remain Bob") + assertEquals(false, testMap.get("isActive"), "Active status should remain false") + assertEquals("bob@example.com", testMap.get("email"), "Email should remain unchanged") + assertNull(testMap.get("score"), "Score should be removed and return null") + assertNull(testMap.get("age"), "Age should remain null") + + // Final verification - ensure all expected keys exist and unwanted keys don't + assertEquals(3, testMap.size(), "Final map should have exactly 3 entries") + + val finalKeys = testMap.keys().toSet() + assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") + + val finalValues = testMap.values().toSet() + assertEquals(setOf("Bob", false, "bob@example.com"), finalValues, "Final values should match expected set") + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt index 85c4d323a..7c96a75f6 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt @@ -2,16 +2,18 @@ package io.ably.lib.objects.integration import com.google.gson.JsonArray import com.google.gson.JsonObject +import io.ably.lib.objects.* import io.ably.lib.objects.Binary -import io.ably.lib.objects.LiveCounter -import io.ably.lib.objects.LiveMap -import io.ably.lib.objects.integration.helpers.initializeRootMap +import io.ably.lib.objects.integration.helpers.State +import io.ably.lib.objects.integration.helpers.fixtures.initializeRootMap import io.ably.lib.objects.integration.setup.IntegrationTest import io.ably.lib.objects.size +import io.ably.lib.objects.state.ObjectsStateEvent import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.text.toByteArray class DefaultLiveObjectsTest : IntegrationTest() { @@ -24,6 +26,37 @@ class DefaultLiveObjectsTest : IntegrationTest() { assertNotNull(objects) } + @Test + fun testObjectsSyncEvents() = runTest { + val channelName = generateChannelName() + // Initialize the root map on the channel with initial data + restObjects.initializeRootMap(channelName) + + val channel = getRealtimeChannel(channelName, autoAttach = false) + val objects = channel.objects + assertNotNull(objects) + + assertEquals(ObjectsState.INITIALIZED, objects.State, "Initial state should be INITIALIZED") + + val syncStates = mutableListOf() + objects.on(ObjectsStateEvent.SYNCING) { + syncStates.add(it) + } + objects.on(ObjectsStateEvent.SYNCED) { + syncStates.add(it) + } + + channel.attach() + + assertWaiter { syncStates.size == 2 } // Wait for both SYNCING and SYNCED events + + assertEquals(ObjectsStateEvent.SYNCING, syncStates[0], "First event should be SYNCING") + assertEquals(ObjectsStateEvent.SYNCED, syncStates[1], "Second event should be SYNCED") + + val rootMap = objects.root + assertEquals(6, rootMap.size(), "Root map should have 6 entries after sync") + } + /** * This will test objects sync process when the root map is initialized before channel attach. * This includes checking the initial values of counters, maps, and other data types. @@ -31,15 +64,11 @@ class DefaultLiveObjectsTest : IntegrationTest() { @Test fun testObjectsSync() = runTest { val channelName = generateChannelName() - // Initialize the root map on the channel with initial data restObjects.initializeRootMap(channelName) val channel = getRealtimeChannel(channelName) - val objects = channel.objects - assertNotNull(objects) - - val rootMap = objects.root + val rootMap = channel.objects.root assertNotNull(rootMap) // Assert Counter Objects @@ -122,4 +151,37 @@ class DefaultLiveObjectsTest : IntegrationTest() { assertNotNull(mapRefCounter) assertEquals(20L, mapRefCounter.value()) // Should point to the same counter with value 20 } + + /** + * Spec: RTLO4e - Tests the removal of objects from the root map. + * Server runs periodic garbage collection (GC) to remove orphaned objects and will send + * OBJECT_DELETE events for objects that are no longer referenced. + * `OBJECT_DELETE` event is not covered in the test and we only check if map entries are removed + */ + @Test + fun testObjectRemovalFromRoot() = runTest { + val channelName = generateChannelName() + // Initialize the root map on the channel with initial data + restObjects.initializeRootMap(channelName) + + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + assertEquals(6L, rootMap.size()) // Should have 6 entries initially + + // Remove the "referencedCounter" from the root map + assertNotNull(rootMap.get("referencedCounter")) // Access to ensure it exists before removal + + restObjects.removeMapValue(channelName, "root", "referencedCounter") + + assertWaiter { rootMap.size() == 5L } // Wait for the removal to complete + assertNull(rootMap.get("referencedCounter")) // Should be null after removal + + // Remove the "referencedMap" from the root map + assertNotNull(rootMap.get("referencedMap")) // Access to ensure it exists before removal + + restObjects.removeMapValue(channelName, "root", "referencedMap") + + assertWaiter { rootMap.size() == 4L } // Wait for the removal to complete + assertNull(rootMap.get("referencedMap")) // Should be null after removal + } } diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt index df98df648..57e841b4c 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt @@ -17,7 +17,6 @@ internal object PayloadBuilder { ObjectOperationAction.MapRemove to "MAP_REMOVE", ObjectOperationAction.CounterCreate to "COUNTER_CREATE", ObjectOperationAction.CounterInc to "COUNTER_INC", - ObjectOperationAction.ObjectDelete to "OBJECT_DELETE" ) /** diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt index 4b45bcd70..820b144ab 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt @@ -118,65 +118,3 @@ internal class RestObjects(options: ClientOptions) { ) } -/** - * Initializes a comprehensive test fixture object tree on the specified channel. - * - * This method creates a predetermined object hierarchy rooted at a "root" map object, - * establishing references between different types of live objects to enable comprehensive testing. - * - * **Object Tree Structure:** - * ``` - * root (Map) - * ├── emptyCounter → Counter(value=0) - * ├── initialValueCounter → Counter(value=10) - * ├── referencedCounter → Counter(value=20) - * ├── emptyMap → Map{} - * ├── referencedMap → Map{ - * │ └── "counterKey" → referencedCounter - * │ } - * └── valuesMap → Map{ - * ├── "string" → "stringValue" - * ├── "emptyString" → "" - * ├── "bytes" → - * ├── "emptyBytes" → - * ├── "maxSafeInteger" → Long.MAX_VALUE - * ├── "negativeMaxSafeInteger" → Long.MIN_VALUE - * ├── "number" → 1 - * ├── "zero" → 0 - * ├── "true" → true - * ├── "false" → false - * ├── "object" → {"foo": "bar"} - * ├── "array" → ["foo", "bar", "baz"] - * └── "mapRef" → referencedMap - * } - * ``` - * - * @param channelName The channel where the object tree will be created - */ -internal fun RestObjects.initializeRootMap(channelName: String) { - // Create counters - val emptyCounterObjectId = createCounter(channelName) - setMapRef(channelName, "root", "emptyCounter", emptyCounterObjectId) - - val initialValueCounterObjectId = createCounter(channelName, 10) - setMapRef(channelName, "root", "initialValueCounter", initialValueCounterObjectId) - - val referencedCounterObjectId = createCounter(channelName, 20) - setMapRef(channelName, "root", "referencedCounter", referencedCounterObjectId) - - // Create maps - val emptyMapObjectId = createMap(channelName) - setMapRef(channelName, "root", "emptyMap", emptyMapObjectId) - - val referencedMapObjectId = createMap( - channelName, - data = mapOf("counterKey" to DataFixtures.mapRef(referencedCounterObjectId)) - ) - setMapRef(channelName, "root", "referencedMap", referencedMapObjectId) - - val valuesMapObjectId = createMap( - channelName, - data = DataFixtures.mapWithAllValues(referencedMapObjectId) - ) - setMapRef(channelName, "root", "valuesMap", valuesMapObjectId) -} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt new file mode 100644 index 000000000..e402347e7 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt @@ -0,0 +1,14 @@ +package io.ably.lib.objects.integration.helpers + +import io.ably.lib.objects.DefaultLiveObjects +import io.ably.lib.objects.LiveCounter +import io.ably.lib.objects.LiveMap +import io.ably.lib.objects.LiveObjects +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap + +internal val LiveMap.ObjectId get() = (this as DefaultLiveMap).objectId + +internal val LiveCounter.ObjectId get() = (this as DefaultLiveCounter).objectId + +internal val LiveObjects.State get() = (this as DefaultLiveObjects).state diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/DataFixtures.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt similarity index 98% rename from live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/DataFixtures.kt rename to live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt index c70fea41a..b23348693 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/DataFixtures.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt @@ -1,4 +1,4 @@ -package io.ably.lib.objects.integration.helpers +package io.ably.lib.objects.integration.helpers.fixtures import com.google.gson.JsonArray import com.google.gson.JsonObject diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt new file mode 100644 index 000000000..8013c8b27 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt @@ -0,0 +1,157 @@ +package io.ably.lib.objects.integration.helpers.fixtures + +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectValue +import io.ably.lib.objects.integration.helpers.RestObjects + +/** + * Initializes a comprehensive test fixture object tree on the specified channel. + * + * This method creates a predetermined object hierarchy rooted at a "root" map object, + * establishing references between different types of live objects to enable comprehensive testing. + * + * **Object Tree Structure:** + * ``` + * root (Map) + * ├── emptyCounter → Counter(value=0) + * ├── initialValueCounter → Counter(value=10) + * ├── referencedCounter → Counter(value=20) + * ├── emptyMap → Map{} + * ├── referencedMap → Map{ + * │ └── "counterKey" → referencedCounter + * │ } + * └── valuesMap → Map{ + * ├── "string" → "stringValue" + * ├── "emptyString" → "" + * ├── "bytes" → + * ├── "emptyBytes" → + * ├── "maxSafeInteger" → Long.MAX_VALUE + * ├── "negativeMaxSafeInteger" → Long.MIN_VALUE + * ├── "number" → 1 + * ├── "zero" → 0 + * ├── "true" → true + * ├── "false" → false + * ├── "object" → {"foo": "bar"} + * ├── "array" → ["foo", "bar", "baz"] + * └── "mapRef" → referencedMap + * } + * ``` + * + * @param channelName The channel where the object tree will be created + */ +internal fun RestObjects.initializeRootMap(channelName: String) { + // Create counters + val emptyCounterObjectId = createCounter(channelName) + setMapRef(channelName, "root", "emptyCounter", emptyCounterObjectId) + + val initialValueCounterObjectId = createCounter(channelName, 10) + setMapRef(channelName, "root", "initialValueCounter", initialValueCounterObjectId) + + val referencedCounterObjectId = createCounter(channelName, 20) + setMapRef(channelName, "root", "referencedCounter", referencedCounterObjectId) + + // Create maps + val emptyMapObjectId = createMap(channelName) + setMapRef(channelName, "root", "emptyMap", emptyMapObjectId) + + val referencedMapObjectId = createMap( + channelName, + data = mapOf("counterKey" to DataFixtures.mapRef(referencedCounterObjectId)) + ) + setMapRef(channelName, "root", "referencedMap", referencedMapObjectId) + + val valuesMapObjectId = createMap( + channelName, + data = DataFixtures.mapWithAllValues(referencedMapObjectId) + ) + setMapRef(channelName, "root", "valuesMap", valuesMapObjectId) +} + + +/** + * Creates a comprehensive test fixture object tree on the specified channel using + * + * This method establishes a hierarchical structure of live objects for testing map operations, + * creating various types of objects and establishing references between them. + * + * **Object Tree Structure:** + * ``` + * testMap (Map) + * ├── userProfile → Map{ + * │ ├── "userId" → "user123" + * │ ├── "name" → "John Doe" + * │ ├── "email" → "john@example.com" + * │ ├── "isActive" → true + * │ ├── "metrics" → metricsMap + * │ └── "preferences" → preferencesMap + * │ } + * ├── loginCounter → Counter(value=5) + * ├── sessionCounter → Counter(value=0) + * ├── preferencesMap → Map{ + * │ ├── "theme" → "dark" + * │ ├── "notifications" → true + * │ ├── "language" → "en" + * │ └── "maxRetries" → 3 + * │ } + * └── metricsMap → Map{ + * ├── "totalLogins" → loginCounter + * ├── "activeSessions" → sessionCounter + * ├── "lastLoginTime" → "2024-01-01T08:30:00Z" + * └── "profileViews" → 42 + * } + * ``` + * + * @param channelName The channel where the test object tree will be created + */ +internal fun RestObjects.createUserMapObject(channelName: String): String { + // Create the main test map first + val testMapObjectId = createMap(channelName) + + // Create counter objects for testing numeric operations + val loginCounterObjectId = createCounter(channelName, 5) + val sessionCounterObjectId = createCounter(channelName, 0) + + // Create a preferences map with various data types + val preferencesMapObjectId = createMap( + channelName, + data = mapOf( + "theme" to ObjectData(value = ObjectValue("dark")), + "notifications" to ObjectData(value = ObjectValue(true)), + "language" to ObjectData(value = ObjectValue("en")), + "maxRetries" to ObjectData(value = ObjectValue(3)) + ) + ) + + // Create a metrics map that tracks single user activity + val metricsMapObjectId = createMap( + channelName, + data = mapOf( + "totalLogins" to DataFixtures.mapRef(loginCounterObjectId), + "activeSessions" to DataFixtures.mapRef(sessionCounterObjectId), + "lastLoginTime" to ObjectData(value = ObjectValue("2024-01-01T08:30:00Z")), + "profileViews" to ObjectData(value = ObjectValue(42)) + ) + ) + + // Create a user profile map with mixed data types and references + val userProfileMapObjectId = createMap( + channelName, + data = mapOf( + "userId" to ObjectData(value = ObjectValue("user123")), + "name" to ObjectData(value = ObjectValue("John Doe")), + "email" to ObjectData(value = ObjectValue("john@example.com")), + "isActive" to ObjectData(value = ObjectValue(true)), + "metrics" to DataFixtures.mapRef(metricsMapObjectId), + "preferences" to DataFixtures.mapRef(preferencesMapObjectId) + ) + ) + + // Set up the main test map structure with references to all created objects + setMapRef(channelName, testMapObjectId, "userProfile", userProfileMapObjectId) + setMapRef(channelName, testMapObjectId, "loginCounter", loginCounterObjectId) + setMapRef(channelName, testMapObjectId, "sessionCounter", sessionCounterObjectId) + setMapRef(channelName, testMapObjectId, "preferencesMap", preferencesMapObjectId) + setMapRef(channelName, testMapObjectId, "metricsMap", metricsMapObjectId) + + return testMapObjectId +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt index 988b6b0b5..b79f24a04 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt @@ -36,7 +36,7 @@ abstract class IntegrationTest { * @return The attached realtime channel. * @throws Exception If the channel fails to attach or the client fails to connect. */ - internal suspend fun getRealtimeChannel(channelName: String, clientId: String = "client1"): Channel { + internal suspend fun getRealtimeChannel(channelName: String, clientId: String = "client1", autoAttach: Boolean = true): Channel { val client = realtimeClients.getOrPut(clientId) { sandbox.createRealtimeClient { this.clientId = clientId @@ -47,8 +47,10 @@ abstract class IntegrationTest { modes = arrayOf(ChannelMode.object_publish, ChannelMode.object_subscribe) } return client.channels.get(channelName, channelOpts).apply { - attach() - ensureAttached() + if (autoAttach) { + attach() + ensureAttached() + } } } From fd1d2cfd73ef60e244cda104f466300dcc1da904 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 21 Jul 2025 16:07:00 +0530 Subject: [PATCH 12/19] [ECO-5457] Added integration tests LiveCounter accessors --- .../integration/DefaultLiveCounterTest.kt | 205 ++++++++++++++++++ .../integration/helpers/PayloadBuilder.kt | 9 +- .../integration/helpers/RestObjects.kt | 1 - .../helpers/fixtures/CounterFixtures.kt | 65 ++++++ 4 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt new file mode 100644 index 000000000..632ac9683 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt @@ -0,0 +1,205 @@ +package io.ably.lib.objects.integration + +import io.ably.lib.objects.LiveCounter +import io.ably.lib.objects.LiveMap +import io.ably.lib.objects.assertWaiter +import io.ably.lib.objects.integration.helpers.fixtures.createUserMapWithCountersObject +import io.ably.lib.objects.integration.setup.IntegrationTest +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class DefaultLiveCounterTest: IntegrationTest() { + /** + * Tests the synchronization process when a user map object with counters is initialized before channel attach. + * This includes checking the initial values of all counter objects and nested maps in the + * comprehensive user engagement counter structure. + */ + @Test + fun testLiveCounterSync() = runTest { + val channelName = generateChannelName() + val userMapObjectId = restObjects.createUserMapWithCountersObject(channelName) + restObjects.setMapRef(channelName, "root", "user", userMapObjectId) + + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + + // Get the user map object from the root map + val userMap = rootMap.get("user") as LiveMap + assertNotNull(userMap, "User map should be synchronized") + assertEquals(7L, userMap.size(), "User map should contain 7 top-level entries") + + // Assert direct counter objects at the top level of the user map + // Test profileViews counter - should have initial value of 127 + val profileViewsCounter = userMap.get("profileViews") as LiveCounter + assertNotNull(profileViewsCounter, "Profile views counter should exist") + assertEquals(127L, profileViewsCounter.value(), "Profile views counter should have initial value of 127") + + // Test postLikes counter - should have initial value of 45 + val postLikesCounter = userMap.get("postLikes") as LiveCounter + assertNotNull(postLikesCounter, "Post likes counter should exist") + assertEquals(45L, postLikesCounter.value(), "Post likes counter should have initial value of 45") + + // Test commentCount counter - should have initial value of 23 + val commentCountCounter = userMap.get("commentCount") as LiveCounter + assertNotNull(commentCountCounter, "Comment count counter should exist") + assertEquals(23L, commentCountCounter.value(), "Comment count counter should have initial value of 23") + + // Test followingCount counter - should have initial value of 89 + val followingCountCounter = userMap.get("followingCount") as LiveCounter + assertNotNull(followingCountCounter, "Following count counter should exist") + assertEquals(89L, followingCountCounter.value(), "Following count counter should have initial value of 89") + + // Test followersCount counter - should have initial value of 156 + val followersCountCounter = userMap.get("followersCount") as LiveCounter + assertNotNull(followersCountCounter, "Followers count counter should exist") + assertEquals(156L, followersCountCounter.value(), "Followers count counter should have initial value of 156") + + // Test loginStreak counter - should have initial value of 7 + val loginStreakCounter = userMap.get("loginStreak") as LiveCounter + assertNotNull(loginStreakCounter, "Login streak counter should exist") + assertEquals(7L, loginStreakCounter.value(), "Login streak counter should have initial value of 7") + + // Assert the nested engagement metrics map + val engagementMetrics = userMap.get("engagementMetrics") as LiveMap + assertNotNull(engagementMetrics, "Engagement metrics map should exist") + assertEquals(4L, engagementMetrics.size(), "Engagement metrics map should contain 4 counter entries") + + // Assert counter objects within the engagement metrics map + // Test totalShares counter - should have initial value of 34 + val totalSharesCounter = engagementMetrics.get("totalShares") as LiveCounter + assertNotNull(totalSharesCounter, "Total shares counter should exist") + assertEquals(34L, totalSharesCounter.value(), "Total shares counter should have initial value of 34") + + // Test totalBookmarks counter - should have initial value of 67 + val totalBookmarksCounter = engagementMetrics.get("totalBookmarks") as LiveCounter + assertNotNull(totalBookmarksCounter, "Total bookmarks counter should exist") + assertEquals(67L, totalBookmarksCounter.value(), "Total bookmarks counter should have initial value of 67") + + // Test totalReactions counter - should have initial value of 189 + val totalReactionsCounter = engagementMetrics.get("totalReactions") as LiveCounter + assertNotNull(totalReactionsCounter, "Total reactions counter should exist") + assertEquals(189L, totalReactionsCounter.value(), "Total reactions counter should have initial value of 189") + + // Test dailyActiveStreak counter - should have initial value of 12 + val dailyActiveStreakCounter = engagementMetrics.get("dailyActiveStreak") as LiveCounter + assertNotNull(dailyActiveStreakCounter, "Daily active streak counter should exist") + assertEquals(12L, dailyActiveStreakCounter.value(), "Daily active streak counter should have initial value of 12") + + // Verify that all expected counter keys exist at the top level + val topLevelKeys = userMap.keys().toSet() + val expectedTopLevelKeys = setOf( + "profileViews", "postLikes", "commentCount", "followingCount", + "followersCount", "loginStreak", "engagementMetrics" + ) + assertEquals(expectedTopLevelKeys, topLevelKeys, "Top-level keys should match expected counter keys") + + // Verify that all expected counter keys exist in the engagement metrics map + val engagementKeys = engagementMetrics.keys().toSet() + val expectedEngagementKeys = setOf( + "totalShares", "totalBookmarks", "totalReactions", "dailyActiveStreak" + ) + assertEquals(expectedEngagementKeys, engagementKeys, "Engagement metrics keys should match expected counter keys") + + // Verify total counter values match expectations (useful for integration testing) + val totalUserCounterValues = listOf(127L, 45L, 23L, 89L, 156L, 7L).sum() + val totalEngagementCounterValues = listOf(34L, 67L, 189L, 12L).sum() + assertEquals(447L, totalUserCounterValues, "Sum of user counter values should be 447") + assertEquals(302L, totalEngagementCounterValues, "Sum of engagement counter values should be 302") + } + + /** + * Tests sequential counter operations including creation with initial value, incrementing by various amounts, + * decrementing by various amounts, and validates the resulting counter value after each operation. + */ + @Test + fun testLiveCounterOperations() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + val rootMap = channel.objects.root + + // Step 1: Create a new counter with initial value of 10 + val testCounterObjectId = restObjects.createCounter(channelName, initialValue = 10L) + restObjects.setMapRef(channelName, "root", "testCounter", testCounterObjectId) + + // Wait for updated testCounter to be available in the root map + assertWaiter { rootMap.get("testCounter") != null } + + // Assert initial state after creation + val testCounter = rootMap.get("testCounter") as LiveCounter + assertNotNull(testCounter, "Test counter should be created and accessible") + assertEquals(10L, testCounter.value(), "Counter should have initial value of 10") + + // Step 2: Increment counter by 5 (10 + 5 = 15) + restObjects.incrementCounter(channelName, testCounterObjectId, 5L) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 15L } + + // Assert after first increment + assertEquals(15L, testCounter.value(), "Counter should be incremented to 15") + + // Step 3: Increment counter by 3 (15 + 3 = 18) + restObjects.incrementCounter(channelName, testCounterObjectId, 3L) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 18L } + + // Assert after second increment + assertEquals(18L, testCounter.value(), "Counter should be incremented to 18") + + // Step 4: Increment counter by a larger amount: 12 (18 + 12 = 30) + restObjects.incrementCounter(channelName, testCounterObjectId, 12L) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 30L } + + // Assert after third increment + assertEquals(30L, testCounter.value(), "Counter should be incremented to 30") + + // Step 5: Decrement counter by 7 (30 - 7 = 23) + restObjects.decrementCounter(channelName, testCounterObjectId, 7L) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 23L } + + // Assert after first decrement + assertEquals(23L, testCounter.value(), "Counter should be decremented to 23") + + // Step 6: Decrement counter by 4 (23 - 4 = 19) + restObjects.decrementCounter(channelName, testCounterObjectId, 4L) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 19L } + + // Assert after second decrement + assertEquals(19L, testCounter.value(), "Counter should be decremented to 19") + + // Step 7: Increment counter by 1 (19 + 1 = 20) + restObjects.incrementCounter(channelName, testCounterObjectId, 1L) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 20L } + + // Assert after final increment + assertEquals(20L, testCounter.value(), "Counter should be incremented to 20") + + // Step 8: Decrement counter by a larger amount: 15 (20 - 15 = 5) + restObjects.decrementCounter(channelName, testCounterObjectId, 15L) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 5L } + + // Assert after large decrement + assertEquals(5L, testCounter.value(), "Counter should be decremented to 5") + + // Final verification - test final increment to ensure counter still works + restObjects.incrementCounter(channelName, testCounterObjectId, 25L) + assertWaiter { testCounter.value() == 30L } + + // Assert final state + assertEquals(30L, testCounter.value(), "Counter should have final value of 30") + + // Verify the counter object is still accessible and functioning + assertNotNull(testCounter, "Counter should still be accessible at the end") + + // Verify we can still access it from the root map + val finalCounterCheck = rootMap.get("testCounter") as LiveCounter + assertNotNull(finalCounterCheck, "Counter should still be accessible from root map") + assertEquals(30L, finalCounterCheck.value(), "Final counter value should be 30 when accessed from root map") + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt index 57e841b4c..e40cb62e2 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt @@ -126,13 +126,10 @@ internal object PayloadBuilder { val opBody = JsonObject().apply { addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterInc]) addProperty("objectId", objectId) + add("data", JsonObject().apply { + addProperty("number", number) + }) } - - val dataObj = JsonObject().apply { - addProperty("number", number) - } - opBody.add("data", dataObj) - return opBody } } diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt index 820b144ab..6b0d44720 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt @@ -117,4 +117,3 @@ internal class RestObjects(options: ClientOptions) { val success: Boolean = true ) } - diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt new file mode 100644 index 000000000..2083472e4 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt @@ -0,0 +1,65 @@ +package io.ably.lib.objects.integration.helpers.fixtures + +import io.ably.lib.objects.integration.helpers.RestObjects + +/** + * Creates a comprehensive test fixture object tree focused on user-context counters. + * + * This method establishes a hierarchical structure of live counter objects for testing + * counter operations in a realistic user engagement context, creating various types of + * counters and establishing references between them through nested maps. + * + * **Object Tree Structure:** + * ``` + * userMap (Map) + * ├── profileViews → Counter(value=127) + * ├── postLikes → Counter(value=45) + * ├── commentCount → Counter(value=23) + * ├── followingCount → Counter(value=89) + * ├── followersCount → Counter(value=156) + * ├── loginStreak → Counter(value=7) + * └── engagementMetrics → Map{ + * ├── "totalShares" → Counter(value=34) + * ├── "totalBookmarks" → Counter(value=67) + * ├── "totalReactions" → Counter(value=189) + * └── "dailyActiveStreak" → Counter(value=12) + * } + * ``` + * + * @param channelName The channel where the counter object tree will be created + * @return The object ID of the root test map containing all counter references + */ +internal fun RestObjects.createUserMapWithCountersObject(channelName: String): String { + // Create the main test map first + val testMapObjectId = createMap(channelName) + + // Create various user-context relevant counters + val profileViewsCounterObjectId = createCounter(channelName, 127) + val postLikesCounterObjectId = createCounter(channelName, 45) + val commentCountCounterObjectId = createCounter(channelName, 23) + val followingCountCounterObjectId = createCounter(channelName, 89) + val followersCountCounterObjectId = createCounter(channelName, 156) + val loginStreakCounterObjectId = createCounter(channelName, 7) + + // Create engagement metrics nested map with counters + val engagementMetricsMapObjectId = createMap( + channelName, + data = mapOf( + "totalShares" to DataFixtures.mapRef(createCounter(channelName, 34)), + "totalBookmarks" to DataFixtures.mapRef(createCounter(channelName, 67)), + "totalReactions" to DataFixtures.mapRef(createCounter(channelName, 189)), + "dailyActiveStreak" to DataFixtures.mapRef(createCounter(channelName, 12)) + ) + ) + + // Set up the main test map structure with references to all created counters + setMapRef(channelName, testMapObjectId, "profileViews", profileViewsCounterObjectId) + setMapRef(channelName, testMapObjectId, "postLikes", postLikesCounterObjectId) + setMapRef(channelName, testMapObjectId, "commentCount", commentCountCounterObjectId) + setMapRef(channelName, testMapObjectId, "followingCount", followingCountCounterObjectId) + setMapRef(channelName, testMapObjectId, "followersCount", followersCountCounterObjectId) + setMapRef(channelName, testMapObjectId, "loginStreak", loginStreakCounterObjectId) + setMapRef(channelName, testMapObjectId, "engagementMetrics", engagementMetricsMapObjectId) + + return testMapObjectId +} From 2e5a81166b441e8e3d5def65d9051be255b7b3db Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 23 Jul 2025 18:32:46 +0530 Subject: [PATCH 13/19] [ECO-5076] Removed unnecessary use of emitterScope since events are emitted on sequentialScope --- .../kotlin/io/ably/lib/objects/ObjectsState.kt | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt index d503e2ddb..0b8dbccc4 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt @@ -63,8 +63,6 @@ internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObj // related to RTC10, should have a separate EventEmitter for users of the library private val externalObjectStateEmitter = ObjectsStateEmitter() - private val emitterScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob()) - override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription { externalObjectStateEmitter.on(event, listener) return ObjectsSubscription { @@ -78,11 +76,8 @@ internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObj override fun objectsStateChanged(newState: ObjectsState) { objectsStateToEventMap[newState]?.let { objectsStateEvent -> - // emitterScope makes sure next launch can only start when previous launch finishes - emitterScope.launch { - internalObjectStateEmitter.emit(objectsStateEvent) - externalObjectStateEmitter.emit(objectsStateEvent) - } + internalObjectStateEmitter.emit(objectsStateEvent) + externalObjectStateEmitter.emit(objectsStateEvent) } } @@ -97,10 +92,7 @@ internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObj } } - override fun disposeObjectsStateListeners() { - offAll() - emitterScope.cancel("ObjectsManager disposed") - } + override fun disposeObjectsStateListeners() = offAll() } private class ObjectsStateEmitter : EventEmitter() { From 6d20ffe441b9a7e4a735cd0abd6c55785fa99b6b Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 24 Jul 2025 13:59:32 +0530 Subject: [PATCH 14/19] [ECO-5076] Updated ObjectsState enum to use PascalCase instead of all uppercase --- .../io/ably/lib/objects/DefaultLiveObjects.kt | 4 ++-- .../kotlin/io/ably/lib/objects/ObjectsManager.kt | 6 +++--- .../kotlin/io/ably/lib/objects/ObjectsState.kt | 14 +++++++------- .../objects/integration/DefaultLiveObjectsTest.kt | 2 +- .../objects/unit/objects/DefaultLiveObjectsTest.kt | 6 +++--- .../lib/objects/unit/objects/ObjectsManagerTest.kt | 10 +++++----- 6 files changed, 21 insertions(+), 21 deletions(-) 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 d54192cda..f8aab4a77 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 @@ -22,7 +22,7 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val */ internal val objectsPool = ObjectsPool(this) - internal var state = ObjectsState.INITIALIZED + internal var state = ObjectsState.Initialized /** * @spec RTO4 - Used for handling object messages and object sync messages @@ -155,7 +155,7 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val Log.v(tag, "Objects.onAttached() channel=$channelName, hasObjects=$hasObjects") // RTO4a - val fromInitializedState = this@DefaultLiveObjects.state == ObjectsState.INITIALIZED + 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. 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 index 8526cb439..206ebc71c 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt @@ -27,7 +27,7 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects): Obje * @spec RTO8 - Buffers messages if not synced, applies immediately if synced */ internal fun handleObjectMessages(objectMessages: List) { - if (liveObjects.state != ObjectsState.SYNCED) { + 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 @@ -77,7 +77,7 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects): Obje bufferedObjectOperations.clear() // RTO5a2b syncObjectsDataPool.clear() // RTO5a2a currentSyncId = syncId - stateChange(ObjectsState.SYNCING, false) + stateChange(ObjectsState.Syncing, false) } /** @@ -95,7 +95,7 @@ internal class ObjectsManager(private val liveObjects: DefaultLiveObjects): Obje bufferedObjectOperations.clear() // RTO5c5 syncObjectsDataPool.clear() // RTO5c4 currentSyncId = null // RTO5c3 - stateChange(ObjectsState.SYNCED, deferStateEvent) + stateChange(ObjectsState.Synced, deferStateEvent) } /** diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt index 0b8dbccc4..8ba280e3d 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt @@ -10,9 +10,9 @@ import kotlinx.coroutines.* * @spec RTO2 - enum representing objects state */ internal enum class ObjectsState { - INITIALIZED, - SYNCING, - SYNCED + Initialized, + Syncing, + Synced } /** @@ -21,9 +21,9 @@ internal enum class ObjectsState { * INITIALIZED maps to null (no event), while SYNCING and SYNCED map to their respective events. */ private val objectsStateToEventMap = mapOf( - ObjectsState.INITIALIZED to null, - ObjectsState.SYNCING to ObjectsStateEvent.SYNCING, - ObjectsState.SYNCED to ObjectsStateEvent.SYNCED + ObjectsState.Initialized to null, + ObjectsState.Syncing to ObjectsStateEvent.SYNCING, + ObjectsState.Synced to ObjectsStateEvent.SYNCED ) /** @@ -82,7 +82,7 @@ internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObj } override suspend fun ensureSynced(currentState: ObjectsState) { - if (currentState != ObjectsState.SYNCED) { + if (currentState != ObjectsState.Synced) { val deferred = CompletableDeferred() internalObjectStateEmitter.once(ObjectsStateEvent.SYNCED) { Log.v(tag, "Objects state changed to SYNCED, resuming ensureSynced") diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt index 1159257ce..8e5396a1a 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt @@ -36,7 +36,7 @@ class DefaultLiveObjectsTest : IntegrationTest() { val objects = channel.objects assertNotNull(objects) - assertEquals(ObjectsState.INITIALIZED, objects.State, "Initial state should be INITIALIZED") + assertEquals(ObjectsState.Initialized, objects.State, "Initial state should be INITIALIZED") val syncStates = mutableListOf() objects.on(ObjectsStateEvent.SYNCING) { 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 index 381ab9b47..ea57da9a6 100644 --- 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 @@ -34,7 +34,7 @@ class DefaultLiveObjectsTest { // 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 } + assertWaiter { defaultLiveObjects.state == ObjectsState.Syncing } // It is expected that the client will start a new sync sequence verify(exactly = 1) { @@ -59,7 +59,7 @@ class DefaultLiveObjectsTest { defaultLiveObjects.handleStateChange(ChannelState.attached, false) // Verify expected outcomes - assertWaiter { defaultLiveObjects.state == ObjectsState.SYNCED } // RTO4b4 + assertWaiter { defaultLiveObjects.state == ObjectsState.Synced } // RTO4b4 verify(exactly = 1) { defaultLiveObjects.objectsPool.resetToInitialPool(true) @@ -80,7 +80,7 @@ class DefaultLiveObjectsTest { val defaultLiveObjects = getDefaultLiveObjectsWithMockedDeps() // Ensure we're in INITIALIZED state - defaultLiveObjects.state = ObjectsState.INITIALIZED + defaultLiveObjects.state = ObjectsState.Initialized // RTO4a - Should start sync even with HAS_OBJECTS flag false when in INITIALIZED state defaultLiveObjects.handleStateChange(ChannelState.attached, false) 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 index 2d777f3ff..aaa3f5c29 100644 --- 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 @@ -17,7 +17,7 @@ class ObjectsManagerTest { @Test fun `(RTO5) ObjectsManager should handle object sync messages`() { val defaultLiveObjects = getDefaultLiveObjectsWithMockedDeps() - assertEquals(ObjectsState.INITIALIZED, defaultLiveObjects.state, "Initial state should be INITIALIZED") + assertEquals(ObjectsState.Initialized, defaultLiveObjects.state, "Initial state should be INITIALIZED") val objectsManager = defaultLiveObjects.ObjectsManager @@ -72,7 +72,7 @@ class ObjectsManagerTest { 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") + 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") @@ -97,7 +97,7 @@ class ObjectsManagerTest { @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 + defaultLiveObjects.state = ObjectsState.Synced // Ensure we're in SYNCED state val objectsManager = defaultLiveObjects.ObjectsManager @@ -165,7 +165,7 @@ class ObjectsManagerTest { @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") + 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") @@ -176,7 +176,7 @@ class ObjectsManagerTest { mockZeroValuedObjects() // Set state to SYNCING - defaultLiveObjects.state = ObjectsState.SYNCING + defaultLiveObjects.state = ObjectsState.Syncing val objectMessage = ObjectMessage( id = "testId", From f7197a917498577d285c27aa1e62516976eaa86b Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 24 Jul 2025 14:49:06 +0530 Subject: [PATCH 15/19] [ECO-5076] Updated impl. to dispose objects using ablyexception --- .../kotlin/io/ably/lib/objects/DefaultLiveObjects.kt | 11 ++++++----- .../io/ably/lib/objects/DefaultLiveObjectsPlugin.kt | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) 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 f8aab4a77..f95fbeb9d 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 @@ -3,6 +3,7 @@ package io.ably.lib.objects import io.ably.lib.objects.state.ObjectsStateChange import io.ably.lib.objects.state.ObjectsStateEvent import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.AblyException import io.ably.lib.types.Callback import io.ably.lib.types.ProtocolMessage import io.ably.lib.util.Log @@ -189,12 +190,12 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val } // 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 + fun dispose(cause: AblyException) { + val disposeReason = CancellationException().apply { initCause(cause) } + incomingObjectsHandler.cancel(disposeReason) // 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) + // Don't cancel sequentialScope (needed in getRoot method), just cancel ongoing coroutines + sequentialScope.coroutineContext.cancelChildren(disposeReason) } } 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 f3f2e71a4..e50eda4b8 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 @@ -22,13 +22,13 @@ public class DefaultLiveObjectsPlugin(private val adapter: LiveObjectsAdapter) : } override fun dispose(channelName: String) { - liveObjects[channelName]?.dispose("Channel has ben released using channels.release()") + liveObjects[channelName]?.dispose(clientError("Channel has ben released using channels.release()")) liveObjects.remove(channelName) } override fun dispose() { liveObjects.values.forEach { - it.dispose("AblyClient has been closed using client.close()") + it.dispose(clientError("AblyClient has been closed using client.close()")) } liveObjects.clear() } From 8b5ea817ae433aae00ae2791f2cb7d19c29a8f39 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 29 Jul 2025 16:05:26 +0530 Subject: [PATCH 16/19] [ECO-5457] Replaced GlobalCallbackScope with ObjectsCallbackScope with lifecycle tied to given objects instance --- .../io/ably/lib/objects/DefaultLiveObjects.kt | 14 +++++++++---- .../main/kotlin/io/ably/lib/objects/Utils.kt | 20 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) 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 f95fbeb9d..9427a7e86 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 @@ -36,6 +36,11 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val private val sequentialScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(channelName) + SupervisorJob()) + /** + * Provides a channel-specific scope for safely executing asynchronous operations with callbacks. + */ + private val callbackScope = ObjectsCallbackScope(channelName) + /** * Event bus for handling incoming object messages sequentially. */ @@ -48,10 +53,6 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val override fun getRoot(): LiveMap = runBlocking { getRootAsync() } - override fun getRootAsync(callback: Callback) { - GlobalCallbackScope.launchWithCallback(callback) { getRootAsync() } - } - override fun createMap(liveMap: LiveMap): LiveMap { TODO("Not yet implemented") } @@ -64,6 +65,10 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val TODO("Not yet implemented") } + override fun getRootAsync(callback: Callback) { + callbackScope.launchWithCallback(callback) { getRootAsync() } + } + override fun createMapAsync(liveMap: LiveMap, callback: Callback) { TODO("Not yet implemented") } @@ -197,5 +202,6 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val objectsManager.dispose() // Don't cancel sequentialScope (needed in getRoot method), just cancel ongoing coroutines sequentialScope.coroutineContext.cancelChildren(disposeReason) + callbackScope.cancel(disposeReason) // cancel all ongoing callbacks } } 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 e0a817f14..249266d30 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 @@ -5,6 +5,7 @@ import io.ably.lib.types.Callback import io.ably.lib.types.ErrorInfo import io.ably.lib.util.Log import kotlinx.coroutines.* +import java.util.concurrent.CancellationException internal fun ablyException( errorMessage: String, @@ -49,21 +50,22 @@ internal val String.byteSize: Int get() = this.toByteArray(Charsets.UTF_8).size /** - * A global coroutine scope for executing callbacks asynchronously. - * Provides safe execution of suspend functions with results delivered via callbacks, - * with proper error handling for both the execution and callback invocation. + * A channel-specific coroutine scope for executing callbacks asynchronously in the LiveObjects system. + * Provides safe execution of suspend functions with results delivered via callbacks. + * Supports proper error handling and cancellation during LiveObjects disposal. */ -internal object GlobalCallbackScope { - private const val TAG = "GlobalCallbackScope" +internal class ObjectsCallbackScope(channelName: String) { + private val tag = "ObjectsCallbackScope-$channelName" + private val scope = - CoroutineScope(Dispatchers.Default + CoroutineName("LiveObjects-GlobalCallbackScope") + SupervisorJob()) + CoroutineScope(Dispatchers.Default + CoroutineName(tag) + SupervisorJob()) internal fun launchWithCallback(callback: Callback, block: suspend () -> T) { scope.launch { try { val result = block() try { callback.onSuccess(result) } catch (t: Throwable) { - Log.e(TAG, "Error occurred while executing callback's onSuccess handler", t) + Log.e(tag, "Error occurred while executing callback's onSuccess handler", t) } // catch and don't rethrow error from callback } catch (throwable: Throwable) { val exception = throwable as? AblyException @@ -71,4 +73,8 @@ internal object GlobalCallbackScope { } } } + + internal fun cancel(cause: CancellationException) { + scope.coroutineContext.cancelChildren(cause) + } } From 1d83242840d990d78eb8ef4389c875394b52cac9 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 29 Jul 2025 20:28:54 +0530 Subject: [PATCH 17/19] [ECO-5457] Renamed callbackScope to asyncScope for better context around async ops --- .../io/ably/lib/objects/DefaultLiveObjects.kt | 15 ++++++++------- .../src/main/kotlin/io/ably/lib/objects/Utils.kt | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) 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 9427a7e86..b96fbe6de 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 @@ -36,17 +36,18 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val private val sequentialScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(channelName) + SupervisorJob()) - /** - * Provides a channel-specific scope for safely executing asynchronous operations with callbacks. - */ - private val callbackScope = ObjectsCallbackScope(channelName) - /** * Event bus for handling incoming object messages sequentially. + * Processes messages inside [incomingObjectsHandler] job created using [sequentialScope]. */ private val objectsEventBus = MutableSharedFlow(extraBufferCapacity = UNLIMITED) private val incomingObjectsHandler: Job + /** + * Provides a channel-specific scope for safely executing asynchronous operations with callbacks. + */ + private val asyncScope = ObjectsAsyncScope(channelName) + init { incomingObjectsHandler = initializeHandlerForIncomingObjectMessages() } @@ -66,7 +67,7 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val } override fun getRootAsync(callback: Callback) { - callbackScope.launchWithCallback(callback) { getRootAsync() } + asyncScope.launchWithCallback(callback) { getRootAsync() } } override fun createMapAsync(liveMap: LiveMap, callback: Callback) { @@ -202,6 +203,6 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val objectsManager.dispose() // Don't cancel sequentialScope (needed in getRoot method), just cancel ongoing coroutines sequentialScope.coroutineContext.cancelChildren(disposeReason) - callbackScope.cancel(disposeReason) // cancel all ongoing callbacks + asyncScope.cancel(disposeReason) // cancel all ongoing callbacks } } 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 249266d30..f3eebf782 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 @@ -54,7 +54,7 @@ internal val String.byteSize: Int * Provides safe execution of suspend functions with results delivered via callbacks. * Supports proper error handling and cancellation during LiveObjects disposal. */ -internal class ObjectsCallbackScope(channelName: String) { +internal class ObjectsAsyncScope(channelName: String) { private val tag = "ObjectsCallbackScope-$channelName" private val scope = From 3f5d115dedfc2507d4c7e9a57ee53d6eaf867642 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 1 Aug 2025 14:34:28 +0530 Subject: [PATCH 18/19] [ECO-5076] Fixed integration test fixtures in accordance with ObjectValue union type --- .../objects/integration/DefaultLiveMapTest.kt | 14 +++++------ .../helpers/fixtures/DataFixtures.kt | 24 +++++++++---------- .../helpers/fixtures/MapFixtures.kt | 20 ++++++++-------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt index 50069f4da..68e94c891 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt @@ -108,9 +108,9 @@ class DefaultLiveMapTest: IntegrationTest() { val testMapObjectId = restObjects.createMap( channelName, data = mapOf( - "name" to ObjectData(value = ObjectValue("Alice")), - "age" to ObjectData(value = ObjectValue(30)), - "isActive" to ObjectData(value = ObjectValue(true)) + "name" to ObjectData(value = ObjectValue.String("Alice")), + "age" to ObjectData(value = ObjectValue.Number(30)), + "isActive" to ObjectData(value = ObjectValue.Boolean(true)) ) ) restObjects.setMapRef(channelName, "root", "testMap", testMapObjectId) @@ -127,7 +127,7 @@ class DefaultLiveMapTest: IntegrationTest() { assertEquals(true, testMap.get("isActive"), "Initial active status should be true") // Step 2: Update an existing field (name from "Alice" to "Bob") - restObjects.setMapValue(channelName, testMapObjectId, "name", ObjectValue("Bob")) + restObjects.setMapValue(channelName, testMapObjectId, "name", ObjectValue.String("Bob")) // Wait for the map to be updated assertWaiter { testMap.get("name") == "Bob" } @@ -138,7 +138,7 @@ class DefaultLiveMapTest: IntegrationTest() { assertEquals(true, testMap.get("isActive"), "Active status should remain unchanged") // Step 3: Add a new field (email) - restObjects.setMapValue(channelName, testMapObjectId, "email", ObjectValue("bob@example.com")) + restObjects.setMapValue(channelName, testMapObjectId, "email", ObjectValue.String("bob@example.com")) // Wait for the map to be updated assertWaiter { testMap.get("email") == "bob@example.com" } @@ -150,7 +150,7 @@ class DefaultLiveMapTest: IntegrationTest() { assertEquals("bob@example.com", testMap.get("email"), "Email should be added successfully") // Step 4: Add another new field with different data type (score as number) - restObjects.setMapValue(channelName, testMapObjectId, "score", ObjectValue(85)) + restObjects.setMapValue(channelName, testMapObjectId, "score", ObjectValue.Number(85)) // Wait for the map to be updated assertWaiter { testMap.get("score") == 85.0 } @@ -163,7 +163,7 @@ class DefaultLiveMapTest: IntegrationTest() { assertEquals(85.0, testMap.get("score"), "Score should be added as numeric value") // Step 5: Update the boolean field - restObjects.setMapValue(channelName, testMapObjectId, "isActive", ObjectValue(false)) + restObjects.setMapValue(channelName, testMapObjectId, "isActive", ObjectValue.Boolean(false)) // Wait for the map to be updated assertWaiter { testMap.get("isActive") == false } diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt index ee2547ef5..18928cd19 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt @@ -9,42 +9,42 @@ import io.ably.lib.objects.ObjectValue internal object DataFixtures { /** Test fixture for string value ("stringValue") data type */ - internal val stringData = ObjectData(value = ObjectValue("stringValue")) + internal val stringData = ObjectData(value = ObjectValue.String("stringValue")) /** Test fixture for empty string data type */ - internal val emptyStringData = ObjectData(value = ObjectValue("")) + internal val emptyStringData = ObjectData(value = ObjectValue.String("")) /** Test fixture for binary data containing encoded JSON */ internal val bytesData = ObjectData( - value = ObjectValue(Binary("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray()))) + value = ObjectValue.Binary(Binary("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray()))) /** Test fixture for empty binary data (zero-length byte array) */ - internal val emptyBytesData = ObjectData(value = ObjectValue(Binary(ByteArray(0)))) + internal val emptyBytesData = ObjectData(value = ObjectValue.Binary(Binary(ByteArray(0)))) /** Test fixture for maximum safe number value */ - internal val maxSafeNumberData = ObjectData(value = ObjectValue(99999999.0)) + internal val maxSafeNumberData = ObjectData(value = ObjectValue.Number(99999999.0)) /** Test fixture for minimum safe number value */ - internal val negativeMaxSafeNumberData = ObjectData(value = ObjectValue(-99999999.0)) + internal val negativeMaxSafeNumberData = ObjectData(value = ObjectValue.Number(-99999999.0)) /** Test fixture for positive number value (1) */ - internal val numberData = ObjectData(value = ObjectValue(1.0)) + internal val numberData = ObjectData(value = ObjectValue.Number(1.0)) /** Test fixture for zero number value */ - internal val zeroData = ObjectData(value = ObjectValue(0.0)) + internal val zeroData = ObjectData(value = ObjectValue.Number(0.0)) /** Test fixture for boolean true value */ - internal val trueData = ObjectData(value = ObjectValue(true)) + internal val trueData = ObjectData(value = ObjectValue.Boolean(true)) /** Test fixture for boolean false value */ - internal val falseData = ObjectData(value = ObjectValue(false)) + internal val falseData = ObjectData(value = ObjectValue.Boolean(false)) /** Test fixture for JSON object value with single property */ - internal val objectData = ObjectData(value = ObjectValue(JsonObject().apply { addProperty("foo", "bar")})) + internal val objectData = ObjectData(value = ObjectValue.JsonObject(JsonObject().apply { addProperty("foo", "bar")})) /** Test fixture for JSON array value with three string elements */ internal val arrayData = ObjectData( - value = ObjectValue(JsonArray().apply { + value = ObjectValue.JsonArray(JsonArray().apply { add("foo") add("bar") add("baz") diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt index ee4b8c01f..f99dd7d9c 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt @@ -115,10 +115,10 @@ internal fun RestObjects.createUserMapObject(channelName: String): String { val preferencesMapObjectId = createMap( channelName, data = mapOf( - "theme" to ObjectData(value = ObjectValue("dark")), - "notifications" to ObjectData(value = ObjectValue(true)), - "language" to ObjectData(value = ObjectValue("en")), - "maxRetries" to ObjectData(value = ObjectValue(3)) + "theme" to ObjectData(value = ObjectValue.String("dark")), + "notifications" to ObjectData(value = ObjectValue.Boolean(true)), + "language" to ObjectData(value = ObjectValue.String("en")), + "maxRetries" to ObjectData(value = ObjectValue.Number(3)) ) ) @@ -128,8 +128,8 @@ internal fun RestObjects.createUserMapObject(channelName: String): String { data = mapOf( "totalLogins" to DataFixtures.mapRef(loginCounterObjectId), "activeSessions" to DataFixtures.mapRef(sessionCounterObjectId), - "lastLoginTime" to ObjectData(value = ObjectValue("2024-01-01T08:30:00Z")), - "profileViews" to ObjectData(value = ObjectValue(42)) + "lastLoginTime" to ObjectData(value = ObjectValue.String("2024-01-01T08:30:00Z")), + "profileViews" to ObjectData(value = ObjectValue.Number(42)) ) ) @@ -137,10 +137,10 @@ internal fun RestObjects.createUserMapObject(channelName: String): String { val userProfileMapObjectId = createMap( channelName, data = mapOf( - "userId" to ObjectData(value = ObjectValue("user123")), - "name" to ObjectData(value = ObjectValue("John Doe")), - "email" to ObjectData(value = ObjectValue("john@example.com")), - "isActive" to ObjectData(value = ObjectValue(true)), + "userId" to ObjectData(value = ObjectValue.String("user123")), + "name" to ObjectData(value = ObjectValue.String("John Doe")), + "email" to ObjectData(value = ObjectValue.String("john@example.com")), + "isActive" to ObjectData(value = ObjectValue.Boolean(true)), "metrics" to DataFixtures.mapRef(metricsMapObjectId), "preferences" to DataFixtures.mapRef(preferencesMapObjectId) ) From 2ddd59835b5cef20a48254ab64d5415b1111368f Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 1 Aug 2025 15:15:36 +0530 Subject: [PATCH 19/19] [ECO-5506] Declared separate interface ObjectsCallback for async ops - Updated ObjectsAsyncScope to handle ObjectsCallback - Added launchWithVoidCallback method to handle void callbacks - Added unit tests covering all scenarios for ObjectsAsyncScope --- .../java/io/ably/lib/objects/LiveCounter.java | 5 +- .../java/io/ably/lib/objects/LiveMap.java | 5 +- .../java/io/ably/lib/objects/LiveObjects.java | 11 +- .../io/ably/lib/objects/ObjectsCallback.java | 31 ++ .../io/ably/lib/objects/DefaultLiveObjects.kt | 11 +- .../lib/objects/DefaultLiveObjectsPlugin.kt | 2 +- .../main/kotlin/io/ably/lib/objects/Utils.kt | 31 +- .../type/livecounter/DefaultLiveCounter.kt | 5 +- .../objects/type/livemap/DefaultLiveMap.kt | 5 +- .../io/ably/lib/objects/unit/UtilsTest.kt | 283 ++++++++++++++++++ 10 files changed, 360 insertions(+), 29 deletions(-) create mode 100644 lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt 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 2339fcb4f..81ef13f37 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveCounter.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveCounter.java @@ -1,6 +1,5 @@ package io.ably.lib.objects; -import io.ably.lib.types.Callback; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; @@ -33,7 +32,7 @@ public interface LiveCounter { * @param callback the callback to be invoked upon completion of the operation. */ @NonBlocking - void incrementAsync(@NotNull Callback callback); + void incrementAsync(@NotNull ObjectsCallback callback); /** * Decrements the value of the counter by 1. @@ -49,7 +48,7 @@ public interface LiveCounter { * @param callback the callback to be invoked upon completion of the operation. */ @NonBlocking - void decrementAsync(@NotNull Callback callback); + void decrementAsync(@NotNull ObjectsCallback callback); /** * Retrieves the current value of the counter. diff --git a/lib/src/main/java/io/ably/lib/objects/LiveMap.java b/lib/src/main/java/io/ably/lib/objects/LiveMap.java index cc297a401..ae1299dd4 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveMap.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveMap.java @@ -1,6 +1,5 @@ package io.ably.lib.objects; -import io.ably.lib.types.Callback; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.Contract; @@ -109,7 +108,7 @@ public interface LiveMap { * @param callback the callback to handle the result or any errors. */ @NonBlocking - void setAsync(@NotNull String keyName, @NotNull Object value, @NotNull Callback callback); + void setAsync(@NotNull String keyName, @NotNull Object value, @NotNull ObjectsCallback callback); /** * Asynchronously removes the specified key and its associated value from the map. @@ -122,5 +121,5 @@ public interface LiveMap { * @param callback the callback to handle the result or any errors. */ @NonBlocking - void removeAsync(@NotNull String keyName, @NotNull Callback callback); + void removeAsync(@NotNull String keyName, @NotNull ObjectsCallback callback); } diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjects.java b/lib/src/main/java/io/ably/lib/objects/LiveObjects.java index fff0344ca..ac5b2c919 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjects.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjects.java @@ -1,7 +1,6 @@ package io.ably.lib.objects; import io.ably.lib.objects.state.ObjectsStateChange; -import io.ably.lib.types.Callback; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; @@ -96,7 +95,7 @@ public interface LiveObjects extends ObjectsStateChange { * @param callback the callback to handle the result or error. */ @NonBlocking - void getRootAsync(@NotNull Callback<@NotNull LiveMap> callback); + void getRootAsync(@NotNull ObjectsCallback<@NotNull LiveMap> callback); /** * Asynchronously creates a new LiveMap based on an existing LiveMap. @@ -109,7 +108,7 @@ public interface LiveObjects extends ObjectsStateChange { * @param callback the callback to handle the result or error. */ @NonBlocking - void createMapAsync(@NotNull LiveMap liveMap, @NotNull Callback<@NotNull LiveMap> callback); + void createMapAsync(@NotNull LiveMap liveMap, @NotNull ObjectsCallback<@NotNull LiveMap> callback); /** * Asynchronously creates a new LiveMap based on a LiveCounter. @@ -122,7 +121,7 @@ public interface LiveObjects extends ObjectsStateChange { * @param callback the callback to handle the result or error. */ @NonBlocking - void createMapAsync(@NotNull LiveCounter liveCounter, @NotNull Callback<@NotNull LiveMap> callback); + void createMapAsync(@NotNull LiveCounter liveCounter, @NotNull ObjectsCallback<@NotNull LiveMap> callback); /** * Asynchronously creates a new LiveMap based on a standard Java Map. @@ -135,7 +134,7 @@ public interface LiveObjects extends ObjectsStateChange { * @param callback the callback to handle the result or error. */ @NonBlocking - void createMapAsync(@NotNull Map map, @NotNull Callback<@NotNull LiveMap> callback); + void createMapAsync(@NotNull Map map, @NotNull ObjectsCallback<@NotNull LiveMap> callback); /** * Asynchronously creates a new LiveCounter with an initial value. @@ -148,5 +147,5 @@ public interface LiveObjects extends ObjectsStateChange { * @param callback the callback to handle the result or error. */ @NonBlocking - void createCounterAsync(@NotNull Long initialValue, @NotNull Callback<@NotNull LiveCounter> callback); + void createCounterAsync(@NotNull Long initialValue, @NotNull ObjectsCallback<@NotNull LiveCounter> callback); } diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java b/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java new file mode 100644 index 000000000..f6614918f --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java @@ -0,0 +1,31 @@ +package io.ably.lib.objects; + +import io.ably.lib.types.AblyException; + +/** + * Callback interface for handling results of asynchronous LiveObjects operations. + * Used for operations like creating LiveMaps/LiveCounters, modifying entries, and retrieving objects. + * Callbacks are executed on background threads managed by the LiveObjects system. + * + * @param the type of the result returned by the asynchronous operation + */ +public interface ObjectsCallback { + + /** + * Called when the asynchronous operation completes successfully. + * For modification operations (set, remove, increment), result is typically Void. + * For creation/retrieval operations, result contains the created/retrieved object. + * + * @param result the result of the operation, may be null for modification operations + */ + void onSuccess(T result); + + /** + * Called when the asynchronous operation fails. + * The exception contains detailed error information including error codes and messages. + * Common errors include network issues, authentication failures, and validation errors. + * + * @param exception the exception that occurred during the operation + */ + void onError(AblyException exception); +} 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 b96fbe6de..d449404ce 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 @@ -4,7 +4,6 @@ import io.ably.lib.objects.state.ObjectsStateChange import io.ably.lib.objects.state.ObjectsStateEvent import io.ably.lib.realtime.ChannelState import io.ably.lib.types.AblyException -import io.ably.lib.types.Callback import io.ably.lib.types.ProtocolMessage import io.ably.lib.util.Log import kotlinx.coroutines.* @@ -66,23 +65,23 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val TODO("Not yet implemented") } - override fun getRootAsync(callback: Callback) { + override fun getRootAsync(callback: ObjectsCallback) { asyncScope.launchWithCallback(callback) { getRootAsync() } } - override fun createMapAsync(liveMap: LiveMap, callback: Callback) { + override fun createMapAsync(liveMap: LiveMap, callback: ObjectsCallback) { TODO("Not yet implemented") } - override fun createMapAsync(liveCounter: LiveCounter, callback: Callback) { + override fun createMapAsync(liveCounter: LiveCounter, callback: ObjectsCallback) { TODO("Not yet implemented") } - override fun createMapAsync(map: MutableMap, callback: Callback) { + override fun createMapAsync(map: MutableMap, callback: ObjectsCallback) { TODO("Not yet implemented") } - override fun createCounterAsync(initialValue: Long, callback: Callback) { + override fun createCounterAsync(initialValue: Long, callback: ObjectsCallback) { TODO("Not yet implemented") } 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 e50eda4b8..66cab1d30 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 @@ -22,7 +22,7 @@ public class DefaultLiveObjectsPlugin(private val adapter: LiveObjectsAdapter) : } override fun dispose(channelName: String) { - liveObjects[channelName]?.dispose(clientError("Channel has ben released using channels.release()")) + liveObjects[channelName]?.dispose(clientError("Channel has been released using channels.release()")) liveObjects.remove(channelName) } 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 f3eebf782..2fde867b9 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 @@ -1,7 +1,6 @@ package io.ably.lib.objects import io.ably.lib.types.AblyException -import io.ably.lib.types.Callback import io.ably.lib.types.ErrorInfo import io.ably.lib.util.Log import kotlinx.coroutines.* @@ -60,7 +59,7 @@ internal class ObjectsAsyncScope(channelName: String) { private val scope = CoroutineScope(Dispatchers.Default + CoroutineName(tag) + SupervisorJob()) - internal fun launchWithCallback(callback: Callback, block: suspend () -> T) { + internal fun launchWithCallback(callback: ObjectsCallback, block: suspend () -> T) { scope.launch { try { val result = block() @@ -68,8 +67,32 @@ internal class ObjectsAsyncScope(channelName: String) { Log.e(tag, "Error occurred while executing callback's onSuccess handler", t) } // catch and don't rethrow error from callback } catch (throwable: Throwable) { - val exception = throwable as? AblyException - callback.onError(exception?.errorInfo) + when (throwable) { + is AblyException -> { callback.onError(throwable) } + else -> { + val ex = ablyException("Error executing operation", ErrorCode.BadRequest, cause = throwable) + callback.onError(ex) + } + } + } + } + } + + internal fun launchWithVoidCallback(callback: ObjectsCallback, block: suspend () -> Unit) { + scope.launch { + try { + block() + try { callback.onSuccess(null) } catch (t: Throwable) { + Log.e(tag, "Error occurred while executing callback's onSuccess handler", t) + } // catch and don't rethrow error from callback + } catch (throwable: Throwable) { + when (throwable) { + is AblyException -> { callback.onError(throwable) } + else -> { + val ex = ablyException("Error executing operation", ErrorCode.BadRequest, cause = throwable) + callback.onError(ex) + } + } } } } 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 index 790e94f36..5f0ee538e 100644 --- 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 @@ -5,7 +5,6 @@ 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 /** @@ -38,7 +37,7 @@ internal class DefaultLiveCounter private constructor( TODO("Not yet implemented") } - override fun incrementAsync(callback: Callback) { + override fun incrementAsync(callback: ObjectsCallback) { TODO("Not yet implemented") } @@ -46,7 +45,7 @@ internal class DefaultLiveCounter private constructor( TODO("Not yet implemented") } - override fun decrementAsync(callback: Callback) { + override fun decrementAsync(callback: ObjectsCallback) { TODO("Not yet implemented") } 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 index 4751a6d7a..b17368de9 100644 --- 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 @@ -7,7 +7,6 @@ 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 import java.util.AbstractMap @@ -94,11 +93,11 @@ internal class DefaultLiveMap private constructor( TODO("Not yet implemented") } - override fun setAsync(keyName: String, value: Any, callback: Callback) { + override fun setAsync(keyName: String, value: Any, callback: ObjectsCallback) { TODO("Not yet implemented") } - override fun removeAsync(keyName: String, callback: Callback) { + override fun removeAsync(keyName: String, callback: ObjectsCallback) { TODO("Not yet implemented") } diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt new file mode 100644 index 000000000..cbf4dbaee --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt @@ -0,0 +1,283 @@ +package io.ably.lib.objects.unit + +import io.ably.lib.objects.* +import io.ably.lib.objects.assertWaiter +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo +import kotlinx.coroutines.* +import kotlinx.coroutines.test.* +import org.junit.Test +import org.junit.Assert.* +import java.util.concurrent.CancellationException + +class UtilsTest { + + @Test + fun testStringByteSize() { + // Test ASCII strings + assertEquals(5, "Hello".byteSize) + assertEquals(0, "".byteSize) + assertEquals(1, "A".byteSize) + + // Test non-ASCII strings + assertEquals(3, "你".byteSize) // Chinese character + assertEquals(4, "😊".byteSize) // Emoji + assertEquals(6, "你好".byteSize) // Two Chinese characters + } + + @Test + fun testErrorCreationFunctions() { + // Test clientError + val clientEx = clientError("Bad request") + assertEquals("Bad request", clientEx.errorInfo.message) + assertEquals(ErrorCode.BadRequest.code, clientEx.errorInfo.code) + assertEquals(HttpStatusCode.BadRequest.code, clientEx.errorInfo.statusCode) + + // Test serverError + val serverEx = serverError("Internal error") + assertEquals("Internal error", serverEx.errorInfo.message) + assertEquals(ErrorCode.InternalError.code, serverEx.errorInfo.code) + assertEquals(HttpStatusCode.InternalServerError.code, serverEx.errorInfo.statusCode) + + // Test objectError + val objectEx = objectError("Invalid object") + assertEquals("Invalid object", objectEx.errorInfo.message) + assertEquals(ErrorCode.InvalidObject.code, objectEx.errorInfo.code) + assertEquals(HttpStatusCode.InternalServerError.code, objectEx.errorInfo.statusCode) + + // Test objectError with cause + val cause = RuntimeException("Original error") + val objectExWithCause = objectError("Invalid object", cause) + assertEquals("Invalid object", objectExWithCause.errorInfo.message) + assertEquals(cause, objectExWithCause.cause) + } + + @Test + fun testAblyExceptionCreation() { + // Test with error message and codes + val ex = ablyException("Test error", ErrorCode.BadRequest, HttpStatusCode.BadRequest) + assertEquals("Test error", ex.errorInfo.message) + assertEquals(ErrorCode.BadRequest.code, ex.errorInfo.code) + assertEquals(HttpStatusCode.BadRequest.code, ex.errorInfo.statusCode) + + // Test with ErrorInfo + val errorInfo = ErrorInfo("Custom error", 400, 40000) + val ex2 = ablyException(errorInfo) + assertEquals("Custom error", ex2.errorInfo.message) + assertEquals(400, ex2.errorInfo.statusCode) + assertEquals(40000, ex2.errorInfo.code) + + // Test with cause + val cause = RuntimeException("Cause") + val ex3 = ablyException(errorInfo, cause) + assertEquals(cause, ex3.cause) + } + + @Test + fun testObjectsAsyncScopeLaunchWithCallback() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var callbackExecuted = false + var resultReceived: String? = null + + val callback = object : ObjectsCallback { + override fun onSuccess(result: String) { + callbackExecuted = true + resultReceived = result + } + + override fun onError(exception: AblyException) { + fail("Should not call onError for successful execution") + } + } + + asyncScope.launchWithCallback(callback) { + delay(10) // Simulate async work + "test result" + } + + // Wait for callback to be executed + assertWaiter { callbackExecuted } + + assertTrue("Callback should be executed", callbackExecuted) + assertEquals("test result", resultReceived) + } + + @Test + fun testObjectsAsyncScopeLaunchWithCallbackError() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var errorReceived: AblyException? = null + + val callback = object : ObjectsCallback { + override fun onSuccess(result: String) { + fail("Should not call onSuccess for error case") + } + + override fun onError(exception: AblyException) { + errorReceived = exception + } + } + + asyncScope.launchWithCallback(callback) { + delay(10) + throw AblyException.fromErrorInfo(ErrorInfo("Test error", 400, 40000)) + } + + // Wait for error to be received + assertWaiter { errorReceived != null } + + assertNotNull("Error should be received", errorReceived) + assertEquals("Test error", errorReceived?.errorInfo?.message) + assertEquals(400, errorReceived?.errorInfo?.statusCode) + } + + @Test + fun testObjectsAsyncScopeLaunchWithVoidCallback() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var callbackExecuted = false + + val callback = object : ObjectsCallback { + override fun onSuccess(result: Void?) { + callbackExecuted = true + } + + override fun onError(exception: AblyException) { + fail("Should not call onError for successful execution") + } + } + + asyncScope.launchWithVoidCallback(callback) { + delay(10) // Simulate async work + } + + // Wait for callback to be executed + assertWaiter { callbackExecuted } + + assertTrue("Callback should be executed", callbackExecuted) + } + + @Test + fun testObjectsAsyncScopeLaunchWithVoidCallbackError() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var errorReceived: AblyException? = null + + val callback = object : ObjectsCallback { + override fun onSuccess(result: Void?) { + fail("Should not call onSuccess for error case") + } + + override fun onError(exception: AblyException) { + errorReceived = exception + } + } + + asyncScope.launchWithVoidCallback(callback) { + delay(10) + throw AblyException.fromErrorInfo(ErrorInfo("Test error", 500, 50000)) + } + + // Wait for error to be received + assertWaiter { errorReceived != null } + + assertNotNull("Error should be received", errorReceived) + assertEquals("Test error", errorReceived?.errorInfo?.message) + assertEquals(500, errorReceived?.errorInfo?.statusCode) + } + + @Test + fun testObjectsAsyncScopeCallbackExceptionHandling() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var callback1Called = false + var callback2Called = false + + val callback1 = object : ObjectsCallback { + override fun onSuccess(result: String) { + callback1Called = true + throw RuntimeException("Callback exception") + } + + override fun onError(exception: AblyException) { + fail("Should not call onError when onSuccess throws") + } + } + + asyncScope.launchWithCallback(callback1) { "test result" } + // Wait for callback to be called + assertWaiter { callback1Called } + + val callback2 = object : ObjectsCallback { + override fun onSuccess(result: String) { + callback2Called = true + } + + override fun onError(exception: AblyException) { + fail("Should not call onError when onSuccess throws") + } + } + + asyncScope.launchWithCallback(callback2) { "test result" } + // Callback 2 should be called even if callback 1 throws an exception + assertWaiter { callback2Called } + } + + @Test + fun testObjectsAsyncScopeCancel() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var errorReceived = false + + val callback = object : ObjectsCallback { + override fun onSuccess(result: String) { + fail("Should not call onSuccess") + } + + override fun onError(exception: AblyException) { + errorReceived = true + } + } + + asyncScope.launchWithCallback(callback) { + delay(1000) // Long delay + "test result" + } + + // Cancel immediately + asyncScope.cancel(CancellationException("Test cancellation")) + + // Wait a bit to ensure cancellation takes effect + assertWaiter { errorReceived } + } + + @Test + fun testObjectsAsyncScopeNonAblyException() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var errorReceived = false + var error: AblyException? = null + + val callback = object : ObjectsCallback { + override fun onSuccess(result: String) { + fail("Should not call onSuccess for error case") + } + + override fun onError(exception: AblyException) { + errorReceived = true + error = exception + } + } + + asyncScope.launchWithCallback(callback) { + delay(10) + throw RuntimeException("Non-Ably exception") + } + + // Wait for error to be received + assertWaiter { errorReceived } + + // Non-Ably exceptions should be wrapped in AblyException + assertNotNull("Non-Ably exceptions should be wrapped in AblyException", error) + assertEquals("Error executing operation", error?.errorInfo?.message) + assertEquals(ErrorCode.BadRequest.code, error?.errorInfo?.code) + assertEquals(HttpStatusCode.BadRequest.code, error?.errorInfo?.statusCode) + + assertTrue(error?.cause is RuntimeException) + assertEquals("Non-Ably exception", error?.cause?.message) + } +}