Coverage Summary for Class: UploadPendingChangesUseCaseImpl (com.georgeci.moneysurfer.data.sync)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| UploadPendingChangesUseCaseImpl |
100%
(2/2)
|
78.6%
(11/14)
|
100%
(53/53)
|
99.5%
(768/772)
|
| UploadPendingChangesUseCaseImpl$Companion |
|
| UploadPendingChangesUseCaseImpl$invoke$1 |
|
| UploadPendingChangesUseCaseImpl$invoke$lambda$0$1$$inlined$groupingBy$1 |
0%
(0/3)
|
|
| Total |
40%
(2/5)
|
78.6%
(11/14)
|
100%
(53/53)
|
99.5%
(768/772)
|
package com.georgeci.moneysurfer.data.sync
import arrow.core.raise.either
import co.touchlab.kermit.Logger
import com.georgeci.moneysurfer.domain.sync.UploadPendingChangesUseCase
import com.georgeci.moneysurfer.domain.sync.UploadProgress
import com.georgeci.moneysurfer.domain.sync.UploadSummary
import com.georgeci.moneysurfer.sync.api.SyncCancelToken
import com.georgeci.moneysurfer.sync.api.SyncResult
import com.georgeci.moneysurfer.sync.api.SyncScope
import com.georgeci.moneysurfer.sync.plugin.SyncEntityPlugin
import com.georgeci.moneysurfer.sync.repository.PendingMutationQueue
import org.koin.core.annotation.Single
import kotlin.coroutines.cancellation.CancellationException
/**
* Drains the outbox by dispatching each [PendingMutation] to the matching
* [SyncEntityPlugin]. Plugins are discovered via Koin `getAll<SyncEntityPlugin>()`.
*
* Successfully pushed mutations are removed; failures bump `attempts` and
* return to `PENDING` for the next sync cycle.
*/
@Single(binds = [UploadPendingChangesUseCase::class])
class UploadPendingChangesUseCaseImpl(
private val outbox: PendingMutationQueue,
private val authInfo: CurrentAuthInfo,
private val plugins: List<SyncEntityPlugin>,
) : UploadPendingChangesUseCase {
private val log = Logger.withTag(TAG)
override suspend fun invoke(
scope: SyncScope,
onProgress: suspend (UploadProgress) -> Unit,
cancelToken: SyncCancelToken,
): SyncResult<UploadSummary> = either {
val batch = outbox.pending(scope, limit = PendingMutationQueue.DEFAULT_BATCH_LIMIT)
if (batch.isEmpty()) {
log.d { "[drain] scope=$scope batch=empty" }
return@either UploadSummary(uploadedCount = 0)
}
log.i {
"[drain] scope=$scope batch=${batch.size} types=" +
batch.groupingBy { it.entityType }.eachCount()
}
outbox.markInFlight(batch.map { it.id })
val completed = mutableListOf<String>()
val allIds = batch.map { it.id }
try {
batch.forEachIndexed { index, mutation ->
cancelToken.throwIfCancelled()
val plugin = plugins.find { it.entityType == mutation.entityType }
if (plugin != null) {
plugin.push(mutation)
} else {
log.w { "[drain:skip] no plugin for entityType=${mutation.entityType} id=${mutation.id}" }
}
completed += mutation.id
onProgress(
UploadProgress(
entityType = mutation.entityType,
current = index + 1,
total = batch.size,
),
)
}
outbox.markCompleted(completed)
} catch (cancelled: CancellationException) {
outbox.markCompleted(completed)
(allIds - completed.toSet()).forEach { id -> outbox.markFailed(id, "cancelled") }
throw cancelled
} catch (
@Suppress("TooGenericExceptionCaught")
failure: Throwable,
) {
val failed = batch.getOrNull(completed.size)
log.e(failure) {
buildString {
append("[drain:fail] uploaded=${completed.size}/${batch.size} ")
append("reason=${failure.message}")
append(" auth=[${authInfo.diagnostics()}]")
if (failed != null) {
append(" failedMutation=[")
append("id=${failed.id}")
append(" type=${failed.entityType}")
append(" entity=${failed.entityId}")
append(" scope=${failed.scopeKey ?: "<none>"}")
append(" op=${failed.operation}")
append("]")
}
}
}
outbox.markCompleted(completed)
val pending = allIds - completed.toSet()
pending.forEach { id -> outbox.markFailed(id, failure.message ?: "unknown") }
raise(failure.toSyncError())
}
log.i { "[drain:done] uploaded=${completed.size}" }
UploadSummary(uploadedCount = completed.size)
}
private companion object {
const val TAG = "UploadPending"
}
}