Skip to content

fix: bulk-submit(sqlite): import aborts with 'database is locked' at high file concurrency with the full search-parameter registry - #946

Open
eterceros wants to merge 2 commits into
mainfrom
fix/942-bulk-submit-sqlite-import
Open

fix: bulk-submit(sqlite): import aborts with 'database is locked' at high file concurrency with the full search-parameter registry#946
eterceros wants to merge 2 commits into
mainfrom
fix/942-bulk-submit-sqlite-import

Conversation

@eterceros

Copy link
Copy Markdown
Contributor

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.

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

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.45390% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...tes/persistence/src/backends/sqlite/bulk_submit.rs 95.41% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

@angela-helios angela-helios left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The per-file status writes (SubmitFileRecord updates) are not wrapped in retry_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.
  2. Once #944 lands (partial indexes shorten the write-lock holds ~40% measured), it may be worth re-evaluating whether SQLITE_MAX_FILE_CONCURRENCY can 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>

@smunini smunini left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 canonical HFS_BULK_SUBMIT_* table - no FILE_CONCURRENCY row. Undocumented since #933 introduced it. (HFS_BULK_SUBMIT_DEFER_INDEXING is missing there too, from #903 - worth adding in the same pass.)
  • Root README.md lists only _ENABLED and _OUTPUT_BACKEND and 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 1 and remove SQLITE_MAX_FILE_CONCURRENCY - at 1 it is the default, so there is no clamp left to name
  • Correct or remove the "overlaps extraction" justification
  • Add FILE_CONCURRENCY (and DEFER_INDEXING) rows to crates/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.

Comment thread crates/rest/src/config.rs
/// `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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/rest/src/config.rs
/// 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 {

@smunini smunini Sep 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/hfs/src/main.rs
.unwrap_or(BackendKind::Sqlite);
let file_concurrency = cfg.effective_file_concurrency(backend_kind);
if file_concurrency < cfg.file_concurrency.max(1) {
info!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}" \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bulk-submit(sqlite): import aborts with 'database is locked' at high file concurrency with the full search-parameter registry

3 participants