fix(quartz): clamp non-positive FTS reindex batch size

A batchSize <= 0 made the SQLite resumable reindex select no rows yet
never report done, so a caller's loop would spin forever. Clamp the page
size to at least one in both stores and add a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys
This commit is contained in:
Claude
2026-06-18 22:32:51 +00:00
parent b419db4fe0
commit af1e34a34d
3 changed files with 28 additions and 3 deletions
@@ -199,6 +199,9 @@ class FullTextSearchModule : IModule {
afterRowId: Long,
batchSize: Int,
): FtsReindexProgress {
// A non-positive page would select no rows yet never report done,
// spinning the caller's loop forever — clamp to at least one.
val limit = batchSize.coerceAtLeast(1)
val kinds = searchableKindsPresent(db)
if (kinds.isEmpty()) return FtsReindexProgress(cursor = null, processedThisBatch = 0, done = true)
@@ -213,7 +216,7 @@ class FullTextSearchModule : IModule {
db.prepare(insertFTS).use { write ->
db.prepare(selectSql).use { read ->
read.bindLong(1, afterRowId)
read.bindLong(2, batchSize.toLong())
read.bindLong(2, limit.toLong())
while (read.step()) {
val rowId = read.getLong(0)
// Clear any existing row for this event first so a
@@ -246,7 +249,7 @@ class FullTextSearchModule : IModule {
}
// Fewer than a full page came back ⇒ we hit the end of the table.
val done = processed < batchSize
val done = processed < limit
return FtsReindexProgress(
cursor = if (done) null else last.toString(),
processedThisBatch = processed,
@@ -299,6 +299,27 @@ class SearchTest : BaseDBTest() {
db.store.assertQuery(b, Filter(search = "uniqbeta"))
}
@Test
fun testResumableReindexClampsNonPositiveBatchSize() =
forEachDB { db ->
val a = signer.sign(TextNoteEvent.build("uniqclamp note", createdAt = TimeUtils.now()))
db.store.insertEvent(a)
db.store.pool.useWriter { db.store.fullTextSearchModule.deleteAll(it) }
// batchSize 0 must not spin forever: it is clamped to one event
// per call. Cap the loop so a regression fails fast instead of
// hanging the suite.
var cursor: String? = null
var guard = 0
do {
val progress = db.store.reindexFullTextSearch(cursor, batchSize = 0)
cursor = progress.cursor
check(guard++ < 100) { "reindex did not terminate with batchSize=0" }
} while (!progress.done)
db.store.assertQuery(a, Filter(search = "uniqclamp"))
}
@Test
fun testChannelJsonFieldsAreSearchable() =
forEachDB { db ->
@@ -554,6 +554,7 @@ open class FsEventStore(
Files.createDirectories(layout.idxFts)
val resumeKind = resumeFrom?.toIntOrNull()
val target = batchSize.coerceAtLeast(1)
val pending =
Files
.list(layout.idxKind)
@@ -575,7 +576,7 @@ open class FsEventStore(
}
}
index++
if (processed >= batchSize) break
if (processed >= target) break
}
val done = index >= pending.size