Files
stack/docs/docs/duckdb-concurrency.md
kert 71861d9d32
All checks were successful
CI / lint (push) Successful in 29s
CI / notebooks-smoke (push) Successful in 1m25s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 56s
Infra CI / zotero (push) Successful in 13s
Infra CI / docs (push) Successful in 1m14s
Infra CI / api (push) Successful in 50s
Infra CI / mc (push) Successful in 19s
Deploy / report (push) Successful in 13s
CI / test (push) Successful in 14m27s
Harden / build-scan-report (push) Successful in 26m15s
Renovate / renovate (push) Successful in 15s
Notebooks Integration / notebooks-integration (push) Successful in 7m16s
Zotero Sync / zotero-sync (push) Successful in 53s
Package Supply Chain / pkg-supply-chain (push) Successful in 58s
fix(lake): M5 covered OPPS only — build out PFS (refs #514)
The M5 close-out missed half the issue's scope: #514 says 'OPPS/PFS
reference data' and I cut over only OPPS, leaving PFS — the largest
reference domain, 23.5M rows across 8 tables — entirely on the
monolith, including pfs.* queries in the very notebook whose OPPS
query was migrated. This completes PFS the same way:

- publish_opps_to_lake.py → publish_reference_to_lake.py with a
  schema registry (opps: 3 tables, pfs: 8); host-side docker-exec
  wrapper extracted to dev/scripts/_lake.py, shared by the ingests.
- PFS published to the lake and read-back verified: carrier_locality
  21,863,770 rows in 10.1s, plus rvu/gpci/clinical_labor/medical_
  equipment/medical_supply/physician_work_time/zip_carrier_locality.
- New dev/scripts/ingest_pfs.py wraps pfs.pipe.load_all (previously
  ad-hoc, no entrypoint) with the standard plumbing: duckdb_batch
  preflight, replica refresh, lake publish.
- 5 notebooks migrated: pfs_calcs, pfs_reconciliation,
  skin_sub_budget_neutrality read the lake as their primary
  connection; skin_sub_pricing and skin_sub_cost_sharing switch their
  pure-pfs cells to the lake. The one cross-source join
  (pfs × skin_subs) stays on the monolith mirror, annotated.
  All 5 headless-verified in prod: zero cell errors.
- pfs_calcs leaves the pre-commit host-run safe list (the lake catalog
  is compose-internal); the nightly integration covers it in-container.
2026-07-10 23:47:51 -04:00

100 lines
4.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: DuckDB concurrency & connection hygiene
sidebar_position: 90
---
# DuckDB concurrency & connection hygiene
`data/aco.duckdb` is a **single-writer** store: one process holding *any*
connection — even read-only — blocks every writer. The classic failure is a
batch ingest dying with:
```
IOException: Could not set lock on file "data/aco.duckdb"
```
while a marimo notebook kernel quietly holds a connection from a cell that ran
hours ago.
## Writing: use `conf.connect.duckdb_batch`
Batch ingests should not call `duckdb.connect()` directly. The
`duckdb_batch()` context manager preflights the lock — retrying with
exponential backoff, and if the file is still held, failing with the holder
PIDs/commands (via `lsof`) instead of a raw `IOException`:
```python
from conf.connect import duckdb_batch
with duckdb_batch("aco") as con: # retries, then names the lock holder
con.execute("CREATE SCHEMA IF NOT EXISTS opps")
...
# connection is always closed here — the lock is released even on error
```
`dev/scripts/ingest_opps.py` and `dev/scripts/ingest_asp.py` use this.
## Reading in notebooks: keep connections short-lived
- `conf.connect.duckdb()` opens **read-only by default** — keep it that way in
notebooks; never pass `read_only=False` from a notebook.
- A read-only handle still blocks writers. Don't keep a module-level
connection alive for the life of the kernel: open, query, and close within
the cell, or wrap access in a small helper that closes after each query.
- If an ingest reports the lock is held by a `marimo` PID, close that notebook
tab (or stop the PID) and re-run — the notebook loses nothing; it reconnects
on the next cell run.
## Notebooks read a replica, not the primary
Read-only opens through `conf.connect.duckdb()` resolve to the snapshot
`data/aco.ro.duckdb` when it exists — so notebook kernels never hold the
primary's lock at all, and ingests stop caring how many notebooks are open.
- **Refresh cadence**: every ingest republishes the snapshot as its last step
(`conf.connect.publish_replica("aco")` — holds the write lock, `CHECKPOINT`s,
copies, then swaps atomically). Staleness is therefore bounded by ingest
frequency; to refresh manually:
`uv run python -c "from conf.connect import publish_replica; publish_replica()"`
- **Reading the live primary instead**: pass `replica=False` to
`conf.connect.duckdb()`, or set `STACK_DUCKDB_REPLICA=0`.
- Kernels holding the *old* snapshot keep a valid file handle after a swap;
re-running the connect cell picks up the fresh one.
## Reference data (OPPS + PFS) is authoritative in the lake
Since M5 (#514), the OPPS and PFS reference tables live in **DuckLake** —
postgres catalog (`ducklake` db) + Parquet on RustFS
(`s3://lakehouse/ducklake/`), per the M3 decision record. Concurrency
there is structural: the catalog serializes writers transactionally and
readers get snapshot isolation.
- **Reading (notebooks / in-container code)**: `conf.connect.ducklake()` —
read-only by default via the `ducklake_ro` postgres role
(`DUCKLAKE_RO_PASSWORD`, in the notebooks container env). The lake is the
connection's default database, so `SELECT … FROM opps.addendum_b` or
`pfs.rvu` works unchanged. Compose-internal only — host-side code goes
through `docker exec`.
- **Writing**: `aco.lake.DuckLakeContext` (see `dev/scripts/
publish_reference_to_lake.py`). `ingest_opps.py` and `ingest_pfs.py`
publish to the lake as their final step (`--no-lake` to skip).
- The monolith's `opps`/`pfs` schemas remain as a **deprecated mirror** for
the `aco.pipe` analytics graph, the read replica, and cross-source joins
(e.g. pfs × skin_subs); drop them once those consumers migrate.
- Cross-source queries (lake schema joined with a monolith-only schema like
`skin_subs`) stay on the monolith connection until the joined schema also
moves.
## Per-year ingests merge, not wipe
`ingest_opps.py --year YYYY` used to `DROP TABLE` + `CREATE ... AS` from only
that year's files, silently erasing every other year (#509). It now merges:
existing rows for other years are preserved and the filtered year is replaced.
## Roadmap
The strategic fixes (per-domain DB files, read replica, lake storage with
snapshot isolation) are tracked as milestones M2–M5 in issues
[#510](https://git.fhirworx.io/homelab/stack/issues/510)–[#514](https://git.fhirworx.io/homelab/stack/issues/514),
per `docs/superpowers/specs/2026-07-08-duckdb-concurrency-streaming.md`.