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:
@@ -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,85 +601,132 @@ 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
|
resetListener?.invoke(this, QueueReset(point, updated))
|
||||||
val updated = writeResetLocked(before, entries, upNext, point)
|
}
|
||||||
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(
|
||||||
before: QueueSnapshot,
|
before: QueueSnapshot,
|
||||||
entries: List<QueueEntry>,
|
entries: List<QueueEntry>,
|
||||||
upNext: List<QueueEntry>,
|
upNext: List<QueueEntry>,
|
||||||
point: QueueResetPoint,
|
point: QueueResetPoint,
|
||||||
): QueueSnapshot {
|
pendingOriginSourceId: Long? = null,
|
||||||
invalidateLibraryCache()
|
onReset: suspend (QueueSnapshot) -> Unit,
|
||||||
val resolvedEntries = entries.map { ResolvedQueueEntry(it, it.resolvedLength()) }
|
) {
|
||||||
val resolvedUpNext = upNext.map { ResolvedQueueEntry(it, it.resolvedLength()) }
|
val resetSnapshot =
|
||||||
val shuffle = before.shuffleSeed?.let { ShuffleState(it, resolvedEntries.sumOf { it.length }) }
|
withCursorLock {
|
||||||
var updated =
|
db.useWriterConnection { connection ->
|
||||||
QueueSnapshot(
|
connection.immediateTransaction {
|
||||||
entries = resolvedEntries,
|
if (pendingOriginSourceId != null) dao.clearPendingSyncOrigins(pendingOriginSourceId)
|
||||||
upNext = resolvedUpNext,
|
invalidateLibraryCache()
|
||||||
shuffle = shuffle,
|
val resolvedEntries = entries.map { ResolvedQueueEntry(it, it.resolvedLength()) }
|
||||||
upNextAnchor = before.upNextAnchor,
|
val resolvedUpNext = upNext.map { ResolvedQueueEntry(it, it.resolvedLength()) }
|
||||||
version = queueVersion.get(),
|
val shuffle = before.shuffleSeed?.let { ShuffleState(it, resolvedEntries.sumOf { it.length }) }
|
||||||
resetVersion = resetVersion.get(),
|
var updated =
|
||||||
)
|
QueueSnapshot(
|
||||||
val anchorChanged = before.upNextSize > 0L && before.upNextAnchor != point.upNextAnchor
|
entries = resolvedEntries,
|
||||||
val latestAnchor = if (anchorChanged) resetItemAt(before, before.anchorPlay) else null
|
upNext = resolvedUpNext,
|
||||||
val anchor =
|
shuffle = shuffle,
|
||||||
if (anchorChanged) {
|
upNextAnchor = before.upNextAnchor,
|
||||||
remapAnchor(updated, latestAnchor) ?: fallbackAnchor(updated, before.anchorPlay)
|
version = queueVersion.get(),
|
||||||
} else {
|
resetVersion = resetVersion.get(),
|
||||||
remapAnchor(updated, point.anchor)
|
)
|
||||||
?: fallbackAnchor(updated, point.anchor?.position ?: before.upNextAnchor)
|
val anchorChanged = before.upNextSize > 0L && before.upNextAnchor != point.upNextAnchor
|
||||||
}
|
val latestAnchor = if (anchorChanged) resetItemAt(before, before.anchorPlay) else null
|
||||||
updated = updated.copy(upNextAnchor = anchor)
|
val anchor =
|
||||||
cursorMutex.withLock {
|
if (anchorChanged) {
|
||||||
val row = cursorRow()
|
remapAnchor(updated, latestAnchor) ?: fallbackAnchor(updated, before.anchorPlay)
|
||||||
val current = point.current ?: resetItemAt(before, row.queuePosition)
|
} else {
|
||||||
val remappedCurrent = current?.let { remapPosition(updated, it) }
|
remapAnchor(updated, point.anchor)
|
||||||
val cursor =
|
?: fallbackAnchor(updated, point.anchor?.position ?: before.upNextAnchor)
|
||||||
when {
|
}
|
||||||
updated.size == 0L -> 0L
|
updated = updated.copy(upNextAnchor = anchor)
|
||||||
remappedCurrent != null -> remappedCurrent.coerceIn(0, updated.size - 1)
|
val row = cursorRow()
|
||||||
current != null -> current.position.coerceIn(0, updated.size - 1)
|
val current = point.current ?: resetItemAt(before, row.queuePosition)
|
||||||
else -> row.queuePosition.coerceIn(0, updated.size - 1)
|
val remappedCurrent = current?.let { remapPosition(updated, it) }
|
||||||
}
|
val cursor =
|
||||||
val previousSongId = current?.songId
|
when {
|
||||||
val updatedSongId = itemAt(updated, cursor)?.song?.id
|
updated.size == 0L -> 0L
|
||||||
val positionMs = if (remappedCurrent != null && previousSongId == updatedSongId) row.positionMs else 0L
|
remappedCurrent != null -> remappedCurrent.coerceIn(0, updated.size - 1)
|
||||||
db.useWriterConnection { connection ->
|
current != null -> current.position.coerceIn(0, updated.size - 1)
|
||||||
connection.immediateTransaction {
|
else -> row.queuePosition.coerceIn(0, updated.size - 1)
|
||||||
dao.clear()
|
}
|
||||||
dao.insert(entries.mapIndexed { index, entry -> entry.copy(id = 0, position = index.toLong()) })
|
val previousSongId = current?.songId
|
||||||
dao.clearUpNext()
|
val updatedSongId = itemAt(updated, cursor)?.song?.id
|
||||||
dao.insertUpNext(
|
val positionMs = if (remappedCurrent != null && previousSongId == updatedSongId) row.positionMs else 0L
|
||||||
upNext.mapIndexed { index, entry -> UpNextEntry.from(entry).copy(id = 0, position = index.toLong()) },
|
dao.clear()
|
||||||
)
|
dao.insert(
|
||||||
dao.setCursor(
|
withOriginEntryIds(entries).mapIndexed { index, entry ->
|
||||||
row.copy(
|
entry.copy(id = 0, position = index.toLong())
|
||||||
queuePosition = cursor,
|
},
|
||||||
positionMs = positionMs,
|
)
|
||||||
upNextAnchor = anchor,
|
dao.clearUpNext()
|
||||||
shuffleEnabled = shuffle != null,
|
dao.insertUpNext(
|
||||||
shuffleSeed = shuffle?.seed ?: 0L,
|
withOriginEntryIds(upNext).mapIndexed { index, entry ->
|
||||||
shuffleSize = shuffle?.domain ?: 0L,
|
UpNextEntry.from(entry).copy(id = 0, position = index.toLong())
|
||||||
),
|
},
|
||||||
)
|
)
|
||||||
|
dao.setCursor(
|
||||||
|
row.copy(
|
||||||
|
queuePosition = cursor,
|
||||||
|
positionMs = positionMs,
|
||||||
|
upNextAnchor = anchor,
|
||||||
|
shuffleEnabled = shuffle != null,
|
||||||
|
shuffleSeed = shuffle?.seed ?: 0L,
|
||||||
|
shuffleSize = shuffle?.domain ?: 0L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (pendingOriginSourceId == null) {
|
||||||
|
updated
|
||||||
|
} else {
|
||||||
|
queueVersion.incrementAndGet()
|
||||||
|
resetVersion.incrementAndGet()
|
||||||
|
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()))
|
||||||
}
|
}
|
||||||
queueVersion.incrementAndGet()
|
|
||||||
resetVersion.incrementAndGet()
|
|
||||||
return updated.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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user