Repository navigation
Conversation
… fails A failed startup lexical or semantic backfill only logged a warning. The index then stayed behind until the next ingest or restart marked the scope dirty, so an idle source could stay unsearchable indefinitely. On failure, startup now marks every extant stream of the affected connector instances dirty in search_index_dirty, in one transaction per instance and under the instance deletion fence. The existing reconcile loop retries them with its backoff. A scope that is already dirty or parked keeps its attempt count and backoff; a clean scope starts fresh. Tombstoned instances and instances owned by another connector get no ticket. One instance that cannot be queued no longer blocks its siblings. Also move the semantic "rebuild completed" log after the final transaction commits, and reword the non-participating-stream cleanup log so it no longer claims stale data was dropped. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…tic backend is unavailable A failed startup backfill could still leave no durable ticket: if the queue write itself threw (for example a transient read or write fault while finding the scopes to mark), the error was only logged. Manifests whose queue write failed now get one more attempt after the startup loop. The list is local to the run, so no new state is stored. When a semantic backend is configured but not available at startup, the semantic pass was skipped and startup logged "completed" with no ticket. It now queues a ticket for connectors that declare semantic fields. With no backend configured, or no semantic fields declared, nothing is queued. The tests run the real startup over retained state on SQLite and PostgreSQL. Both new behavior tests fail without the change. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
When a startup ticket write failed for one instance, the AggregateError carried the raw error only, so the log could not say which instance was never queued. Each failure is now wrapped with its connector instance id and keeps the original error as its cause. Also document why the orphan-instance lookup does not read `records`: the semantic resolver already lists every instance with records for a manifest stream. A test pins that, and another pins that startup continues when the ticket write keeps failing. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…tained semantic state Review of the startup retry found four gaps. - A ticket write that failed twice was dropped, so idle sources stayed unqueued until the next restart. Failed manifests now stay in the startup task and are retried with capped exponential backoff (250 ms to 30 s) until a mark commits or shutdown aborts the sleep. - On PostgreSQL the mark cast every retained semantic scope key to jsonb. One malformed key failed the whole mark and left the instance without a ticket. Keys are now decoded in code; a key that names no stream is skipped and left for the repair that fails closed on it. - A manifest that drops its last semantic field skipped the ticket when the backend was unavailable, though retained semantic state still owed removed-field cleanup. Retained meta, progress or vector rows now count like declared fields. - Instance discovery for a ticket no longer calls the lexical and semantic backfill resolvers. One store query lists every instance with records or index state, so those two resolvers are private again. The tests assert outcomes (a ticket exists), not which lookup found it. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
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.
What
When a startup lexical or semantic search-index backfill fails for a connector, startup now queues a durable retry instead of only logging. For each affected connector instance it marks every extant stream dirty in
search_index_dirty; the existing reconcile loop (maintenance sweep and read-time self-heal) retries with its backoff. Before this, an idle source stayed behind until the next ingest or restart.Two more paths leave a ticket, so no failed or skipped startup pass is left unrecorded:
semantic_fieldsor still holds semantic index state (meta, backfill progress or vectors), the semantic pass is skipped and a ticket is queued. Retained state counts because removed-field cleanup also needs the backend. With no backend configured, or neither fields nor retained state, nothing is queued, so a deployment without a backend does not dirty every connector on every boot.Why
Root cause: the startup backfill is the only repair path that runs without a prior write, and its failure left no persisted state. The dirty table is the existing durable retry ledger; startup failure just never wrote to it. No new table or column is added. The change is 4 small SQL files, one 54-line module (
server/startup-search-index-retry.ts), three store functions, and one retry loop in the startup task (the backoff above). Existing databases need no migration, and old and new binaries share the samesearch_index_dirtyrows.State model
One row per
(connector_instance_id, stream)insearch_index_dirty. Existing states, unchanged:dirty=0dirty=1,next_attempt_atnull or pastdirty=1,attempts>0,next_attempt_atin the futureoperator_parked=1(operator lever;rearmreturns it to eligible)Transitions:
markStartupSearchIndexFailure: clean becomes dirty/eligible withattempts=0; dirty or parked keepsattempts,next_attempt_at,marked_at,operator_parked;revision+1in all cases.revision.attempts+1and backoff, compare-and-set onrevision.Crash analysis:
writeTransaction) under the instance deletion fence, so each instance is fully marked or not marked. The next startup repeats the backfill.revision, so the reconcile's clear is a no-op and the scope is retried.scopesInFlightinsearch-index-reconcile.tsallows one per scope per process; the backfills take the per-instance fences.assertCurrentStorageGenerationbefore and inside each transaction; the work is dropped.Both additions reuse existing transitions. One in-process state is added: queue-pending, a manifest whose ticket write failed. It moves to done (a mark commits) or is dropped on shutdown. A crash while queue-pending loses it, and the next startup repeats the backfill, which fails or succeeds again from the same persisted state. A backend that is unavailable at startup gets the startup-failure transition; reconcile keeps the scope dirty until a backend can prove the semantic half.
Discovery: a ticket is written for every instance that holds records or search-index state for the connector (one store query), or for the pinned instance when the manifest names one. An instance with neither has nothing to repair.
PostgreSQL: the mark reads retained semantic scope keys as text and decodes them in code. A key that is not a JSON array with a string stream names no stream and is skipped, so one malformed key cannot stop the ticket; the repair still fails closed on it.
Defect found and fixed here: the loop over instances stopped at the first instance whose mark threw, so siblings got no ticket until the next restart. It now attempts every instance and throws an
AggregateErrorafterwards, one error per failed instance naming itsconnectorInstanceIdwith the original error ascause(new test, below). The startup caller logs that error and continues; a ticket write that keeps failing never fails startup (test below).Other changes
search-semantic.ts: the "rebuild completed" log now follows the final transaction instead of preceding it, and the non-participating-stream log says "checking cleanup" because it can run when nothing is dropped.startServeroptiontestOnlyAlreadyAdmittedPostgresChildAttachment: test-only pass-through to the existinginitPostgresStorageoption so a restart test can reopen a populated test database. It is inert in production:initPostgresStoragereads it only when the test-database guard is active (PDPP_TEST_POSTGRES_URLorPDPP_REQUIRE_TEST_DATABASE=1), the capability is single-use and minted inside the target database, and no environment variable or CLI flag maps to it. No smaller shape exists: the restart reopens a database that already holds owner data, which admission refuses without the capability. Eight existing test fixtures already pass the same capability toinitPostgresStorage.closeHttpServeris exported from the test helper.postgres-template-eligibility.tsandrun-tests-resource-policy.test.ts: the new test is listed as cold-required (count 37 to 38) because it starts the real server over retained state.server/retrieval-startup-backfill.tshas no importer; the live path is the copy inserver/index.ts.Tests
cd reference-implementation && node --import tsx --test test/startup-backfill-durable-retry.test.ts scripts/run-tests-resource-policy.test.ts: 21 pass, 0 fail (memory/SQLite).npm run typecheckpasses.PostgreSQL 18 with pgvector, private cluster via
scripts/test-sandboxed.sh --pg-env ... scripts/test-postgres-private.sh, one file per run:startup-backfill-durable-retry.test.ts11 pass, 0 fail;semantic-startup-backfill-catches-crash-abandoned-record.test.ts70 pass, 0 fail. (Run together in one process, the files share the cluster and fail; run them one at a time.)The new tests fail without the fix (checked on memory and on PostgreSQL):
startServerwith an embedder that times out: with the queue call removed, no ticket exists; with it, one maintenance round converges with no ingest.semantic_fields: without the new branch, no ticket exists.not json,null,{"a":1},[1]) on PostgreSQL: without in-code decoding, the mark fails and no ticket exists (red on PostgreSQL; SQLite guards the JSON in SQL).semantic_fieldsand no retained state, queue nothing.Also covered: history preserved on dirty/parked scopes, clean scopes reset, tombstoned scope not resurrected, stale generation rejected while another writer holds the fence. The external embedder is a stub; no real service is touched.
Not run
scripts/quality-ratchet/check-mass-ratchet.tsneeds the workspace Biome binary, which this checkout does not have.npm run typecheckpasses. Repo lint reports format differences in files this PR edits (server/index.tsand others) that predate it.Open risks
startupBackfillDonestays pending (the retry is its tail). Only theawaitStartupBackfilloption waits on it, and only a test fixture sets that; the listeners and the periodic sweep do not wait on it.Assisted-by: AI