performance fix for queue

This commit is contained in:
Boof2015
2026-07-30 19:56:19 -04:00
parent 254724cf4d
commit c302c62a3f
3 changed files with 637 additions and 30 deletions
@@ -11,6 +11,10 @@ import androidx.room.Transaction
import androidx.room.Upsert
import androidx.room.migration.Migration
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
data class RemotePlaylistSyncPlan(
@@ -406,6 +410,31 @@ interface UserDao {
)
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")
suspend fun countQueueEntries(sessionId: String): Long
@@ -439,6 +468,31 @@ interface UserDao {
)
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")
suspend fun getSnapshotMetadata(): SnapshotMetadataEntity?
@@ -471,18 +525,34 @@ interface UserDao {
) {
val oldEntries = getAllQueueEntries(session.id)
putPlaybackSession(session)
val changedAt = firstChangedQueuePosition(oldEntries, entries)
if (changedAt != null) {
parkQueuePositions(session.id, changedAt.toLong(), 1_000_000_000_000L)
val retainedIds = entries.mapTo(hashSetOf(), PlaybackQueueEntryEntity::entryId)
val removedIds = oldEntries
.asSequence()
.map(PlaybackQueueEntryEntity::entryId)
.filterNot(retainedIds::contains)
.toList()
if (removedIds.isNotEmpty()) deleteQueueEntriesById(session.id, removedIds)
val changedRows = entries.drop(changedAt)
if (changedRows.isNotEmpty()) putQueueEntries(changedRows)
// Removals and single moves — the edits a user performs on an open queue —
// become a handful of range UPDATEs. The fallback below rewrites every row
// from the change point, which for an edit near the active track means
// marshalling almost the whole queue through Room on every tap.
when (val plan = QueueReorder.planWrite(oldEntries.asRowKeys(), entries.asRowKeys())) {
QueueWritePlan.NoChange -> Unit
is QueueWritePlan.Removal -> {
deleteQueueEntriesById(session.id, plan.removedIds)
parkQueuePositions(session.id, plan.removedPositions.min(), QUEUE_PARK_OFFSET)
QueueReorder.compactionShifts(plan.removedPositions).forEach { shift ->
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)
@@ -490,28 +560,82 @@ interface UserDao {
if (oldOriginal.isNotEmpty()) clearOriginalQueueEntries(session.id)
return
}
val originalChangedAt = firstChangedOriginalPosition(oldOriginal, originalEntries)
if (originalChangedAt != null) {
parkOriginalQueuePositions(
session.id,
originalChangedAt.toLong(),
1_000_000_000_000L,
)
val retainedIds = originalEntries
.mapTo(hashSetOf(), PlaybackOriginalQueueEntryEntity::entryId)
val removedIds = oldOriginal
.asSequence()
.map(PlaybackOriginalQueueEntryEntity::entryId)
.filterNot(retainedIds::contains)
.toList()
if (removedIds.isNotEmpty()) {
deleteOriginalQueueEntriesById(session.id, removedIds)
when (
val plan = QueueReorder.planWrite(oldOriginal.asOriginalRowKeys(), originalEntries.asOriginalRowKeys())
) {
QueueWritePlan.NoChange -> Unit
is QueueWritePlan.Removal -> {
deleteOriginalQueueEntriesById(session.id, plan.removedIds)
parkOriginalQueuePositions(session.id, plan.removedPositions.min(), QUEUE_PARK_OFFSET)
QueueReorder.compactionShifts(plan.removedPositions).forEach { shift ->
shiftOriginalQueueRange(session.id, shift.fromPosition, shift.toPosition, shift.delta)
}
}
val changedRows = originalEntries.drop(originalChangedAt)
if (changedRows.isNotEmpty()) putOriginalQueueEntries(changedRows)
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()
.map(PlaybackOriginalQueueEntryEntity::entryId)
.filterNot(retainedIds::contains)
.toList()
if (removedIds.isNotEmpty()) deleteOriginalQueueEntriesById(sessionId, removedIds)
val changedRows = after.drop(changedAt)
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(
before: List<PlaybackQueueEntryEntity>,
after: List<PlaybackQueueEntryEntity>,
@@ -1,5 +1,72 @@
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.
*
@@ -35,4 +102,159 @@ object QueueReorder {
items.add(to.coerceIn(0, items.size), item)
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,
)
}
}
@@ -2,6 +2,7 @@ package expo.modules.astralibraryscanner.queue
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
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 = 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)))
}
}