mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +01:00
sync: consume pending origins with queue resets
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
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
|
||||||
import com.subtracks.data.model.QueueOrigin
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.QueueResetPoint
|
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.subsonic.SubsonicException
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
@@ -83,26 +84,28 @@ class SyncManager(
|
|||||||
runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) }
|
runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) }
|
||||||
.onFailure { Log.w("sync", "could not record the sync attempt", it) }
|
.onFailure { Log.w("sync", "could not record the sync attempt", it) }
|
||||||
val startedAt = System.nanoTime()
|
val startedAt = System.nanoTime()
|
||||||
var summary: SyncSummary? = null
|
|
||||||
var resetPoint: QueueResetPoint? = null
|
|
||||||
var syncSourceId: Long? = null
|
|
||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
||||||
Log.i("sync", "started (source ${source.id})")
|
Log.i("sync", "started (source ${source.id})")
|
||||||
serverActions.flush()
|
serverActions.flush()
|
||||||
resetPoint = queueRepository.captureResetPoint()
|
val resetPoint = queueRepository.captureResetPoint()
|
||||||
syncSourceId = source.id
|
|
||||||
val service = SyncService(db, source)
|
val service = SyncService(db, source)
|
||||||
val completed = service.sync()
|
val completed = service.sync()
|
||||||
val pending = db.queueDao().pendingSyncOrigins(source.id).map { QueueOrigin(it.kind, it.sourceId, it.refId) }
|
val pending = db.queueDao().pendingSyncOrigins(source.id).map { QueueOrigin(it.kind, it.sourceId, it.refId) }
|
||||||
summary = completed.copy(changedOrigins = pending.toSet() + completed.changedOrigins)
|
val changedOrigins = pending.toSet() + completed.changedOrigins
|
||||||
Log.i(
|
Log.i(
|
||||||
"sync",
|
"sync",
|
||||||
"finished in ${elapsedMs(startedAt)}ms: " +
|
"finished in ${elapsedMs(startedAt)}ms: " +
|
||||||
"${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 ->
|
||||||
|
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
|
||||||
@@ -122,14 +125,6 @@ class SyncManager(
|
|||||||
if (!silent) showMessage(message)
|
if (!silent) showMessage(message)
|
||||||
SyncStatus.Failed(message)
|
SyncStatus.Failed(message)
|
||||||
}
|
}
|
||||||
if (result is SyncStatus.Success) {
|
|
||||||
val completed = summary
|
|
||||||
val point = resetPoint
|
|
||||||
if (completed != null && point != null) {
|
|
||||||
queueRepository.resetChangedOrigins(completed.changedOrigins, point)
|
|
||||||
db.queueDao().clearPendingSyncOrigins(sourceId = checkNotNull(syncSourceId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_status.value = result
|
_status.value = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.subtracks.data.sync
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
|
import androidx.room3.useWriterConnection
|
||||||
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.TEST_TIMEOUT_MS
|
import com.subtracks.TEST_TIMEOUT_MS
|
||||||
@@ -278,6 +279,87 @@ class SyncManagerTest {
|
|||||||
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPendingClearFailureRollsBackTheQueueResetAndCanBeRetried() =
|
||||||
|
runBlocking {
|
||||||
|
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() }
|
||||||
|
failPendingOriginClear()
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
assertTrue(resetAttempted.await(10, TimeUnit.SECONDS))
|
||||||
|
awaitUntil("waiting for the pending-origin clear failure") { manager.status.value !is SyncStatus.Running }
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
0L,
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertTrue(manager.status.value is SyncStatus.Failed)
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty())
|
||||||
|
|
||||||
|
allowPendingOriginClear()
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.drop(1).first { it is SyncStatus.Success } }
|
||||||
|
|
||||||
|
assertNull(
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aResetListenerFailureIsReportedAndCanBeRetried() =
|
||||||
|
runBlocking {
|
||||||
|
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)
|
||||||
|
val resetAttempts = AtomicInteger()
|
||||||
|
queueRepository.setResetListener {
|
||||||
|
resetAttempted.countDown()
|
||||||
|
if (resetAttempts.incrementAndGet() == 1) throw IllegalStateException("reconciliation failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
assertTrue(resetAttempted.await(10, TimeUnit.SECONDS))
|
||||||
|
awaitUntil("waiting for the queue reset listener failure") { manager.status.value !is SyncStatus.Running }
|
||||||
|
|
||||||
|
assertTrue(manager.status.value is SyncStatus.Failed)
|
||||||
|
assertEquals(
|
||||||
|
0L,
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty())
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.drop(1).first { it is SyncStatus.Success } }
|
||||||
|
|
||||||
|
assertEquals(2, resetAttempts.get())
|
||||||
|
assertNull(
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aFailedSyncPreservesResolvedQueueShuffle() =
|
fun aFailedSyncPreservesResolvedQueueShuffle() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -419,6 +501,21 @@ class SyncManagerTest {
|
|||||||
assertEquals(2, artistRequests.get())
|
assertEquals(2, artistRequests.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun failPendingOriginClear() {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.usePrepared(
|
||||||
|
"CREATE TRIGGER fail_pending_origin_clear BEFORE DELETE ON pending_sync_origins " +
|
||||||
|
"BEGIN SELECT RAISE(FAIL, 'pending-origin clear failed'); END",
|
||||||
|
) { it.step() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun allowPendingOriginClear() {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.usePrepared("DROP TRIGGER fail_pending_origin_clear") { it.step() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun healthyDispatcher() =
|
private fun healthyDispatcher() =
|
||||||
object : Dispatcher() {
|
object : Dispatcher() {
|
||||||
override fun dispatch(request: RecordedRequest): MockResponse =
|
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||||
|
|||||||
Reference in New Issue
Block a user