mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-17 11:12:33 +02:00
performance fix for queue
This commit is contained in:
+154
-30
@@ -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>,
|
||||
|
||||
+222
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+261
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user