Files
hkuds--lightrag/tests/kg/postgres_impl/test_postgres_vector_deferred.py
T
2026-07-13 12:08:54 +08:00

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