Coverage Summary for Class: SyncCoordinatorImpl (com.georgeci.moneysurfer.sync.internal.coordinator)

Class Method, % Branch, % Line, % Instruction, %
SyncCoordinatorImpl 100% (31/31) 80.5% (66/82) 99% (201/203) 99.3% (1764/1777)
SyncCoordinatorImpl$1 100% (1/1) 100% (1/1) 100% (2/2)
SyncCoordinatorImpl$2 100% (1/1) 100% (1/1) 100% (22/22)
SyncCoordinatorImpl$actorLoop$1
SyncCoordinatorImpl$awaitNetwork$1
SyncCoordinatorImpl$awaitNetwork$2$1 100% (1/1) 100% (1/1) 100% (7/7)
SyncCoordinatorImpl$awaitNetwork$2$onlined$1 100% (1/1) 100% (1/1) 100% (19/19)
SyncCoordinatorImpl$awaitNetwork$2$onlined$1$1 100% (1/1) 100% (1/1) 100% (7/7)
SyncCoordinatorImpl$cancelPendingInternal$1
SyncCoordinatorImpl$completeCancelled$1
SyncCoordinatorImpl$completeFailure$1
SyncCoordinatorImpl$completeSuccess$1
SyncCoordinatorImpl$emitStep$1
SyncCoordinatorImpl$ensureAppVersionAllowed$1
SyncCoordinatorImpl$finalizeCancelled$1
SyncCoordinatorImpl$handleCancelAll$1
SyncCoordinatorImpl$handleCancelCurrent$1
SyncCoordinatorImpl$handleCancelHandle$1
SyncCoordinatorImpl$handleCancelQueued$1
SyncCoordinatorImpl$handleEnqueue$1
SyncCoordinatorImpl$runSyncRequest$1
SyncCoordinatorImpl$runSyncRequest$2$pullSummary$1 100% (1/1) 100% (1/1) 100% (29/29)
SyncCoordinatorImpl$runSyncRequest$2$uploadSummary$1 100% (1/1) 100% (1/1) 100% (29/29)
SyncCoordinatorImpl$startWorkerIfNeeded$2 100% (1/1) 100% (2/2) 100% (11/11) 100% (231/231)
SyncCoordinatorImpl$startWorkerIfNeeded$2$4 100% (1/1) 100% (1/1) 100% (24/24)
SyncCoordinatorImpl$startWorkerIfNeeded$2$6 100% (1/1) 100% (1/1) 100% (24/24)
SyncCoordinatorImpl$startWorkerIfNeeded$2$7 100% (1/1) 100% (1/1) 100% (30/30)
SyncCoordinatorImpl$WhenMappings
Total 100% (42/42) 81% (68/84) 99.1% (222/224) 99.4% (2188/2201)


 package com.georgeci.moneysurfer.sync.internal.coordinator
 
 import arrow.core.left
 import arrow.core.raise.either
 import arrow.core.right
 import com.georgeci.moneysurfer.domain.sync.ProjectionScope
 import com.georgeci.moneysurfer.domain.sync.PullProgress
 import com.georgeci.moneysurfer.domain.sync.PullRemoteChangesUseCase
 import com.georgeci.moneysurfer.domain.sync.RecalculateLocalProjectionsUseCase
 import com.georgeci.moneysurfer.domain.sync.UploadPendingChangesUseCase
 import com.georgeci.moneysurfer.domain.sync.UploadProgress
 import com.georgeci.moneysurfer.sync.api.CompositeCancelToken
 import com.georgeci.moneysurfer.sync.api.LastSyncOutcome
 import com.georgeci.moneysurfer.sync.api.SyncCancelToken
 import com.georgeci.moneysurfer.sync.api.SyncCancelledException
 import com.georgeci.moneysurfer.sync.api.SyncError
 import com.georgeci.moneysurfer.sync.api.SyncHandleStatus
 import com.georgeci.moneysurfer.sync.api.SyncMode
 import com.georgeci.moneysurfer.sync.api.SyncReason
 import com.georgeci.moneysurfer.sync.api.SyncRequestId
 import com.georgeci.moneysurfer.sync.api.SyncResult
 import com.georgeci.moneysurfer.sync.api.SyncScope
 import com.georgeci.moneysurfer.sync.api.SyncState
 import com.georgeci.moneysurfer.sync.api.SyncStep
 import com.georgeci.moneysurfer.sync.api.SyncSummary
 import com.georgeci.moneysurfer.sync.coordinator.SyncCoordinator
 import com.georgeci.moneysurfer.sync.coordinator.SyncHandle
 import com.georgeci.moneysurfer.sync.internal.SyncSummaryBuilder
 import com.georgeci.moneysurfer.sync.internal.network.NetworkWaitMode
 import com.georgeci.moneysurfer.sync.internal.network.toNetworkWaitMode
 import com.georgeci.moneysurfer.sync.network.NetworkMonitor
 import com.georgeci.moneysurfer.sync.runtime.ApplicationScope
 import com.georgeci.moneysurfer.sync.telemetry.SyncTelemetry
 import com.georgeci.moneysurfer.sync.version.SyncVersionGate
 import com.georgeci.moneysurfer.sync.version.SyncVersionStatus
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.NonCancellable
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withTimeoutOrNull
 import org.koin.core.annotation.Single
 import kotlin.time.Clock
 
 /**
  * Single-actor implementation of [SyncCoordinator].
  *
  * All mutations of `pending` / `currentRequest` / `currentJob` happen inside
  * [actorLoop] — the only place that owns those fields. External callers
  * (`requestSync`, `cancelXxx`, `handle.cancel()`) interact with the actor only
  * via [commands].
  *
  * Cancel tokens are thread-safe ([SyncCancelToken] uses atomic state), so
  * callers may flip them synchronously from any thread before sending the
  * command. The actor then performs the visible state changes.
  *
  * See SyncCoordinatorFAQ.md (especially №1, №3, №4, №10, №19) for rationale.
  */
 @Single(binds = [SyncCoordinator::class])
 @Suppress("TooManyFunctions", "LongParameterList")
 class SyncCoordinatorImpl(
     private val appScope: ApplicationScope,
     private val networkMonitor: NetworkMonitor,
     private val versionGate: SyncVersionGate,
     private val uploadPendingChangesUseCase: UploadPendingChangesUseCase,
     private val pullRemoteChangesUseCase: PullRemoteChangesUseCase,
     private val recalculateLocalProjectionsUseCase: RecalculateLocalProjectionsUseCase,
     private val telemetry: SyncTelemetry = SyncTelemetry.NoOp,
     private val newRequestId: () -> SyncRequestId = { SyncRequestId.uuid() },
 ) : SyncCoordinator {
 
     /** Wall-clock start times keyed by request id — used for [SyncTelemetry] durations. */
     private val startedAt = mutableMapOf<SyncRequestId, kotlin.time.Instant>()
 
     private val commands = Channel<SyncCommand>(capacity = Channel.UNLIMITED)
 
     override val state: StateFlow<SyncState>
         field = MutableStateFlow<SyncState>(SyncState.Idle)
 
     override val lastOutcome: StateFlow<LastSyncOutcome>
         field = MutableStateFlow<LastSyncOutcome>(LastSyncOutcome.None)
 
     // Owned exclusively by actorLoop. Never read or mutated from outside the loop.
     private var pending: MergedSyncRequest? = null
     private var currentRequest: MergedSyncRequest? = null
     private var currentJob: Job? = null
 
     init {
         appScope.launch { actorLoop() }
     }
 
     // -------------------------------------------------------------------------
     // Public API
     // -------------------------------------------------------------------------
 
     override fun requestSync(reason: SyncReason, mode: SyncMode): SyncHandle {
         val syncRequest = SyncRequest(id = newRequestId(), reason = reason)
 
         commands.trySend(SyncCommand.Enqueue(syncRequest, mode))
 
         return SyncHandleImpl(
             id = syncRequest.id,
             status = syncRequest.status.asStateFlow(),
             steps = syncRequest.steps,
             result = syncRequest.result,
             cancelAction = {
                 syncRequest.cancelToken.cancel()
                 commands.trySend(SyncCommand.CancelHandle(syncRequest.id))
             },
         )
     }
 
     override fun cancelCurrent() {
         commands.trySend(SyncCommand.CancelCurrent)
     }
 
     override fun cancelAllQueued() {
         commands.trySend(SyncCommand.CancelQueued)
     }
 
     override fun cancelAll() {
         commands.trySend(SyncCommand.CancelAll)
     }
 
     // -------------------------------------------------------------------------
     // Actor loop
     // -------------------------------------------------------------------------
 
     private suspend fun actorLoop() {
         for (command in commands) {
             when (command) {
                 is SyncCommand.Enqueue -> handleEnqueue(command.request, command.mode)
                 is SyncCommand.CancelHandle -> handleCancelHandle(command.requestId)
                 SyncCommand.CancelCurrent -> handleCancelCurrent()
                 SyncCommand.CancelQueued -> handleCancelQueued()
                 SyncCommand.CancelAll -> handleCancelAll()
                 SyncCommand.WorkerFinished -> handleWorkerFinished()
             }
         }
     }
 
     private suspend fun handleEnqueue(request: SyncRequest, mode: SyncMode) {
         when (mode) {
             SyncMode.Enqueue -> {
                 pending = pending?.merge(request) ?: MergedSyncRequest.from(request)
             }
             SyncMode.ReplaceQueued -> {
                 cancelPendingInternal()
                 pending = MergedSyncRequest.from(request)
             }
         }
         publishQueueState()
         startWorkerIfNeeded()
     }
 
     private suspend fun handleCancelHandle(targetId: SyncRequestId) {
         // First check the currently running request.
         val current = currentRequest
         if (current != null) {
             val child = current.childRequests.firstOrNull { it.id == targetId }
             if (child != null) {
                 finalizeCancelled(child)
                 if (current.childRequests.all { it.cancelToken.isCancelled() }) {
                     currentJob?.cancel()
                 }
                 return
             }
         }
 
         // Otherwise look in the pending merged request.
         val queued = pending
         if (queued != null) {
             val child = queued.childRequests.firstOrNull { it.id == targetId }
             if (child != null) {
                 finalizeCancelled(child)
                 if (queued.childRequests.all { it.cancelToken.isCancelled() }) {
                     pending = null
                 }
                 publishQueueState()
             }
         }
     }
 
     private suspend fun handleCancelCurrent() {
         val current = currentRequest ?: return
         current.childRequests.forEach { finalizeCancelled(it) }
         currentJob?.cancel()
     }
 
     private suspend fun handleCancelQueued() {
         cancelPendingInternal()
         publishQueueState()
     }
 
     private suspend fun handleCancelAll() {
         cancelPendingInternal()
         val current = currentRequest
         if (current != null) {
             current.childRequests.forEach { finalizeCancelled(it) }
             currentJob?.cancel()
         }
         publishQueueState()
     }
 
     private fun handleWorkerFinished() {
         currentRequest = null
         currentJob = null
         startWorkerIfNeeded()
         publishQueueState()
     }
 
     // -------------------------------------------------------------------------
     // Worker
     // -------------------------------------------------------------------------
 
     private fun startWorkerIfNeeded() {
         if (currentJob?.isActive == true) return
         val next = pending ?: return
 
         // Skip already fully-cancelled merged requests.
         if (next.childRequests.all { it.cancelToken.isCancelled() }) {
             pending = null
             publishQueueState()
             return
         }
 
         pending = null
         currentRequest = next
         startedAt[next.id] = Clock.System.now()
         telemetry.onSyncStarted(next.id, next.reasons, next.scope)
 
         currentJob = appScope.launch {
             try {
                 val outcome = runSyncRequest(next)
                 outcome.fold(
                     ifLeft = { error ->
                         if (error is SyncError.Cancelled) {
                             completeCancelled(next)
                         } else {
                             completeFailure(next, error)
                         }
                     },
                     ifRight = { summary -> completeSuccess(next, summary) },
                 )
             } catch (_: SyncCancelledException) {
                 // suspend cleanup must run in NonCancellable so child.steps.emit(...)
                 // does not re-throw on the already-cancelled coroutine.
                 withContext(NonCancellable) { completeCancelled(next) }
             } catch (_: CancellationException) {
                 withContext(NonCancellable) { completeCancelled(next) }
             } catch (@Suppress("TooGenericExceptionCaught") t: Throwable) {
                 // Catch-all for unexpected errors — coordinator must always finish
                 // gracefully so WorkerFinished is emitted and the actor unblocks.
                 withContext(NonCancellable) { completeFailure(next, SyncError.Unknown(t)) }
             } finally {
                 commands.trySend(SyncCommand.WorkerFinished)
             }
         }
     }
 
     private suspend fun runSyncRequest(request: MergedSyncRequest): SyncResult<SyncSummary> {
         val cancelToken = mergedCancelToken(request)
         val summary = SyncSummaryBuilder()
 
         return either {
             emitStep(request, SyncStep.Started)
             cancelToken.throwIfCancelled()
 
             ensureAppVersionAllowed().bind()
             cancelToken.throwIfCancelled()
 
             awaitNetwork(request, cancelToken).bind()
             cancelToken.throwIfCancelled()
 
             emitStep(request, SyncStep.UploadingPendingChanges)
             val uploadSummary = uploadPendingChangesUseCase(
                 scope = request.scope,
                 onProgress = { progress -> emitUploadProgress(request, progress) },
                 cancelToken = cancelToken,
             ).bind()
             summary.addUpload(uploadSummary.uploadedCount)
             cancelToken.throwIfCancelled()
 
             var downloadedCount = 0
             if (request.scope != SyncScope.UploadOnly) {
                 emitStep(request, SyncStep.PullingRemoteChanges)
                 val pullSummary = pullRemoteChangesUseCase(
                     scope = request.scope,
                     onProgress = { progress -> emitPullProgress(request, progress) },
                     cancelToken = cancelToken,
                 ).bind()
                 downloadedCount = pullSummary.downloadedCount
                 summary.addDownload(downloadedCount)
                 summary.addConflicts(pullSummary.conflictCount)
             }
             cancelToken.throwIfCancelled()
 
             if (downloadedCount > 0) {
                 emitStep(request, SyncStep.RecalculatingProjections)
                 val projectionSummary = recalculateLocalProjectionsUseCase(
                     scope = projectionScopeFor(request.scope),
                     cancelToken = cancelToken,
                 ).bind()
                 summary.addRecalculated(projectionSummary.recalculatedCount)
             }
 
             summary.build()
         }
     }
 
     private suspend fun awaitNetwork(
         request: MergedSyncRequest,
         cancelToken: SyncCancelToken,
     ): SyncResult<Unit> = either {
         if (networkMonitor.online.value) return@either
         emitStep(request, SyncStep.WaitingForNetwork)
         when (val mode = request.reasons.toNetworkWaitMode()) {
             NetworkWaitMode.Indefinite -> {
                 networkMonitor.online.first { it }
             }
             is NetworkWaitMode.Bounded -> {
                 val onlined = withTimeoutOrNull(mode.timeout) {
                     networkMonitor.online.first { it }
                 }
                 if (onlined == null) raise(SyncError.NetworkUnavailable)
             }
         }
         cancelToken.throwIfCancelled()
     }
 
     /**
      * Refreshes the version gate (so a force-update flipped on the server
      * blocks within one sync cycle, not just at app start) and short-circuits
      * the pipeline if the app build is unsupported.
      *
      * See block.md.
      */
     private suspend fun ensureAppVersionAllowed(): SyncResult<Unit> = either {
         val status = versionGate.refresh()
         if (status is SyncVersionStatus.Blocked) {
             raise(SyncError.UnsupportedAppVersion(status.message))
         }
     }
 
     // -------------------------------------------------------------------------
     // State emission
     // -------------------------------------------------------------------------
 
     private suspend fun emitStep(request: MergedSyncRequest, step: SyncStep) {
         request.childRequests
             .filter { !it.result.isCompleted }
             .forEach { child ->
                 child.steps.emit(step)
                 child.status.value = SyncHandleStatus.Running(step)
             }
         state.value = SyncState.Running(
             requestId = request.id,
             reasons = request.reasons,
             scope = request.scope,
             currentStep = step,
         )
         telemetry.onStepEntered(request.id, step)
     }
 
     private suspend fun emitUploadProgress(request: MergedSyncRequest, progress: UploadProgress) {
         emitStep(
             request,
             SyncStep.UploadingEntity(
                 entityType = progress.entityType,
                 current = progress.current,
                 total = progress.total,
             ),
         )
     }
 
     private suspend fun emitPullProgress(request: MergedSyncRequest, progress: PullProgress) {
         emitStep(
             request,
             SyncStep.PullingCollection(
                 collection = progress.collection,
                 current = progress.current,
                 total = progress.total,
             ),
         )
     }
 
     // -------------------------------------------------------------------------
     // Terminal transitions
     // -------------------------------------------------------------------------
 
     private suspend fun completeSuccess(request: MergedSyncRequest, summary: SyncSummary) {
         request.childRequests
             .filter { !it.result.isCompleted }
             .forEach { child ->
                 child.steps.emit(SyncStep.Completed(summary))
                 child.status.value = SyncHandleStatus.Completed(summary)
                 child.result.complete(summary.right())
             }
         val now = Clock.System.now()
         lastOutcome.value = LastSyncOutcome.Success(summary, now)
         telemetry.onSyncCompleted(request.id, summary, durationSinceStart(request.id, now))
     }
 
     private suspend fun completeFailure(request: MergedSyncRequest, error: SyncError) {
         request.childRequests
             .filter { !it.result.isCompleted }
             .forEach { child ->
                 child.steps.emit(SyncStep.Failed(error))
                 child.status.value = SyncHandleStatus.Failed(error)
                 child.result.complete(error.left())
             }
         val now = Clock.System.now()
         lastOutcome.value = LastSyncOutcome.Failed(error, now)
         telemetry.onSyncFailed(request.id, error, durationSinceStart(request.id, now))
     }
 
     private suspend fun completeCancelled(request: MergedSyncRequest) {
         request.childRequests.forEach { child -> finalizeCancelled(child) }
         val now = Clock.System.now()
         lastOutcome.value = LastSyncOutcome.Cancelled(now)
         telemetry.onSyncCancelled(request.id, durationSinceStart(request.id, now))
     }
 
     private fun durationSinceStart(id: SyncRequestId, now: kotlin.time.Instant): kotlin.time.Duration {
         val started = startedAt.remove(id) ?: return kotlin.time.Duration.ZERO
         return now - started
     }
 
     private suspend fun finalizeCancelled(child: SyncRequest) {
         if (child.result.isCompleted) return
         child.cancelToken.cancel()
         child.steps.emit(SyncStep.Cancelled())
         child.status.value = SyncHandleStatus.Cancelled
         child.result.complete(SyncError.Cancelled.left())
     }
 
     // -------------------------------------------------------------------------
     // Helpers
     // -------------------------------------------------------------------------
 
     private suspend fun cancelPendingInternal() {
         val queued = pending ?: return
         queued.childRequests.forEach { finalizeCancelled(it) }
         pending = null
     }
 
     private fun publishQueueState() {
         val current = currentRequest
         if (current != null) return // running state is emitted via emitStep
         val queuedCount = pending?.childRequests
             ?.count { !it.cancelToken.isCancelled() }
             ?: 0
         state.value = if (queuedCount > 0) SyncState.Queued(queuedCount) else SyncState.Idle
     }
 
     private fun mergedCancelToken(request: MergedSyncRequest): SyncCancelToken =
         CompositeCancelToken(request.childRequests.map { it.cancelToken })
 
     /**
      * Maps [SyncScope] onto [ProjectionScope]. Without a workspace pointer at
      * coordinator level, all scopes collapse to [ProjectionScope.All].
      * Implementations of [RecalculateLocalProjectionsUseCase] may interpret
      * this as "all workspaces of the current user" and filter further.
      */
     private fun projectionScopeFor(scope: SyncScope): ProjectionScope = when (scope) {
         SyncScope.UploadOnly,
         SyncScope.ActiveWorkspace,
         SyncScope.AllUserData,
         SyncScope.ChangedSinceLastSync,
         -> ProjectionScope.All
     }
 }