From af1e34a34ddf8447895dc7cc0a2096673a0907f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 22:32:51 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys --- .../store/sqlite/FullTextSearchModule.kt | 7 +++++-- .../nip01Core/store/sqlite/SearchTest.kt | 21 +++++++++++++++++++ .../quartz/nip01Core/store/fs/FsEventStore.kt | 3 ++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt index 3de6859e4b..71f55b1096 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt @@ -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, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt index bdf7ae690a..615e6fc47e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -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 -> diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt index 0313eebda5..9062867baf 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt @@ -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