chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""LIGHTRAG_DOC_FULL.parse_engine must be TEXT (Phase 2).
|
||||
|
||||
The parse_engine field may carry an encoded engine-parameter directive
|
||||
(``mineru(page_range=1-3,language=en)``) longer than the original VARCHAR(32),
|
||||
so the CREATE DDL uses TEXT and the migration widens an existing VARCHAR(32)
|
||||
column. (A real overflow round-trip against live Postgres is the separate
|
||||
@pytest.mark.integration test; these mocked assertions cannot run real SQL.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.kg.postgres_impl import TABLES, PostgreSQLDB
|
||||
|
||||
|
||||
def test_create_ddl_uses_text_for_parse_engine():
|
||||
ddl = TABLES["LIGHTRAG_DOC_FULL"]["ddl"]
|
||||
assert "parse_engine TEXT" in ddl
|
||||
assert "parse_engine VARCHAR(32)" not in ddl
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_widens_existing_varchar_column_to_text():
|
||||
db = PostgreSQLDB.__new__(PostgreSQLDB) # skip __init__/connection setup
|
||||
|
||||
executed: list[str] = []
|
||||
|
||||
async def fake_query(sql, *args, **kwargs):
|
||||
# All columns already present -> ADD COLUMN loop is a no-op; the
|
||||
# parse_engine column reports the legacy VARCHAR type so the widening
|
||||
# ALTER fires.
|
||||
if "information_schema.columns" in sql and "column_name = ANY" in sql:
|
||||
return [
|
||||
{"column_name": c}
|
||||
for c in (
|
||||
"sidecar_location",
|
||||
"parse_format",
|
||||
"content_hash",
|
||||
"process_options",
|
||||
"chunk_options",
|
||||
"parse_engine",
|
||||
)
|
||||
]
|
||||
if "information_schema.columns" in sql: # the parse_engine type probe
|
||||
return {"data_type": "character varying"}
|
||||
return None
|
||||
|
||||
async def fake_execute(sql, *args, **kwargs):
|
||||
executed.append(sql)
|
||||
|
||||
db.query = AsyncMock(side_effect=fake_query)
|
||||
db.execute = AsyncMock(side_effect=fake_execute)
|
||||
|
||||
await db._migrate_doc_full_add_pipeline_fields()
|
||||
|
||||
assert any("ALTER COLUMN parse_engine TYPE TEXT" in sql for sql in executed), (
|
||||
executed
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_skips_alter_when_already_text():
|
||||
db = PostgreSQLDB.__new__(PostgreSQLDB)
|
||||
executed: list[str] = []
|
||||
|
||||
async def fake_query(sql, *args, **kwargs):
|
||||
if "information_schema.columns" in sql and "column_name = ANY" in sql:
|
||||
return [
|
||||
{"column_name": c}
|
||||
for c in (
|
||||
"sidecar_location",
|
||||
"parse_format",
|
||||
"content_hash",
|
||||
"process_options",
|
||||
"chunk_options",
|
||||
"parse_engine",
|
||||
)
|
||||
]
|
||||
if "information_schema.columns" in sql:
|
||||
return {"data_type": "text"}
|
||||
return None
|
||||
|
||||
db.query = AsyncMock(side_effect=fake_query)
|
||||
db.execute = AsyncMock(side_effect=lambda sql, *a, **k: executed.append(sql))
|
||||
|
||||
await db._migrate_doc_full_add_pipeline_fields()
|
||||
|
||||
assert not any("ALTER COLUMN parse_engine" in sql for sql in executed)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Unit tests for PGGraphStorage.get_nodes_edges_batch and get_node_edges
|
||||
with special characters in entity names.
|
||||
|
||||
Verifies the fix for KeyError when entity names contain double quotes (PR #2872)
|
||||
and the follow-up Option C refactor to parameterized Cypher queries.
|
||||
|
||||
The root cause: AGE returns the original un-escaped entity_id, but the edges_norm
|
||||
dict was previously keyed with the normalized (escaped) ID, causing a KeyError on lookup.
|
||||
|
||||
The Option C fix: use $node_ids / $entity_id parameters instead of string interpolation,
|
||||
eliminating the need for _normalize_node_id in these read paths entirely.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from lightrag.kg.postgres_impl import PGGraphStorage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_graph_storage() -> PGGraphStorage:
|
||||
"""Construct a PGGraphStorage instance with a mocked _query method."""
|
||||
storage = PGGraphStorage.__new__(PGGraphStorage)
|
||||
storage.workspace = "test_ws"
|
||||
storage.namespace = "test_graph"
|
||||
storage.graph_name = "test_graph"
|
||||
storage.db = MagicMock()
|
||||
return storage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _normalize_node_id (still used by write paths: remove_nodes, upsert_node, etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_plain_id():
|
||||
assert PGGraphStorage._normalize_node_id("Alice") == "Alice"
|
||||
|
||||
|
||||
def test_normalize_double_quote():
|
||||
assert PGGraphStorage._normalize_node_id('John "Smith"') == 'John \\"Smith\\"'
|
||||
|
||||
|
||||
def test_normalize_backslash():
|
||||
assert PGGraphStorage._normalize_node_id("C:\\path") == "C:\\\\path"
|
||||
|
||||
|
||||
def test_normalize_both_special_chars():
|
||||
assert (
|
||||
PGGraphStorage._normalize_node_id('say \\"hello\\"')
|
||||
== 'say \\\\\\"hello\\\\\\"'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_node_edges — parameterized query (Option C)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_node_edges_passes_original_id_as_parameter():
|
||||
"""entity_id must be passed as a JSON parameter, not interpolated into Cypher."""
|
||||
storage = make_graph_storage()
|
||||
entity = 'John "Smith"'
|
||||
captured_params: list[dict] = []
|
||||
|
||||
async def fake_query(sql, **kwargs):
|
||||
if kwargs.get("params"):
|
||||
captured_params.append(json.loads(list(kwargs["params"].values())[0]))
|
||||
return []
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
await storage.get_node_edges(entity)
|
||||
|
||||
assert len(captured_params) == 1
|
||||
assert captured_params[0]["entity_id"] == entity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_node_edges_cypher_uses_parameter_syntax():
|
||||
"""The SQL sent to _query must use $1::agtype, not a hardcoded escaped string."""
|
||||
storage = make_graph_storage()
|
||||
entity = 'John "Smith"'
|
||||
captured_sql: list[str] = []
|
||||
|
||||
async def fake_query(sql, **kwargs):
|
||||
captured_sql.append(sql)
|
||||
return []
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
await storage.get_node_edges(entity)
|
||||
|
||||
assert len(captured_sql) == 1
|
||||
assert "$1::agtype" in captured_sql[0]
|
||||
# Entity name must NOT appear literally in the SQL string
|
||||
assert entity not in captured_sql[0]
|
||||
assert '\\"' not in captured_sql[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_node_edges_returns_edges():
|
||||
storage = make_graph_storage()
|
||||
|
||||
async def fake_query(_sql, **_kwargs):
|
||||
return [
|
||||
{"source_id": "Alice", "connected_id": "Bob"},
|
||||
{"source_id": "Alice", "connected_id": None},
|
||||
]
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
result = await storage.get_node_edges("Alice")
|
||||
|
||||
assert result == [("Alice", "Bob")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_nodes_edges_batch — parameterized query (Option C)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nodes_edges_batch_passes_original_ids_as_parameter():
|
||||
"""node_ids batch must be passed as a JSON parameter, not interpolated."""
|
||||
storage = make_graph_storage()
|
||||
entities = ['John "Smith"', "Alice", "O\\Brien"]
|
||||
captured_params: list[dict] = []
|
||||
|
||||
async def fake_query(_sql, **kwargs):
|
||||
if kwargs.get("params"):
|
||||
captured_params.append(json.loads(list(kwargs["params"].values())[0]))
|
||||
return []
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
await storage.get_nodes_edges_batch(entities)
|
||||
|
||||
assert len(captured_params) == 2 # outgoing + incoming
|
||||
assert captured_params[0]["node_ids"] == entities
|
||||
assert captured_params[1]["node_ids"] == entities
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nodes_edges_batch_cypher_uses_parameter_syntax():
|
||||
"""The SQL must use $1::agtype, not hardcoded escaped entity names."""
|
||||
storage = make_graph_storage()
|
||||
entity = 'John "Smith"'
|
||||
captured_sql: list[str] = []
|
||||
|
||||
async def fake_query(sql, **_kwargs):
|
||||
captured_sql.append(sql)
|
||||
return []
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
await storage.get_nodes_edges_batch([entity])
|
||||
|
||||
assert len(captured_sql) == 2
|
||||
for sql in captured_sql:
|
||||
assert "$1::agtype" in sql
|
||||
assert entity not in sql
|
||||
assert '\\"' not in sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nodes_edges_batch_with_quoted_entity():
|
||||
"""
|
||||
AGE returns the original un-escaped node_id in query results.
|
||||
The result dict must be keyed by the original ID.
|
||||
"""
|
||||
storage = make_graph_storage()
|
||||
entity = 'John "Smith"'
|
||||
|
||||
async def fake_query(sql, **_kwargs):
|
||||
if "OPTIONAL MATCH (n:base)-[]->" in sql:
|
||||
return [{"node_id": entity, "connected_id": "Alice"}]
|
||||
if "OPTIONAL MATCH (n:base)<-[]-" in sql:
|
||||
return [{"node_id": entity, "connected_id": "Bob"}]
|
||||
return []
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
result = await storage.get_nodes_edges_batch([entity])
|
||||
|
||||
assert entity in result
|
||||
assert (entity, "Alice") in result[entity]
|
||||
assert ("Bob", entity) in result[entity]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nodes_edges_batch_plain_entity():
|
||||
"""Entity names without special chars still work correctly."""
|
||||
storage = make_graph_storage()
|
||||
entity = "Alice"
|
||||
|
||||
async def fake_query(sql, **_kwargs):
|
||||
if "OPTIONAL MATCH (n:base)-[]->" in sql:
|
||||
return [{"node_id": entity, "connected_id": "Bob"}]
|
||||
return []
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
result = await storage.get_nodes_edges_batch([entity])
|
||||
|
||||
assert entity in result
|
||||
assert (entity, "Bob") in result[entity]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nodes_edges_batch_no_results():
|
||||
"""Nodes with no edges return an empty list, not a KeyError."""
|
||||
storage = make_graph_storage()
|
||||
entity = 'Entity "X"'
|
||||
|
||||
async def fake_query(_sql, **_kwargs):
|
||||
return []
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
result = await storage.get_nodes_edges_batch([entity])
|
||||
|
||||
assert entity in result
|
||||
assert result[entity] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nodes_edges_batch_deduplication():
|
||||
"""Duplicate input IDs are deduplicated; each maps to the same edge list."""
|
||||
storage = make_graph_storage()
|
||||
entity = 'Dup "Entity"'
|
||||
|
||||
async def fake_query(sql, **_kwargs):
|
||||
if "OPTIONAL MATCH (n:base)-[]->" in sql:
|
||||
return [{"node_id": entity, "connected_id": "Other"}]
|
||||
return []
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
result = await storage.get_nodes_edges_batch([entity, entity])
|
||||
|
||||
assert result[entity] == [(entity, "Other")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nodes_edges_batch_empty_input():
|
||||
"""Empty input returns empty dict without calling _query."""
|
||||
storage = make_graph_storage()
|
||||
|
||||
with patch.object(storage, "_query") as mock_q:
|
||||
result = await storage.get_nodes_edges_batch([])
|
||||
|
||||
assert result == {}
|
||||
mock_q.assert_not_called()
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Unit tests for the PostgreSQL batch-limit helpers.
|
||||
|
||||
Covers the module-level batching primitives shared by the PG non-graph write
|
||||
paths (mirrors the MONGO_* / OPENSEARCH_* contract):
|
||||
|
||||
- ``_resolve_pg_batch_limits``: env parsing, defaults, non-positive disable.
|
||||
- ``_estimate_record_bytes``: per-field byte estimation for asyncpg tuples.
|
||||
- ``_chunk_by_budget``: record-count + payload-byte splitting.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from lightrag.kg.postgres_impl import (
|
||||
DEFAULT_PG_DELETE_MAX_RECORDS_PER_BATCH,
|
||||
DEFAULT_PG_UPSERT_MAX_PAYLOAD_BYTES,
|
||||
DEFAULT_PG_UPSERT_MAX_RECORDS_PER_BATCH,
|
||||
_chunk_by_budget,
|
||||
_estimate_record_bytes,
|
||||
_resolve_pg_batch_limits,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_pg_batch_limits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolvePgBatchLimits:
|
||||
def test_defaults(self):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
payload, upserts, deletes = _resolve_pg_batch_limits()
|
||||
assert payload == DEFAULT_PG_UPSERT_MAX_PAYLOAD_BYTES
|
||||
assert upserts == DEFAULT_PG_UPSERT_MAX_RECORDS_PER_BATCH
|
||||
assert deletes == DEFAULT_PG_DELETE_MAX_RECORDS_PER_BATCH
|
||||
|
||||
def test_default_record_cap_keeps_kv_historical_200(self):
|
||||
# The PG-wide upsert record default deliberately stays at 200 (KV's
|
||||
# historical batch size), not Mongo/OS's 128.
|
||||
assert DEFAULT_PG_UPSERT_MAX_RECORDS_PER_BATCH == 200
|
||||
|
||||
def test_env_override(self):
|
||||
env = {
|
||||
"POSTGRES_UPSERT_MAX_PAYLOAD_BYTES": "12345",
|
||||
"POSTGRES_UPSERT_MAX_RECORDS_PER_BATCH": "7",
|
||||
"POSTGRES_DELETE_MAX_RECORDS_PER_BATCH": "9",
|
||||
}
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
assert _resolve_pg_batch_limits() == (12345, 7, 9)
|
||||
|
||||
def test_non_positive_disables_and_warns(self):
|
||||
env = {
|
||||
"POSTGRES_UPSERT_MAX_PAYLOAD_BYTES": "0",
|
||||
"POSTGRES_UPSERT_MAX_RECORDS_PER_BATCH": "-1",
|
||||
"POSTGRES_DELETE_MAX_RECORDS_PER_BATCH": "0",
|
||||
}
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
with patch("lightrag.kg.postgres_impl.logger") as mock_logger:
|
||||
payload, upserts, deletes = _resolve_pg_batch_limits()
|
||||
assert (payload, upserts, deletes) == (0, -1, 0)
|
||||
warnings = [c.args[0] for c in mock_logger.warning.call_args_list]
|
||||
assert sum("non-positive" in msg for msg in warnings) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _estimate_record_bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEstimateRecordBytes:
|
||||
def test_ndarray_uses_nbytes(self):
|
||||
vec = np.zeros(128, dtype=np.float32)
|
||||
# 128 * 4 bytes (nbytes) plus the 2-byte "id" str.
|
||||
assert _estimate_record_bytes(("id", vec)) == vec.nbytes + 2
|
||||
|
||||
def test_str_uses_utf8_length(self):
|
||||
assert _estimate_record_bytes(("abc",)) == 3
|
||||
|
||||
def test_multibyte_str(self):
|
||||
# Each CJK char is 3 bytes in UTF-8.
|
||||
assert _estimate_record_bytes(("中文",)) == 6
|
||||
|
||||
def test_bytes_and_none(self):
|
||||
assert _estimate_record_bytes((b"abcd", None)) == 4
|
||||
|
||||
def test_dict_and_list_use_compact_json(self):
|
||||
payload = {"a": 1, "b": [1, 2, 3]}
|
||||
expected = len(
|
||||
json.dumps(
|
||||
payload, ensure_ascii=False, separators=(",", ":"), default=str
|
||||
).encode("utf-8")
|
||||
)
|
||||
assert _estimate_record_bytes((payload,)) == expected
|
||||
|
||||
def test_scalar_constant(self):
|
||||
# int + float + None -> 16 + 16 + 0
|
||||
assert _estimate_record_bytes((1, 2.0, None)) == 32
|
||||
|
||||
def test_mixed_tuple(self):
|
||||
vec = np.ones(4, dtype=np.float32) # 16 bytes
|
||||
record = ("ws", "id", "hello", vec, None, 42)
|
||||
# 2 + 2 + 5 + 16 + 0 + 16
|
||||
assert _estimate_record_bytes(record) == 41
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _chunk_by_budget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChunkByBudget:
|
||||
def test_empty(self):
|
||||
assert _chunk_by_budget([], lambda x: 1, 100, 100) == []
|
||||
|
||||
def test_split_by_record_count(self):
|
||||
items = list(range(7))
|
||||
batches = _chunk_by_budget(
|
||||
items, lambda x: 1, max_payload_bytes=0, max_records_per_batch=3
|
||||
)
|
||||
sizes = [len(b) for b, _ in batches]
|
||||
assert sizes == [3, 3, 1]
|
||||
|
||||
def test_split_by_payload_bytes(self):
|
||||
# Each item ~10 bytes; a 25-byte budget fits 2 per batch (2 overhead +
|
||||
# 10 + 1 + 10 = 23 <= 25; adding a third would exceed).
|
||||
items = ["x" * 10, "y" * 10, "z" * 10, "w" * 10]
|
||||
batches = _chunk_by_budget(
|
||||
items, lambda s: len(s), max_payload_bytes=25, max_records_per_batch=0
|
||||
)
|
||||
sizes = [len(b) for b, _ in batches]
|
||||
assert sizes == [2, 2]
|
||||
|
||||
def test_both_dimensions_record_cap_wins(self):
|
||||
items = ["x" * 5] * 10
|
||||
# record cap 4 binds before the generous byte budget.
|
||||
batches = _chunk_by_budget(
|
||||
items, lambda s: len(s), max_payload_bytes=10_000, max_records_per_batch=4
|
||||
)
|
||||
sizes = [len(b) for b, _ in batches]
|
||||
assert sizes == [4, 4, 2]
|
||||
|
||||
def test_oversized_single_item_becomes_own_batch(self):
|
||||
items = ["small", "X" * 1000, "small2"]
|
||||
batches = _chunk_by_budget(
|
||||
items, lambda s: len(s), max_payload_bytes=50, max_records_per_batch=0
|
||||
)
|
||||
# The 1000-byte item is emitted alone rather than raising.
|
||||
sizes = [len(b) for b, _ in batches]
|
||||
assert sizes == [1, 1, 1]
|
||||
assert batches[1][0] == ["X" * 1000]
|
||||
|
||||
def test_non_positive_disables_both_dimensions(self):
|
||||
items = list(range(100))
|
||||
batches = _chunk_by_budget(
|
||||
items, lambda x: 999, max_payload_bytes=0, max_records_per_batch=0
|
||||
)
|
||||
assert len(batches) == 1
|
||||
assert len(batches[0][0]) == 100
|
||||
@@ -0,0 +1,73 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.kg.postgres_impl import ClientManager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_client_manager_state() -> None:
|
||||
ClientManager._instances = {"db": None, "ref_count": 0, "vector_signature": None}
|
||||
|
||||
|
||||
def test_pg_vector_storage_enables_vector() -> None:
|
||||
config = ClientManager.get_config("PGVectorStorage")
|
||||
assert config["enable_vector"] is True
|
||||
|
||||
|
||||
def test_non_pg_vector_storage_disables_vector() -> None:
|
||||
config = ClientManager.get_config("NanoVectorDBStorage")
|
||||
assert config["enable_vector"] is False
|
||||
|
||||
|
||||
def test_milvus_storage_disables_vector() -> None:
|
||||
config = ClientManager.get_config("MilvusVectorDBStorage")
|
||||
assert config["enable_vector"] is False
|
||||
|
||||
|
||||
def test_qdrant_storage_disables_vector() -> None:
|
||||
config = ClientManager.get_config("QdrantVectorDBStorage")
|
||||
assert config["enable_vector"] is False
|
||||
|
||||
|
||||
def test_none_vector_storage_defaults_to_true() -> None:
|
||||
# Backward compatibility: when vector_storage is unknown (None), default to True.
|
||||
config = ClientManager.get_config(None)
|
||||
assert config["enable_vector"] is True
|
||||
|
||||
|
||||
def test_no_args_defaults_to_true() -> None:
|
||||
# Backward compatibility: calling without arguments preserves prior behavior.
|
||||
config = ClientManager.get_config()
|
||||
assert config["enable_vector"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_reuses_shared_pool_for_same_vector_settings() -> None:
|
||||
db = MagicMock()
|
||||
db.initdb = AsyncMock()
|
||||
db.check_tables = AsyncMock()
|
||||
|
||||
with patch("lightrag.kg.postgres_impl.PostgreSQLDB", return_value=db) as db_cls:
|
||||
first = await ClientManager.get_client("PGVectorStorage")
|
||||
second = await ClientManager.get_client("PGVectorStorage")
|
||||
|
||||
assert first is db
|
||||
assert second is db
|
||||
assert ClientManager._instances["ref_count"] == 2
|
||||
db_cls.assert_called_once()
|
||||
db.initdb.assert_awaited_once()
|
||||
db.check_tables.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_rejects_conflicting_vector_storage_settings() -> None:
|
||||
db = MagicMock()
|
||||
db.initdb = AsyncMock()
|
||||
db.check_tables = AsyncMock()
|
||||
|
||||
with patch("lightrag.kg.postgres_impl.PostgreSQLDB", return_value=db):
|
||||
await ClientManager.get_client("NanoVectorDBStorage")
|
||||
|
||||
with pytest.raises(RuntimeError, match="process-wide"):
|
||||
await ClientManager.get_client("PGVectorStorage")
|
||||
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Unit tests for Cypher injection prevention in PGGraphStorage write paths.
|
||||
|
||||
Verifies that upsert_node and upsert_edge keep entity IDs parameterized while
|
||||
rendering property maps as safely escaped Cypher literals, which is required by
|
||||
Apache AGE because ``SET ... += $props`` is not supported.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from lightrag.kg.postgres_impl import PGGraphStorage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_graph_storage() -> PGGraphStorage:
|
||||
"""Construct a PGGraphStorage instance with a mocked db."""
|
||||
storage = PGGraphStorage.__new__(PGGraphStorage)
|
||||
storage.workspace = "test_ws"
|
||||
storage.namespace = "test_graph"
|
||||
storage.graph_name = "test_graph"
|
||||
storage.db = MagicMock()
|
||||
return storage
|
||||
|
||||
|
||||
class _FakeConnection:
|
||||
"""Captures statements + args passed to a fake asyncpg connection."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def transaction(self):
|
||||
return _FakeTransaction()
|
||||
|
||||
async def execute(self, sql, *args):
|
||||
self.calls.append({"sql": sql, "args": args})
|
||||
return ""
|
||||
|
||||
|
||||
class _FakeTransaction:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
async def _capture_upsert_edge(storage: PGGraphStorage, src: str, tgt: str, edge_data):
|
||||
"""Invoke upsert_edge against a fake connection and return the captured calls."""
|
||||
conn = _FakeConnection()
|
||||
|
||||
async def fake_run_with_retry(operation, **_kwargs):
|
||||
return await operation(conn)
|
||||
|
||||
storage.db._run_with_retry = AsyncMock(side_effect=fake_run_with_retry)
|
||||
await storage.upsert_edge(src, tgt, edge_data)
|
||||
return conn.calls
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# upsert_node — parameterized Cypher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_node_uses_parameterized_cypher():
|
||||
"""upsert_node must pass entity_id as a Cypher parameter, not interpolate it."""
|
||||
storage = make_graph_storage()
|
||||
captured_calls: list[dict] = []
|
||||
|
||||
async def fake_query(sql, **kwargs):
|
||||
captured_calls.append({"sql": sql, **kwargs})
|
||||
return []
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
await storage.upsert_node(
|
||||
"Alice", {"entity_id": "Alice", "description": "A person"}
|
||||
)
|
||||
|
||||
assert len(captured_calls) == 1
|
||||
call = captured_calls[0]
|
||||
assert "$1::agtype" in call["sql"]
|
||||
assert '"Alice"' not in call["sql"].replace("$1::agtype", "")
|
||||
assert "params" in call
|
||||
params = json.loads(call["params"]["params"])
|
||||
assert params["entity_id"] == "Alice"
|
||||
assert "props" not in params
|
||||
assert '`description`: "A person"' in call["sql"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_node_injection_payload_in_entity_id():
|
||||
"""A Cypher injection payload in entity_id must be treated as data, not code."""
|
||||
storage = make_graph_storage()
|
||||
injection = 'test"}) RETURN n; MATCH (m) DETACH DELETE m; //'
|
||||
captured_calls: list[dict] = []
|
||||
|
||||
async def fake_query(sql, **kwargs):
|
||||
captured_calls.append({"sql": sql, **kwargs})
|
||||
return []
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
await storage.upsert_node(
|
||||
injection, {"entity_id": injection, "description": "malicious"}
|
||||
)
|
||||
|
||||
call = captured_calls[0]
|
||||
# The injection payload must NOT appear in the SQL string
|
||||
assert "DETACH DELETE" not in call["sql"]
|
||||
assert injection not in call["sql"]
|
||||
# It must be safely contained in the JSON parameter
|
||||
params = json.loads(call["params"]["params"])
|
||||
assert params["entity_id"] == injection
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_node_special_chars_in_properties():
|
||||
"""Property values with special characters are safely escaped in Cypher."""
|
||||
storage = make_graph_storage()
|
||||
captured_calls: list[dict] = []
|
||||
|
||||
async def fake_query(sql, **kwargs):
|
||||
captured_calls.append({"sql": sql, **kwargs})
|
||||
return []
|
||||
|
||||
node_data = {
|
||||
"entity_id": "test_node",
|
||||
"description": 'He said "hello" and used a backslash \\',
|
||||
"notes": "Line1\nLine2\tTabbed",
|
||||
"formula": "x < 5 && y > 3",
|
||||
}
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
await storage.upsert_node("test_node", node_data)
|
||||
|
||||
call = captured_calls[0]
|
||||
assert (
|
||||
'`description`: "He said \\"hello\\" and used a backslash \\\\"' in call["sql"]
|
||||
)
|
||||
assert '`notes`: "Line1\\nLine2\\tTabbed"' in call["sql"]
|
||||
assert '`formula`: "x < 5 && y > 3"' in call["sql"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_node_unicode_entity_id():
|
||||
"""Unicode entity names are safely parameterized."""
|
||||
storage = make_graph_storage()
|
||||
captured_calls: list[dict] = []
|
||||
|
||||
async def fake_query(sql, **kwargs):
|
||||
captured_calls.append({"sql": sql, **kwargs})
|
||||
return []
|
||||
|
||||
unicode_id = "\u4e2d\u6587\u5b9e\u4f53" # Chinese characters
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
await storage.upsert_node(
|
||||
unicode_id, {"entity_id": unicode_id, "description": "\u63cf\u8ff0"}
|
||||
)
|
||||
|
||||
call = captured_calls[0]
|
||||
params = json.loads(call["params"]["params"])
|
||||
assert params["entity_id"] == unicode_id
|
||||
assert '`description`: "描述"' in call["sql"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_node_dollar_signs_in_entity_id():
|
||||
"""Dollar signs in entity_id don't break dollar-quoting of the Cypher template."""
|
||||
storage = make_graph_storage()
|
||||
captured_calls: list[dict] = []
|
||||
|
||||
async def fake_query(sql, **kwargs):
|
||||
captured_calls.append({"sql": sql, **kwargs})
|
||||
return []
|
||||
|
||||
dollar_id = "price is $100 or $$200$$"
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
await storage.upsert_node(
|
||||
dollar_id, {"entity_id": dollar_id, "description": "has dollars"}
|
||||
)
|
||||
|
||||
call = captured_calls[0]
|
||||
# The dollar signs are in the params, not the SQL template
|
||||
params = json.loads(call["params"]["params"])
|
||||
assert params["entity_id"] == dollar_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_node_escapes_backticks_in_property_keys():
|
||||
"""Backticks in property keys must be escaped before inlining the map."""
|
||||
storage = make_graph_storage()
|
||||
captured_calls: list[dict] = []
|
||||
|
||||
async def fake_query(sql, **kwargs):
|
||||
captured_calls.append({"sql": sql, **kwargs})
|
||||
return []
|
||||
|
||||
with patch.object(storage, "_query", side_effect=fake_query):
|
||||
await storage.upsert_node(
|
||||
"node",
|
||||
{"entity_id": "node", "danger`key": 'value "quoted"'},
|
||||
)
|
||||
|
||||
assert '`danger``key`: "value \\"quoted\\""' in captured_calls[0]["sql"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_node_requires_entity_id():
|
||||
"""upsert_node still raises ValueError when entity_id is missing."""
|
||||
storage = make_graph_storage()
|
||||
with pytest.raises(ValueError, match="entity_id"):
|
||||
await storage.upsert_node("test", {"description": "no entity_id"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# upsert_edge — parameterized Cypher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edge_uses_parameterized_cypher():
|
||||
"""upsert_edge must pass entity IDs as Cypher parameters."""
|
||||
storage = make_graph_storage()
|
||||
calls = await _capture_upsert_edge(
|
||||
storage, "Alice", "Bob", {"weight": "1.0", "description": "knows"}
|
||||
)
|
||||
|
||||
# Three statements: per-edge lock, graph-wide shared lock, then cypher.
|
||||
assert len(calls) == 3
|
||||
|
||||
lock_sql = calls[0]["sql"]
|
||||
# Raw node IDs are positional params on the lock, never interpolated.
|
||||
assert "Alice" not in lock_sql
|
||||
assert "Bob" not in lock_sql
|
||||
# graph_name flows as $1, the endpoint pair as $2/$3.
|
||||
assert calls[0]["args"] == ("test_graph", "Alice", "Bob")
|
||||
|
||||
cypher_call = calls[2]
|
||||
cypher_sql = cypher_call["sql"]
|
||||
assert "$1::agtype" in cypher_sql
|
||||
assert '"Alice"' not in cypher_sql.replace("$1::agtype", "")
|
||||
assert '"Bob"' not in cypher_sql.replace("$1::agtype", "")
|
||||
# Cypher params arrive as a single positional agtype JSON arg.
|
||||
params = json.loads(cypher_call["args"][0])
|
||||
assert params["src_id"] == "Alice"
|
||||
assert params["tgt_id"] == "Bob"
|
||||
assert "props" not in params
|
||||
assert '`weight`: "1.0"' in cypher_sql
|
||||
assert '`description`: "knows"' in cypher_sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edge_injection_payload():
|
||||
"""Injection payloads in edge entity IDs are safely parameterized."""
|
||||
storage = make_graph_storage()
|
||||
injection_src = 'src"}) MATCH (x) DETACH DELETE x; //'
|
||||
injection_tgt = 'tgt"})-[r]-() DELETE r; //'
|
||||
calls = await _capture_upsert_edge(
|
||||
storage, injection_src, injection_tgt, {"description": "edge"}
|
||||
)
|
||||
|
||||
# Injection payloads must never appear in either SQL template — they only
|
||||
# flow through positional params.
|
||||
for call in calls:
|
||||
assert "DETACH DELETE" not in call["sql"]
|
||||
assert "DELETE r" not in call["sql"]
|
||||
assert injection_src not in call["sql"]
|
||||
assert injection_tgt not in call["sql"]
|
||||
|
||||
# Lock statement passes graph_name + raw IDs as positional params.
|
||||
assert calls[0]["args"] == ("test_graph", injection_src, injection_tgt)
|
||||
|
||||
# Cypher params arrive as a single positional agtype JSON arg (3rd statement,
|
||||
# after the per-edge and graph-wide-shared locks).
|
||||
params = json.loads(calls[2]["args"][0])
|
||||
assert params["src_id"] == injection_src
|
||||
assert params["tgt_id"] == injection_tgt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edge_unicode_entity_ids():
|
||||
"""Unicode entity IDs in edges are safely parameterized."""
|
||||
storage = make_graph_storage()
|
||||
src = "\u5317\u4eac"
|
||||
tgt = "\u4e0a\u6d77"
|
||||
calls = await _capture_upsert_edge(
|
||||
storage, src, tgt, {"description": "\u8def\u7ebf"}
|
||||
)
|
||||
|
||||
# Lock statement carries graph_name + raw IDs as positional params, not
|
||||
# interpolated.
|
||||
assert calls[0]["args"] == ("test_graph", src, tgt)
|
||||
assert src not in calls[0]["sql"]
|
||||
assert tgt not in calls[0]["sql"]
|
||||
|
||||
# Cypher params parsed from the positional agtype JSON arg (3rd statement).
|
||||
cypher_sql = calls[2]["sql"]
|
||||
params = json.loads(calls[2]["args"][0])
|
||||
assert params["src_id"] == src
|
||||
assert params["tgt_id"] == tgt
|
||||
assert '`description`: "路线"' in cypher_sql
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _normalize_node_id — defence-in-depth for remaining interpolation paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_node_id_strips_null_bytes():
|
||||
"""Null bytes are stripped to prevent string truncation."""
|
||||
assert PGGraphStorage._normalize_node_id("before\x00after") == "beforeafter"
|
||||
|
||||
|
||||
def test_normalize_node_id_escapes_backslash_and_quote():
|
||||
"""Backslashes and double quotes are escaped."""
|
||||
assert PGGraphStorage._normalize_node_id('a\\"b') == 'a\\\\\\"b'
|
||||
|
||||
|
||||
def test_normalize_node_id_injection_payload():
|
||||
"""Injection payload is escaped so it cannot break out of Cypher string."""
|
||||
payload = 'test"}) RETURN n; MATCH (m) DETACH DELETE m; //'
|
||||
normalized = PGGraphStorage._normalize_node_id(payload)
|
||||
# The double quote must be escaped
|
||||
assert '\\"' in normalized
|
||||
# The escaped string must not contain an unescaped double quote
|
||||
# (remove all escaped quotes and check no raw ones remain)
|
||||
unescaped = normalized.replace('\\"', "")
|
||||
assert '"' not in unescaped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _query write path passes params to db.execute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_write_path_passes_params():
|
||||
"""When readonly=False, _query must forward params to db.execute."""
|
||||
storage = make_graph_storage()
|
||||
captured_execute_kwargs: list[dict] = []
|
||||
|
||||
async def fake_execute(sql, **kwargs):
|
||||
captured_execute_kwargs.append(kwargs)
|
||||
return None
|
||||
|
||||
storage.db.execute = fake_execute
|
||||
|
||||
test_params = {"params": json.dumps({"entity_id": "test"})}
|
||||
await storage._query(
|
||||
"SELECT 1",
|
||||
readonly=False,
|
||||
upsert=True,
|
||||
params=test_params,
|
||||
)
|
||||
|
||||
assert len(captured_execute_kwargs) == 1
|
||||
assert captured_execute_kwargs[0]["data"] == test_params
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Regression test for Issue #3286.
|
||||
|
||||
``adelete_by_doc_id`` / ``_purge_doc_chunks_and_kg`` historically passed
|
||||
``chunk_ids`` as a ``set`` to ``PGKVStorage.delete`` / ``PGDocStatusStorage.delete``.
|
||||
After delete-batching was introduced, the chunked path slices the id collection
|
||||
(``ids[i : i + chunk]``); slicing a ``set`` raises ``'set' object is not
|
||||
subscriptable``, which the broad ``except Exception`` in ``delete`` then swallows
|
||||
as a log line -- so the records were silently NOT deleted.
|
||||
|
||||
These tests feed a ``set`` straight into ``delete`` with a positive batch cap
|
||||
(forcing the chunked path) and assert that every id is actually handed to the
|
||||
SQL layer. Asserting "no exception" alone is insufficient because the bug is
|
||||
swallowed; we must assert the records really got deleted.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("asyncpg")
|
||||
|
||||
from lightrag.kg.postgres_impl import ( # noqa: E402
|
||||
PGDocStatusStorage,
|
||||
PGKVStorage,
|
||||
)
|
||||
from lightrag.namespace import NameSpace # noqa: E402
|
||||
|
||||
|
||||
class _FakeTransaction:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
class _RecordingConnection:
|
||||
"""Captures the id-array argument of each ``execute`` call."""
|
||||
|
||||
def __init__(self):
|
||||
self.deleted_id_batches: list[list[str]] = []
|
||||
|
||||
def transaction(self):
|
||||
return _FakeTransaction()
|
||||
|
||||
async def execute(self, sql, workspace, id_slice):
|
||||
# The chunked path slices ids; a slice of a list is a list. Record it.
|
||||
assert isinstance(id_slice, list)
|
||||
self.deleted_id_batches.append(id_slice)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, connection):
|
||||
self._connection = connection
|
||||
|
||||
async def _run_with_retry(self, operation):
|
||||
# Mirror the real helper: invoke the closure against a live connection.
|
||||
return await operation(self._connection)
|
||||
|
||||
|
||||
def _make_storage(cls, namespace, batch_cap):
|
||||
"""Build a storage instance exercising only the attributes ``delete`` reads."""
|
||||
storage = object.__new__(cls)
|
||||
storage.namespace = namespace
|
||||
storage.workspace = "test_ws"
|
||||
storage._max_delete_records_per_batch = batch_cap
|
||||
connection = _RecordingConnection()
|
||||
storage.db = _FakeDB(connection)
|
||||
return storage, connection
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pgkv_delete_accepts_set_and_chunks_all_ids():
|
||||
ids = {f"chunk-{i}" for i in range(5)}
|
||||
# Positive cap < len(ids) forces the chunked slicing path that broke on sets.
|
||||
storage, connection = _make_storage(
|
||||
PGKVStorage, NameSpace.KV_STORE_TEXT_CHUNKS, batch_cap=2
|
||||
)
|
||||
|
||||
await storage.delete(ids)
|
||||
|
||||
flattened = [cid for batch in connection.deleted_id_batches for cid in batch]
|
||||
assert set(flattened) == ids
|
||||
assert len(flattened) == len(ids) # no duplicates / drops
|
||||
assert all(len(batch) <= 2 for batch in connection.deleted_id_batches)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pgdocstatus_delete_accepts_set_and_chunks_all_ids():
|
||||
ids = {f"doc-{i}" for i in range(5)}
|
||||
storage, connection = _make_storage(
|
||||
PGDocStatusStorage, NameSpace.DOC_STATUS, batch_cap=2
|
||||
)
|
||||
|
||||
await storage.delete(ids)
|
||||
|
||||
flattened = [did for batch in connection.deleted_id_batches for did in batch]
|
||||
assert set(flattened) == ids
|
||||
assert len(flattened) == len(ids)
|
||||
assert all(len(batch) <= 2 for batch in connection.deleted_id_batches)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Unit tests for PGDocStatusStorage database-native overrides.
|
||||
|
||||
Covers the PG-specific implementations of:
|
||||
* get_doc_by_file_basename
|
||||
* get_doc_by_content_hash
|
||||
|
||||
Both override the base-class full-table scan with indexed SQL queries.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from lightrag.kg.postgres_impl import PGDocStatusStorage
|
||||
from lightrag.namespace import NameSpace
|
||||
|
||||
|
||||
def _make_storage():
|
||||
storage = PGDocStatusStorage.__new__(PGDocStatusStorage)
|
||||
storage.namespace = NameSpace.DOC_STATUS
|
||||
storage.workspace = "test_ws"
|
||||
storage.global_config = {"embedding_batch_num": 10}
|
||||
storage.db = MagicMock()
|
||||
storage.db.query = AsyncMock()
|
||||
return storage
|
||||
|
||||
|
||||
def _row(**overrides):
|
||||
base = {
|
||||
"id": "doc-1",
|
||||
"content_summary": "summary",
|
||||
"content_length": 12,
|
||||
"chunks_count": 1,
|
||||
"status": "processed",
|
||||
"file_path": "report.pdf",
|
||||
"chunks_list": "[]",
|
||||
"metadata": "{}",
|
||||
"error_msg": None,
|
||||
"track_id": None,
|
||||
"content_hash": "abc123",
|
||||
"created_at": datetime(2024, 1, 1, 0, 0, 0),
|
||||
"updated_at": datetime(2024, 1, 1, 0, 0, 0),
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_doc_by_file_basename
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_doc_by_file_basename_empty_returns_none():
|
||||
storage = _make_storage()
|
||||
assert await storage.get_doc_by_file_basename("") is None
|
||||
storage.db.query.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_doc_by_file_basename_unknown_source_returns_none():
|
||||
storage = _make_storage()
|
||||
# normalize_document_file_path returns "unknown_source" for None-ish inputs
|
||||
assert await storage.get_doc_by_file_basename("unknown_source") is None
|
||||
storage.db.query.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_doc_by_file_basename_exact_match():
|
||||
storage = _make_storage()
|
||||
storage.db.query.return_value = [_row(file_path="report.pdf")]
|
||||
|
||||
result = await storage.get_doc_by_file_basename("report.pdf")
|
||||
|
||||
assert result is not None
|
||||
doc_id, doc = result
|
||||
assert doc_id == "doc-1"
|
||||
assert doc["file_path"] == "report.pdf"
|
||||
assert doc["content_hash"] == "abc123"
|
||||
|
||||
call = storage.db.query.call_args
|
||||
sql = call.args[0]
|
||||
params = call.args[1]
|
||||
assert "LIGHTRAG_DOC_STATUS" in sql
|
||||
assert "workspace=$1" in sql
|
||||
assert params[0] == "test_ws"
|
||||
assert params[1] == "report.pdf"
|
||||
assert params == ["test_ws", "report.pdf"]
|
||||
assert "LIKE" not in sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_doc_by_file_basename_orders_stably_for_canonical_rows():
|
||||
storage = _make_storage()
|
||||
storage.db.query.return_value = [_row(id="doc-exact", file_path="report.pdf")]
|
||||
|
||||
result = await storage.get_doc_by_file_basename("report.pdf")
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == "doc-exact"
|
||||
|
||||
sql = storage.db.query.call_args.args[0]
|
||||
assert "file_path = $2" in sql
|
||||
assert "created_at ASC" in sql
|
||||
assert "id ASC" in sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_doc_by_file_basename_uses_exact_match_for_like_metacharacters():
|
||||
storage = _make_storage()
|
||||
storage.db.query.return_value = []
|
||||
|
||||
await storage.get_doc_by_file_basename("100%_off.pdf")
|
||||
|
||||
sql = storage.db.query.call_args.args[0]
|
||||
params = storage.db.query.call_args.args[1]
|
||||
assert "LIKE" not in sql
|
||||
assert params == ["test_ws", "100%_off.pdf"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_doc_by_file_basename_no_match_returns_none():
|
||||
storage = _make_storage()
|
||||
storage.db.query.return_value = []
|
||||
assert await storage.get_doc_by_file_basename("missing.pdf") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_doc_by_content_hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_doc_by_content_hash_empty_returns_none():
|
||||
storage = _make_storage()
|
||||
assert await storage.get_doc_by_content_hash("") is None
|
||||
storage.db.query.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_doc_by_content_hash_match():
|
||||
storage = _make_storage()
|
||||
storage.db.query.return_value = [_row(content_hash="hash-abc")]
|
||||
|
||||
result = await storage.get_doc_by_content_hash("hash-abc")
|
||||
|
||||
assert result is not None
|
||||
doc_id, doc = result
|
||||
assert doc_id == "doc-1"
|
||||
assert doc["content_hash"] == "hash-abc"
|
||||
|
||||
call = storage.db.query.call_args
|
||||
sql = call.args[0]
|
||||
params = call.args[1]
|
||||
assert "content_hash=$2" in sql
|
||||
# Stable ordering for repeatability across re-runs / replicas
|
||||
assert "ORDER BY created_at ASC, id ASC" in sql
|
||||
assert "LIMIT 1" in sql
|
||||
assert params == ["test_ws", "hash-abc"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_doc_by_content_hash_no_match_returns_none():
|
||||
storage = _make_storage()
|
||||
storage.db.query.return_value = []
|
||||
assert await storage.get_doc_by_content_hash("nope") is None
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Unit tests for PGGraphStorage.get_knowledge_graph("*") — the full-graph view.
|
||||
|
||||
These cover the undirected fix for the degree-based node selection used when the
|
||||
graph is truncated to ``max_nodes``: the previous implementation counted only
|
||||
outgoing edges (``OPTIONAL MATCH (n)-[r]->()``), so a node that is mostly an edge
|
||||
*target* was under-ranked and could be dropped on truncation. The fix selects
|
||||
top-degree nodes via native SQL that counts both ``start_id`` and ``end_id``
|
||||
(undirected degree), LEFT JOIN-ing the base vertex table so isolated nodes are
|
||||
preserved when the graph is not truncated.
|
||||
|
||||
The subgraph edge read intentionally stays directed: ``a`` iterates over every
|
||||
selected node, so ``(a)-[r]->(b)`` already captures every edge whose endpoints
|
||||
are both selected. These tests guard against an accidental regression there.
|
||||
|
||||
All tests mock ``PGGraphStorage._query`` and inspect the SQL it receives.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from lightrag.kg.postgres_impl import PGGraphStorage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_graph_storage() -> PGGraphStorage:
|
||||
"""Construct a PGGraphStorage instance with a mocked _query method."""
|
||||
storage = PGGraphStorage.__new__(PGGraphStorage)
|
||||
storage.workspace = "test_ws"
|
||||
storage.namespace = "test_graph"
|
||||
storage.graph_name = "test_graph"
|
||||
storage.global_config = {"max_graph_nodes": 1000}
|
||||
storage.db = MagicMock()
|
||||
return storage
|
||||
|
||||
|
||||
class _QueryCapture:
|
||||
"""Dispatch the three _query calls of the '*' branch by SQL content."""
|
||||
|
||||
def __init__(self, *, total_nodes, degree_rows, subgraph_rows):
|
||||
self._total_nodes = total_nodes
|
||||
self._degree_rows = degree_rows
|
||||
self._subgraph_rows = subgraph_rows
|
||||
self.count_sql = None
|
||||
self.degree_sql = None
|
||||
self.degree_params = None
|
||||
self.subgraph_sql = None
|
||||
|
||||
def as_side_effect(self):
|
||||
"""Return an ``async def`` so AsyncMock awaits it (a callable instance is not)."""
|
||||
|
||||
async def fake_query(query, **kwargs):
|
||||
if "count(distinct n)" in query:
|
||||
self.count_sql = query
|
||||
return [{"total_nodes": self._total_nodes}]
|
||||
if "node_degrees" in query:
|
||||
self.degree_sql = query
|
||||
self.degree_params = kwargs.get("params")
|
||||
return self._degree_rows
|
||||
# subgraph read
|
||||
self.subgraph_sql = query
|
||||
return self._subgraph_rows
|
||||
|
||||
return fake_query
|
||||
|
||||
|
||||
def _node(node_id, entity_id):
|
||||
return {"id": node_id, "properties": {"entity_id": entity_id}}
|
||||
|
||||
|
||||
def _edge(edge_id, start_id, end_id, weight="1"):
|
||||
return {
|
||||
"id": edge_id,
|
||||
"label": "DIRECTED",
|
||||
"start_id": start_id,
|
||||
"end_id": end_id,
|
||||
"properties": {"weight": weight},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# degree node selection SQL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_degree_selection_sql_is_undirected_and_preserves_isolated():
|
||||
"""The degree query must count start_id + end_id and keep degree-0 nodes."""
|
||||
capture = _QueryCapture(
|
||||
total_nodes=2,
|
||||
degree_rows=[{"node_id": 1, "degree": 3}, {"node_id": 2, "degree": 0}],
|
||||
subgraph_rows=[{"a": _node(1, "Alice"), "r": None, "b": None}],
|
||||
)
|
||||
storage = make_graph_storage()
|
||||
|
||||
with patch.object(storage, "_query", side_effect=capture.as_side_effect()):
|
||||
await storage.get_knowledge_graph("*", max_nodes=50)
|
||||
|
||||
sql = capture.degree_sql
|
||||
assert sql is not None, "degree selection query was never issued"
|
||||
# Undirected: both edge endpoints are counted.
|
||||
assert "start_id" in sql
|
||||
assert "end_id" in sql
|
||||
assert "UNION ALL" in sql
|
||||
# Isolated nodes preserved.
|
||||
assert "LEFT JOIN" in sql
|
||||
assert "COALESCE" in sql
|
||||
# Stable ordering with id tie-break.
|
||||
assert "ORDER BY degree DESC" in sql
|
||||
assert "v.id ASC" in sql
|
||||
# The old outgoing-only Cypher must be gone.
|
||||
assert "-[r]->()" not in sql
|
||||
assert "OPTIONAL MATCH (n)-[r]->()" not in sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_degree_selection_limit_is_parameterized():
|
||||
"""max_nodes must be passed via params, not interpolated into the SQL."""
|
||||
capture = _QueryCapture(
|
||||
total_nodes=2,
|
||||
degree_rows=[{"node_id": 1, "degree": 3}],
|
||||
subgraph_rows=[{"a": _node(1, "Alice"), "r": None, "b": None}],
|
||||
)
|
||||
storage = make_graph_storage()
|
||||
|
||||
with patch.object(storage, "_query", side_effect=capture.as_side_effect()):
|
||||
await storage.get_knowledge_graph("*", max_nodes=37)
|
||||
|
||||
assert "LIMIT $1" in capture.degree_sql
|
||||
assert "37" not in capture.degree_sql
|
||||
assert capture.degree_params == {"limit": 37}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_isolated_node_survives_end_to_end():
|
||||
"""A degree-0 node selected by the degree query reaches the final KnowledgeGraph."""
|
||||
capture = _QueryCapture(
|
||||
total_nodes=2,
|
||||
degree_rows=[{"node_id": 1, "degree": 2}, {"node_id": 2, "degree": 0}],
|
||||
subgraph_rows=[
|
||||
{"a": _node(1, "Alice"), "r": None, "b": None},
|
||||
# MATCH (a) still returns the isolated node with null r/b.
|
||||
{"a": _node(2, "Bob"), "r": None, "b": None},
|
||||
],
|
||||
)
|
||||
storage = make_graph_storage()
|
||||
|
||||
with patch.object(storage, "_query", side_effect=capture.as_side_effect()):
|
||||
kg = await storage.get_knowledge_graph("*", max_nodes=50)
|
||||
|
||||
# The isolated node id is formatted into the subgraph id list...
|
||||
assert "2" in capture.subgraph_sql
|
||||
# ...and present in the returned graph.
|
||||
assert {node.id for node in kg.nodes} == {"1", "2"}
|
||||
assert kg.is_truncated is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# subgraph edge read — regression guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subgraph_read_stays_directed_and_dedupes_edges():
|
||||
"""Subgraph read keeps the directed (a)-[r]->(b) match and dedupes by edge id."""
|
||||
duplicate_edge = _edge(10, 1, 2)
|
||||
capture = _QueryCapture(
|
||||
total_nodes=2,
|
||||
degree_rows=[{"node_id": 1, "degree": 1}, {"node_id": 2, "degree": 1}],
|
||||
subgraph_rows=[
|
||||
{"a": _node(1, "Alice"), "r": duplicate_edge, "b": _node(2, "Bob")},
|
||||
# Same AGE edge id appearing twice must collapse to one edge.
|
||||
{"a": _node(1, "Alice"), "r": duplicate_edge, "b": _node(2, "Bob")},
|
||||
],
|
||||
)
|
||||
storage = make_graph_storage()
|
||||
|
||||
with patch.object(storage, "_query", side_effect=capture.as_side_effect()):
|
||||
kg = await storage.get_knowledge_graph("*", max_nodes=50)
|
||||
|
||||
assert "(a)-[r]->(b)" in capture.subgraph_sql
|
||||
assert len(kg.edges) == 1
|
||||
edge = kg.edges[0]
|
||||
assert edge.source == "1"
|
||||
assert edge.target == "2"
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Unit tests for PGGraphStorage chunk-level transaction batch paths.
|
||||
|
||||
Covers the PR-2 chunk-level fallback: ``upsert_nodes_batch`` /
|
||||
``upsert_edges_batch`` group per-row Cypher into payload/record-bounded chunks
|
||||
run in a single transaction; ``remove_nodes`` runs all chunks in ONE
|
||||
transaction (all-or-nothing); ``remove_edges`` runs one transaction per chunk.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from lightrag.kg.postgres_impl import PGGraphStorage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capture harness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Capture:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = [] # every connection.execute(sql, *args)
|
||||
self.tx_count = 0 # connection.transaction() opens
|
||||
self.run_count = 0 # _run_with_retry invocations (= chunks issued)
|
||||
|
||||
|
||||
class _FakeTransaction:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeConnection:
|
||||
def __init__(self, capture: _Capture):
|
||||
self._capture = capture
|
||||
|
||||
def transaction(self):
|
||||
self._capture.tx_count += 1
|
||||
return _FakeTransaction()
|
||||
|
||||
async def execute(self, sql, *args):
|
||||
self._capture.calls.append({"sql": sql, "args": args})
|
||||
return ""
|
||||
|
||||
|
||||
def make_graph_storage(
|
||||
*,
|
||||
max_upsert_records: int | None = None,
|
||||
max_upsert_payload: int | None = None,
|
||||
max_delete_records: int | None = None,
|
||||
) -> tuple[PGGraphStorage, _Capture]:
|
||||
storage = PGGraphStorage.__new__(PGGraphStorage)
|
||||
storage.workspace = "test_ws"
|
||||
storage.namespace = "test_graph"
|
||||
storage.graph_name = "test_graph"
|
||||
storage.__post_init__() # resolves the chunk-level batch limits
|
||||
if max_upsert_records is not None:
|
||||
storage._max_upsert_records_per_batch = max_upsert_records
|
||||
if max_upsert_payload is not None:
|
||||
storage._max_upsert_payload_bytes = max_upsert_payload
|
||||
if max_delete_records is not None:
|
||||
storage._max_delete_records_per_batch = max_delete_records
|
||||
|
||||
capture = _Capture()
|
||||
conn = _FakeConnection(capture)
|
||||
|
||||
async def fake_run_with_retry(operation, **_kwargs):
|
||||
capture.run_count += 1
|
||||
return await operation(conn)
|
||||
|
||||
storage.db = AsyncMock()
|
||||
storage.db._run_with_retry = AsyncMock(side_effect=fake_run_with_retry)
|
||||
return storage, capture
|
||||
|
||||
|
||||
def _sql_count(capture: _Capture, needle: str) -> int:
|
||||
return sum(1 for c in capture.calls if needle in c["sql"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# upsert_nodes_batch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_nodes_batch_splits_into_chunk_transactions():
|
||||
storage, cap = make_graph_storage(max_upsert_records=2)
|
||||
nodes = [(f"n{i}", {"entity_id": f"n{i}", "v": str(i)}) for i in range(5)]
|
||||
|
||||
await storage.upsert_nodes_batch(nodes)
|
||||
|
||||
# 5 nodes / cap 2 => 3 chunks, each its own _run_with_retry + transaction.
|
||||
assert cap.run_count == 3
|
||||
assert cap.tx_count == 3
|
||||
# One MERGE per node.
|
||||
assert _sql_count(cap, "MERGE (n:base") == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_nodes_batch_dedupes_last_write_wins():
|
||||
storage, cap = make_graph_storage()
|
||||
await storage.upsert_nodes_batch(
|
||||
[
|
||||
("A", {"entity_id": "A", "weight": "first"}),
|
||||
("A", {"entity_id": "A", "weight": "second"}),
|
||||
]
|
||||
)
|
||||
|
||||
merge_calls = [c for c in cap.calls if "MERGE (n:base" in c["sql"]]
|
||||
assert len(merge_calls) == 1
|
||||
assert '"second"' in merge_calls[0]["sql"]
|
||||
assert '"first"' not in merge_calls[0]["sql"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_nodes_batch_splits_by_payload_bytes():
|
||||
# Disable the record cap, force a small byte budget so each big node lands
|
||||
# in its own chunk.
|
||||
storage, cap = make_graph_storage(max_upsert_records=0, max_upsert_payload=200)
|
||||
nodes = [(f"n{i}", {"entity_id": f"n{i}", "blob": "X" * 150}) for i in range(3)]
|
||||
|
||||
await storage.upsert_nodes_batch(nodes)
|
||||
|
||||
assert cap.run_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_nodes_batch_missing_entity_id_raises_before_tx():
|
||||
storage, cap = make_graph_storage()
|
||||
with pytest.raises(ValueError, match="entity_id"):
|
||||
await storage.upsert_nodes_batch([("A", {"foo": "bar"})])
|
||||
# The builder runs before the transaction opens, so nothing was issued.
|
||||
assert cap.run_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_nodes_batch_empty_noop():
|
||||
storage, cap = make_graph_storage()
|
||||
await storage.upsert_nodes_batch([])
|
||||
assert cap.run_count == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# upsert_edges_batch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edges_batch_splits_into_chunk_transactions():
|
||||
storage, cap = make_graph_storage(max_upsert_records=2)
|
||||
edges = [(f"s{i}", f"t{i}", {"w": str(i)}) for i in range(5)]
|
||||
|
||||
await storage.upsert_edges_batch(edges)
|
||||
|
||||
# 5 edges / cap 2 => 3 chunks; each chunk = one transaction.
|
||||
assert cap.run_count == 3
|
||||
assert cap.tx_count == 3
|
||||
# One CREATE cypher per edge, and exactly one graph-wide lock per chunk.
|
||||
assert _sql_count(cap, "CREATE (source)-[r:DIRECTED") == 5
|
||||
assert _sql_count(cap, "pg_advisory_xact_lock") == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edges_batch_takes_one_graph_lock_per_chunk():
|
||||
"""The batch chunk takes a single graph-wide advisory lock (keyed on
|
||||
graph_name only), not one lock per edge -- bounded regardless of edge count."""
|
||||
storage, cap = make_graph_storage()
|
||||
await storage.upsert_edges_batch(
|
||||
[(f"s{i}", f"t{i}", {"w": str(i)}) for i in range(4)]
|
||||
)
|
||||
|
||||
lock_calls = [c for c in cap.calls if "pg_advisory_xact_lock" in c["sql"]]
|
||||
assert len(lock_calls) == 1 # one chunk, one lock (not 4)
|
||||
assert lock_calls[0]["args"] == ("test_graph",) # graph-keyed, no endpoints
|
||||
assert _sql_count(cap, "CREATE (source)-[r:DIRECTED") == 4
|
||||
# The lock is acquired before any edge cypher in the chunk.
|
||||
assert "pg_advisory_xact_lock" in cap.calls[0]["sql"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edges_batch_empty_noop():
|
||||
storage, cap = make_graph_storage()
|
||||
await storage.upsert_edges_batch([])
|
||||
assert cap.run_count == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remove_nodes — all chunks in ONE transaction (all-or-nothing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_nodes_chunks_share_one_transaction():
|
||||
storage, cap = make_graph_storage(max_delete_records=2)
|
||||
await storage.remove_nodes([f"n{i}" for i in range(5)])
|
||||
|
||||
# One transaction wraps all chunks (preserves single-statement atomicity).
|
||||
assert cap.run_count == 1
|
||||
assert cap.tx_count == 1
|
||||
# 5 ids / cap 2 => 3 bounded IN [...] DETACH DELETE statements.
|
||||
assert _sql_count(cap, "DETACH DELETE n") == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_nodes_empty_noop():
|
||||
storage, cap = make_graph_storage()
|
||||
await storage.remove_nodes([])
|
||||
assert cap.run_count == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remove_edges — one transaction per chunk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_edges_one_transaction_per_chunk():
|
||||
storage, cap = make_graph_storage(max_delete_records=2)
|
||||
edges = [(f"s{i}", f"t{i}") for i in range(5)]
|
||||
|
||||
await storage.remove_edges(edges)
|
||||
|
||||
# 5 edges / cap 2 => 3 chunks, each its own transaction.
|
||||
assert cap.run_count == 3
|
||||
assert cap.tx_count == 3
|
||||
# One DELETE r statement per edge.
|
||||
assert _sql_count(cap, "DELETE r") == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_edges_empty_noop():
|
||||
storage, cap = make_graph_storage()
|
||||
await storage.remove_edges([])
|
||||
assert cap.run_count == 0
|
||||
@@ -0,0 +1,374 @@
|
||||
import pytest
|
||||
import numpy as np
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from lightrag.utils import EmbeddingFunc
|
||||
from lightrag.kg.postgres_impl import (
|
||||
PGVectorStorage,
|
||||
PostgreSQLDB,
|
||||
_safe_index_name,
|
||||
)
|
||||
from lightrag.exceptions import DataMigrationError
|
||||
from lightrag.namespace import NameSpace
|
||||
|
||||
|
||||
# Mock PostgreSQLDB
|
||||
@pytest.fixture
|
||||
def mock_pg_db():
|
||||
"""Mock PostgreSQL database connection"""
|
||||
db = AsyncMock()
|
||||
db.workspace = "test_workspace"
|
||||
db.vector_index_type = None
|
||||
|
||||
# Mock query responses: list for search queries (multirows=True), dict for DDL checks
|
||||
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
||||
if multirows:
|
||||
return []
|
||||
return {"exists": False, "count": 0}
|
||||
|
||||
# Mock for execute that mimics PostgreSQLDB.execute() behavior
|
||||
async def mock_execute(sql, data=None, **kwargs):
|
||||
return None
|
||||
|
||||
db.query = AsyncMock(side_effect=mock_query)
|
||||
db.execute = AsyncMock(side_effect=mock_execute)
|
||||
|
||||
return db
|
||||
|
||||
|
||||
# Mock get_data_init_lock to avoid async lock issues in tests
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_data_init_lock():
|
||||
with patch("lightrag.kg.postgres_impl.get_data_init_lock") as mock_lock:
|
||||
mock_lock_ctx = AsyncMock()
|
||||
mock_lock.return_value = mock_lock_ctx
|
||||
yield mock_lock
|
||||
|
||||
|
||||
# Mock ClientManager
|
||||
@pytest.fixture
|
||||
def mock_client_manager(mock_pg_db):
|
||||
with patch("lightrag.kg.postgres_impl.ClientManager") as mock_manager:
|
||||
mock_manager.get_client = AsyncMock(return_value=mock_pg_db)
|
||||
mock_manager.release_client = AsyncMock()
|
||||
yield mock_manager
|
||||
|
||||
|
||||
# Mock Embedding function
|
||||
@pytest.fixture
|
||||
def mock_embedding_func():
|
||||
async def embed_func(texts, **kwargs):
|
||||
return np.array([[0.1] * 768 for _ in texts])
|
||||
|
||||
# Note: EmbeddingFunc in this version of lightrag supports model_name
|
||||
func = EmbeddingFunc(embedding_dim=768, func=embed_func, model_name="test_model")
|
||||
return func
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgres_halfvec_table_creation(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func
|
||||
):
|
||||
"""Test if table is created with HALFVEC type when HNSW_HALFVEC is selected"""
|
||||
# Set index type to HNSW_HALFVEC
|
||||
mock_pg_db.vector_index_type = "HNSW_HALFVEC"
|
||||
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
|
||||
storage = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=mock_embedding_func,
|
||||
workspace="test_ws",
|
||||
)
|
||||
|
||||
# Mock table doesn't exist
|
||||
mock_pg_db.check_table_exists = AsyncMock(return_value=False)
|
||||
|
||||
# Initialize storage (should trigger table creation)
|
||||
await storage.initialize()
|
||||
|
||||
# Verify table creation SQL contains HALFVEC(768)
|
||||
create_table_calls = [
|
||||
call
|
||||
for call in mock_pg_db.execute.call_args_list
|
||||
if "CREATE TABLE" in call[0][0]
|
||||
]
|
||||
|
||||
assert len(create_table_calls) > 0
|
||||
create_sql = create_table_calls[0][0][0]
|
||||
assert "HALFVEC(768)" in create_sql
|
||||
assert "VECTOR(768)" not in create_sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgres_vector_table_creation_default(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func
|
||||
):
|
||||
"""Test if table is created with default VECTOR type when other index type is selected"""
|
||||
# Set index type to HNSW (default)
|
||||
mock_pg_db.vector_index_type = "HNSW"
|
||||
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
|
||||
storage = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=mock_embedding_func,
|
||||
workspace="test_ws",
|
||||
)
|
||||
|
||||
# Mock table doesn't exist
|
||||
mock_pg_db.check_table_exists = AsyncMock(return_value=False)
|
||||
|
||||
# Initialize storage (should trigger table creation)
|
||||
await storage.initialize()
|
||||
|
||||
# Verify table creation SQL contains VECTOR(768)
|
||||
create_table_calls = [
|
||||
call
|
||||
for call in mock_pg_db.execute.call_args_list
|
||||
if "CREATE TABLE" in call[0][0]
|
||||
]
|
||||
|
||||
assert len(create_table_calls) > 0
|
||||
create_sql = create_table_calls[0][0][0]
|
||||
assert "VECTOR(768)" in create_sql
|
||||
assert "HALFVEC(768)" not in create_sql
|
||||
|
||||
|
||||
# Namespaces that use vector search SQL templates (query path)
|
||||
QUERY_NAMESPACES = [
|
||||
NameSpace.VECTOR_STORE_CHUNKS,
|
||||
NameSpace.VECTOR_STORE_ENTITIES,
|
||||
NameSpace.VECTOR_STORE_RELATIONSHIPS,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("namespace", QUERY_NAMESPACES)
|
||||
async def test_query_uses_halfvec_cast_when_hnsw_halfvec(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func, namespace
|
||||
):
|
||||
"""When HNSW_HALFVEC is set, generated search SQL uses ::halfvec (not ::vector)."""
|
||||
mock_pg_db.vector_index_type = "HNSW_HALFVEC"
|
||||
mock_pg_db.check_table_exists = AsyncMock(return_value=True)
|
||||
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
storage = PGVectorStorage(
|
||||
namespace=namespace,
|
||||
global_config=config,
|
||||
embedding_func=mock_embedding_func,
|
||||
workspace="test_ws",
|
||||
)
|
||||
await storage.initialize()
|
||||
|
||||
query_embedding = [0.1] * 768
|
||||
await storage.query("test query", top_k=5, query_embedding=query_embedding)
|
||||
|
||||
assert mock_pg_db.query.called
|
||||
call_args = mock_pg_db.query.call_args
|
||||
sql = call_args[0][0]
|
||||
assert "::halfvec" in sql
|
||||
assert "::vector" not in sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("namespace", QUERY_NAMESPACES)
|
||||
async def test_query_uses_vector_cast_when_hnsw_default(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func, namespace
|
||||
):
|
||||
"""When HNSW (default) is set, generated search SQL uses ::vector (not ::halfvec)."""
|
||||
mock_pg_db.vector_index_type = "HNSW"
|
||||
mock_pg_db.check_table_exists = AsyncMock(return_value=True)
|
||||
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
storage = PGVectorStorage(
|
||||
namespace=namespace,
|
||||
global_config=config,
|
||||
embedding_func=mock_embedding_func,
|
||||
workspace="test_ws",
|
||||
)
|
||||
await storage.initialize()
|
||||
|
||||
query_embedding = [0.1] * 768
|
||||
await storage.query("test query", top_k=5, query_embedding=query_embedding)
|
||||
|
||||
assert mock_pg_db.query.called
|
||||
call_args = mock_pg_db.query.call_args
|
||||
sql = call_args[0][0]
|
||||
assert "::vector" in sql
|
||||
assert "::halfvec" not in sql
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Index switching: old conflicting indexes are dropped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vector_index_drops_old_indexes_when_switching(mock_pg_db):
|
||||
"""Switching from HNSW to HNSW_HALFVEC drops the old hnsw_cosine index."""
|
||||
mock_pg_db.vector_index_type = "HNSW_HALFVEC"
|
||||
mock_pg_db.hnsw_m = 16
|
||||
mock_pg_db.hnsw_ef = 64
|
||||
mock_pg_db.ivfflat_lists = 100
|
||||
mock_pg_db.vchordrq_build_options = ""
|
||||
|
||||
table_name = "lightrag_vdb_chunks_test"
|
||||
|
||||
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
||||
if "pg_indexes" in sql:
|
||||
return None
|
||||
return None
|
||||
|
||||
mock_pg_db.query = AsyncMock(side_effect=mock_query)
|
||||
mock_pg_db.execute = AsyncMock()
|
||||
|
||||
# Call the real method with mock_pg_db as self
|
||||
await PostgreSQLDB._create_vector_index(mock_pg_db, table_name, 3072)
|
||||
|
||||
execute_calls = [call[0][0] for call in mock_pg_db.execute.call_args_list]
|
||||
|
||||
old_hnsw_name = _safe_index_name(table_name, "hnsw_cosine")
|
||||
old_ivfflat_name = _safe_index_name(table_name, "ivfflat_cosine")
|
||||
old_vchordrq_name = _safe_index_name(table_name, "vchordrq_cosine")
|
||||
|
||||
drop_calls = [c for c in execute_calls if "DROP INDEX IF EXISTS" in c]
|
||||
dropped_names = {c.split("DROP INDEX IF EXISTS ")[1].strip() for c in drop_calls}
|
||||
assert old_hnsw_name in dropped_names
|
||||
assert old_ivfflat_name in dropped_names
|
||||
assert old_vchordrq_name in dropped_names
|
||||
|
||||
new_index_name = _safe_index_name(table_name, "hnsw_halfvec_cosine")
|
||||
assert new_index_name not in dropped_names
|
||||
|
||||
alter_calls = [c for c in execute_calls if "ALTER TABLE" in c]
|
||||
assert any("HALFVEC(3072)" in c for c in alter_calls)
|
||||
|
||||
create_calls = [c for c in execute_calls if "CREATE INDEX" in c]
|
||||
assert any("halfvec_cosine_ops" in c for c in create_calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vector_index_no_drop_when_index_exists(mock_pg_db):
|
||||
"""If the target index already exists, no DROP or CREATE is issued."""
|
||||
mock_pg_db.vector_index_type = "HNSW_HALFVEC"
|
||||
mock_pg_db.hnsw_m = 16
|
||||
mock_pg_db.hnsw_ef = 64
|
||||
mock_pg_db.ivfflat_lists = 100
|
||||
mock_pg_db.vchordrq_build_options = ""
|
||||
|
||||
table_name = "lightrag_vdb_chunks_test"
|
||||
|
||||
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
||||
if "pg_indexes" in sql:
|
||||
return {"?column?": 1}
|
||||
return None
|
||||
|
||||
mock_pg_db.query = AsyncMock(side_effect=mock_query)
|
||||
mock_pg_db.execute = AsyncMock()
|
||||
|
||||
await PostgreSQLDB._create_vector_index(mock_pg_db, table_name, 3072)
|
||||
|
||||
execute_calls = [call[0][0] for call in mock_pg_db.execute.call_args_list]
|
||||
assert not any("DROP INDEX" in c for c in execute_calls)
|
||||
assert not any("CREATE INDEX" in c for c in execute_calls)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HalfVector dimension detection in setup_table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MockHalfVector:
|
||||
"""Mimics pgvector.halfvec.HalfVector for testing dimension detection."""
|
||||
|
||||
def __init__(self, dim: int):
|
||||
self._dim = dim
|
||||
|
||||
def dimensions(self) -> int:
|
||||
return self._dim
|
||||
|
||||
def to_list(self):
|
||||
return [0.0] * self._dim
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_table_detects_halfvector_dimension_mismatch(mock_pg_db):
|
||||
"""DataMigrationError is raised when a HalfVector column has a different dimension."""
|
||||
table_name = "lightrag_vdb_chunks_new"
|
||||
legacy_table = "lightrag_vdb_chunks"
|
||||
|
||||
mock_pg_db.check_table_exists = AsyncMock(
|
||||
side_effect=lambda t: t.lower() == legacy_table.lower()
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if "COUNT(*)" in sql:
|
||||
return {"count": 5}
|
||||
if "content_vector" in sql:
|
||||
return {"content_vector": _MockHalfVector(1024)}
|
||||
return None
|
||||
|
||||
mock_pg_db.query = AsyncMock(side_effect=mock_query)
|
||||
mock_pg_db.execute = AsyncMock()
|
||||
|
||||
with pytest.raises(DataMigrationError, match="Dimension mismatch"):
|
||||
await PGVectorStorage.setup_table(
|
||||
db=mock_pg_db,
|
||||
table_name=table_name,
|
||||
workspace="test_ws",
|
||||
embedding_dim=768,
|
||||
legacy_table_name=legacy_table,
|
||||
base_table=legacy_table,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_table_accepts_matching_halfvector_dimension(mock_pg_db):
|
||||
"""No error when HalfVector dimension matches the expected embedding_dim."""
|
||||
table_name = "lightrag_vdb_chunks_new"
|
||||
legacy_table = "lightrag_vdb_chunks"
|
||||
|
||||
mock_pg_db.check_table_exists = AsyncMock(
|
||||
side_effect=lambda t: t.lower() == legacy_table.lower()
|
||||
)
|
||||
mock_pg_db.vector_index_type = "HNSW_HALFVEC"
|
||||
|
||||
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
||||
if "COUNT(*)" in sql:
|
||||
return {"count": 5}
|
||||
if "content_vector" in sql:
|
||||
return {"content_vector": _MockHalfVector(768)}
|
||||
if multirows:
|
||||
return []
|
||||
return None
|
||||
|
||||
mock_pg_db.query = AsyncMock(side_effect=mock_query)
|
||||
mock_pg_db.execute = AsyncMock()
|
||||
|
||||
with patch.object(PGVectorStorage, "_pg_create_table", new_callable=AsyncMock):
|
||||
await PGVectorStorage.setup_table(
|
||||
db=mock_pg_db,
|
||||
table_name=table_name,
|
||||
workspace="test_ws",
|
||||
embedding_dim=768,
|
||||
legacy_table_name=legacy_table,
|
||||
base_table=legacy_table,
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Unit tests for PostgreSQL safe index name generation.
|
||||
|
||||
This module tests the _safe_index_name helper function which prevents
|
||||
PostgreSQL's silent 63-byte identifier truncation from causing index
|
||||
lookup failures.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
# Mark all tests as offline (no external dependencies)
|
||||
pytestmark = pytest.mark.offline
|
||||
|
||||
|
||||
class TestSafeIndexName:
|
||||
"""Test suite for _safe_index_name function."""
|
||||
|
||||
def test_short_name_unchanged(self):
|
||||
"""Short index names should remain unchanged."""
|
||||
from lightrag.kg.postgres_impl import _safe_index_name
|
||||
|
||||
# Short table name - should return unchanged
|
||||
result = _safe_index_name("lightrag_vdb_entity", "hnsw_cosine")
|
||||
assert result == "idx_lightrag_vdb_entity_hnsw_cosine"
|
||||
assert len(result.encode("utf-8")) <= 63
|
||||
|
||||
def test_long_name_gets_hashed(self):
|
||||
"""Long table names exceeding 63 bytes should get hashed."""
|
||||
from lightrag.kg.postgres_impl import _safe_index_name
|
||||
|
||||
# Long table name that would exceed 63 bytes
|
||||
long_table_name = "LIGHTRAG_VDB_ENTITY_text_embedding_3_large_3072d"
|
||||
result = _safe_index_name(long_table_name, "hnsw_cosine")
|
||||
|
||||
# Should be within 63 bytes
|
||||
assert len(result.encode("utf-8")) <= 63
|
||||
|
||||
# Should start with idx_ prefix
|
||||
assert result.startswith("idx_")
|
||||
|
||||
# Should contain the suffix
|
||||
assert result.endswith("_hnsw_cosine")
|
||||
|
||||
# Should NOT be the naive concatenation (which would be truncated)
|
||||
naive_name = f"idx_{long_table_name.lower()}_hnsw_cosine"
|
||||
assert result != naive_name
|
||||
|
||||
def test_deterministic_output(self):
|
||||
"""Same input should always produce same output (deterministic)."""
|
||||
from lightrag.kg.postgres_impl import _safe_index_name
|
||||
|
||||
table_name = "LIGHTRAG_VDB_CHUNKS_text_embedding_3_large_3072d"
|
||||
suffix = "hnsw_cosine"
|
||||
|
||||
result1 = _safe_index_name(table_name, suffix)
|
||||
result2 = _safe_index_name(table_name, suffix)
|
||||
|
||||
assert result1 == result2
|
||||
|
||||
def test_different_suffixes_different_results(self):
|
||||
"""Different suffixes should produce different index names."""
|
||||
from lightrag.kg.postgres_impl import _safe_index_name
|
||||
|
||||
table_name = "LIGHTRAG_VDB_ENTITY_text_embedding_3_large_3072d"
|
||||
|
||||
result1 = _safe_index_name(table_name, "hnsw_cosine")
|
||||
result2 = _safe_index_name(table_name, "ivfflat_cosine")
|
||||
|
||||
assert result1 != result2
|
||||
|
||||
def test_case_insensitive(self):
|
||||
"""Table names should be normalized to lowercase."""
|
||||
from lightrag.kg.postgres_impl import _safe_index_name
|
||||
|
||||
result_upper = _safe_index_name("LIGHTRAG_VDB_ENTITY", "hnsw_cosine")
|
||||
result_lower = _safe_index_name("lightrag_vdb_entity", "hnsw_cosine")
|
||||
|
||||
assert result_upper == result_lower
|
||||
|
||||
def test_boundary_case_exactly_63_bytes(self):
|
||||
"""Test boundary case where name is exactly at 63-byte limit."""
|
||||
from lightrag.kg.postgres_impl import _safe_index_name
|
||||
|
||||
# Create a table name that results in exactly 63 bytes
|
||||
# idx_ (4) + table_name + _ (1) + suffix = 63
|
||||
# So table_name + suffix = 58
|
||||
|
||||
# Test a name that's just under the limit (should remain unchanged)
|
||||
short_suffix = "id"
|
||||
# idx_ (4) + 56 chars + _ (1) + id (2) = 63
|
||||
table_56 = "a" * 56
|
||||
result = _safe_index_name(table_56, short_suffix)
|
||||
expected = f"idx_{table_56}_{short_suffix}"
|
||||
assert result == expected
|
||||
assert len(result.encode("utf-8")) == 63
|
||||
|
||||
def test_unicode_handling(self):
|
||||
"""Unicode characters should be properly handled (bytes, not chars)."""
|
||||
from lightrag.kg.postgres_impl import _safe_index_name
|
||||
|
||||
# Unicode characters can take more bytes than visible chars
|
||||
# Chinese characters are 3 bytes each in UTF-8
|
||||
table_name = "lightrag_测试_table" # Contains Chinese chars
|
||||
result = _safe_index_name(table_name, "hnsw_cosine")
|
||||
|
||||
# Should always be within 63 bytes
|
||||
assert len(result.encode("utf-8")) <= 63
|
||||
|
||||
def test_real_world_model_names(self):
|
||||
"""Test with real-world embedding model names that cause issues."""
|
||||
from lightrag.kg.postgres_impl import _safe_index_name
|
||||
|
||||
# These are actual model names that have caused issues
|
||||
test_cases = [
|
||||
("LIGHTRAG_VDB_CHUNKS_text_embedding_3_large_3072d", "hnsw_cosine"),
|
||||
("LIGHTRAG_VDB_ENTITY_text_embedding_3_large_3072d", "hnsw_cosine"),
|
||||
("LIGHTRAG_VDB_RELATION_text_embedding_3_large_3072d", "hnsw_cosine"),
|
||||
(
|
||||
"LIGHTRAG_VDB_ENTITY_bge_m3_1024d",
|
||||
"hnsw_cosine",
|
||||
), # Shorter model name
|
||||
(
|
||||
"LIGHTRAG_VDB_CHUNKS_nomic_embed_text_v1_768d",
|
||||
"ivfflat_cosine",
|
||||
), # Different index type
|
||||
]
|
||||
|
||||
for table_name, suffix in test_cases:
|
||||
result = _safe_index_name(table_name, suffix)
|
||||
|
||||
# Critical: must be within PostgreSQL's 63-byte limit
|
||||
assert len(result.encode("utf-8")) <= 63, (
|
||||
f"Index name too long: {result} for table {table_name}"
|
||||
)
|
||||
|
||||
# Must have consistent format
|
||||
assert result.startswith("idx_"), f"Missing idx_ prefix: {result}"
|
||||
assert result.endswith(f"_{suffix}"), f"Missing suffix {suffix}: {result}"
|
||||
|
||||
def test_hash_uniqueness_for_similar_tables(self):
|
||||
"""Similar but different table names should produce different hashes."""
|
||||
from lightrag.kg.postgres_impl import _safe_index_name
|
||||
|
||||
# These tables have similar names but should have different hashes
|
||||
tables = [
|
||||
"LIGHTRAG_VDB_CHUNKS_model_a_1024d",
|
||||
"LIGHTRAG_VDB_CHUNKS_model_b_1024d",
|
||||
"LIGHTRAG_VDB_ENTITY_model_a_1024d",
|
||||
]
|
||||
|
||||
results = [_safe_index_name(t, "hnsw_cosine") for t in tables]
|
||||
|
||||
# All results should be unique
|
||||
assert len(set(results)) == len(results), "Hash collision detected!"
|
||||
|
||||
|
||||
class TestIndexNameIntegration:
|
||||
"""Integration-style tests for index name usage patterns."""
|
||||
|
||||
def test_pg_indexes_lookup_compatibility(self):
|
||||
"""
|
||||
Test that the generated index name will work with pg_indexes lookup.
|
||||
|
||||
This is the core problem: PostgreSQL stores the truncated name,
|
||||
but we were looking up the untruncated name. Our fix ensures we
|
||||
always use a name that fits within 63 bytes.
|
||||
"""
|
||||
from lightrag.kg.postgres_impl import _safe_index_name
|
||||
|
||||
table_name = "LIGHTRAG_VDB_CHUNKS_text_embedding_3_large_3072d"
|
||||
suffix = "hnsw_cosine"
|
||||
|
||||
# Generate the index name
|
||||
index_name = _safe_index_name(table_name, suffix)
|
||||
|
||||
# Simulate what PostgreSQL would store (truncate at 63 bytes)
|
||||
stored_name = index_name.encode("utf-8")[:63].decode("utf-8", errors="ignore")
|
||||
|
||||
# The key fix: our generated name should equal the stored name
|
||||
# because it's already within the 63-byte limit
|
||||
assert index_name == stored_name, (
|
||||
"Index name would be truncated by PostgreSQL, causing lookup failures!"
|
||||
)
|
||||
|
||||
def test_backward_compatibility_short_names(self):
|
||||
"""
|
||||
Ensure backward compatibility with existing short index names.
|
||||
|
||||
For tables that have existing indexes with short names (pre-model-suffix era),
|
||||
the function should not change their names.
|
||||
"""
|
||||
from lightrag.kg.postgres_impl import _safe_index_name
|
||||
|
||||
# Legacy table names without model suffix
|
||||
legacy_tables = [
|
||||
"LIGHTRAG_VDB_ENTITY",
|
||||
"LIGHTRAG_VDB_RELATION",
|
||||
"LIGHTRAG_VDB_CHUNKS",
|
||||
]
|
||||
|
||||
for table in legacy_tables:
|
||||
for suffix in ["hnsw_cosine", "ivfflat_cosine", "id"]:
|
||||
result = _safe_index_name(table, suffix)
|
||||
expected = f"idx_{table.lower()}_{suffix}"
|
||||
|
||||
# Short names should remain unchanged for backward compatibility
|
||||
if len(expected.encode("utf-8")) <= 63:
|
||||
assert result == expected, (
|
||||
f"Short name changed unexpectedly: {result} != {expected}"
|
||||
)
|
||||
@@ -0,0 +1,856 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
import numpy as np
|
||||
from lightrag.utils import EmbeddingFunc
|
||||
from lightrag.kg.postgres_impl import (
|
||||
PGVectorStorage,
|
||||
)
|
||||
from lightrag.namespace import NameSpace
|
||||
|
||||
|
||||
# Mock PostgreSQLDB
|
||||
@pytest.fixture
|
||||
def mock_pg_db():
|
||||
"""Mock PostgreSQL database connection"""
|
||||
db = AsyncMock()
|
||||
db.workspace = "test_workspace"
|
||||
|
||||
# Mock query responses with multirows support
|
||||
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
||||
# Default return value
|
||||
if multirows:
|
||||
return [] # Return empty list for multirows
|
||||
return {"exists": False, "count": 0}
|
||||
|
||||
# Mock for execute that mimics PostgreSQLDB.execute() behavior
|
||||
async def mock_execute(sql, data=None, **kwargs):
|
||||
"""
|
||||
Mock that mimics PostgreSQLDB.execute() behavior:
|
||||
- Accepts data as dict[str, Any] | None (second parameter)
|
||||
- Internally converts dict.values() to tuple for AsyncPG
|
||||
"""
|
||||
# Mimic real execute() which accepts dict and converts to tuple
|
||||
if data is not None and not isinstance(data, dict):
|
||||
raise TypeError(
|
||||
f"PostgreSQLDB.execute() expects data as dict, got {type(data).__name__}"
|
||||
)
|
||||
return None
|
||||
|
||||
db.query = AsyncMock(side_effect=mock_query)
|
||||
db.execute = AsyncMock(side_effect=mock_execute)
|
||||
|
||||
return db
|
||||
|
||||
|
||||
# Mock get_data_init_lock to avoid async lock issues in tests
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_data_init_lock():
|
||||
with patch("lightrag.kg.postgres_impl.get_data_init_lock") as mock_lock:
|
||||
mock_lock_ctx = AsyncMock()
|
||||
mock_lock.return_value = mock_lock_ctx
|
||||
yield mock_lock
|
||||
|
||||
|
||||
# Mock ClientManager
|
||||
@pytest.fixture
|
||||
def mock_client_manager(mock_pg_db):
|
||||
with patch("lightrag.kg.postgres_impl.ClientManager") as mock_manager:
|
||||
mock_manager.get_client = AsyncMock(return_value=mock_pg_db)
|
||||
mock_manager.release_client = AsyncMock()
|
||||
yield mock_manager
|
||||
|
||||
|
||||
# Mock Embedding function
|
||||
@pytest.fixture
|
||||
def mock_embedding_func():
|
||||
async def embed_func(texts, **kwargs):
|
||||
return np.array([[0.1] * 768 for _ in texts])
|
||||
|
||||
func = EmbeddingFunc(embedding_dim=768, func=embed_func, model_name="test_model")
|
||||
return func
|
||||
|
||||
|
||||
async def test_postgres_table_naming(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func
|
||||
):
|
||||
"""Test if table name is correctly generated with model suffix"""
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
|
||||
storage = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=mock_embedding_func,
|
||||
workspace="test_ws",
|
||||
)
|
||||
|
||||
# Verify table name contains model suffix
|
||||
expected_suffix = "test_model_768d"
|
||||
assert expected_suffix in storage.table_name
|
||||
assert storage.table_name == f"LIGHTRAG_VDB_CHUNKS_{expected_suffix}"
|
||||
|
||||
# Verify legacy table name
|
||||
assert storage.legacy_table_name == "LIGHTRAG_VDB_CHUNKS"
|
||||
|
||||
|
||||
async def test_postgres_migration_trigger(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func
|
||||
):
|
||||
"""Test if migration logic is triggered correctly"""
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
|
||||
storage = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=mock_embedding_func,
|
||||
workspace="test_ws",
|
||||
)
|
||||
|
||||
# Setup mocks for migration scenario
|
||||
# 1. New table does not exist, legacy table exists
|
||||
async def mock_check_table_exists(table_name):
|
||||
return table_name == storage.legacy_table_name
|
||||
|
||||
mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)
|
||||
|
||||
# 2. Legacy table has 100 records
|
||||
mock_rows = [
|
||||
{"id": f"test_id_{i}", "content": f"content_{i}", "workspace": "test_ws"}
|
||||
for i in range(100)
|
||||
]
|
||||
migration_state = {"new_table_count": 0}
|
||||
|
||||
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
||||
if "COUNT(*)" in sql:
|
||||
sql_upper = sql.upper()
|
||||
legacy_table = storage.legacy_table_name.upper()
|
||||
new_table = storage.table_name.upper()
|
||||
is_new_table = new_table in sql_upper
|
||||
is_legacy_table = legacy_table in sql_upper and not is_new_table
|
||||
|
||||
if is_new_table:
|
||||
return {"count": migration_state["new_table_count"]}
|
||||
if is_legacy_table:
|
||||
return {"count": 100}
|
||||
return {"count": 0}
|
||||
elif multirows and "SELECT *" in sql:
|
||||
# Mock batch fetch for migration using keyset pagination
|
||||
# New pattern: WHERE workspace = $1 AND id > $2 ORDER BY id LIMIT $3
|
||||
# or first batch: WHERE workspace = $1 ORDER BY id LIMIT $2
|
||||
if "WHERE workspace" in sql:
|
||||
if "id >" in sql:
|
||||
# Keyset pagination: params = [workspace, last_id, limit]
|
||||
last_id = params[1] if len(params) > 1 else None
|
||||
# Find rows after last_id
|
||||
start_idx = 0
|
||||
for i, row in enumerate(mock_rows):
|
||||
if row["id"] == last_id:
|
||||
start_idx = i + 1
|
||||
break
|
||||
limit = params[2] if len(params) > 2 else 500
|
||||
else:
|
||||
# First batch (no last_id): params = [workspace, limit]
|
||||
start_idx = 0
|
||||
limit = params[1] if len(params) > 1 else 500
|
||||
else:
|
||||
# No workspace filter with keyset
|
||||
if "id >" in sql:
|
||||
last_id = params[0] if params else None
|
||||
start_idx = 0
|
||||
for i, row in enumerate(mock_rows):
|
||||
if row["id"] == last_id:
|
||||
start_idx = i + 1
|
||||
break
|
||||
limit = params[1] if len(params) > 1 else 500
|
||||
else:
|
||||
start_idx = 0
|
||||
limit = params[0] if params else 500
|
||||
end = min(start_idx + limit, len(mock_rows))
|
||||
return mock_rows[start_idx:end]
|
||||
return {}
|
||||
|
||||
mock_pg_db.query = AsyncMock(side_effect=mock_query)
|
||||
|
||||
# Track migration through _run_with_retry calls
|
||||
migration_executed = []
|
||||
|
||||
async def mock_run_with_retry(operation, **kwargs):
|
||||
# Track that migration batch operation was called
|
||||
migration_executed.append(True)
|
||||
migration_state["new_table_count"] = 100
|
||||
return None
|
||||
|
||||
mock_pg_db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry)
|
||||
|
||||
with patch(
|
||||
"lightrag.kg.postgres_impl.PGVectorStorage._pg_create_table", AsyncMock()
|
||||
):
|
||||
# Initialize storage (should trigger migration)
|
||||
await storage.initialize()
|
||||
|
||||
# Verify migration was executed by checking _run_with_retry was called
|
||||
# (batch migration uses _run_with_retry with executemany)
|
||||
assert len(migration_executed) > 0, "Migration should have been executed"
|
||||
|
||||
|
||||
async def test_postgres_no_migration_needed(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func
|
||||
):
|
||||
"""Test scenario where new table already exists (no migration needed)"""
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
|
||||
storage = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=mock_embedding_func,
|
||||
workspace="test_ws",
|
||||
)
|
||||
|
||||
# Mock: new table already exists
|
||||
async def mock_check_table_exists(table_name):
|
||||
return table_name == storage.table_name
|
||||
|
||||
mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)
|
||||
|
||||
with patch(
|
||||
"lightrag.kg.postgres_impl.PGVectorStorage._pg_create_table", AsyncMock()
|
||||
) as mock_create:
|
||||
await storage.initialize()
|
||||
|
||||
# Verify no table creation was attempted
|
||||
mock_create.assert_not_called()
|
||||
|
||||
|
||||
async def test_scenario_1_new_workspace_creation(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func
|
||||
):
|
||||
"""
|
||||
Scenario 1: New workspace creation
|
||||
|
||||
Expected behavior:
|
||||
- No legacy table exists
|
||||
- Directly create new table with model suffix
|
||||
- No migration needed
|
||||
"""
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
|
||||
embedding_func = EmbeddingFunc(
|
||||
embedding_dim=3072,
|
||||
func=mock_embedding_func.func,
|
||||
model_name="text-embedding-3-large",
|
||||
)
|
||||
|
||||
storage = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=embedding_func,
|
||||
workspace="new_workspace",
|
||||
)
|
||||
|
||||
# Mock: neither table exists
|
||||
async def mock_check_table_exists(table_name):
|
||||
return False
|
||||
|
||||
mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)
|
||||
|
||||
with patch(
|
||||
"lightrag.kg.postgres_impl.PGVectorStorage._pg_create_table", AsyncMock()
|
||||
) as mock_create:
|
||||
await storage.initialize()
|
||||
|
||||
# Verify table name format
|
||||
assert "text_embedding_3_large_3072d" in storage.table_name
|
||||
|
||||
# Verify new table creation was called
|
||||
mock_create.assert_called_once()
|
||||
call_args = mock_create.call_args
|
||||
assert (
|
||||
call_args[0][1] == storage.table_name
|
||||
) # table_name is second positional arg
|
||||
|
||||
|
||||
async def test_scenario_2_legacy_upgrade_migration(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func
|
||||
):
|
||||
"""
|
||||
Scenario 2: Upgrade from legacy version
|
||||
|
||||
Expected behavior:
|
||||
- Legacy table exists (without model suffix)
|
||||
- New table doesn't exist
|
||||
- Automatically migrate data to new table with suffix
|
||||
"""
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
|
||||
embedding_func = EmbeddingFunc(
|
||||
embedding_dim=1536,
|
||||
func=mock_embedding_func.func,
|
||||
model_name="text-embedding-ada-002",
|
||||
)
|
||||
|
||||
storage = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=embedding_func,
|
||||
workspace="legacy_workspace",
|
||||
)
|
||||
|
||||
# Mock: only legacy table exists
|
||||
async def mock_check_table_exists(table_name):
|
||||
return table_name == storage.legacy_table_name
|
||||
|
||||
mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)
|
||||
|
||||
# Mock: legacy table has 50 records
|
||||
mock_rows = [
|
||||
{
|
||||
"id": f"legacy_id_{i}",
|
||||
"content": f"legacy_content_{i}",
|
||||
"workspace": "legacy_workspace",
|
||||
}
|
||||
for i in range(50)
|
||||
]
|
||||
|
||||
# Track which queries have been made for proper response
|
||||
query_history = []
|
||||
migration_state = {"new_table_count": 0}
|
||||
|
||||
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
||||
query_history.append(sql)
|
||||
|
||||
if "COUNT(*)" in sql:
|
||||
# Determine table type:
|
||||
# - Legacy: contains base name but NOT model suffix
|
||||
# - New: contains model suffix (e.g., text_embedding_ada_002_1536d)
|
||||
sql_upper = sql.upper()
|
||||
base_name = storage.legacy_table_name.upper()
|
||||
|
||||
# Check if this is querying the new table (has model suffix)
|
||||
has_model_suffix = storage.table_name.upper() in sql_upper
|
||||
|
||||
is_legacy_table = base_name in sql_upper and not has_model_suffix
|
||||
has_workspace_filter = "WHERE workspace" in sql
|
||||
|
||||
if is_legacy_table and has_workspace_filter:
|
||||
# Count for legacy table with workspace filter (before migration)
|
||||
return {"count": 50}
|
||||
elif is_legacy_table and not has_workspace_filter:
|
||||
# Total count for legacy table
|
||||
return {"count": 50}
|
||||
else:
|
||||
# New table count (before/after migration)
|
||||
return {"count": migration_state["new_table_count"]}
|
||||
elif multirows and "SELECT *" in sql:
|
||||
# Mock batch fetch for migration using keyset pagination
|
||||
# New pattern: WHERE workspace = $1 AND id > $2 ORDER BY id LIMIT $3
|
||||
# or first batch: WHERE workspace = $1 ORDER BY id LIMIT $2
|
||||
if "WHERE workspace" in sql:
|
||||
if "id >" in sql:
|
||||
# Keyset pagination: params = [workspace, last_id, limit]
|
||||
last_id = params[1] if len(params) > 1 else None
|
||||
# Find rows after last_id
|
||||
start_idx = 0
|
||||
for i, row in enumerate(mock_rows):
|
||||
if row["id"] == last_id:
|
||||
start_idx = i + 1
|
||||
break
|
||||
limit = params[2] if len(params) > 2 else 500
|
||||
else:
|
||||
# First batch (no last_id): params = [workspace, limit]
|
||||
start_idx = 0
|
||||
limit = params[1] if len(params) > 1 else 500
|
||||
else:
|
||||
# No workspace filter with keyset
|
||||
if "id >" in sql:
|
||||
last_id = params[0] if params else None
|
||||
start_idx = 0
|
||||
for i, row in enumerate(mock_rows):
|
||||
if row["id"] == last_id:
|
||||
start_idx = i + 1
|
||||
break
|
||||
limit = params[1] if len(params) > 1 else 500
|
||||
else:
|
||||
start_idx = 0
|
||||
limit = params[0] if params else 500
|
||||
end = min(start_idx + limit, len(mock_rows))
|
||||
return mock_rows[start_idx:end]
|
||||
return {}
|
||||
|
||||
mock_pg_db.query = AsyncMock(side_effect=mock_query)
|
||||
|
||||
# Track migration through _run_with_retry calls
|
||||
migration_executed = []
|
||||
|
||||
async def mock_run_with_retry(operation, **kwargs):
|
||||
# Track that migration batch operation was called
|
||||
migration_executed.append(True)
|
||||
migration_state["new_table_count"] = 50
|
||||
return None
|
||||
|
||||
mock_pg_db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry)
|
||||
|
||||
with patch(
|
||||
"lightrag.kg.postgres_impl.PGVectorStorage._pg_create_table", AsyncMock()
|
||||
) as mock_create:
|
||||
await storage.initialize()
|
||||
|
||||
# Verify table name contains ada-002
|
||||
assert "text_embedding_ada_002_1536d" in storage.table_name
|
||||
|
||||
# Verify migration was executed (batch migration uses _run_with_retry)
|
||||
assert len(migration_executed) > 0, "Migration should have been executed"
|
||||
mock_create.assert_called_once()
|
||||
|
||||
|
||||
async def test_scenario_3_multi_model_coexistence(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func
|
||||
):
|
||||
"""
|
||||
Scenario 3: Multiple embedding models coexist
|
||||
|
||||
Expected behavior:
|
||||
- Different embedding models create separate tables
|
||||
- Tables are isolated by model suffix
|
||||
- No interference between different models
|
||||
"""
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
|
||||
# Workspace A: uses bge-small (768d)
|
||||
embedding_func_a = EmbeddingFunc(
|
||||
embedding_dim=768, func=mock_embedding_func.func, model_name="bge-small"
|
||||
)
|
||||
|
||||
storage_a = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=embedding_func_a,
|
||||
workspace="workspace_a",
|
||||
)
|
||||
|
||||
# Workspace B: uses bge-large (1024d)
|
||||
async def embed_func_b(texts, **kwargs):
|
||||
return np.array([[0.1] * 1024 for _ in texts])
|
||||
|
||||
embedding_func_b = EmbeddingFunc(
|
||||
embedding_dim=1024, func=embed_func_b, model_name="bge-large"
|
||||
)
|
||||
|
||||
storage_b = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=embedding_func_b,
|
||||
workspace="workspace_b",
|
||||
)
|
||||
|
||||
# Verify different table names
|
||||
assert storage_a.table_name != storage_b.table_name
|
||||
assert "bge_small_768d" in storage_a.table_name
|
||||
assert "bge_large_1024d" in storage_b.table_name
|
||||
|
||||
# Mock: both tables don't exist yet
|
||||
async def mock_check_table_exists(table_name):
|
||||
return False
|
||||
|
||||
mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)
|
||||
|
||||
with patch(
|
||||
"lightrag.kg.postgres_impl.PGVectorStorage._pg_create_table", AsyncMock()
|
||||
) as mock_create:
|
||||
# Initialize both storages
|
||||
await storage_a.initialize()
|
||||
await storage_b.initialize()
|
||||
|
||||
# Verify two separate tables were created
|
||||
assert mock_create.call_count == 2
|
||||
|
||||
# Verify table names are different
|
||||
call_args_list = mock_create.call_args_list
|
||||
table_names = [call[0][1] for call in call_args_list] # Second positional arg
|
||||
assert len(set(table_names)) == 2 # Two unique table names
|
||||
assert storage_a.table_name in table_names
|
||||
assert storage_b.table_name in table_names
|
||||
|
||||
|
||||
async def test_case1_empty_legacy_auto_cleanup(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func
|
||||
):
|
||||
"""
|
||||
Case 1a: Both new and legacy tables exist, but legacy is EMPTY
|
||||
Expected: Automatically delete empty legacy table (safe cleanup)
|
||||
"""
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
|
||||
embedding_func = EmbeddingFunc(
|
||||
embedding_dim=1536,
|
||||
func=mock_embedding_func.func,
|
||||
model_name="test-model",
|
||||
)
|
||||
|
||||
storage = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=embedding_func,
|
||||
workspace="test_ws",
|
||||
)
|
||||
|
||||
# Mock: Both tables exist
|
||||
async def mock_check_table_exists(table_name):
|
||||
return True # Both new and legacy exist
|
||||
|
||||
mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)
|
||||
|
||||
# Mock: Legacy table is empty (0 records)
|
||||
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
||||
if "COUNT(*)" in sql:
|
||||
if storage.legacy_table_name in sql:
|
||||
return {"count": 0} # Empty legacy table
|
||||
else:
|
||||
return {"count": 100} # New table has data
|
||||
return {}
|
||||
|
||||
mock_pg_db.query = AsyncMock(side_effect=mock_query)
|
||||
|
||||
with patch("lightrag.kg.postgres_impl.logger"):
|
||||
await storage.initialize()
|
||||
|
||||
# Verify: Empty legacy table should be automatically cleaned up
|
||||
# Empty tables are safe to delete without data loss risk
|
||||
delete_calls = [
|
||||
call
|
||||
for call in mock_pg_db.execute.call_args_list
|
||||
if call[0][0] and "DROP TABLE" in call[0][0]
|
||||
]
|
||||
assert len(delete_calls) >= 1, "Empty legacy table should be auto-deleted"
|
||||
# Check if legacy table was dropped
|
||||
dropped_table = storage.legacy_table_name
|
||||
assert any(dropped_table in str(call) for call in delete_calls), (
|
||||
f"Expected to drop empty legacy table '{dropped_table}'"
|
||||
)
|
||||
|
||||
print(
|
||||
f"✅ Case 1a: Empty legacy table '{dropped_table}' auto-deleted successfully"
|
||||
)
|
||||
|
||||
|
||||
async def test_case1_nonempty_legacy_warning(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func
|
||||
):
|
||||
"""
|
||||
Case 1b: Both new and legacy tables exist, and legacy HAS DATA
|
||||
Expected: Log warning, do not delete legacy (preserve data)
|
||||
"""
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
|
||||
embedding_func = EmbeddingFunc(
|
||||
embedding_dim=1536,
|
||||
func=mock_embedding_func.func,
|
||||
model_name="test-model",
|
||||
)
|
||||
|
||||
storage = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=embedding_func,
|
||||
workspace="test_ws",
|
||||
)
|
||||
|
||||
# Mock: Both tables exist
|
||||
async def mock_check_table_exists(table_name):
|
||||
return True # Both new and legacy exist
|
||||
|
||||
mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)
|
||||
|
||||
# Mock: Legacy table has data (50 records)
|
||||
async def mock_query(sql, params=None, multirows=False, **kwargs):
|
||||
if "COUNT(*)" in sql:
|
||||
if storage.legacy_table_name in sql:
|
||||
return {"count": 50} # Legacy has data
|
||||
else:
|
||||
return {"count": 100} # New table has data
|
||||
return {}
|
||||
|
||||
mock_pg_db.query = AsyncMock(side_effect=mock_query)
|
||||
|
||||
with patch("lightrag.kg.postgres_impl.logger"):
|
||||
await storage.initialize()
|
||||
|
||||
# Verify: Legacy table with data should be preserved
|
||||
# We never auto-delete tables that contain data to prevent accidental data loss
|
||||
delete_calls = [
|
||||
call
|
||||
for call in mock_pg_db.execute.call_args_list
|
||||
if call[0][0] and "DROP TABLE" in call[0][0]
|
||||
]
|
||||
# Check if legacy table was deleted (it should not be)
|
||||
dropped_table = storage.legacy_table_name
|
||||
legacy_deleted = any(dropped_table in str(call) for call in delete_calls)
|
||||
assert not legacy_deleted, "Legacy table with data should NOT be auto-deleted"
|
||||
|
||||
print(
|
||||
f"✅ Case 1b: Legacy table '{dropped_table}' with data preserved (warning only)"
|
||||
)
|
||||
|
||||
|
||||
async def test_case1_sequential_workspace_migration(
|
||||
mock_client_manager, mock_pg_db, mock_embedding_func
|
||||
):
|
||||
"""
|
||||
Case 1c: Sequential workspace migration (Multi-tenant scenario)
|
||||
|
||||
Critical bug fix verification:
|
||||
Timeline:
|
||||
1. Legacy table has workspace_a (3 records) + workspace_b (3 records)
|
||||
2. Workspace A initializes first → Case 3 (only legacy exists) → migrates A's data
|
||||
3. Workspace B initializes later → Case 3 (both tables exist, legacy has B's data) → should migrate B's data
|
||||
4. Verify workspace B's data is correctly migrated to new table
|
||||
|
||||
This test verifies the migration logic correctly handles multi-tenant scenarios
|
||||
where different workspaces migrate sequentially.
|
||||
"""
|
||||
config = {
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.8},
|
||||
}
|
||||
|
||||
embedding_func = EmbeddingFunc(
|
||||
embedding_dim=1536,
|
||||
func=mock_embedding_func.func,
|
||||
model_name="test-model",
|
||||
)
|
||||
|
||||
# Mock data: Legacy table has 6 records total (3 from workspace_a, 3 from workspace_b)
|
||||
mock_rows_a = [
|
||||
{"id": f"a_{i}", "content": f"A content {i}", "workspace": "workspace_a"}
|
||||
for i in range(3)
|
||||
]
|
||||
mock_rows_b = [
|
||||
{"id": f"b_{i}", "content": f"B content {i}", "workspace": "workspace_b"}
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
# Track migration state
|
||||
migration_state = {
|
||||
"new_table_exists": False,
|
||||
"workspace_a_migrated": False,
|
||||
"workspace_a_migration_count": 0,
|
||||
"workspace_b_migration_count": 0,
|
||||
}
|
||||
|
||||
# Step 1: Simulate workspace_a initialization (Case 3 - only legacy exists)
|
||||
# CRITICAL: Set db.workspace to workspace_a
|
||||
mock_pg_db.workspace = "workspace_a"
|
||||
|
||||
storage_a = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=embedding_func,
|
||||
workspace="workspace_a",
|
||||
)
|
||||
|
||||
# Mock table_exists for workspace_a
|
||||
async def mock_check_table_exists_a(table_name):
|
||||
if table_name == storage_a.legacy_table_name:
|
||||
return True
|
||||
if table_name == storage_a.table_name:
|
||||
return migration_state["new_table_exists"]
|
||||
return False
|
||||
|
||||
mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists_a)
|
||||
|
||||
# Mock query for workspace_a (Case 3)
|
||||
async def mock_query_a(sql, params=None, multirows=False, **kwargs):
|
||||
sql_upper = sql.upper()
|
||||
base_name = storage_a.legacy_table_name.upper()
|
||||
|
||||
if "COUNT(*)" in sql:
|
||||
has_model_suffix = "TEST_MODEL_1536D" in sql_upper
|
||||
is_legacy = base_name in sql_upper and not has_model_suffix
|
||||
has_workspace_filter = "WHERE workspace" in sql
|
||||
|
||||
if is_legacy and has_workspace_filter:
|
||||
workspace = params[0] if params and len(params) > 0 else None
|
||||
if workspace == "workspace_a":
|
||||
return {"count": 3}
|
||||
elif workspace == "workspace_b":
|
||||
return {"count": 3}
|
||||
elif is_legacy and not has_workspace_filter:
|
||||
# Global count in legacy table
|
||||
return {"count": 6}
|
||||
elif has_model_suffix:
|
||||
if has_workspace_filter:
|
||||
workspace = params[0] if params and len(params) > 0 else None
|
||||
if workspace == "workspace_a":
|
||||
return {"count": migration_state["workspace_a_migration_count"]}
|
||||
if workspace == "workspace_b":
|
||||
return {"count": migration_state["workspace_b_migration_count"]}
|
||||
return {
|
||||
"count": migration_state["workspace_a_migration_count"]
|
||||
+ migration_state["workspace_b_migration_count"]
|
||||
}
|
||||
elif multirows and "SELECT *" in sql:
|
||||
if "WHERE workspace" in sql:
|
||||
workspace = params[0] if params and len(params) > 0 else None
|
||||
if workspace == "workspace_a":
|
||||
# Handle keyset pagination
|
||||
if "id >" in sql:
|
||||
# params = [workspace, last_id, limit]
|
||||
last_id = params[1] if len(params) > 1 else None
|
||||
start_idx = 0
|
||||
for i, row in enumerate(mock_rows_a):
|
||||
if row["id"] == last_id:
|
||||
start_idx = i + 1
|
||||
break
|
||||
limit = params[2] if len(params) > 2 else 500
|
||||
else:
|
||||
# First batch: params = [workspace, limit]
|
||||
start_idx = 0
|
||||
limit = params[1] if len(params) > 1 else 500
|
||||
end = min(start_idx + limit, len(mock_rows_a))
|
||||
return mock_rows_a[start_idx:end]
|
||||
return {}
|
||||
|
||||
mock_pg_db.query = AsyncMock(side_effect=mock_query_a)
|
||||
|
||||
# Track migration via _run_with_retry (batch migration uses this)
|
||||
migration_a_executed = []
|
||||
|
||||
async def mock_run_with_retry_a(operation, **kwargs):
|
||||
migration_a_executed.append(True)
|
||||
migration_state["workspace_a_migration_count"] = len(mock_rows_a)
|
||||
return None
|
||||
|
||||
mock_pg_db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry_a)
|
||||
|
||||
# Initialize workspace_a (Case 3)
|
||||
with patch("lightrag.kg.postgres_impl.logger"):
|
||||
await storage_a.initialize()
|
||||
migration_state["new_table_exists"] = True
|
||||
migration_state["workspace_a_migrated"] = True
|
||||
|
||||
print("✅ Step 1: Workspace A initialized")
|
||||
# Verify migration was executed via _run_with_retry (batch migration uses executemany)
|
||||
assert len(migration_a_executed) > 0, (
|
||||
"Migration should have been executed for workspace_a"
|
||||
)
|
||||
print(f"✅ Step 1: Migration executed {len(migration_a_executed)} batch(es)")
|
||||
|
||||
# Step 2: Simulate workspace_b initialization (Case 3 - both exist, but legacy has B's data)
|
||||
# CRITICAL: Set db.workspace to workspace_b
|
||||
mock_pg_db.workspace = "workspace_b"
|
||||
|
||||
storage_b = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_CHUNKS,
|
||||
global_config=config,
|
||||
embedding_func=embedding_func,
|
||||
workspace="workspace_b",
|
||||
)
|
||||
|
||||
mock_pg_db.reset_mock()
|
||||
|
||||
# Mock table_exists for workspace_b (both exist)
|
||||
async def mock_check_table_exists_b(table_name):
|
||||
return True # Both tables exist
|
||||
|
||||
mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists_b)
|
||||
|
||||
# Mock query for workspace_b (Case 3)
|
||||
async def mock_query_b(sql, params=None, multirows=False, **kwargs):
|
||||
sql_upper = sql.upper()
|
||||
base_name = storage_b.legacy_table_name.upper()
|
||||
|
||||
if "COUNT(*)" in sql:
|
||||
has_model_suffix = "TEST_MODEL_1536D" in sql_upper
|
||||
is_legacy = base_name in sql_upper and not has_model_suffix
|
||||
has_workspace_filter = "WHERE workspace" in sql
|
||||
|
||||
if is_legacy and has_workspace_filter:
|
||||
workspace = params[0] if params and len(params) > 0 else None
|
||||
if workspace == "workspace_b":
|
||||
return {"count": 3} # workspace_b still has data in legacy
|
||||
elif workspace == "workspace_a":
|
||||
return {"count": 0} # workspace_a already migrated
|
||||
elif is_legacy and not has_workspace_filter:
|
||||
# Global count: only workspace_b data remains
|
||||
return {"count": 3}
|
||||
elif has_model_suffix:
|
||||
if has_workspace_filter:
|
||||
workspace = params[0] if params and len(params) > 0 else None
|
||||
if workspace == "workspace_b":
|
||||
return {"count": migration_state["workspace_b_migration_count"]}
|
||||
elif workspace == "workspace_a":
|
||||
return {"count": 3}
|
||||
else:
|
||||
return {"count": 3 + migration_state["workspace_b_migration_count"]}
|
||||
elif multirows and "SELECT *" in sql:
|
||||
if "WHERE workspace" in sql:
|
||||
workspace = params[0] if params and len(params) > 0 else None
|
||||
if workspace == "workspace_b":
|
||||
# Handle keyset pagination
|
||||
if "id >" in sql:
|
||||
# params = [workspace, last_id, limit]
|
||||
last_id = params[1] if len(params) > 1 else None
|
||||
start_idx = 0
|
||||
for i, row in enumerate(mock_rows_b):
|
||||
if row["id"] == last_id:
|
||||
start_idx = i + 1
|
||||
break
|
||||
limit = params[2] if len(params) > 2 else 500
|
||||
else:
|
||||
# First batch: params = [workspace, limit]
|
||||
start_idx = 0
|
||||
limit = params[1] if len(params) > 1 else 500
|
||||
end = min(start_idx + limit, len(mock_rows_b))
|
||||
return mock_rows_b[start_idx:end]
|
||||
return {}
|
||||
|
||||
mock_pg_db.query = AsyncMock(side_effect=mock_query_b)
|
||||
|
||||
# Track migration via _run_with_retry for workspace_b
|
||||
migration_b_executed = []
|
||||
|
||||
async def mock_run_with_retry_b(operation, **kwargs):
|
||||
migration_b_executed.append(True)
|
||||
migration_state["workspace_b_migration_count"] = len(mock_rows_b)
|
||||
return None
|
||||
|
||||
mock_pg_db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry_b)
|
||||
|
||||
# Initialize workspace_b (Case 3 - both tables exist)
|
||||
with patch("lightrag.kg.postgres_impl.logger"):
|
||||
await storage_b.initialize()
|
||||
|
||||
print("✅ Step 2: Workspace B initialized")
|
||||
|
||||
# Verify workspace_b migration happens when new table has no workspace_b data
|
||||
# but legacy table still has workspace_b data.
|
||||
assert len(migration_b_executed) > 0, (
|
||||
"Migration should have been executed for workspace_b"
|
||||
)
|
||||
print("✅ Step 2: Migration executed for workspace_b")
|
||||
|
||||
print("\n🎉 Case 1c: Sequential workspace migration verification complete!")
|
||||
print(" - Workspace A: Migrated successfully (only legacy existed)")
|
||||
print(" - Workspace B: Migrated successfully (new table empty for workspace_b)")
|
||||
@@ -0,0 +1,138 @@
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import lightrag.utils as utils_module
|
||||
from lightrag.kg.postgres_impl import PGGraphStorage, PostgreSQLDB
|
||||
from lightrag.namespace import NameSpace
|
||||
|
||||
|
||||
def make_db() -> PostgreSQLDB:
|
||||
return PostgreSQLDB(
|
||||
{
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"user": "postgres",
|
||||
"password": "postgres",
|
||||
"database": "postgres",
|
||||
"workspace": "test_ws",
|
||||
"max_connections": 10,
|
||||
"connection_retry_attempts": 3,
|
||||
"connection_retry_backoff": 0,
|
||||
"connection_retry_backoff_max": 0,
|
||||
"pool_close_timeout": 5.0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_timing_logs_success():
|
||||
db = make_db()
|
||||
|
||||
async def fake_run_with_retry(operation, **kwargs):
|
||||
conn = AsyncMock()
|
||||
conn.execute = AsyncMock(return_value="INSERT 0 1")
|
||||
await operation(conn)
|
||||
|
||||
db._run_with_retry = AsyncMock(side_effect=fake_run_with_retry)
|
||||
|
||||
with patch("lightrag.kg.postgres_impl.performance_timing_log") as timing_log:
|
||||
await db.execute("SELECT 1", timing_label="test label")
|
||||
|
||||
assert any(
|
||||
"connection.execute completed" in call.args[0]
|
||||
for call in timing_log.call_args_list
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_timing_logs_failure():
|
||||
db = make_db()
|
||||
|
||||
async def fake_run_with_retry(operation, **kwargs):
|
||||
conn = AsyncMock()
|
||||
conn.execute = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
await operation(conn)
|
||||
|
||||
db._run_with_retry = AsyncMock(side_effect=fake_run_with_retry)
|
||||
|
||||
with patch("lightrag.kg.postgres_impl.performance_timing_log") as timing_log:
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await db.execute("SELECT 1", timing_label="test label")
|
||||
|
||||
assert any(
|
||||
"connection.execute failed" in call.args[0]
|
||||
for call in timing_log.call_args_list
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_upsert_node_passes_timing_label():
|
||||
storage = PGGraphStorage(
|
||||
namespace=NameSpace.GRAPH_STORE_CHUNK_ENTITY_RELATION,
|
||||
workspace="test_ws",
|
||||
global_config={},
|
||||
embedding_func=AsyncMock(),
|
||||
)
|
||||
storage.graph_name = "test_graph"
|
||||
storage._query = AsyncMock(return_value=[])
|
||||
|
||||
await storage.upsert_node(
|
||||
"node-1",
|
||||
{
|
||||
"entity_id": "node-1",
|
||||
"description": "desc",
|
||||
},
|
||||
)
|
||||
|
||||
assert storage._query.await_args.kwargs["timing_label"] == (
|
||||
"test_ws PGGraphStorage.upsert_node"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_upsert_edge_passes_timing_label():
|
||||
storage = PGGraphStorage(
|
||||
namespace=NameSpace.GRAPH_STORE_CHUNK_ENTITY_RELATION,
|
||||
workspace="test_ws",
|
||||
global_config={},
|
||||
embedding_func=AsyncMock(),
|
||||
)
|
||||
storage.graph_name = "test_graph"
|
||||
# upsert_edge drives the lock + cypher via db._run_with_retry, not _query.
|
||||
storage.db = MagicMock()
|
||||
storage.db._run_with_retry = AsyncMock(return_value=None)
|
||||
|
||||
await storage.upsert_edge(
|
||||
"node-1",
|
||||
"node-2",
|
||||
{
|
||||
"weight": 1.0,
|
||||
"description": "desc",
|
||||
},
|
||||
)
|
||||
|
||||
assert storage.db._run_with_retry.await_args.kwargs["timing_label"] == (
|
||||
"test_ws PGGraphStorage.upsert_edge"
|
||||
)
|
||||
|
||||
|
||||
def test_performance_timing_logs_reads_new_env_only(monkeypatch):
|
||||
with monkeypatch.context() as m:
|
||||
m.setenv("LIGHTRAG_DOC_QUERY_TIMING_LOGS", "false")
|
||||
m.setenv("LIGHTRAG_PERFORMANCE_TIMING_LOGS", "true")
|
||||
reloaded = importlib.reload(utils_module)
|
||||
assert reloaded.PERFORMANCE_TIMING_LOGS is True
|
||||
|
||||
importlib.reload(utils_module)
|
||||
|
||||
|
||||
def test_performance_timing_logs_ignores_old_env(monkeypatch):
|
||||
with monkeypatch.context() as m:
|
||||
m.setenv("LIGHTRAG_DOC_QUERY_TIMING_LOGS", "true")
|
||||
m.setenv("LIGHTRAG_PERFORMANCE_TIMING_LOGS", "false")
|
||||
reloaded = importlib.reload(utils_module)
|
||||
assert reloaded.PERFORMANCE_TIMING_LOGS is False
|
||||
|
||||
importlib.reload(utils_module)
|
||||
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Integration test suite for PostgreSQL retry mechanism using real database.
|
||||
|
||||
This test suite connects to a real PostgreSQL database using credentials from .env
|
||||
and tests the retry mechanism with actual network failures.
|
||||
|
||||
Prerequisites:
|
||||
1. PostgreSQL server running and accessible
|
||||
2. .env file with POSTGRES_* configuration
|
||||
3. asyncpg installed: pip install asyncpg
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from dotenv import load_dotenv
|
||||
from unittest.mock import patch
|
||||
from lightrag.kg.postgres_impl import PostgreSQLDB
|
||||
|
||||
asyncpg = pytest.importorskip("asyncpg")
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv(dotenv_path=".env", override=False)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.requires_db
|
||||
class TestPostgresRetryIntegration:
|
||||
"""Integration tests for PostgreSQL retry mechanism with real database."""
|
||||
|
||||
@pytest.fixture
|
||||
def db_config(self):
|
||||
"""Load database configuration from environment variables.
|
||||
|
||||
Uses new HA-optimized defaults that match postgres_impl.py ClientManager.get_config():
|
||||
- 10 retry attempts (up from 3)
|
||||
- 3.0s initial backoff (up from 0.5s)
|
||||
- 30.0s max backoff (up from 5.0s)
|
||||
"""
|
||||
return {
|
||||
"host": os.getenv("POSTGRES_HOST", "localhost"),
|
||||
"port": int(os.getenv("POSTGRES_PORT", "5432")),
|
||||
"user": os.getenv("POSTGRES_USER", "postgres"),
|
||||
"password": os.getenv("POSTGRES_PASSWORD", ""),
|
||||
"database": os.getenv("POSTGRES_DATABASE", "postgres"),
|
||||
"workspace": os.getenv("POSTGRES_WORKSPACE", "test_retry"),
|
||||
"max_connections": int(os.getenv("POSTGRES_MAX_CONNECTIONS", "10")),
|
||||
# Connection retry configuration - mirrors postgres_impl.py ClientManager.get_config()
|
||||
# NEW DEFAULTS optimized for HA deployments
|
||||
"connection_retry_attempts": min(
|
||||
100,
|
||||
int(os.getenv("POSTGRES_CONNECTION_RETRIES", "10")), # 3 → 10
|
||||
),
|
||||
"connection_retry_backoff": min(
|
||||
300.0,
|
||||
float(
|
||||
os.getenv("POSTGRES_CONNECTION_RETRY_BACKOFF", "3.0")
|
||||
), # 0.5 → 3.0
|
||||
),
|
||||
"connection_retry_backoff_max": min(
|
||||
600.0,
|
||||
float(
|
||||
os.getenv("POSTGRES_CONNECTION_RETRY_BACKOFF_MAX", "30.0")
|
||||
), # 5.0 → 30.0
|
||||
),
|
||||
"pool_close_timeout": min(
|
||||
30.0, float(os.getenv("POSTGRES_POOL_CLOSE_TIMEOUT", "5.0"))
|
||||
),
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_connection_success(self, db_config):
|
||||
"""
|
||||
Test successful connection to real PostgreSQL database.
|
||||
|
||||
This validates that:
|
||||
1. Database credentials are correct
|
||||
2. Connection pool initializes properly
|
||||
3. Basic query works
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEGRATION TEST 1: Real Database Connection")
|
||||
print("=" * 80)
|
||||
print(
|
||||
f" → Connecting to {db_config['host']}:{db_config['port']}/{db_config['database']}"
|
||||
)
|
||||
|
||||
db = PostgreSQLDB(db_config)
|
||||
|
||||
try:
|
||||
# Initialize database connection
|
||||
await db.initdb()
|
||||
print(" ✓ Connection successful")
|
||||
|
||||
# Test simple query
|
||||
result = await db.query("SELECT 1 as test", multirows=False)
|
||||
assert result is not None
|
||||
assert result.get("test") == 1
|
||||
print(" ✓ Query executed successfully")
|
||||
|
||||
print("\n✅ Test passed: Real database connection works")
|
||||
print("=" * 80)
|
||||
finally:
|
||||
if db.pool:
|
||||
await db.pool.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simulated_transient_error_with_real_db(self, db_config):
|
||||
"""
|
||||
Test retry mechanism with simulated transient errors on real database.
|
||||
|
||||
Simulates connection failures on first 2 attempts, then succeeds.
|
||||
Uses new HA defaults (10 retries, 3s backoff).
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEGRATION TEST 2: Simulated Transient Errors")
|
||||
print("=" * 80)
|
||||
|
||||
db = PostgreSQLDB(db_config)
|
||||
attempt_count = {"value": 0}
|
||||
|
||||
# Original create_pool function
|
||||
original_create_pool = asyncpg.create_pool
|
||||
|
||||
async def mock_create_pool_with_failures(*args, **kwargs):
|
||||
"""Mock that fails first 2 times, then calls real create_pool."""
|
||||
attempt_count["value"] += 1
|
||||
print(f" → Connection attempt {attempt_count['value']}")
|
||||
|
||||
if attempt_count["value"] <= 2:
|
||||
print(" ✗ Simulating connection failure")
|
||||
raise asyncpg.exceptions.ConnectionFailureError(
|
||||
f"Simulated failure on attempt {attempt_count['value']}"
|
||||
)
|
||||
|
||||
print(" ✓ Allowing real connection")
|
||||
return await original_create_pool(*args, **kwargs)
|
||||
|
||||
try:
|
||||
# Patch create_pool to simulate failures
|
||||
with patch(
|
||||
"asyncpg.create_pool", side_effect=mock_create_pool_with_failures
|
||||
):
|
||||
await db.initdb()
|
||||
|
||||
assert attempt_count["value"] == 3, (
|
||||
f"Expected 3 attempts, got {attempt_count['value']}"
|
||||
)
|
||||
assert db.pool is not None, "Pool should be initialized after retries"
|
||||
|
||||
# Verify database is actually working
|
||||
result = await db.query("SELECT 1 as test", multirows=False)
|
||||
assert result.get("test") == 1
|
||||
|
||||
print(
|
||||
f"\n✅ Test passed: Retry mechanism worked, connected after {attempt_count['value']} attempts"
|
||||
)
|
||||
print("=" * 80)
|
||||
finally:
|
||||
if db.pool:
|
||||
await db.pool.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_retry_with_real_db(self, db_config):
|
||||
"""
|
||||
Test query-level retry with simulated connection issues.
|
||||
|
||||
Tests that queries retry on transient failures by simulating
|
||||
a temporary database unavailability.
|
||||
Uses new HA defaults (10 retries, 3s backoff).
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEGRATION TEST 3: Query-Level Retry")
|
||||
print("=" * 80)
|
||||
|
||||
db = PostgreSQLDB(db_config)
|
||||
|
||||
try:
|
||||
# First initialize normally
|
||||
await db.initdb()
|
||||
print(" ✓ Database initialized")
|
||||
|
||||
# Close the pool to simulate connection loss
|
||||
print(" → Simulating connection loss (closing pool)...")
|
||||
await db.pool.close()
|
||||
db.pool = None
|
||||
|
||||
# Now query should trigger pool recreation and retry
|
||||
print(" → Attempting query (should auto-reconnect)...")
|
||||
result = await db.query("SELECT 1 as test", multirows=False)
|
||||
|
||||
assert result.get("test") == 1, "Query should succeed after reconnection"
|
||||
assert db.pool is not None, "Pool should be recreated"
|
||||
|
||||
print(" ✓ Query succeeded after automatic reconnection")
|
||||
print("\n✅ Test passed: Auto-reconnection works correctly")
|
||||
print("=" * 80)
|
||||
finally:
|
||||
if db.pool:
|
||||
await db.pool.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_queries_with_real_db(self, db_config):
|
||||
"""
|
||||
Test concurrent queries to validate thread safety and connection pooling.
|
||||
|
||||
Runs multiple concurrent queries to ensure no deadlocks or race conditions.
|
||||
Uses new HA defaults (10 retries, 3s backoff).
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEGRATION TEST 4: Concurrent Queries")
|
||||
print("=" * 80)
|
||||
|
||||
db = PostgreSQLDB(db_config)
|
||||
|
||||
try:
|
||||
await db.initdb()
|
||||
print(" ✓ Database initialized")
|
||||
|
||||
# Launch 10 concurrent queries
|
||||
num_queries = 10
|
||||
print(f" → Launching {num_queries} concurrent queries...")
|
||||
|
||||
async def run_query(query_id):
|
||||
result = await db.query(
|
||||
f"SELECT {query_id} as id, pg_sleep(0.1)", multirows=False
|
||||
)
|
||||
return result.get("id")
|
||||
|
||||
start_time = time.time()
|
||||
tasks = [run_query(i) for i in range(num_queries)]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Check results
|
||||
successful = sum(1 for r in results if not isinstance(r, Exception))
|
||||
failed = sum(1 for r in results if isinstance(r, Exception))
|
||||
|
||||
print(f" → Completed in {elapsed:.2f}s")
|
||||
print(f" → Results: {successful} successful, {failed} failed")
|
||||
|
||||
assert successful == num_queries, (
|
||||
f"All {num_queries} queries should succeed"
|
||||
)
|
||||
assert failed == 0, "No queries should fail"
|
||||
|
||||
print("\n✅ Test passed: All concurrent queries succeeded, no deadlocks")
|
||||
print("=" * 80)
|
||||
finally:
|
||||
if db.pool:
|
||||
await db.pool.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pool_close_timeout_real(self, db_config):
|
||||
"""
|
||||
Test pool close timeout protection with real database.
|
||||
Uses new HA defaults (10 retries, 3s backoff).
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEGRATION TEST 5: Pool Close Timeout")
|
||||
print("=" * 80)
|
||||
|
||||
db = PostgreSQLDB(db_config)
|
||||
|
||||
try:
|
||||
await db.initdb()
|
||||
print(" ✓ Database initialized")
|
||||
|
||||
# Trigger pool reset (which includes close)
|
||||
print(" → Triggering pool reset...")
|
||||
start_time = time.time()
|
||||
await db._reset_pool()
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
print(f" ✓ Pool reset completed in {elapsed:.2f}s")
|
||||
assert db.pool is None, "Pool should be None after reset"
|
||||
assert elapsed < db.pool_close_timeout + 1, (
|
||||
"Reset should complete within timeout"
|
||||
)
|
||||
|
||||
print("\n✅ Test passed: Pool reset handled correctly")
|
||||
print("=" * 80)
|
||||
finally:
|
||||
# Already closed in test
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configuration_from_env(self, db_config):
|
||||
"""
|
||||
Test that configuration is correctly loaded from environment variables.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEGRATION TEST 6: Environment Configuration")
|
||||
print("=" * 80)
|
||||
|
||||
db = PostgreSQLDB(db_config)
|
||||
|
||||
print(" → Configuration loaded:")
|
||||
print(f" • Host: {db.host}")
|
||||
print(f" • Port: {db.port}")
|
||||
print(f" • Database: {db.database}")
|
||||
print(f" • User: {db.user}")
|
||||
print(f" • Workspace: {db.workspace}")
|
||||
print(f" • Max Connections: {db.max}")
|
||||
print(f" • Retry Attempts: {db.connection_retry_attempts}")
|
||||
print(f" • Retry Backoff: {db.connection_retry_backoff}s")
|
||||
print(f" • Max Backoff: {db.connection_retry_backoff_max}s")
|
||||
print(f" • Pool Close Timeout: {db.pool_close_timeout}s")
|
||||
|
||||
# Verify required fields are present
|
||||
assert db.host, "Host should be configured"
|
||||
assert db.port, "Port should be configured"
|
||||
assert db.user, "User should be configured"
|
||||
assert db.database, "Database should be configured"
|
||||
|
||||
print("\n✅ Test passed: All configuration loaded correctly from .env")
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
def run_integration_tests():
|
||||
"""Run all integration tests with detailed output."""
|
||||
print("\n" + "=" * 80)
|
||||
print("POSTGRESQL RETRY MECHANISM - INTEGRATION TESTS")
|
||||
print("Testing with REAL database from .env configuration")
|
||||
print("=" * 80)
|
||||
|
||||
# Check if database configuration exists
|
||||
if not os.getenv("POSTGRES_HOST"):
|
||||
print("\n⚠️ WARNING: No POSTGRES_HOST in .env file")
|
||||
print("Please ensure .env file exists with PostgreSQL configuration.")
|
||||
return
|
||||
|
||||
print("\nRunning integration tests...\n")
|
||||
|
||||
# Run pytest with verbose output
|
||||
pytest.main(
|
||||
[
|
||||
__file__,
|
||||
"-v",
|
||||
"-s", # Don't capture output
|
||||
"--tb=short", # Short traceback format
|
||||
"--color=yes",
|
||||
"-x", # Stop on first failure
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_integration_tests()
|
||||
@@ -0,0 +1,35 @@
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from lightrag.kg.postgres_impl import PostgreSQLDB
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_table_exists_uses_search_path_visible_regclass():
|
||||
db = PostgreSQLDB.__new__(PostgreSQLDB)
|
||||
db.query = AsyncMock(return_value={"exists": True})
|
||||
|
||||
assert await db.check_table_exists("LIGHTRAG_DOC_FULL") is True
|
||||
|
||||
db.query.assert_awaited_once_with(
|
||||
"SELECT to_regclass($1) IS NOT NULL AS exists",
|
||||
["lightrag_doc_full"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_entity_relation_migration_reuses_table_exists_helper():
|
||||
db = PostgreSQLDB.__new__(PostgreSQLDB)
|
||||
db.check_table_exists = AsyncMock(return_value=True)
|
||||
db.query = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
|
||||
await db._migrate_create_full_entities_relations_tables()
|
||||
|
||||
assert [call.args[0] for call in db.check_table_exists.await_args_list] == [
|
||||
"LIGHTRAG_FULL_ENTITIES",
|
||||
"LIGHTRAG_FULL_RELATIONS",
|
||||
]
|
||||
db.query.assert_not_called()
|
||||
db.execute.assert_not_called()
|
||||
@@ -0,0 +1,844 @@
|
||||
"""
|
||||
Unit tests for PGKVStorage.upsert batch optimization (PR #2742 fixes).
|
||||
|
||||
Verifies:
|
||||
1. Each namespace builds correct tuple ordering matching SQL positional params.
|
||||
2. _run_with_retry is used (not the removed PostgreSQLDB.executemany wrapper).
|
||||
3. Sub-batching splits data when len(data) > _max_batch_size.
|
||||
4. Unknown namespace raises ValueError.
|
||||
5. Empty data returns without any DB call.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
import numpy as np
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from lightrag.kg.postgres_impl import PGDocStatusStorage, PGKVStorage, PGVectorStorage
|
||||
from lightrag.namespace import NameSpace
|
||||
from lightrag.utils import EmbeddingFunc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GLOBAL_CONFIG = {"embedding_batch_num": 10}
|
||||
|
||||
|
||||
def make_storage(namespace: str) -> PGKVStorage:
|
||||
"""Construct a PGKVStorage instance with a mocked db."""
|
||||
db = MagicMock()
|
||||
captured: list[tuple] = []
|
||||
captured_execute: list[tuple] = []
|
||||
retry_kwargs: list[dict] = []
|
||||
|
||||
async def fake_run_with_retry(operation, **kwargs):
|
||||
"""Call the closure with a mock connection to capture executemany args."""
|
||||
retry_kwargs.append(kwargs)
|
||||
mock_conn = AsyncMock()
|
||||
tx = AsyncMock()
|
||||
tx.__aenter__.return_value = tx
|
||||
tx.__aexit__.return_value = False
|
||||
mock_conn.transaction = MagicMock(return_value=tx)
|
||||
await operation(mock_conn)
|
||||
# Store (sql, data) from each executemany call
|
||||
for call in mock_conn.executemany.call_args_list:
|
||||
captured.append((call.args[0], call.args[1]))
|
||||
# Store (sql, args) from each execute call (chunked delete path).
|
||||
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.workspace = "test_ws"
|
||||
|
||||
storage = PGKVStorage.__new__(PGKVStorage)
|
||||
storage.namespace = namespace
|
||||
storage.workspace = "test_ws"
|
||||
storage.global_config = GLOBAL_CONFIG
|
||||
storage.db = db
|
||||
storage.__post_init__()
|
||||
|
||||
storage._captured = captured
|
||||
storage._captured_execute = captured_execute
|
||||
storage._retry_kwargs = retry_kwargs
|
||||
return storage
|
||||
|
||||
|
||||
def make_doc_status_storage() -> PGDocStatusStorage:
|
||||
"""Construct a PGDocStatusStorage instance with a mocked db."""
|
||||
db = MagicMock()
|
||||
captured: list[tuple] = []
|
||||
captured_execute: list[tuple] = []
|
||||
retry_kwargs: list[dict] = []
|
||||
|
||||
async def fake_run_with_retry(operation, **kwargs):
|
||||
retry_kwargs.append(kwargs)
|
||||
mock_conn = AsyncMock()
|
||||
tx = AsyncMock()
|
||||
tx.__aenter__.return_value = tx
|
||||
tx.__aexit__.return_value = False
|
||||
mock_conn.transaction = MagicMock(return_value=tx)
|
||||
await operation(mock_conn)
|
||||
for call in mock_conn.executemany.call_args_list:
|
||||
captured.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.workspace = "test_ws"
|
||||
|
||||
storage = PGDocStatusStorage.__new__(PGDocStatusStorage)
|
||||
storage.namespace = NameSpace.DOC_STATUS
|
||||
storage.workspace = "test_ws"
|
||||
storage.global_config = GLOBAL_CONFIG
|
||||
storage.db = db
|
||||
storage.__post_init__() # resolves batch-limit attrs (payload/records caps)
|
||||
storage._captured = captured
|
||||
storage._captured_execute = captured_execute
|
||||
storage._retry_kwargs = retry_kwargs
|
||||
return storage
|
||||
|
||||
|
||||
def make_vector_storage(namespace: str) -> PGVectorStorage:
|
||||
"""Construct a PGVectorStorage instance with a mocked db and embedding func."""
|
||||
db = MagicMock()
|
||||
captured: list[tuple] = []
|
||||
retry_kwargs: list[dict] = []
|
||||
|
||||
async def fake_run_with_retry(operation, **kwargs):
|
||||
retry_kwargs.append(kwargs)
|
||||
mock_conn = AsyncMock()
|
||||
# connection.transaction() is a synchronous factory in asyncpg that
|
||||
# returns an async context manager. AsyncMock would return a coroutine,
|
||||
# so swap in a MagicMock that returns an AsyncMock-backed context.
|
||||
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.append((call.args[0], call.args[1]))
|
||||
# Also capture connection.execute calls (delete SQL inside flush).
|
||||
for call in mock_conn.execute.call_args_list:
|
||||
captured.append((call.args[0], call.args[1:]))
|
||||
|
||||
db._run_with_retry = AsyncMock(side_effect=fake_run_with_retry)
|
||||
db.workspace = "test_ws"
|
||||
|
||||
async def embed_func(texts, **kwargs):
|
||||
return np.array([[0.1, 0.2, 0.3] for _ in texts], dtype=np.float32)
|
||||
|
||||
embedding = EmbeddingFunc(
|
||||
embedding_dim=3,
|
||||
func=embed_func,
|
||||
model_name="test_model",
|
||||
)
|
||||
storage = PGVectorStorage(
|
||||
namespace=namespace,
|
||||
workspace="test_ws",
|
||||
global_config={
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {
|
||||
"cosine_better_than_threshold": 0.5,
|
||||
},
|
||||
},
|
||||
embedding_func=embedding,
|
||||
)
|
||||
storage.db = db
|
||||
storage._flush_lock = asyncio.Lock()
|
||||
storage._captured = captured
|
||||
storage._retry_kwargs = retry_kwargs
|
||||
return storage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. _max_batch_size is always 200 (not embedding_batch_num)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_max_batch_size_is_constant():
|
||||
storage = make_storage(NameSpace.KV_STORE_TEXT_CHUNKS)
|
||||
assert storage._max_batch_size == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Namespace: TEXT_CHUNKS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_text_chunks_tuple_order():
|
||||
storage = make_storage(NameSpace.KV_STORE_TEXT_CHUNKS)
|
||||
data = {
|
||||
"chunk-1": {
|
||||
"tokens": 42,
|
||||
"chunk_order_index": 0,
|
||||
"full_doc_id": "doc-1",
|
||||
"content": "hello world",
|
||||
"file_path": "/a/b.[native-Fi].txt",
|
||||
"llm_cache_list": ["cache-key"],
|
||||
"heading": {"level": 2, "text": "Section A"},
|
||||
"sidecar": {"type": "drawing", "id": "img-1", "refs": []},
|
||||
}
|
||||
}
|
||||
await storage.upsert(data)
|
||||
|
||||
assert len(storage._captured) == 1
|
||||
sql, rows = storage._captured[0]
|
||||
assert "LIGHTRAG_DOC_CHUNKS" in sql
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
# SQL: (workspace, id, tokens, chunk_order_index, full_doc_id,
|
||||
# content, file_path, llm_cache_list, heading, sidecar,
|
||||
# create_time, update_time)
|
||||
assert row[0] == "test_ws" # workspace
|
||||
assert row[1] == "chunk-1" # id
|
||||
assert row[2] == 42 # tokens
|
||||
assert row[3] == 0 # chunk_order_index
|
||||
assert row[4] == "doc-1" # full_doc_id
|
||||
assert row[5] == "hello world" # content
|
||||
assert row[6] == "/a/b.[native-Fi].txt" # file_path
|
||||
assert json.loads(row[7]) == ["cache-key"] # llm_cache_list
|
||||
assert json.loads(row[8]) == {"level": 2, "text": "Section A"} # heading
|
||||
assert json.loads(row[9]) == {
|
||||
"type": "drawing",
|
||||
"id": "img-1",
|
||||
"refs": [],
|
||||
} # sidecar
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_text_chunks_missing_heading_sidecar_defaults_to_empty_dict():
|
||||
"""Plain-text chunks without heading/sidecar should serialize to '{}'."""
|
||||
storage = make_storage(NameSpace.KV_STORE_TEXT_CHUNKS)
|
||||
data = {
|
||||
"chunk-1": {
|
||||
"tokens": 10,
|
||||
"chunk_order_index": 0,
|
||||
"full_doc_id": "doc-1",
|
||||
"content": "plain text",
|
||||
"file_path": "/a/b.txt",
|
||||
}
|
||||
}
|
||||
await storage.upsert(data)
|
||||
|
||||
_, rows = storage._captured[0]
|
||||
row = rows[0]
|
||||
assert json.loads(row[8]) == {} # heading
|
||||
assert json.loads(row[9]) == {} # sidecar
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_text_chunks_none_heading_sidecar_defaults_to_empty_dict():
|
||||
"""Explicit None values should be coerced to '{}' to avoid type errors."""
|
||||
storage = make_storage(NameSpace.KV_STORE_TEXT_CHUNKS)
|
||||
data = {
|
||||
"chunk-1": {
|
||||
"tokens": 10,
|
||||
"chunk_order_index": 0,
|
||||
"full_doc_id": "doc-1",
|
||||
"content": "plain text",
|
||||
"file_path": "/a/b.txt",
|
||||
"heading": None,
|
||||
"sidecar": None,
|
||||
}
|
||||
}
|
||||
await storage.upsert(data)
|
||||
|
||||
_, rows = storage._captured[0]
|
||||
row = rows[0]
|
||||
assert json.loads(row[8]) == {}
|
||||
assert json.loads(row[9]) == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Namespace: FULL_DOCS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_full_docs_tuple_order():
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)
|
||||
data = {
|
||||
"doc-1": {
|
||||
"content": "full text",
|
||||
"file_path": "/path/doc.[mineru-Fi].pdf",
|
||||
"sidecar_location": "lightrag://sidecar/doc-1",
|
||||
"parse_format": "lightrag",
|
||||
"content_hash": "deadbeef",
|
||||
"process_options": "Fi",
|
||||
"chunk_options": {"chunk_token_size": 1200, "chunk_overlap": 100},
|
||||
"parse_engine": "mineru",
|
||||
}
|
||||
}
|
||||
await storage.upsert(data)
|
||||
|
||||
assert len(storage._captured) == 1
|
||||
_, rows = storage._captured[0]
|
||||
row = rows[0]
|
||||
# SQL: (id, content, doc_name, workspace, sidecar_location, parse_format,
|
||||
# content_hash, process_options, chunk_options, parse_engine)
|
||||
assert row[0] == "doc-1"
|
||||
assert row[1] == "full text"
|
||||
assert row[2] == "/path/doc.[mineru-Fi].pdf"
|
||||
assert row[3] == "test_ws"
|
||||
assert row[4] == "lightrag://sidecar/doc-1"
|
||||
assert row[5] == "lightrag"
|
||||
assert row[6] == "deadbeef"
|
||||
assert row[7] == "Fi"
|
||||
assert json.loads(row[8]) == {"chunk_token_size": 1200, "chunk_overlap": 100}
|
||||
assert row[9] == "mineru"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_full_docs_missing_pipeline_fields_pass_through_as_none():
|
||||
"""Missing pipeline-derived fields must serialize as None at the Python
|
||||
layer so the SQL-level COALESCE guard can distinguish "caller did not
|
||||
supply" from "caller supplied a real value".
|
||||
|
||||
The 'raw' default for parse_format is provided by the column DDL on
|
||||
initial insert; the Python layer must NOT inject it, otherwise the
|
||||
COALESCE guard never triggers on subsequent partial writes (a follow-up
|
||||
upsert with no parse_format would re-stamp the column with 'raw' and
|
||||
blow away a previously-set 'lightrag').
|
||||
"""
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)
|
||||
data = {"doc-1": {"content": "full text", "file_path": "/path/doc.pdf"}}
|
||||
await storage.upsert(data)
|
||||
|
||||
_, rows = storage._captured[0]
|
||||
row = rows[0]
|
||||
assert row[4] is None # sidecar_location
|
||||
assert row[5] is None # parse_format — DDL supplies 'raw' default on insert
|
||||
assert row[6] is None # content_hash
|
||||
assert row[7] is None # process_options
|
||||
assert json.loads(row[8]) == {} # chunk_options default
|
||||
assert row[9] is None # parse_engine
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_full_docs_none_chunk_options_defaults_to_empty_dict():
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)
|
||||
data = {
|
||||
"doc-1": {
|
||||
"content": "full text",
|
||||
"file_path": "/path/doc.pdf",
|
||||
"chunk_options": None,
|
||||
}
|
||||
}
|
||||
await storage.upsert(data)
|
||||
|
||||
_, rows = storage._captured[0]
|
||||
assert json.loads(rows[0][8]) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_full_docs_sql_protects_partial_writes():
|
||||
"""The ON CONFLICT clause must COALESCE+NULLIF every pipeline-derived
|
||||
column so a follow-up upsert that only carries ``content`` + ``doc_name``
|
||||
does not silently overwrite previously-recorded metadata back to defaults.
|
||||
|
||||
We assert this at the SQL-template level since the actual COALESCE
|
||||
behavior is executed by Postgres. The presence of the protective
|
||||
expression in the SQL is the single source of truth for the guarantee.
|
||||
"""
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)
|
||||
await storage.upsert(
|
||||
{"doc-1": {"content": "full text", "file_path": "/path/doc.pdf"}}
|
||||
)
|
||||
sql, _ = storage._captured[0]
|
||||
normalized = " ".join(sql.split()).lower()
|
||||
|
||||
# Each pipeline-derived string column must be COALESCE/NULLIF-guarded
|
||||
for col in (
|
||||
"sidecar_location",
|
||||
"parse_format",
|
||||
"content_hash",
|
||||
"process_options",
|
||||
"parse_engine",
|
||||
):
|
||||
assert f"coalesce( nullif(excluded.{col}, '')" in normalized, (
|
||||
f"upsert_doc_full must guard {col} via COALESCE+NULLIF"
|
||||
)
|
||||
assert f"lightrag_doc_full.{col}" in normalized, (
|
||||
f"upsert_doc_full must preserve existing {col} on partial write"
|
||||
)
|
||||
|
||||
# chunk_options (JSONB) is guarded via CASE on NULL/empty-object literal
|
||||
assert "excluded.chunk_options is null" in normalized
|
||||
assert "excluded.chunk_options = '{}'::jsonb" in normalized
|
||||
assert "lightrag_doc_full.chunk_options" in normalized
|
||||
|
||||
# content / doc_name remain straight overwrites — they ARE the payload
|
||||
assert "content = excluded.content" in normalized
|
||||
assert "doc_name = excluded.doc_name" in normalized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Namespace: LLM_RESPONSE_CACHE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_llm_cache_tuple_order():
|
||||
storage = make_storage(NameSpace.KV_STORE_LLM_RESPONSE_CACHE)
|
||||
data = {
|
||||
"key-1": {
|
||||
"original_prompt": "what is X?",
|
||||
"return": "X is Y",
|
||||
"chunk_id": "chunk-1",
|
||||
"cache_type": "query",
|
||||
"queryparam": {"mode": "hybrid"},
|
||||
}
|
||||
}
|
||||
await storage.upsert(data)
|
||||
|
||||
assert len(storage._captured) == 1
|
||||
_, rows = storage._captured[0]
|
||||
row = rows[0]
|
||||
# SQL: (workspace, id, original_prompt, return_value, chunk_id, cache_type, queryparam)
|
||||
assert row[0] == "test_ws"
|
||||
assert row[1] == "key-1"
|
||||
assert row[2] == "what is X?"
|
||||
assert row[3] == "X is Y"
|
||||
assert row[4] == "chunk-1"
|
||||
assert row[5] == "query"
|
||||
assert json.loads(row[6]) == {"mode": "hybrid"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_llm_cache_null_queryparam():
|
||||
storage = make_storage(NameSpace.KV_STORE_LLM_RESPONSE_CACHE)
|
||||
data = {
|
||||
"key-2": {
|
||||
"original_prompt": "prompt",
|
||||
"return": "answer",
|
||||
"cache_type": "extract",
|
||||
}
|
||||
}
|
||||
await storage.upsert(data)
|
||||
_, rows = storage._captured[0]
|
||||
assert rows[0][6] is None # queryparam should be None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Namespace: FULL_ENTITIES
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_full_entities_tuple_order():
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_ENTITIES)
|
||||
data = {"ent-1": {"entity_names": ["EntityA", "EntityB"], "count": 2}}
|
||||
await storage.upsert(data)
|
||||
|
||||
_, rows = storage._captured[0]
|
||||
row = rows[0]
|
||||
# SQL: (workspace, id, entity_names, count, create_time, update_time)
|
||||
assert row[0] == "test_ws"
|
||||
assert row[1] == "ent-1"
|
||||
assert json.loads(row[2]) == ["EntityA", "EntityB"]
|
||||
assert row[3] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Namespace: FULL_RELATIONS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_full_relations_tuple_order():
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_RELATIONS)
|
||||
data = {"rel-1": {"relation_pairs": [["A", "B"]], "count": 1}}
|
||||
await storage.upsert(data)
|
||||
|
||||
_, rows = storage._captured[0]
|
||||
row = rows[0]
|
||||
# SQL: (workspace, id, relation_pairs, count, create_time, update_time)
|
||||
assert row[0] == "test_ws"
|
||||
assert row[1] == "rel-1"
|
||||
assert json.loads(row[2]) == [["A", "B"]]
|
||||
assert row[3] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Namespace: ENTITY_CHUNKS / RELATION_CHUNKS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_entity_chunks_tuple_order():
|
||||
storage = make_storage(NameSpace.KV_STORE_ENTITY_CHUNKS)
|
||||
data = {"ec-1": {"chunk_ids": ["c1", "c2"], "count": 2}}
|
||||
await storage.upsert(data)
|
||||
|
||||
_, rows = storage._captured[0]
|
||||
row = rows[0]
|
||||
# SQL: (workspace, id, chunk_ids, count, create_time, update_time)
|
||||
assert row[0] == "test_ws"
|
||||
assert row[1] == "ec-1"
|
||||
assert json.loads(row[2]) == ["c1", "c2"]
|
||||
assert row[3] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_relation_chunks_tuple_order():
|
||||
storage = make_storage(NameSpace.KV_STORE_RELATION_CHUNKS)
|
||||
data = {"rc-1": {"chunk_ids": ["c3"], "count": 1}}
|
||||
await storage.upsert(data)
|
||||
|
||||
_, rows = storage._captured[0]
|
||||
row = rows[0]
|
||||
assert row[0] == "test_ws"
|
||||
assert row[1] == "rc-1"
|
||||
assert json.loads(row[2]) == ["c3"]
|
||||
assert row[3] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Sub-batching: data > _max_batch_size splits into multiple _run_with_retry calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sub_batching_splits_correctly():
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)
|
||||
# upsert splits by _max_upsert_records_per_batch (byte cap left at default).
|
||||
storage._max_upsert_records_per_batch = 3
|
||||
|
||||
data = {f"doc-{i}": {"content": f"text {i}", "file_path": ""} for i in range(7)}
|
||||
await storage.upsert(data)
|
||||
|
||||
# 7 records / batch_size 3 => 3 batches (3 + 3 + 1)
|
||||
assert len(storage._captured) == 3
|
||||
assert len(storage._captured[0][1]) == 3
|
||||
assert len(storage._captured[1][1]) == 3
|
||||
assert len(storage._captured[2][1]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sub_batching_exact_multiple():
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)
|
||||
storage._max_upsert_records_per_batch = 3
|
||||
|
||||
data = {f"doc-{i}": {"content": f"text {i}", "file_path": ""} for i in range(6)}
|
||||
await storage.upsert(data)
|
||||
|
||||
# 6 / 3 => exactly 2 batches
|
||||
assert len(storage._captured) == 2
|
||||
assert len(storage._captured[0][1]) == 3
|
||||
assert len(storage._captured[1][1]) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Empty data: no DB call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_empty_data_no_db_call():
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)
|
||||
await storage.upsert({})
|
||||
assert len(storage._captured) == 0
|
||||
storage.db._run_with_retry.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Unknown namespace raises ValueError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_unknown_namespace_raises():
|
||||
storage = make_storage("unknown_namespace")
|
||||
with pytest.raises(ValueError, match="Unknown namespace"):
|
||||
await storage.upsert({"k": {"v": 1}})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. Multiple records go into one batch when within limit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_records_single_batch():
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)
|
||||
data = {
|
||||
"doc-1": {"content": "text 1", "file_path": "/a"},
|
||||
"doc-2": {"content": "text 2", "file_path": "/b"},
|
||||
"doc-3": {"content": "text 3", "file_path": "/c"},
|
||||
}
|
||||
await storage.upsert(data)
|
||||
|
||||
# All 3 fit within default batch size of 200
|
||||
assert len(storage._captured) == 1
|
||||
_, rows = storage._captured[0]
|
||||
assert len(rows) == 3
|
||||
ids = {row[0] for row in rows} # id is $1 for FULL_DOCS
|
||||
assert ids == {"doc-1", "doc-2", "doc-3"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kv_upsert_passes_timing_label():
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)
|
||||
await storage.upsert({"doc-1": {"content": "text 1", "file_path": "/a"}})
|
||||
|
||||
assert storage._retry_kwargs[0]["timing_label"] == (
|
||||
f"test_ws PGKVStorage.upsert[{NameSpace.KV_STORE_FULL_DOCS}]"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doc_status_upsert_passes_timing_label():
|
||||
storage = make_doc_status_storage()
|
||||
await storage.upsert(
|
||||
{
|
||||
"doc-1": {
|
||||
"content_summary": "summary",
|
||||
"content_length": 12,
|
||||
"chunks_count": 1,
|
||||
"status": "processed",
|
||||
"file_path": "/a.txt",
|
||||
"chunks_list": ["chunk-1"],
|
||||
"metadata": {"source": "test"},
|
||||
"created_at": "2024-01-01T00:00:00+00:00",
|
||||
"updated_at": "2024-01-01T00:00:00+00:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert storage._retry_kwargs[0]["timing_label"] == (
|
||||
"test_ws PGDocStatusStorage.upsert"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# doc_status: content_hash tuple + COALESCE SQL guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doc_status_upsert_includes_content_hash():
|
||||
storage = make_doc_status_storage()
|
||||
await storage.upsert(
|
||||
{
|
||||
"doc-1": {
|
||||
"content_summary": "summary",
|
||||
"content_length": 12,
|
||||
"chunks_count": 1,
|
||||
"status": "processed",
|
||||
"file_path": "/uploads/a.[native-Fi].txt",
|
||||
"chunks_list": ["chunk-1"],
|
||||
"metadata": {"source": "test"},
|
||||
"content_hash": "abc123",
|
||||
"created_at": "2024-01-01T00:00:00+00:00",
|
||||
"updated_at": "2024-01-01T00:00:00+00:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
sql, rows = storage._captured[0]
|
||||
# content_hash should be present in the INSERT column list and tuple
|
||||
assert "content_hash" in sql
|
||||
row = rows[0]
|
||||
# Tuple layout: workspace, id, content_summary, content_length, chunks_count,
|
||||
# status, file_path, chunks_list, track_id, metadata, error_msg,
|
||||
# content_hash, created_at, updated_at
|
||||
assert row[6] == "/uploads/a.[native-Fi].txt"
|
||||
assert row[11] == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doc_status_upsert_missing_content_hash_is_none():
|
||||
"""Existing callers that do not pass content_hash still produce valid tuples."""
|
||||
storage = make_doc_status_storage()
|
||||
await storage.upsert(
|
||||
{
|
||||
"doc-1": {
|
||||
"content_summary": "summary",
|
||||
"content_length": 12,
|
||||
"chunks_count": 1,
|
||||
"status": "processed",
|
||||
"file_path": "/a.txt",
|
||||
"chunks_list": ["chunk-1"],
|
||||
"metadata": {"source": "test"},
|
||||
"created_at": "2024-01-01T00:00:00+00:00",
|
||||
"updated_at": "2024-01-01T00:00:00+00:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
_, rows = storage._captured[0]
|
||||
assert rows[0][11] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doc_status_upsert_sql_protects_existing_content_hash():
|
||||
"""The ON CONFLICT clause must COALESCE+NULLIF to preserve a previously
|
||||
set content_hash when a subsequent state-transition upsert carries no
|
||||
hash (None) or an empty string.
|
||||
|
||||
We assert this at the SQL-template level since the actual COALESCE
|
||||
behavior is executed by Postgres. The presence of the protective
|
||||
expression in the SQL is the single source of truth for the guarantee.
|
||||
"""
|
||||
storage = make_doc_status_storage()
|
||||
await storage.upsert(
|
||||
{
|
||||
"doc-1": {
|
||||
"content_summary": "summary",
|
||||
"content_length": 12,
|
||||
"chunks_count": 1,
|
||||
"status": "processed",
|
||||
"file_path": "/a.txt",
|
||||
"chunks_list": [],
|
||||
"metadata": {},
|
||||
"created_at": "2024-01-01T00:00:00+00:00",
|
||||
"updated_at": "2024-01-01T00:00:00+00:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
sql, _ = storage._captured[0]
|
||||
normalized = " ".join(sql.split()).lower()
|
||||
assert "coalesce(" in normalized
|
||||
assert "nullif(excluded.content_hash, '')" in normalized
|
||||
assert "lightrag_doc_status.content_hash" in normalized
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vector_flush_passes_timing_label():
|
||||
"""upsert() now buffers; the timing_label is emitted by the flush path
|
||||
that runs from index_done_callback() / finalize().
|
||||
"""
|
||||
storage = make_vector_storage(NameSpace.VECTOR_STORE_CHUNKS)
|
||||
await storage.upsert(
|
||||
{
|
||||
"chunk-1": {
|
||||
"tokens": 42,
|
||||
"chunk_order_index": 0,
|
||||
"full_doc_id": "doc-1",
|
||||
"content": "hello world",
|
||||
"file_path": "/a/b.txt",
|
||||
}
|
||||
}
|
||||
)
|
||||
# No retry call until flush.
|
||||
assert storage._retry_kwargs == []
|
||||
|
||||
await storage.index_done_callback()
|
||||
|
||||
assert storage._retry_kwargs[0]["timing_label"] == (
|
||||
f"test_ws PGVectorStorage.flush[{NameSpace.VECTOR_STORE_CHUNKS}]"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch limits: KV byte-budget split + delete chunking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kv_upsert_splits_by_payload_bytes():
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)
|
||||
# Disable the record cap; force a byte cap that no two rows can share.
|
||||
storage._max_upsert_records_per_batch = 0
|
||||
storage._max_upsert_payload_bytes = 200
|
||||
|
||||
data = {f"doc-{i}": {"content": "X" * 150, "file_path": ""} for i in range(3)}
|
||||
await storage.upsert(data)
|
||||
|
||||
# Each ~150-byte content row exceeds half the budget -> 3 separate batches.
|
||||
assert len(storage._captured) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kv_delete_splits_by_id_cap():
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)
|
||||
storage._max_delete_records_per_batch = 2
|
||||
|
||||
await storage.delete([f"doc-{i}" for i in range(5)])
|
||||
|
||||
# 5 ids / cap 2 => 3 bounded ANY($2) DELETE statements, all in ONE
|
||||
# transaction (a single _run_with_retry closure) for all-or-nothing semantics.
|
||||
assert len(storage._retry_kwargs) == 1
|
||||
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]
|
||||
assert all("DELETE FROM" in sql for sql, _ in storage._captured_execute)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kv_delete_chunks_share_one_transaction():
|
||||
"""All delete chunks run inside a single connection.transaction()."""
|
||||
storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)
|
||||
storage._max_delete_records_per_batch = 2
|
||||
|
||||
tx_calls = {"n": 0}
|
||||
|
||||
async def run(operation, **kwargs):
|
||||
mock_conn = AsyncMock()
|
||||
tx = AsyncMock()
|
||||
tx.__aenter__.return_value = tx
|
||||
tx.__aexit__.return_value = False
|
||||
|
||||
def _make_tx():
|
||||
tx_calls["n"] += 1
|
||||
return tx
|
||||
|
||||
mock_conn.transaction = MagicMock(side_effect=_make_tx)
|
||||
await operation(mock_conn)
|
||||
|
||||
storage.db._run_with_retry = AsyncMock(side_effect=run)
|
||||
|
||||
await storage.delete([f"doc-{i}" for i in range(5)])
|
||||
|
||||
# One transaction wrapping all 3 chunks, not one per chunk.
|
||||
assert tx_calls["n"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch limits: DocStatus record-cap split + delete chunking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _doc_status_payload(i: int) -> dict:
|
||||
return {
|
||||
"content_summary": f"summary {i}",
|
||||
"content_length": 10,
|
||||
"chunks_count": 1,
|
||||
"status": "processed",
|
||||
"file_path": f"/{i}.txt",
|
||||
"chunks_list": ["chunk-1"],
|
||||
"metadata": {"source": "test"},
|
||||
"created_at": "2024-01-01T00:00:00+00:00",
|
||||
"updated_at": "2024-01-01T00:00:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doc_status_upsert_splits_by_record_cap():
|
||||
storage = make_doc_status_storage()
|
||||
storage._max_upsert_records_per_batch = 2
|
||||
|
||||
data = {f"doc-{i}": _doc_status_payload(i) for i in range(5)}
|
||||
await storage.upsert(data)
|
||||
|
||||
# 5 records / cap 2 => 3 executemany calls.
|
||||
assert len(storage._captured) == 3
|
||||
assert [len(rows) for _, rows in storage._captured] == [2, 2, 1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doc_status_delete_splits_by_id_cap():
|
||||
storage = make_doc_status_storage()
|
||||
storage._max_delete_records_per_batch = 2
|
||||
|
||||
await storage.delete([f"doc-{i}" for i in range(5)])
|
||||
|
||||
# One transaction (single _run_with_retry) wrapping 3 bounded DELETEs.
|
||||
assert len(storage._retry_kwargs) == 1
|
||||
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]
|
||||
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
Unit tests for PGGraphStorage.upsert_edge Cypher query generation.
|
||||
|
||||
Verifies the Cypher query sent to AGE uses the OPTIONAL MATCH + DELETE +
|
||||
CREATE pattern with inline edge properties — the only reliable way to write
|
||||
edge properties in Apache AGE (SET r += {...}, ON CREATE/ON MATCH SET, and
|
||||
SET r.key = value all silently fail for DIRECTED edges).
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import asyncpg
|
||||
from tenacity import wait_none
|
||||
|
||||
from lightrag.kg.postgres_impl import (
|
||||
PGGraphQueryException,
|
||||
PGGraphStorage,
|
||||
_is_transient_graph_write_error,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_graph_storage() -> PGGraphStorage:
|
||||
"""Construct a PGGraphStorage instance with a mocked db."""
|
||||
storage = PGGraphStorage.__new__(PGGraphStorage)
|
||||
storage.workspace = "test_ws"
|
||||
storage.namespace = "test_graph"
|
||||
storage.graph_name = "test_graph"
|
||||
storage.__post_init__() # resolves chunk-level batch-limit attrs
|
||||
storage.db = MagicMock()
|
||||
return storage
|
||||
|
||||
|
||||
class _FakeConnection:
|
||||
"""Captures statements + args passed to a fake asyncpg connection."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def transaction(self):
|
||||
return _FakeTransaction()
|
||||
|
||||
async def execute(self, sql, *args):
|
||||
self.calls.append({"sql": sql, "args": args})
|
||||
return ""
|
||||
|
||||
|
||||
class _FakeTransaction:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
async def _capture_upsert_edge(storage: PGGraphStorage, src: str, tgt: str, edge_data):
|
||||
"""Invoke upsert_edge against a fake connection and return the captured calls."""
|
||||
conn = _FakeConnection()
|
||||
|
||||
async def fake_run_with_retry(operation, **_kwargs):
|
||||
return await operation(conn)
|
||||
|
||||
storage.db._run_with_retry = AsyncMock(side_effect=fake_run_with_retry)
|
||||
await storage.upsert_edge(src, tgt, edge_data)
|
||||
return conn.calls
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# upsert_edge: Cypher query correctness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edge_uses_delete_create_not_set():
|
||||
"""Cypher must use OPTIONAL MATCH + DELETE + CREATE — not SET-based update.
|
||||
|
||||
Apache AGE silently drops edge properties written via SET r += {...},
|
||||
SET r.key = value, and ON CREATE/ON MATCH SET. The only reliable pattern
|
||||
is to delete any existing edge and CREATE a new one with inline props.
|
||||
"""
|
||||
storage = make_graph_storage()
|
||||
calls = await _capture_upsert_edge(
|
||||
storage, "NodeA", "NodeB", {"weight": "1.0", "description": "test edge"}
|
||||
)
|
||||
|
||||
# The cypher statement is the third one (after the per-edge + shared locks).
|
||||
cypher_sql = calls[2]["sql"]
|
||||
|
||||
# The new query must not contain any SET-based edge update — those silently
|
||||
# fail against AGE. All edge props live inline in the CREATE clause.
|
||||
assert "SET r" not in cypher_sql, (
|
||||
f"Edge SET clauses are silently dropped by AGE: {cypher_sql}"
|
||||
)
|
||||
assert "ON CREATE SET" not in cypher_sql
|
||||
assert "ON MATCH SET" not in cypher_sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edge_contains_optional_match_delete_create():
|
||||
"""The Cypher query must use OPTIONAL MATCH + DELETE + CREATE with inline props."""
|
||||
storage = make_graph_storage()
|
||||
calls = await _capture_upsert_edge(storage, "Alice", "Bob", {"weight": "0.5"})
|
||||
|
||||
cypher_sql = calls[2]["sql"]
|
||||
assert "OPTIONAL MATCH (source)-[old:DIRECTED]-(target)" in cypher_sql
|
||||
assert "DELETE old" in cypher_sql
|
||||
assert "CREATE (source)-[r:DIRECTED" in cypher_sql
|
||||
assert "]->(target)" in cypher_sql
|
||||
# Edge properties must be inlined into the CREATE clause as a literal map.
|
||||
assert '`weight`: "0.5"' in cypher_sql
|
||||
assert "RETURN r" in cypher_sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edge_handles_empty_props():
|
||||
"""Empty edge_data must inline an empty literal map, not crash."""
|
||||
storage = make_graph_storage()
|
||||
calls = await _capture_upsert_edge(storage, "Alice", "Bob", {})
|
||||
|
||||
cypher_sql = calls[2]["sql"]
|
||||
assert "CREATE (source)-[r:DIRECTED {}]->(target)" in cypher_sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edge_uses_parameterized_match_ids():
|
||||
"""Source and target IDs must flow through Cypher parameters as agtype JSON."""
|
||||
storage = make_graph_storage()
|
||||
calls = await _capture_upsert_edge(storage, "Node A", "Node B", {"weight": "1.0"})
|
||||
|
||||
cypher_call = calls[2]
|
||||
cypher_sql = cypher_call["sql"]
|
||||
assert "entity_id: $src_id" in cypher_sql
|
||||
assert "entity_id: $tgt_id" in cypher_sql
|
||||
# Cypher params arrive as a single positional agtype JSON arg.
|
||||
params_json = cypher_call["args"][0]
|
||||
params = json.loads(params_json)
|
||||
assert params["src_id"] == "Node A"
|
||||
assert params["tgt_id"] == "Node B"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edge_serialises_with_advisory_lock():
|
||||
"""Concurrent upserts on the same edge must be serialised via pg_advisory_xact_lock.
|
||||
|
||||
OPTIONAL MATCH + DELETE + CREATE is not atomic on its own: two transactions
|
||||
could both pass the OPTIONAL MATCH and both run CREATE, leaving duplicate
|
||||
DIRECTED rows. We open a transaction and acquire a transaction-scoped
|
||||
advisory lock keyed on (graph_name, ordered (src_id, tgt_id)) before running
|
||||
the cypher upsert, so concurrent upserts of the same logical edge run
|
||||
serially without serialising independent graphs.
|
||||
|
||||
AGE refuses to plan a join against a cypher() call that contains a CREATE
|
||||
clause, so the lock cannot live in a CTE — it must be a separate statement
|
||||
on the same connection inside an explicit transaction.
|
||||
"""
|
||||
storage = make_graph_storage()
|
||||
calls = await _capture_upsert_edge(storage, "Alice", "Bob", {"weight": "0.5"})
|
||||
|
||||
# Three statements: per-edge exclusive lock, graph-wide shared lock, cypher.
|
||||
assert len(calls) == 3
|
||||
|
||||
lock_sql = calls[0]["sql"]
|
||||
assert "pg_advisory_xact_lock(" in lock_sql # exclusive (not _shared)
|
||||
# graph_name flows as $1 so independent AGE graphs in the same DB do not
|
||||
# serialise each other's edges.
|
||||
assert "$1::text || E'\\x01' ||" in lock_sql
|
||||
# Key must be order-independent for (src, tgt) so {A, B} and {B, A} collide
|
||||
# on the same lock (the OPTIONAL MATCH is undirected).
|
||||
assert "LEAST($2::text, $3::text)" in lock_sql
|
||||
assert "GREATEST($2::text, $3::text)" in lock_sql
|
||||
# Raw graph_name + node IDs flow as positional params — never interpolated.
|
||||
assert "test_graph" not in lock_sql
|
||||
assert "Alice" not in lock_sql and "Bob" not in lock_sql
|
||||
assert calls[0]["args"] == ("test_graph", "Alice", "Bob")
|
||||
|
||||
# Second statement: graph-wide SHARED lock keyed on graph_name only, so it
|
||||
# conflicts with the batch path's exclusive graph lock but not with other
|
||||
# single writers.
|
||||
shared_sql = calls[1]["sql"]
|
||||
assert "pg_advisory_xact_lock_shared" in shared_sql
|
||||
assert calls[1]["args"] == ("test_graph",)
|
||||
|
||||
# The cypher statement must not contain a lock — that would cause AGE to
|
||||
# reject the plan with "cypher create clause cannot be rescanned".
|
||||
cypher_sql = calls[2]["sql"]
|
||||
assert "pg_advisory_xact_lock" not in cypher_sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edge_lock_key_includes_graph_name():
|
||||
"""Advisory lock key must include graph_name so independent AGE graphs in
|
||||
the same PostgreSQL database don't serialise each other's edges.
|
||||
|
||||
Regression for the codex review on PR #3056: pg_advisory_xact_lock is
|
||||
database-wide, so hashing only (src, tgt) would make {Alice, Bob} in
|
||||
`graph_a` block {Alice, Bob} in `graph_b` even though they touch different
|
||||
AGE graph tables.
|
||||
"""
|
||||
storage_a = make_graph_storage()
|
||||
storage_a.graph_name = "graph_a"
|
||||
storage_b = make_graph_storage()
|
||||
storage_b.graph_name = "graph_b"
|
||||
|
||||
calls_a = await _capture_upsert_edge(storage_a, "Alice", "Bob", {})
|
||||
calls_b = await _capture_upsert_edge(storage_b, "Alice", "Bob", {})
|
||||
|
||||
# graph_name flows as the first positional arg into the lock SQL so the
|
||||
# hashed lock key differs between graphs even when (src, tgt) match.
|
||||
assert calls_a[0]["args"] == ("graph_a", "Alice", "Bob")
|
||||
assert calls_b[0]["args"] == ("graph_b", "Alice", "Bob")
|
||||
|
||||
# And the lock template references graph_name as $1, with the ID pair as
|
||||
# $2/$3 — keep the param order pinned so future refactors don't silently
|
||||
# swap them.
|
||||
lock_sql = calls_a[0]["sql"]
|
||||
assert "$1::text" in lock_sql
|
||||
assert "LEAST($2::text, $3::text)" in lock_sql
|
||||
assert "GREATEST($2::text, $3::text)" in lock_sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edge_lock_key_is_endpoint_order_independent():
|
||||
"""{A, B} and {B, A} must produce the same advisory lock key.
|
||||
|
||||
The lock SQL itself is identical across both call directions; only the
|
||||
positional args differ. LEAST/GREATEST inside the template then normalises
|
||||
them to the same hash input, so concurrent {A,B} and {B,A} writes collide
|
||||
on a single lock (matching the undirected OPTIONAL MATCH).
|
||||
"""
|
||||
storage = make_graph_storage()
|
||||
forward = await _capture_upsert_edge(storage, "Alice", "Bob", {})
|
||||
reverse = await _capture_upsert_edge(storage, "Bob", "Alice", {})
|
||||
|
||||
# Same lock SQL template for both directions.
|
||||
assert forward[0]["sql"] == reverse[0]["sql"]
|
||||
# graph_name first, then the endpoint pair in whatever order the caller
|
||||
# passed — LEAST/GREATEST canonicalises inside the SQL.
|
||||
assert forward[0]["args"][0] == reverse[0]["args"][0] == "test_graph"
|
||||
assert (
|
||||
set(forward[0]["args"][1:])
|
||||
== set(reverse[0]["args"][1:])
|
||||
== {
|
||||
"Alice",
|
||||
"Bob",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edge_wraps_transient_errors_for_retry(monkeypatch):
|
||||
"""Query-level transient errors must be wrapped in PGGraphQueryException so
|
||||
the outer @retry predicate can identify them and retry.
|
||||
|
||||
Regression: when upsert_edge was moved off self._query onto
|
||||
self.db._run_with_retry (to run the advisory lock + cypher in one
|
||||
transaction), the _query exception-wrapping path was bypassed. A raw
|
||||
asyncpg.DeadlockDetectedError surfacing from connection.execute would
|
||||
therefore fail _is_transient_graph_write_error's first guard
|
||||
(isinstance(exc, PGGraphQueryException)) and skip the retry loop, silently
|
||||
degrading concurrent ingestion under contention. This test pins the
|
||||
wrapping back in place and asserts the retry loop actually fires.
|
||||
"""
|
||||
# Make the retry loop fire with zero backoff so the test stays fast.
|
||||
monkeypatch.setattr(PGGraphStorage.upsert_edge.retry, "wait", wait_none())
|
||||
|
||||
storage = make_graph_storage()
|
||||
deadlock = asyncpg.exceptions.DeadlockDetectedError("simulated deadlock")
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_run_with_retry(_operation, **_kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise deadlock
|
||||
|
||||
storage.db._run_with_retry = AsyncMock(side_effect=fake_run_with_retry)
|
||||
|
||||
with pytest.raises(PGGraphQueryException) as excinfo:
|
||||
await storage.upsert_edge("Alice", "Bob", {"weight": "1.0"})
|
||||
|
||||
# The original asyncpg exception is preserved as __cause__ so the predicate
|
||||
# can introspect it via exc.__cause__.
|
||||
assert excinfo.value.__cause__ is deadlock
|
||||
# And the predicate now recognises this exception as retryable.
|
||||
assert _is_transient_graph_write_error(excinfo.value) is True
|
||||
# Retried up to stop_after_attempt(3) — proves the wrapping actually
|
||||
# engages the @retry loop rather than failing fast on the first attempt.
|
||||
assert call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edge_does_not_retry_non_transient_errors(monkeypatch):
|
||||
"""Non-transient errors must not be retried by the @retry loop.
|
||||
|
||||
The wrapping in upsert_edge unconditionally re-raises as
|
||||
PGGraphQueryException, but _is_transient_graph_write_error only returns
|
||||
True for a small set of asyncpg transient causes. A plain ValueError
|
||||
bubbling out of _run_with_retry should fail fast, not loop 3 times.
|
||||
"""
|
||||
monkeypatch.setattr(PGGraphStorage.upsert_edge.retry, "wait", wait_none())
|
||||
|
||||
storage = make_graph_storage()
|
||||
boom = ValueError("not a transient db error")
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_run_with_retry(_operation, **_kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise boom
|
||||
|
||||
storage.db._run_with_retry = AsyncMock(side_effect=fake_run_with_retry)
|
||||
|
||||
with pytest.raises(PGGraphQueryException) as excinfo:
|
||||
await storage.upsert_edge("Alice", "Bob", {"weight": "1.0"})
|
||||
|
||||
assert excinfo.value.__cause__ is boom
|
||||
assert _is_transient_graph_write_error(excinfo.value) is False
|
||||
assert call_count == 1
|
||||
|
||||
|
||||
async def _capture_upsert_edges_batch(storage: PGGraphStorage, edges):
|
||||
"""Run upsert_edges_batch against a fake connection; return captured calls."""
|
||||
conn = _FakeConnection()
|
||||
|
||||
async def fake_run_with_retry(operation, **_kwargs):
|
||||
return await operation(conn)
|
||||
|
||||
storage.db._run_with_retry = AsyncMock(side_effect=fake_run_with_retry)
|
||||
await storage.upsert_edges_batch(edges)
|
||||
return conn.calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edges_batch_iterates_in_sorted_order():
|
||||
"""A chunk emits edge cypher in canonical (LEAST, GREATEST) order regardless
|
||||
of insertion order (deterministic dedup / reproducible replay)."""
|
||||
storage = make_graph_storage()
|
||||
|
||||
# Insertion order intentionally non-canonical: B-A, C-A, D-A.
|
||||
calls = await _capture_upsert_edges_batch(
|
||||
storage,
|
||||
[
|
||||
("B", "A", {"weight": "1"}),
|
||||
("C", "A", {"weight": "2"}),
|
||||
("D", "A", {"weight": "3"}),
|
||||
],
|
||||
)
|
||||
|
||||
# The batch chunk takes ONE graph-wide advisory lock (keyed on graph_name
|
||||
# only, no per-edge endpoints), not a per-edge lock. Order is observed from
|
||||
# the cypher calls' bound (src_id, tgt_id) params, canonicalised.
|
||||
lock_calls = [c for c in calls if "pg_advisory_xact_lock" in c["sql"]]
|
||||
assert len(lock_calls) == 1
|
||||
assert lock_calls[0]["args"] == ("test_graph",)
|
||||
cypher_calls = [c for c in calls if "CREATE (source)-[r:DIRECTED" in c["sql"]]
|
||||
edge_pairs = [
|
||||
(json.loads(c["args"][0])["src_id"], json.loads(c["args"][0])["tgt_id"])
|
||||
for c in cypher_calls
|
||||
]
|
||||
canonical_keys = [tuple(sorted(pair)) for pair in edge_pairs]
|
||||
assert canonical_keys == sorted(canonical_keys)
|
||||
assert canonical_keys == [("A", "B"), ("A", "C"), ("A", "D")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_edges_batch_dedupes_last_write_wins():
|
||||
"""Reciprocal duplicates collapse to a single edge upsert carrying the
|
||||
LATEST edge_data, regardless of which orientation arrives last."""
|
||||
storage = make_graph_storage()
|
||||
|
||||
calls = await _capture_upsert_edges_batch(
|
||||
storage,
|
||||
[
|
||||
("A", "B", {"weight": "first"}),
|
||||
("B", "A", {"weight": "second"}), # reciprocal, wins
|
||||
],
|
||||
)
|
||||
|
||||
# Exactly one edge cypher (CREATE) ran, with the latest payload inlined and
|
||||
# the last write's orientation in the bound params.
|
||||
cypher_calls = [c for c in calls if "CREATE (source)-[r:DIRECTED" in c["sql"]]
|
||||
assert len(cypher_calls) == 1
|
||||
assert '"second"' in cypher_calls[0]["sql"]
|
||||
assert '"first"' not in cypher_calls[0]["sql"]
|
||||
params_json = cypher_calls[0]["args"][0]
|
||||
assert json.loads(params_json) == {"src_id": "B", "tgt_id": "A"}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
"""PGVectorStorage.get_by_id/get_by_ids must not leak the embedding column.
|
||||
|
||||
The SQL-fallback path used ``SELECT *`` and returned ``content_vector`` — a
|
||||
pgvector value the asyncpg codec materializes as a numpy array, which is not
|
||||
JSON-serializable. ``get_entity_info(include_vector_data=True)`` puts that row
|
||||
into the API response, so ``POST /graph/entity/edit`` raised a 500 during
|
||||
response serialization when every storage was PostgreSQL. Both read paths must
|
||||
strip ``content_vector`` to match the buffered shape and the other vector
|
||||
backends.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from lightrag.kg.postgres_impl import PGVectorStorage
|
||||
from lightrag.namespace import NameSpace
|
||||
from lightrag.utils import EmbeddingFunc
|
||||
|
||||
pytestmark = pytest.mark.offline
|
||||
|
||||
|
||||
async def _embed(texts, **kwargs):
|
||||
return np.array([[0.0, 0.0, 0.0] for _ in texts], dtype=np.float32)
|
||||
|
||||
|
||||
def _make_storage(row):
|
||||
storage = PGVectorStorage(
|
||||
namespace=NameSpace.VECTOR_STORE_ENTITIES,
|
||||
workspace="ws",
|
||||
global_config={
|
||||
"embedding_batch_num": 10,
|
||||
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.5},
|
||||
},
|
||||
embedding_func=EmbeddingFunc(
|
||||
embedding_dim=3, func=_embed, model_name="test_model"
|
||||
),
|
||||
)
|
||||
|
||||
async def _query(sql, params, multirows=False):
|
||||
return [row] if multirows else row
|
||||
|
||||
storage.db = AsyncMock()
|
||||
storage.db.query = AsyncMock(side_effect=_query)
|
||||
storage._flush_lock = asyncio.Lock()
|
||||
return storage
|
||||
|
||||
|
||||
def _row():
|
||||
# content_vector is a numpy array in production (pgvector codec).
|
||||
return {
|
||||
"id": "ent-1",
|
||||
"content": "Alice\ndescription",
|
||||
"entity_name": "Alice",
|
||||
"source_id": "chunk-1",
|
||||
"file_path": "doc.txt",
|
||||
"content_vector": np.array([0.1, 0.2, 0.3], dtype=np.float32),
|
||||
"created_at": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_strips_content_vector():
|
||||
storage = _make_storage(_row())
|
||||
|
||||
result = await storage.get_by_id("ent-1")
|
||||
|
||||
assert result is not None
|
||||
assert "content_vector" not in result
|
||||
assert result["content"] == "Alice\ndescription"
|
||||
assert result["id"] == "ent-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_ids_strips_content_vector():
|
||||
storage = _make_storage(_row())
|
||||
|
||||
results = await storage.get_by_ids(["ent-1"])
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0] is not None
|
||||
assert "content_vector" not in results[0]
|
||||
assert results[0]["content"] == "Alice\ndescription"
|
||||
Reference in New Issue
Block a user