fix(conf,llm,compose): the DuckDB replica lives in data/replica/ (refs P48)

The llm container mounted the whole ./data directory read-only just to
read one snapshot. Snapshots now get their own directory, published with
mkdir -p, and compose mounts only ./data/replica — the primary databases
are no longer visible inside the container.
This commit is contained in:
kert
2026-09-08 23:23:59 -04:00
parent 51a5abf3bc
commit 0bc53ccb7d
7 changed files with 26 additions and 20 deletions

View File

@@ -680,12 +680,12 @@ services:
- data - data
- observability - observability
volumes: volumes:
- ./data:/app/data:ro # DuckDB read replica for code valuations (directory mount survives replica re-publish) - ./data/replica:/app/data/replica:ro # DuckDB read replica only (directory mount survives replica re-publish)
environment: environment:
- LLM_OLLAMA_HOSTS=${LLM_OLLAMA_HOSTS_IN_CONTAINER:-http://ollama:11434} - LLM_OLLAMA_HOSTS=${LLM_OLLAMA_HOSTS_IN_CONTAINER:-http://ollama:11434}
- LLM_PG_HOST=postgres - LLM_PG_HOST=postgres
- LLM_DB_PASSWORD=${LLM_DB_PASSWORD} - LLM_DB_PASSWORD=${LLM_DB_PASSWORD}
- LLM_DUCKDB_REPLICA=/app/data/aco.ro.duckdb - LLM_DUCKDB_REPLICA=/app/data/replica/aco.ro.duckdb
- OTEL_SERVICE_NAME=llm - OTEL_SERVICE_NAME=llm
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
depends_on: depends_on:

View File

@@ -48,7 +48,7 @@ with duckdb_batch("aco") as con: # retries, then names the lock holder
## Notebooks read a replica, not the primary ## Notebooks read a replica, not the primary
Read-only opens through `conf.connect.duckdb()` resolve to the snapshot Read-only opens through `conf.connect.duckdb()` resolve to the snapshot
`data/aco.ro.duckdb` when it exists — so notebook kernels never hold the `data/replica/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. 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 - **Refresh cadence**: every ingest republishes the snapshot as its last step

View File

@@ -30,8 +30,10 @@ from conf import ROOT, cfg, path
def _replica_path(name: str) -> Any: def _replica_path(name: str) -> Any:
"""data/<name>.ro.duckdb next to the primary.""" """data/replica/<name>.ro.duckdb — its own directory, so a container
return path(f"db.{name}").with_suffix(".ro.duckdb") can mount the snapshots without seeing the primary databases."""
primary = path(f"db.{name}")
return primary.parent / "replica" / f"{primary.stem}.ro.duckdb"
def duckdb( def duckdb(
@@ -46,7 +48,8 @@ def duckdb(
read_only read_only
Open in read-only mode (default ``True``). Open in read-only mode (default ``True``).
replica replica
Read-only opens resolve to the ``<name>.ro.duckdb`` snapshot Read-only opens resolve to the ``replica/<name>.ro.duckdb``
snapshot
when it exists — long-running readers (notebook kernels) then when it exists — long-running readers (notebook kernels) then
never hold the primary's single-writer lock, so ingests stop never hold the primary's single-writer lock, so ingests stop
failing (#510). Staleness is bounded by the refresh cadence: failing (#510). Staleness is bounded by the refresh cadence:
@@ -136,7 +139,7 @@ def duckdb_batch(
def publish_replica(name: str = "aco") -> Any: def publish_replica(name: str = "aco") -> Any:
"""Publish/refresh the read-only snapshot ``<name>.ro.duckdb``. """Publish/refresh the read-only snapshot ``replica/<name>.ro.duckdb``.
Called after each ingest so notebook readers (see :func:`duckdb`) Called after each ingest so notebook readers (see :func:`duckdb`)
work from a lock-free snapshot instead of contending with writers work from a lock-free snapshot instead of contending with writers
@@ -152,7 +155,9 @@ def publish_replica(name: str = "aco") -> Any:
import shutil import shutil
src = path(f"db.{name}") src = path(f"db.{name}")
dst = _replica_path(name).resolve() dst = _replica_path(name)
dst.parent.mkdir(parents=True, exist_ok=True)
dst = dst.resolve()
tmp = dst.with_suffix(".tmp") tmp = dst.with_suffix(".tmp")
with duckdb_batch(name) as con: with duckdb_batch(name) as con:
con.execute("CHECKPOINT") con.execute("CHECKPOINT")

View File

@@ -7,7 +7,7 @@ Env contract:
LLM_PG_HOST beats [llm].pg_host (containers set this to "postgres") LLM_PG_HOST beats [llm].pg_host (containers set this to "postgres")
LLM_DB_PASSWORD required for pg_url(); lives in .env, never stack.toml LLM_DB_PASSWORD required for pg_url(); lives in .env, never stack.toml
LLM_BUILD_ANN_INDEX beats [llm].build_ann_index ("1"/"true"/"yes" = True) LLM_BUILD_ANN_INDEX beats [llm].build_ann_index ("1"/"true"/"yes" = True)
LLM_DUCKDB_REPLICA beats [llm].duckdb_replica (containers set /app/data/aco.ro.duckdb) LLM_DUCKDB_REPLICA beats [llm].duckdb_replica (containers set /app/data/replica/aco.ro.duckdb)
Ollama has no GPU-size API, so VRAM is *declared* per host; liveness is Ollama has no GPU-size API, so VRAM is *declared* per host; liveness is
probed at request time (``HostPool.check``). Together they implement probed at request time (``HostPool.check``). Together they implement
@@ -44,7 +44,7 @@ class LlmConfig:
default_factory=lambda: dict(_DEFAULT_K_PER_KIND) default_factory=lambda: dict(_DEFAULT_K_PER_KIND)
) )
top_n: int = 8 top_n: int = 8
duckdb_replica: str = "data/aco.ro.duckdb" duckdb_replica: str = "data/replica/aco.ro.duckdb"
valuation_years: int = 4 valuation_years: int = 4
code_cited_per_code: int = 3 code_cited_per_code: int = 3
@@ -102,7 +102,7 @@ def load() -> LlmConfig:
k_per_kind=k_per_kind, k_per_kind=k_per_kind,
top_n=int(_opt(section, "top_n", 8)), top_n=int(_opt(section, "top_n", 8)),
duckdb_replica=os.environ.get("LLM_DUCKDB_REPLICA") duckdb_replica=os.environ.get("LLM_DUCKDB_REPLICA")
or str(_opt(section, "duckdb_replica", "data/aco.ro.duckdb")), or str(_opt(section, "duckdb_replica", "data/replica/aco.ro.duckdb")),
valuation_years=int(_opt(section, "valuation_years", 4)), valuation_years=int(_opt(section, "valuation_years", 4)),
code_cited_per_code=int(_opt(section, "code_cited_per_code", 3)), code_cited_per_code=int(_opt(section, "code_cited_per_code", 3)),
embed_dim=int(section.embed_dim), embed_dim=int(section.embed_dim),

View File

@@ -124,7 +124,7 @@ pg_user = "llm"
recency_half_life_days = 365 recency_half_life_days = 365
recency_weight = 0.3 recency_weight = 0.3
top_n = 8 top_n = 8
duckdb_replica = "data/aco.ro.duckdb" # read-only DuckDB replica the chat reads valuations from duckdb_replica = "data/replica/aco.ro.duckdb" # read-only DuckDB replica the chat reads valuations from
valuation_years = 4 # final-rule vintages shown per code (plus the newest NPRM) valuation_years = 4 # final-rule vintages shown per code (plus the newest NPRM)
code_cited_per_code = 3 # rule paragraphs literally citing each detected code code_cited_per_code = 3 # rule paragraphs literally citing each detected code

View File

@@ -289,6 +289,8 @@ class TestReplica:
dst = connect.publish_replica("custom") dst = connect.publish_replica("custom")
assert dst.name == "custom.ro.duckdb" assert dst.name == "custom.ro.duckdb"
# its own directory, so the container mount cannot see the primary
assert dst.parent == db_file.parent / "replica"
import duckdb as _duckdb import duckdb as _duckdb
con = _duckdb.connect(str(dst), read_only=True) con = _duckdb.connect(str(dst), read_only=True)
@@ -344,17 +346,16 @@ class TestReplica:
self._patch_path(monkeypatch, db_file) self._patch_path(monkeypatch, db_file)
target = shared / "custom.ro.duckdb" target = shared / "custom.ro.duckdb"
link = tmp_path / "replica" / "custom.ro.duckdb"
connect.publish_replica("custom") # seed a real file to link to connect.publish_replica("custom") # seed a real file to link to
(tmp_path / "custom.ro.duckdb").rename(target) link.rename(target)
(tmp_path / "custom.ro.duckdb").symlink_to(target) link.symlink_to(target)
with connect.duckdb_batch("custom") as con: with connect.duckdb_batch("custom") as con:
con.execute("UPDATE t SET x = 7") con.execute("UPDATE t SET x = 7")
dst = connect.publish_replica("custom") dst = connect.publish_replica("custom")
assert (tmp_path / "custom.ro.duckdb").is_symlink(), ( assert link.is_symlink(), "publish detached the symlink into a standalone copy"
"publish detached the symlink into a standalone copy"
)
import duckdb as _duckdb import duckdb as _duckdb
con = _duckdb.connect(str(target), read_only=True) con = _duckdb.connect(str(target), read_only=True)

View File

@@ -91,10 +91,10 @@ class TestNewKnobs:
def test_valuation_knobs_defaults(self, monkeypatch): def test_valuation_knobs_defaults(self, monkeypatch):
monkeypatch.delenv("LLM_DUCKDB_REPLICA", raising=False) monkeypatch.delenv("LLM_DUCKDB_REPLICA", raising=False)
cfg = llm_config.load() cfg = llm_config.load()
assert cfg.duckdb_replica == "data/aco.ro.duckdb" assert cfg.duckdb_replica == "data/replica/aco.ro.duckdb"
assert cfg.valuation_years == 4 assert cfg.valuation_years == 4
assert cfg.code_cited_per_code == 3 assert cfg.code_cited_per_code == 3
def test_duckdb_replica_env_override(self, monkeypatch): def test_duckdb_replica_env_override(self, monkeypatch):
monkeypatch.setenv("LLM_DUCKDB_REPLICA", "/app/data/aco.ro.duckdb") monkeypatch.setenv("LLM_DUCKDB_REPLICA", "/app/data/replica/aco.ro.duckdb")
assert llm_config.load().duckdb_replica == "/app/data/aco.ro.duckdb" assert llm_config.load().duckdb_replica == "/app/data/replica/aco.ro.duckdb"