Skip to content

Commit 88c94e6

Browse files
chris nelsonclaude
authored andcommitted
fix(indexing): yield the event loop during a synchronous custom-index backfill
A custom index (HNSW vector index) indexes synchronously: in runIndexing's per-row loop, index.customIndex.index() runs inline and returns void, so it never assigns lastResolution and never raises `outstanding`. The existing yield is gated on `outstanding > MIN_OUTSTANDING_INDEXING`, so for a custom-index backfill it never fires — the entire backfill over the populated rows runs in a single event-loop turn, freezing the worker's main thread for the whole build (starving replication keepalive, the operations API, and schema signalling, and never letting isIndexing be observed; vector search returns 503 the entire time). Track whether a row performed synchronous custom-index work and yield once per such row when the outstanding-based yields don't apply. Validated on a live 5.1.0-beta.1 instance with a 100ms main-thread heartbeat over an identical 2,000-row int8 HNSW backfill (clean A/B, same box/data): before: max event-loop stall 71,030 ms (frozen for the entire 71.2s build) after: max event-loop stall 166 ms; build 67.5s (no measurable throughput cost) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a65f072 commit 88c94e6

1 file changed

Lines changed: 8 additions & 1 deletion

File tree

resources/databases.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1334,6 +1334,10 @@ async function runIndexing(Table, attributes, indicesToRemove) {
13341334
// TODO: Do we ever need to interrupt due to a schema change that was not a restart?
13351335
//if (Table.schemaVersion !== schemaVersion) return; // break out if there are any schema changes and let someone else pick it up
13361336
outstanding++;
1337+
// Custom indexes (e.g. HNSW) index synchronously and never raise `outstanding`, so the
1338+
// outstanding-based yield below never fires for them. Track that this row did synchronous
1339+
// indexing work so we can still yield the event loop after it.
1340+
let didSynchronousIndexing = false;
13371341
// every index operation needs to be guarded by the version still be the same. If it has already changed before
13381342
// we index, that's fine because indexing is idempotent, we can just put the same values again. If it changes
13391343
// during the indexing, the indexing here will fail. This is also fine because it means the other thread will have
@@ -1347,6 +1351,7 @@ async function runIndexing(Table, attributes, indicesToRemove) {
13471351
const value = record && (resolver ? resolver(record) : record[property]);
13481352
if (index.customIndex) {
13491353
index.customIndex.index(key, value);
1354+
didSynchronousIndexing = true;
13501355
continue;
13511356
}
13521357
const values = getIndexedValues(value, index.indexNulls);
@@ -1385,7 +1390,9 @@ async function runIndexing(Table, attributes, indicesToRemove) {
13851390
if (interrupted) return;
13861391
}
13871392
if (outstanding > MAX_OUTSTANDING_INDEXING) await lastResolution;
1388-
else if (outstanding > MIN_OUTSTANDING_INDEXING) await new Promise((resolve) => setImmediate(resolve)); // yield event turn, don't want to use all computation
1393+
else if (outstanding > MIN_OUTSTANDING_INDEXING)
1394+
await new Promise((resolve) => setImmediate(resolve)); // yield event turn, don't want to use all computation
1395+
else if (didSynchronousIndexing) await new Promise((resolve) => setImmediate(resolve)); // custom indexes (e.g. HNSW) index synchronously and never raise `outstanding`; without this yield a large backfill runs in a single event-loop turn, starving keepalive/replication and queries and never letting the isIndexing flag be observed
13891396
}
13901397
}
13911398
// Await the last pending put. If it rejects, that is also an indexing error.

0 commit comments

Comments
 (0)