Skip to content

fix(persistence)+ci: FHIR-benchmark bug fixes (PG token search, tx rollback, sqlite locks) + timeouts + truthful reporting#219

Merged
smunini merged 6 commits into
mainfrom
ci/fhir-benchmark-reporting-fixes
Jul 8, 2026
Merged

fix(persistence)+ci: FHIR-benchmark bug fixes (PG token search, tx rollback, sqlite locks) + timeouts + truthful reporting#219
smunini merged 6 commits into
mainfrom
ci/fhir-benchmark-reporting-fixes

Conversation

@mauripunzueta

@mauripunzueta mauripunzueta commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #218 (FHIR benchmark workflow). Started as step-summary reporting
fixes; grew to carry the fixes for the bugs the first full internal benchmark
run
(issue #190, item 3) surfaced, plus the config needed to run the suites
fairly. Kept on one PR per request.

Backend correctness / concurrency fixes (helios-persistence)

1. Postgres token search returned 500 on comma-OR value lists.
build_token_condition numbered SQL placeholders with a fixed 2-slot stride
(offset + i*2), but a code-only token value binds only one param — so
Encounter?status=finished,in-progress emitted $3 and $5 while binding two
params → out-of-range placeholder → "Failed to execute search: db error". Every
token OR-list query in the search suite hit this. Fixed to advance by each
value's actual param count. +2 regression tests.

2. Postgres transaction-bundle import failure cascade. A transaction dropped
without explicit commit/rollback returned its connection to the deadpool with an
open transaction (the old Drop comment wrongly assumed recycling
auto-rolls-back — deadpool does not reset session state), so the next checkout
failed with "already a transaction in progress" and cascaded. Drop now moves
the client into a spawned task that ROLLBACKs before the connection returns to
the pool (guarded for the no-runtime case).

3. SQLite "database is locked" under concurrent writes. busy_timeout and
foreign_keys are per-connection PRAGMAs but were set on only one pooled
connection; the rest defaulted to busy_timeout = 0 and failed writes instantly
instead of waiting. Now applied in the r2d2 with_init hook (every connection),
and the default raised 5s → 30s so writers queue behind the bulk-import backlog.

Benchmark config (workflow)

  • HFS_REQUEST_TIMEOUT=900 — the 30s default was cutting large (1,600-entry)
    transaction bundles off mid-flight under the 20-VU import load (import p95 was
    pinned at 30s; the handler future was cancelled, not erroring). Comparators
    impose no such cap and import.js allows 900s client-side.

Reporting fixes (the original scope)

  • Err% column always read 0.00% — it read http_req_failed.rate, but that
    is a k6 Rate metric whose fraction lives in .value (.rate exists only on
    Counter metrics). This is what masked every failure in the first run.
  • Added p99 (via --summary-trend-stats) and a Checks ✓/✗ column.
  • Added a log_level input (enables HFS_LOG_LEVEL=debug reruns — used to pin
    the token-OR search bug) and bench-results cleanup (checkout is clean:false).

Results (Postgres — the backend all comparators use)

Suite Baseline (first run) After fixes
CRUD 0% err, 43,200/0 0% err
Import 17.4% err, 653/347 0.1% err, 816/2
Search (correctness) 94 SQL-error 500s 0 SQL errors
Search (latency @ full data) ~8s median still slow at volume — tracked in #224

SQLite import improved (47.5% → 39%) but remains write-serialized by design under
the 20-VU import; the public comparison is Postgres-based.

Note on search latency: at full data volume Postgres search is slow
(median ~7s, p99 ~51s, one composite query 500s). An ANALYZE-after-import
step was tried and validated as ineffective (reverted), so this is a genuine
query-plan/index problem, not stale statistics. It is out of scope here and
tracked separately in #224. This PR's search fix is about correctness
(0 SQL-error 500s), not latency.

Regression safety

All fixes are benchmark-workflow config or in helios-persistence's FHIR
resource paths (transaction bundles, resource search index) — not the HTS
terminology hot paths. The one shared change (SQLite busy_timeout default) only
affects how long a contended write waits before erroring, so it can't slow the
read-heavy terminology workload. CI (Test Rust) is green. HTS-benchmark
no-regression run still to be done.

Refs #190.

The first full internal run surfaced bugs in the reporting itself:

- Err% column always showed 0.00%: it read http_req_failed.rate, but that
  is a k6 Rate metric whose fraction lives in .value (.rate exists only on
  Counter metrics). Now reads .value — e.g. postgres import correctly shows
  17.4% instead of 0%.
- p99 column always 0: default summary trend stats omit p(99). Add
  --summary-trend-stats so p(99) is exported.
- Added a Checks ✓/✗ column (bolded when any fail) — the clearest signal of
  a suite that 'ran' but had failing assertions.
- Added a log_level input (default info) to enable HFS_LOG_LEVEL=debug runs
  for diagnosing the postgres search 500s.
- Wipe bench-results before a run (checkout is clean:false) so a stale
  sibling-backend dir doesn't ride along in the uploaded artifact.
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.04878% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...s/persistence/src/backends/postgres/transaction.rs 0.00% 13 Missing ⚠️
...ence/src/backends/postgres/search/query_builder.rs 93.44% 4 Missing ⚠️
crates/persistence/src/backends/sqlite/backend.rs 87.50% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

… benchmark

The first full internal benchmark run (issue #190) exposed three real backend
bugs. All are in helios-persistence's FHIR resource paths, which the HTS
terminology benchmark does not exercise, so HTS parity is unaffected.

1. PG token search with a comma-OR value list returned 500 ('Failed to execute
   search: db error'). build_token_condition numbered placeholders with a fixed
   2-slot stride (offset + i*2), but code-only values bind only 1 param, so
   e.g. status=finished,in-progress emitted $3 and $5 while binding 2 params —
   an out-of-range placeholder PG rejects. Now tracks a running offset that
   advances by each value's actual param count. Every benchmark token OR-list
   query hit this. + 2 regression tests.

2. PG transaction-bundle import failure cascade (~35% of imports at 20 VUs). A
   transaction dropped without explicit commit/rollback returned its connection
   to the deadpool with an OPEN transaction (the old Drop comment wrongly assumed
   recycling auto-rolls-back — deadpool does not reset session state), so the
   next checkout failed with 'already a transaction in progress' and cascaded.
   Drop now moves the client into a spawned task that ROLLBACKs before the
   connection returns to the pool (guarded for the no-runtime case).

3. SQLite 'database is locked' under concurrent writes (~95% of imports at 20
   VUs). busy_timeout and foreign_keys are per-connection PRAGMAs but were set on
   only one pooled connection in configure_connection(); the rest defaulted to
   busy_timeout=0 and failed writes immediately instead of waiting. Now applied
   in the r2d2 with_init hook so every connection gets them. (WAL stays in
   configure_connection — it's persistent and DB-level.)

Refs #190.
…bulk import

Root-caused the residual import failures (they were config, not logic):

- PG import ~20% failures were the 30s HFS_REQUEST_TIMEOUT cutting large
  Synthea transaction bundles mid-flight under 20-VU load (import p95 pinned at
  30s; 'Transaction failed' logged 0 times because the handler future was
  cancelled before its error path — the prior Drop-rollback fix is what now
  cleanly rolls those back). Set HFS_REQUEST_TIMEOUT=900 in the benchmark to
  match import.js's client timeout and the uncapped comparators.

- SQLite import 'database is locked' (writers failing at BEGIN): busy_timeout
  now applies to every connection (prior fix) but 5s was too short to queue
  behind 20 concurrent write transactions. Raise the default to 30s — only
  affects contended-write wait time, safe for read-heavy workloads (HTS).

PG search stays fixed at 0% (token OR-list). Refs #190.
…steady state

The import fix fully populates Postgres (~1.3M rows), and search then ran at
~8s median with 3 queries hitting the 30s statement_timeout — not an index gap
(the indexes exist) but missing planner statistics on the freshly bulk-loaded
tables, so the planner seq-scans search_index. Autovacuum would ANALYZE
eventually, but the suite searches immediately after import. Run ANALYZE once
after the import suite (postgres) so the search suite reflects steady-state
performance rather than a cold-stats artifact. Refs #190.
@mauripunzueta mauripunzueta changed the title ci(fhir-benchmark): truthful error/checks reporting + debug log input fix(persistence)+ci: FHIR-benchmark bug fixes (PG token search, tx rollback, sqlite locks) + timeouts + truthful reporting Jul 8, 2026
…easures steady state"

This reverts commit f57f495.

The ANALYZE-after-import experiment was validated in run 28911652793
(postgres-only import+search) and had no measurable effect: search
latency stayed at med 7.18s / p99 51s / max 65s, statistically identical
to the pre-ANALYZE run 28908173123 (med ~8s / p99 45s / max 62s). So the
slowness is NOT a stale-planner-statistics artifact — it is a genuine
query-plan/concurrency problem at full data volume that needs EXPLAIN
ANALYZE diagnosis. Removing the dead step; tracking the real issue
separately as a FHIR-benchmark follow-up. Refs #190.
@smunini smunini merged commit 5013967 into main Jul 8, 2026
29 checks passed
@smunini smunini deleted the ci/fhir-benchmark-reporting-fixes branch July 8, 2026 23:27
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.

2 participants