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
@@ -11,11 +11,15 @@ import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.QueueOrigin import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.model.SongItem import com.subtracks.data.model.SongItem
import com.subtracks.data.model.UpNextEntry import com.subtracks.data.model.UpNextEntry
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
const val QUEUE_CHUNK = 60 const val QUEUE_CHUNK = 60
@@ -183,6 +187,10 @@ data class QueueReset(
val snapshot: QueueSnapshot, val snapshot: QueueSnapshot,
) )
private class CursorLockContext : AbstractCoroutineContextElement(CursorLockContext) {
companion object Key : CoroutineContext.Key<CursorLockContext>
}
private interface QueueKindResolver { private interface QueueKindResolver {
fun supportsKeyset(key: OrderKey): Boolean = false fun supportsKeyset(key: OrderKey): Boolean = false
@@ -414,6 +422,13 @@ class QueueRepository(
internal val cursorMutex = Mutex() internal val cursorMutex = Mutex()
private suspend fun <T> withCursorLock(block: suspend () -> T): T {
if (currentCoroutineContext()[CursorLockContext] != null) return block()
return cursorMutex.withLock {
withContext(CursorLockContext()) { block() }
}
}
fun playlistEntry( fun playlistEntry(
sourceId: Long, sourceId: Long,
playlistId: String, playlistId: String,
@@ -508,7 +523,7 @@ class QueueRepository(
writeLocked(withOriginEntryIds(entries, assignFragmentIds = false)) writeLocked(withOriginEntryIds(entries, assignFragmentIds = false))
writeUpNextLocked(withOriginEntryIds(upNext, assignFragmentIds = false)) writeUpNextLocked(withOriginEntryIds(upNext, assignFragmentIds = false))
setShuffleLocked(shuffleSeed) setShuffleLocked(shuffleSeed)
cursorMutex.withLock { withCursorLock {
dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor)) dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor))
} }
} }
@@ -549,7 +564,7 @@ class QueueRepository(
private suspend fun setShuffleLocked(seed: Long?) { private suspend fun setShuffleLocked(seed: Long?) {
val size = if (seed != null) contextSize() else 0L val size = if (seed != null) contextSize() else 0L
cursorMutex.withLock { withCursorLock {
dao.setCursor( dao.setCursor(
cursorRow().copy( cursorRow().copy(
shuffleEnabled = seed != null, shuffleEnabled = seed != null,
@@ -563,6 +578,8 @@ class QueueRepository(
fun contentVersion(): Long = queueVersion.get() fun contentVersion(): Long = queueVersion.get()
internal fun libraryCacheVersion(): Long = libraryVersion.get()
fun invalidateLibraryCache() { fun invalidateLibraryCache() {
lengthCache.clear() lengthCache.clear()
keyCache.clear() keyCache.clear()
@@ -584,16 +601,43 @@ class QueueRepository(
point: QueueResetPoint, point: QueueResetPoint,
) { ) {
if (changed.isEmpty()) return if (changed.isEmpty()) return
withMutationLock { withMutationLock { resetChangedOriginsLocked(changed, point) }
}
suspend fun resetChangedOriginsAndClearPendingOrigins(
changed: Set<QueueOrigin>,
point: QueueResetPoint,
sourceId: Long,
) = withMutationLock {
resetChangedOriginsLocked(changed, point, sourceId)
}
private suspend fun QueueMutationScope.resetChangedOriginsLocked(
changed: Set<QueueOrigin>,
point: QueueResetPoint,
pendingOriginSourceId: Long? = null,
) {
if (changed.isNotEmpty()) {
val before = snapshot() val before = snapshot()
val entries = canonicalEntries(before.entries.map { it.entry }, changed) val entries = canonicalEntries(before.entries.map { it.entry }, changed)
val upNext = canonicalEntries(before.upNext.map { it.entry }, changed) val upNext = canonicalEntries(before.upNext.map { it.entry }, changed)
val matchingOrigin = if (entries != before.entries.map { it.entry } || upNext != before.upNext.map { it.entry }) {
before.entries.any { it.entry.origin in changed } || before.upNext.any { it.entry.origin in changed } writeResetLocked(before, entries, upNext, point, pendingOriginSourceId) { updated ->
if (!matchingOrigin) return@withMutationLock
val updated = writeResetLocked(before, entries, upNext, point)
resetListener?.invoke(this, QueueReset(point, updated)) resetListener?.invoke(this, QueueReset(point, updated))
} }
return
}
}
if (pendingOriginSourceId != null) clearPendingSyncOriginsLocked(pendingOriginSourceId)
}
private suspend fun clearPendingSyncOriginsLocked(sourceId: Long) =
withCursorLock {
db.useWriterConnection { connection ->
connection.immediateTransaction {
dao.clearPendingSyncOrigins(sourceId)
}
}
} }
private suspend fun writeResetLocked( private suspend fun writeResetLocked(
@@ -601,7 +645,14 @@ class QueueRepository(
entries: List<QueueEntry>, entries: List<QueueEntry>,
upNext: List<QueueEntry>, upNext: List<QueueEntry>,
point: QueueResetPoint, point: QueueResetPoint,
): QueueSnapshot { pendingOriginSourceId: Long? = null,
onReset: suspend (QueueSnapshot) -> Unit,
) {
val resetSnapshot =
withCursorLock {
db.useWriterConnection { connection ->
connection.immediateTransaction {
if (pendingOriginSourceId != null) dao.clearPendingSyncOrigins(pendingOriginSourceId)
invalidateLibraryCache() invalidateLibraryCache()
val resolvedEntries = entries.map { ResolvedQueueEntry(it, it.resolvedLength()) } val resolvedEntries = entries.map { ResolvedQueueEntry(it, it.resolvedLength()) }
val resolvedUpNext = upNext.map { ResolvedQueueEntry(it, it.resolvedLength()) } val resolvedUpNext = upNext.map { ResolvedQueueEntry(it, it.resolvedLength()) }
@@ -625,7 +676,6 @@ class QueueRepository(
?: fallbackAnchor(updated, point.anchor?.position ?: before.upNextAnchor) ?: fallbackAnchor(updated, point.anchor?.position ?: before.upNextAnchor)
} }
updated = updated.copy(upNextAnchor = anchor) updated = updated.copy(upNextAnchor = anchor)
cursorMutex.withLock {
val row = cursorRow() val row = cursorRow()
val current = point.current ?: resetItemAt(before, row.queuePosition) val current = point.current ?: resetItemAt(before, row.queuePosition)
val remappedCurrent = current?.let { remapPosition(updated, it) } val remappedCurrent = current?.let { remapPosition(updated, it) }
@@ -639,13 +689,17 @@ class QueueRepository(
val previousSongId = current?.songId val previousSongId = current?.songId
val updatedSongId = itemAt(updated, cursor)?.song?.id val updatedSongId = itemAt(updated, cursor)?.song?.id
val positionMs = if (remappedCurrent != null && previousSongId == updatedSongId) row.positionMs else 0L val positionMs = if (remappedCurrent != null && previousSongId == updatedSongId) row.positionMs else 0L
db.useWriterConnection { connection ->
connection.immediateTransaction {
dao.clear() dao.clear()
dao.insert(entries.mapIndexed { index, entry -> entry.copy(id = 0, position = index.toLong()) }) dao.insert(
withOriginEntryIds(entries).mapIndexed { index, entry ->
entry.copy(id = 0, position = index.toLong())
},
)
dao.clearUpNext() dao.clearUpNext()
dao.insertUpNext( dao.insertUpNext(
upNext.mapIndexed { index, entry -> UpNextEntry.from(entry).copy(id = 0, position = index.toLong()) }, withOriginEntryIds(upNext).mapIndexed { index, entry ->
UpNextEntry.from(entry).copy(id = 0, position = index.toLong())
},
) )
dao.setCursor( dao.setCursor(
row.copy( row.copy(
@@ -657,12 +711,22 @@ class QueueRepository(
shuffleSize = shuffle?.domain ?: 0L, shuffleSize = shuffle?.domain ?: 0L,
), ),
) )
} if (pendingOriginSourceId == null) {
} updated
} } else {
queueVersion.incrementAndGet() queueVersion.incrementAndGet()
resetVersion.incrementAndGet() resetVersion.incrementAndGet()
return updated.copy(version = queueVersion.get(), resetVersion = resetVersion.get()) onReset(updated.copy(version = queueVersion.get(), resetVersion = resetVersion.get()))
null
}
}
}
}
if (resetSnapshot != null) {
queueVersion.incrementAndGet()
resetVersion.incrementAndGet()
onReset(resetSnapshot.copy(version = queueVersion.get(), resetVersion = resetVersion.get()))
}
} }
private suspend fun remapAnchor( private suspend fun remapAnchor(
@@ -874,7 +938,7 @@ class QueueRepository(
return null return null
} }
suspend fun setRepeat(mode: Int) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(repeatMode = mode)) } suspend fun setRepeat(mode: Int) = withCursorLock { dao.setCursor(cursorRow().copy(repeatMode = mode)) }
suspend fun itemAt( suspend fun itemAt(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
@@ -963,7 +1027,7 @@ class QueueRepository(
current.upNextAnchor current.upNextAnchor
} }
writeUpNextLocked(list) writeUpNextLocked(list)
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) } withCursorLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) }
if (current.upNextSize == 0L) return currentPosition if (current.upNextSize == 0L) return currentPosition
val before = current.upNext.take(insertIndex).sumOf { it.length } val before = current.upNext.take(insertIndex).sumOf { it.length }
return if (currentPosition >= current.anchorPlay + 1 + before) currentPosition + 1 else currentPosition return if (currentPosition >= current.anchorPlay + 1 + before) currentPosition + 1 else currentPosition
@@ -978,7 +1042,7 @@ class QueueRepository(
val play = snapshot.contextPlay(target) val play = snapshot.contextPlay(target)
val flat = snapshot.flatContext(play) ?: return Reanchored(target, false) val flat = snapshot.flatContext(play) ?: return Reanchored(target, false)
if (flat == snapshot.upNextAnchor) return Reanchored(target, false) if (flat == snapshot.upNextAnchor) return Reanchored(target, false)
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = flat)) } withCursorLock { dao.setCursor(cursorRow().copy(upNextAnchor = flat)) }
queueVersion.incrementAndGet() queueVersion.incrementAndGet()
return Reanchored(play, true) return Reanchored(play, true)
} }
@@ -1007,7 +1071,7 @@ class QueueRepository(
} }
private suspend fun adjustAnchorOnRemove(removedFlat: Long) = private suspend fun adjustAnchorOnRemove(removedFlat: Long) =
cursorMutex.withLock { withCursorLock {
val row = cursorRow() val row = cursorRow()
val anchor = row.upNextAnchor val anchor = row.upNextAnchor
// Removing the track the block sits behind leaves the block next in line, so move the // Removing the track the block sits behind leaves the block next in line, so move the
@@ -1020,7 +1084,7 @@ class QueueRepository(
private suspend fun adjustAnchorOnMove( private suspend fun adjustAnchorOnMove(
flatFrom: Long, flatFrom: Long,
flatTo: Long, flatTo: Long,
) = cursorMutex.withLock { ) = withCursorLock {
val row = cursorRow() val row = cursorRow()
val anchor = row.upNextAnchor val anchor = row.upNextAnchor
val moved = val moved =
@@ -1073,12 +1137,12 @@ class QueueRepository(
suspend fun cursorPositionMs(): Long = dao.cursor()?.positionMs ?: 0 suspend fun cursorPositionMs(): Long = dao.cursor()?.positionMs ?: 0
suspend fun setPosition(positionMs: Long) = suspend fun setPosition(positionMs: Long) =
cursorMutex.withLock { withCursorLock {
dao.setCursor(cursorRow().copy(positionMs = positionMs.coerceAtLeast(0))) dao.setCursor(cursorRow().copy(positionMs = positionMs.coerceAtLeast(0)))
} }
suspend fun setCursor(position: Long) = suspend fun setCursor(position: Long) =
cursorMutex.withLock { withCursorLock {
val row = cursorRow() val row = cursorRow()
dao.setCursor(row.copy(queuePosition = position, positionMs = if (row.queuePosition == position) row.positionMs else 0)) dao.setCursor(row.copy(queuePosition = position, positionMs = if (row.queuePosition == position) row.positionMs else 0))
} }
@@ -1,7 +1,5 @@
package com.subtracks.data.sync package com.subtracks.data.sync
import androidx.room3.deferredTransaction
import androidx.room3.useWriterConnection
import com.subtracks.R import com.subtracks.R
import com.subtracks.UiMessage import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
@@ -100,12 +98,7 @@ class SyncManager(
"${completed.artists} artists, ${completed.albums} albums, ${completed.songs} songs, " + "${completed.artists} artists, ${completed.albums} albums, ${completed.songs} songs, " +
"${completed.playlists} playlists, ${completed.playlistSongs} playlist tracks, ${completed.pruned} pruned", "${completed.playlists} playlists, ${completed.playlistSongs} playlist tracks, ${completed.pruned} pruned",
) )
db.useWriterConnection { connection -> queueRepository.resetChangedOriginsAndClearPendingOrigins(changedOrigins, resetPoint, source.id)
connection.deferredTransaction {
queueRepository.resetChangedOrigins(changedOrigins, resetPoint)
db.queueDao().clearPendingSyncOrigins(source.id)
}
}
SyncStatus.Success SyncStatus.Success
} catch (cancellation: CancellationException) { } catch (cancellation: CancellationException) {
throw cancellation throw cancellation
@@ -15,9 +15,13 @@ import com.subtracks.data.model.QueueOrigin
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -27,6 +31,8 @@ import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class QueueRepositoryTest { class QueueRepositoryTest {
@@ -922,6 +928,53 @@ class QueueRepositoryTest {
assertEquals(2, repository.modes().repeat) 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 @Test
fun upNextResolvesAfterTheCurrentTrack() = fun upNextResolvesAfterTheCurrentTrack() =
runTest { runTest {
@@ -285,13 +285,14 @@ class SyncManagerTest {
server.dispatcher = healthyDispatcher() server.dispatcher = healthyDispatcher()
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false) sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0))) queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)))
val resetAttempted = CountDownLatch(1) val before = queueRepository.snapshot()
queueRepository.setResetListener { resetAttempted.countDown() } val cacheVersion = queueRepository.libraryCacheVersion()
val resetAttempts = AtomicInteger()
queueRepository.setResetListener { resetAttempts.incrementAndGet() }
failPendingOriginClear() failPendingOriginClear()
manager.requestSync() manager.requestSync()
assertTrue(resetAttempted.await(10, TimeUnit.SECONDS)) withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
awaitUntil("waiting for the pending-origin clear failure") { manager.status.value !is SyncStatus.Running }
assertEquals( assertEquals(
0L, 0L,
@@ -301,8 +302,16 @@ class SyncManagerTest {
.single() .single()
.entry.rangeStart, .entry.rangeStart,
) )
assertTrue(manager.status.value is SyncStatus.Failed)
assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty()) 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() allowPendingOriginClear()
manager.requestSync() manager.requestSync()
@@ -316,6 +325,7 @@ class SyncManagerTest {
.entry.rangeStart, .entry.rangeStart,
) )
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty()) assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
assertEquals(1, resetAttempts.get())
} }
@Test @Test
@@ -327,6 +337,7 @@ class SyncManagerTest {
val resetAttempted = CountDownLatch(1) val resetAttempted = CountDownLatch(1)
val resetAttempts = AtomicInteger() val resetAttempts = AtomicInteger()
queueRepository.setResetListener { queueRepository.setResetListener {
queueRepository.setPosition(42_000L)
resetAttempted.countDown() resetAttempted.countDown()
if (resetAttempts.incrementAndGet() == 1) throw IllegalStateException("reconciliation failed") if (resetAttempts.incrementAndGet() == 1) throw IllegalStateException("reconciliation failed")
} }