1031 lines
38 KiB
Python
1031 lines
38 KiB
Python
"""Unit tests for PGVectorStorage deferred-embedding contract.
|
|
|
|
PGVectorStorage now buffers upserts/deletes in process-local pending buffers
|
|
and embeds + persists only during ``index_done_callback()`` / ``finalize()``.
|
|
This mirrors OpenSearchVectorDBStorage and NanoVectorDBStorage.
|
|
|
|
These tests use the same ``MagicMock``-based DB stub as
|
|
``test_postgres_upsert.py``, plus a counting embedding function adapted from
|
|
``tests/kg/opensearch_impl/test_opensearch_storage.py``.
|
|
"""
|
|
|
|
import asyncio
|
|
import datetime
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from lightrag.kg.postgres_impl import (
|
|
PGVectorStorage,
|
|
_PendingPGVectorDoc,
|
|
)
|
|
from lightrag.namespace import NameSpace
|
|
from lightrag.utils import EmbeddingFunc, compute_mdhash_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class CountingEmbed:
|
|
"""Embedding test double that records calls and can fail N times first."""
|
|
|
|
def __init__(self, dim: int = 3, fail_times: int = 0):
|
|
self.embedding_dim = dim
|
|
self.max_token_size = 512
|
|
self.model_name = "test_model"
|
|
self.fail_times = fail_times
|
|
self.call_count = 0
|
|
self.batches: list[list[str]] = []
|
|
|
|
async def __call__(self, texts, **kwargs):
|
|
self.call_count += 1
|
|
batch = list(texts)
|
|
self.batches.append(batch)
|
|
if self.fail_times > 0:
|
|
self.fail_times -= 1
|
|
raise RuntimeError("embedding failed")
|
|
return np.array(
|
|
[[float(self.call_count), 0.0, 0.0] for _ in batch], dtype=np.float32
|
|
)
|
|
|
|
|
|
def _make_storage(
|
|
namespace: str = NameSpace.VECTOR_STORE_CHUNKS,
|
|
embed: CountingEmbed | None = None,
|
|
embedding_batch_num: int = 10,
|
|
fail_run_with_retry: bool = False,
|
|
) -> PGVectorStorage:
|
|
"""Construct a PGVectorStorage with a stubbed DB and embedding func."""
|
|
db = MagicMock()
|
|
captured_executemany: list[tuple] = []
|
|
captured_execute: list[tuple] = []
|
|
retry_kwargs: list[dict] = []
|
|
retry_call_count = {"n": 0}
|
|
|
|
async def fake_run_with_retry(operation, **kwargs):
|
|
retry_kwargs.append(kwargs)
|
|
retry_call_count["n"] += 1
|
|
if fail_run_with_retry:
|
|
raise RuntimeError("simulated PG failure")
|
|
mock_conn = AsyncMock()
|
|
tx_cm = AsyncMock()
|
|
tx_cm.__aenter__.return_value = None
|
|
tx_cm.__aexit__.return_value = None
|
|
mock_conn.transaction = MagicMock(return_value=tx_cm)
|
|
await operation(mock_conn)
|
|
for call in mock_conn.executemany.call_args_list:
|
|
captured_executemany.append((call.args[0], call.args[1]))
|
|
for call in mock_conn.execute.call_args_list:
|
|
captured_execute.append((call.args[0], call.args[1:]))
|
|
|
|
db._run_with_retry = AsyncMock(side_effect=fake_run_with_retry)
|
|
|
|
# db.execute is used by delete_entity, delete_entity_relation, drop.
|
|
db.execute = AsyncMock(return_value=None)
|
|
db.query = AsyncMock(return_value=[])
|
|
# drop() probes the legacy table to clear its workspace rows; default to
|
|
# "no legacy table" so unrelated tests see a single workspace delete.
|
|
db.check_table_exists = AsyncMock(return_value=False)
|
|
db.workspace = "test_ws"
|
|
|
|
embedding = embed or CountingEmbed()
|
|
embedding_func = EmbeddingFunc(
|
|
embedding_dim=embedding.embedding_dim,
|
|
func=embedding,
|
|
model_name=embedding.model_name,
|
|
)
|
|
storage = PGVectorStorage(
|
|
namespace=namespace,
|
|
workspace="test_ws",
|
|
global_config={
|
|
"embedding_batch_num": embedding_batch_num,
|
|
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.5},
|
|
},
|
|
embedding_func=embedding_func,
|
|
)
|
|
storage.db = db
|
|
storage._flush_lock = asyncio.Lock()
|
|
storage._counting_embed = embedding
|
|
storage._captured_executemany = captured_executemany
|
|
storage._captured_execute = captured_execute
|
|
storage._retry_kwargs = retry_kwargs
|
|
storage._retry_call_count = retry_call_count
|
|
return storage
|
|
|
|
|
|
def _chunk_data(**overrides):
|
|
base = {
|
|
"tokens": 1,
|
|
"chunk_order_index": 0,
|
|
"full_doc_id": "doc-1",
|
|
"content": "alpha",
|
|
"file_path": "/a.txt",
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def _entity_data(name: str = "Alice", **overrides):
|
|
base = {
|
|
"entity_name": name,
|
|
"content": f"{name} content",
|
|
"source_id": "chunk-1",
|
|
"file_path": "/e.txt",
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def _relation_data(src: str = "Alice", tgt: str = "Bob", **overrides):
|
|
base = {
|
|
"src_id": src,
|
|
"tgt_id": tgt,
|
|
"content": f"{src}->{tgt}",
|
|
"source_id": "chunk-1",
|
|
"file_path": "/r.txt",
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. upsert() buffers only
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_buffers_without_embedding_or_db_call():
|
|
storage = _make_storage()
|
|
await storage.upsert({"c1": _chunk_data(content="alpha")})
|
|
|
|
assert storage._counting_embed.call_count == 0
|
|
assert storage._retry_call_count["n"] == 0
|
|
assert "c1" in storage._pending_vector_docs
|
|
pending = storage._pending_vector_docs["c1"]
|
|
assert isinstance(pending, _PendingPGVectorDoc)
|
|
assert pending.vector is None
|
|
assert pending.item["__id__"] == "c1"
|
|
assert pending.item["content"] == "alpha"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Deferred batching across many upsert() calls
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_many_upserts_flush_in_one_executemany():
|
|
storage = _make_storage(embedding_batch_num=3)
|
|
for i in range(5):
|
|
await storage.upsert({f"c{i}": _chunk_data(content=f"doc {i}")})
|
|
|
|
assert storage._counting_embed.call_count == 0
|
|
await storage.index_done_callback()
|
|
|
|
# Embedding split only by embedding_batch_num (3 + 2).
|
|
assert [len(b) for b in storage._counting_embed.batches] == [3, 2]
|
|
# One executemany for 5 records (not one per upsert call).
|
|
assert len(storage._captured_executemany) == 1
|
|
sql, rows = storage._captured_executemany[0]
|
|
assert len(rows) == 5
|
|
assert "LIGHTRAG_VDB_CHUNKS" in sql
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Same-id upsert overwrite
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_same_id_upsert_overwrites():
|
|
storage = _make_storage()
|
|
await storage.upsert({"x": _chunk_data(content="a")})
|
|
await storage.upsert({"x": _chunk_data(content="b")})
|
|
await storage.index_done_callback()
|
|
|
|
rows = storage._captured_executemany[0][1]
|
|
assert len(rows) == 1
|
|
# The chunk tuple position 6 ($6) is content.
|
|
assert rows[0][5] == "b"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Lazy vector cache: get_vectors_by_ids embeds, flush reuses
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lazy_vector_cache_reused_by_flush():
|
|
storage = _make_storage()
|
|
await storage.upsert({"c1": _chunk_data(content="alpha")})
|
|
vecs = await storage.get_vectors_by_ids(["c1"])
|
|
assert "c1" in vecs
|
|
assert storage._counting_embed.call_count == 1
|
|
|
|
await storage.index_done_callback()
|
|
# Flush must not re-embed; total call count stays 1.
|
|
assert storage._counting_embed.call_count == 1
|
|
# The vector that landed in the executemany row equals what get_vectors_by_ids returned.
|
|
rows = storage._captured_executemany[0][1]
|
|
persisted_vec = rows[0][6] # chunks tuple: $7 is content_vector
|
|
assert list(np.asarray(persisted_vec, dtype=np.float32)) == list(
|
|
np.asarray(vecs["c1"], dtype=np.float32)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Upsert after lazy cache discards the cached vector
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_after_lazy_cache_discards_cached_vector():
|
|
storage = _make_storage()
|
|
await storage.upsert({"c1": _chunk_data(content="a")})
|
|
await storage.get_vectors_by_ids(["c1"]) # embed call #1
|
|
assert storage._pending_vector_docs["c1"].vector is not None
|
|
|
|
await storage.upsert({"c1": _chunk_data(content="b")}) # discards cache
|
|
assert storage._pending_vector_docs["c1"].vector is None
|
|
|
|
await storage.index_done_callback()
|
|
# Two embed calls total: one lazy, one for the new content during flush.
|
|
assert storage._counting_embed.call_count == 2
|
|
# And the persisted content is "b".
|
|
rows = storage._captured_executemany[0][1]
|
|
assert rows[0][5] == "b"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Embedding failure leaves buffers intact
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_embedding_failure_leaves_pending_for_retry():
|
|
embed = CountingEmbed(fail_times=1)
|
|
storage = _make_storage(embed=embed)
|
|
await storage.upsert({"c1": _chunk_data(content="retry me")})
|
|
|
|
with pytest.raises(RuntimeError, match="embedding failed"):
|
|
await storage.index_done_callback()
|
|
|
|
assert storage._retry_call_count["n"] == 0
|
|
assert "c1" in storage._pending_vector_docs
|
|
assert storage._pending_vector_docs["c1"].vector is None
|
|
|
|
# Next flush succeeds; embed called twice total (one failure + one success).
|
|
await storage.index_done_callback()
|
|
assert embed.call_count == 2
|
|
assert storage._pending_vector_docs == {}
|
|
assert len(storage._captured_executemany) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. _run_with_retry failure leaves buffers + cached vectors intact
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persistence_failure_keeps_buffers_and_cached_vectors():
|
|
storage = _make_storage(fail_run_with_retry=True)
|
|
await storage.upsert({"c1": _chunk_data(content="alpha")})
|
|
|
|
with pytest.raises(RuntimeError, match="simulated PG failure"):
|
|
await storage.index_done_callback()
|
|
|
|
# Buffer intact, vector cached (so next flush won't re-embed).
|
|
assert "c1" in storage._pending_vector_docs
|
|
assert storage._pending_vector_docs["c1"].vector is not None
|
|
embed_calls_before = storage._counting_embed.call_count
|
|
|
|
# Repair the DB and flush again.
|
|
storage.db._run_with_retry.side_effect = None
|
|
storage.db._run_with_retry.return_value = None
|
|
|
|
# We need to actually persist this time; re-attach a working side_effect.
|
|
captured_em = storage._captured_executemany
|
|
captured_ex = storage._captured_execute
|
|
|
|
async def working_retry(operation, **kwargs):
|
|
mock_conn = AsyncMock()
|
|
tx_cm = AsyncMock()
|
|
tx_cm.__aenter__.return_value = None
|
|
tx_cm.__aexit__.return_value = None
|
|
mock_conn.transaction = MagicMock(return_value=tx_cm)
|
|
await operation(mock_conn)
|
|
for call in mock_conn.executemany.call_args_list:
|
|
captured_em.append((call.args[0], call.args[1]))
|
|
for call in mock_conn.execute.call_args_list:
|
|
captured_ex.append((call.args[0], call.args[1:]))
|
|
|
|
storage.db._run_with_retry.side_effect = working_retry
|
|
|
|
await storage.index_done_callback()
|
|
# No re-embed thanks to the cached vector.
|
|
assert storage._counting_embed.call_count == embed_calls_before
|
|
assert storage._pending_vector_docs == {}
|
|
assert len(captured_em) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. Delete cancels pending upsert
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_cancels_pending_upsert():
|
|
storage = _make_storage()
|
|
await storage.upsert({"c1": _chunk_data()})
|
|
await storage.delete(["c1"])
|
|
|
|
assert "c1" not in storage._pending_vector_docs
|
|
assert "c1" in storage._pending_vector_deletes
|
|
|
|
await storage.index_done_callback()
|
|
# Only a delete went out, no upsert executemany.
|
|
assert storage._captured_executemany == []
|
|
assert len(storage._captured_execute) == 1
|
|
sql, args = storage._captured_execute[0]
|
|
assert "DELETE FROM" in sql
|
|
assert args[0] == "test_ws"
|
|
assert args[1] == ["c1"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. Upsert cancels pending delete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_cancels_pending_delete():
|
|
storage = _make_storage()
|
|
await storage.delete(["c1"])
|
|
await storage.upsert({"c1": _chunk_data(content="new")})
|
|
|
|
assert "c1" in storage._pending_vector_docs
|
|
assert "c1" not in storage._pending_vector_deletes
|
|
|
|
await storage.index_done_callback()
|
|
assert len(storage._captured_executemany) == 1
|
|
# And no DELETE in the same flush.
|
|
assert storage._captured_execute == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. delete_entity prunes pending docs and runs SQL predicate under lock
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_prunes_pending_and_runs_sql():
|
|
storage = _make_storage(namespace=NameSpace.VECTOR_STORE_ENTITIES)
|
|
entity_id = compute_mdhash_id("Alice", prefix="ent-")
|
|
# Pending entity keyed by the hash id.
|
|
await storage.upsert({entity_id: _entity_data(name="Alice")})
|
|
|
|
await storage.delete_entity("Alice")
|
|
|
|
# Pending pruned.
|
|
assert entity_id not in storage._pending_vector_docs
|
|
# SQL predicate fired against db.execute (the immediate path).
|
|
storage.db.execute.assert_awaited_once()
|
|
sql_arg = storage.db.execute.await_args.args[0]
|
|
params_arg = storage.db.execute.await_args.args[1]
|
|
assert "entity_name=$2" in sql_arg
|
|
assert params_arg == {"workspace": "test_ws", "entity_name": "Alice"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 11. delete_entity_relation prunes pending relation docs + runs SQL predicate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_relation_prunes_pending_and_runs_sql():
|
|
storage = _make_storage(namespace=NameSpace.VECTOR_STORE_RELATIONSHIPS)
|
|
await storage.upsert(
|
|
{
|
|
"r1": _relation_data(src="Alice", tgt="Bob"),
|
|
"r2": _relation_data(src="Carol", tgt="Alice"),
|
|
"r3": _relation_data(src="Eve", tgt="Mallory"),
|
|
}
|
|
)
|
|
|
|
await storage.delete_entity_relation("Alice")
|
|
|
|
assert "r1" not in storage._pending_vector_docs
|
|
assert "r2" not in storage._pending_vector_docs
|
|
assert "r3" in storage._pending_vector_docs
|
|
|
|
storage.db.execute.assert_awaited_once()
|
|
sql_arg = storage.db.execute.await_args.args[0]
|
|
assert "source_id=$2 OR target_id=$2" in sql_arg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 12. drop() clears buffers and runs workspace delete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drop_clears_buffers_and_runs_delete():
|
|
storage = _make_storage()
|
|
await storage.upsert({"c1": _chunk_data()})
|
|
await storage.delete(["c2"])
|
|
assert storage._pending_vector_docs and storage._pending_vector_deletes
|
|
|
|
result = await storage.drop()
|
|
assert result["status"] == "success"
|
|
assert storage._pending_vector_docs == {}
|
|
assert storage._pending_vector_deletes == set()
|
|
storage.db.execute.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drop_also_clears_workspace_rows_from_legacy_table():
|
|
"""drop() must also delete this workspace's rows from the kept legacy table,
|
|
so a later startup does not re-migrate the cleared rows back into the
|
|
suffixed table (resurrection)."""
|
|
storage = _make_storage()
|
|
# Legacy table exists (kept after migration); suffixed table != legacy.
|
|
storage.db.check_table_exists = AsyncMock(return_value=True)
|
|
assert storage.legacy_table_name.lower() != storage.table_name.lower()
|
|
|
|
result = await storage.drop()
|
|
assert result["status"] == "success"
|
|
|
|
deleted_tables = [
|
|
call.args[0] for call in storage.db.execute.await_args_list if call.args
|
|
]
|
|
# Both the active suffixed table and the legacy table are cleared for the
|
|
# workspace.
|
|
assert any(storage.table_name in sql for sql in deleted_tables)
|
|
assert any(storage.legacy_table_name in sql for sql in deleted_tables)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 13. Read-your-writes: get_by_id, get_by_ids, get_vectors_by_ids
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_id_returns_pending_and_hides_deletes():
|
|
storage = _make_storage()
|
|
await storage.upsert({"c1": _chunk_data(content="hello")})
|
|
|
|
doc = await storage.get_by_id("c1")
|
|
assert doc is not None
|
|
assert doc["id"] == "c1"
|
|
assert doc["content"] == "hello"
|
|
assert "__vector__" not in doc
|
|
assert "__id__" not in doc
|
|
assert "created_at" in doc
|
|
|
|
# SQL not touched for buffered hits.
|
|
storage.db.query.assert_not_called()
|
|
|
|
# Now delete and ensure the buffered tombstone wins over SQL.
|
|
await storage.delete(["c1"])
|
|
assert (await storage.get_by_id("c1")) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_ids_preserves_order_and_uses_any_sql():
|
|
storage = _make_storage()
|
|
await storage.upsert({"c1": _chunk_data(content="a")})
|
|
await storage.delete(["c2"])
|
|
|
|
# c3 will fall through to SQL.
|
|
storage.db.query = AsyncMock(
|
|
return_value=[{"id": "c3", "content": "from-pg", "created_at": 0}]
|
|
)
|
|
|
|
docs = await storage.get_by_ids(["c1", "c2", "c3"])
|
|
assert docs[0] is not None and docs[0]["id"] == "c1" and docs[0]["content"] == "a"
|
|
assert docs[1] is None # pending delete
|
|
assert docs[2] is not None and docs[2]["id"] == "c3"
|
|
|
|
# SQL fallback used `id = ANY($2)` (not string-built IN).
|
|
sql_used = storage.db.query.await_args.args[0]
|
|
assert "id = ANY($2)" in sql_used
|
|
assert storage.db.query.await_args.args[1] == ["test_ws", ["c3"]]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_vectors_by_ids_returns_cached_and_skips_deletes():
|
|
storage = _make_storage()
|
|
await storage.upsert({"c1": _chunk_data(content="a")})
|
|
await storage.upsert({"c2": _chunk_data(content="b")})
|
|
await storage.delete(["c2"])
|
|
|
|
# c3 falls through to SQL.
|
|
storage.db.query = AsyncMock(
|
|
return_value=[{"id": "c3", "content_vector": [0.5, 0.6, 0.7]}]
|
|
)
|
|
|
|
vecs = await storage.get_vectors_by_ids(["c1", "c2", "c3"])
|
|
# c1 lazily embedded; c2 skipped; c3 from SQL.
|
|
assert "c1" in vecs and len(vecs["c1"]) == 3
|
|
assert "c2" not in vecs
|
|
assert vecs["c3"] == [0.5, 0.6, 0.7]
|
|
|
|
sql_used = storage.db.query.await_args.args[0]
|
|
assert "id = ANY($2)" in sql_used
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 14. finalize() raises with pending counts if flush failed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finalize_raises_when_flush_fails_and_releases_client():
|
|
storage = _make_storage(fail_run_with_retry=True)
|
|
await storage.upsert({"c1": _chunk_data()})
|
|
await storage.delete(["c2"])
|
|
|
|
# Patch ClientManager.release_client to a no-op so we don't touch real state.
|
|
from lightrag.kg import postgres_impl
|
|
|
|
release_mock = AsyncMock()
|
|
original = postgres_impl.ClientManager.release_client
|
|
postgres_impl.ClientManager.release_client = release_mock
|
|
try:
|
|
with pytest.raises(RuntimeError, match="pending upserts"):
|
|
await storage.finalize()
|
|
release_mock.assert_awaited_once()
|
|
assert storage.db is None
|
|
finally:
|
|
postgres_impl.ClientManager.release_client = original
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finalize_clean_path_flushes_then_releases_client():
|
|
storage = _make_storage()
|
|
await storage.upsert({"c1": _chunk_data()})
|
|
|
|
from lightrag.kg import postgres_impl
|
|
|
|
release_mock = AsyncMock()
|
|
original = postgres_impl.ClientManager.release_client
|
|
postgres_impl.ClientManager.release_client = release_mock
|
|
try:
|
|
await storage.finalize()
|
|
finally:
|
|
postgres_impl.ClientManager.release_client = original
|
|
|
|
release_mock.assert_awaited_once()
|
|
assert storage.db is None
|
|
assert storage._pending_vector_docs == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 15. Empty input no-ops
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_inputs_are_noops():
|
|
storage = _make_storage()
|
|
await storage.upsert({})
|
|
await storage.delete([])
|
|
await storage.index_done_callback()
|
|
assert storage._retry_call_count["n"] == 0
|
|
assert storage._counting_embed.call_count == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 16. delete_entity serializes against an in-flight flush via _flush_lock
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _GatedEmbed:
|
|
"""Embedding func that blocks on a gate so a flush can be paused mid-call."""
|
|
|
|
def __init__(self, dim: int = 3):
|
|
self.embedding_dim = dim
|
|
self.max_token_size = 512
|
|
self.model_name = "test_model"
|
|
self.gate = asyncio.Event()
|
|
self.entered = asyncio.Event()
|
|
self.call_count = 0
|
|
|
|
async def __call__(self, texts, **kwargs):
|
|
self.call_count += 1
|
|
self.entered.set()
|
|
await self.gate.wait()
|
|
return np.array([[1.0, 0.0, 0.0] for _ in texts], dtype=np.float32)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_serializes_against_in_flight_flush():
|
|
"""A `delete_entity` issued while a flush is mid-embedding must wait for
|
|
the flush's lock to release before its SQL predicate runs — otherwise the
|
|
flush could persist the entity row a microsecond after the predicate
|
|
deleted it. This pins the lock-then-SQL contract in the source.
|
|
"""
|
|
embed = _GatedEmbed()
|
|
storage = _make_storage(namespace=NameSpace.VECTOR_STORE_ENTITIES, embed=embed)
|
|
|
|
entity_id = compute_mdhash_id("Alice", prefix="ent-")
|
|
await storage.upsert({entity_id: _entity_data(name="Alice")})
|
|
|
|
flush_task = asyncio.create_task(storage.index_done_callback())
|
|
|
|
# Wait until the flush is blocked inside the embedding call (it now holds
|
|
# _flush_lock).
|
|
await asyncio.wait_for(embed.entered.wait(), timeout=1.0)
|
|
|
|
# Kick off delete_entity; it must block on the same lock.
|
|
delete_task = asyncio.create_task(storage.delete_entity("Alice"))
|
|
|
|
# Give the event loop a few turns to confirm delete_entity is blocked.
|
|
for _ in range(5):
|
|
await asyncio.sleep(0)
|
|
assert not delete_task.done(), (
|
|
"delete_entity should be waiting on _flush_lock while flush holds it"
|
|
)
|
|
|
|
# Unblock the flush; both should complete.
|
|
embed.gate.set()
|
|
await asyncio.wait_for(flush_task, timeout=1.0)
|
|
await asyncio.wait_for(delete_task, timeout=1.0)
|
|
|
|
# Flush ran its executemany, then delete_entity ran its predicate SQL.
|
|
assert len(storage._captured_executemany) == 1
|
|
storage.db.execute.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 17. Deletes-only flush: no executemany, single ANY($2) DELETE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deletes_only_flush_skips_executemany():
|
|
"""A flush that has only buffered deletes (no upserts) must still issue
|
|
the parameterized DELETE under the transaction, and must NOT call
|
|
executemany with an empty batch.
|
|
"""
|
|
storage = _make_storage()
|
|
await storage.delete(["c1", "c2", "c3"])
|
|
assert storage._pending_vector_docs == {}
|
|
assert len(storage._pending_vector_deletes) == 3
|
|
|
|
await storage.index_done_callback()
|
|
|
|
# No embedding was needed.
|
|
assert storage._counting_embed.call_count == 0
|
|
# No upsert executemany ran.
|
|
assert storage._captured_executemany == []
|
|
# Exactly one parameterized DELETE under the transaction.
|
|
assert len(storage._captured_execute) == 1
|
|
sql, args = storage._captured_execute[0]
|
|
assert "DELETE FROM" in sql
|
|
assert "id = ANY($2)" in sql
|
|
assert args[0] == "test_ws"
|
|
assert sorted(args[1]) == ["c1", "c2", "c3"]
|
|
# Buffers cleared on success.
|
|
assert storage._pending_vector_deletes == set()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 18. Embedding count mismatch raises and preserves the buffer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_embedding_count_mismatch_raises_and_preserves_buffer():
|
|
"""The in-flush ``len(embeddings) != len(docs_to_embed)`` check is
|
|
defense-in-depth against an embedding provider that bypasses the
|
|
``EmbeddingFunc`` wrapper validation. Bypass the wrapper by replacing
|
|
``storage.embedding_func`` with a bare async callable that returns
|
|
fewer rows than requested.
|
|
"""
|
|
storage = _make_storage(embedding_batch_num=10)
|
|
|
|
async def short_embed(texts, **kwargs):
|
|
rows = max(0, len(list(texts)) - 1)
|
|
return np.array([[1.0, 0.0, 0.0] for _ in range(rows)], dtype=np.float32)
|
|
|
|
storage.embedding_func = short_embed
|
|
|
|
await storage.upsert({"c1": _chunk_data(content="a")})
|
|
await storage.upsert({"c2": _chunk_data(content="b")})
|
|
|
|
with pytest.raises(RuntimeError, match="Embedding count mismatch"):
|
|
await storage.index_done_callback()
|
|
|
|
# Buffer survives the mismatch; nothing was persisted.
|
|
assert {"c1", "c2"} == set(storage._pending_vector_docs.keys())
|
|
assert storage._retry_call_count["n"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 19. delete_entity discards a matching pending delete for the same hash id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_discards_matching_pending_delete():
|
|
"""If both `delete()` (which buffers a tombstone) and `delete_entity()`
|
|
fire for the same entity, the pending tombstone for the entity's hash id
|
|
must be discarded — the predicate SQL covers it and we don't want a
|
|
redundant ANY-DELETE for the same id in the next flush.
|
|
"""
|
|
storage = _make_storage(namespace=NameSpace.VECTOR_STORE_ENTITIES)
|
|
entity_id = compute_mdhash_id("Alice", prefix="ent-")
|
|
|
|
await storage.delete([entity_id])
|
|
assert entity_id in storage._pending_vector_deletes
|
|
|
|
await storage.delete_entity("Alice")
|
|
|
|
# The pending tombstone for the hash id was discarded.
|
|
assert entity_id not in storage._pending_vector_deletes
|
|
# And the predicate SQL ran.
|
|
storage.db.execute.assert_awaited_once()
|
|
|
|
# A subsequent flush has nothing to do.
|
|
await storage.index_done_callback()
|
|
assert storage._retry_call_count["n"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 20. delete_entity / delete_entity_relation raise pre-initialize()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_pre_initialize_raises():
|
|
"""Calling delete_entity / delete_entity_relation before initialize()
|
|
must raise RuntimeError, not silently drop the destructive intent.
|
|
|
|
Silent no-op would defeat the data-loss visibility that finalize() and
|
|
_flush_pending_vector_ops enforce on the symmetric paths.
|
|
"""
|
|
db = MagicMock()
|
|
db.execute = AsyncMock(return_value=None)
|
|
embed = CountingEmbed()
|
|
embedding_func = EmbeddingFunc(
|
|
embedding_dim=embed.embedding_dim,
|
|
func=embed,
|
|
model_name=embed.model_name,
|
|
)
|
|
storage = PGVectorStorage(
|
|
namespace=NameSpace.VECTOR_STORE_ENTITIES,
|
|
workspace="test_ws",
|
|
global_config={
|
|
"embedding_batch_num": 10,
|
|
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.5},
|
|
},
|
|
embedding_func=embedding_func,
|
|
)
|
|
storage.db = db
|
|
# Intentionally do NOT set _flush_lock (mimics pre-initialize state).
|
|
assert storage._flush_lock is None
|
|
|
|
with pytest.raises(RuntimeError, match="called before initialize"):
|
|
await storage.delete_entity("Alice")
|
|
with pytest.raises(RuntimeError, match="called before initialize"):
|
|
await storage.delete_entity_relation("Alice")
|
|
|
|
# No SQL fired (the methods short-circuited before touching db.execute).
|
|
db.execute.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 21. _flush_pending_vector_ops raises on lifecycle violations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_after_client_release_raises_with_counts():
|
|
"""Direct call to _flush_pending_vector_ops after db release with a
|
|
non-empty buffer must raise — silent return would lose data without any
|
|
operator-visible signal (the symmetric path in finalize() already raises).
|
|
"""
|
|
storage = _make_storage()
|
|
await storage.upsert({"c1": _chunk_data()})
|
|
await storage.delete(["c2"])
|
|
|
|
# Mimic post-finalize state: client released, buffers preserved.
|
|
storage.db = None
|
|
|
|
with pytest.raises(RuntimeError, match="after client release"):
|
|
await storage._flush_pending_vector_ops()
|
|
# Buffers untouched — the call must not have eaten the data on its way out.
|
|
assert "c1" in storage._pending_vector_docs
|
|
assert "c2" in storage._pending_vector_deletes
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_pre_initialize_with_pending_raises():
|
|
"""Pre-initialize call with a non-empty buffer (programmer error path:
|
|
direct buffer manipulation before initialize) also raises rather than
|
|
silently returning."""
|
|
storage = _make_storage()
|
|
# Reset to pre-initialize state but seed the buffer to simulate the
|
|
# programmer-error scenario.
|
|
storage._flush_lock = None
|
|
storage._pending_vector_docs["c1"] = _PendingPGVectorDoc(
|
|
item={"__id__": "c1", **_chunk_data()},
|
|
created_at=datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="called before initialize"):
|
|
await storage._flush_pending_vector_ops()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 22. get_vectors_by_ids drops embeddings whose pending record changed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_vectors_by_ids_drops_when_pending_record_swapped():
|
|
"""If a concurrent upsert replaces the pending record while embedding I/O
|
|
is in flight (outside the lock), the freshly-computed vector no longer
|
|
matches the current buffer state and must be dropped from the response
|
|
rather than returned stale."""
|
|
embed = _GatedEmbed()
|
|
storage = _make_storage(embed=embed)
|
|
|
|
# Seed the original pending doc.
|
|
await storage.upsert({"c1": _chunk_data(content="original")})
|
|
original_pending = storage._pending_vector_docs["c1"]
|
|
|
|
# Kick off get_vectors_by_ids — it will block inside the embedding gate
|
|
# *outside* _flush_lock.
|
|
task = asyncio.create_task(storage.get_vectors_by_ids(["c1"]))
|
|
await asyncio.wait_for(embed.entered.wait(), timeout=1.0)
|
|
|
|
# While embedding is in flight, replace the pending record via upsert.
|
|
# The new doc has a different content and a vector=None.
|
|
await storage.upsert({"c1": _chunk_data(content="replaced")})
|
|
assert storage._pending_vector_docs["c1"] is not original_pending
|
|
|
|
# Release the gate; the embedding completes and the identity check fires.
|
|
embed.gate.set()
|
|
result = await asyncio.wait_for(task, timeout=1.0)
|
|
|
|
# The stale embedding is NOT returned, and is NOT cached on the new
|
|
# pending record (which keeps vector=None for the next flush to embed).
|
|
assert result == {}
|
|
assert storage._pending_vector_docs["c1"].vector is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_vectors_by_ids_drops_when_pending_record_removed():
|
|
"""Same identity-check guard but for the delete-while-embedding race."""
|
|
embed = _GatedEmbed()
|
|
storage = _make_storage(embed=embed)
|
|
|
|
await storage.upsert({"c1": _chunk_data(content="original")})
|
|
|
|
task = asyncio.create_task(storage.get_vectors_by_ids(["c1"]))
|
|
await asyncio.wait_for(embed.entered.wait(), timeout=1.0)
|
|
|
|
# Delete the pending record mid-embedding.
|
|
await storage.delete(["c1"])
|
|
assert "c1" not in storage._pending_vector_docs
|
|
|
|
embed.gate.set()
|
|
result = await asyncio.wait_for(task, timeout=1.0)
|
|
|
|
# The vector for the removed id is dropped from the response.
|
|
assert result == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Flush batching: upsert split by record cap, delete split by id cap
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_upsert_splits_by_record_cap():
|
|
storage = _make_storage()
|
|
storage._max_upsert_records_per_batch = 2 # byte cap left at default
|
|
|
|
await storage.upsert({f"c{i}": _chunk_data(content=f"text {i}") for i in range(5)})
|
|
await storage.index_done_callback()
|
|
|
|
# 5 records / cap 2 => 3 executemany calls (one per upsert chunk).
|
|
assert len(storage._captured_executemany) == 3
|
|
assert [len(rows) for _, rows in storage._captured_executemany] == [2, 2, 1]
|
|
# No deletes in this flush.
|
|
assert storage._captured_execute == []
|
|
assert storage._pending_vector_docs == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_upsert_splits_by_payload_bytes():
|
|
storage = _make_storage()
|
|
# Disable record cap; force a byte cap small enough that each big chunk
|
|
# lands in its own batch.
|
|
storage._max_upsert_records_per_batch = 0
|
|
storage._max_upsert_payload_bytes = 200
|
|
|
|
await storage.upsert({f"c{i}": _chunk_data(content="X" * 150) for i in range(3)})
|
|
await storage.index_done_callback()
|
|
|
|
# Each ~150-byte content row exceeds half the 200-byte budget, so the three
|
|
# rows cannot coalesce -> 3 separate executemany calls.
|
|
assert len(storage._captured_executemany) == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_delete_splits_by_id_cap():
|
|
storage = _make_storage()
|
|
storage._max_delete_records_per_batch = 2
|
|
|
|
await storage.delete([f"c{i}" for i in range(5)])
|
|
await storage.index_done_callback()
|
|
|
|
# 5 ids / cap 2 => 3 DELETE statements, each a bounded ANY($2) slice.
|
|
assert len(storage._captured_execute) == 3
|
|
slices = [args[1] for _, args in storage._captured_execute]
|
|
assert [len(s) for s in slices] == [2, 2, 1]
|
|
# Every captured statement is a DELETE.
|
|
assert all("DELETE FROM" in sql for sql, _ in storage._captured_execute)
|
|
assert storage._pending_vector_deletes == set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_only_flush_with_delete_splitting_disabled():
|
|
"""Regression: delete cap <= 0 + no pending deletes must not raise.
|
|
|
|
The disabled-delete fallback used to yield delete_chunk == 0, making the
|
|
empty-delete range() step raise ValueError and fail the upsert.
|
|
"""
|
|
storage = _make_storage()
|
|
storage._max_delete_records_per_batch = 0 # documented "disable splitting"
|
|
|
|
await storage.upsert({"c1": _chunk_data(content="alpha")})
|
|
await storage.index_done_callback() # upsert-only flush, no deletes
|
|
|
|
assert len(storage._captured_executemany) == 1
|
|
assert storage._captured_execute == []
|
|
assert storage._pending_vector_docs == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_delete_with_splitting_disabled_runs_single_statement():
|
|
"""delete cap <= 0 sends every pending id in one ANY($2) statement."""
|
|
storage = _make_storage()
|
|
storage._max_delete_records_per_batch = 0
|
|
|
|
await storage.delete([f"c{i}" for i in range(5)])
|
|
await storage.index_done_callback()
|
|
|
|
assert len(storage._captured_execute) == 1
|
|
_, args = storage._captured_execute[0]
|
|
assert len(args[1]) == 5
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_upsert_and_delete_are_separate_phases():
|
|
storage = _make_storage()
|
|
storage._max_upsert_records_per_batch = 10
|
|
storage._max_delete_records_per_batch = 10
|
|
|
|
await storage.upsert({"keep": _chunk_data(content="v")})
|
|
await storage.delete(["gone1", "gone2"])
|
|
await storage.index_done_callback()
|
|
|
|
# One upsert executemany + one delete execute (disjoint buffers).
|
|
assert len(storage._captured_executemany) == 1
|
|
assert len(storage._captured_execute) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_failure_keeps_pending_buffers():
|
|
storage = _make_storage(fail_run_with_retry=True)
|
|
await storage.upsert({"c1": _chunk_data()})
|
|
await storage.delete(["c2"])
|
|
|
|
with pytest.raises(RuntimeError, match="simulated PG failure"):
|
|
await storage.index_done_callback()
|
|
|
|
# Fail-fast-retain: nothing cleared, next flush replays everything.
|
|
assert "c1" in storage._pending_vector_docs
|
|
assert "c2" in storage._pending_vector_deletes
|
|
|
|
|
|
@pytest.mark.offline
|
|
@pytest.mark.asyncio
|
|
async def test_drop_pending_index_ops_clears_buffers():
|
|
"""On an internal-error abort the pipeline calls drop_pending_index_ops to
|
|
discard buffered upserts/deletes without flushing them (PR #3187)."""
|
|
storage = _make_storage()
|
|
await storage.upsert({"c1": _chunk_data(content="alpha")})
|
|
storage._pending_vector_deletes.add("old-id")
|
|
assert storage._pending_vector_docs
|
|
await storage.drop_pending_index_ops()
|
|
assert not storage._pending_vector_docs
|
|
assert not storage._pending_vector_deletes
|