foreground task для фоновой синхронизации

This commit is contained in:
2026-05-22 00:51:29 +03:00
parent 35ba6dd377
commit b00eed901b
5 changed files with 117 additions and 20 deletions

View File

@@ -6,6 +6,7 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason
import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
import com.github.nullptroma.wallenc.usecases.StorageSyncRunOutcome
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import timber.log.Timber import timber.log.Timber
@@ -19,15 +20,21 @@ class StorageSyncWorker @AssistedInject constructor(
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
Timber.d("Periodic storage sync started (attempt %d)", runAttemptCount) Timber.d("Periodic storage sync started (attempt %d)", runAttemptCount)
return runCatching { return when (val outcome = syncRunner.enqueueAndAwait(StorageSyncTriggerReason.Background)) {
syncRunner.runBlocking(StorageSyncTriggerReason.Background) StorageSyncRunOutcome.SkippedAlreadyRunning -> {
Timber.d("Periodic storage sync skipped — already running")
Result.success()
}
StorageSyncRunOutcome.Completed -> {
Timber.d("Periodic storage sync finished") Timber.d("Periodic storage sync finished")
Result.success() Result.success()
}.getOrElse { error -> }
Timber.w(error, "Periodic storage sync failed") is StorageSyncRunOutcome.Failed -> {
Timber.w(outcome.error, "Periodic storage sync failed")
Result.retry() Result.retry()
} }
} }
}
companion object { companion object {
const val UNIQUE_WORK_NAME = "wallenc-storage-sync-periodic" const val UNIQUE_WORK_NAME = "wallenc-storage-sync-periodic"

View File

@@ -9,10 +9,13 @@ import com.github.nullptroma.wallenc.domain.tasks.TaskId
import com.github.nullptroma.wallenc.domain.tasks.TaskLogKey import com.github.nullptroma.wallenc.domain.tasks.TaskLogKey
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -73,19 +76,21 @@ class RunStorageSyncUseCase @Inject constructor(
} }
} }
suspend fun runBlocking(reason: StorageSyncTriggerReason) { /**
if (!running.compareAndSet(false, true)) { * Ставит sync в пайплайн задач (как debounce / sync-tab) и ждёт завершения.
return * Для WorkManager и других фоновых запусков без отдельного «orphan»-лога.
*/
suspend fun enqueueAndAwait(reason: StorageSyncTriggerReason): StorageSyncRunOutcome {
if (!enqueue(reason)) {
return StorageSyncRunOutcome.SkippedAlreadyRunning
} }
_syncRunning.value = true val taskId = _activeSyncTaskId.value
try { ?: return StorageSyncRunOutcome.Completed
executeSync( syncRunning.filter { !it }.first()
reason = reason, val state = orchestrator.pipelineState.value.tasks.find { it.id == taskId }?.state
reportProgress = { _, _ -> }, return when (state) {
log = { level, key -> orchestrator.appendPipelineLog(level, key) }, is TaskRunState.Failed -> StorageSyncRunOutcome.Failed(state.error)
) else -> StorageSyncRunOutcome.Completed
} finally {
clearRunningState()
} }
} }

View File

@@ -0,0 +1,13 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.errors.WallencException
/** Результат [RunStorageSyncUseCase.enqueueAndAwait]. */
sealed class StorageSyncRunOutcome {
/** Синхронизация уже выполнялась — новая задача не создана. */
data object SkippedAlreadyRunning : StorageSyncRunOutcome()
data object Completed : StorageSyncRunOutcome()
data class Failed(val error: WallencException) : StorageSyncRunOutcome()
}

View File

@@ -171,6 +171,74 @@ class StorageSyncEngineTest {
) )
engine.syncGroup(group.id) { _, label -> labels.add(label) } engine.syncGroup(group.id) { _, label -> labels.add(label) }
assertTrue(labels.any { it is TaskProgressLabel.SyncGroupLockFailed }) assertTrue(labels.any { it is TaskProgressLabel.SyncGroupLockFailed })
assertNull((first.accessor as FakeStorageAccessor).syncLock)
assertNull((second.accessor as FakeStorageAccessor).syncLock)
}
@Test
fun syncGroupReleasesLocksAfterSuccessfulSync() = runBlocking {
val source = FakeStorage()
val target = FakeStorage()
source.addSyncJournalEntry(
StorageSyncJournalEntry(
path = "a.txt",
operation = StorageSyncOperation.UPSERT,
revision = StorageSyncRevision(1L, "x", Instant.EPOCH),
),
)
source.putFile("a.txt", "x".encodeToByteArray())
val group = StorageSyncGroup(
id = "ok",
storageUuids = setOf(source.uuid, target.uuid),
encryptionKind = StorageSyncGroupEncryptionKind.NONE,
)
val engine = createEngine(
storages = listOf(source, target),
groups = listOf(group),
)
engine.syncGroup(group.id) { _, _ -> }
assertNull((source.accessor as FakeStorageAccessor).syncLock)
assertNull((target.accessor as FakeStorageAccessor).syncLock)
}
@Test
fun syncGroupReleasesLocksWhenJournalReadFails() = runBlocking {
val first = FakeStorage()
val second = FakeStorage()
(first.accessor as FakeStorageAccessor).readSyncJournalThrows =
IllegalStateException("journal read failed")
val group = StorageSyncGroup(
id = "journal-fail",
storageUuids = setOf(first.uuid, second.uuid),
encryptionKind = StorageSyncGroupEncryptionKind.NONE,
)
val engine = createEngine(
storages = listOf(first, second),
groups = listOf(group),
)
runCatching { engine.syncGroup(group.id) { _, _ -> } }
assertNull((first.accessor as FakeStorageAccessor).syncLock)
assertNull((second.accessor as FakeStorageAccessor).syncLock)
}
@Test
fun syncGroupReleasesLocksWhenJournalEmpty() = runBlocking {
val first = FakeStorage()
val second = FakeStorage()
val group = StorageSyncGroup(
id = "empty-journal",
storageUuids = setOf(first.uuid, second.uuid),
encryptionKind = StorageSyncGroupEncryptionKind.NONE,
)
val labels = mutableListOf<TaskProgressLabel?>()
val engine = createEngine(
storages = listOf(first, second),
groups = listOf(group),
)
engine.syncGroup(group.id) { _, label -> labels.add(label) }
assertTrue(labels.any { it is TaskProgressLabel.SyncGroupNoJournalEntries })
assertNull((first.accessor as FakeStorageAccessor).syncLock)
assertNull((second.accessor as FakeStorageAccessor).syncLock)
} }
private fun createEngine( private fun createEngine(

View File

@@ -30,6 +30,7 @@ class FakeStorageAccessor : IStorageAccessor {
var syncJournal: StorageSyncJournal = emptyMap() var syncJournal: StorageSyncJournal = emptyMap()
var syncLock: StorageSyncLock? = null var syncLock: StorageSyncLock? = null
var acquireLockResult: Boolean = true var acquireLockResult: Boolean = true
var readSyncJournalThrows: Throwable? = null
override val size: StateFlow<Long?> = MutableStateFlow(0L) override val size: StateFlow<Long?> = MutableStateFlow(0L)
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0) override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
@@ -106,7 +107,10 @@ class FakeStorageAccessor : IStorageAccessor {
} }
} }
override suspend fun readSyncJournal(): StorageSyncJournal = syncJournal override suspend fun readSyncJournal(): StorageSyncJournal {
readSyncJournalThrows?.let { throw it }
return syncJournal
}
override suspend fun putSyncJournalEntries(entries: StorageSyncJournal) { override suspend fun putSyncJournalEntries(entries: StorageSyncJournal) {
syncJournal = StorageSyncJournalMerge.merge(syncJournal, entries) syncJournal = StorageSyncJournalMerge.merge(syncJournal, entries)