fix(sync): исправил журнал при DELETE/TRASH и безопасный flush

Добавил recordSyncJournal для delete/moveToTrash, StorageSyncJournalBuffer
с восстановлением pending при ошибке записи и немедленным flush без debounce.
This commit is contained in:
2026-05-22 13:22:05 +03:00
parent b00eed901b
commit bc2b354820
9 changed files with 460 additions and 107 deletions

View File

@@ -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()

View File

@@ -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(

View File

@@ -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()