fix: bulk-submit(sqlite): import aborts with 'database is locked' at high file concurrency with the full search-parameter registry - #946
Conversation
At HFS_BULK_SUBMIT_FILE_CONCURRENCY=8 on SQLite with the full search-parameter registry, fanned-out ingest batches queue behind the single write lock long enough to outlast busy_timeout, and the manifest bookkeeping UPDATE aborts the whole import with "database is locked". Two combinable guards: - Clamp the effective file concurrency to SQLITE_MAX_FILE_CONCURRENCY (2) when the primary backend is SQLite, with a startup log stating configured vs effective; concurrent-writer backends keep the configured value. - Retry the SQLite bookkeeping writes (manifest counts, submission updated_at, progress, bytes) on SQLITE_BUSY/LOCKED with bounded backoff instead of aborting the manifest: a busy attempt never acquired the write lock so reissuing is safe, and the lease's worker_id/fencing_token still fences stale writers. Busy now classifies via classify_sqlite_error (Unavailable/503) instead of collapsing to Internal. Closes #942 Co-Authored-By: Claude <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
angela-helios
left a comment
There was a problem hiding this comment.
Reviewed the full diff and ran the suite locally on the branch: the 49 sqlite bulk_submit tests (including the three new retry tests) and the clamp tests in config.rs all pass.
The design looks right to me, and it matches Steve's point about SQLite being a single-user engine: rather than fighting the single-writer model, the clamp respects it, and the retry only hardens the one bookkeeping write that today aborts a whole manifest over a transient lock. The two care points from the issue are handled — a busy UPDATE in autocommit applied nothing so reissuing the increment is safe, and the affected == 0 fencing check still runs after the retry, so a stale lease still loses.
Two minor notes, neither blocking:
- The per-file status writes (
SubmitFileRecordupdates) are not wrapped inretry_bookkeeping_on_busy. With the clamp at 2 the exposure drops a lot, but if your extra testing shows that site failing under contention, it deserves the same treatment. - Once #944 lands (partial indexes shorten the write-lock holds ~40% measured), it may be worth re-evaluating whether
SQLITE_MAX_FILE_CONCURRENCYcan go to 3–4. Not for this PR — just leaving the breadcrumb.
LGTM once you finish your testing and mark it ready.
The bulk-submit smoke flow only existed inline in the CI workflow, so there was no reusable way to reproduce the SQLite fan-out behaviour by hand. These three scripts stand up a throwaway HFS plus a static Data Provider on free ports above 18000, all under `timeout`, so nothing has to be killed afterwards. They live under crates/hfs/tests/bulk_submit/ to match the existing per-area harnesses (audit, bulk_export, cluster, subscriptions). Unlike those, they start their own server rather than driving an external one, hence `_check` instead of `run_external_*_smoke`. Free ports are found by attempting a real bind instead of parsing netstat: netstat prints LISTEN on Linux and LISTENING on Windows, and is frequently absent altogether, so a pattern miss would silently report every port as free and only fail later at startup. The SQLite database is a file rather than `:memory:`. An in-memory database cannot honour `PRAGMA journal_mode = WAL`, and without WAL the contention surfaces as table-level SQLITE_LOCKED, which does not represent a real deployment. Co-Authored-By: Claude <noreply@anthropic.com>
6b3dcde to
92508f5
Compare
There was a problem hiding this comment.
Request changes
The retry is the right idea and CI is green, but the SQLite clamp rests on a justification that the code contradicts, and the operator-facing documentation for this knob doesn't exist. Details inline; the three headline items:
1. The clamp should be 1, not 2
The 2 traces to the parenthetical in #942 ("clamp the effective file concurrency (e.g. to 2)") and was hardcoded as SQLITE_MAX_FILE_CONCURRENCY with this justification:
"Two still overlaps fetch and extraction - the part that does parallelise"
That isn't what the code does. record_file_results opens one BEGIN IMMEDIATE transaction per batch and runs ingest_entry_in_txn - including search-index extraction - inside it (crates/persistence/src/backends/sqlite/bulk_submit.rs:699-745). Extraction is under the exclusive write lock, which is the whole premise of #942 ("holds SQLite's exclusive write lock for its whole extraction + ~25-INSERT span") and of the hoist-extraction prototype. Fan-out 2 overlaps the HTTP fetch and NDJSON parse only.
Nor is 2 measured anywhere in this PR. run_bulk_submit_volume_check.sh prints RESULT: OK ... with effective fan-out 2, but its only pass condition is [ "$CODE" = "200" ] - it counts sqlite busy during retries and database is locked lines and prints them without asserting they are zero, so a run with heavy contention still prints OK.
Since the default is already file_concurrency: 1, a cap of 2 can only ever fire for an operator who explicitly asked for more - and it hands them an unmeasured value instead of the tested one. With the cap at 1 the constant has nothing left to express and should be removed outright: 1 is already the default, so what remains is simply that file fan-out is unsupported on SQLite.
2. HFS_BULK_SUBMIT_FILE_CONCURRENCY is undocumented for operators
It is absent from every operator-facing doc, before and after this PR:
crates/rest/README.md:285-308, the canonicalHFS_BULK_SUBMIT_*table - noFILE_CONCURRENCYrow. Undocumented since #933 introduced it. (HFS_BULK_SUBMIT_DEFER_INDEXINGis missing there too, from #903 - worth adding in the same pass.)- Root
README.mdlists only_ENABLEDand_OUTPUT_BACKENDand points at that table.
The clamp is documented only in .claude/skills/bulk-data-submit/SKILL.md, which is agent-facing. An operator who sets FILE_CONCURRENCY=8 on SQLite silently gets a different number, and the only place that is written down is a file they will never open. .agents/skills/bulk-data-submit/SKILL.md (the Codex mirror referenced by AGENTS.md:79) was not updated either.
3. The retry covers 4 sites; the same failure class is left on the others
Failed to update manifest counts is fixed, but the identical internal_error(...) -> abort-the-manifest pattern remains on writes in the same hot path: claim manifest (:1488), heartbeat (:1519), mark processing (:1662), record submit file (:1800), finish/fail manifest (:1820/:1848), and the per-entry change and entry-result inserts (:1003, :1044, :1303). run_bulk_submit_volume_check.sh flags one of these in its own comment: "This path does NOT go through retry_bookkeeping_on_busy: bulk_submit.rs:1488 ... It is the same failure class as issue #942." Either extend the retry or open a follow-up that says explicitly which paths are still exposed.
Worth noting for the design discussion: clamping to 1 would not make the retry redundant. HFS_BULK_SUBMIT_WORKER_CONCURRENCY defaults to 2 and crates/hfs/src/main.rs:1783 spawns that many workers, so two manifests write to the same database concurrently at file concurrency 1; the lease keeper flushes from a separate task (the pre-existing comment at bulk_submit.rs:793 already says it "can starve for tens of seconds against back-to-back batch transactions"); and the server is live throughout, with ordinary REST writes, the audit sink, subscriptions and cleanup all contending. The retry is the actual fix - the clamp is defence in depth.
Asks
- Cap SQLite at
1and removeSQLITE_MAX_FILE_CONCURRENCY- at1it is the default, so there is no clamp left to name - Correct or remove the "overlaps extraction" justification
- Add
FILE_CONCURRENCY(andDEFER_INDEXING) rows tocrates/rest/README.md - Sync
.agents/skills/bulk-data-submit/SKILL.md - Fix the Windows-only binary path in the volume script
- Extend the busy retry to the remaining write sites, or file the follow-up
- Retry budget currently exceeds the lease duration (inline)
One more thing: the PR description says this was "validated with the Playwright e2e suite". Playwright covers crates/ui and has no bearing on SQLite lock contention in the bulk-submit worker - please correct that so the validation record is accurate.
| /// `busy_timeout` and the ingest fails outright rather than merely running | ||
| /// slowly (#942). Two still overlaps fetch and extraction — the part that does | ||
| /// parallelise — without pushing the write queue past the timeout. | ||
| pub const SQLITE_MAX_FILE_CONCURRENCY: u32 = 2; |
There was a problem hiding this comment.
This should be 1, and the justification above it is incorrect.
The doc comment claims "Two still overlaps fetch and extraction - the part that does parallelise". Extraction does not parallelise here: record_file_results opens one BEGIN IMMEDIATE transaction per batch and calls ingest_entry_in_txn - search-index extraction included - inside it (bulk_submit.rs:699-745). The extraction runs while the exclusive write lock is held. That is precisely what #942 describes ("its whole extraction + ~25-INSERT span") and what the hoist-extraction prototype was built to change.
So fan-out 2 buys overlapped HTTP fetch and NDJSON parse, not overlapped extraction - and it costs a second writer queued on the lock, which is the contention this PR exists to remove.
The 2 appears to come straight from the "(e.g. to 2)" parenthetical in #942, and nothing in this PR measures it. Given file_concurrency already defaults to 1, a cap of 2 only ever applies to an operator who asked for more - and gives them an unmeasured value rather than the tested one.
| /// asks for 8 gets a slower import rather than a failed one. | ||
| /// | ||
| /// The result is always at least `1`, so a configured `0` still ingests. | ||
| pub fn effective_file_concurrency(&self, backend: BackendKind) -> u32 { |
There was a problem hiding this comment.
SQLITE_MAX_FILE_CONCURRENCY should be removed, not retuned.
Once the SQLite cap is 1 there is nothing left for this constant to express. 1 is already the default (config.rs:682), so the "clamp" is no longer a clamp - it is the statement file fan-out is not supported on SQLite, and that belongs in a match arm and a doc line, not in a named public constant that reads like a tunable.
Keeping it costs real things:
- It is
pub, so it becomes API surface that has to stay meaningful. - A named cap invites the question "can I raise it?", and the answer is no - there is no env override. That is a worse experience than an honest "SQLite runs one file at a time".
- The value has never been measured, so a constant is documenting a guess as though it were a threshold.
Concretely, this reduces to:
pub fn effective_file_concurrency(&self, backend: BackendKind) -> u32 {
match backend {
// SQLite serialises writers: a manifest's batch writes queue behind
// one exclusive lock, and past a single in-flight file the queued
// writers outlast busy_timeout and abort the ingest (#942).
BackendKind::Sqlite => 1,
_ => self.file_concurrency.max(1),
}
}The three tests below then simplify accordingly - sqlite_clamps_effective_file_concurrency asserts 1, and a_configured_value_under_the_sqlite_cap_is_untouched has nothing left to cover and can go.
| /// waits up to the connection's `busy_timeout` (30 s by default), so a | ||
| /// handful of retries rides out a fan-out contention spike without | ||
| /// masking a genuinely wedged database forever. | ||
| const MAX_ATTEMPTS: u32 = 5; |
There was a problem hiding this comment.
The retry budget outlives the lease it is protecting.
5 attempts, each waiting up to busy_timeout (30s by default, backend.rs:206) plus backoff, is a worst case around 150s. HFS_BULK_SUBMIT_LEASE_DURATION defaults to 60s.
Once the lease expires another worker can claim the manifest, so every attempt past ~60s can only ever return affected == 0 -> LeaseLost. Those attempts are pure latency on a manifest that has already been handed off.
Suggest bounding total elapsed retry time against the lease duration rather than counting attempts - e.g. take a deadline of lease_duration / 2 and stop when it passes.
| /// Only busy/locked — classified as [`BackendError::Unavailable`] by | ||
| /// [`classify_sqlite_error`] — is retried; every other error surfaces | ||
| /// immediately. | ||
| async fn retry_bookkeeping_on_busy<T>( |
There was a problem hiding this comment.
This is an async fn, but attempt is a synchronous closure whose conn.execute blocks the calling tokio worker thread for up to busy_timeout (30s).
That blocking is pre-existing, but the retry multiplies the worst case by MAX_ATTEMPTS - up to ~150s of a runtime thread held on a blocking call. Worth wrapping the attempt in spawn_blocking while this code is being touched anyway.
| // starve for tens of seconds against back-to-back batch | ||
| // transactions. MAX keeps a late keeper flush from regressing it. | ||
| // | ||
| // Both bookkeeping writes retry on SQLITE_BUSY instead of aborting |
There was a problem hiding this comment.
Two things on the retry's scope and its idempotency argument.
Scope: this fixes the write named in #942, but the identical internal_error(...) -> abort pattern is still on the neighbouring writes in the same hot path - claim manifest (:1488), heartbeat (:1519), mark processing (:1662), record submit file (:1800), finish/fail manifest (:1820/:1848), and the per-entry change and entry-result inserts (:1003, :1044, :1303). run_bulk_submit_volume_check.sh calls this out itself: "This path does NOT go through retry_bookkeeping_on_busy: bulk_submit.rs:1488 ... It is the same failure class as issue #942." Either extend the retry or land a follow-up issue that states which paths remain exposed.
Idempotency: the progress and bytes writes are absolute / MAX(), so they are genuinely idempotent. This one is not - total_entries = total_entries + ?1 is a relative increment, and its safety rests entirely on "an attempt that fails busy/locked never acquired the write lock, so it changed nothing." That holds for a single-statement autocommit, but a double-apply corrupts the counts silently with no way to detect it afterwards. Wrapping this in an explicit transaction would make it airtight instead of argued.
| .unwrap_or(BackendKind::Sqlite); | ||
| let file_concurrency = cfg.effective_file_concurrency(backend_kind); | ||
| if file_concurrency < cfg.file_concurrency.max(1) { | ||
| info!( |
There was a problem hiding this comment.
warn! rather than info! - this is HFS overriding a value the operator set deliberately. At info it is easy to miss in startup output, which is exactly the audience that needs to see it.
| HFS_BULK_SUBMIT_ENABLED=true \ | ||
| HFS_BULK_SUBMIT_FILE_CONCURRENCY="$FILE_CONCURRENCY" \ | ||
| HFS_LOG_LEVEL=info \ | ||
| timeout "$TTL" "${HFS_BIN:-target/debug/hfs.exe}" \ |
There was a problem hiding this comment.
This will not run on Linux or macOS: the default hardcodes the Windows binary. The other two scripts in this PR probe both:
HFS_BIN="target/debug/hfs"
[ -x "$HFS_BIN" ] || HFS_BIN="target/debug/hfs.exe"Worth adopting the same here (and a cargo build, which this script also omits).
This matters more than a portability nit, because this is the only script in the PR that exercises fan-out at all - run_bulk_submit_fanout_check.sh serves a manifest with one file of 2 Patients, where file concurrency is inert. Its pass condition is also just [ "$CODE" = "200" ]: it prints the busy-retry and lock-error counts without asserting they are zero, so it cannot substantiate "effective fan-out 2" being contention-free.
| | `HFS_BULK_SUBMIT_S3_BUCKET` | none | S3 bucket, required when output backend is s3 | | ||
| | `HFS_BULK_SUBMIT_REQUIRES_ACCESS_TOKEN` | `auto` | Manifest posture; false is invalid with local-fs | | ||
| | `HFS_BULK_SUBMIT_WORKER_CONCURRENCY` | `2` | In-process submit worker count | | ||
| | `HFS_BULK_SUBMIT_FILE_CONCURRENCY` | `1` | Files of one manifest ingested at once (fan-out); clamped to `2` on SQLite | |
There was a problem hiding this comment.
This is the only place the clamp is documented, and it is agent-facing.
HFS_BULK_SUBMIT_FILE_CONCURRENCY is absent from crates/rest/README.md:285-308 (the canonical HFS_BULK_SUBMIT_* table) and from the root README.md - it has been undocumented since #933 introduced it. An operator who sets it to 8 on SQLite gets a different number with no documentation saying so.
Please add the row to crates/rest/README.md - and HFS_BULK_SUBMIT_DEFER_INDEXING while you are in there, missing from the same table since #903.
Also: .agents/skills/bulk-data-submit/SKILL.md (the Codex mirror, per AGENTS.md:79) did not get this row or either of the two new behavior bullets.
Closes #942
Fix scoped to two localized changes in the bulk-submit worker: clamp effective file concurrency when the primary backend is SQLite, and add a bounded SQLITE_BUSY retry around the idempotent update_manifest_progress bookkeeping write so a contended counter update no longer aborts the whole manifest. BackendKind and StorageBackendMode::primary_backend_kind() already exist and only need plumbing through spawn_submit_workers, so no new abstractions, schema changes, or migrations are required. Main care points are emitting a startup log line explaining the clamp and ensuring the retry does not swallow LeaseError::LeaseLost fencing failures.
PR auto-generated by claude-agent; fix implemented with Claude Code and validated with the Playwright e2e suite.