Skip to content

Commit 374400a

Browse files
jpheinclaude
andcommitted
test(backends): live-substrate conformance module for pgvector
Mirrors the portable fake-client arms of test_pgvector_backend.py against a real PostgreSQL+pgvector server and adds live-only arms the in-memory fake cannot exercise: real <=> operator ground truth, JSONB pushdown vs local-fallback equivalence, cross-namespace isolation on real tables, 8-connection concurrent writers, and the advisory-lock serialization of run_maintenance('reindex') under a 2-connection race. Gated on MEMPALACE_PGVECTOR_LIVE_DSN (same pattern as the qdrant live gate); skips cleanly when unset. First run: 15/15 pass on PostgreSQL 16.10 + pgvector 0.8.2 (+AGE 1.6.0 in the same server), psycopg 3.3.4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f124bd2 commit 374400a

1 file changed

Lines changed: 328 additions & 0 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
"""Live-substrate conformance run for the pgvector backend (RFC 001).
2+
3+
Mirrors the fake-client arms of ``test_pgvector_backend.py`` against a real
4+
PostgreSQL + pgvector server, plus live-only arms the in-memory fake cannot
5+
exercise: the real ``<=>`` operator class, JSONB pushdown vs local-fallback
6+
equivalence, multi-connection concurrent writers, and the advisory-lock
7+
serialization of ``run_maintenance("reindex")``.
8+
9+
Gate: ``MEMPALACE_PGVECTOR_LIVE_DSN`` (a scratch database — every test creates
10+
its own namespaced tables; never point this at a production palace).
11+
"""
12+
13+
import os
14+
import threading
15+
from concurrent.futures import ThreadPoolExecutor
16+
17+
import pytest
18+
19+
from _backend_conformance import assert_partition_isolation
20+
21+
from mempalace.backends import (
22+
BackendError,
23+
BackendMismatchError,
24+
CollectionNotInitializedError,
25+
DimensionMismatchError,
26+
PalaceRef,
27+
)
28+
from mempalace.backends.pgvector import PgVectorBackend
29+
30+
LIVE_DSN = os.environ.get("MEMPALACE_PGVECTOR_LIVE_DSN")
31+
32+
pytestmark = pytest.mark.skipif(
33+
not LIVE_DSN, reason="set MEMPALACE_PGVECTOR_LIVE_DSN (scratch DB) to run"
34+
)
35+
36+
37+
@pytest.fixture
38+
def live(request, tmp_path):
39+
"""Backend + collection on the live server, namespaced per test."""
40+
namespace = "conf_" + request.node.name.replace("[", "_").replace("]", "")[:40]
41+
backend = PgVectorBackend()
42+
created = []
43+
44+
def make(path, name="drawers", create=True, ns=namespace, dsn=LIVE_DSN, backend_=None):
45+
b = backend_ or backend
46+
ref = PalaceRef(id=str(path), local_path=str(path), namespace=ns)
47+
col = b.get_collection(
48+
palace=ref, collection_name=name, create=create, options={"dsn": dsn, "namespace": ns}
49+
)
50+
created.append(col)
51+
return col
52+
53+
yield backend, make, namespace
54+
for col in created:
55+
try:
56+
col._client.drop_table(col._table)
57+
except Exception:
58+
pass
59+
backend.close()
60+
61+
62+
def _seed(col):
63+
col.add(
64+
ids=["a", "b", "c"],
65+
documents=[
66+
"alpha backend note",
67+
"rareterm pgvector backend note",
68+
"frontend design note",
69+
],
70+
metadatas=[
71+
{"wing": "project", "room": "backend", "rank": 1},
72+
{"wing": "project", "room": "backend", "rank": 3},
73+
{"wing": "project", "room": "frontend", "rank": 2},
74+
],
75+
embeddings=[[1, 0], [0.9, 0.1], [0, 1]],
76+
)
77+
78+
79+
def test_live_add_query_filters_lexical_and_marker(live, tmp_path):
80+
backend, make, _ns = live
81+
col = make(tmp_path)
82+
assert not os.path.isfile(tmp_path / "pgvector_backend.json")
83+
_seed(col)
84+
85+
assert PgVectorBackend.detect(str(tmp_path))
86+
assert os.path.isfile(tmp_path / "pgvector_backend.json")
87+
assert col.count() == 3
88+
89+
result = col.query(
90+
query_embeddings=[[1, 0]],
91+
n_results=3,
92+
where={"wing": "project"},
93+
include=["documents", "metadatas", "distances", "embeddings"],
94+
)
95+
assert result.ids[0][0] == "a"
96+
assert set(result.ids[0]) == {"a", "b", "c"}
97+
assert result.embeddings[0][0] == pytest.approx([1.0, 0.0])
98+
99+
hits = col.lexical_search(query="rareterm backend", n_results=2, where={"wing": "project"}).hits
100+
assert [hit.id for hit in hits] == ["b", "a"]
101+
102+
103+
def test_live_requires_explicit_embeddings(live, tmp_path):
104+
_backend, make, _ns = live
105+
col = make(tmp_path)
106+
with pytest.raises(ValueError, match="explicit embeddings"):
107+
col.add(ids=["a"], documents=["no vector"], metadatas=[{}])
108+
109+
110+
def test_live_dimension_mismatch(live, tmp_path):
111+
_backend, make, _ns = live
112+
col = make(tmp_path)
113+
col.upsert(ids=["a"], documents=["one"], metadatas=[{}], embeddings=[[1, 0]])
114+
with pytest.raises(DimensionMismatchError):
115+
col.upsert(ids=["b"], documents=["two"], metadatas=[{}], embeddings=[[1, 0, 0]])
116+
117+
118+
def test_live_duplicate_ids_in_batch_rejected(live, tmp_path):
119+
_backend, make, _ns = live
120+
col = make(tmp_path)
121+
with pytest.raises(ValueError, match="unique"):
122+
col.add(
123+
ids=["a", "a"], documents=["x", "y"], metadatas=[{}, {}], embeddings=[[1, 0], [0, 1]]
124+
)
125+
126+
127+
def test_live_complex_filters_pushdown_vs_local_fallback(live, tmp_path):
128+
"""$or / $contains route to local fallback, equality/$gte push down to
129+
JSONB SQL — on the live server both paths must agree with the fake."""
130+
_backend, make, _ns = live
131+
col = make(tmp_path)
132+
col.add(
133+
ids=["a", "b", "c"],
134+
documents=["alpha", "beta", "gamma"],
135+
metadatas=[
136+
{"wing": "x", "rank": 1, "tags": "core,vector"},
137+
{"wing": "y", "rank": 3, "tags": "sqlite,exact"},
138+
{"wing": "z", "rank": 2, "tags": "old"},
139+
],
140+
embeddings=[[1, 0], [0.9, 0.1], [0, 1]],
141+
)
142+
143+
or_hits = col.get(where={"$or": [{"wing": "x"}, {"wing": "z"}]})
144+
assert set(or_hits.ids) == {"a", "c"}
145+
146+
contains = col.get(where={"tags": {"$contains": "sqlite"}})
147+
assert contains.ids == ["b"]
148+
149+
ranked = col.query(query_embeddings=[[1, 0]], n_results=3, where={"rank": {"$gte": 2}})
150+
assert set(ranked.ids[0]) == {"b", "c"}
151+
152+
eq_pushdown = col.get(where={"wing": "y"})
153+
assert eq_pushdown.ids == ["b"]
154+
155+
156+
def test_live_marker_rejects_target_change(live, tmp_path):
157+
_backend, make, _ns = live
158+
col = make(tmp_path)
159+
col.upsert(ids=["a"], documents=["one"], metadatas=[{}], embeddings=[[1, 0]])
160+
161+
backend2 = PgVectorBackend()
162+
palace = PalaceRef(id=str(tmp_path), local_path=str(tmp_path))
163+
try:
164+
with pytest.raises(BackendMismatchError):
165+
backend2.get_collection(
166+
palace=palace,
167+
collection_name="drawers",
168+
create=True,
169+
options={"dsn": "postgresql://other-host:5432/other"},
170+
)
171+
finally:
172+
backend2.close()
173+
174+
175+
def test_live_marker_backend_mismatch(live, tmp_path):
176+
from mempalace.palace import resolve_backend_name
177+
178+
_backend, make, _ns = live
179+
col = make(tmp_path)
180+
col.upsert(ids=["a"], documents=["one"], metadatas=[{}], embeddings=[[1, 0]])
181+
182+
assert resolve_backend_name(str(tmp_path)) == "pgvector"
183+
with pytest.raises(BackendMismatchError):
184+
resolve_backend_name(str(tmp_path), explicit="qdrant")
185+
186+
187+
def test_live_rejects_pure_remote_palace(live):
188+
backend = PgVectorBackend()
189+
palace = PalaceRef(id="tenant-remote", local_path=None, namespace="tenant-remote")
190+
try:
191+
with pytest.raises(BackendError, match="local palace path"):
192+
backend.get_collection(
193+
palace=palace, collection_name="drawers", create=True, options={"dsn": LIVE_DSN}
194+
)
195+
finally:
196+
backend.close()
197+
198+
199+
def test_live_missing_table_after_marker_is_not_initialized(live, tmp_path):
200+
_backend, make, _ns = live
201+
col = make(tmp_path)
202+
col.upsert(ids=["a"], documents=["one"], metadatas=[{}], embeddings=[[1, 0]])
203+
col._client.drop_table(col._table)
204+
205+
assert col.health().ok is False
206+
with pytest.raises(CollectionNotInitializedError):
207+
col.count()
208+
209+
210+
def test_live_cross_palace_isolation_conformance(live, tmp_path):
211+
backend, make, _ns = live
212+
cols = [make(tmp_path / label) for label in ("alpha", "beta")]
213+
assert cols[0]._table != cols[1]._table
214+
assert_partition_isolation(backend, cols[0], cols[1], embedding=[1.0, 0.0])
215+
216+
217+
def test_live_cross_namespace_isolation_conformance(live, tmp_path):
218+
"""The cschnatz arm: same DSN, two namespaces, no leakage either way."""
219+
assert "supports_namespace_isolation" in PgVectorBackend.capabilities
220+
backend, make, ns = live
221+
col_a = make(tmp_path / "tenant-a", ns=f"{ns}_a")
222+
col_b = make(tmp_path / "tenant-b", ns=f"{ns}_b")
223+
assert col_a._table != col_b._table
224+
assert_partition_isolation(backend, col_a, col_b, embedding=[1.0, 0.0])
225+
226+
227+
def test_live_cosine_operator_ranking_ground_truth(live, tmp_path):
228+
"""The real ``<=>`` operator class must rank by cosine distance exactly
229+
as the fake's local math claims (our #1679 Q2-adjacent point: distance
230+
semantics should be a contract fact; here we verify the live operator)."""
231+
_backend, make, _ns = live
232+
col = make(tmp_path)
233+
col.add(
234+
ids=["same", "close", "orthogonal", "opposite"],
235+
documents=["d1", "d2", "d3", "d4"],
236+
metadatas=[{}, {}, {}, {}],
237+
embeddings=[[1, 0], [0.9, 0.1], [0, 1], [-1, 0]],
238+
)
239+
result = col.query(query_embeddings=[[1, 0]], n_results=4, include=["distances"])
240+
assert result.ids[0] == ["same", "close", "orthogonal", "opposite"]
241+
distances = result.distances[0]
242+
assert distances[0] == pytest.approx(0.0, abs=1e-6)
243+
assert distances[2] == pytest.approx(1.0, abs=1e-6)
244+
assert distances[3] == pytest.approx(2.0, abs=1e-6)
245+
246+
247+
def test_live_concurrent_writers_distinct_connections(live, tmp_path):
248+
"""8 backends (8 connections) upserting distinct rows into the same
249+
table concurrently — the multi-daemon-writer shape from production."""
250+
_backend, make, ns = live
251+
seed_col = make(tmp_path)
252+
seed_col.upsert(ids=["seed"], documents=["seed"], metadatas=[{}], embeddings=[[1, 0]])
253+
254+
errors = []
255+
256+
def writer(worker):
257+
backend = PgVectorBackend()
258+
try:
259+
col = make(tmp_path, backend_=backend)
260+
for i in range(25):
261+
col.upsert(
262+
ids=[f"w{worker}-r{i}"],
263+
documents=[f"row {i} from worker {worker}"],
264+
metadatas=[{"worker": worker}],
265+
embeddings=[[1.0, float(i) / 100]],
266+
)
267+
except Exception as exc: # noqa: BLE001 - collected for the report
268+
errors.append(repr(exc))
269+
finally:
270+
backend.close()
271+
272+
with ThreadPoolExecutor(max_workers=8) as pool:
273+
list(pool.map(writer, range(8)))
274+
275+
assert errors == [], f"concurrent writers raised: {errors[:3]}"
276+
assert seed_col.count() == 1 + 8 * 25
277+
278+
279+
def test_live_reindex_advisory_lock_race(live, tmp_path):
280+
"""Two connections racing run_maintenance('reindex') — the #1732
281+
advisory-lock behavior: at most one 'ran', the loser learns
282+
'already_running' (or 'noop' after the winner finishes), nobody stacks
283+
a second ACCESS EXCLUSIVE build and nobody raises."""
284+
_backend, make, ns = live
285+
col = make(tmp_path)
286+
col.add(
287+
ids=[f"r{i}" for i in range(50)],
288+
documents=[f"doc {i}" for i in range(50)],
289+
metadatas=[{} for _ in range(50)],
290+
embeddings=[[1.0, float(i)] for i in range(50)],
291+
)
292+
assert col.maintenance_state()["vector_index"] is None
293+
294+
barrier = threading.Barrier(2)
295+
statuses, errors = [], []
296+
297+
def race():
298+
backend = PgVectorBackend()
299+
try:
300+
racer = make(tmp_path, backend_=backend)
301+
barrier.wait(timeout=10)
302+
result = racer.run_maintenance("reindex")
303+
statuses.append(result.status)
304+
except Exception as exc: # noqa: BLE001 - collected for the report
305+
errors.append(repr(exc))
306+
finally:
307+
backend.close()
308+
309+
threads = [threading.Thread(target=race) for _ in range(2)]
310+
for t in threads:
311+
t.start()
312+
for t in threads:
313+
t.join(timeout=60)
314+
315+
assert errors == [], f"reindex race raised: {errors}"
316+
assert statuses.count("ran") <= 1
317+
assert all(s in {"ran", "already_running", "noop"} for s in statuses), statuses
318+
state = col.maintenance_state()
319+
assert state["vector_index"] == "hnsw"
320+
assert state["index_build_complete"] is True
321+
322+
323+
def test_live_analyze_maintenance(live, tmp_path):
324+
_backend, make, _ns = live
325+
col = make(tmp_path)
326+
col.upsert(ids=["a"], documents=["one"], metadatas=[{}], embeddings=[[1, 0]])
327+
result = col.run_maintenance("analyze")
328+
assert result.status == "ran"

0 commit comments

Comments
 (0)