Files
mempalace--mempalace/tests/test_sqlite_exact_backend.py
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

623 lines
22 KiB
Python

import math
import sqlite3
import threading
import pytest
from _chroma_palace_helper import make_minimal_chroma_sqlite, make_minimal_sqlite_exact_sqlite
import mempalace.backends.sqlite_exact as sqlite_exact_module
from mempalace.backends import (
BackendMismatchError,
CollectionNotInitializedError,
DimensionMismatchError,
PalaceRef,
QueryResult,
UnsupportedCapabilityError,
available_backends,
)
from mempalace.backends.sqlite_exact import SQLiteExactBackend
def _collection(tmp_path, name="mempalace_drawers", create=True):
backend = SQLiteExactBackend()
palace = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
return backend, backend.get_collection(palace=palace, collection_name=name, create=create)
def test_sqlite_exact_missing_collection_error_names_collection(tmp_path):
"""CollectionNotInitializedError must identify the missing collection, not
the palace path — consistent with line 287 and the other backends."""
backend, _ = _collection(tmp_path, name="mempalace_drawers")
palace = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
with pytest.raises(CollectionNotInitializedError) as exc:
backend.get_collection(palace=palace, collection_name="does_not_exist", create=False)
assert "does_not_exist" in str(exc.value)
assert str(tmp_path) not in str(exc.value)
with pytest.raises(CollectionNotInitializedError) as exc2:
backend.delete_collection(str(tmp_path), "also_missing")
assert "also_missing" in str(exc2.value)
assert str(tmp_path) not in str(exc2.value)
def test_registry_exposes_sqlite_exact():
assert "sqlite_exact" in available_backends()
def test_sqlite_exact_add_query_filters_and_persistence(tmp_path):
backend, col = _collection(tmp_path)
col.add(
ids=["a", "b", "c"],
documents=[
"alpha vector memory",
"beta sqlite exact memory",
"gamma filtered memory",
],
metadatas=[
{"wing": "alpha", "room": "notes", "chunk_index": 0, "tags": "core,vector"},
{"wing": "alpha", "room": "notes", "chunk_index": 1, "tags": "sqlite,exact"},
{"wing": "gamma", "room": "archive", "chunk_index": 2, "tags": "old"},
],
embeddings=[[1.0, 0.0], [0.0, 1.0], [0.2, 0.8]],
)
ranked = col.query(query_embeddings=[[1.0, 0.0]], n_results=3)
assert ranked.ids[0] == ["a", "c", "b"]
assert ranked.distances[0][0] == pytest.approx(0.0)
filtered = col.get(
where={
"$and": [
{"wing": "alpha"},
{"chunk_index": {"$gte": 1}},
{"tags": {"$contains": "sqlite"}},
]
},
include=["documents", "metadatas", "embeddings"],
)
assert filtered.ids == ["b"]
assert filtered.documents == ["beta sqlite exact memory"]
assert filtered.embeddings == [[0.0, 1.0]]
col.update(ids=["b"], metadatas=[{"room": "lab"}])
assert col.get(ids=["b"]).metadatas[0]["room"] == "lab"
backend.close_palace(str(tmp_path))
reopened = backend.get_collection(
palace=PalaceRef(id=str(tmp_path), local_path=str(tmp_path)),
collection_name="mempalace_drawers",
create=False,
)
assert reopened.count() == 3
assert reopened.get(ids=["a"]).documents == ["alpha vector memory"]
def test_sqlite_exact_write_failure_rolls_back_whole_batch(tmp_path):
_backend, col = _collection(tmp_path)
with pytest.raises(Exception):
col.add(
ids=["dup", "dup"],
documents=["first write", "duplicate write"],
metadatas=[{}, {}],
embeddings=[[1.0, 0.0], [0.0, 1.0]],
)
assert col.count() == 0
def test_sqlite_exact_enforces_collection_dimension(tmp_path):
_backend, col = _collection(tmp_path)
col.add(ids=["a"], documents=["two dims"], metadatas=[{}], embeddings=[[1.0, 0.0]])
with pytest.raises(DimensionMismatchError):
col.add(ids=["b"], documents=["three dims"], metadatas=[{}], embeddings=[[1.0, 0.0, 0.0]])
with pytest.raises(DimensionMismatchError):
col.upsert(
ids=["b"], documents=["three dims"], metadatas=[{}], embeddings=[[1.0, 0.0, 0.0]]
)
with pytest.raises(DimensionMismatchError):
col.update(ids=["a"], embeddings=[[1.0, 0.0, 0.0]])
with pytest.raises(DimensionMismatchError):
col.query(query_embeddings=[[1.0, 0.0, 0.0]], n_results=1)
assert col.count() == 1
assert col.get(ids=["a"]).documents == ["two dims"]
def test_sqlite_exact_get_preserves_requested_id_order_and_duplicates(tmp_path):
_backend, col = _collection(tmp_path)
col.add(
ids=["a", "b"],
documents=["doc a", "doc b"],
metadatas=[{}, {}],
embeddings=[[1, 0], [0, 1]],
)
result = col.get(ids=["b", "a", "b"], include=["documents"])
assert result.ids == ["b", "a", "b"]
assert result.documents == ["doc b", "doc a", "doc b"]
def _doc_select_sql(col, action):
"""Run ``action`` while tracing SQL; return (result, [documents SELECTs]).
The documents-table scan in ``_rows`` is the only statement that is both
``FROM documents`` and ``ORDER BY rowid`` (``count`` lacks the ORDER BY),
so filtering on both isolates it from collection-id lookups and commits.
"""
statements = []
conn = col._handle.conn
conn.set_trace_callback(statements.append)
try:
result = action()
finally:
conn.set_trace_callback(None)
selects = [s for s in statements if "FROM documents" in s and "ORDER BY rowid" in s]
return result, selects
def _seed(col, n):
col.add(
ids=[f"d{i}" for i in range(n)],
documents=[f"doc {i}" for i in range(n)],
metadatas=[{"wing": "w", "n": i} for i in range(n)],
embeddings=[[float(i), 1.0] for i in range(n)],
)
def test_sqlite_exact_get_unfiltered_page_pushes_limit_offset(tmp_path):
_backend, col = _collection(tmp_path)
_seed(col, 10)
result, selects = _doc_select_sql(
col, lambda: col.get(limit=3, offset=2, include=["documents"])
)
assert result.ids == ["d2", "d3", "d4"]
assert result.documents == ["doc 2", "doc 3", "doc 4"]
assert len(selects) == 1
assert "LIMIT" in selects[0]
assert "OFFSET" in selects[0]
def test_sqlite_exact_get_filtered_page_stays_on_full_scan(tmp_path):
_backend, col = _collection(tmp_path)
_seed(col, 6)
# With a filter the rows are dropped after the scan, so LIMIT/OFFSET must
# not reach SQL; the page is taken in Python over the filtered rows.
result, selects = _doc_select_sql(
col,
lambda: col.get(where={"wing": "w"}, limit=2, offset=1, include=["metadatas"]),
)
assert result.ids == ["d1", "d2"]
assert len(selects) == 1
assert "LIMIT" not in selects[0]
assert "OFFSET" not in selects[0]
def test_sqlite_exact_get_offset_only_and_limit_only_push(tmp_path):
_backend, col = _collection(tmp_path)
_seed(col, 5)
limit_only, limit_sql = _doc_select_sql(col, lambda: col.get(limit=2))
assert limit_only.ids == ["d0", "d1"]
assert len(limit_sql) == 1
assert "LIMIT" in limit_sql[0]
assert "OFFSET" not in limit_sql[0]
offset_only, offset_sql = _doc_select_sql(col, lambda: col.get(offset=3))
assert offset_only.ids == ["d3", "d4"]
assert len(offset_sql) == 1
assert "OFFSET" in offset_sql[0]
# SQLite requires a LIMIT before OFFSET; an offset-only page uses LIMIT -1.
assert "LIMIT" in offset_sql[0]
def test_sqlite_exact_get_negative_bounds_use_python_slice(tmp_path):
_backend, col = _collection(tmp_path)
_seed(col, 5)
# Negative limit means Python "all but last", which a SQL LIMIT (negative ==
# unbounded in SQLite) cannot express, so it must stay on the slice path.
neg_limit, neg_limit_sql = _doc_select_sql(col, lambda: col.get(limit=-1))
assert neg_limit.ids == ["d0", "d1", "d2", "d3"]
assert len(neg_limit_sql) == 1
assert "LIMIT" not in neg_limit_sql[0]
# Negative offset means Python "last N"; it must not reach SQL either.
neg_offset, neg_offset_sql = _doc_select_sql(col, lambda: col.get(offset=-2))
assert neg_offset.ids == ["d3", "d4"]
assert len(neg_offset_sql) == 1
assert "OFFSET" not in neg_offset_sql[0]
def test_sqlite_exact_get_pages_tile_without_overlap(tmp_path):
_backend, col = _collection(tmp_path)
_seed(col, 10)
seen = []
offset = 0
while True:
page = col.get(limit=4, offset=offset)
if not page.ids:
break
seen.extend(page.ids)
offset += len(page.ids)
assert seen == [f"d{i}" for i in range(10)]
# The same set, same rowid order, as a single unfiltered scan.
assert col.get().ids == seen
def test_sqlite_exact_get_limit_zero_pushes_empty_page(tmp_path):
_backend, col = _collection(tmp_path)
_seed(col, 3)
# limit=0 is a real bound, not "no limit": it pushes LIMIT 0 and returns
# nothing, matching the old rows[:0] slice. Guards the `is not None` check
# against an `if limit:` regression that would treat 0 as unbounded.
result, selects = _doc_select_sql(col, lambda: col.get(limit=0))
assert result.ids == []
assert len(selects) == 1
assert "LIMIT" in selects[0]
def test_sqlite_exact_get_offset_zero_is_a_full_scan(tmp_path):
_backend, col = _collection(tmp_path)
_seed(col, 3)
# offset=0 with no limit is not a page request, so it stays on the full scan.
result, selects = _doc_select_sql(col, lambda: col.get(offset=0))
assert result.ids == ["d0", "d1", "d2"]
assert len(selects) == 1
assert "LIMIT" not in selects[0]
assert "OFFSET" not in selects[0]
def test_sqlite_exact_get_ids_with_page_slices_in_python(tmp_path):
_backend, col = _collection(tmp_path)
_seed(col, 5)
# ids force the Python path even with a page: the requested order is kept,
# then offset/limit slice the reordered list with no SQL LIMIT/OFFSET.
result, selects = _doc_select_sql(
col, lambda: col.get(ids=["d4", "d3", "d2", "d1"], offset=1, limit=2)
)
assert result.ids == ["d3", "d2"]
assert len(selects) == 1
assert "LIMIT" not in selects[0]
assert "OFFSET" not in selects[0]
def test_sqlite_exact_upsert_delete_and_multi_collection_isolation(tmp_path):
backend, drawers = _collection(tmp_path, "drawers")
palace = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
closets = backend.get_collection(palace=palace, collection_name="closets", create=True)
drawers.upsert(
ids=["same"], documents=["drawer one"], metadatas=[{"kind": "drawer"}], embeddings=[[1, 0]]
)
closets.upsert(
ids=["same"], documents=["closet one"], metadatas=[{"kind": "closet"}], embeddings=[[0, 1]]
)
drawers.upsert(
ids=["same"],
documents=["drawer replaced"],
metadatas=[{"kind": "drawer", "version": 2}],
embeddings=[[1, 0]],
)
assert drawers.count() == 1
assert closets.count() == 1
assert drawers.get(ids=["same"]).documents == ["drawer replaced"]
assert closets.get(ids=["same"]).documents == ["closet one"]
drawers.delete(where={"version": {"$in": [2, 3]}})
assert drawers.count() == 0
assert closets.count() == 1
def test_sqlite_exact_lexical_search_and_python_fallback(tmp_path, monkeypatch):
_backend, col = _collection(tmp_path)
col.add(
ids=["a", "b", "c"],
documents=[
"ordinary project note",
"rareterm rareterm sqlite exact note",
"rareterm unrelated archive",
],
metadatas=[
{"wing": "w", "room": "a"},
{"wing": "w", "room": "b"},
{"wing": "old", "room": "b"},
],
embeddings=[[1, 0], [0, 1], [0.5, 0.5]],
)
hits = col.lexical_search(query="rareterm sqlite", n_results=2, where={"wing": "w"}).hits
assert [hit.id for hit in hits] == ["b"]
monkeypatch.setattr(col, "_fts_available", lambda _cur: False)
fallback_hits = col.lexical_search(query="rareterm sqlite", n_results=2).hits
assert fallback_hits[0].id == "b"
def test_sqlite_exact_lexical_search_filters_after_full_fts_window(tmp_path):
_backend, col = _collection(tmp_path)
ids = [f"old-{i}" for i in range(12)] + ["target"]
col.add(
ids=ids,
documents=["needle shared lexical note" for _ in ids],
metadatas=[{"wing": "old"} for _ in range(12)] + [{"wing": "target"}],
embeddings=[[1.0, 0.0] for _ in ids],
)
hits = col.lexical_search(query="needle", n_results=1, where={"wing": "target"}).hits
assert [hit.id for hit in hits] == ["target"]
def test_sqlite_exact_logical_filters_evaluate_sibling_predicates(tmp_path):
_backend, col = _collection(tmp_path)
col.add(
ids=["a", "b"],
documents=["alpha document", "beta document"],
metadatas=[
{"wing": "w", "room": "wrong", "kind": "note"},
{"wing": "w", "room": "right", "kind": "note"},
],
embeddings=[[1, 0], [0, 1]],
)
result = col.get(where={"$and": [{"wing": "w"}], "room": "right"})
assert result.ids == ["b"]
def test_sqlite_exact_close_palace_marks_existing_collections_closed(tmp_path):
backend, col = _collection(tmp_path)
palace = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
col.add(ids=["a"], documents=["doc"], metadatas=[{}], embeddings=[[1, 0]])
backend.close_palace(palace)
assert not col.health().ok
with pytest.raises(Exception):
col.count()
def test_palace_wrapper_embeds_for_sqlite_exact(tmp_path, monkeypatch):
import mempalace.backends.embedding_wrapper as embedding_wrapper
from mempalace.palace import get_collection
monkeypatch.setenv("MEMPALACE_BACKEND_EXPLICIT", "sqlite_exact")
monkeypatch.setattr(
embedding_wrapper,
"_embed_texts",
lambda texts: [[float(len(text)), 1.0] for text in texts],
)
col = get_collection(str(tmp_path), create=True)
col.add(ids=["a"], documents=["abcd"], metadatas=[{"wing": "w"}])
result = col.query(query_texts=["abcd"], n_results=1)
assert result.ids == [["a"]]
def test_backend_mismatch_protection(tmp_path, monkeypatch):
from mempalace.palace import get_collection
make_minimal_chroma_sqlite(tmp_path)
monkeypatch.setenv("MEMPALACE_BACKEND_EXPLICIT", "sqlite_exact")
with pytest.raises(BackendMismatchError):
get_collection(str(tmp_path), create=True)
def test_mixed_backend_artifacts_are_rejected_even_when_chroma_selected(tmp_path, monkeypatch):
from mempalace.palace import resolve_backend_name
make_minimal_chroma_sqlite(tmp_path)
make_minimal_sqlite_exact_sqlite(tmp_path)
monkeypatch.setenv("MEMPALACE_BACKEND_EXPLICIT", "chroma")
with pytest.raises(BackendMismatchError):
resolve_backend_name(str(tmp_path))
def test_sqlite_exact_detect_matches_palace_with_sqlite_header(tmp_path):
"""A real SQLite database at ``<path>/sqlite_exact.sqlite3`` registers
as sqlite_exact. Mirrors the chroma analog at
``test_chroma_detect_matches_palace_with_sqlite_header``.
"""
make_minimal_sqlite_exact_sqlite(tmp_path)
assert SQLiteExactBackend.detect(str(tmp_path)) is True
assert SQLiteExactBackend.detect(str(tmp_path.parent)) is False
def test_sqlite_exact_detect_rejects_empty_sqlite_exact_sqlite(tmp_path):
"""A 0-byte ``sqlite_exact.sqlite3`` is not a sqlite_exact palace (#1893).
Same root cause as the chroma side: bare ``sqlite3.connect()`` against
a missing path leaves a 0-byte file behind because the SQLite header is
written on the first statement, not on connect. Detection must reject
that artifact so it cannot trip ``BackendMismatchError`` against a real
non-sqlite_exact backend marker in the same directory.
"""
(tmp_path / "sqlite_exact.sqlite3").write_bytes(b"")
assert SQLiteExactBackend.detect(str(tmp_path)) is False
def test_sqlite_exact_detect_rejects_non_sqlite_file(tmp_path):
"""A non-SQLite file at the ``sqlite_exact.sqlite3`` path is not
sqlite_exact. Defends against partial writes / garbage content / anything
that lands at the canonical path but isn't actually a SQLite database.
"""
(tmp_path / "sqlite_exact.sqlite3").write_bytes(b"not a sqlite file" * 4)
assert SQLiteExactBackend.detect(str(tmp_path)) is False
def test_sqlite_exact_exact_ranking_uses_cosine(tmp_path):
_backend, col = _collection(tmp_path)
halfway = [0.5, math.sqrt(0.75)]
col.add(
ids=["half", "orthogonal", "same"],
documents=["half", "orthogonal", "same"],
metadatas=[{}, {}, {}],
embeddings=[halfway, [0.0, 1.0], [1.0, 0.0]],
)
result = col.query(query_embeddings=[[1.0, 0.0]], n_results=3)
assert result.ids[0] == ["same", "half", "orthogonal"]
assert result.distances[0] == pytest.approx([0.0, 0.5, 1.0])
def test_search_union_uses_sqlite_exact_lexical_search(tmp_path, monkeypatch):
import mempalace.backends.embedding_wrapper as embedding_wrapper
from mempalace.palace import get_collection
from mempalace.searcher import search_memories
def fake_embed(texts):
vectors = []
for text in texts:
if text == "rareterm":
vectors.append([1.0, 0.0])
elif "rareterm" in text:
vectors.append([0.0, 1.0])
else:
vectors.append([0.5, math.sqrt(0.75)])
return vectors
monkeypatch.setenv("MEMPALACE_BACKEND_EXPLICIT", "sqlite_exact")
monkeypatch.setattr(embedding_wrapper, "_embed_texts", fake_embed)
col = get_collection(str(tmp_path), create=True)
col.add(
ids=["d1", "d2", "d3", "rare"],
documents=[
"ordinary support note",
"ordinary billing note",
"ordinary project note",
"rareterm rareterm rareterm policy note",
],
metadatas=[
{"wing": "w", "room": "r", "source_file": "/tmp/d1.md", "chunk_index": 0},
{"wing": "w", "room": "r", "source_file": "/tmp/d2.md", "chunk_index": 0},
{"wing": "w", "room": "r", "source_file": "/tmp/d3.md", "chunk_index": 0},
{"wing": "w", "room": "r", "source_file": "/tmp/rare.md", "chunk_index": 0},
],
)
result = search_memories(
"rareterm",
str(tmp_path),
n_results=1,
candidate_strategy="union",
)
assert result["results"][0]["source_file"] == "rare.md"
assert result["results"][0]["matched_via"] == "bm25_backend"
def test_search_union_reports_unsupported_lexical_capability(monkeypatch, tmp_path):
import mempalace.searcher as searcher
class NoLexicalCollection:
def query(self, **_kwargs):
return QueryResult(
ids=[["a"]],
documents=[["ordinary note"]],
metadatas=[[{"source_file": "/tmp/a.md", "chunk_index": 0}]],
distances=[[0.5]],
)
def lexical_search(self, **_kwargs):
raise UnsupportedCapabilityError("no lexical support")
monkeypatch.setattr(searcher, "get_collection", lambda *_args, **_kwargs: NoLexicalCollection())
monkeypatch.setattr(
searcher,
"get_closets_collection",
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("no closets")),
)
result = searcher.search_memories(
"anything",
str(tmp_path),
n_results=1,
candidate_strategy="union",
)
assert result["unsupported_capability"] == "supports_lexical_search"
def test_search_vector_disabled_fallback_is_chroma_only(tmp_path, monkeypatch):
from mempalace.searcher import search_memories
monkeypatch.setenv("MEMPALACE_BACKEND_EXPLICIT", "sqlite_exact")
result = search_memories("anything", str(tmp_path), vector_disabled=True)
assert result["unsupported_capability"] == "chroma_hnsw_fallback"
assert result["backend"] == "sqlite_exact"
def test_concurrent_first_open_single_connection_no_leak(tmp_path, monkeypatch):
"""Two threads first-opening the same palace concurrently must share one
handle and one sqlite connection.
The barrier inside the patched ``sqlite3.connect`` releases immediately
only when both threads pass the cache-miss check together: the broken
interleaving, which also ran ``_init_schema`` concurrently on a fresh
file and surfaced "database is locked". With creation serialized under
``_clients_lock`` the second thread waits on the lock instead, the
winner's barrier times out, and exactly one connection is ever created.
"""
created = []
barrier = threading.Barrier(2)
real_connect = sqlite3.connect
def racing_connect(*args, **kwargs):
try:
barrier.wait(timeout=1.0)
except threading.BrokenBarrierError:
pass
conn = real_connect(*args, **kwargs)
created.append(conn)
return conn
monkeypatch.setattr(sqlite_exact_module.sqlite3, "connect", racing_connect)
backend = SQLiteExactBackend()
palace = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
results = [None, None]
errors = []
def open_collection(i):
try:
results[i] = backend.get_collection(
palace=palace, collection_name="drawers", create=True
)
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=open_collection, args=(i,), daemon=True) for i in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=30)
assert not any(t.is_alive() for t in threads)
assert errors == []
assert len(created) == 1
assert results[0]._handle is results[1]._handle
backend.close()
with pytest.raises(sqlite3.ProgrammingError):
created[0].execute("SELECT 1")