fix(sync): стабилизировал синхронизацию, Yandex I/O и вёрстку карточки storage

Добавил TRASH вместо DELETE для moveToTrash, компакцию журналов и отчёт об ошибках apply.
Исправил проброс ошибок upload Yandex при close, CAS lock и загрузку OAuth-токена.
Упростил совместимость sync-групп (только encInfo), поправил растягивание StorageTree при недоступных meta.
This commit is contained in:
2026-05-21 18:46:03 +03:00
parent ef40aa9e73
commit 51e6f40587
18 changed files with 268 additions and 89 deletions

View File

@@ -1,17 +1,14 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
import java.util.UUID
/** Совместим, если у storage нет активного шифрования ([encInfo] == null). */
fun isStorageCompatibleWithGroup(
storage: IStorage,
group: StorageSyncGroup,
resolveStorageKey: (UUID) -> EncryptKey?,
): Boolean {
// Режим упрощён: в sync-группах допускаются только незашифрованные storage.
if (storage.metaInfo.value.encInfo != null) {
return false
}

View File

@@ -19,6 +19,11 @@ import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
/**
* Синхронизация по журналам storage в группе.
* Блокировка на Yandex Disk — best-effort (см. [IStorageAccessor.tryAcquireSyncLock]);
* сериализация внутри процесса — [groupMutexes].
*/
@Singleton
class StorageSyncEngine @Inject constructor(
private val vaultsManager: IVaultsManager,
@@ -81,7 +86,6 @@ class StorageSyncEngine @Inject constructor(
isStorageCompatibleWithGroup(
storage = storage,
group = group,
resolveStorageKey = vaultsManager.unlockManager::getOpenedStorageKey,
)
}
if (incompatible.isNotEmpty()) {
@@ -148,6 +152,7 @@ class StorageSyncEngine @Inject constructor(
null,
TaskProgressLabel.SyncGroupProcessingEntries(groupId, mergedEntries.size),
)
var applyFailures = 0
for ((pathIndex, merged) in mergedEntries.withIndex()) {
leaseUntil = renewLocksIfNeeded(
groupId = groupId,
@@ -166,7 +171,9 @@ class StorageSyncEngine @Inject constructor(
return
}
val sourceStorage = findSourceStorage(storages, entriesByStorage, path, winnerEntry)
if (sourceStorage == null && winnerEntry.operation == StorageSyncOperation.UPSERT) {
if (sourceStorage == null &&
winnerEntry.operation == StorageSyncOperation.UPSERT
) {
continue
}
@@ -185,9 +192,18 @@ class StorageSyncEngine @Inject constructor(
)
if (applied) {
target.accessor.appendSyncJournal(listOf(winnerEntry))
} else {
applyFailures++
}
}
}
compactSyncJournals(storages)
if (applyFailures > 0) {
reportProgress(
null,
TaskProgressLabel.SyncGroupEntriesFailed(groupId, applyFailures),
)
}
reportProgress(null, TaskProgressLabel.SyncGroupCompleted(groupId))
} finally {
for (accessor in lockedAccessors) {
@@ -244,8 +260,12 @@ class StorageSyncEngine @Inject constructor(
path: String,
winnerEntry: StorageSyncJournalEntry,
): IStorage? {
if (winnerEntry.operation == StorageSyncOperation.DELETE) {
return storages.firstOrNull()
if (winnerEntry.operation == StorageSyncOperation.DELETE ||
winnerEntry.operation == StorageSyncOperation.TRASH
) {
return storages.firstOrNull { storage ->
entriesByStorage[storage.uuid]?.get(path) != null
} ?: storages.firstOrNull()
}
return storages.firstOrNull { storage ->
val entry = entriesByStorage[storage.uuid]?.get(path) ?: return@firstOrNull false
@@ -253,29 +273,52 @@ class StorageSyncEngine @Inject constructor(
}
}
private suspend fun compactSyncJournals(storages: List<IStorage>) {
for (storage in storages) {
val entries = storage.accessor.readSyncJournal()
val compacted = latestByPath(entries).values.toList()
if (compacted.size < entries.size) {
storage.accessor.rewriteSyncJournal(compacted)
}
}
}
private suspend fun applyEntry(
source: IStorage?,
target: IStorage,
entry: StorageSyncJournalEntry,
): Boolean {
when (entry.operation) {
val result = when (entry.operation) {
StorageSyncOperation.DELETE -> {
return runCatching {
runCatching {
target.accessor.delete(entry.path)
}.isSuccess
}
}
StorageSyncOperation.TRASH -> {
runCatching {
target.accessor.moveToTrash(entry.path)
}
}
StorageSyncOperation.UPSERT -> {
val sourceAccessor = source?.accessor ?: return false
return runCatching {
runCatching {
sourceAccessor.openRead(entry.path).use { input ->
target.accessor.openWrite(entry.path).use { output ->
input.copyTo(output)
}
}
}.isSuccess
}
}
}
result.exceptionOrNull()?.let { error ->
System.err.println(
"StorageSyncEngine: apply ${entry.operation} ${entry.path} " +
"target=${target.uuid}: ${error.message}",
)
}
return result.isSuccess
}
private fun compareEntries(a: StorageSyncJournalEntry, b: StorageSyncJournalEntry): Int {

View File

@@ -0,0 +1,41 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
import com.github.nullptroma.wallenc.usecases.fakes.FakeMetaInfo
import com.github.nullptroma.wallenc.usecases.fakes.FakeStorage
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.UUID
class StorageSyncEncryptionCompatTest {
@Test
fun storageWithoutEncInfoIsCompatible() {
val storage = FakeStorage(uuid = UUID.randomUUID(), meta = FakeMetaInfo(encInfo = null))
val group = StorageSyncGroup(
id = "g1",
storageUuids = setOf(storage.uuid),
encryptionKind = StorageSyncGroupEncryptionKind.NONE,
)
assertTrue(isStorageCompatibleWithGroup(storage = storage, group = group))
}
@Test
fun storageWithEncInfoIsIncompatible() {
val storage = FakeStorage(
uuid = UUID.randomUUID(),
meta = FakeMetaInfo(
encInfo = StorageEncryptionInfo(encryptedTestData = "x", pathIv = ByteArray(16)),
),
)
val group = StorageSyncGroup(
id = "g1",
storageUuids = setOf(storage.uuid),
encryptionKind = StorageSyncGroupEncryptionKind.NONE,
)
assertFalse(isStorageCompatibleWithGroup(storage = storage, group = group))
}
}

View File

@@ -7,6 +7,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.usecases.fakes.FakeStorage
import com.github.nullptroma.wallenc.usecases.fakes.FakeStorageAccessor
import com.github.nullptroma.wallenc.usecases.fakes.FakeStorageSyncGroupStore
import com.github.nullptroma.wallenc.usecases.fakes.FakeVaultsManager
import kotlinx.coroutines.runBlocking
@@ -112,6 +113,41 @@ class StorageSyncEngineTest {
assertNull(target.fileBytes(path))
}
@Test
fun syncGroupTrashSoftDeletesOnTarget() = runBlocking {
val source = FakeStorage()
val target = FakeStorage()
val path = "trashed/doc.txt"
val payload = "keep-in-trash".encodeToByteArray()
source.putFile(path, payload)
target.putFile(path, payload)
val entry = StorageSyncJournalEntry(
path = path,
operation = StorageSyncOperation.TRASH,
revision = StorageSyncRevision(
sequence = 3L,
actorId = "actor-trash",
createdAt = Instant.parse("2024-07-01T00:00:00Z"),
),
)
source.addSyncJournalEntry(entry)
val group = StorageSyncGroup(
id = "trash-group",
storageUuids = setOf(source.uuid, target.uuid),
encryptionKind = StorageSyncGroupEncryptionKind.NONE,
)
val engine = createEngine(
storages = listOf(source, target),
groups = listOf(group),
)
engine.syncGroup(group.id) { _, _ -> }
assertArrayEquals(payload, target.fileBytes(path))
assertTrue(path in (target.accessor as FakeStorageAccessor).trashedPaths)
}
@Test
fun syncGroupStopsWhenLockCannotBeAcquired() = runBlocking {
val first = FakeStorage()

View File

@@ -21,6 +21,7 @@ import java.time.Instant
class FakeStorageAccessor : IStorageAccessor {
val dataFiles: MutableMap<String, ByteArray> = mutableMapOf()
val trashedPaths: MutableSet<String> = mutableSetOf()
private val systemFiles: MutableMap<String, ByteArray> = mutableMapOf()
private val _filesUpdates = MutableSharedFlow<DataPage<IFile>>(extraBufferCapacity = 16)
@@ -84,7 +85,11 @@ class FakeStorageAccessor : IStorageAccessor {
return ByteArrayInputStream(bytes)
}
override suspend fun moveToTrash(path: String) = Unit
override suspend fun moveToTrash(path: String) {
if (path in dataFiles) {
trashedPaths.add(path)
}
}
override suspend fun openReadSystemFile(name: String): InputStream {
val bytes = systemFiles[name] ?: ByteArray(0)