11package com.powersync.sync
22
33import co.touchlab.kermit.Logger
4- import co.touchlab.stately.concurrency.AtomicBoolean
4+ import co.touchlab.stately.concurrency.AtomicReference
55import com.powersync.bucket.BucketChecksum
66import com.powersync.bucket.BucketRequest
77import com.powersync.bucket.BucketStorage
@@ -29,9 +29,13 @@ import io.ktor.http.contentType
2929import io.ktor.utils.io.ByteReadChannel
3030import io.ktor.utils.io.readUTF8Line
3131import kotlinx.coroutines.CancellationException
32+ import kotlinx.coroutines.CompletableDeferred
33+ import kotlinx.coroutines.CoroutineScope
34+ import kotlinx.coroutines.Job
3235import kotlinx.coroutines.delay
3336import kotlinx.coroutines.flow.Flow
3437import kotlinx.coroutines.flow.flow
38+ import kotlinx.coroutines.launch
3539import kotlinx.datetime.Clock
3640import kotlinx.serialization.encodeToString
3741import kotlinx.serialization.json.JsonObject
@@ -43,9 +47,10 @@ internal class SyncStream(
4347 private val retryDelayMs : Long = 5000L ,
4448 private val logger : Logger ,
4549 private val params : JsonObject ,
50+ private val scope : CoroutineScope ,
4651 httpEngine : HttpClientEngine ? = null ,
4752) {
48- private var isUploadingCrud = AtomicBoolean ( false )
53+ private var isUploadingCrud = AtomicReference < PendingCrudUpload ?>( null )
4954
5055 /* *
5156 * The current sync status. This instance is updated as changes occur
@@ -116,13 +121,18 @@ internal class SyncStream(
116121 }
117122 }
118123
119- suspend fun triggerCrudUpload () {
120- if (! status.connected || isUploadingCrud.value) {
121- return
124+ fun triggerCrudUploadAsync (): Job = scope.launch {
125+ val thisIteration = PendingCrudUpload (CompletableDeferred ())
126+ try {
127+ if (! status.connected || ! isUploadingCrud.compareAndSet(null , thisIteration)) {
128+ return @launch
129+ }
130+
131+ uploadAllCrud()
132+ } finally {
133+ isUploadingCrud.set(null )
134+ thisIteration.done.complete(Unit )
122135 }
123- isUploadingCrud.value = true
124- uploadAllCrud()
125- isUploadingCrud.value = false
126136 }
127137
128138 private suspend fun uploadAllCrud () {
@@ -153,8 +163,13 @@ internal class SyncStream(
153163 break
154164 }
155165 } catch (e: Exception ) {
156- logger.e { " Error uploading crud: ${e.message} " }
157166 status.update(uploading = false , uploadError = e)
167+
168+ if (e is CancellationException ) {
169+ throw e
170+ }
171+
172+ logger.e { " Error uploading crud: ${e.message} " }
158173 delay(retryDelayMs)
159174 break
160175 }
@@ -237,7 +252,6 @@ internal class SyncStream(
237252 validatedCheckpoint = null ,
238253 appliedCheckpoint = null ,
239254 bucketSet = initialBuckets.keys.toMutableSet(),
240- retry = false ,
241255 )
242256
243257 bucketEntries.forEach { entry ->
@@ -253,7 +267,12 @@ internal class SyncStream(
253267
254268 streamingSyncRequest(req).collect { value ->
255269 val line = JsonUtil .json.decodeFromString<SyncLine >(value)
270+
256271 state = handleInstruction(line, value, state)
272+
273+ if (state.abortIteration) {
274+ return @collect
275+ }
257276 }
258277
259278 status.update(downloading = false )
@@ -314,30 +333,40 @@ internal class SyncStream(
314333 }
315334
316335 private suspend fun handleStreamingSyncCheckpointComplete (state : SyncStreamState ): SyncStreamState {
317- val result = bucketStorage.syncLocalDatabase(state.targetCheckpoint!! )
336+ val checkpoint = state.targetCheckpoint!!
337+ var result = bucketStorage.syncLocalDatabase(checkpoint)
338+ val pending = isUploadingCrud.get()
339+
318340 if (! result.checkpointValid) {
319341 // This means checksums failed. Start again with a new checkpoint.
320342 // TODO: better back-off
321343 delay(50 )
322- state.retry = true
344+ state.abortIteration = true
323345 // TODO handle retries
324346 return state
325- } else if (! result.ready) {
326- // Checksums valid, but need more data for a consistent checkpoint.
327- // Continue waiting .
328- // landing here the whole time
329- } else {
330- state.appliedCheckpoint = state.targetCheckpoint !! .clone()
331- logger.i { " validated checkpoint ${state.appliedCheckpoint} " }
347+ } else if (! result.ready && pending != null ) {
348+ // We have pending entries in the local upload queue or are waiting to confirm a write checkpoint, which
349+ // prevented this checkpoint from applying. Wait for that to complete and try again .
350+ logger.d { " Could not apply checkpoint due to local data. Waiting for in-progress upload before retrying. " }
351+ pending.done.await()
352+
353+ result = bucketStorage.syncLocalDatabase(checkpoint)
332354 }
333355
334- state.validatedCheckpoint = state.targetCheckpoint
335- status.update(
336- lastSyncedAt = Clock .System .now(),
337- downloading = false ,
338- hasSynced = true ,
339- clearDownloadError = true ,
340- )
356+ if (result.checkpointValid && result.ready) {
357+ state.appliedCheckpoint = checkpoint.clone()
358+ logger.i { " validated checkpoint ${state.appliedCheckpoint} " }
359+
360+ state.validatedCheckpoint = state.targetCheckpoint
361+ status.update(
362+ lastSyncedAt = Clock .System .now(),
363+ downloading = false ,
364+ hasSynced = true ,
365+ clearDownloadError = true ,
366+ )
367+ } else {
368+ logger.d { " Could not apply checkpoint. Waiting for next sync complete line" }
369+ }
341370
342371 return state
343372 }
@@ -352,12 +381,12 @@ internal class SyncStream(
352381 // This means checksums failed. Start again with a new checkpoint.
353382 // TODO: better back-off
354383 delay(50 )
355- state.retry = true
384+ state.abortIteration = true
356385 // TODO handle retries
357386 return state
358387 } else if (! result.ready) {
359- // Checksums valid, but need more data for a consistent checkpoint.
360- // Continue waiting .
388+ // Checkpoint is valid, but we have local data preventing this to be published. We'll try to resolve this
389+ // once we have a complete checkpoint if the problem persists .
361390 } else {
362391 logger.i { " validated partial checkpoint ${state.appliedCheckpoint} up to priority of $priority " }
363392 }
@@ -441,10 +470,11 @@ internal class SyncStream(
441470 // Connection would be closed automatically right after this
442471 logger.i { " Token expiring reconnect" }
443472 connector.invalidateCredentials()
444- state.retry = true
473+ state.abortIteration = true
445474 return state
446475 }
447- triggerCrudUpload()
476+ // Don't await the upload job, we can keep receiving sync lines
477+ triggerCrudUploadAsync()
448478 return state
449479 }
450480}
@@ -454,5 +484,7 @@ internal data class SyncStreamState(
454484 var validatedCheckpoint : Checkpoint ? ,
455485 var appliedCheckpoint : Checkpoint ? ,
456486 var bucketSet : MutableSet <String >? ,
457- var retry : Boolean ,
487+ var abortIteration : Boolean = false
458488)
489+
490+ private class PendingCrudUpload (val done : CompletableDeferred <Unit >)
0 commit comments