From b00eed901bb5607a261d5d7ff3f028272e88d8ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Fri, 22 May 2026 00:51:29 +0300 Subject: [PATCH] =?UTF-8?q?foreground=20task=20=D0=B4=D0=BB=D1=8F=20=D1=84?= =?UTF-8?q?=D0=BE=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=81=D0=B8=D0=BD=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallenc/app/sync/StorageSyncWorker.kt | 21 ++++-- .../wallenc/usecases/RunStorageSyncUseCase.kt | 29 ++++---- .../wallenc/usecases/StorageSyncRunOutcome.kt | 13 ++++ .../wallenc/usecases/StorageSyncEngineTest.kt | 68 +++++++++++++++++++ .../usecases/fakes/FakeStorageAccessor.kt | 6 +- 5 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncRunOutcome.kt diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncWorker.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncWorker.kt index 1d2c930..84530e0 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncWorker.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncWorker.kt @@ -6,6 +6,7 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase +import com.github.nullptroma.wallenc.usecases.StorageSyncRunOutcome import dagger.assisted.Assisted import dagger.assisted.AssistedInject import timber.log.Timber @@ -19,13 +20,19 @@ class StorageSyncWorker @AssistedInject constructor( override suspend fun doWork(): Result { Timber.d("Periodic storage sync started (attempt %d)", runAttemptCount) - return runCatching { - syncRunner.runBlocking(StorageSyncTriggerReason.Background) - Timber.d("Periodic storage sync finished") - Result.success() - }.getOrElse { error -> - Timber.w(error, "Periodic storage sync failed") - Result.retry() + return when (val outcome = syncRunner.enqueueAndAwait(StorageSyncTriggerReason.Background)) { + StorageSyncRunOutcome.SkippedAlreadyRunning -> { + Timber.d("Periodic storage sync skipped — already running") + Result.success() + } + StorageSyncRunOutcome.Completed -> { + Timber.d("Periodic storage sync finished") + Result.success() + } + is StorageSyncRunOutcome.Failed -> { + Timber.w(outcome.error, "Periodic storage sync failed") + Result.retry() + } } } diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt index 9d578a1..09e98b5 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt @@ -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.TaskLogLevel import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel +import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton @@ -73,19 +76,21 @@ class RunStorageSyncUseCase @Inject constructor( } } - suspend fun runBlocking(reason: StorageSyncTriggerReason) { - if (!running.compareAndSet(false, true)) { - return + /** + * Ставит sync в пайплайн задач (как debounce / sync-tab) и ждёт завершения. + * Для WorkManager и других фоновых запусков без отдельного «orphan»-лога. + */ + suspend fun enqueueAndAwait(reason: StorageSyncTriggerReason): StorageSyncRunOutcome { + if (!enqueue(reason)) { + return StorageSyncRunOutcome.SkippedAlreadyRunning } - _syncRunning.value = true - try { - executeSync( - reason = reason, - reportProgress = { _, _ -> }, - log = { level, key -> orchestrator.appendPipelineLog(level, key) }, - ) - } finally { - clearRunningState() + val taskId = _activeSyncTaskId.value + ?: return StorageSyncRunOutcome.Completed + syncRunning.filter { !it }.first() + val state = orchestrator.pipelineState.value.tasks.find { it.id == taskId }?.state + return when (state) { + is TaskRunState.Failed -> StorageSyncRunOutcome.Failed(state.error) + else -> StorageSyncRunOutcome.Completed } } diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncRunOutcome.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncRunOutcome.kt new file mode 100644 index 0000000..8786c79 --- /dev/null +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncRunOutcome.kt @@ -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() +} diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngineTest.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngineTest.kt index de9a9e1..9a8237e 100644 --- a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngineTest.kt +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngineTest.kt @@ -171,6 +171,74 @@ class StorageSyncEngineTest { ) engine.syncGroup(group.id) { _, label -> labels.add(label) } 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() + 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( diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorageAccessor.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorageAccessor.kt index f4ee453..02e7f7d 100644 --- a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorageAccessor.kt +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorageAccessor.kt @@ -30,6 +30,7 @@ class FakeStorageAccessor : IStorageAccessor { var syncJournal: StorageSyncJournal = emptyMap() var syncLock: StorageSyncLock? = null var acquireLockResult: Boolean = true + var readSyncJournalThrows: Throwable? = null override val size: StateFlow = MutableStateFlow(0L) override val numberOfFiles: StateFlow = 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) { syncJournal = StorageSyncJournalMerge.merge(syncJournal, entries)