sync: preserve queue lock order during resets

Move pending-origin clearing into the queue mutation lock, acquire the cursor lock before Room, and clear pending rows before cache, revision, or listener side effects. Keep sync listener reconciliation transactional for retries while preserving direct reset commit visibility.
This commit is contained in:
austinried
2026-11-14 12:05:28 +00:00
parent 92e5bb45c9
commit 95c8a2a915
4 changed files with 208 additions and 87 deletions
@@ -15,9 +15,13 @@ import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
import org.junit.After
import org.junit.Assert.assertEquals
@@ -27,6 +31,8 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
class QueueRepositoryTest {
@@ -922,6 +928,53 @@ class QueueRepositoryTest {
assertEquals(2, repository.modes().repeat)
}
@Test
fun aConcurrentPositionWriteDoesNotDeadlockAPendingOriginReset() =
runBlocking {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)))
val point = repository.captureResetPoint()
val positionStarted = CountDownLatch(1)
var cursorLocked = true
repository.cursorMutex.lock()
try {
withTimeout(5_000) {
val position =
launch(Dispatchers.Default) {
repository.withMutationLock {
positionStarted.countDown()
repository.setPosition(42_000L)
}
}
assertTrue(positionStarted.await(5, TimeUnit.SECONDS))
val reset =
launch(Dispatchers.Default, start = CoroutineStart.UNDISPATCHED) {
repository.resetChangedOriginsAndClearPendingOrigins(
setOf(QueueOrigin(QueueKind.Album, 1, "al1")),
point,
sourceId = 1,
)
}
yield()
repository.cursorMutex.unlock()
cursorLocked = false
position.join()
reset.join()
}
} finally {
if (cursorLocked) repository.cursorMutex.unlock()
}
assertNull(
repository
.snapshot()
.entries
.single()
.entry.rangeStart,
)
assertEquals(42_000L, repository.cursorPositionMs())
}
@Test
fun upNextResolvesAfterTheCurrentTrack() =
runTest {
@@ -285,13 +285,14 @@ class SyncManagerTest {
server.dispatcher = healthyDispatcher()
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)))
val resetAttempted = CountDownLatch(1)
queueRepository.setResetListener { resetAttempted.countDown() }
val before = queueRepository.snapshot()
val cacheVersion = queueRepository.libraryCacheVersion()
val resetAttempts = AtomicInteger()
queueRepository.setResetListener { resetAttempts.incrementAndGet() }
failPendingOriginClear()
manager.requestSync()
assertTrue(resetAttempted.await(10, TimeUnit.SECONDS))
awaitUntil("waiting for the pending-origin clear failure") { manager.status.value !is SyncStatus.Running }
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(
0L,
@@ -301,8 +302,16 @@ class SyncManagerTest {
.single()
.entry.rangeStart,
)
assertTrue(manager.status.value is SyncStatus.Failed)
assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty())
assertEquals(
listOf(0L, before.version, before.resetVersion, cacheVersion),
listOf(
resetAttempts.get().toLong(),
queueRepository.contentVersion(),
queueRepository.snapshot().resetVersion,
queueRepository.libraryCacheVersion(),
),
)
allowPendingOriginClear()
manager.requestSync()
@@ -316,6 +325,7 @@ class SyncManagerTest {
.entry.rangeStart,
)
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
assertEquals(1, resetAttempts.get())
}
@Test
@@ -327,6 +337,7 @@ class SyncManagerTest {
val resetAttempted = CountDownLatch(1)
val resetAttempts = AtomicInteger()
queueRepository.setResetListener {
queueRepository.setPosition(42_000L)
resetAttempted.countDown()
if (resetAttempts.incrementAndGet() == 1) throw IllegalStateException("reconciliation failed")
}