|
| 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