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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user