Причина синхронизации и временная метка в логах

This commit is contained in:
2026-05-21 22:30:40 +03:00
parent d0f490a3fd
commit d3eac81660
14 changed files with 182 additions and 52 deletions

View File

@@ -4,6 +4,7 @@ import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncPaths
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason
import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -61,7 +62,7 @@ class StorageSyncBootstrap @Inject constructor(
} }
syncRunner.enqueue( syncRunner.enqueue(
displayTitle = uiStrings(R.string.task_title_storage_sync_background), displayTitle = uiStrings(R.string.task_title_storage_sync_background),
logReason = "debounce", reason = StorageSyncTriggerReason.Debounce,
) )
} }
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker 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.usecases.RunStorageSyncUseCase import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@@ -19,7 +20,7 @@ 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 runCatching {
syncRunner.runBlocking() syncRunner.runBlocking(StorageSyncTriggerReason.Background)
Timber.d("Periodic storage sync finished") Timber.d("Periodic storage sync finished")
Result.success() Result.success()
}.getOrElse { error -> }.getOrElse { error ->

View File

@@ -20,4 +20,7 @@ interface ITaskOrchestrator {
fun cancel(taskId: TaskId): Boolean fun cancel(taskId: TaskId): Boolean
fun cancelAll() fun cancelAll()
/** Запись в общий лог пайплайна вне контекста [TaskContext] (например, WorkManager sync). */
fun appendPipelineLog(level: TaskLogLevel, key: TaskLogKey)
} }

View File

@@ -6,6 +6,7 @@ import java.util.UUID
data class PipelineTask( data class PipelineTask(
val id: TaskId, val id: TaskId,
val title: String, val title: String,
val enqueuedAtMs: Long,
val dispatcher: CoroutineDispatcher, val dispatcher: CoroutineDispatcher,
val state: TaskRunState, val state: TaskRunState,
/** UUID storage, для которого идёт задача (кнопки только этой строки в UI). */ /** UUID storage, для которого идёт задача (кнопки только этой строки в UI). */

View File

@@ -0,0 +1,8 @@
package com.github.nullptroma.wallenc.domain.tasks
/** Источник запуска синхронизации хранилищ (для логов пайплайна задач). */
enum class StorageSyncTriggerReason {
Debounce,
SyncTab,
Background,
}

View File

@@ -3,7 +3,10 @@ package com.github.nullptroma.wallenc.domain.tasks
import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.domain.errors.WallencException
sealed class TaskLogKey { sealed class TaskLogKey {
data object SyncStarted : TaskLogKey() data class SyncStarted(val reason: StorageSyncTriggerReason) : TaskLogKey()
data object SyncFinished : TaskLogKey() data class SyncFinished(val reason: StorageSyncTriggerReason) : TaskLogKey()
data class SyncFailed(val error: WallencException) : TaskLogKey() data class SyncFailed(
val error: WallencException,
val reason: StorageSyncTriggerReason,
) : TaskLogKey()
} }

View File

@@ -73,6 +73,7 @@ class TaskOrchestrator(
val task = PipelineTask( val task = PipelineTask(
id = id, id = id,
title = title, title = title,
enqueuedAtMs = System.currentTimeMillis(),
dispatcher = dispatcher, dispatcher = dispatcher,
state = TaskRunState.Queued, state = TaskRunState.Queued,
busyStorageUuid = busyStorageUuid, busyStorageUuid = busyStorageUuid,
@@ -104,6 +105,10 @@ class TaskOrchestrator(
} }
} }
override fun appendPipelineLog(level: TaskLogLevel, key: TaskLogKey) {
appendLogLine(level, message = "", logKey = key)
}
private fun replaceTask(id: TaskId, fn: (PipelineTask) -> PipelineTask) { private fun replaceTask(id: TaskId, fn: (PipelineTask) -> PipelineTask) {
synchronized(tasksById) { synchronized(tasksById) {
val cur = tasksById[id] ?: return val cur = tasksById[id] ?: return

View File

@@ -1,13 +1,31 @@
package com.github.nullptroma.wallenc.ui.resources package com.github.nullptroma.wallenc.ui.resources
import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason
import com.github.nullptroma.wallenc.domain.tasks.TaskLogKey import com.github.nullptroma.wallenc.domain.tasks.TaskLogKey
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
fun TaskLogKey.resolve(resolver: UiStringResolver): String = when (this) { fun TaskLogKey.resolve(resolver: UiStringResolver): String = when (this) {
TaskLogKey.SyncStarted -> resolver(R.string.task_log_sync_started) is TaskLogKey.SyncStarted -> resolver(
TaskLogKey.SyncFinished -> resolver(R.string.task_log_sync_finished) R.string.task_log_sync_started,
resolver.resolveSyncTriggerReason(reason),
)
is TaskLogKey.SyncFinished -> resolver(
R.string.task_log_sync_finished,
resolver.resolveSyncTriggerReason(reason),
)
is TaskLogKey.SyncFailed -> { is TaskLogKey.SyncFailed -> {
val notification = error.toUserNotification().resolve(resolver) val notification = error.toUserNotification().resolve(resolver)
resolver(R.string.task_log_sync_failed, notification) resolver(
R.string.task_log_sync_failed,
resolver.resolveSyncTriggerReason(reason),
notification,
)
} }
} }
private fun UiStringResolver.resolveSyncTriggerReason(reason: StorageSyncTriggerReason): String =
when (reason) {
StorageSyncTriggerReason.Debounce -> this(R.string.task_sync_trigger_debounce)
StorageSyncTriggerReason.SyncTab -> this(R.string.task_sync_trigger_sync_tab)
StorageSyncTriggerReason.Background -> this(R.string.task_sync_trigger_background)
}

View File

@@ -0,0 +1,13 @@
package com.github.nullptroma.wallenc.ui.resources
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val pipelineTimeFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("HH:mm:ss")
fun formatTaskPipelineTime(timestampMs: Long): String =
Instant.ofEpochMilli(timestampMs)
.atZone(ZoneId.systemDefault())
.format(pipelineTimeFormatter)

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -36,7 +37,9 @@ import com.github.nullptroma.wallenc.domain.tasks.PipelineTask
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLine
import com.github.nullptroma.wallenc.ui.resources.displayText import com.github.nullptroma.wallenc.ui.resources.displayText
import com.github.nullptroma.wallenc.ui.resources.formatTaskPipelineTime
import com.github.nullptroma.wallenc.ui.resources.resolveText import com.github.nullptroma.wallenc.ui.resources.resolveText
import com.github.nullptroma.wallenc.ui.resources.toUserNotification import com.github.nullptroma.wallenc.ui.resources.toUserNotification
@@ -91,18 +94,7 @@ fun TaskPipelineScreen(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
items(logs.size) { i -> items(logs.size) { i ->
val line = logs[i] PipelineLogRow(line = logs[i])
val prefix = when (line.level) {
TaskLogLevel.Debug -> "D"
TaskLogLevel.Info -> "I"
TaskLogLevel.Warn -> "W"
TaskLogLevel.Error -> "E"
}
Text(
"[$prefix] ${line.displayText()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
Button( Button(
@@ -178,14 +170,59 @@ fun TaskPipelineScreen(
} }
} }
@Composable
private fun PipelineLogRow(line: TaskLogLine) {
val prefix = when (line.level) {
TaskLogLevel.Debug -> "D"
TaskLogLevel.Info -> "I"
TaskLogLevel.Warn -> "W"
TaskLogLevel.Error -> "E"
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Text(
text = "[$prefix] ${line.displayText()}",
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = formatTaskPipelineTime(line.timestampMs),
modifier = Modifier.widthIn(min = 56.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable @Composable
private fun TaskRow(task: PipelineTask, isRunning: Boolean) { private fun TaskRow(task: PipelineTask, isRunning: Boolean) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Text( Row(
task.title, modifier = Modifier.fillMaxWidth(),
style = if (isRunning) MaterialTheme.typography.titleSmall horizontalArrangement = Arrangement.SpaceBetween,
else MaterialTheme.typography.bodyMedium, verticalAlignment = Alignment.CenterVertically,
) ) {
Text(
text = task.title,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
style = if (isRunning) MaterialTheme.typography.titleSmall
else MaterialTheme.typography.bodyMedium,
)
Text(
text = formatTaskPipelineTime(task.enqueuedAtMs),
modifier = Modifier.widthIn(min = 56.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val runningProgress = (task.state as? TaskRunState.Running)?.progress val runningProgress = (task.state as? TaskRunState.Running)?.progress
val progressLabel = runningProgress?.label?.resolveText() val progressLabel = runningProgress?.label?.resolveText()
val stateLabel = when (val s = task.state) { val stateLabel = when (val s = task.state) {

View File

@@ -13,6 +13,7 @@ import com.github.nullptroma.wallenc.ui.resources.resolve
import com.github.nullptroma.wallenc.ui.resources.UserNotification import com.github.nullptroma.wallenc.ui.resources.UserNotification
import com.github.nullptroma.wallenc.usecases.AddStorageToSyncGroupResult import com.github.nullptroma.wallenc.usecases.AddStorageToSyncGroupResult
import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase
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.StorageSyncCompatibilityInput import com.github.nullptroma.wallenc.usecases.StorageSyncCompatibilityInput
import com.github.nullptroma.wallenc.usecases.isStorageCompatibleWithGroup import com.github.nullptroma.wallenc.usecases.isStorageCompatibleWithGroup
@@ -209,7 +210,7 @@ class StorageSyncViewModel @Inject constructor(
fun runSyncNow() { fun runSyncNow() {
val started = runStorageSyncUseCase.enqueue( val started = runStorageSyncUseCase.enqueue(
displayTitle = uiStrings(R.string.task_title_storage_sync), displayTitle = uiStrings(R.string.task_title_storage_sync),
logReason = "sync-tab", reason = StorageSyncTriggerReason.SyncTab,
) )
if (!started) { if (!started) {
updateState( updateState(

View File

@@ -289,9 +289,12 @@
<string name="sync_progress_group_renewing_locks">Синхронизация: группа «%1$s» — продление блокировок</string> <string name="sync_progress_group_renewing_locks">Синхронизация: группа «%1$s» — продление блокировок</string>
<string name="sync_progress_group_lock_renewal_failed">Синхронизация: группа «%1$s» — не удалось продлить блокировку</string> <string name="sync_progress_group_lock_renewal_failed">Синхронизация: группа «%1$s» — не удалось продлить блокировку</string>
<string name="task_progress_clear_content">%1$d / %2$d</string> <string name="task_progress_clear_content">%1$d / %2$d</string>
<string name="task_log_sync_started">Синхронизация хранилищ запущена</string> <string name="task_log_sync_started">Синхронизация хранилищ запущена (%1$s)</string>
<string name="task_log_sync_finished">Синхронизация хранилищ завершена</string> <string name="task_log_sync_finished">Синхронизация хранилищ завершена (%1$s)</string>
<string name="task_log_sync_failed">Синхронизация не удалась: %1$s</string> <string name="task_log_sync_failed">Синхронизация не удалась (%1$s): %2$s</string>
<string name="task_sync_trigger_debounce">debounce</string>
<string name="task_sync_trigger_sync_tab">sync-tab</string>
<string name="task_sync_trigger_background">background</string>
<string name="task_log_enumerating">Перечисление файлов и папок…</string> <string name="task_log_enumerating">Перечисление файлов и папок…</string>
<string name="task_log_creating_storage">Создание хранилища…</string> <string name="task_log_creating_storage">Создание хранилища…</string>
<string name="task_log_storage_created">Хранилище создано</string> <string name="task_log_storage_created">Хранилище создано</string>

View File

@@ -289,9 +289,12 @@
<string name="sync_progress_group_renewing_locks">Storage sync: group "%1$s" renewing locks</string> <string name="sync_progress_group_renewing_locks">Storage sync: group "%1$s" renewing locks</string>
<string name="sync_progress_group_lock_renewal_failed">Storage sync: group "%1$s" lock renewal failed</string> <string name="sync_progress_group_lock_renewal_failed">Storage sync: group "%1$s" lock renewal failed</string>
<string name="task_progress_clear_content">%1$d / %2$d</string> <string name="task_progress_clear_content">%1$d / %2$d</string>
<string name="task_log_sync_started">Storage sync started</string> <string name="task_log_sync_started">Storage sync started (%1$s)</string>
<string name="task_log_sync_finished">Storage sync finished</string> <string name="task_log_sync_finished">Storage sync finished (%1$s)</string>
<string name="task_log_sync_failed">Storage sync failed: %1$s</string> <string name="task_log_sync_failed">Storage sync failed (%1$s): %2$s</string>
<string name="task_sync_trigger_debounce">debounce</string>
<string name="task_sync_trigger_sync_tab">sync-tab</string>
<string name="task_sync_trigger_background">background</string>
<string name="task_log_enumerating">Enumerating files and directories…</string> <string name="task_log_enumerating">Enumerating files and directories…</string>
<string name="task_log_creating_storage">Creating storage…</string> <string name="task_log_creating_storage">Creating storage…</string>
<string name="task_log_storage_created">Storage created</string> <string name="task_log_storage_created">Storage created</string>

View File

@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.errors.toWallencException import com.github.nullptroma.wallenc.domain.errors.toWallencException
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.StorageSyncTriggerReason
import com.github.nullptroma.wallenc.domain.tasks.TaskId 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
@@ -31,10 +32,10 @@ class RunStorageSyncUseCase @Inject constructor(
/** /**
* @param displayTitle заголовок задачи в UI (локализованный на стороне вызова) * @param displayTitle заголовок задачи в UI (локализованный на стороне вызова)
* @param logReason техническая метка для логов (не для UI) * @param reason источник запуска — попадает в лог пайплайна
* @return false, если синхронизация уже в очереди или выполняется — новая задача не создана * @return false, если синхронизация уже в очереди или выполняется — новая задача не создана
*/ */
fun enqueue(displayTitle: String, logReason: String): Boolean { fun enqueue(displayTitle: String, reason: StorageSyncTriggerReason): Boolean {
if (!running.compareAndSet(false, true)) { if (!running.compareAndSet(false, true)) {
return false return false
} }
@@ -45,36 +46,68 @@ class RunStorageSyncUseCase @Inject constructor(
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
work = { ctx -> work = { ctx ->
try { try {
ctx.log(TaskLogLevel.Info, TaskLogKey.SyncStarted) executeSync(
ctx.reportProgress(null, TaskProgressLabel.SyncStarted) reason = reason,
syncEngine.syncAllGroups { fraction, label -> reportProgress = { fraction, label ->
ctx.reportProgress(fraction, label) ctx.reportProgress(fraction, label)
} },
ctx.log(TaskLogLevel.Info, TaskLogKey.SyncFinished) log = { level, key -> ctx.log(level, key) },
ctx.reportProgress(null, TaskProgressLabel.SyncCompleted) )
} catch (e: Exception) { } catch (e: Exception) {
val err = e.toWallencException() ctx.fail(e.toWallencException())
ctx.log(TaskLogLevel.Error, TaskLogKey.SyncFailed(err))
ctx.fail(err)
} finally { } finally {
running.set(false) clearRunningState()
_syncRunning.value = false
_activeSyncTaskId.value = null
} }
}, },
) )
_activeSyncTaskId.value = taskId _activeSyncTaskId.value = taskId
return true return true
} catch (t: Throwable) { } catch (t: Throwable) {
running.set(false) clearRunningState()
_syncRunning.value = false
_activeSyncTaskId.value = null
throw t throw t
} }
} }
suspend fun runBlocking() { suspend fun runBlocking(reason: StorageSyncTriggerReason) {
if (!running.compareAndSet(false, true)) {
return
}
_syncRunning.value = true
try {
executeSync(
reason = reason,
reportProgress = { _, _ -> },
log = { level, key -> orchestrator.appendPipelineLog(level, key) },
)
} finally {
clearRunningState()
}
}
private suspend fun executeSync(
reason: StorageSyncTriggerReason,
reportProgress: suspend (fraction: Float?, label: TaskProgressLabel?) -> Unit,
log: (TaskLogLevel, TaskLogKey) -> Unit,
) {
syncReadiness.awaitReady() syncReadiness.awaitReady()
syncEngine.syncAllGroups() log(TaskLogLevel.Info, TaskLogKey.SyncStarted(reason))
reportProgress(null, TaskProgressLabel.SyncStarted)
try {
syncEngine.syncAllGroups { fraction, label ->
reportProgress(fraction, label)
}
log(TaskLogLevel.Info, TaskLogKey.SyncFinished(reason))
reportProgress(null, TaskProgressLabel.SyncCompleted)
} catch (e: Exception) {
val err = e.toWallencException()
log(TaskLogLevel.Error, TaskLogKey.SyncFailed(err, reason))
throw e
}
}
private fun clearRunningState() {
running.set(false)
_syncRunning.value = false
_activeSyncTaskId.value = null
} }
} }