feat(sync): добавлен механизм снятия блокировки синхронизации для хранилищ
This commit is contained in:
@@ -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) {
|
private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) {
|
||||||
val cleanedPath = if (path.startsWith("/")) path else "/$path"
|
val cleanedPath = if (path.startsWith("/")) path else "/$path"
|
||||||
val entries = readSyncJournal()
|
val entries = readSyncJournal()
|
||||||
|
|||||||
@@ -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 =
|
private fun systemLockPath(): Path =
|
||||||
_filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME).resolve(SYNC_LOCK_FILENAME)
|
_filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME).resolve(SYNC_LOCK_FILENAME)
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) {
|
||||||
val cleanedPath = if (path.startsWith("/")) path else "/$path"
|
val cleanedPath = if (path.startsWith("/")) path else "/$path"
|
||||||
val entries = readSyncJournal()
|
val entries = readSyncJournal()
|
||||||
|
|||||||
@@ -59,4 +59,10 @@ interface IStorageAccessor {
|
|||||||
suspend fun readSyncLock(): StorageSyncLock?
|
suspend fun readSyncLock(): StorageSyncLock?
|
||||||
suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean
|
suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean
|
||||||
suspend fun releaseSyncLock(holderId: String)
|
suspend fun releaseSyncLock(holderId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сбрасывает lock синхронизации без проверки [holderId] (снятие «залипшей» блокировки).
|
||||||
|
* Не использовать в обычном цикле синка — только для ручного вмешательства.
|
||||||
|
*/
|
||||||
|
suspend fun forceClearSyncLock()
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.ripple
|
import androidx.compose.material3.ripple
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -63,6 +64,8 @@ fun StorageTree(
|
|||||||
onDisableEncryption: (Tree<IStorageInfo>) -> Unit,
|
onDisableEncryption: (Tree<IStorageInfo>) -> Unit,
|
||||||
getStatusText: (Tree<IStorageInfo>) -> String,
|
getStatusText: (Tree<IStorageInfo>) -> String,
|
||||||
isEncryptionOpened: (Tree<IStorageInfo>) -> Boolean,
|
isEncryptionOpened: (Tree<IStorageInfo>) -> Boolean,
|
||||||
|
isStorageSyncLockHeld: suspend (IStorageInfo) -> Boolean,
|
||||||
|
onClearStorageSyncLock: (IStorageInfo) -> Unit,
|
||||||
) {
|
) {
|
||||||
val cur = tree.value
|
val cur = tree.value
|
||||||
val available by cur.isAvailable.collectAsStateWithLifecycle()
|
val available by cur.isAvailable.collectAsStateWithLifecycle()
|
||||||
@@ -125,6 +128,15 @@ fun StorageTree(
|
|||||||
horizontalAlignment = Alignment.End
|
horizontalAlignment = Alignment.End
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
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 showRenameDialog by remember { mutableStateOf(false) }
|
||||||
var showRemoveConfirmDialog by remember { mutableStateOf(false) }
|
var showRemoveConfirmDialog by remember { mutableStateOf(false) }
|
||||||
var showLockDialog by remember { mutableStateOf(false) }
|
var showLockDialog by remember { mutableStateOf(false) }
|
||||||
@@ -166,6 +178,25 @@ fun StorageTree(
|
|||||||
text = { Text(stringResource(R.string.encrypt)) }
|
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) {
|
if (showRenameDialog) {
|
||||||
@@ -295,7 +326,9 @@ fun StorageTree(
|
|||||||
onCloseEncrypted,
|
onCloseEncrypted,
|
||||||
onDisableEncryption,
|
onDisableEncryption,
|
||||||
getStatusText,
|
getStatusText,
|
||||||
isEncryptionOpened
|
isEncryptionOpened,
|
||||||
|
isStorageSyncLockHeld,
|
||||||
|
onClearStorageSyncLock,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,4 +328,37 @@ abstract class AbstractVaultBrowserViewModel(
|
|||||||
val openedMap = getOpenedStoragesUseCase.openedStorages.value
|
val openedMap = getOpenedStoragesUseCase.openedStorages.value
|
||||||
return openedMap.containsKey(storage.uuid)
|
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 ?: "Не удалось снять блокировку синхронизации")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ fun VaultBrowserScreen(
|
|||||||
onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) },
|
onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) },
|
||||||
getStatusText = { tree -> viewModel.getStorageStatus(tree.value) },
|
getStatusText = { tree -> viewModel.getStorageStatus(tree.value) },
|
||||||
isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(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)) }
|
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||||
|
|||||||
@@ -34,6 +34,9 @@
|
|||||||
<string name="new_name_title">New name</string>
|
<string name="new_name_title">New name</string>
|
||||||
<string name="remove_confirmation_dialog">Delete storage "%1$s"?</string>
|
<string name="remove_confirmation_dialog">Delete storage "%1$s"?</string>
|
||||||
<string name="storage_lock_actions">Storage encryption actions</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_title">Task pipeline</string>
|
||||||
<string name="task_pipeline_jobs">Jobs</string>
|
<string name="task_pipeline_jobs">Jobs</string>
|
||||||
|
|||||||
@@ -27,8 +27,14 @@ class RunStorageSyncUseCase(
|
|||||||
syncEngine.syncAllGroups { fraction, label ->
|
syncEngine.syncAllGroups { fraction, label ->
|
||||||
ctx.reportProgress(fraction, label)
|
ctx.reportProgress(fraction, label)
|
||||||
}
|
}
|
||||||
|
ctx.log(TaskLogLevel.Info, "Storage sync finished")
|
||||||
ctx.reportProgress(null, "Storage sync: completed")
|
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)
|
running.set(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user