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
- observability
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:
- LLM_OLLAMA_HOSTS=${LLM_OLLAMA_HOSTS_IN_CONTAINER:-http://ollama:11434}
- LLM_PG_HOST=postgres
- 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_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
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
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.
- **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:
"""data/<name>.ro.duckdb next to the primary."""
return path(f"db.{name}").with_suffix(".ro.duckdb")
"""data/replica/<name>.ro.duckdb — its own directory, so a container
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(
@@ -46,7 +48,8 @@ def duckdb(
read_only
Open in read-only mode (default ``True``).
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
never hold the primary's single-writer lock, so ingests stop
failing (#510). Staleness is bounded by the refresh cadence:
@@ -136,7 +139,7 @@ def duckdb_batch(
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`)
work from a lock-free snapshot instead of contending with writers
@@ -152,7 +155,9 @@ def publish_replica(name: str = "aco") -> Any:
import shutil
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")
with duckdb_batch(name) as con:
con.execute("CHECKPOINT")

View File

@@ -7,7 +7,7 @@ Env contract:
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_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
probed at request time (``HostPool.check``). Together they implement
@@ -44,7 +44,7 @@ class LlmConfig:
default_factory=lambda: dict(_DEFAULT_K_PER_KIND)
)
top_n: int = 8
duckdb_replica: str = "data/aco.ro.duckdb"
duckdb_replica: str = "data/replica/aco.ro.duckdb"
valuation_years: int = 4
code_cited_per_code: int = 3
@@ -102,7 +102,7 @@ def load() -> LlmConfig:
k_per_kind=k_per_kind,
top_n=int(_opt(section, "top_n", 8)),
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)),
code_cited_per_code=int(_opt(section, "code_cited_per_code", 3)),
embed_dim=int(section.embed_dim),

View File

@@ -124,7 +124,7 @@ pg_user = "llm"
recency_half_life_days = 365
recency_weight = 0.3
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)
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")
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
con = _duckdb.connect(str(dst), read_only=True)
@@ -344,17 +346,16 @@ class TestReplica:
self._patch_path(monkeypatch, db_file)
target = shared / "custom.ro.duckdb"
link = tmp_path / "replica" / "custom.ro.duckdb"
connect.publish_replica("custom") # seed a real file to link to
(tmp_path / "custom.ro.duckdb").rename(target)
(tmp_path / "custom.ro.duckdb").symlink_to(target)
link.rename(target)
link.symlink_to(target)
with connect.duckdb_batch("custom") as con:
con.execute("UPDATE t SET x = 7")
dst = connect.publish_replica("custom")
assert (tmp_path / "custom.ro.duckdb").is_symlink(), (
"publish detached the symlink into a standalone copy"
)
assert link.is_symlink(), "publish detached the symlink into a standalone copy"
import duckdb as _duckdb
con = _duckdb.connect(str(target), read_only=True)

View File

@@ -91,10 +91,10 @@ class TestNewKnobs:
def test_valuation_knobs_defaults(self, monkeypatch):
monkeypatch.delenv("LLM_DUCKDB_REPLICA", raising=False)
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.code_cited_per_code == 3
def test_duckdb_replica_env_override(self, monkeypatch):
monkeypatch.setenv("LLM_DUCKDB_REPLICA", "/app/data/aco.ro.duckdb")
assert llm_config.load().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/replica/aco.ro.duckdb"