feat(sync): добавлен механизм снятия блокировки синхронизации для хранилищ

This commit is contained in:
2026-05-13 14:43:27 +03:00
parent f38b3dfbb4
commit 6c18a1d741
9 changed files with 122 additions and 2 deletions

View File

@@ -371,6 +371,14 @@ class EncryptedStorageAccessor(
}
}
override suspend fun forceClearSyncLock() {
syncLockMutex.withLock {
openWriteSystemFile(SYNC_LOCK_FILENAME).use { out ->
out.write(ByteArray(0))
}
}
}
private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) {
val cleanedPath = if (path.startsWith("/")) path else "/$path"
val entries = readSyncJournal()

View File

@@ -669,6 +669,27 @@ class LocalStorageAccessor(
}
}
override suspend fun forceClearSyncLock() = withContext(ioDispatcher) {
val lockPath = systemLockPath()
if (!Files.exists(lockPath)) {
return@withContext
}
Files.createDirectories(lockPath.parent)
FileChannel.open(
lockPath,
StandardOpenOption.READ,
StandardOpenOption.WRITE,
).use { channel ->
val fileLock = channel.lock()
try {
channel.truncate(0)
channel.force(true)
} finally {
fileLock.release()
}
}
}
private fun systemLockPath(): Path =
_filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME).resolve(SYNC_LOCK_FILENAME)

View File

@@ -636,6 +636,14 @@ class YandexStorageAccessor(
}
}
override suspend fun forceClearSyncLock() = withContext(ioDispatcher) {
syncLockMutex.withLock {
openWriteSystemFile(SYNC_LOCK_FILENAME).use { out ->
out.write(ByteArray(0))
}
}
}
private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) {
val cleanedPath = if (path.startsWith("/")) path else "/$path"
val entries = readSyncJournal()

View File

@@ -59,4 +59,10 @@ interface IStorageAccessor {
suspend fun readSyncLock(): StorageSyncLock?
suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean
suspend fun releaseSyncLock(holderId: String)
/**
* Сбрасывает lock синхронизации без проверки [holderId] (снятие «залипшей» блокировки).
* Не использовать в обычном цикле синка — только для ручного вмешательства.
*/
suspend fun forceClearSyncLock()
}

View File

@@ -29,6 +29,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -63,6 +64,8 @@ fun StorageTree(
onDisableEncryption: (Tree<IStorageInfo>) -> Unit,
getStatusText: (Tree<IStorageInfo>) -> String,
isEncryptionOpened: (Tree<IStorageInfo>) -> Boolean,
isStorageSyncLockHeld: suspend (IStorageInfo) -> Boolean,
onClearStorageSyncLock: (IStorageInfo) -> Unit,
) {
val cur = tree.value
val available by cur.isAvailable.collectAsStateWithLifecycle()
@@ -125,6 +128,15 @@ fun StorageTree(
horizontalAlignment = Alignment.End
) {
var expanded by remember { mutableStateOf(false) }
var syncLockHeld by remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(expanded, cur.uuid) {
if (expanded) {
syncLockHeld = null
syncLockHeld = isStorageSyncLockHeld(cur)
} else {
syncLockHeld = null
}
}
var showRenameDialog by remember { mutableStateOf(false) }
var showRemoveConfirmDialog by remember { mutableStateOf(false) }
var showLockDialog by remember { mutableStateOf(false) }
@@ -166,6 +178,25 @@ fun StorageTree(
text = { Text(stringResource(R.string.encrypt)) }
)
}
HorizontalDivider()
DropdownMenuItem(
enabled = syncLockHeld == true,
onClick = {
expanded = false
if (syncLockHeld == true) {
onClearStorageSyncLock(cur)
}
},
text = {
Text(
when (syncLockHeld) {
null -> stringResource(R.string.storage_sync_lock_checking)
true -> stringResource(R.string.storage_sync_unlock_action)
false -> stringResource(R.string.storage_sync_not_locked)
},
)
},
)
}
if (showRenameDialog) {
@@ -295,7 +326,9 @@ fun StorageTree(
onCloseEncrypted,
onDisableEncryption,
getStatusText,
isEncryptionOpened
isEncryptionOpened,
isStorageSyncLockHeld,
onClearStorageSyncLock,
)
}
}

View File

@@ -328,4 +328,37 @@ abstract class AbstractVaultBrowserViewModel(
val openedMap = getOpenedStoragesUseCase.openedStorages.value
return openedMap.containsKey(storage.uuid)
}
suspend fun isStorageSyncLockHeld(storage: IStorageInfo): Boolean {
val s = storage as? IStorage ?: return false
return try {
s.accessor.readSyncLock() != null
} catch (_: Exception) {
false
}
}
fun clearStorageSyncLock(storage: IStorageInfo) {
taskOrchestrator.enqueue(
title = "Снятие блокировки синхронизации",
dispatcher = Dispatchers.IO,
work = { ctx ->
try {
val s = storage as? IStorage
if (s == null) {
ctx.log(TaskLogLevel.Error, "Некорректное хранилище")
_messages.emit("Некорректное хранилище")
return@enqueue
}
ctx.log(TaskLogLevel.Info, "Снимаю блокировку синхронизации…")
s.accessor.forceClearSyncLock()
ctx.log(TaskLogLevel.Info, "Блокировка синхронизации снята")
_messages.emit("Блокировка синхронизации снята")
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Не удалось снять блокировку")
_messages.emit(e.message ?: "Не удалось снять блокировку синхронизации")
}
},
)
}
}

View File

@@ -90,6 +90,8 @@ fun VaultBrowserScreen(
onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) },
getStatusText = { tree -> viewModel.getStorageStatus(tree.value) },
isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) },
isStorageSyncLockHeld = { info -> viewModel.isStorageSyncLockHeld(info) },
onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) },
)
}
item { Spacer(modifier = Modifier.height(8.dp)) }

View File

@@ -34,6 +34,9 @@
<string name="new_name_title">New name</string>
<string name="remove_confirmation_dialog">Delete storage "%1$s"?</string>
<string name="storage_lock_actions">Storage encryption actions</string>
<string name="storage_sync_lock_checking">Проверка блокировки…</string>
<string name="storage_sync_unlock_action">Снять блокировку синхронизации</string>
<string name="storage_sync_not_locked">Синхронизация не заблокирована</string>
<string name="task_pipeline_title">Task pipeline</string>
<string name="task_pipeline_jobs">Jobs</string>

View File

@@ -27,8 +27,14 @@ class RunStorageSyncUseCase(
syncEngine.syncAllGroups { fraction, label ->
ctx.reportProgress(fraction, label)
}
ctx.log(TaskLogLevel.Info, "Storage sync finished")
ctx.reportProgress(null, "Storage sync: completed")
} finally {
}
catch (e: Exception) {
ctx.log(TaskLogLevel.Error, "Storage sync failed: ${e.message}")
ctx.reportProgress(null, "Storage sync: failed - ${e.message}")
}
finally {
running.set(false)
}
},