Files
stack/tests/conf/test_connect.py
kert 0bc53ccb7d 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.
2026-09-08 23:40:22 -04:00

419 lines
15 KiB
Python

"""Tests for conf.connect — connection factories."""
from __future__ import annotations
import os
import sqlite3
from unittest.mock import MagicMock, patch
import pytest
from conf import connect, path
_HAS_ACO_DB = path("db.aco").exists()
class TestDuckdb:
@pytest.mark.skipif(not _HAS_ACO_DB, reason="data/aco.duckdb not present")
def test_returns_connection(self):
con = connect.duckdb()
assert con is not None
result = con.execute("SELECT 1 AS x").fetchone()
assert result[0] == 1
con.close()
@pytest.mark.skipif(not _HAS_ACO_DB, reason="data/aco.duckdb not present")
def test_read_only_default(self):
con = connect.duckdb()
with pytest.raises(Exception, match="read-only"):
con.execute("CREATE TABLE _test_ro (x INT)")
con.close()
def test_custom_db_name(self, tmp_path, monkeypatch):
"""Verify connect.duckdb() resolves a custom [db] key."""
# Create a real DuckDB file so it opens without sqlite_scanner
import duckdb as _duckdb
db_file = tmp_path / "custom.duckdb"
_duckdb.connect(str(db_file)).close()
# Patch cfg.db to point "custom" at our temp file
monkeypatch.setattr(
"conf.connect.path",
lambda key: db_file if key == "db.custom" else path(key),
)
con = connect.duckdb("custom")
result = con.execute("SELECT 1 AS x").fetchone()
assert result[0] == 1
con.close()
class TestBib:
def test_returns_store(self):
store = connect.bib()
assert hasattr(store, "list_items")
assert hasattr(store, "list_tags")
store.close()
class TestZotero:
def test_returns_sqlite_connection(self):
try:
con = connect.zotero()
assert isinstance(con, sqlite3.Connection)
con.close()
except Exception:
pytest.skip("Zotero database not available")
class TestTrino:
def _mock_trino(self):
"""Create a mock trino module with dbapi.connect."""
mock_mod = MagicMock()
return mock_mod
def test_creates_connection(self):
mock_trino = self._mock_trino()
with patch.dict("sys.modules", {"trino": mock_trino}):
connect.trino()
mock_trino.dbapi.connect.assert_called_once()
kw = mock_trino.dbapi.connect.call_args.kwargs
assert kw["host"] == "trino"
assert kw["port"] == 8080
assert kw["catalog"] == "iceberg"
def test_custom_catalog(self):
mock_trino = self._mock_trino()
with patch.dict("sys.modules", {"trino": mock_trino}):
connect.trino(catalog="memory")
assert mock_trino.dbapi.connect.call_args.kwargs["catalog"] == "memory"
def test_custom_user(self):
mock_trino = self._mock_trino()
with patch.dict("sys.modules", {"trino": mock_trino}):
connect.trino(user="testuser")
assert mock_trino.dbapi.connect.call_args.kwargs["user"] == "testuser"
def test_custom_schema(self):
mock_trino = self._mock_trino()
with patch.dict("sys.modules", {"trino": mock_trino}):
connect.trino(schema="myschema")
assert mock_trino.dbapi.connect.call_args.kwargs["schema"] == "myschema"
class TestNessie:
def test_creates_client(self):
client = connect.nessie(base_url="http://localhost:19120/api/v2")
assert client.base_url == "http://localhost:19120/api/v2"
client.close()
def test_default_url_from_config(self):
client = connect.nessie()
assert "nessie" in client.base_url
client.close()
class TestPolaris:
def test_creates_client_without_secret(self):
client = connect.polaris(base_url="http://localhost:8181/api/catalog")
assert client.base_url == "http://localhost:8181/api/catalog"
client.close()
def test_default_url_from_config(self):
client = connect.polaris()
assert "polaris" in client.base_url
client.close()
class TestS3:
def test_creates_client(self):
client = connect.s3()
assert client.bucket == "lakehouse"
client.close()
def test_custom_bucket(self):
client = connect.s3(bucket="exports")
assert client.bucket == "exports"
client.close()
def test_reads_env_vars(self):
with patch.dict(
"os.environ",
{
"RUSTFS_ENDPOINT": "http://test:9000",
"RUSTFS_ACCESS_KEY": "testkey",
"RUSTFS_SECRET_KEY": "testsecret",
},
):
client = connect.s3()
assert client.endpoint == "http://test:9000"
assert client.access_key == "testkey"
assert client.secret_key == "testsecret"
client.close()
class TestObstore:
def test_creates_s3store_and_sets_env(self):
mock_store = MagicMock()
env = {
"RUSTFS_ENDPOINT": "http://test:9000",
"RUSTFS_ACCESS_KEY": "ak",
"RUSTFS_SECRET_KEY": "sk",
}
with patch("conf.connect.cfg") as mock_cfg:
mock_cfg.s3.endpoint = "http://rustfs:9000"
with patch.dict("os.environ", env, clear=False):
with patch(
"obstore.store.S3Store", return_value=mock_store
) as mock_cls:
result = connect.obstore(bucket="mybucket")
mock_cls.assert_called_once_with("mybucket")
assert result is mock_store
assert os.environ["AWS_ENDPOINT_URL"] == "http://test:9000"
assert os.environ["AWS_ACCESS_KEY_ID"] == "ak"
assert os.environ["AWS_SECRET_ACCESS_KEY"] == "sk"
assert os.environ["AWS_ALLOW_HTTP"] == "true"
def test_default_bucket(self):
mock_store = MagicMock()
with patch("conf.connect.cfg") as mock_cfg:
mock_cfg.s3.endpoint = "http://rustfs:9000"
with patch("obstore.store.S3Store", return_value=mock_store) as mock_cls:
connect.obstore()
assert mock_cls.call_args.args[0] == "lakehouse"
class TestTheme:
def test_activates_altair_theme(self):
connect.theme()
import altair as alt
assert alt.theme.active == "fhirworx"
class TestDuckdbBatch:
def _patch_path(self, monkeypatch, db_file):
monkeypatch.setattr(
"conf.connect.path",
lambda key: db_file if key == "db.custom" else path(key),
)
def test_yields_writable_connection_and_closes(self, tmp_path, monkeypatch):
import duckdb as _duckdb
db_file = tmp_path / "custom.duckdb"
_duckdb.connect(str(db_file)).close()
self._patch_path(monkeypatch, db_file)
with connect.duckdb_batch("custom") as con:
con.execute("CREATE TABLE t (x INT)")
con.execute("INSERT INTO t VALUES (1)")
kept = con
# closed on exit
with pytest.raises(Exception):
kept.execute("SELECT 1")
# write persisted
con2 = connect.duckdb("custom")
assert con2.execute("SELECT count(*) FROM t").fetchone()[0] == 1
con2.close()
def test_lock_held_raises_actionable_error(self, tmp_path, monkeypatch):
"""A connection held by another process blocks the writer — after
retries the error names the holders instead of a raw IOException
(#508). Same-process connections share one DuckDB instance, so the
holder must be a subprocess."""
import subprocess
import sys
import duckdb as _duckdb
db_file = tmp_path / "custom.duckdb"
_duckdb.connect(str(db_file)).close()
self._patch_path(monkeypatch, db_file)
holder = subprocess.Popen(
[
sys.executable,
"-c",
"import time, duckdb; "
f"con = duckdb.connect({str(db_file)!r}); "
"print('locked', flush=True); time.sleep(60)",
],
stdout=subprocess.PIPE,
text=True,
)
try:
assert holder.stdout is not None
assert holder.stdout.readline().strip() == "locked"
with pytest.raises(RuntimeError, match="(?s)write lock.*Holders"):
with connect.duckdb_batch("custom", retries=2, backoff_s=0.05):
pass
finally:
holder.kill()
holder.wait()
def test_closes_on_exception_in_body(self, tmp_path, monkeypatch):
import duckdb as _duckdb
db_file = tmp_path / "custom.duckdb"
_duckdb.connect(str(db_file)).close()
self._patch_path(monkeypatch, db_file)
with pytest.raises(ValueError, match="boom"):
with connect.duckdb_batch("custom") as con:
raise ValueError("boom")
# lock released — a fresh writer can open immediately
with connect.duckdb_batch("custom", retries=1) as con:
con.execute("SELECT 1")
class TestReplica:
def _patch_path(self, monkeypatch, db_file):
monkeypatch.setattr(
"conf.connect.path",
lambda key: db_file if key == "db.custom" else path(key),
)
def _make_primary(self, tmp_path):
import duckdb as _duckdb
db_file = tmp_path / "custom.duckdb"
con = _duckdb.connect(str(db_file))
con.execute("CREATE TABLE t AS SELECT 1 AS x")
con.close()
return db_file
def test_publish_creates_consistent_snapshot(self, tmp_path, monkeypatch):
db_file = self._make_primary(tmp_path)
self._patch_path(monkeypatch, db_file)
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)
assert con.execute("SELECT x FROM t").fetchone()[0] == 1
con.close()
def test_read_only_resolves_to_replica(self, tmp_path, monkeypatch):
db_file = self._make_primary(tmp_path)
self._patch_path(monkeypatch, db_file)
connect.publish_replica("custom")
# diverge the primary so we can tell which file we read
with connect.duckdb_batch("custom") as con:
con.execute("UPDATE t SET x = 2")
assert connect.duckdb("custom").execute("SELECT x FROM t").fetchone()[0] == 1
# explicit opt-out reads the live primary
assert (
connect.duckdb("custom", replica=False)
.execute("SELECT x FROM t")
.fetchone()[0]
== 2
)
# env kill-switch
monkeypatch.setenv("STACK_DUCKDB_REPLICA", "0")
assert connect.duckdb("custom").execute("SELECT x FROM t").fetchone()[0] == 2
def test_no_replica_falls_back_to_primary(self, tmp_path, monkeypatch):
db_file = self._make_primary(tmp_path)
self._patch_path(monkeypatch, db_file)
assert connect.duckdb("custom").execute("SELECT x FROM t").fetchone()[0] == 1
def test_write_opens_never_use_replica(self, tmp_path, monkeypatch):
db_file = self._make_primary(tmp_path)
self._patch_path(monkeypatch, db_file)
connect.publish_replica("custom")
con = connect.duckdb("custom", read_only=False)
con.execute("UPDATE t SET x = 3")
con.close()
assert (
connect.duckdb("custom", replica=False)
.execute("SELECT x FROM t")
.fetchone()[0]
== 3
)
def test_publish_follows_symlinked_replica(self, tmp_path, monkeypatch):
"""A symlinked replica path (worktree leaf-symlink pattern) must be
followed: publish updates the shared target, not the symlink."""
shared = tmp_path / "shared"
shared.mkdir()
db_file = self._make_primary(tmp_path)
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
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 link.is_symlink(), "publish detached the symlink into a standalone copy"
import duckdb as _duckdb
con = _duckdb.connect(str(target), read_only=True)
assert con.execute("SELECT x FROM t").fetchone()[0] == 7
con.close()
assert dst == target
def test_republish_refreshes_atomically(self, tmp_path, monkeypatch):
db_file = self._make_primary(tmp_path)
self._patch_path(monkeypatch, db_file)
connect.publish_replica("custom")
with connect.duckdb_batch("custom") as con:
con.execute("UPDATE t SET x = 9")
connect.publish_replica("custom")
assert connect.duckdb("custom").execute("SELECT x FROM t").fetchone()[0] == 9
class TestDucklakeFactory:
def test_returns_caller_owned_lake_connection(self, tmp_path, monkeypatch):
"""connect.ducklake() with a file catalog: connection survives the
internal context's GC and resolves lake tables as the default db."""
from aco.lake import DuckLakeContext
# Seed a lake with a file catalog + local data path
seed = DuckLakeContext(
catalog_dsn=str(tmp_path / "cat.ducklake"),
data_path=str(tmp_path / "data") + "/",
read_only=False,
)
import narwhals as nw
import polars as pl
seed.save(
"opps.addendum_b",
nw.from_native(pl.DataFrame({"hcpcs": ["Q4186"], "year": [2026]})),
mode="replace",
)
seed.close()
from types import SimpleNamespace
_dl = SimpleNamespace(
catalog=str(tmp_path / "cat.ducklake"),
catalog_ro=str(tmp_path / "cat.ducklake"),
data_path=str(tmp_path / "data") + "/",
s3_endpoint="",
)
# cfg.lake materializes fresh objects per access — patch the whole
# cfg reference connect.ducklake() reads.
monkeypatch.setattr(
connect, "cfg", SimpleNamespace(lake=SimpleNamespace(ducklake=_dl))
)
import gc
con = connect.ducklake()
gc.collect() # the internal context must not close our connection
assert con.execute("SELECT count(*) FROM opps.addendum_b").fetchone()[0] == 1
with pytest.raises(Exception, match='type "CREATE"'):
con.execute("CREATE TABLE opps.nope (x INT)")
con.close()