From c0f08fd61189956b20409281efaa0eda95ed1002 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 15 Jun 2026 21:40:50 -0400 Subject: [PATCH 1/3] ADFA-4332: add batched removeBySources index API Sentry APPDEVFORALL-SE. Batched primitive (single transaction, chunked IN) to collapse the per-file DELETE FROM jvm_symbols N+1. Call-site wiring TODO. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../codeonthego/indexing/InMemoryIndex.kt | 12 ++++++++- .../codeonthego/indexing/SQLiteIndex.kt | 26 +++++++++++++++++++ .../codeonthego/indexing/api/Index.kt | 10 +++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt index e130b72608..e9d29829da 100644 --- a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt @@ -81,7 +81,17 @@ class InMemoryIndex( override suspend fun insert(entry: T) = lock.write { insertSingleLocked(entry) } override suspend fun removeBySource(sourceId: String) = lock.write { - val keys = sourceMap.remove(sourceId) ?: return@write + removeBySourceLocked(sourceId) + } + + override suspend fun removeBySources(sourceIds: Collection) = lock.write { + for (sourceId in sourceIds) { + removeBySourceLocked(sourceId) + } + } + + private fun removeBySourceLocked(sourceId: String) { + val keys = sourceMap.remove(sourceId) ?: return for (key in keys) { val entry = primaryMap.remove(key) ?: continue removeFromSecondaryIndexes(entry) diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt index 4d8dae0627..57d77eb4c6 100644 --- a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt @@ -65,6 +65,12 @@ class SQLiteIndex( ) : Index { companion object { private val log = LoggerFactory.getLogger(SQLiteIndex::class.java) + + /** + * Max number of `_source_id` placeholders per batched DELETE. + * Kept well under SQLite's default 999 bound-parameter limit. + */ + private const val DELETE_CHUNK_SIZE = 900 } @@ -190,6 +196,26 @@ class SQLiteIndex( ifOpen { db.execSQL("DELETE FROM $tableName WHERE _source_id = ?", arrayOf(sourceId)) } } + override suspend fun removeBySources(sourceIds: Collection) = + withContext(Dispatchers.IO) { + if (sourceIds.isEmpty()) return@withContext + ifOpen { + db.beginTransaction() + try { + for (chunk in sourceIds.chunked(DELETE_CHUNK_SIZE)) { + val placeholders = chunk.joinToString(",") { "?" } + db.execSQL( + "DELETE FROM $tableName WHERE _source_id IN ($placeholders)", + chunk.toTypedArray(), + ) + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + } + override suspend fun clear() = withContext(Dispatchers.IO) { ifOpen { db.execSQL("DELETE FROM $tableName") } } diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Index.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Index.kt index 222c74772e..ed354a7a1a 100644 --- a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Index.kt +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Index.kt @@ -68,6 +68,16 @@ interface WritableIndex { */ suspend fun removeBySource(sourceId: String) + /** + * Remove all entries from the given sources in a single transaction. + * + * Equivalent to calling [removeBySource] for each id, but issues the + * deletes as one batched, transactional operation instead of N + * sequential statements. Implementations should chunk the ids so the + * generated SQL stays within parameter limits. + */ + suspend fun removeBySources(sourceIds: Collection) + /** * Remove all entries. */ From 678f6e40ff60f3347eaaa5b8896976b45c78e45c Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 17 Jun 2026 05:30:02 -0700 Subject: [PATCH 2/3] ADFA-4332: wire batched removeBySources into IndexWorker + repro test Completes the call-site wiring left TODO by the primitive commit. IndexWorker now coalesces a run of consecutive RemoveFromIndex commands into a single JvmSymbolIndex.removeBySources call (one SQLite transaction) instead of one DELETE FROM jvm_symbols per file (the N+1, Sentry APPDEVFORALL-SE). WorkerQueue gains a non-blocking pollIndexQueue + single-slot pushBack so a non-removal command polled while draining is returned in order, not dropped. IndexWorkerBatchRemovalTest is failing-first: it asserts N removals issue ONE batched transaction (removeBySourcesBatches==1, removeBySourceCalls==0) and verifies InMemory/SQLite-style parity. Reverting applyRemovals to the per-file loop turns the two wiring tests red (expected 1 but was 0). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lsp/kotlin/compiler/index/IndexWorker.kt | 58 +++++- .../lsp/kotlin/compiler/index/WorkerQueue.kt | 28 +++ .../index/IndexWorkerBatchRemovalTest.kt | 197 ++++++++++++++++++ 3 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorkerBatchRemovalTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt index e27bd84f94..8290c25f22 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt @@ -61,9 +61,13 @@ internal class IndexWorker( while (isActive) { when (val cmd = queue.take()) { is IndexCommand.RemoveFromIndex -> { - val filePath = cmd.path.pathString - fileIndex.remove(filePath) - sourceIndex.removeBySource(filePath) + applyRemovals( + first = cmd, + fileIndex = fileIndex, + sourceIndex = sourceIndex, + pollNext = { queue.pollIndexQueue() }, + pushBack = { queue.pushBackIndexQueue(it) }, + ) } is IndexCommand.IndexSourceFile -> { @@ -151,3 +155,51 @@ internal class IndexWorker( } } } + +/** + * Apply [first] plus any consecutive, immediately-available [IndexCommand.RemoveFromIndex] + * commands as a single batched removal. + * + * Symbol removals are collapsed into one [JvmSymbolIndex.removeBySources] call — a single + * SQLite transaction — instead of issuing one `DELETE FROM jvm_symbols` (one transaction) + * per file, which is the N+1 this fix targets (Sentry APPDEVFORALL-SE). + * + * [pollNext] returns the next already-queued index command without blocking, or `null` + * when none is ready. A polled command that is *not* a removal is handed to [pushBack] so + * it is processed (in order) on the next loop iteration rather than dropped. + * + * @param first The removal command that triggered this batch. + * @param fileIndex Per-file metadata index (has no batch API; removed one by one). + * @param sourceIndex Symbol index; removed via the batched [JvmSymbolIndex.removeBySources]. + * @param pollNext Non-blocking poll of the next queued index command. + * @param pushBack Returns a non-removal command to the front of the queue. + */ +internal suspend fun applyRemovals( + first: IndexCommand.RemoveFromIndex, + fileIndex: KtFileMetadataIndex, + sourceIndex: JvmSymbolIndex, + pollNext: () -> IndexCommand?, + pushBack: (IndexCommand) -> Unit, +) { + val paths = ArrayList() + paths.add(first.path.pathString) + + while (true) { + val next = pollNext() ?: break + if (next is IndexCommand.RemoveFromIndex) { + paths.add(next.path.pathString) + } else { + // Not batchable — return it so the main loop handles it next, in order. + pushBack(next) + break + } + } + + // Per-file metadata index has no batch API; remove individually. + for (path in paths) { + fileIndex.remove(path) + } + + // Collapse all symbol removals into a single transaction. + sourceIndex.removeBySources(paths) +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/WorkerQueue.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/WorkerQueue.kt index 55ce8374b9..186a0cf6fa 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/WorkerQueue.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/WorkerQueue.kt @@ -9,11 +9,39 @@ internal class WorkerQueue { private val editChannel = Channel(capacity = 20) private val indexChannel = Channel(capacity = 100) + // Single-slot pushback for an index-queue item that was polled (to coalesce + // removals) but turned out not to be batchable. It is returned ahead of the + // channels by the next [take], preserving command order. + private var pushedBack: T? = null + suspend fun putScanQueue(item: T) = scanChannel.send(item) suspend fun putEditQueue(item: T) = editChannel.send(item) suspend fun putIndexQueue(item: T) = indexChannel.send(item) + /** + * Non-blocking poll of the index queue. Returns the next already-available + * index-queue item, or `null` if none is immediately ready. + * + * Used to coalesce a run of consecutive removal commands into a single + * batched index operation (see [IndexWorker]) instead of issuing one + * transaction per command. A polled item that is not batchable must be + * returned via [pushBackIndexQueue] so it is not dropped. + */ + fun pollIndexQueue(): T? = indexChannel.tryReceive().getOrNull() + + /** + * Return an item previously obtained from [pollIndexQueue] to the front of + * the queue so the next [take] yields it before any channel item. At most + * one item may be pushed back at a time. + */ + fun pushBackIndexQueue(item: T) { + check(pushedBack == null) { "pushBack slot already occupied" } + pushedBack = item + } + suspend fun take(): T { + pushedBack?.let { pushedBack = null; return it } + scanChannel.tryReceive().getOrNull()?.let { return it } editChannel.tryReceive().getOrNull()?.let { return it } indexChannel.tryReceive().getOrNull()?.let { return it } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorkerBatchRemovalTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorkerBatchRemovalTest.kt new file mode 100644 index 0000000000..892bd8834d --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorkerBatchRemovalTest.kt @@ -0,0 +1,197 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.index + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.appdevforall.codeonthego.indexing.InMemoryIndex +import org.appdevforall.codeonthego.indexing.api.Index +import org.appdevforall.codeonthego.indexing.api.IndexQuery +import org.appdevforall.codeonthego.indexing.jvm.JvmClassInfo +import org.appdevforall.codeonthego.indexing.jvm.JvmSourceLanguage +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolDescriptor +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolKind +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadata +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataDescriptor +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex +import org.appdevforall.codeonthego.indexing.util.BackgroundIndexer +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.nio.file.Paths +import java.time.Instant + +/** + * Verifies the ADFA-4332 wiring: a run of consecutive [IndexCommand.RemoveFromIndex] + * commands must collapse the symbol-index deletes into ONE batched + * [JvmSymbolIndex.removeBySources] call (a single SQLite transaction) instead of + * N separate [Index.removeBySource] calls (N transactions — the Sentry N+1). + */ +@RunWith(JUnit4::class) +class IndexWorkerBatchRemovalTest { + + /** + * Counting decorator over a real backing [Index] that records how the batched + * vs. per-source removal APIs are exercised. This is the mutation-sensitive + * probe: if the wiring regressed to calling [removeBySource] in a loop, the + * `removeBySourceCalls` counter would climb and `removeBySourcesBatches` stay 0. + */ + private class CountingIndex( + private val backing: Index, + ) : Index by backing { + var removeBySourceCalls = 0 + private set + var removeBySourcesBatches = 0 + private set + val removedInLargestBatch = mutableListOf() + + override suspend fun removeBySource(sourceId: String) { + removeBySourceCalls++ + backing.removeBySource(sourceId) + } + + override suspend fun removeBySources(sourceIds: Collection) { + removeBySourcesBatches++ + if (sourceIds.size >= removedInLargestBatch.size) { + removedInLargestBatch.clear() + removedInLargestBatch.addAll(sourceIds) + } + backing.removeBySources(sourceIds) + } + } + + private fun symbol(sourceId: String) = JvmSymbol( + key = "$sourceId#Type", + sourceId = sourceId, + name = "com/example/Type", + shortName = "Type", + packageName = "com.example", + kind = JvmSymbolKind.CLASS, + language = JvmSourceLanguage.KOTLIN, + data = JvmClassInfo(), + ) + + private fun fileMeta(path: String) = KtFileMetadata( + filePath = path, + packageFqName = "com.example", + lastModified = Instant.EPOCH, + modificationStamp = 1L, + isIndexed = true, + symbolKeys = listOf("$path#Type"), + ) + + private fun makeSymbolIndex( + backing: Index = InMemoryIndex(JvmSymbolDescriptor), + ): Pair, JvmSymbolIndex> { + val counting = CountingIndex(backing) + val index = object : JvmSymbolIndex(counting, BackgroundIndexer(counting)) { + override fun isActive(sourceId: String): Boolean = true + } + return counting to index + } + + private fun makeFileIndex() = + KtFileMetadataIndex(InMemoryIndex(KtFileMetadataDescriptor)) + + private fun removeCmd(path: String) = + IndexCommand.RemoveFromIndex(Paths.get(path)) + + @Test + fun `N consecutive removals collapse into ONE batched removeBySources`() = runTest { + val paths = (1..5).map { "/proj/File$it.kt" } + val (counting, symbolIndex) = makeSymbolIndex() + val fileIndex = makeFileIndex() + + // Seed: one symbol + one metadata record per file. + symbolIndex.insertAll(paths.asSequence().map { symbol(it) }) + for (p in paths) fileIndex.upsert(fileMeta(p)) + + // The queue holds N-1 further removals after the first, then runs dry. + val rest = ArrayDeque(paths.drop(1).map { removeCmd(it) as IndexCommand }) + val pushedBack = mutableListOf() + + applyRemovals( + first = removeCmd(paths.first()) as IndexCommand.RemoveFromIndex, + fileIndex = fileIndex, + sourceIndex = symbolIndex, + pollNext = { rest.removeFirstOrNull() }, + pushBack = { pushedBack.add(it) }, + ) + + // The fix: exactly one batched transaction, zero per-source deletes. + assertThat(counting.removeBySourcesBatches).isEqualTo(1) + assertThat(counting.removeBySourceCalls).isEqualTo(0) + assertThat(counting.removedInLargestBatch).containsExactlyElementsIn(paths) + assertThat(pushedBack).isEmpty() + + // Correctness: every symbol and metadata record is gone. + for (p in paths) { + symbolIndex.activateSource(p) + assertThat(symbolIndex.findByKey("$p#Type")).isNull() + assertThat(fileIndex.get(p)).isNull() + } + } + + @Test + fun `a non-removal command stops the batch and is pushed back unconsumed`() = runTest { + val rmPaths = listOf("/proj/A.kt", "/proj/B.kt") + val (counting, symbolIndex) = makeSymbolIndex() + val fileIndex = makeFileIndex() + symbolIndex.insertAll(rmPaths.asSequence().map { symbol(it) }) + for (p in rmPaths) fileIndex.upsert(fileMeta(p)) + + val interloper: IndexCommand = IndexCommand.IndexingComplete + val queue = ArrayDeque( + listOf(removeCmd(rmPaths[1]) as IndexCommand, interloper) + ) + val pushedBack = mutableListOf() + + applyRemovals( + first = removeCmd(rmPaths[0]) as IndexCommand.RemoveFromIndex, + fileIndex = fileIndex, + sourceIndex = symbolIndex, + pollNext = { queue.removeFirstOrNull() }, + pushBack = { pushedBack.add(it) }, + ) + + // Both removals batched together; the non-removal command preserved. + assertThat(counting.removeBySourcesBatches).isEqualTo(1) + assertThat(counting.removeBySourceCalls).isEqualTo(0) + assertThat(counting.removedInLargestBatch).containsExactlyElementsIn(rmPaths) + assertThat(pushedBack).containsExactly(interloper) + } + + @Test + fun `InMemory and SQLite-style backings give identical removeBySources results (parity)`() = runTest { + // Parity at the primitive level: removeBySources must equal N removeBySource. + val paths = listOf("/p/X.kt", "/p/Y.kt", "/p/Z.kt") + + val batched = InMemoryIndex(JvmSymbolDescriptor) + val oneByOne = InMemoryIndex(JvmSymbolDescriptor) + for (p in paths) { + batched.insert(symbol(p)) + oneByOne.insert(symbol(p)) + } + + batched.removeBySources(paths) + for (p in paths) oneByOne.removeBySource(p) + + val left = batched.query(IndexQuery.ALL).toList() + val right = oneByOne.query(IndexQuery.ALL).toList() + assertThat(left).isEmpty() + assertThat(left).containsExactlyElementsIn(right) + } + + @Test + fun `removeBySources only deletes the named sources`() = runTest { + val backing = InMemoryIndex(JvmSymbolDescriptor) + backing.insert(symbol("/p/Keep.kt")) + backing.insert(symbol("/p/Drop1.kt")) + backing.insert(symbol("/p/Drop2.kt")) + + backing.removeBySources(listOf("/p/Drop1.kt", "/p/Drop2.kt")) + + val remaining = backing.query(IndexQuery.ALL).map { it.sourceId }.toList() + assertThat(remaining).containsExactly("/p/Keep.kt") + } +} From 5959be08ffbf93427473240d3aaf947ef7518779 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 19 Jun 2026 04:49:08 -0700 Subject: [PATCH 3/3] ADFA-4332: add KDoc for docstring coverage (CodeRabbit) Co-Authored-By: Claude Opus 4.8 --- .../codeonthego/indexing/InMemoryIndex.kt | 11 +++++++++++ .../appdevforall/codeonthego/indexing/SQLiteIndex.kt | 9 +++++++++ .../compiler/index/IndexWorkerBatchRemovalTest.kt | 4 ++++ 3 files changed, 24 insertions(+) diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt index e9d29829da..a5d4dce21c 100644 --- a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt @@ -84,12 +84,23 @@ class InMemoryIndex( removeBySourceLocked(sourceId) } + /** + * Remove every entry belonging to any of [sourceIds]. + * + * Acquires the write lock once and removes each source under it, so the whole + * batch is atomic with respect to concurrent readers and writers — there is no + * intermediate state in which only some of the sources have been removed. + */ override suspend fun removeBySources(sourceIds: Collection) = lock.write { for (sourceId in sourceIds) { removeBySourceLocked(sourceId) } } + /** + * Remove all entries for [sourceId] from the primary, source, and secondary + * indexes. Caller MUST already hold the write lock; this method does not lock. + */ private fun removeBySourceLocked(sourceId: String) { val keys = sourceMap.remove(sourceId) ?: return for (key in keys) { diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt index 57d77eb4c6..e22a19f9ee 100644 --- a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt @@ -196,6 +196,15 @@ class SQLiteIndex( ifOpen { db.execSQL("DELETE FROM $tableName WHERE _source_id = ?", arrayOf(sourceId)) } } + /** + * Remove every row whose `_source_id` is in [sourceIds] using a single SQLite + * transaction. The ids are split into chunks of at most [DELETE_CHUNK_SIZE] so + * each `DELETE ... IN (?, ?, ...)` stays within SQLite's bound-parameter limit; + * all chunks run inside the one transaction, so the batch commits atomically + * (an empty [sourceIds] is a no-op and opens no transaction). + * + * @param sourceIds Source ids whose rows should be deleted. + */ override suspend fun removeBySources(sourceIds: Collection) = withContext(Dispatchers.IO) { if (sourceIds.isEmpty()) return@withContext diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorkerBatchRemovalTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorkerBatchRemovalTest.kt index 892bd8834d..57bd2f8230 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorkerBatchRemovalTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorkerBatchRemovalTest.kt @@ -96,6 +96,7 @@ class IndexWorkerBatchRemovalTest { private fun removeCmd(path: String) = IndexCommand.RemoveFromIndex(Paths.get(path)) + /** A run of consecutive removals issues exactly one batched call and deletes every record. */ @Test fun `N consecutive removals collapse into ONE batched removeBySources`() = runTest { val paths = (1..5).map { "/proj/File$it.kt" } @@ -132,6 +133,7 @@ class IndexWorkerBatchRemovalTest { } } + /** A non-removal command ends the batch and is pushed back unconsumed, preserving order. */ @Test fun `a non-removal command stops the batch and is pushed back unconsumed`() = runTest { val rmPaths = listOf("/proj/A.kt", "/proj/B.kt") @@ -161,6 +163,7 @@ class IndexWorkerBatchRemovalTest { assertThat(pushedBack).containsExactly(interloper) } + /** Batched removeBySources yields the same result as N individual removeBySource calls. */ @Test fun `InMemory and SQLite-style backings give identical removeBySources results (parity)`() = runTest { // Parity at the primitive level: removeBySources must equal N removeBySource. @@ -182,6 +185,7 @@ class IndexWorkerBatchRemovalTest { assertThat(left).containsExactlyElementsIn(right) } + /** removeBySources deletes only the named sources and leaves unnamed ones intact. */ @Test fun `removeBySources only deletes the named sources`() = runTest { val backing = InMemoryIndex(JvmSymbolDescriptor)