haiku.rag/tests/multi_db/test_lifecycle.py
Yiorgis Gozadinos cb9f945c79
Give every client shape a way to be released
`close()` refused a client covering a set, which left one with no method to
call: `async with` was the only lifecycle it had. `aclose()` runs that teardown
for a caller that owns the client some other way, whatever it covers, and
nothing to release is not an error, so it is safe before entering and after
closing.

`close()` stays what it is, one connection and nothing else, and says so:
draining the background vacuum and releasing the embedder and reranker are
awaitable.
2026-08-27 18:04:27 +03:00

632 lines
24 KiB
Python

"""Opening, borrowing and closing the databases a client covers."""
import asyncio
import pytest
from docling_core.types.doc.document import DoclingDocument
from docling_core.types.doc.labels import DocItemLabel
from haiku.rag.client import HaikuRAG
from haiku.rag.client.session import FederatedSession
from haiku.rag.config import get_config
from haiku.rag.store.exceptions import (
AmbiguousDatabaseError,
SourceUnavailableError,
)
from haiku.rag.store.models import Chunk
from tests.multi_db.helpers import (
_config,
_seed,
)
class TestOpeningDatabases:
@pytest.mark.asyncio
async def test_missing_databases_open_together(self, tmp_path):
"""A cold fan-out costs one open, not their sum. On object storage a
serial loop is the difference between one round trip and N."""
names = ["alpha", "beta", "gamma"]
config = _config(tmp_path, names)
for name in names:
await _seed(config, name, [f"{name} document about cats"])
async with HaikuRAG(config=config) as rag:
assert isinstance(rag._session, FederatedSession)
barrier = asyncio.Barrier(len(names))
open_one = rag._session._open
async def gated(name):
# Every open has to be in flight before any of them finishes, so
# a serial loop cannot get past this and the wait times out.
await barrier.wait()
await open_one(name)
rag._session._open = gated
clients = await asyncio.wait_for(rag.clients_for(names), timeout=15)
assert {client.source for client in clients} == set(names)
@pytest.mark.asyncio
async def test_a_failed_open_does_not_leak_the_ones_that_worked(self, tmp_path):
"""Opening together means a failure has siblings already open. They are
tracked before it is reported, so closing the set closes them."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
config.lancedb.databases["beta"] = str(tmp_path / "absent.lancedb")
async with HaikuRAG(config=config) as rag:
with pytest.raises(SourceUnavailableError, match="beta"):
await rag.clients_for(["alpha", "beta"])
assert isinstance(rag._session, FederatedSession)
assert set(rag._session._sessions) == {"alpha"}
@pytest.mark.asyncio
async def test_a_cancelled_open_does_not_leak_the_ones_that_worked(self, tmp_path):
"""Cancellation discards the fan-out's results rather than returning them,
so a database that opened while a sibling was still pending is reachable
only because the opener recorded it."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
await _seed(config, "beta", ["beta document about cats"])
async with HaikuRAG(config=config) as rag:
assert isinstance(rag._session, FederatedSession)
open_one = rag._session._open
alpha_open = asyncio.Event()
async def staged(name):
if name == "beta":
await asyncio.sleep(60)
await open_one(name)
alpha_open.set()
rag._session._open = staged
fanout = asyncio.create_task(rag.clients_for(["alpha", "beta"]))
await asyncio.wait_for(alpha_open.wait(), timeout=15)
fanout.cancel()
with pytest.raises(asyncio.CancelledError):
await fanout
assert set(rag._session._sessions) == {"alpha"}
alpha = rag._session._sessions["alpha"]
assert not alpha.store.db.is_open()
@pytest.mark.asyncio
async def test_a_database_named_twice_is_opened_once(self, tmp_path):
"""Fusion would count a repeated database as two rank lists."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
await _seed(config, "beta", ["beta document about cats"])
async with HaikuRAG(config=config) as rag:
clients = await rag.clients_for(["alpha", "alpha", "beta"])
assert [client.source for client in clients] == ["alpha", "beta"]
@pytest.mark.asyncio
async def test_a_database_named_twice_returns_each_result_once(self, tmp_path):
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
await _seed(config, "beta", ["beta document about cats"])
async with HaikuRAG(config=config) as rag:
results = await rag.search(
"cats", limit=10, search_type="fts", sources=["alpha", "alpha"]
)
assert [r.source for r in results] == ["alpha"]
@pytest.mark.asyncio
async def test_one_database_named_twice_is_still_that_database(self, tmp_path):
"""A client covering a single named database compares the selection
against its own name, so repeats have to collapse first."""
config = _config(tmp_path, ["alpha"])
await _seed(config, "alpha", ["alpha document about cats"])
async with HaikuRAG(config=config) as rag:
covering = await rag.clients_covering(["alpha", "alpha"])
assert [client.source for client in covering] == ["alpha"]
class TestReportingWhereADatabaseIs:
@pytest.mark.asyncio
async def test_one_database_reports_its_location_and_a_set_none(self, tmp_path):
"""A set has no single location to report. What the CLI and the info
modal print comes from here."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
await _seed(config, "beta", ["beta document about cats"])
async with HaikuRAG(config=config) as covering:
assert covering.location is None
async with HaikuRAG(config=config, sources=["alpha"]) as one:
assert one.location == tmp_path / "alpha.lancedb"
class TestClosingASet:
@pytest.mark.asyncio
async def test_every_database_opened_is_released(self, tmp_path):
"""A covered database owns an embedder and may owe a vacuum. Closing only
its connection would leave both behind."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha one"])
await _seed(config, "beta", ["beta one"])
released: list[str | None] = []
drained: list[str | None] = []
async with HaikuRAG(config=config, read_only=True) as rag:
assert isinstance(rag._session, FederatedSession)
await rag.clients_for(["alpha", "beta"])
for name, session in rag._session._sessions.items():
original = session.store.embedder.aclose
drain = session.drain_vacuum
async def release(_original=original, _name=name):
released.append(_name)
return await _original()
async def drain_it(_drain=drain, _name=name):
drained.append(_name)
return await _drain()
session.store.embedder.aclose = release
session.drain_vacuum = drain_it
assert sorted(released) == ["alpha", "beta"]
assert sorted(drained) == ["alpha", "beta"]
class TestBorrowedDatabases:
"""A client for one of a set wraps a database the set opened."""
@pytest.mark.asyncio
async def test_closing_a_borrowed_client_leaves_the_set_working(self, tmp_path):
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
await _seed(config, "beta", ["beta document about cats"])
async with HaikuRAG(config=config) as rag:
(alpha,) = await rag.clients_for(["alpha"])
store = alpha.store
alpha.close()
assert store.db.is_open(), "close() closed a database it borrowed"
await alpha.__aexit__(None, None, None)
assert store.db.is_open(), "exit closed a database it borrowed"
results = await rag.search("cats", search_type="fts")
assert {r.source for r in results} == {"alpha", "beta"}
assert not store.db.is_open(), "the set left a database open"
@pytest.mark.asyncio
async def test_entering_a_borrowed_client_reuses_its_database(self, tmp_path):
"""`async with` on a borrowed client is a plausible thing to write.
Opening a second session would leak it, since teardown declines to close
what this client did not open."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
async with HaikuRAG(config=config) as rag:
(alpha,) = await rag.clients_for(["alpha"])
borrowed = alpha.store
async with alpha as entered:
assert entered is alpha
assert alpha.store is borrowed, "entry opened a second database"
assert borrowed.db.is_open(), "exit closed a database it borrowed"
assert alpha.store is borrowed
assert not borrowed.db.is_open(), "the set left a database open"
@pytest.mark.asyncio
async def test_a_borrowed_client_releases_what_it_built(self, tmp_path):
"""Its reranker is its own; the database it wraps is not."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
closed: list[str] = []
class Reranker:
async def aclose(self):
closed.append("reranker")
async with HaikuRAG(config=config) as rag:
(alpha,) = await rag.clients_for(["alpha"])
alpha.__dict__["_own_reranker"] = Reranker()
assert closed == ["reranker"]
class TestReleasingAClient:
"""`async with` is the usual lifecycle, and `aclose` is it for a caller that
owns the client some other way. `close` is a connection, not a lifecycle."""
@pytest.mark.asyncio
async def test_aclose_releases_a_set(self, tmp_path):
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
await _seed(config, "beta", ["beta document about cats"])
rag = HaikuRAG(config=config)
await rag.__aenter__()
alpha, beta = await rag.clients_for(["alpha", "beta"])
assert alpha.store.db.is_open()
await rag.aclose()
assert not alpha.store.db.is_open()
assert not beta.store.db.is_open()
@pytest.mark.asyncio
async def test_aclose_releases_one_database(self, tmp_path):
config = _config(tmp_path, ["alpha"])
await _seed(config, "alpha", ["alpha document about cats"])
rag = HaikuRAG(config=config)
await rag.__aenter__()
assert rag.store.db.is_open()
await rag.aclose()
assert not rag.store.db.is_open()
@pytest.mark.asyncio
async def test_aclose_before_entering_does_nothing(self, tmp_path):
"""Nothing was opened, so there is nothing to release and no error."""
config = _config(tmp_path, ["alpha"])
await _seed(config, "alpha", ["alpha document about cats"])
await HaikuRAG(config=config).aclose()
@pytest.mark.asyncio
async def test_aclose_twice_releases_once(self, tmp_path):
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
closed: list[str] = []
rag = HaikuRAG(config=config)
await rag.__aenter__()
(alpha,) = await rag.clients_for(["alpha"])
session = rag._session
assert isinstance(session, FederatedSession)
real = session.aclose
async def counting():
closed.append("set")
await real()
session.aclose = counting # ty: ignore[invalid-assignment]
await rag.aclose()
await rag.aclose()
assert closed == ["set"]
assert not alpha.store.db.is_open()
@pytest.mark.asyncio
async def test_close_refuses_a_set_and_names_aclose(self, tmp_path):
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
async with HaikuRAG(config=config) as rag:
with pytest.raises(AmbiguousDatabaseError, match="aclose"):
rag.close()
class TestSharingTheReranker:
@pytest.mark.asyncio
async def test_the_set_builds_and_closes_one_reranker(self, tmp_path, monkeypatch):
"""A local reranker loads model weights, so one per database in a set
would load the same weights that many times."""
import haiku.rag.client as client_module
built: list[object] = []
closed: list[object] = []
class Reranker:
def __init__(self):
built.append(self)
async def aclose(self):
closed.append(self)
monkeypatch.setattr(client_module, "get_reranker", lambda config: Reranker())
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
await _seed(config, "beta", ["beta document about cats"])
async with HaikuRAG(config=config) as rag:
alpha, beta = await rag.clients_for(["alpha", "beta"])
assert alpha.reranker is beta.reranker is rag.reranker
assert len(built) == 1
assert closed == built
class TestLazyOpening:
@pytest.mark.asyncio
async def test_entering_opens_nothing(self, tmp_path):
"""25 configured databases queried a few at a time must not all open."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
await _seed(config, "beta", ["beta document about cats"])
async with HaikuRAG(config=config) as rag:
assert rag._clients == {}
@pytest.mark.asyncio
async def test_only_the_selected_database_opens(self, tmp_path):
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha document about cats"])
await _seed(config, "beta", ["beta document about cats"])
async with HaikuRAG(config=config) as rag:
await rag.search("cats", search_type="fts", sources=["alpha"])
assert list(rag._clients) == ["alpha"]
@pytest.mark.asyncio
async def test_an_unselected_broken_database_does_not_break_a_query(self, tmp_path):
"""A database nobody asked for cannot fail a query."""
config = _config(tmp_path, ["alpha", "missing"])
await _seed(config, "alpha", ["alpha document about cats"])
async with HaikuRAG(config=config) as rag:
results = await rag.search("cats", search_type="fts", sources=["alpha"])
assert [r.source for r in results] == ["alpha"]
@pytest.mark.asyncio
async def test_a_selected_broken_database_fails_the_query(self, tmp_path):
config = _config(tmp_path, ["alpha", "missing"])
await _seed(config, "alpha", ["alpha document about cats"])
async with HaikuRAG(config=config) as rag:
with pytest.raises(SourceUnavailableError, match="missing"):
await rag.search("cats", search_type="fts")
class TestReadOnlyMode:
@pytest.mark.asyncio
async def test_a_client_covering_a_set_reports_its_mode(self, tmp_path):
"""A client covering a set has no store of its own to ask."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha one"])
await _seed(config, "beta", ["beta one"])
async with HaikuRAG(config=config, read_only=True) as rag:
assert rag.is_read_only is True
async with HaikuRAG(config=config) as rag:
assert rag.is_read_only is False
class TestFailureNaming:
@pytest.mark.asyncio
async def test_a_single_named_database_is_reported_by_name(self, tmp_path):
"""One configured database is still a named one: it must not fall back to
the raw error, which spells out the path."""
config = _config(tmp_path, ["alpha"])
with pytest.raises(SourceUnavailableError, match="alpha") as caught:
async with HaikuRAG(config=config):
pass
assert str(tmp_path) not in str(caught.value)
assert caught.value.__cause__ is None
@pytest.mark.asyncio
async def test_a_legacy_uri_client_keeps_its_error(self, tmp_path):
"""Nothing named it, so there is no name to report instead."""
with pytest.raises(FileNotFoundError):
async with HaikuRAG(tmp_path / "nope.lancedb"):
pass
@pytest.mark.asyncio
async def test_the_location_is_absent_from_the_whole_chain(self, tmp_path):
config = _config(tmp_path, ["alpha", "missing"])
await _seed(config, "alpha", ["alpha document about cats"])
with pytest.raises(SourceUnavailableError) as caught:
async with HaikuRAG(config=config) as rag:
await rag.search("cats", search_type="fts")
rendered = str(caught.value)
error = caught.value.__cause__ or caught.value.__context__
assert "missing.lancedb" not in rendered
assert error is None, "the location-bearing cause is still attached"
class TestCreatingNeedsOneDatabase:
"""Creating names a database. Covering a set, the flag had nothing to act on
and was accepted anyway, leaving the first query to fail on whichever
database turned out to be missing."""
@pytest.mark.asyncio
async def test_creating_a_set_is_refused(self, tmp_path):
config = _config(tmp_path, ["alpha", "beta"])
with pytest.raises(AmbiguousDatabaseError, match="alpha, beta"):
async with HaikuRAG(config=config, create=True):
pass
@pytest.mark.asyncio
async def test_naming_one_of_the_set_creates_it(self, tmp_path):
config = _config(tmp_path, ["alpha", "beta"])
async with HaikuRAG(config=config, create=True, sources=["alpha"]) as rag:
assert await rag.count_documents() == 0
assert (tmp_path / "alpha.lancedb").exists()
assert not (tmp_path / "beta.lancedb").exists()
@pytest.mark.asyncio
async def test_covering_a_set_without_creating_is_unaffected(self, tmp_path):
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha one"])
await _seed(config, "beta", ["beta one"])
async with HaikuRAG(config=config) as rag:
assert await rag.count_documents() == 2
class TestOperationsThatNeedOneDatabase:
@pytest.mark.asyncio
async def test_writing_names_the_databases_it_covers(self, tmp_path):
"""A domain error, so a caller can tell an unsupported selection from a
missing attribute."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha one"])
await _seed(config, "beta", ["beta one"])
async with HaikuRAG(config=config) as rag:
with pytest.raises(AmbiguousDatabaseError, match="alpha, beta"):
await rag.create_document("orphan")
with pytest.raises(AmbiguousDatabaseError, match="clients_for"):
await rag.vacuum()
with pytest.raises(AmbiguousDatabaseError, match="close"):
rag.close()
@pytest.mark.asyncio
async def test_a_set_has_no_store_of_its_own(self, tmp_path):
"""A store and its repositories belong to one database. `clients_for`
reaches the one holding a given database."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha one"])
async with HaikuRAG(config=config) as rag:
for name in (
"store",
"document_repository",
"chunk_repository",
"document_item_repository",
):
with pytest.raises(AttributeError, match=name):
getattr(rag, name)
@pytest.mark.asyncio
async def test_a_selected_database_is_still_writable(self, tmp_path):
"""Naming one of the set is how a write picks its database."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha one"])
await _seed(config, "beta", ["beta one"])
dim = get_config().embeddings.model.vector_dim
written = DoclingDocument(name="written")
written.add_text(label=DocItemLabel.TEXT, text="written")
async with HaikuRAG(config=config) as rag:
alpha = (await rag.clients_for(["alpha"]))[0]
assert alpha.is_read_only is False
document = await alpha.import_document(
written,
[Chunk(content="written", embedding=[0.1] * dim, order=0)],
uri="test://alpha/written",
)
assert await alpha.count_documents() == 2
assert document.id is not None
class TestDatabaseIndependentWork:
"""Converting, chunking and titling are functions of the configuration, not
of a database, so covering a set does not stop them."""
@pytest.mark.asyncio
async def test_chunking_opens_no_database(self, tmp_path, monkeypatch):
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha one"])
await _seed(config, "beta", ["beta one"])
opened: list[str] = []
async def refuse(self, name):
opened.append(name)
raise AssertionError("opened a database to chunk a document")
monkeypatch.setattr(FederatedSession, "_open", refuse)
doc = DoclingDocument(name="note")
doc.add_text(
label=DocItemLabel.TEXT, text="Boltzmann machines are energy based."
)
async with HaikuRAG(config=config, read_only=True) as rag:
chunks = await rag.chunk(doc)
assert opened == []
assert [c.content for c in chunks]
@pytest.mark.asyncio
async def test_the_embedder_is_built_once_and_closed_once(
self, tmp_path, monkeypatch
):
"""The parent owns the embedder it built, so leaving the context closes
it, once."""
from haiku.rag.embeddings import EmbedderWrapper
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha one"])
closed: list[object] = []
original = EmbedderWrapper.aclose
async def counting(self):
closed.append(self)
return await original(self)
monkeypatch.setattr(EmbedderWrapper, "aclose", counting)
rag = HaikuRAG(config=config, read_only=True)
async with rag:
built = rag.embedder
assert rag.embedder is built
assert closed == [built]
@pytest.mark.asyncio
async def test_re_entering_a_set_builds_a_fresh_embedder(self, tmp_path):
"""Teardown closes the embedder, so keeping it would hand the next
context one that is already closed."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha one"])
rag = HaikuRAG(config=config, read_only=True)
async with rag:
first = rag.embedder
async with rag:
assert rag.embedder is not first
@pytest.mark.asyncio
async def test_re_entering_one_database_builds_a_fresh_embedder(self, temp_db_path):
"""One database opens a new store on re-entry, and the embedder is that
store's."""
rag = HaikuRAG(temp_db_path, create=True)
async with rag:
first = rag.embedder
async with rag:
assert rag.embedder is rag.store.embedder
assert rag.embedder is not first
@pytest.mark.asyncio
async def test_a_set_nobody_asked_anything_of_builds_no_embedder(self, tmp_path):
"""Built on first use, so a client that answered nothing holds nothing."""
config = _config(tmp_path, ["alpha", "beta"])
await _seed(config, "alpha", ["alpha one"])
async with HaikuRAG(config=config, read_only=True) as rag:
assert "embedder" not in rag.__dict__
@pytest.mark.asyncio
async def test_one_database_still_uses_its_store_s_embedder(self, temp_db_path):
async with HaikuRAG(temp_db_path, create=True) as rag:
assert rag.embedder is rag.store.embedder