From 44a1c634f7271a5e0cc84bdf34160a48888743af Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Fri, 31 Jul 2026 23:25:51 +0800 Subject: [PATCH] :bug: prevent paste pull cursor gaps --- .../crosspaste/paste/PasteReleaseService.kt | 20 +++- .../crosspaste/sync/PastePullCursorManager.kt | 97 +++++++++++++++++++ .../com/crosspaste/sync/PastePullService.kt | 48 +++------ .../com/crosspaste/sync/SyncDeviceManager.kt | 5 +- .../com/crosspaste/DesktopNetworkModule.kt | 2 +- .../crosspaste/DesktopPasteComponentModule.kt | 3 + .../headless/HeadlessPasteboardService.kt | 11 +-- .../paste/AbstractPasteboardService.kt | 11 +-- .../paste/PasteReleaseServicePushTest.kt | 2 + .../PasteReleaseServiceRemoteDiscardTest.kt | 32 +++++- .../sync/PastePullCursorManagerTest.kt | 75 ++++++++++++++ .../crosspaste/sync/PastePullServiceTest.kt | 39 +++----- .../crosspaste/sync/SyncDeviceManagerTest.kt | 7 +- 13 files changed, 258 insertions(+), 94 deletions(-) create mode 100644 app/src/commonMain/kotlin/com/crosspaste/sync/PastePullCursorManager.kt create mode 100644 app/src/desktopTest/kotlin/com/crosspaste/sync/PastePullCursorManagerTest.kt diff --git a/app/src/commonMain/kotlin/com/crosspaste/paste/PasteReleaseService.kt b/app/src/commonMain/kotlin/com/crosspaste/paste/PasteReleaseService.kt index ec8ea255d..2189cb8ee 100644 --- a/app/src/commonMain/kotlin/com/crosspaste/paste/PasteReleaseService.kt +++ b/app/src/commonMain/kotlin/com/crosspaste/paste/PasteReleaseService.kt @@ -17,6 +17,7 @@ import com.crosspaste.path.UserDataPathProvider import com.crosspaste.presist.FilesIndex import com.crosspaste.presist.buildFilesIndexForReceive import com.crosspaste.sync.FilePullService +import com.crosspaste.sync.PastePullCursorManager import com.crosspaste.task.TaskBuilder import com.crosspaste.task.TaskSubmitter import com.crosspaste.utils.getFileUtils @@ -44,6 +45,7 @@ class PasteReleaseService( private val pasteDao: PasteDao, private val pasteItemReader: PasteItemReader, private val pasteProcessPlugins: List, + private val pastePullCursorManager: PastePullCursorManager, private val searchContentService: SearchContentService, private val syncRuntimeInfoDao: SyncRuntimeInfoDao, private val taskSubmitter: TaskSubmitter, @@ -230,9 +232,9 @@ class PasteReleaseService( "Discard oversized remote non-file paste from ${pasteData.appInstanceId}: " + "size=${pasteData.size}, limit=$maxSize" } - pasteDao.upsertPastePullCursorMaxCreateTime( + pastePullCursorManager.persistDiscardedMaxCreateTime( appInstanceId = pasteData.appInstanceId, - maxCreateTime = pasteData.createTime, + createTime = pasteData.createTime, ) val deviceName = syncRuntimeInfoDao @@ -291,6 +293,20 @@ class PasteReleaseService( } } + suspend fun releaseRemotePasteDataList( + pasteDataList: List, + releaseLast: suspend (PasteData) -> Result, + ): Result { + if (pasteDataList.isEmpty()) return Result.success(null) + + for (index in 0 until pasteDataList.lastIndex) { + releaseRemotePasteData(pasteDataList[index]) { _ -> } + .onFailure { return Result.failure(it) } + } + + return releaseLast(pasteDataList.last()) + } + suspend fun releaseRemotePasteDataWithFile( id: Long, tryWritePasteboard: (PasteData) -> Unit, diff --git a/app/src/commonMain/kotlin/com/crosspaste/sync/PastePullCursorManager.kt b/app/src/commonMain/kotlin/com/crosspaste/sync/PastePullCursorManager.kt new file mode 100644 index 000000000..938c4118b --- /dev/null +++ b/app/src/commonMain/kotlin/com/crosspaste/sync/PastePullCursorManager.kt @@ -0,0 +1,97 @@ +package com.crosspaste.sync + +import com.crosspaste.db.paste.PasteDao +import com.crosspaste.db.sync.SyncRuntimeInfoDao +import com.crosspaste.utils.StripedMutex +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.util.collections.ConcurrentMap +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class PastePullCursorManager( + private val pasteDao: PasteDao, + private val syncRuntimeInfoDao: SyncRuntimeInfoDao, +) { + + private val logger = KotlinLogging.logger {} + + private val deviceMaxCreateTime: MutableMap = ConcurrentMap() + private val deviceMutex = StripedMutex() + private val initMutex = Mutex() + + suspend fun init() { + initMutex.withLock { + val storedPasteMaxCreateTimes = pasteDao.getMaxCreateTimeByRemoteAppInstanceId() + val persistedPullCursorMaxCreateTimes = + pasteDao.getPastePullCursorMaxCreateTimes() + val appInstanceIds = + storedPasteMaxCreateTimes.keys + persistedPullCursorMaxCreateTimes.keys + val maxCreateTimeMap = + appInstanceIds.associateWith { appInstanceId -> + listOfNotNull( + storedPasteMaxCreateTimes[appInstanceId], + persistedPullCursorMaxCreateTimes[appInstanceId], + ).max() + } + + deviceMaxCreateTime.putAll(maxCreateTimeMap) + maxCreateTimeMap.forEach { (appInstanceId, maxCreateTime) -> + logger.debug { "Initialized sync state for $appInstanceId: maxCreateTime=$maxCreateTime" } + } + logger.info { "Paste pull cursor initialized with ${deviceMaxCreateTime.size} device(s)" } + } + } + + fun getMaxCreateTime(appInstanceId: String): Long? = deviceMaxCreateTime[appInstanceId] + + suspend fun updateMaxCreateTime( + appInstanceId: String, + createTime: Long, + ) { + deviceMutex.withLock(appInstanceId) { + updateMaxCreateTimeUnsafe(appInstanceId, createTime) + } + } + + suspend fun persistDiscardedMaxCreateTime( + appInstanceId: String, + createTime: Long, + ): Boolean = + deviceMutex.withLock(appInstanceId) { + if (syncRuntimeInfoDao.getSyncRuntimeInfo(appInstanceId) == null) { + logger.debug { "Skip persisting paste pull cursor for removed device $appInstanceId" } + return@withLock false + } + + pasteDao.upsertPastePullCursorMaxCreateTime( + appInstanceId = appInstanceId, + maxCreateTime = createTime, + ) + updateMaxCreateTimeUnsafe(appInstanceId, createTime) + true + } + + suspend fun removeDevice(appInstanceId: String) { + deviceMutex.withLock(appInstanceId) { + pasteDao.deletePastePullCursor(appInstanceId) + val storedPasteMaxCreateTime = + pasteDao.getMaxCreateTimeByRemoteAppInstanceId()[appInstanceId] + if (storedPasteMaxCreateTime == null) { + deviceMaxCreateTime.remove(appInstanceId) + } else { + deviceMaxCreateTime[appInstanceId] = storedPasteMaxCreateTime + } + logger.debug { "Removed paste pull cursor for $appInstanceId" } + } + } + + private fun updateMaxCreateTimeUnsafe( + appInstanceId: String, + createTime: Long, + ) { + val current = deviceMaxCreateTime[appInstanceId] + if (current == null || createTime > current) { + deviceMaxCreateTime[appInstanceId] = createTime + } + } +} diff --git a/app/src/commonMain/kotlin/com/crosspaste/sync/PastePullService.kt b/app/src/commonMain/kotlin/com/crosspaste/sync/PastePullService.kt index 41c1ac901..69df6bb75 100644 --- a/app/src/commonMain/kotlin/com/crosspaste/sync/PastePullService.kt +++ b/app/src/commonMain/kotlin/com/crosspaste/sync/PastePullService.kt @@ -1,6 +1,5 @@ package com.crosspaste.sync -import com.crosspaste.db.paste.PasteDao import com.crosspaste.db.sync.SyncState import com.crosspaste.net.clientapi.PullClientApi import com.crosspaste.net.clientapi.SuccessResult @@ -9,12 +8,9 @@ import com.crosspaste.paste.PasteboardService import com.crosspaste.utils.HostAndPort import com.crosspaste.utils.buildUrl import io.github.oshai.kotlinlogging.KotlinLogging -import io.ktor.util.collections.ConcurrentMap -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock class PastePullService( - private val pasteDao: PasteDao, + private val pastePullCursorManager: PastePullCursorManager, private val pasteboardService: PasteboardService, private val pullClientApi: PullClientApi, private val syncManager: SyncManager, @@ -22,43 +18,17 @@ class PastePullService( private val logger = KotlinLogging.logger {} - private val deviceMaxCreateTime: MutableMap = ConcurrentMap() - private val mutex = Mutex() - suspend fun init() { - val storedPasteMaxCreateTimes = pasteDao.getMaxCreateTimeByRemoteAppInstanceId() - val persistedPullCursorMaxCreateTimes = pasteDao.getPastePullCursorMaxCreateTimes() - val maxCreateTimeMap = - (storedPasteMaxCreateTimes.keys + persistedPullCursorMaxCreateTimes.keys).associateWith { appInstanceId -> - listOfNotNull( - storedPasteMaxCreateTimes[appInstanceId], - persistedPullCursorMaxCreateTimes[appInstanceId], - ).max() - } - deviceMaxCreateTime.putAll(maxCreateTimeMap) - maxCreateTimeMap.forEach { (appInstanceId, maxCreateTime) -> - logger.debug { "Initialized sync state for $appInstanceId: maxCreateTime=$maxCreateTime" } - } - logger.info { "PastePullService initialized with ${deviceMaxCreateTime.size} device(s)" } + pastePullCursorManager.init() } - fun getMaxCreateTime(appInstanceId: String): Long? = deviceMaxCreateTime[appInstanceId] + fun getMaxCreateTime(appInstanceId: String): Long? = pastePullCursorManager.getMaxCreateTime(appInstanceId) suspend fun updateMaxCreateTime( appInstanceId: String, createTime: Long, ) { - mutex.withLock { - val current = deviceMaxCreateTime[appInstanceId] - if (current == null || createTime > current) { - deviceMaxCreateTime[appInstanceId] = createTime - } - } - } - - fun removeDevice(appInstanceId: String) { - deviceMaxCreateTime.remove(appInstanceId) - logger.debug { "Removed sync state for $appInstanceId" } + pastePullCursorManager.updateMaxCreateTime(appInstanceId, createTime) } suspend fun pullAllDevices(limit: Long = 10L) { @@ -84,9 +54,13 @@ class PastePullService( allPasteData.sortBy { it.createTime } - pasteboardService.tryWriteRemotePasteboardList(allPasteData) - - logger.info { "Pulled and wrote ${allPasteData.size} paste(s) from all devices" } + pasteboardService + .tryWriteRemotePasteboardList(allPasteData) + .onSuccess { + logger.info { "Pulled and wrote ${allPasteData.size} paste(s) from all devices" } + }.onFailure { e -> + logger.warn(e) { "Stopped writing pulled pastes after a release failure" } + } } suspend fun pullBatch( diff --git a/app/src/commonMain/kotlin/com/crosspaste/sync/SyncDeviceManager.kt b/app/src/commonMain/kotlin/com/crosspaste/sync/SyncDeviceManager.kt index 640d57ecf..515b55b03 100644 --- a/app/src/commonMain/kotlin/com/crosspaste/sync/SyncDeviceManager.kt +++ b/app/src/commonMain/kotlin/com/crosspaste/sync/SyncDeviceManager.kt @@ -1,6 +1,5 @@ package com.crosspaste.sync -import com.crosspaste.db.paste.PasteDao import com.crosspaste.db.sync.SyncRuntimeInfo import com.crosspaste.db.sync.SyncRuntimeInfoDao import com.crosspaste.db.sync.SyncState @@ -16,7 +15,7 @@ import com.crosspaste.utils.buildUrl import io.github.oshai.kotlinlogging.KotlinLogging class SyncDeviceManager( - private val pasteDao: PasteDao, + private val pastePullCursorManager: PastePullCursorManager, private val pendingExchangeLedger: PendingExchangeLedger, private val secureStore: SecureStore, private val syncClientApi: SyncClientApi, @@ -199,7 +198,7 @@ class SyncDeviceManager( wsSessionManager.closeSession(syncRuntimeInfo.appInstanceId) secureStore.deleteCryptPublicKey(syncRuntimeInfo.appInstanceId) syncRuntimeInfoDao.deleteSyncRuntimeInfo(syncRuntimeInfo.appInstanceId) - pasteDao.deletePastePullCursor(syncRuntimeInfo.appInstanceId) + pastePullCursorManager.removeDevice(syncRuntimeInfo.appInstanceId) // HTTP fallback notification (best-effort, after local cleanup) if (!notifiedViaWs) { diff --git a/app/src/desktopMain/kotlin/com/crosspaste/DesktopNetworkModule.kt b/app/src/desktopMain/kotlin/com/crosspaste/DesktopNetworkModule.kt index 952d9f351..b3095f161 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/DesktopNetworkModule.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopNetworkModule.kt @@ -252,7 +252,7 @@ fun desktopNetworkModule(marketingMode: Boolean): Module = single { SharePushOrchestrator(get(), get(), get()) } single { SyncDeviceManager( - pasteDao = get(), + pastePullCursorManager = get(), pendingExchangeLedger = get(), secureStore = get(), syncClientApi = get(), diff --git a/app/src/desktopMain/kotlin/com/crosspaste/DesktopPasteComponentModule.kt b/app/src/desktopMain/kotlin/com/crosspaste/DesktopPasteComponentModule.kt index 5ef0d88b2..34fb24b87 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/DesktopPasteComponentModule.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopPasteComponentModule.kt @@ -53,6 +53,7 @@ import com.crosspaste.paste.plugin.type.UrlTypePlugin import com.crosspaste.rendering.OpenGraphService import com.crosspaste.rendering.RenderingService import com.crosspaste.sync.FilePullService +import com.crosspaste.sync.PastePullCursorManager import com.crosspaste.sync.PastePullService import com.crosspaste.task.CleanPasteTaskExecutor import com.crosspaste.task.CleanTaskTaskExecutor @@ -169,6 +170,7 @@ fun desktopPasteComponentModule(headless: Boolean): Module = notificationManager = get(), pasteDao = get(), pasteItemReader = get(), + pastePullCursorManager = get(), // Plugin order is load-bearing (mobile assembles the same commonMain // plugins and must keep these invariants): // 1. RemoveInvalid must run first. @@ -211,6 +213,7 @@ fun desktopPasteComponentModule(headless: Boolean): Module = // region Sync pull single { FilePullService(get(), get(), get(), get(), get(), get()) } + single { PastePullCursorManager(get(), get()) } single { PastePullService(get(), get(), get(), get()) } // endregion diff --git a/app/src/desktopMain/kotlin/com/crosspaste/headless/HeadlessPasteboardService.kt b/app/src/desktopMain/kotlin/com/crosspaste/headless/HeadlessPasteboardService.kt index 8efb87a9f..86d2831f0 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/headless/HeadlessPasteboardService.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/headless/HeadlessPasteboardService.kt @@ -71,15 +71,8 @@ class HeadlessPasteboardService( } } - override suspend fun tryWriteRemotePasteboardList(pasteDataList: List): Result { - if (pasteDataList.isEmpty()) return Result.success(null) - - for (i in 0 until pasteDataList.size - 1) { - pasteReleaseService.releaseRemotePasteData(pasteDataList[i]) { _ -> } - } - - return tryWriteRemotePasteboard(pasteDataList.last()) - } + override suspend fun tryWriteRemotePasteboardList(pasteDataList: List): Result = + pasteReleaseService.releaseRemotePasteDataList(pasteDataList, ::tryWriteRemotePasteboard) override suspend fun tryWriteRemotePasteboardWithFile(pasteId: Long): Result = pasteReleaseService.releaseRemotePasteDataWithFile(pasteId) { diff --git a/app/src/desktopMain/kotlin/com/crosspaste/paste/AbstractPasteboardService.kt b/app/src/desktopMain/kotlin/com/crosspaste/paste/AbstractPasteboardService.kt index a4b6afcd7..7808503ec 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/paste/AbstractPasteboardService.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/paste/AbstractPasteboardService.kt @@ -119,15 +119,8 @@ abstract class AbstractPasteboardService : } } - override suspend fun tryWriteRemotePasteboardList(pasteDataList: List): Result { - if (pasteDataList.isEmpty()) return Result.success(null) - - for (i in 0 until pasteDataList.size - 1) { - pasteReleaseService.releaseRemotePasteData(pasteDataList[i]) { _ -> } - } - - return tryWriteRemotePasteboard(pasteDataList.last()) - } + override suspend fun tryWriteRemotePasteboardList(pasteDataList: List): Result = + pasteReleaseService.releaseRemotePasteDataList(pasteDataList, ::tryWriteRemotePasteboard) override suspend fun tryWriteRemotePasteboardWithFile(pasteId: Long): Result = pasteReleaseService.releaseRemotePasteDataWithFile(pasteId) { storePasteData -> diff --git a/app/src/desktopTest/kotlin/com/crosspaste/paste/PasteReleaseServicePushTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/paste/PasteReleaseServicePushTest.kt index 2e9e04dd0..81db41842 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/paste/PasteReleaseServicePushTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/paste/PasteReleaseServicePushTest.kt @@ -9,6 +9,7 @@ import com.crosspaste.paste.item.PasteItemReader import com.crosspaste.path.PlatformUserDataPathProvider import com.crosspaste.path.UserDataPathProvider import com.crosspaste.presist.SingleFileInfoTree +import com.crosspaste.sync.PastePullCursorManager import com.crosspaste.task.TaskSubmitter import com.crosspaste.utils.getJsonUtils import io.mockk.coEvery @@ -43,6 +44,7 @@ class PasteReleaseServicePushTest { pasteDao = pasteDao, pasteItemReader = mockk(relaxed = true), pasteProcessPlugins = emptyList(), + pastePullCursorManager = mockk(relaxed = true), searchContentService = mockk(relaxed = true), syncRuntimeInfoDao = mockk(relaxed = true), taskSubmitter = mockk(relaxed = true), diff --git a/app/src/desktopTest/kotlin/com/crosspaste/paste/PasteReleaseServiceRemoteDiscardTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/paste/PasteReleaseServiceRemoteDiscardTest.kt index 5ebfc593e..6791a0d0e 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/paste/PasteReleaseServiceRemoteDiscardTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/paste/PasteReleaseServiceRemoteDiscardTest.kt @@ -10,6 +10,7 @@ import com.crosspaste.paste.item.CreatePasteItemHelper.createTextPasteItem import com.crosspaste.paste.item.PasteItemReader import com.crosspaste.paste.item.TextPasteItem import com.crosspaste.path.UserDataPathProvider +import com.crosspaste.sync.PastePullCursorManager import com.crosspaste.task.TaskSubmitter import com.crosspaste.utils.getJsonUtils import io.mockk.coEvery @@ -39,6 +40,7 @@ class PasteReleaseServiceRemoteDiscardTest { commonConfigManager: CommonConfigManager = configManager(), notificationManager: NotificationManager = mockk(relaxed = true), pasteDao: PasteDao = mockk(relaxed = true), + pastePullCursorManager: PastePullCursorManager = mockk(relaxed = true), syncRuntimeInfoDao: SyncRuntimeInfoDao = mockk(relaxed = true), taskSubmitter: TaskSubmitter = mockk(relaxed = true), ): PasteReleaseService = @@ -50,6 +52,7 @@ class PasteReleaseServiceRemoteDiscardTest { pasteDao = pasteDao, pasteItemReader = mockk(relaxed = true), pasteProcessPlugins = emptyList(), + pastePullCursorManager = pastePullCursorManager, searchContentService = mockk(relaxed = true), syncRuntimeInfoDao = syncRuntimeInfoDao, taskSubmitter = taskSubmitter, @@ -81,6 +84,7 @@ class PasteReleaseServiceRemoteDiscardTest { runBlocking { val notificationManager = mockk(relaxed = true) val pasteDao = mockk(relaxed = true) + val pastePullCursorManager = mockk(relaxed = true) val syncRuntimeInfoDao = mockk(relaxed = true) coEvery { syncRuntimeInfoDao.getSyncRuntimeInfo("remote-device") } returns null val taskSubmitter = mockk(relaxed = true) @@ -88,6 +92,7 @@ class PasteReleaseServiceRemoteDiscardTest { newService( notificationManager = notificationManager, pasteDao = pasteDao, + pastePullCursorManager = pastePullCursorManager, syncRuntimeInfoDao = syncRuntimeInfoDao, taskSubmitter = taskSubmitter, ) @@ -102,9 +107,9 @@ class PasteReleaseServiceRemoteDiscardTest { assertTrue(!wrotePasteboard) coVerify(exactly = 0) { taskSubmitter.submit(any()) } coVerify(exactly = 1) { - pasteDao.upsertPastePullCursorMaxCreateTime( + pastePullCursorManager.persistDiscardedMaxCreateTime( appInstanceId = "remote-device", - maxCreateTime = any(), + createTime = any(), ) } verify(exactly = 1) { notificationManager.sendNotification(any(), any(), any(), any()) } @@ -147,4 +152,27 @@ class PasteReleaseServiceRemoteDiscardTest { coVerify(exactly = 1) { taskSubmitter.submit(any()) } verify(exactly = 0) { notificationManager.sendNotification(any(), any(), any(), any()) } } + + @Test + fun `batch stops before a later discard when an earlier paste fails`() = + runBlocking { + val pastePullCursorManager = mockk(relaxed = true) + val taskSubmitter = mockk(relaxed = true) + coEvery { taskSubmitter.submit(any()) } throws IllegalStateException("database write failed") + val service = + newService( + pastePullCursorManager = pastePullCursorManager, + taskSubmitter = taskSubmitter, + ) + val normalPaste = remotePasteData(createTextPasteItem(text = "small")).copy(createTime = 100L) + val laterDiscard = remotePasteData(oversizedTextItem()).copy(createTime = 200L) + + val result = + service.releaseRemotePasteDataList(listOf(normalPaste, laterDiscard)) { + service.releaseRemotePasteData(it) { _ -> } + } + + assertTrue(result.isFailure) + coVerify(exactly = 0) { pastePullCursorManager.persistDiscardedMaxCreateTime(any(), any()) } + } } diff --git a/app/src/desktopTest/kotlin/com/crosspaste/sync/PastePullCursorManagerTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/sync/PastePullCursorManagerTest.kt new file mode 100644 index 000000000..de19f339e --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/sync/PastePullCursorManagerTest.kt @@ -0,0 +1,75 @@ +package com.crosspaste.sync + +import com.crosspaste.db.paste.PasteDao +import com.crosspaste.db.sync.SyncRuntimeInfoDao +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PastePullCursorManagerTest { + + @Test + fun `init restores newest cursor from stored pastes and durable pull cursor`() = + runTest { + val pasteDao = mockk() + coEvery { pasteDao.getMaxCreateTimeByRemoteAppInstanceId() } returns + mapOf( + "stored-only" to 100L, + "both" to 200L, + ) + coEvery { pasteDao.getPastePullCursorMaxCreateTimes() } returns + mapOf( + "cursor-only" to 300L, + "both" to 250L, + ) + val manager = PastePullCursorManager(pasteDao, mockk(relaxed = true)) + + manager.init() + + assertEquals(100L, manager.getMaxCreateTime("stored-only")) + assertEquals(300L, manager.getMaxCreateTime("cursor-only")) + assertEquals(250L, manager.getMaxCreateTime("both")) + } + + @Test + fun `discard cursor is persisted and updates memory for an existing device`() = + runTest { + val pasteDao = mockk(relaxed = true) + val syncRuntimeInfoDao = mockk() + coEvery { syncRuntimeInfoDao.getSyncRuntimeInfo("remote-device") } returns mockk() + val manager = PastePullCursorManager(pasteDao, syncRuntimeInfoDao) + + val persisted = manager.persistDiscardedMaxCreateTime("remote-device", 200L) + + assertTrue(persisted) + assertEquals(200L, manager.getMaxCreateTime("remote-device")) + coVerify(exactly = 1) { + pasteDao.upsertPastePullCursorMaxCreateTime("remote-device", 200L) + } + } + + @Test + fun `device removal restores stored paste cursor and does not recreate discarded cursor`() = + runTest { + val pasteDao = mockk(relaxed = true) + coEvery { pasteDao.getMaxCreateTimeByRemoteAppInstanceId() } returns + mapOf("remote-device" to 100L) + val syncRuntimeInfoDao = mockk() + coEvery { syncRuntimeInfoDao.getSyncRuntimeInfo("remote-device") } returns null + val manager = PastePullCursorManager(pasteDao, syncRuntimeInfoDao) + + manager.updateMaxCreateTime("remote-device", 150L) + manager.removeDevice("remote-device") + val persisted = manager.persistDiscardedMaxCreateTime("remote-device", 200L) + + assertFalse(persisted) + assertEquals(100L, manager.getMaxCreateTime("remote-device")) + coVerify(exactly = 1) { pasteDao.deletePastePullCursor("remote-device") } + coVerify(exactly = 0) { pasteDao.upsertPastePullCursorMaxCreateTime(any(), any()) } + } +} diff --git a/app/src/desktopTest/kotlin/com/crosspaste/sync/PastePullServiceTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/sync/PastePullServiceTest.kt index 78eb23a2a..e59d531e8 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/sync/PastePullServiceTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/sync/PastePullServiceTest.kt @@ -1,6 +1,5 @@ package com.crosspaste.sync -import com.crosspaste.db.paste.PasteDao import com.crosspaste.net.clientapi.PullClientApi import com.crosspaste.paste.PasteboardService import io.mockk.coEvery @@ -12,50 +11,36 @@ import kotlin.test.assertEquals class PastePullServiceTest { - private fun newService(pasteDao: PasteDao): PastePullService = + private fun newService(pastePullCursorManager: PastePullCursorManager): PastePullService = PastePullService( - pasteDao = pasteDao, + pastePullCursorManager = pastePullCursorManager, pasteboardService = mockk(relaxed = true), pullClientApi = mockk(relaxed = true), syncManager = mockk(relaxed = true), ) @Test - fun `init restores newest cursor from stored pastes and durable pull cursor`() = + fun `init delegates to cursor manager`() = runTest { - val pasteDao = mockk() - coEvery { pasteDao.getMaxCreateTimeByRemoteAppInstanceId() } returns - mapOf( - "stored-only" to 100L, - "both" to 200L, - ) - coEvery { pasteDao.getPastePullCursorMaxCreateTimes() } returns - mapOf( - "cursor-only" to 300L, - "both" to 250L, - ) - - val service = newService(pasteDao) + val cursorManager = mockk(relaxed = true) + + val service = newService(cursorManager) service.init() - assertEquals(100L, service.getMaxCreateTime("stored-only")) - assertEquals(300L, service.getMaxCreateTime("cursor-only")) - assertEquals(250L, service.getMaxCreateTime("both")) + coVerify(exactly = 1) { cursorManager.init() } } @Test fun `in-memory cursor update does not mark an unpersisted paste as durable`() = runTest { - val pasteDao = mockk() - coEvery { pasteDao.getMaxCreateTimeByRemoteAppInstanceId() } returns emptyMap() - coEvery { pasteDao.getPastePullCursorMaxCreateTimes() } returns - mapOf("remote-device" to 200L) + val cursorManager = mockk(relaxed = true) + coEvery { cursorManager.getMaxCreateTime("remote-device") } returns 300L - val service = newService(pasteDao) - service.init() + val service = newService(cursorManager) service.updateMaxCreateTime("remote-device", 300L) assertEquals(300L, service.getMaxCreateTime("remote-device")) - coVerify(exactly = 0) { pasteDao.upsertPastePullCursorMaxCreateTime(any(), any()) } + coVerify(exactly = 1) { cursorManager.updateMaxCreateTime("remote-device", 300L) } + coVerify(exactly = 0) { cursorManager.persistDiscardedMaxCreateTime(any(), any()) } } } diff --git a/app/src/desktopTest/kotlin/com/crosspaste/sync/SyncDeviceManagerTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/sync/SyncDeviceManagerTest.kt index 0d3593b45..2a0ebf00c 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/sync/SyncDeviceManagerTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/sync/SyncDeviceManagerTest.kt @@ -1,6 +1,5 @@ package com.crosspaste.sync -import com.crosspaste.db.paste.PasteDao import com.crosspaste.db.sync.SyncRuntimeInfo import com.crosspaste.db.sync.SyncRuntimeInfoDao import com.crosspaste.db.sync.SyncState @@ -25,7 +24,7 @@ import kotlin.test.assertTrue class SyncDeviceManagerTest { private class TestDeps { - val pasteDao: PasteDao = mockk(relaxed = true) + val pastePullCursorManager: PastePullCursorManager = mockk(relaxed = true) val pendingExchangeLedger: PendingExchangeLedger = PendingExchangeLedger() val secureStore: SecureStore = mockk(relaxed = true) val syncClientApi: SyncClientApi = mockk(relaxed = true) @@ -35,7 +34,7 @@ class SyncDeviceManagerTest { fun createManager(): SyncDeviceManager = SyncDeviceManager( - pasteDao = pasteDao, + pastePullCursorManager = pastePullCursorManager, pendingExchangeLedger = pendingExchangeLedger, secureStore = secureStore, syncClientApi = syncClientApi, @@ -426,7 +425,7 @@ class SyncDeviceManagerTest { coVerify { deps.secureStore.deleteCryptPublicKey(syncRuntimeInfo.appInstanceId) } coVerify { deps.syncRuntimeInfoDao.deleteSyncRuntimeInfo(syncRuntimeInfo.appInstanceId) } - coVerify { deps.pasteDao.deletePastePullCursor(syncRuntimeInfo.appInstanceId) } + coVerify { deps.pastePullCursorManager.removeDevice(syncRuntimeInfo.appInstanceId) } coVerify { deps.syncClientApi.notifyRemove(any()) } }