@@ -4,7 +4,9 @@ import io.ably.lib.objects.type.BaseRealtimeObject
44import io.ably.lib.objects.type.ObjectUpdate
55import io.ably.lib.objects.type.livecounter.DefaultLiveCounter
66import io.ably.lib.objects.type.livemap.DefaultLiveMap
7+ import io.ably.lib.types.AblyException
78import io.ably.lib.util.Log
9+ import kotlinx.coroutines.CompletableDeferred
810
911/* *
1012 * @spec RTO5 - Processes OBJECT and OBJECT_SYNC messages during sync sequences
@@ -21,6 +23,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject
2123 * @spec RTO7 - Buffered object operations during sync
2224 */
2325 private val bufferedObjectOperations = mutableListOf<ObjectMessage >() // RTO7a
26+ private var syncCompletionWaiter: CompletableDeferred <Unit >? = null
2427
2528 /* *
2629 * Handles object messages (non-sync messages).
@@ -39,7 +42,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject
3942 }
4043
4144 // Apply messages immediately if synced
42- applyObjectMessages(objectMessages) // RTO8b
45+ applyObjectMessages(objectMessages, ObjectsOperationSource . CHANNEL ) // RTO8b
4346 }
4447
4548 /* *
@@ -62,7 +65,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject
6265 if (syncTracker.hasSyncEnded()) {
6366 // defer the state change event until the next tick if this was a new sync sequence
6467 // to allow any event listeners to process the start of the new sequence event that was emitted earlier during this event loop.
65- endSync(isNewSync )
68+ endSync()
6669 }
6770 }
6871
@@ -78,25 +81,48 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject
7881 bufferedObjectOperations.clear() // RTO5a2b
7982 syncObjectsDataPool.clear() // RTO5a2a
8083 currentSyncId = syncId
81- stateChange(ObjectsState .Syncing , false )
84+ syncCompletionWaiter = CompletableDeferred ()
85+ stateChange(ObjectsState .Syncing )
8286 }
8387
8488 /* *
8589 * Ends the current sync sequence.
8690 *
8791 * @spec RTO5c - Applies sync data and buffered operations
8892 */
89- internal fun endSync (deferStateEvent : Boolean ) {
93+ internal fun endSync () {
9094 Log .v(tag, " Ending sync sequence" )
91- applySync()
92- // should apply buffered object operations after we applied the sync.
93- // can use regular non-sync object.operation logic
94- applyObjectMessages(bufferedObjectOperations) // RTO5c6
95-
96- bufferedObjectOperations.clear() // RTO5c5
97- syncObjectsDataPool.clear() // RTO5c4
98- currentSyncId = null // RTO5c3
99- stateChange(ObjectsState .Synced , deferStateEvent)
95+ applySync() // RTO5c1/2/7
96+ applyObjectMessages(bufferedObjectOperations, ObjectsOperationSource .CHANNEL ) // RTO5c6
97+ bufferedObjectOperations.clear() // RTO5c5
98+ syncObjectsDataPool.clear() // RTO5c4
99+ currentSyncId = null // RTO5c3
100+ realtimeObjects.appliedOnAckSerials.clear() // RTO5c9
101+ stateChange(ObjectsState .Synced ) // RTO5c8
102+ syncCompletionWaiter?.complete(Unit )
103+ syncCompletionWaiter = null
104+ }
105+
106+ /* *
107+ * Called from publishAndApply (via withContext sequentialScope).
108+ * If SYNCED: apply immediately with LOCAL source.
109+ * If not SYNCED: suspend until endSync transitions to SYNCED (RTO20e), then apply.
110+ */
111+ internal suspend fun applyAckResult (messages : List <ObjectMessage >) {
112+ if (realtimeObjects.state != ObjectsState .Synced ) {
113+ if (syncCompletionWaiter == null ) syncCompletionWaiter = CompletableDeferred ()
114+ syncCompletionWaiter?.await() // suspends; resumes after endSync transitions to SYNCED (RTO20e1)
115+ }
116+ applyObjectMessages(messages, ObjectsOperationSource .LOCAL ) // RTO20f
117+ }
118+
119+ /* *
120+ * Fails all pending apply waiters.
121+ * Called when the channel enters DETACHED/SUSPENDED/FAILED (RTO20e1).
122+ */
123+ internal fun failBufferedAcks (error : AblyException ) {
124+ syncCompletionWaiter?.completeExceptionally(error)
125+ syncCompletionWaiter = null
100126 }
101127
102128 /* *
@@ -162,7 +188,10 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject
162188 *
163189 * @spec RTO9 - Creates zero-value objects if they don't exist
164190 */
165- private fun applyObjectMessages (objectMessages : List <ObjectMessage >) {
191+ private fun applyObjectMessages (
192+ objectMessages : List <ObjectMessage >,
193+ source : ObjectsOperationSource = ObjectsOperationSource .CHANNEL ,
194+ ) {
166195 // RTO9a
167196 for (objectMessage in objectMessages) {
168197 if (objectMessage.operation == null ) {
@@ -177,14 +206,30 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject
177206 Log .w(tag, " Object operation action is unknown, skipping message: ${objectMessage.id} " )
178207 continue
179208 }
209+
210+ // RTO9a3 - skip operations already applied on ACK (discard without taking any further action).
211+ // This check comes before zero-value object creation (RTO9a2a1) so that no zero-value object is
212+ // created for an objectId not yet in the pool when the echo is being discarded.
213+ // Note: siteTimeserials is NOT updated here intentionally — updating it to the echo's serial would
214+ // incorrectly reject older-but-unprocessed operations from the same site that arrive after the echo.
215+ if (objectMessage.serial != null &&
216+ realtimeObjects.appliedOnAckSerials.contains(objectMessage.serial)) {
217+ Log .d(tag, " RTO9a3: serial ${objectMessage.serial} already applied on ACK; discarding echo" )
218+ realtimeObjects.appliedOnAckSerials.remove(objectMessage.serial)
219+ continue // discard without taking any further action
220+ }
221+
180222 // RTO9a2a - we can receive an op for an object id we don't have yet in the pool. instead of buffering such operations,
181223 // we can create a zero-value object for the provided object id and apply the operation to that zero-value object.
182224 // this also means that all objects are capable of applying the corresponding *_CREATE ops on themselves,
183225 // since they need to be able to eventually initialize themselves from that *_CREATE op.
184226 // so to simplify operations handling, we always try to create a zero-value object in the pool first,
185227 // and then we can always apply the operation on the existing object in the pool.
186228 val obj = realtimeObjects.objectsPool.createZeroValueObjectIfNotExists(objectOperation.objectId) // RTO9a2a1
187- obj.applyObject(objectMessage) // RTO9a2a2, RTO9a2a3
229+ val applied = obj.applyObject(objectMessage, source) // RTO9a2a2, RTO9a2a3
230+ if (source == ObjectsOperationSource .LOCAL && applied && objectMessage.serial != null ) {
231+ realtimeObjects.appliedOnAckSerials.add(objectMessage.serial) // RTO9a2a4
232+ }
188233 }
189234 }
190235
@@ -228,7 +273,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject
228273 *
229274 * @spec RTO2 - Emits state change events for syncing and synced states
230275 */
231- private fun stateChange (newState : ObjectsState , deferEvent : Boolean ) {
276+ private fun stateChange (newState : ObjectsState ) {
232277 if (realtimeObjects.state == newState) {
233278 return
234279 }
@@ -240,6 +285,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject
240285 }
241286
242287 internal fun dispose () {
288+ syncCompletionWaiter?.cancel()
243289 syncObjectsDataPool.clear()
244290 bufferedObjectOperations.clear()
245291 disposeObjectsStateListeners()
0 commit comments