Skip to content

Commit 459d021

Browse files
fryanpanclaude
andauthored
ADFA-4332: Batch JVM-symbol removals into one transaction (N+1) (#1411)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * ADFA-4332: add KDoc for docstring coverage (CodeRabbit) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ADFA-4332: add batch-removal API on KtFileMetadataIndex wrapper (review) itsaky asked (IndexWorker.kt:201) for KtFileMetadataIndex — a wrapper over an Index — to expose a batch-removal API mirroring the underlying Index, instead of IndexWorker looping per-file. The symbol side already batched via JvmSymbolIndex's `WritableIndex by backing` delegation; the file wrapper is hand-written, so it needed an explicit method. - KtFileMetadataIndex.removeAll(filePaths): delegates to backing.removeBySources — one transaction, paralleling the existing remove(filePath). Idiomatic remove/removeAll. - IndexWorker.applyRemovals: replace the per-file loop with fileIndex.removeAll(paths); now BOTH the symbol and metadata removals collapse into one transaction each (the N+1 this ticket targets). KDoc updated to drop the "no batch API" note. - Test: extend the CountingIndex probe to the file index and assert it also issues exactly one batched call with zero per-source deletes — mutation-sensitive, fails if the per-file loop is reintroduced. Verified: :lsp:jvm-symbol-index + :lsp:kotlin testV8DebugUnitTest — 134 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b755cfd commit 459d021

7 files changed

Lines changed: 369 additions & 4 deletions

File tree

‎lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,28 @@ class InMemoryIndex<T : Indexable>(
8181
override suspend fun insert(entry: T) = lock.write { insertSingleLocked(entry) }
8282

8383
override suspend fun removeBySource(sourceId: String) = lock.write {
84-
val keys = sourceMap.remove(sourceId) ?: return@write
84+
removeBySourceLocked(sourceId)
85+
}
86+
87+
/**
88+
* Remove every entry belonging to any of [sourceIds].
89+
*
90+
* Acquires the write lock once and removes each source under it, so the whole
91+
* batch is atomic with respect to concurrent readers and writers — there is no
92+
* intermediate state in which only some of the sources have been removed.
93+
*/
94+
override suspend fun removeBySources(sourceIds: Collection<String>) = lock.write {
95+
for (sourceId in sourceIds) {
96+
removeBySourceLocked(sourceId)
97+
}
98+
}
99+
100+
/**
101+
* Remove all entries for [sourceId] from the primary, source, and secondary
102+
* indexes. Caller MUST already hold the write lock; this method does not lock.
103+
*/
104+
private fun removeBySourceLocked(sourceId: String) {
105+
val keys = sourceMap.remove(sourceId) ?: return
85106
for (key in keys) {
86107
val entry = primaryMap.remove(key) ?: continue
87108
removeFromSecondaryIndexes(entry)

‎lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ class SQLiteIndex<T : Indexable>(
6565
) : Index<T> {
6666
companion object {
6767
private val log = LoggerFactory.getLogger(SQLiteIndex::class.java)
68+
69+
/**
70+
* Max number of `_source_id` placeholders per batched DELETE.
71+
* Kept well under SQLite's default 999 bound-parameter limit.
72+
*/
73+
private const val DELETE_CHUNK_SIZE = 900
6874
}
6975

7076

@@ -190,6 +196,35 @@ class SQLiteIndex<T : Indexable>(
190196
ifOpen { db.execSQL("DELETE FROM $tableName WHERE _source_id = ?", arrayOf(sourceId)) }
191197
}
192198

199+
/**
200+
* Remove every row whose `_source_id` is in [sourceIds] using a single SQLite
201+
* transaction. The ids are split into chunks of at most [DELETE_CHUNK_SIZE] so
202+
* each `DELETE ... IN (?, ?, ...)` stays within SQLite's bound-parameter limit;
203+
* all chunks run inside the one transaction, so the batch commits atomically
204+
* (an empty [sourceIds] is a no-op and opens no transaction).
205+
*
206+
* @param sourceIds Source ids whose rows should be deleted.
207+
*/
208+
override suspend fun removeBySources(sourceIds: Collection<String>) =
209+
withContext(Dispatchers.IO) {
210+
if (sourceIds.isEmpty()) return@withContext
211+
ifOpen {
212+
db.beginTransaction()
213+
try {
214+
for (chunk in sourceIds.chunked(DELETE_CHUNK_SIZE)) {
215+
val placeholders = chunk.joinToString(",") { "?" }
216+
db.execSQL(
217+
"DELETE FROM $tableName WHERE _source_id IN ($placeholders)",
218+
chunk.toTypedArray(),
219+
)
220+
}
221+
db.setTransactionSuccessful()
222+
} finally {
223+
db.endTransaction()
224+
}
225+
}
226+
}
227+
193228
override suspend fun clear() = withContext(Dispatchers.IO) {
194229
ifOpen { db.execSQL("DELETE FROM $tableName") }
195230
}

‎lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Index.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,16 @@ interface WritableIndex<T : Indexable> {
6868
*/
6969
suspend fun removeBySource(sourceId: String)
7070

71+
/**
72+
* Remove all entries from the given sources in a single transaction.
73+
*
74+
* Equivalent to calling [removeBySource] for each id, but issues the
75+
* deletes as one batched, transactional operation instead of N
76+
* sequential statements. Implementations should chunk the ids so the
77+
* generated SQL stays within parameter limits.
78+
*/
79+
suspend fun removeBySources(sourceIds: Collection<String>)
80+
7181
/**
7282
* Remove all entries.
7383
*/

‎lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndex.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ class KtFileMetadataIndex(
5252
*/
5353
suspend fun remove(filePath: String) = backing.removeBySource(filePath)
5454

55+
/**
56+
* Remove the metadata records for every path in [filePaths] as a single
57+
* batched, transactional operation.
58+
*
59+
* Equivalent to calling [remove] once per path, but issues the deletes as one
60+
* transaction instead of N — see [Index.removeBySources]. Paths not present in
61+
* the index are ignored.
62+
*/
63+
suspend fun removeAll(filePaths: Collection<String>) = backing.removeBySources(filePaths)
64+
5565
/**
5666
* Return the [KtFileMetadata] for [filePath], or `null` if the file is
5767
* not present in the index.

‎lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt‎

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,13 @@ internal class IndexWorker(
6666

6767
when (val cmd = queue.take()) {
6868
is IndexCommand.RemoveFromIndex -> {
69-
val filePath = cmd.path.pathString
70-
fileIndex.remove(filePath)
71-
sourceIndex.removeBySource(filePath)
69+
applyRemovals(
70+
first = cmd,
71+
fileIndex = fileIndex,
72+
sourceIndex = sourceIndex,
73+
pollNext = { queue.pollIndexQueue() },
74+
pushBack = { queue.pushBackIndexQueue(it) },
75+
)
7276
}
7377

7478
is IndexCommand.IndexSourceFile -> {
@@ -160,3 +164,50 @@ internal class IndexWorker(
160164
}
161165
}
162166
}
167+
168+
/**
169+
* Apply [first] plus any consecutive, immediately-available [IndexCommand.RemoveFromIndex]
170+
* commands as a single batched removal.
171+
*
172+
* The symbol removals and the per-file metadata removals are each collapsed into one
173+
* batched call — [JvmSymbolIndex.removeBySources] and [KtFileMetadataIndex.removeAll], a
174+
* single SQLite transaction apiece — instead of issuing one `DELETE` per file (one
175+
* transaction each), which is the N+1 this fix targets (Sentry APPDEVFORALL-SE).
176+
*
177+
* [pollNext] returns the next already-queued index command without blocking, or `null`
178+
* when none is ready. A polled command that is *not* a removal is handed to [pushBack] so
179+
* it is processed (in order) on the next loop iteration rather than dropped.
180+
*
181+
* @param first The removal command that triggered this batch.
182+
* @param fileIndex Per-file metadata index; removed via the batched [KtFileMetadataIndex.removeAll].
183+
* @param sourceIndex Symbol index; removed via the batched [JvmSymbolIndex.removeBySources].
184+
* @param pollNext Non-blocking poll of the next queued index command.
185+
* @param pushBack Returns a non-removal command to the front of the queue.
186+
*/
187+
internal suspend fun applyRemovals(
188+
first: IndexCommand.RemoveFromIndex,
189+
fileIndex: KtFileMetadataIndex,
190+
sourceIndex: JvmSymbolIndex,
191+
pollNext: () -> IndexCommand?,
192+
pushBack: (IndexCommand) -> Unit,
193+
) {
194+
val paths = ArrayList<String>()
195+
paths.add(first.path.pathString)
196+
197+
while (true) {
198+
val next = pollNext() ?: break
199+
if (next is IndexCommand.RemoveFromIndex) {
200+
paths.add(next.path.pathString)
201+
} else {
202+
// Not batchable — return it so the main loop handles it next, in order.
203+
pushBack(next)
204+
break
205+
}
206+
}
207+
208+
// Collapse all per-file metadata removals into a single transaction.
209+
fileIndex.removeAll(paths)
210+
211+
// Collapse all symbol removals into a single transaction.
212+
sourceIndex.removeBySources(paths)
213+
}

‎lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/WorkerQueue.kt‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,39 @@ internal class WorkerQueue<T> {
99
private val editChannel = Channel<T>(capacity = 20)
1010
private val indexChannel = Channel<T>(capacity = 100)
1111

12+
// Single-slot pushback for an index-queue item that was polled (to coalesce
13+
// removals) but turned out not to be batchable. It is returned ahead of the
14+
// channels by the next [take], preserving command order.
15+
private var pushedBack: T? = null
16+
1217
suspend fun putScanQueue(item: T) = scanChannel.send(item)
1318
suspend fun putEditQueue(item: T) = editChannel.send(item)
1419
suspend fun putIndexQueue(item: T) = indexChannel.send(item)
1520

21+
/**
22+
* Non-blocking poll of the index queue. Returns the next already-available
23+
* index-queue item, or `null` if none is immediately ready.
24+
*
25+
* Used to coalesce a run of consecutive removal commands into a single
26+
* batched index operation (see [IndexWorker]) instead of issuing one
27+
* transaction per command. A polled item that is not batchable must be
28+
* returned via [pushBackIndexQueue] so it is not dropped.
29+
*/
30+
fun pollIndexQueue(): T? = indexChannel.tryReceive().getOrNull()
31+
32+
/**
33+
* Return an item previously obtained from [pollIndexQueue] to the front of
34+
* the queue so the next [take] yields it before any channel item. At most
35+
* one item may be pushed back at a time.
36+
*/
37+
fun pushBackIndexQueue(item: T) {
38+
check(pushedBack == null) { "pushBack slot already occupied" }
39+
pushedBack = item
40+
}
41+
1642
suspend fun take(): T {
43+
pushedBack?.let { pushedBack = null; return it }
44+
1745
scanChannel.tryReceive().getOrNull()?.let { return it }
1846
editChannel.tryReceive().getOrNull()?.let { return it }
1947
indexChannel.tryReceive().getOrNull()?.let { return it }

0 commit comments

Comments
 (0)