mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
Harden the pending-action queue after review
- Order direct stars against the queue so a successful online unstar is no longer overwritten by an older queued star, and serialize all queue mutations under one lock. - Treat a SubsonicException with a negative code as a transport failure: queue it in setStar and keep it on flush instead of dropping it. - Make coalescing atomic, keep the offline collector alive across errors, and drain from the persisted offline flag so a queued action flushes on a cold start that is already online. - Give ServerActionSink.flush a default body and drop the no-op overrides.
This commit is contained in:
@@ -3,6 +3,7 @@ package com.subtracks.data.db
|
|||||||
import androidx.room3.Dao
|
import androidx.room3.Dao
|
||||||
import androidx.room3.Insert
|
import androidx.room3.Insert
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
|
import androidx.room3.Transaction
|
||||||
import com.subtracks.data.model.PendingAction
|
import com.subtracks.data.model.PendingAction
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -25,4 +26,10 @@ interface PendingActionDao {
|
|||||||
starType: String,
|
starType: String,
|
||||||
targetId: String,
|
targetId: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun replace(action: PendingAction) {
|
||||||
|
if (action.starType != null) clearStar(action.sourceId, action.starType, action.targetId)
|
||||||
|
insert(action)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,4 @@ class NetworkServerActionSink(
|
|||||||
val source = sources.activeMusicSource() ?: throw IllegalStateException("No active server")
|
val source = sources.activeMusicSource() ?: throw IllegalStateException("No active server")
|
||||||
source.setStar(type, id, starred)
|
source.setStar(type, id, starred)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun flush() = Unit
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,58 +8,54 @@ import com.subtracks.data.source.StarType
|
|||||||
import com.subtracks.data.source.subsonic.SubsonicException
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
class PendingServerActionSink(
|
class PendingServerActionSink(
|
||||||
private val delegate: ServerActionSink,
|
private val delegate: ServerActionSink,
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val activeSourceId: suspend () -> Long?,
|
private val activeSourceId: suspend () -> Long?,
|
||||||
private val offline: StateFlow<Boolean>,
|
private val offline: StateFlow<Boolean>,
|
||||||
|
offlinePreferences: Flow<Boolean>,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
) : ServerActionSink {
|
) : ServerActionSink {
|
||||||
private val flushLock = Mutex()
|
private val lock = Mutex()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// The first emission is the StateFlow's default, not necessarily the persisted value, and
|
offlinePreferences.collect { isOffline ->
|
||||||
// replaying onto the network must not race a cold start with offline mode on.
|
// The persisted value is authoritative, unlike the process flag's default at cold
|
||||||
offline
|
// start, so wait for that flag to agree before replaying.
|
||||||
.drop(1)
|
if (!isOffline) {
|
||||||
.collect { isOffline -> if (!isOffline) flush() }
|
offline.first { !it }
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun nowPlaying(songId: String) {
|
override suspend fun nowPlaying(songId: String) {
|
||||||
if (offline.value) return
|
if (offline.value) return
|
||||||
try {
|
if (attempt { delegate.nowPlaying(songId) } == null) flush()
|
||||||
delegate.nowPlaying(songId)
|
|
||||||
flush()
|
|
||||||
} catch (cancellation: CancellationException) {
|
|
||||||
throw cancellation
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun scrobble(
|
override suspend fun scrobble(
|
||||||
songId: String,
|
songId: String,
|
||||||
time: Long,
|
time: Long,
|
||||||
) {
|
) {
|
||||||
if (offline.value) {
|
lock.withLock {
|
||||||
queue(PendingActionKind.Scrobble, songId, null, time)
|
if (offline.value) {
|
||||||
return
|
enqueueLocked(PendingActionKind.Scrobble, songId, null, time)
|
||||||
}
|
return
|
||||||
try {
|
}
|
||||||
delegate.scrobble(songId, time)
|
when (val failure = attempt { delegate.scrobble(songId, time) }) {
|
||||||
flush()
|
null -> drainSafelyLocked()
|
||||||
} catch (cancellation: CancellationException) {
|
else -> if (isRetryable(failure)) enqueueLocked(PendingActionKind.Scrobble, songId, null, time)
|
||||||
throw cancellation
|
}
|
||||||
} catch (_: Exception) {
|
|
||||||
queue(PendingActionKind.Scrobble, songId, null, time)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,41 +65,58 @@ class PendingServerActionSink(
|
|||||||
starred: Boolean,
|
starred: Boolean,
|
||||||
) {
|
) {
|
||||||
val kind = if (starred) PendingActionKind.Star else PendingActionKind.Unstar
|
val kind = if (starred) PendingActionKind.Star else PendingActionKind.Unstar
|
||||||
if (offline.value) {
|
lock.withLock {
|
||||||
if (!queue(kind, id, type.name, 0)) throw IllegalStateException("No active server")
|
if (offline.value) {
|
||||||
return
|
if (!enqueueLocked(kind, id, type.name, 0)) throw IllegalStateException("No active server")
|
||||||
}
|
return
|
||||||
try {
|
}
|
||||||
delegate.setStar(type, id, starred)
|
when (val failure = attempt { delegate.setStar(type, id, starred) }) {
|
||||||
flush()
|
null -> {
|
||||||
} catch (cancellation: CancellationException) {
|
// The absolute state just sent wins; a queued star for the same item is older.
|
||||||
throw cancellation
|
clearStarLocked(type.name, id)
|
||||||
} catch (rejected: SubsonicException) {
|
drainSafelyLocked()
|
||||||
// The server answered and refused; retrying the same request will not help.
|
}
|
||||||
throw rejected
|
|
||||||
} catch (failure: Exception) {
|
else -> {
|
||||||
if (!queue(kind, id, type.name, 0)) throw failure
|
if (!isRetryable(failure) || !enqueueLocked(kind, id, type.name, 0)) throw failure
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun flush() {
|
override suspend fun flush() {
|
||||||
flushLock.withLock {
|
try {
|
||||||
if (offline.value) return
|
lock.withLock { drainLocked() }
|
||||||
val sourceId = activeSourceId() ?: return
|
} catch (cancellation: CancellationException) {
|
||||||
val dao = db.pendingActionDao()
|
throw cancellation
|
||||||
for (action in dao.pending(sourceId)) {
|
} catch (_: Exception) {
|
||||||
try {
|
}
|
||||||
deliver(action)
|
}
|
||||||
} catch (cancellation: CancellationException) {
|
|
||||||
throw cancellation
|
private suspend fun drainLocked() {
|
||||||
} catch (_: IOException) {
|
if (offline.value) return
|
||||||
// Still unreachable: keep this and the remaining actions for the next attempt.
|
val sourceId = activeSourceId() ?: return
|
||||||
return
|
val dao = db.pendingActionDao()
|
||||||
} catch (_: Exception) {
|
for (action in dao.pending(sourceId)) {
|
||||||
// The server answered and refused; drop the action rather than block the queue.
|
try {
|
||||||
}
|
deliver(action)
|
||||||
dao.delete(action.id)
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
// Still unreachable: keep this and the remaining actions for the next attempt.
|
||||||
|
if (isRetryable(failure)) return
|
||||||
|
// The server answered and refused; drop the action rather than block the queue.
|
||||||
}
|
}
|
||||||
|
dao.delete(action.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun drainSafelyLocked() {
|
||||||
|
try {
|
||||||
|
drainLocked()
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,16 +133,38 @@ class PendingServerActionSink(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun queue(
|
private suspend fun enqueueLocked(
|
||||||
kind: PendingActionKind,
|
kind: PendingActionKind,
|
||||||
targetId: String,
|
targetId: String,
|
||||||
starType: String?,
|
starType: String?,
|
||||||
time: Long,
|
time: Long,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val sourceId = activeSourceId() ?: return false
|
val sourceId = activeSourceId() ?: return false
|
||||||
val dao = db.pendingActionDao()
|
db.pendingActionDao().replace(
|
||||||
if (starType != null) dao.clearStar(sourceId, starType, targetId)
|
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
||||||
dao.insert(PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time))
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun clearStarLocked(
|
||||||
|
starType: String,
|
||||||
|
targetId: String,
|
||||||
|
) {
|
||||||
|
val sourceId = activeSourceId() ?: return
|
||||||
|
db.pendingActionDao().clearStar(sourceId, starType, targetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun attempt(block: suspend () -> Unit): Exception? =
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
null
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// A SubsonicException with a negative code is a transport or protocol failure (HTTP non-2xx,
|
||||||
|
// malformed body), not a refusal, so it is still worth retrying.
|
||||||
|
private fun isRetryable(failure: Exception): Boolean = failure !is SubsonicException || failure.code < 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,5 @@ interface ServerActionSink {
|
|||||||
starred: Boolean,
|
starred: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun flush()
|
suspend fun flush() = Unit
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ fun appModule(
|
|||||||
db = get(),
|
db = get(),
|
||||||
activeSourceId = get<SourceRepository>()::activeSourceIdOnce,
|
activeSourceId = get<SourceRepository>()::activeSourceIdOnce,
|
||||||
offline = get<SourceRepository>().offline,
|
offline = get<SourceRepository>().offline,
|
||||||
|
offlinePreferences = get<UserPreferences>().offlineMode(),
|
||||||
scope = get(),
|
scope = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.room3.Room
|
|||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.model.PendingActionKind
|
import com.subtracks.data.model.PendingActionKind
|
||||||
@@ -64,8 +65,6 @@ class PendingServerActionSinkTest {
|
|||||||
starFailure?.let { throw it }
|
starFailure?.let { throw it }
|
||||||
calls += Call.Star(type, id, starred)
|
calls += Call.Star(type, id, starred)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun flush() = Unit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var db: SubtracksDatabase
|
private lateinit var db: SubtracksDatabase
|
||||||
@@ -73,6 +72,7 @@ class PendingServerActionSinkTest {
|
|||||||
private lateinit var delegate: RecordingSink
|
private lateinit var delegate: RecordingSink
|
||||||
private lateinit var sink: PendingServerActionSink
|
private lateinit var sink: PendingServerActionSink
|
||||||
private val offline = MutableStateFlow(true)
|
private val offline = MutableStateFlow(true)
|
||||||
|
private val offlinePreferences = MutableStateFlow(true)
|
||||||
private var sourceId = 0L
|
private var sourceId = 0L
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
@@ -86,7 +86,15 @@ class PendingServerActionSinkTest {
|
|||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
|
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
|
||||||
delegate = RecordingSink()
|
delegate = RecordingSink()
|
||||||
sink = PendingServerActionSink(delegate, db, activeSourceId = { sourceId }, offline = offline, scope = scope)
|
sink =
|
||||||
|
PendingServerActionSink(
|
||||||
|
delegate = delegate,
|
||||||
|
db = db,
|
||||||
|
activeSourceId = { sourceId },
|
||||||
|
offline = offline,
|
||||||
|
offlinePreferences = offlinePreferences,
|
||||||
|
scope = scope,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -110,6 +118,19 @@ class PendingServerActionSinkTest {
|
|||||||
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
|
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun turningOfflineOffReplaysThroughTheCollector() =
|
||||||
|
runBlocking {
|
||||||
|
sink.setStar(StarType.Song, "s1", true)
|
||||||
|
|
||||||
|
offline.value = false
|
||||||
|
offlinePreferences.value = false
|
||||||
|
|
||||||
|
awaitUntil("the queued star to replay") { delegate.calls.isNotEmpty() }
|
||||||
|
assertEquals(listOf(Call.Star(StarType.Song, "s1", true)), delegate.calls)
|
||||||
|
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun anOfflineUnstarCoalescesAwayAnEarlierStar() =
|
fun anOfflineUnstarCoalescesAwayAnEarlierStar() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -126,6 +147,21 @@ class PendingServerActionSinkTest {
|
|||||||
assertEquals(listOf(Call.Star(StarType.Song, "s1", false)), delegate.calls)
|
assertEquals(listOf(Call.Star(StarType.Song, "s1", false)), delegate.calls)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aQueuedStarCannotOverwriteANewerOnlineUnstar() =
|
||||||
|
runBlocking {
|
||||||
|
offline.value = false
|
||||||
|
delegate.starFailure = IOException("down")
|
||||||
|
sink.setStar(StarType.Song, "s1", true)
|
||||||
|
assertEquals(1, db.pendingActionDao().pending(sourceId).size)
|
||||||
|
|
||||||
|
delegate.starFailure = null
|
||||||
|
sink.setStar(StarType.Song, "s1", false)
|
||||||
|
|
||||||
|
assertEquals(listOf(Call.Star(StarType.Song, "s1", false)), delegate.calls)
|
||||||
|
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun offlineScrobblesReplayInOrderWithTheirTimes() =
|
fun offlineScrobblesReplayInOrderWithTheirTimes() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -169,6 +205,17 @@ class PendingServerActionSinkTest {
|
|||||||
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
|
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTransportFailureIsQueuedRatherThanRejected() =
|
||||||
|
runBlocking {
|
||||||
|
offline.value = false
|
||||||
|
delegate.starFailure = SubsonicException(-1, "HTTP 503")
|
||||||
|
|
||||||
|
sink.setStar(StarType.Song, "s1", true)
|
||||||
|
|
||||||
|
assertEquals(1, db.pendingActionDao().pending(sourceId).size)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun flushKeepsQueuedActionsWhileTheServerIsStillUnreachable() =
|
fun flushKeepsQueuedActionsWhileTheServerIsStillUnreachable() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -186,4 +233,22 @@ class PendingServerActionSinkTest {
|
|||||||
assertEquals(listOf(Call.Star(StarType.Song, "s1", true)), delegate.calls)
|
assertEquals(listOf(Call.Star(StarType.Song, "s1", true)), delegate.calls)
|
||||||
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
|
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun flushKeepsQueuedActionsOnATransportFailure() =
|
||||||
|
runBlocking {
|
||||||
|
sink.scrobble("s1", 1_000)
|
||||||
|
|
||||||
|
offline.value = false
|
||||||
|
delegate.scrobbleFailure = SubsonicException(-1, "HTTP 503")
|
||||||
|
sink.flush()
|
||||||
|
|
||||||
|
assertEquals(1, db.pendingActionDao().pending(sourceId).size)
|
||||||
|
|
||||||
|
delegate.scrobbleFailure = null
|
||||||
|
sink.flush()
|
||||||
|
|
||||||
|
assertEquals(listOf(Call.Scrobble("s1", 1_000)), delegate.calls)
|
||||||
|
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ class ScrobblerTest {
|
|||||||
id: String,
|
id: String,
|
||||||
starred: Boolean,
|
starred: Boolean,
|
||||||
) = Unit
|
) = Unit
|
||||||
|
|
||||||
override suspend fun flush() = Unit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TestScope.testScope() = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + SupervisorJob())
|
private fun TestScope.testScope() = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + SupervisorJob())
|
||||||
|
|||||||
Reference in New Issue
Block a user