Skip to content

ci(bench): harden shared-host cleanup in fhir-benchmark.yml - #1543

Open
dougc95 wants to merge 6 commits into
mainfrom
ci/bench-host-hardening
Open

dougc95 wants to merge 6 commits into
mainfrom
ci/bench-host-hardening

Conversation

@dougc95

@dougc95 dougc95 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Preparatory PR for #1475. This PR fixes cleanup problems in .github/workflows/fhir-benchmark.yml that exist on main today. #1475 will put up to six backend legs on the same shared Docker host, so these need fixing first. This PR adds no new backends, inputs or features.

What changes

Problem on main Evidence Fix
docker volume prune --filter label=hfs-bench=1 never removes the Postgres data volume. On Docker ≥ 23, prune only considers anonymous volumes unless --all is passed. Runs 34187014872 and 35867150861 logged Total reclaimed space: 0B while 76–84 GB of volumes were reclaimable An explicit sweep of this workflow's labelled named volumes. It skips this run's own volumes and anything younger than the age guard, and removes without -f, so Docker refuses to delete a volume still in use.
The reaper's docker inspect … | sed aborts the whole step under bash -e + pipefail if a container disappears between ps and inspect A sibling leg's --rm canary finishes in exactly that window … || continue
The canary and df containers are named only by run id. Both legs of one run start in the same second, so their names collide. 4 of 4 past two-leg runs started their legs in the same second The backend is added to the names
The canary's remedy text says docker system prune -af --volumes docker-host-gc/action.yml:54-64 rules that out on this host, because it runs long-lived services The remedy now names only this workflow's own containers and labelled volumes
Teardown ran last. When the runner is lost during post-processing, containers are orphaned. Run 33515369645: the runner was lost during "Attribute search latency", and the Stop steps were skipped. The containers survived 6.4 days until the next reaper. Stop HFS, Stop tgz and Stop Postgres now run right after the artifact uploads. Post-processing only reads files.
No job timeout. The reaper's 120-minute age guard is hard-coded and nothing else reaps orphans. The only thing that reaps hfs-bench-* is this workflow's next run Setup emits leg_timeout_min=150, which becomes the benchmark job's timeout-minutes and the reaper's guard (+30). The tgz and Postgres containers are labelled hfs-ci=true, so docker-host-gc (default age 180 min, compared against StartedAt) reaps any orphan within hours. 150 plus the 5-minute cancel window stays under 180.
There is no way to confirm cleanup worked, because the shared host can't be inspected by hand — A final "Docker host usage after cleanup" step lists any container or volume still carrying both this run's and this leg's labels (hfs-bench-run + a new hfs-bench-leg), and emits ::warning:: if any remain

Verification

  • actionlint 1.7.12 (shellcheck and pyflakes integration off): no findings on this branch or on main. The shellcheck integration hangs on this file on Windows, on main too.
  • shellcheck 0.11.0, run directly on each changed run: block (reaper, tgz, Postgres, leak check): 0 findings, same as main.
  • Two adversarial review rounds. No blockers or majors. The one minor and all nits are applied.
  • Live run: 36374582807, dispatched with -f tests=prewarm on the default legs (sqlite + postgres, started together): success.
    • Reaper: age guard 180m (150 + 30). No live containers were in range.
    • Volume sweep: the postgres leg removed three orphaned Postgres data volumes that the old prune line had never matched: hfs-bench-pgdata-33209821414, -33404996556 and -33515369645, aged 26–30 days. The last one is the run whose runner was lost.
    • Canary and df containers ran under the per-backend names (hfs-bench-canary-sqlite-…, hfs-bench-canary-postgres-…) without colliding.
    • Teardown order on both legs: Upload results → Upload server log → Stop HFS → Stop tgz → Stop Postgres → Attribute → Summary → leak check.
    • Leak check: 0 containers and 0 volumes left on both legs, with no warnings or annotations.

Refs #1475

🤖 Generated with Claude Code

Fixes cleanup problems that exist on main today, before #1475 adds
more backend legs to the same shared Docker host:

- Reap this workflow's named volumes explicitly. On Docker >= 23,
  `docker volume prune --filter label=hfs-bench=1` only considers
  anonymous volumes, so it never matched the Postgres data volume;
  runs 34187014872 and 35867150861 logged "0B" reclaimed next to
  76-84 GB of reclaimable volumes.
- Guard the reaper's `docker inspect` so a container vanishing
  between `ps` and `inspect` no longer aborts the step under bash -e.
- Put the backend in the canary and df container names; the sqlite
  and postgres legs of one run started in the same second and
  collided on them.
- Stop suggesting `docker system prune -af --volumes` on a host that
  runs long-lived services.
- Run teardown right after the artifact uploads. On run 33515369645
  the runner was lost during "Attribute search latency", every later
  step was skipped, and its containers survived 6.4 days.
- Give the benchmark job a 150-minute timeout from one setup output,
  derive the reaper's age guard from it (+30), and label the tgz and
  Postgres containers hfs-ci=true so docker-host-gc reaps orphans
  after 180 minutes.
- Add a per-leg leak check at the end of the job, keyed on a new
  hfs-bench-leg label, since the shared host can't be inspected by
  hand.

Refs #1475

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 28, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@codecov

codecov Bot commented Sep 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Extends fhir-benchmark.yml from sqlite + postgres to every storage
backend with and without Elasticsearch: sqlite, sqlite-elasticsearch,
postgres, postgres-elasticsearch, mongodb and mongodb-elasticsearch.
Bare s3 is left out because it has no search, so the import and
search suites cannot run on it; s3-elasticsearch is a follow-up.

Dispatch:
- backend: core (default; sqlite + postgres), all (6 legs),
  elasticsearch (the 3 composites), or any single backend.
- max_parallel (default 1, max 2): legs share one 12 GB / 4-CPU
  Docker host, so running them together skews every leg's numbers.
- es_heap, es_sync_mode (asynchronous = HFS default | synchronous),
  mongo_wt_cache_gb, hfs_mongo_max_connections. The setup job
  validates them all before the build starts.

Per leg:
- MongoDB runs as a single-member replica set (transaction bundles
  need one), with a capped WiredTiger cache and a 900 s transaction
  lifetime to match HFS_REQUEST_TIMEOUT.
- Elasticsearch runs single-node. Yellow health is expected, because
  HFS creates every index with one replica.
- A capacity gate waits up to 10 minutes for enough free memory on
  the Docker host and fails the leg rather than risk an OOM.
- Containers and volumes carry the hfs-bench, hfs-ci and leg labels,
  so the reaper, docker-host-gc and the leak check all cover them.

Measurement:
- On ES legs, a drain gate runs before the search suite: a
  conditional DELETE that matches nothing acts as a barrier on the
  composite's sync queue, then ES counters must settle, then live
  primary and ES resource counts are compared.
- Import completeness, per-type crud leftovers and a _summary=count
  cross-check are recorded, so legs that did not load the same data
  are flagged rather than silently compared.
- Backend stats, a "how to read this leg" note and the tuning knobs
  go into the step summary and runner-info.txt.

The new bash and Python live in .github/scripts/fhir-bench/, following
.github/scripts/obs-ab, so the workflow keeps its orchestration and
the scripts can be linted directly.

Closes #1475

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NudzWDu2yTGExYaxdTQYJ
dougc95 and others added 4 commits September 28, 2026 08:06
Run 36410157709 showed the gate measuring the wrong machine:
`docker info` reported MemTotal 12000 MB while a plain container's
/proc/meminfo showed MemAvailable 61324 MB, so the gate would pass
almost every time. The Docker daemon evidently runs inside a 12 GB
limit that containers' /proc/meminfo does not reflect (likely an
lxcfs-virtualised LXC, where even a bind-mounted meminfo would report
free memory relative to the reading container's own cgroup).

host-mem.sh now derives available memory from one source it can
trust: docker info MemTotal minus the usage `docker stats` reports for
every running container, minus 1024 MB for dockerd and non-container
processes. If `docker stats` fails the reading is `unknown` rather
than "nothing running", so the gate keeps polling and fails at its
10-minute budget instead of passing on a guess.

The capacity gate, probe_host (host_mem_avail_mb + host_mem_source in
host-contention.txt), runner-info.txt (capacity_mem_source), the step
summary and diagnose.sh all use it. probe_host now runs before
SUITE_START, so suite wall time no longer includes the probe itself.

Refs #1475

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NudzWDu2yTGExYaxdTQYJ
Run 36412022609 (sqlite-elasticsearch, asynchronous, prewarm+import+
search) showed that async mode cannot finish the import corpus inside
k6's 60-minute cap: 313 of 1000 bundles succeeded, 14 hit the 900 s
client timeout, and bundle latency p95 was 489 s. The drain gate then
reported status=incomplete: SQLite held 562,649 live resources and
Elasticsearch 367,985, with composite_secondary_sync_needs_reindex=0.

As agreed for this case, es_sync_mode now defaults to synchronous
(bundles batched via _bulk, refresh=wait_for). asynchronous, HFS's own
out-of-the-box mode, stays available as an input, and its
"How to read this leg" note now says import is expected to hit the cap.

Refs #1475

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NudzWDu2yTGExYaxdTQYJ
Dispatching cffc076 failed before any job ran: "failed to parse
workflow: (Line: 1145, Col: 14): Exceeded max expression length
21000". A run: block that contains any ${{ }} is evaluated as one
expression, and GitHub caps expressions at 21,000 characters. The
"Run benchmark suites" block had grown to ~26,300 characters with 31
substitutions.

Every matrix/inputs/github/needs value that block uses now comes in
through the step's env: (BACKEND, BENCH_PORT, BENCH_RUN_ID,
BENCH_IN_*, BENCH_SHA, BENCH_REF_NAME, BENCH_MAX_PARALLEL,
BENCH_LEG_TIMEOUT_MIN), with the same `||` fallbacks done in bash, so
the block is a plain string with no length cap. A comment above the
env: block records the rule. No behaviour change.

Refs #1475

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NudzWDu2yTGExYaxdTQYJ
…d-matrix

ci(bench): benchmark all six storage backends (#1475)

This branch has not been deployed

No deployments
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