mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +01:00
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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user