Repository navigation
Answer the first full-text search on an idle index from a worker that has not opened its reader - #4281
Merged
Conversation
…reader Since #4248 an idle derived index stays ownerless while its logs do not move, so nothing refreshes the shared coverage clock once the idle owner releases. readDerivedIndexCoverage then needed the reader slot's cursor, which a worker that has never searched the index lacks, so that worker's first no-wait full-text search (default or strict, scoring or not) threw DERIVED_INDEX_LAGGING although the index was current, and its first waiting search requested coverage and minted a new owner epoch. readDerivedIndexCoverage now also accepts the idle release's quiet mark, through the predicate #quietSinceRelease uses (quietMarkHolds): no epoch minted since the release, ready with no rebuild or lag demand, the same table and token, and the same log mark, with the epoch re-checked after the position read. The full-text query index opts in; HNSW reads its cursor from its index store and is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-fulltext-idle-coverage-503
… constraint comments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-fulltext-idle-coverage-503
…nd purge in the release note Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-fulltext-idle-coverage-503
…token in its own suite Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-fulltext-idle-coverage-503
…'s idle-mark coverage case Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-fulltext-idle-coverage-503
kriszyp
force-pushed
the
fix/fulltext-idle-cold-worker-coverage
branch
from
November 11, 2026 20:44
fc7d79f to
066c3ac
Compare
…duled
Empty commit. GitHub left this head's workflow runs queued with no jobs
and refused to cancel them ("has not been queued yet"), while runs created
later scheduled normally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xoBAAPr17fhni6BzKLqDD
Dispatch-Task: pr-maint-fbf095b75b3b76d2cbf87e6f5cca9293
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
Since #4248, an index whose database gets no writes for about 30 seconds keeps no owner, so nothing refreshes its coverage clock. A worker that has never searched it has no reader cursor either, so its first no-wait full-text search fails with
503 DERIVED_INDEX_LAGGINGalthough the index is current (QA: 33 of 72 first searches). Its first waiting search instead takes ownership, which reopens every worker's reader.💡 Solution
When the idle owner releases, it already publishes a mark of the log positions it covered, and the runtime uses that mark to leave the index ownerless. Coverage now accepts the same mark through one shared predicate,
quietMarkHolds: a ready index with no new owner, whose logs still match the mark, iscurrent. The full-text query index opts in.⚖️ Alternatives
🤔 Open the reader before checking coverage
This was the brief's first suggestion. Coverage would then be known only after an await, but
searchByIndexreadsindexCoveragesynchronously for theHarper-Index-Coverageheader. A cold waiting search would also still take ownership.🤔 The quiet period is database-wide
The mark sums every log in the database. A write to any table, or a log rotation or purge, ends it, and a cold search can return
503until an owner certifies again. Per-table tracking could narrow that later.🤔 Only full-text opts in
HNSW reads its cursor from its own index store, and that cursor's coverage matches the mark at release, so its path is unchanged.
Other options
getDurableCursor(): a synchronous filesystem inspection on the query path, which the design note rules out.🔧 Changes
resources/derivedIndexRuntime.ts:quietMarkHoldsis the predicate#quietSinceReleaseused, now shared.readDerivedIndexCoveragetakes an optionalquietidentity and certifies through the mark when the clock and cursor cannot. It re-reads the owner epoch after reading positions.resources/indexes/fullTextQueryIndex.ts: the opt-in is built once per index and passed to every coverage read.resources/indexes/fullTextDerivedIndex.ts:FULL_TEXT_QUIET_TOKENnames the backend's constantquietToken().resources/indexes/DESIGN.md: the cold-worker sentence namesquietMarkHolds; a measurement sentence goes so the note fits its base-aware budget.503, with the write, rotation and purge cases that still can.✅ Verification
derivedIndexIdleEpoch.test.js(real RocksDB store, two runtimes, real full-text backend over an in-memory native lifecycle): first plain and scoring default searches after the clock ages past 3 s return the record withcurrent; strict and waiting cold searches take no ownership; a held write still returnsDERIVED_INDEX_LAGGING; negative cases cover another table or token, raised demands, an uncommitted tail, a new log and an epoch minted mid-read. Fails on base:DerivedIndexLagError … last certified 3214 ms ago.derivedIndexRuntime.test.js: the runtime contract with a storage-bound token. Fails on base.full-text-idle-coverage.test.tswith its fixture (schema, config, runner-lock probe): two HTTP workers, no search before the idle period, then the first plain and scoringQUERYreturn 200 withcurrent; lag=0; tolerance=3000and no new owner. Fails on base: the stream aborts withDerivedIndexLagError … last certified 5214 ms ago.Full gates
test:unit:resources:core:a-n3211 passing, 0 failing; the derived-index and full-text unit files 385 passing;full-text-idle-coverage,full-text-searchandderived-index-idle-admission20 pass, 1 skip.test:unit:resources6772 passing, 110 failing, all main's (Revert rocksdb-js to 2.13.0: the 2.14.0 bump fails unit tests on every leg #4223 cascade,recordLock,rocksOpenConfig), fixed on main by Fix plain-to-HNSW index changes and main's red unit tests under rocksdb-js 2.14 #4260.test:unit:mainwedged in the components process-group tests after 1093 tests on this machine, so it was run by directory instead:test:unit:server2401 passing, 3 failing (no 127.0.0.2 alias, install sandbox, process-group harness);test:unit:dataLayer565 passing, 0 failing.full-text-idle-coverage,full-text-search,full-text-search-docs,derived-index-idle-anchor,derived-index-idle-admissionandnative-plane-coverage: 32 pass, 1 skip.test:integration:allwas not run: this machine has no loopback aliases.typecheckclean.check:design-docs --base origin/mainpasses for the indexes note.Regression from #4248. Related: #4231, #4243, #4244.
🤖 Generated by Claude Opus 5.5 (Claude Code dev agent); posted via @kriszyp.
🤖 Generated with Claude Code
Release target: v5.4 — fixes code from #4248, which ships in v5.4
Related PRs: #3846 overlaps (rewrites the same full-text query-plane paragraph of resources/indexes/DESIGN.md), #4248 overlaps (this fixes its cold-worker regression)
Complexity: complicated
Origin — the dispatch brief this PR was written from
Regression from harper PR #4248 (merged 2026-11-11, merge commit 240ac63): after a derived full-text index has been idle long enough for its owner to release (about 30 s with no writes), each cold worker's first default full-text search returns 503 DERIVED_INDEX_LAGGING even though the index is current. Mechanism on origin/main: resources/indexes/fullTextQueryIndex.ts defaults waitForIndexMilliseconds to 0 (~line 251), and on the no-wait path runs
coverage = this.#queryCoverage(maxIndexLagMilliseconds)(~line 328) before #search opens a reader. readDerivedIndexCoverage (derivedIndexRuntime.ts ~2754) returnsunknownwhen the shared coverage clock is stale and the worker has no #readerSlot cursor; #quietSinceRelease() (~1134, used ~1101) keeps an idle index from taking a new owner, so nothing refreshes the clock. #queryCoverage then throws and calls #refreshReader(true), which is why the worker's second search succeeds. Fix so a current idle index answers the first search: acquire/refresh the reader before the coverage check on the no-wait path (reuse the reader-acquisition budget added in PR #4231), or otherwise let a worker without a reader cursor establish coverage for a quiet index instead of reporting unknown. Locate code by symbol; line numbers are approximate.Acceptance
A regression test (unit or integration) that fails on current main: index is current, no writes for longer than the owner-release quiet period, a worker with no reader slot runs a default (waitForIndex 0) full-text search and gets results, not 503; scoring and non-scoring paths both covered. Existing #4248 and #4231 tests stay green. A genuinely lagging index still returns DERIVED_INDEX_LAGGING. Update the relevant DESIGN.md section and add a release note if behavior text changes.
Dispatch: task
harper-fulltext-idle-coverage-503· queued by finding-triage/wave-52 · ran by claude/opus/xhigh · worker kris-mac-m3-fleet-18b9c6Ready-For-Promotion: db54a31 urgency=normal late=folded
Review-Coverage: authored=claude; ran=cursor-composer,codex,cursor-grok,gemini; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=6; full=3 @ db54a31
Review-Attention: deep ~40m (critical: derivedIndexRuntime.ts, fullTextDerivedIndex.ts +1; decisions: query-side-certification-source, constant-quiet-token-on-reader, database-wide-quiet-scope; raised: degraded review) @ db54a31