fix(sync): исправил журнал при DELETE/TRASH и безопасный flush
Добавил recordSyncJournal для delete/moveToTrash, StorageSyncJournalBuffer с восстановлением pending при ошибке записи и немедленным flush без debounce.
This commit is contained in:
@@ -2,6 +2,7 @@ package com.github.nullptroma.wallenc.usecases
|
||||
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncOperation
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncPaths
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncRevision
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
|
||||
@@ -10,8 +11,11 @@ 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.CancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -19,6 +23,8 @@ import java.time.Instant
|
||||
|
||||
class StorageSyncEngineTest {
|
||||
|
||||
private fun norm(path: String): String = StorageSyncPaths.normalize(path)
|
||||
|
||||
@Test
|
||||
fun syncAllGroupsReportsNoGroupsWhenEmpty() = runBlocking {
|
||||
val labels = mutableListOf<TaskProgressLabel?>()
|
||||
@@ -63,7 +69,7 @@ class StorageSyncEngineTest {
|
||||
|
||||
assertArrayEquals(payload, target.fileBytes(path))
|
||||
assertTrue(labels.any { it is TaskProgressLabel.SyncGroupCompleted })
|
||||
assertTrue(target.syncJournalEntries().any { it.path == path })
|
||||
assertTrue(target.syncJournalEntries().any { it.path == norm(path) })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -111,6 +117,81 @@ class StorageSyncEngineTest {
|
||||
engine.syncGroup(group.id) { _, _ -> }
|
||||
|
||||
assertNull(target.fileBytes(path))
|
||||
val targetEntry = target.syncJournalEntries().single { it.path == norm(path) }
|
||||
assertEquals(2L, targetEntry.revision.sequence)
|
||||
assertEquals("actor-b", targetEntry.revision.actorId)
|
||||
assertEquals(StorageSyncOperation.DELETE, targetEntry.operation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncSkipsWhenTargetRevisionAlreadyWinner() = runBlocking {
|
||||
val source = FakeStorage()
|
||||
val target = FakeStorage()
|
||||
val path = "already-synced.txt"
|
||||
val payload = "same".encodeToByteArray()
|
||||
source.putFile(path, payload)
|
||||
target.putFile(path, payload)
|
||||
|
||||
val winner = StorageSyncJournalEntry(
|
||||
path = path,
|
||||
operation = StorageSyncOperation.UPSERT,
|
||||
revision = StorageSyncRevision(
|
||||
sequence = 5L,
|
||||
actorId = "winner-actor",
|
||||
createdAt = Instant.parse("2024-08-01T00:00:00Z"),
|
||||
),
|
||||
size = payload.size.toLong(),
|
||||
)
|
||||
source.addSyncJournalEntry(winner)
|
||||
target.addSyncJournalEntry(winner)
|
||||
|
||||
val group = StorageSyncGroup(
|
||||
id = "skip-group",
|
||||
storageUuids = setOf(source.uuid, target.uuid),
|
||||
encryptionKind = StorageSyncGroupEncryptionKind.NONE,
|
||||
)
|
||||
val engine = createEngine(
|
||||
storages = listOf(source, target),
|
||||
groups = listOf(group),
|
||||
)
|
||||
engine.syncGroup(group.id) { _, _ -> }
|
||||
|
||||
val targetJournal = target.syncJournalEntries().single { it.path == norm(path) }
|
||||
assertEquals(winner.revision, targetJournal.revision)
|
||||
assertEquals(winner.operation, targetJournal.operation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openReadDoesNotChangeJournal() = runBlocking {
|
||||
val storage = FakeStorage()
|
||||
val path = "read-only.txt"
|
||||
storage.putFile(path, "data".encodeToByteArray())
|
||||
val before = storage.syncJournalEntries().size
|
||||
|
||||
storage.accessor.openRead(path).use { it.readBytes() }
|
||||
|
||||
assertEquals(before, storage.syncJournalEntries().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteWithRecordSyncJournalFalseDoesNotBumpSequence() = runBlocking {
|
||||
val storage = FakeStorage()
|
||||
val path = "to-delete.txt"
|
||||
storage.putFile(path, "x".encodeToByteArray())
|
||||
storage.addSyncJournalEntry(
|
||||
StorageSyncJournalEntry(
|
||||
path = path,
|
||||
operation = StorageSyncOperation.UPSERT,
|
||||
revision = StorageSyncRevision(10L, "prior", Instant.EPOCH),
|
||||
),
|
||||
)
|
||||
|
||||
storage.accessor.delete(path, recordSyncJournal = false)
|
||||
|
||||
assertNull(storage.fileBytes(path))
|
||||
val entry = storage.syncJournalEntries().single { it.path == norm(path) }
|
||||
assertEquals(10L, entry.revision.sequence)
|
||||
assertEquals(StorageSyncOperation.UPSERT, entry.operation)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -145,7 +226,11 @@ class StorageSyncEngineTest {
|
||||
engine.syncGroup(group.id) { _, _ -> }
|
||||
|
||||
assertArrayEquals(payload, target.fileBytes(path))
|
||||
assertTrue(path in (target.accessor as FakeStorageAccessor).trashedPaths)
|
||||
assertTrue(norm(path) in (target.accessor as FakeStorageAccessor).trashedPaths)
|
||||
val targetEntry = target.syncJournalEntries().single { it.path == norm(path) }
|
||||
assertEquals(3L, targetEntry.revision.sequence)
|
||||
assertEquals("actor-trash", targetEntry.revision.actorId)
|
||||
assertEquals(StorageSyncOperation.TRASH, targetEntry.operation)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -221,6 +306,45 @@ class StorageSyncEngineTest {
|
||||
assertNull((second.accessor as FakeStorageAccessor).syncLock)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncGroupCooperativeCancellationReleasesLocks() = runBlocking {
|
||||
val source = FakeStorage()
|
||||
val target = FakeStorage()
|
||||
val path = "slow.txt"
|
||||
val payload = "payload".encodeToByteArray()
|
||||
source.putFile(path, payload)
|
||||
(source.accessor as FakeStorageAccessor).openReadDelayMs = 5_000
|
||||
source.addSyncJournalEntry(
|
||||
StorageSyncJournalEntry(
|
||||
path = path,
|
||||
operation = StorageSyncOperation.UPSERT,
|
||||
revision = StorageSyncRevision(1L, "actor", Instant.EPOCH),
|
||||
size = payload.size.toLong(),
|
||||
),
|
||||
)
|
||||
val group = StorageSyncGroup(
|
||||
id = "cancel-group",
|
||||
storageUuids = setOf(source.uuid, target.uuid),
|
||||
encryptionKind = StorageSyncGroupEncryptionKind.NONE,
|
||||
)
|
||||
val engine = createEngine(
|
||||
storages = listOf(source, target),
|
||||
groups = listOf(group),
|
||||
)
|
||||
val job = async {
|
||||
engine.syncGroup(group.id) { _, _ -> }
|
||||
}
|
||||
kotlinx.coroutines.delay(50)
|
||||
job.cancel()
|
||||
try {
|
||||
job.await()
|
||||
} catch (_: CancellationException) {
|
||||
// expected
|
||||
}
|
||||
assertNull((source.accessor as FakeStorageAccessor).syncLock)
|
||||
assertNull((target.accessor as FakeStorageAccessor).syncLock)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncGroupReleasesLocksWhenJournalEmpty() = runBlocking {
|
||||
val first = FakeStorage()
|
||||
|
||||
@@ -41,10 +41,12 @@ class FakeStorage(
|
||||
}
|
||||
|
||||
fun putFile(path: String, bytes: ByteArray) {
|
||||
accessorImpl.dataFiles[path] = bytes
|
||||
accessorImpl.dataFiles[com.github.nullptroma.wallenc.domain.datatypes.StorageSyncPaths.normalize(path)] =
|
||||
bytes
|
||||
}
|
||||
|
||||
fun fileBytes(path: String): ByteArray? = accessorImpl.dataFiles[path]
|
||||
fun fileBytes(path: String): ByteArray? =
|
||||
accessorImpl.dataFiles[com.github.nullptroma.wallenc.domain.datatypes.StorageSyncPaths.normalize(path)]
|
||||
|
||||
fun addSyncJournalEntry(entry: StorageSyncJournalEntry) {
|
||||
accessorImpl.syncJournal = StorageSyncJournalMerge.merge(
|
||||
|
||||
@@ -5,10 +5,14 @@ import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournal
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalMerge
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncLock
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncOperation
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncPaths
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncRevision
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IFile
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -23,6 +27,8 @@ import java.time.Instant
|
||||
|
||||
class FakeStorageAccessor : IStorageAccessor {
|
||||
val dataFiles: MutableMap<String, ByteArray> = mutableMapOf()
|
||||
|
||||
private fun norm(path: String): String = StorageSyncPaths.normalize(path)
|
||||
val trashedPaths: MutableSet<String> = mutableSetOf()
|
||||
private val systemFiles: MutableMap<String, ByteArray> = mutableMapOf()
|
||||
private val _filesUpdates = MutableSharedFlow<DataPage<IFile>>(extraBufferCapacity = 16)
|
||||
@@ -31,6 +37,7 @@ class FakeStorageAccessor : IStorageAccessor {
|
||||
var syncLock: StorageSyncLock? = null
|
||||
var acquireLockResult: Boolean = true
|
||||
var readSyncJournalThrows: Throwable? = null
|
||||
var openReadDelayMs: Long = 0
|
||||
|
||||
override val size: StateFlow<Long?> = MutableStateFlow(0L)
|
||||
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
|
||||
@@ -51,7 +58,9 @@ class FakeStorageAccessor : IStorageAccessor {
|
||||
override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = emptyFlow()
|
||||
|
||||
override suspend fun getFileInfo(path: String): IFile {
|
||||
error("Not implemented in tests")
|
||||
val key = norm(path)
|
||||
val bytes = dataFiles[key] ?: throw IllegalStateException("File not found: $path")
|
||||
return FakeFile(key, bytes.size.toLong())
|
||||
}
|
||||
|
||||
override suspend fun getDirInfo(path: String): IDirectory {
|
||||
@@ -64,14 +73,17 @@ class FakeStorageAccessor : IStorageAccessor {
|
||||
|
||||
override suspend fun touchDir(path: String) = Unit
|
||||
|
||||
override suspend fun delete(path: String) {
|
||||
dataFiles.remove(path)
|
||||
override suspend fun delete(path: String, recordSyncJournal: Boolean) {
|
||||
dataFiles.remove(norm(path))
|
||||
if (recordSyncJournal) {
|
||||
recordDeleteJournal(path)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun openWrite(path: String, recordSyncJournal: Boolean): OutputStream {
|
||||
return object : ByteArrayOutputStream() {
|
||||
override fun close() {
|
||||
dataFiles[path] = toByteArray()
|
||||
dataFiles[norm(path)] = toByteArray()
|
||||
_filesUpdates.tryEmit(
|
||||
DataPage(
|
||||
listOf(FakeFile(path)),
|
||||
@@ -84,13 +96,20 @@ class FakeStorageAccessor : IStorageAccessor {
|
||||
}
|
||||
|
||||
override suspend fun openRead(path: String): InputStream {
|
||||
val bytes = dataFiles[path] ?: throw IllegalStateException("File not found: $path")
|
||||
if (openReadDelayMs > 0) {
|
||||
delay(openReadDelayMs)
|
||||
}
|
||||
val bytes = dataFiles[norm(path)] ?: throw IllegalStateException("File not found: $path")
|
||||
return ByteArrayInputStream(bytes)
|
||||
}
|
||||
|
||||
override suspend fun moveToTrash(path: String) {
|
||||
if (path in dataFiles) {
|
||||
trashedPaths.add(path)
|
||||
override suspend fun moveToTrash(path: String, recordSyncJournal: Boolean) {
|
||||
val key = norm(path)
|
||||
if (key in dataFiles) {
|
||||
trashedPaths.add(key)
|
||||
if (recordSyncJournal) {
|
||||
recordTrashJournal(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,10 +131,38 @@ class FakeStorageAccessor : IStorageAccessor {
|
||||
return syncJournal
|
||||
}
|
||||
|
||||
override suspend fun flushPendingSyncJournal() = Unit
|
||||
|
||||
override suspend fun putSyncJournalEntries(entries: StorageSyncJournal) {
|
||||
syncJournal = StorageSyncJournalMerge.merge(syncJournal, entries)
|
||||
}
|
||||
|
||||
private suspend fun recordDeleteJournal(path: String) {
|
||||
appendJournalEntry(path, StorageSyncOperation.DELETE)
|
||||
}
|
||||
|
||||
private suspend fun recordTrashJournal(path: String) {
|
||||
appendJournalEntry(path, StorageSyncOperation.TRASH)
|
||||
}
|
||||
|
||||
private suspend fun appendJournalEntry(path: String, operation: StorageSyncOperation) {
|
||||
val cleaned = norm(path)
|
||||
val nextSequence = (syncJournal.values.maxOfOrNull { it.revision.sequence } ?: 0L) + 1L
|
||||
putSyncJournalEntries(
|
||||
mapOf(
|
||||
cleaned to StorageSyncJournalEntry(
|
||||
path = cleaned,
|
||||
operation = operation,
|
||||
revision = StorageSyncRevision(
|
||||
sequence = nextSequence,
|
||||
actorId = "fake-actor",
|
||||
createdAt = Instant.now(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun readSyncLock(): StorageSyncLock? = syncLock
|
||||
|
||||
override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean {
|
||||
@@ -135,9 +182,12 @@ class FakeStorageAccessor : IStorageAccessor {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeFile(path: String) : IFile {
|
||||
class FakeFile(
|
||||
path: String,
|
||||
size: Long = 0L,
|
||||
) : IFile {
|
||||
override val metaInfo: IMetaInfo = object : IMetaInfo {
|
||||
override val size: Long = 0L
|
||||
override val size: Long = size
|
||||
override val isDeleted: Boolean = false
|
||||
override val isHidden: Boolean = false
|
||||
override val lastModified: Instant = Instant.now()
|
||||
|
||||
Reference in New Issue
Block a user