mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 20:49:47 +02:00
performance fix for queue
This commit is contained in:
+151
-27
@@ -11,6 +11,10 @@ import androidx.room.Transaction
|
|||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
import androidx.room.migration.Migration
|
import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
import expo.modules.astralibraryscanner.queue.QUEUE_PARK_OFFSET
|
||||||
|
import expo.modules.astralibraryscanner.queue.QueueReorder
|
||||||
|
import expo.modules.astralibraryscanner.queue.QueueRowKey
|
||||||
|
import expo.modules.astralibraryscanner.queue.QueueWritePlan
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
data class RemotePlaylistSyncPlan(
|
data class RemotePlaylistSyncPlan(
|
||||||
@@ -406,6 +410,31 @@ interface UserDao {
|
|||||||
)
|
)
|
||||||
suspend fun deleteQueueEntriesById(sessionId: String, entryIds: List<Long>)
|
suspend fun deleteQueueEntriesById(sessionId: String, entryIds: List<Long>)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE playback_queue_entries
|
||||||
|
SET position = position + :delta
|
||||||
|
WHERE session_id = :sessionId
|
||||||
|
AND position >= :fromPosition
|
||||||
|
AND position <= :toPosition
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun shiftQueueRange(
|
||||||
|
sessionId: String,
|
||||||
|
fromPosition: Long,
|
||||||
|
toPosition: Long,
|
||||||
|
delta: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE playback_queue_entries
|
||||||
|
SET position = :position
|
||||||
|
WHERE session_id = :sessionId AND entry_id = :entryId
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun setQueueEntryPosition(sessionId: String, entryId: Long, position: Long)
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM playback_queue_entries WHERE session_id = :sessionId")
|
@Query("SELECT COUNT(*) FROM playback_queue_entries WHERE session_id = :sessionId")
|
||||||
suspend fun countQueueEntries(sessionId: String): Long
|
suspend fun countQueueEntries(sessionId: String): Long
|
||||||
|
|
||||||
@@ -439,6 +468,31 @@ interface UserDao {
|
|||||||
)
|
)
|
||||||
suspend fun deleteOriginalQueueEntriesById(sessionId: String, entryIds: List<Long>)
|
suspend fun deleteOriginalQueueEntriesById(sessionId: String, entryIds: List<Long>)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE playback_original_queue_entries
|
||||||
|
SET position = position + :delta
|
||||||
|
WHERE session_id = :sessionId
|
||||||
|
AND position >= :fromPosition
|
||||||
|
AND position <= :toPosition
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun shiftOriginalQueueRange(
|
||||||
|
sessionId: String,
|
||||||
|
fromPosition: Long,
|
||||||
|
toPosition: Long,
|
||||||
|
delta: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE playback_original_queue_entries
|
||||||
|
SET position = :position
|
||||||
|
WHERE session_id = :sessionId AND entry_id = :entryId
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun setOriginalQueueEntryPosition(sessionId: String, entryId: Long, position: Long)
|
||||||
|
|
||||||
@Query("SELECT * FROM snapshot_metadata WHERE id = 1")
|
@Query("SELECT * FROM snapshot_metadata WHERE id = 1")
|
||||||
suspend fun getSnapshotMetadata(): SnapshotMetadataEntity?
|
suspend fun getSnapshotMetadata(): SnapshotMetadataEntity?
|
||||||
|
|
||||||
@@ -471,18 +525,34 @@ interface UserDao {
|
|||||||
) {
|
) {
|
||||||
val oldEntries = getAllQueueEntries(session.id)
|
val oldEntries = getAllQueueEntries(session.id)
|
||||||
putPlaybackSession(session)
|
putPlaybackSession(session)
|
||||||
val changedAt = firstChangedQueuePosition(oldEntries, entries)
|
// Removals and single moves — the edits a user performs on an open queue —
|
||||||
if (changedAt != null) {
|
// become a handful of range UPDATEs. The fallback below rewrites every row
|
||||||
parkQueuePositions(session.id, changedAt.toLong(), 1_000_000_000_000L)
|
// from the change point, which for an edit near the active track means
|
||||||
val retainedIds = entries.mapTo(hashSetOf(), PlaybackQueueEntryEntity::entryId)
|
// marshalling almost the whole queue through Room on every tap.
|
||||||
val removedIds = oldEntries
|
when (val plan = QueueReorder.planWrite(oldEntries.asRowKeys(), entries.asRowKeys())) {
|
||||||
.asSequence()
|
QueueWritePlan.NoChange -> Unit
|
||||||
.map(PlaybackQueueEntryEntity::entryId)
|
|
||||||
.filterNot(retainedIds::contains)
|
is QueueWritePlan.Removal -> {
|
||||||
.toList()
|
deleteQueueEntriesById(session.id, plan.removedIds)
|
||||||
if (removedIds.isNotEmpty()) deleteQueueEntriesById(session.id, removedIds)
|
parkQueuePositions(session.id, plan.removedPositions.min(), QUEUE_PARK_OFFSET)
|
||||||
val changedRows = entries.drop(changedAt)
|
QueueReorder.compactionShifts(plan.removedPositions).forEach { shift ->
|
||||||
if (changedRows.isNotEmpty()) putQueueEntries(changedRows)
|
shiftQueueRange(session.id, shift.fromPosition, shift.toPosition, shift.delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is QueueWritePlan.Move -> {
|
||||||
|
val move = QueueReorder.movePlan(plan.from, plan.to)
|
||||||
|
if (move == null) {
|
||||||
|
rebuildQueueTail(session.id, oldEntries, entries)
|
||||||
|
} else {
|
||||||
|
setQueueEntryPosition(session.id, plan.entryId, move.parkedMovedPosition)
|
||||||
|
move.spanOut?.let { shiftQueueRange(session.id, it.fromPosition, it.toPosition, it.delta) }
|
||||||
|
move.spanBack?.let { shiftQueueRange(session.id, it.fromPosition, it.toPosition, it.delta) }
|
||||||
|
setQueueEntryPosition(session.id, plan.entryId, move.finalPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueWritePlan.Rebuild -> rebuildQueueTail(session.id, oldEntries, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
val oldOriginal = getOriginalQueueEntries(session.id)
|
val oldOriginal = getOriginalQueueEntries(session.id)
|
||||||
@@ -490,27 +560,81 @@ interface UserDao {
|
|||||||
if (oldOriginal.isNotEmpty()) clearOriginalQueueEntries(session.id)
|
if (oldOriginal.isNotEmpty()) clearOriginalQueueEntries(session.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val originalChangedAt = firstChangedOriginalPosition(oldOriginal, originalEntries)
|
when (
|
||||||
if (originalChangedAt != null) {
|
val plan = QueueReorder.planWrite(oldOriginal.asOriginalRowKeys(), originalEntries.asOriginalRowKeys())
|
||||||
parkOriginalQueuePositions(
|
) {
|
||||||
session.id,
|
QueueWritePlan.NoChange -> Unit
|
||||||
originalChangedAt.toLong(),
|
|
||||||
1_000_000_000_000L,
|
is QueueWritePlan.Removal -> {
|
||||||
)
|
deleteOriginalQueueEntriesById(session.id, plan.removedIds)
|
||||||
val retainedIds = originalEntries
|
parkOriginalQueuePositions(session.id, plan.removedPositions.min(), QUEUE_PARK_OFFSET)
|
||||||
.mapTo(hashSetOf(), PlaybackOriginalQueueEntryEntity::entryId)
|
QueueReorder.compactionShifts(plan.removedPositions).forEach { shift ->
|
||||||
val removedIds = oldOriginal
|
shiftOriginalQueueRange(session.id, shift.fromPosition, shift.toPosition, shift.delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is QueueWritePlan.Move -> {
|
||||||
|
val move = QueueReorder.movePlan(plan.from, plan.to)
|
||||||
|
if (move == null) {
|
||||||
|
rebuildOriginalQueueTail(session.id, oldOriginal, originalEntries)
|
||||||
|
} else {
|
||||||
|
setOriginalQueueEntryPosition(session.id, plan.entryId, move.parkedMovedPosition)
|
||||||
|
move.spanOut?.let {
|
||||||
|
shiftOriginalQueueRange(session.id, it.fromPosition, it.toPosition, it.delta)
|
||||||
|
}
|
||||||
|
move.spanBack?.let {
|
||||||
|
shiftOriginalQueueRange(session.id, it.fromPosition, it.toPosition, it.delta)
|
||||||
|
}
|
||||||
|
setOriginalQueueEntryPosition(session.id, plan.entryId, move.finalPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueWritePlan.Rebuild -> rebuildOriginalQueueTail(session.id, oldOriginal, originalEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pre-existing write path: park the tail, drop removals, upsert from the change point. */
|
||||||
|
private suspend fun rebuildQueueTail(
|
||||||
|
sessionId: String,
|
||||||
|
before: List<PlaybackQueueEntryEntity>,
|
||||||
|
after: List<PlaybackQueueEntryEntity>,
|
||||||
|
) {
|
||||||
|
val changedAt = firstChangedQueuePosition(before, after) ?: return
|
||||||
|
parkQueuePositions(sessionId, changedAt.toLong(), QUEUE_PARK_OFFSET)
|
||||||
|
val retainedIds = after.mapTo(hashSetOf(), PlaybackQueueEntryEntity::entryId)
|
||||||
|
val removedIds = before
|
||||||
|
.asSequence()
|
||||||
|
.map(PlaybackQueueEntryEntity::entryId)
|
||||||
|
.filterNot(retainedIds::contains)
|
||||||
|
.toList()
|
||||||
|
if (removedIds.isNotEmpty()) deleteQueueEntriesById(sessionId, removedIds)
|
||||||
|
val changedRows = after.drop(changedAt)
|
||||||
|
if (changedRows.isNotEmpty()) putQueueEntries(changedRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun rebuildOriginalQueueTail(
|
||||||
|
sessionId: String,
|
||||||
|
before: List<PlaybackOriginalQueueEntryEntity>,
|
||||||
|
after: List<PlaybackOriginalQueueEntryEntity>,
|
||||||
|
) {
|
||||||
|
val changedAt = firstChangedOriginalPosition(before, after) ?: return
|
||||||
|
parkOriginalQueuePositions(sessionId, changedAt.toLong(), QUEUE_PARK_OFFSET)
|
||||||
|
val retainedIds = after.mapTo(hashSetOf(), PlaybackOriginalQueueEntryEntity::entryId)
|
||||||
|
val removedIds = before
|
||||||
.asSequence()
|
.asSequence()
|
||||||
.map(PlaybackOriginalQueueEntryEntity::entryId)
|
.map(PlaybackOriginalQueueEntryEntity::entryId)
|
||||||
.filterNot(retainedIds::contains)
|
.filterNot(retainedIds::contains)
|
||||||
.toList()
|
.toList()
|
||||||
if (removedIds.isNotEmpty()) {
|
if (removedIds.isNotEmpty()) deleteOriginalQueueEntriesById(sessionId, removedIds)
|
||||||
deleteOriginalQueueEntriesById(session.id, removedIds)
|
val changedRows = after.drop(changedAt)
|
||||||
}
|
|
||||||
val changedRows = originalEntries.drop(originalChangedAt)
|
|
||||||
if (changedRows.isNotEmpty()) putOriginalQueueEntries(changedRows)
|
if (changedRows.isNotEmpty()) putOriginalQueueEntries(changedRows)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private fun List<PlaybackQueueEntryEntity>.asRowKeys(): List<QueueRowKey> =
|
||||||
|
map { QueueRowKey(it.entryId, it.trackPath, it.position) }
|
||||||
|
|
||||||
|
private fun List<PlaybackOriginalQueueEntryEntity>.asOriginalRowKeys(): List<QueueRowKey> =
|
||||||
|
map { QueueRowKey(it.entryId, it.trackPath, it.position) }
|
||||||
|
|
||||||
private fun firstChangedQueuePosition(
|
private fun firstChangedQueuePosition(
|
||||||
before: List<PlaybackQueueEntryEntity>,
|
before: List<PlaybackQueueEntryEntity>,
|
||||||
|
|||||||
+222
@@ -1,5 +1,72 @@
|
|||||||
package expo.modules.astralibraryscanner.queue
|
package expo.modules.astralibraryscanner.queue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positions are parked into this disjoint space while an edit is in flight.
|
||||||
|
*
|
||||||
|
* `playback_queue_entries` has a unique `(session_id, position)` index, and a
|
||||||
|
* bulk `UPDATE ... SET position = position + delta` would violate it the moment
|
||||||
|
* SQLite visits rows in an order where a row lands on a slot not yet vacated —
|
||||||
|
* an order the query planner does not promise. Moving the affected rows clear of
|
||||||
|
* the real position space first makes every write collision-free regardless of
|
||||||
|
* visit order.
|
||||||
|
*/
|
||||||
|
const val QUEUE_PARK_OFFSET = 1_000_000_000_000L
|
||||||
|
|
||||||
|
/** One `UPDATE ... SET position = position + delta` over an inclusive range. */
|
||||||
|
data class QueueShift(
|
||||||
|
val fromPosition: Long,
|
||||||
|
val toPosition: Long,
|
||||||
|
val delta: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The statement sequence that relocates one row, leaving positions dense.
|
||||||
|
*
|
||||||
|
* The moved row parks below the span so the two never collide even when the
|
||||||
|
* destination is position zero.
|
||||||
|
*/
|
||||||
|
data class QueueMovePlan(
|
||||||
|
val parkedMovedPosition: Long,
|
||||||
|
val spanOut: QueueShift?,
|
||||||
|
val spanBack: QueueShift?,
|
||||||
|
val finalPosition: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Identity of one queue row, decoupled from the Room entity so it stays JVM-testable. */
|
||||||
|
data class QueueRowKey(
|
||||||
|
val entryId: Long,
|
||||||
|
val trackPath: String,
|
||||||
|
val position: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How to persist an order change.
|
||||||
|
*
|
||||||
|
* [Removal] and [Move] are the edits a user actually performs on an open queue,
|
||||||
|
* and both are expressible as a handful of `UPDATE`s over a position range.
|
||||||
|
* Anything else — shuffle, append, insert-after-active — falls back to
|
||||||
|
* [Rebuild], which rewrites rows from the change point. Detection is
|
||||||
|
* conservative on purpose: an unrecognised shape costs the old behaviour, never
|
||||||
|
* a wrong order.
|
||||||
|
*/
|
||||||
|
sealed interface QueueWritePlan {
|
||||||
|
data class Removal(
|
||||||
|
val removedIds: List<Long>,
|
||||||
|
val removedPositions: List<Long>,
|
||||||
|
) : QueueWritePlan
|
||||||
|
|
||||||
|
data class Move(
|
||||||
|
val entryId: Long,
|
||||||
|
val from: Long,
|
||||||
|
val to: Long,
|
||||||
|
) : QueueWritePlan
|
||||||
|
|
||||||
|
/** Order is unchanged; only the session row needs writing. */
|
||||||
|
data object NoChange : QueueWritePlan
|
||||||
|
|
||||||
|
data object Rebuild : QueueWritePlan
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure index math shared by the queue view and the playback repository.
|
* Pure index math shared by the queue view and the playback repository.
|
||||||
*
|
*
|
||||||
@@ -35,4 +102,159 @@ object QueueReorder {
|
|||||||
items.add(to.coerceIn(0, items.size), item)
|
items.add(to.coerceIn(0, items.size), item)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Range shifts that re-densify positions after the rows at [removedPositions]
|
||||||
|
* have been deleted and everything from the first gap onwards has been parked
|
||||||
|
* by [parkOffset].
|
||||||
|
*
|
||||||
|
* A surviving row at original position `q` must end up at
|
||||||
|
* `q - (removed positions below q)`, so rows between consecutive gaps share a
|
||||||
|
* shift and each gap deepens it by one. That collapses an edit into one
|
||||||
|
* statement per removed row instead of one row-write per surviving row.
|
||||||
|
*/
|
||||||
|
fun compactionShifts(
|
||||||
|
removedPositions: List<Long>,
|
||||||
|
parkOffset: Long = QUEUE_PARK_OFFSET,
|
||||||
|
): List<QueueShift> {
|
||||||
|
val gaps = removedPositions.distinct().sorted()
|
||||||
|
if (gaps.isEmpty()) return emptyList()
|
||||||
|
return gaps
|
||||||
|
.mapIndexed { index, position ->
|
||||||
|
QueueShift(
|
||||||
|
fromPosition = parkOffset + position + 1L,
|
||||||
|
// The final segment runs to the end of the parked space.
|
||||||
|
toPosition = gaps.getOrNull(index + 1)?.let { parkOffset + it - 1L } ?: Long.MAX_VALUE,
|
||||||
|
delta = -parkOffset - (index + 1L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Adjacent gaps enclose no rows.
|
||||||
|
.filter { it.fromPosition <= it.toPosition }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statement sequence to move the row at [from] to [to], keeping positions
|
||||||
|
* dense. Returns null when the move is a no-op.
|
||||||
|
*
|
||||||
|
* Matches [applyMove]: the row ends at exactly [to] in both directions.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Classify an order change so the common edits can be written as position
|
||||||
|
* arithmetic instead of rewriting every row from the change point onward.
|
||||||
|
*
|
||||||
|
* [before] must already be dense and ordered by position (what
|
||||||
|
* `ORDER BY position` yields for an intact queue); anything else returns
|
||||||
|
* [QueueWritePlan.Rebuild], as does any shape this does not positively
|
||||||
|
* recognise.
|
||||||
|
*/
|
||||||
|
fun planWrite(
|
||||||
|
before: List<QueueRowKey>,
|
||||||
|
after: List<QueueRowKey>,
|
||||||
|
): QueueWritePlan {
|
||||||
|
if (before.isEmpty() || after.isEmpty()) return QueueWritePlan.Rebuild
|
||||||
|
if (!isDense(before) || !isDense(after)) return QueueWritePlan.Rebuild
|
||||||
|
return when {
|
||||||
|
after.size < before.size -> planRemoval(before, after)
|
||||||
|
after.size == before.size -> planMove(before, after)
|
||||||
|
else -> QueueWritePlan.Rebuild
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isDense(rows: List<QueueRowKey>): Boolean =
|
||||||
|
rows.withIndex().all { (index, row) -> row.position == index.toLong() }
|
||||||
|
|
||||||
|
private fun planRemoval(
|
||||||
|
before: List<QueueRowKey>,
|
||||||
|
after: List<QueueRowKey>,
|
||||||
|
): QueueWritePlan {
|
||||||
|
val keptIds = after.mapTo(HashSet(after.size), QueueRowKey::entryId)
|
||||||
|
val removedIds = ArrayList<Long>(before.size - after.size)
|
||||||
|
val removedPositions = ArrayList<Long>(before.size - after.size)
|
||||||
|
val survivors = ArrayList<QueueRowKey>(after.size)
|
||||||
|
before.forEach { row ->
|
||||||
|
if (row.entryId in keptIds) {
|
||||||
|
survivors.add(row)
|
||||||
|
} else {
|
||||||
|
removedIds.add(row.entryId)
|
||||||
|
removedPositions.add(row.position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (survivors.size != after.size || removedIds.isEmpty()) return QueueWritePlan.Rebuild
|
||||||
|
// Survivors must keep both identity and relative order for a compaction to
|
||||||
|
// reproduce `after` exactly.
|
||||||
|
for (index in after.indices) {
|
||||||
|
if (
|
||||||
|
survivors[index].entryId != after[index].entryId ||
|
||||||
|
survivors[index].trackPath != after[index].trackPath
|
||||||
|
) return QueueWritePlan.Rebuild
|
||||||
|
}
|
||||||
|
return QueueWritePlan.Removal(removedIds, removedPositions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun planMove(
|
||||||
|
before: List<QueueRowKey>,
|
||||||
|
after: List<QueueRowKey>,
|
||||||
|
): QueueWritePlan {
|
||||||
|
var first = -1
|
||||||
|
var last = -1
|
||||||
|
for (index in before.indices) {
|
||||||
|
if (before[index].entryId != after[index].entryId) {
|
||||||
|
if (first < 0) first = index
|
||||||
|
last = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (first < 0) return QueueWritePlan.NoChange
|
||||||
|
// A single relocation shows up as a rotation of [first, last] by one, in
|
||||||
|
// whichever direction the row travelled.
|
||||||
|
val movedDown = matchesRotation(before, after, first, last, movedDown = true)
|
||||||
|
val movedUp = matchesRotation(before, after, first, last, movedDown = false)
|
||||||
|
return when {
|
||||||
|
movedDown -> QueueWritePlan.Move(before[first].entryId, first.toLong(), last.toLong())
|
||||||
|
movedUp -> QueueWritePlan.Move(before[last].entryId, last.toLong(), first.toLong())
|
||||||
|
else -> QueueWritePlan.Rebuild
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesRotation(
|
||||||
|
before: List<QueueRowKey>,
|
||||||
|
after: List<QueueRowKey>,
|
||||||
|
first: Int,
|
||||||
|
last: Int,
|
||||||
|
movedDown: Boolean,
|
||||||
|
): Boolean {
|
||||||
|
val moved = if (movedDown) before[first] else before[last]
|
||||||
|
val landing = if (movedDown) last else first
|
||||||
|
if (after[landing].entryId != moved.entryId || after[landing].trackPath != moved.trackPath) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The rest of the window shifts one slot toward the vacated end.
|
||||||
|
for (index in first..last) {
|
||||||
|
if (index == landing) continue
|
||||||
|
val source = if (movedDown) before[index + 1] else before[index - 1]
|
||||||
|
if (
|
||||||
|
after[index].entryId != source.entryId ||
|
||||||
|
after[index].trackPath != source.trackPath
|
||||||
|
) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun movePlan(
|
||||||
|
from: Long,
|
||||||
|
to: Long,
|
||||||
|
parkOffset: Long = QUEUE_PARK_OFFSET,
|
||||||
|
): QueueMovePlan? {
|
||||||
|
if (from == to || from < 0L || to < 0L) return null
|
||||||
|
// Below the parked span, so a move to position zero cannot collide with it.
|
||||||
|
val parkedMoved = parkOffset - 1L
|
||||||
|
val spanStart = if (from < to) from + 1L else to
|
||||||
|
val spanEnd = if (from < to) to else from - 1L
|
||||||
|
val backDelta = if (from < to) -parkOffset - 1L else -parkOffset + 1L
|
||||||
|
return QueueMovePlan(
|
||||||
|
parkedMovedPosition = parkedMoved,
|
||||||
|
spanOut = QueueShift(spanStart, spanEnd, parkOffset),
|
||||||
|
spanBack = QueueShift(parkOffset + spanStart, parkOffset + spanEnd, backDelta),
|
||||||
|
finalPosition = to,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+261
@@ -2,6 +2,7 @@ package expo.modules.astralibraryscanner.queue
|
|||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -94,4 +95,264 @@ class QueueReorderTest {
|
|||||||
assertEquals(-1, QueueReorder.adapterIndex(activePosition = 5L, queuePosition = 5L))
|
assertEquals(-1, QueueReorder.adapterIndex(activePosition = 5L, queuePosition = 5L))
|
||||||
assertEquals(-1, QueueReorder.adapterIndex(activePosition = 5L, queuePosition = 0L))
|
assertEquals(-1, QueueReorder.adapterIndex(activePosition = 5L, queuePosition = 0L))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SQL position arithmetic -------------------------------------------------
|
||||||
|
//
|
||||||
|
// These simulate the statements the DAO issues against an in-memory row set,
|
||||||
|
// and assert two things: the end state is dense and matches the plain list
|
||||||
|
// semantics above, and no intermediate step ever puts two rows on the same
|
||||||
|
// position — which is exactly what the unique (session_id, position) index
|
||||||
|
// would reject.
|
||||||
|
|
||||||
|
/** (entryId, position), mirroring playback_queue_entries. */
|
||||||
|
private fun rows(count: Int): MutableList<Pair<Long, Long>> =
|
||||||
|
(0 until count).mapTo(mutableListOf()) { (100L + it) to it.toLong() }
|
||||||
|
|
||||||
|
private fun applyShift(rows: MutableList<Pair<Long, Long>>, shift: QueueShift) {
|
||||||
|
rows.forEachIndexed { index, (id, position) ->
|
||||||
|
if (position >= shift.fromPosition && position <= shift.toPosition) {
|
||||||
|
rows[index] = id to (position + shift.delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNoCollision(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPosition(rows: MutableList<Pair<Long, Long>>, entryId: Long, position: Long) {
|
||||||
|
rows[rows.indexOfFirst { it.first == entryId }] = entryId to position
|
||||||
|
assertNoCollision(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertNoCollision(rows: List<Pair<Long, Long>>) {
|
||||||
|
val positions = rows.map { it.second }
|
||||||
|
assertEquals(
|
||||||
|
"two rows share a position — the unique index would reject this",
|
||||||
|
positions.size,
|
||||||
|
positions.toSet().size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun orderedIds(rows: List<Pair<Long, Long>>): List<Long> =
|
||||||
|
rows.sortedBy { it.second }.map { it.first }
|
||||||
|
|
||||||
|
private fun assertDense(rows: List<Pair<Long, Long>>) {
|
||||||
|
assertEquals(
|
||||||
|
(0 until rows.size).map(Int::toLong),
|
||||||
|
rows.map { it.second }.sorted(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeAndCompact(count: Int, removed: List<Long>): List<Long> {
|
||||||
|
val live = rows(count)
|
||||||
|
val expected = orderedIds(live).filterIndexed { index, _ -> index.toLong() !in removed }
|
||||||
|
|
||||||
|
live.removeAll { it.second in removed }
|
||||||
|
val park = QueueReorder.compactionShifts(removed)
|
||||||
|
applyShift(live, QueueShift(removed.min(), Long.MAX_VALUE, QUEUE_PARK_OFFSET))
|
||||||
|
park.forEach { applyShift(live, it) }
|
||||||
|
|
||||||
|
assertDense(live)
|
||||||
|
return orderedIds(live).also { assertEquals(expected, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removing one row in the middle compacts the rest`() {
|
||||||
|
removeAndCompact(count = 8, removed = listOf(3L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removing the first and last rows compacts correctly`() {
|
||||||
|
removeAndCompact(count = 8, removed = listOf(0L))
|
||||||
|
removeAndCompact(count = 8, removed = listOf(7L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removing several rows at once compacts by a deepening offset`() {
|
||||||
|
removeAndCompact(count = 12, removed = listOf(2L, 5L, 6L, 9L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removing adjacent rows leaves no empty segment behind`() {
|
||||||
|
removeAndCompact(count = 6, removed = listOf(1L, 2L, 3L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every single-row removal in a queue compacts densely`() {
|
||||||
|
for (position in 0L until 10L) {
|
||||||
|
removeAndCompact(count = 10, removed = listOf(position))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the move plan reproduces applyMove for every source and destination`() {
|
||||||
|
val size = 7
|
||||||
|
for (from in 0 until size) {
|
||||||
|
for (to in 0 until size) {
|
||||||
|
val live = rows(size)
|
||||||
|
val movedId = 100L + from
|
||||||
|
val expected = orderedIds(live).toMutableList()
|
||||||
|
val changed = QueueReorder.applyMove(expected, from, to)
|
||||||
|
|
||||||
|
val plan = QueueReorder.movePlan(from.toLong(), to.toLong())
|
||||||
|
assertEquals("from=$from to=$to", changed, plan != null)
|
||||||
|
if (plan == null) continue
|
||||||
|
|
||||||
|
setPosition(live, movedId, plan.parkedMovedPosition)
|
||||||
|
plan.spanOut?.let { applyShift(live, it) }
|
||||||
|
plan.spanBack?.let { applyShift(live, it) }
|
||||||
|
setPosition(live, movedId, plan.finalPosition)
|
||||||
|
|
||||||
|
assertDense(live)
|
||||||
|
assertEquals("from=$from to=$to", expected, orderedIds(live))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moving to position zero does not collide with the parked row`() {
|
||||||
|
val live = rows(5)
|
||||||
|
val plan = QueueReorder.movePlan(4L, 0L)!!
|
||||||
|
|
||||||
|
setPosition(live, 104L, plan.parkedMovedPosition)
|
||||||
|
plan.spanOut?.let { applyShift(live, it) }
|
||||||
|
plan.spanBack?.let { applyShift(live, it) }
|
||||||
|
setPosition(live, 104L, plan.finalPosition)
|
||||||
|
|
||||||
|
assertDense(live)
|
||||||
|
assertEquals(listOf(104L, 100L, 101L, 102L, 103L), orderedIds(live))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a no-op move produces no plan`() {
|
||||||
|
assertNull(QueueReorder.movePlan(3L, 3L))
|
||||||
|
assertNull(QueueReorder.movePlan(-1L, 2L))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write-plan detection ----------------------------------------------------
|
||||||
|
//
|
||||||
|
// Mis-classification here would persist a wrong order, so the bar is that every
|
||||||
|
// recognised plan must reproduce `after` exactly when executed, and anything
|
||||||
|
// not positively recognised must fall back to Rebuild.
|
||||||
|
|
||||||
|
private fun keys(vararg ids: Long): List<QueueRowKey> =
|
||||||
|
ids.mapIndexed { index, id -> QueueRowKey(id, "track-$id", index.toLong()) }
|
||||||
|
|
||||||
|
private fun keysOf(ids: List<Long>): List<QueueRowKey> = keys(*ids.toLongArray())
|
||||||
|
|
||||||
|
/** Runs a plan against the simulated table and returns the resulting id order. */
|
||||||
|
private fun execute(before: List<QueueRowKey>, plan: QueueWritePlan): List<Long> {
|
||||||
|
val live = before.mapTo(mutableListOf()) { it.entryId to it.position }
|
||||||
|
when (plan) {
|
||||||
|
is QueueWritePlan.Removal -> {
|
||||||
|
live.removeAll { (id, _) -> id in plan.removedIds }
|
||||||
|
applyShift(live, QueueShift(plan.removedPositions.min(), Long.MAX_VALUE, QUEUE_PARK_OFFSET))
|
||||||
|
QueueReorder.compactionShifts(plan.removedPositions).forEach { applyShift(live, it) }
|
||||||
|
}
|
||||||
|
is QueueWritePlan.Move -> {
|
||||||
|
val movePlan = QueueReorder.movePlan(plan.from, plan.to)!!
|
||||||
|
setPosition(live, plan.entryId, movePlan.parkedMovedPosition)
|
||||||
|
movePlan.spanOut?.let { applyShift(live, it) }
|
||||||
|
movePlan.spanBack?.let { applyShift(live, it) }
|
||||||
|
setPosition(live, plan.entryId, movePlan.finalPosition)
|
||||||
|
}
|
||||||
|
QueueWritePlan.NoChange, QueueWritePlan.Rebuild -> Unit
|
||||||
|
}
|
||||||
|
assertDense(live)
|
||||||
|
return orderedIds(live)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every single removal is detected and executes to the right order`() {
|
||||||
|
val ids = listOf(10L, 11L, 12L, 13L, 14L, 15L)
|
||||||
|
for (dropped in ids) {
|
||||||
|
val before = keysOf(ids)
|
||||||
|
val after = keysOf(ids - dropped)
|
||||||
|
val plan = QueueReorder.planWrite(before, after)
|
||||||
|
|
||||||
|
assertTrue("dropping $dropped -> $plan", plan is QueueWritePlan.Removal)
|
||||||
|
assertEquals("dropping $dropped", ids - dropped, execute(before, plan))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-row removal is detected and executes to the right order`() {
|
||||||
|
val ids = listOf(10L, 11L, 12L, 13L, 14L, 15L, 16L)
|
||||||
|
val dropped = setOf(11L, 12L, 15L)
|
||||||
|
val before = keysOf(ids)
|
||||||
|
val after = keysOf(ids.filterNot(dropped::contains))
|
||||||
|
|
||||||
|
val plan = QueueReorder.planWrite(before, after)
|
||||||
|
|
||||||
|
assertTrue(plan is QueueWritePlan.Removal)
|
||||||
|
assertEquals(ids.filterNot(dropped::contains), execute(before, plan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every single move is detected and executes to the right order`() {
|
||||||
|
val ids = listOf(10L, 11L, 12L, 13L, 14L, 15L)
|
||||||
|
for (from in ids.indices) {
|
||||||
|
for (to in ids.indices) {
|
||||||
|
if (from == to) continue
|
||||||
|
val expected = ids.toMutableList()
|
||||||
|
QueueReorder.applyMove(expected, from, to)
|
||||||
|
val before = keysOf(ids)
|
||||||
|
val plan = QueueReorder.planWrite(before, keysOf(expected))
|
||||||
|
|
||||||
|
assertTrue("from=$from to=$to -> $plan", plan is QueueWritePlan.Move)
|
||||||
|
assertEquals("from=$from to=$to", expected, execute(before, plan))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unchanged order is detected as NoChange`() {
|
||||||
|
val ids = listOf(10L, 11L, 12L)
|
||||||
|
assertEquals(QueueWritePlan.NoChange, QueueReorder.planWrite(keysOf(ids), keysOf(ids)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shapes that are not a plain removal or single move fall back to Rebuild`() {
|
||||||
|
val ids = listOf(10L, 11L, 12L, 13L, 14L)
|
||||||
|
|
||||||
|
// A shuffle.
|
||||||
|
assertEquals(
|
||||||
|
QueueWritePlan.Rebuild,
|
||||||
|
QueueReorder.planWrite(keysOf(ids), keysOf(listOf(13L, 10L, 14L, 11L, 12L))),
|
||||||
|
)
|
||||||
|
// Two independent swaps — not expressible as one relocation.
|
||||||
|
assertEquals(
|
||||||
|
QueueWritePlan.Rebuild,
|
||||||
|
QueueReorder.planWrite(keysOf(ids), keysOf(listOf(11L, 10L, 13L, 12L, 14L))),
|
||||||
|
)
|
||||||
|
// An insertion.
|
||||||
|
assertEquals(
|
||||||
|
QueueWritePlan.Rebuild,
|
||||||
|
QueueReorder.planWrite(keysOf(ids), keysOf(ids + 99L)),
|
||||||
|
)
|
||||||
|
// A removal that also reorders the survivors.
|
||||||
|
assertEquals(
|
||||||
|
QueueWritePlan.Rebuild,
|
||||||
|
QueueReorder.planWrite(keysOf(ids), keysOf(listOf(14L, 11L, 12L, 13L))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a survivor whose track path changed is not treated as a plain removal`() {
|
||||||
|
val before = keys(10L, 11L, 12L)
|
||||||
|
val after = listOf(
|
||||||
|
QueueRowKey(10L, "track-10", 0L),
|
||||||
|
QueueRowKey(12L, "replaced", 1L),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(QueueWritePlan.Rebuild, QueueReorder.planWrite(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non-dense before list falls back to Rebuild`() {
|
||||||
|
val sparse = listOf(
|
||||||
|
QueueRowKey(10L, "track-10", 0L),
|
||||||
|
QueueRowKey(11L, "track-11", 5L),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(QueueWritePlan.Rebuild, QueueReorder.planWrite(sparse, keys(10L)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user