chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:54 +08:00
commit 4a4a1fed67
721 changed files with 262090 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Test package markers for shared helper imports."""
View File
View File
+272
View File
@@ -0,0 +1,272 @@
import importlib
import sys
from types import SimpleNamespace
import bcrypt
import pytest
from fastapi import HTTPException
from starlette.status import HTTP_403_FORBIDDEN
from lightrag.api.passwords import BCRYPT_PASSWORD_PREFIX, hash_password
from lightrag.tools.hash_password import main as hash_password_main
from lightrag.utils import logger as lightrag_logger
def import_real_api_module(module_name: str):
sys.modules.pop(module_name, None)
package_name, _, child_name = module_name.rpartition(".")
package = sys.modules.get(package_name)
if package is not None and hasattr(package, child_name):
delattr(package, child_name)
return importlib.import_module(module_name)
@pytest.fixture
def auth_module(monkeypatch):
config = import_real_api_module("lightrag.api.config")
mock_global_args = SimpleNamespace(
token_secret="test-jwt-secret",
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="admin:admin_pass",
)
monkeypatch.setattr(config, "global_args", mock_global_args)
module = import_real_api_module("lightrag.api.auth")
module = importlib.reload(module)
yield module
sys.modules.pop("lightrag.api.auth", None)
def build_bcrypt_value(password: str) -> str:
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
return f"{BCRYPT_PASSWORD_PREFIX}{hashed}"
def test_verify_plaintext_password(auth_module):
handler = auth_module.AuthHandler()
handler.accounts = {"admin": "admin_pass"}
assert handler.verify_password("admin", "admin_pass")
assert not handler.verify_password("admin", "wrong_pass")
def test_verify_prefixed_bcrypt_password(auth_module):
handler = auth_module.AuthHandler()
handler.accounts = {"user": build_bcrypt_value("user_pass")}
assert handler.verify_password("user", "user_pass")
assert not handler.verify_password("user", "wrong_pass")
def test_plaintext_password_with_bcrypt_prefix_stays_plaintext(auth_module):
handler = auth_module.AuthHandler()
handler.accounts = {"user": "$2b$not-a-real-hash"}
assert handler.verify_password("user", "$2b$not-a-real-hash")
assert not handler.verify_password("user", "anything-else")
def test_invalid_auth_accounts_raises(monkeypatch):
config = import_real_api_module("lightrag.api.config")
mock_global_args = SimpleNamespace(
token_secret="test-jwt-secret",
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="admin",
)
monkeypatch.setattr(config, "global_args", mock_global_args)
with pytest.raises(ValueError, match="AUTH_ACCOUNTS must use"):
import_real_api_module("lightrag.api.auth")
sys.modules.pop("lightrag.api.auth", None)
def test_initialize_config_rejects_default_token_secret_with_auth_accounts():
config = import_real_api_module("lightrag.api.config")
insecure_args = SimpleNamespace(
auth_accounts="admin:admin_pass",
token_secret=config.DEFAULT_TOKEN_SECRET,
)
with pytest.raises(ValueError, match="TOKEN_SECRET must be explicitly set"):
config.initialize_config(insecure_args, force=True)
def test_initialize_config_allows_custom_token_secret_with_auth_accounts():
config = import_real_api_module("lightrag.api.config")
secure_args = SimpleNamespace(
auth_accounts="admin:admin_pass",
token_secret="custom-jwt-secret",
)
initialized = config.initialize_config(secure_args, force=True)
assert initialized is secure_args
def test_guest_tokens_fall_back_to_default_secret_when_token_secret_missing(
monkeypatch,
):
config = import_real_api_module("lightrag.api.config")
mock_global_args = SimpleNamespace(
token_secret=None,
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="",
)
monkeypatch.setattr(config, "global_args", mock_global_args)
warning_messages = []
def capture_warning(message):
warning_messages.append(message)
monkeypatch.setattr(lightrag_logger, "warning", capture_warning)
module = import_real_api_module("lightrag.api.auth")
module = importlib.reload(module)
handler = module.AuthHandler()
token = handler.create_token("guest", role="guest")
token_info = handler.validate_token(token)
assert handler.secret == config.DEFAULT_TOKEN_SECRET
assert token_info["username"] == "guest"
assert token_info["role"] == "guest"
assert any(
"Falling back to the default guest-mode JWT secret" in msg
for msg in warning_messages
)
sys.modules.pop("lightrag.api.auth", None)
def _load_api_key_only_modules(monkeypatch):
"""Import auth + utils_api in the API-key-only profile.
API-key-only = an API key is set but AUTH_ACCOUNTS is empty, so
``auth_configured`` is False and AuthHandler falls back to the public
DEFAULT_TOKEN_SECRET. Returns (config, auth, utils_api).
"""
config = import_real_api_module("lightrag.api.config")
mock_global_args = SimpleNamespace(
token_secret=None, # -> AuthHandler falls back to DEFAULT_TOKEN_SECRET
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="", # no password accounts -> auth_configured is False
whitelist_paths="/health", # consumed by utils_api at import time
token_auto_renew=False,
)
monkeypatch.setattr(config, "global_args", mock_global_args)
auth = import_real_api_module("lightrag.api.auth")
auth = importlib.reload(auth)
utils_api = import_real_api_module("lightrag.api.utils_api")
return config, auth, utils_api
async def test_combined_auth_rejects_guest_token_in_api_key_mode(monkeypatch):
"""A forged/obtained guest token must not bypass the X-API-Key check.
Regression for GHSA-f4vv-55c2-5789 / GHSA-xr5c-v5r6-c9f9: in the
API-key-only profile an anonymous caller can mint a guest JWT with the
public default secret (or fetch one from /auth-status) and previously
short-circuited authorization before the API key was ever checked.
"""
config, auth, utils_api = _load_api_key_only_modules(monkeypatch)
try:
assert utils_api.auth_configured is False
assert auth.auth_handler.secret == config.DEFAULT_TOKEN_SECRET
forged_guest = auth.auth_handler.create_token(username="guest", role="guest")
api_key = "operator-secret-key"
dependency = utils_api.get_combined_auth_dependency(api_key=api_key)
request = SimpleNamespace(url=SimpleNamespace(path="/documents"), scope={})
response = SimpleNamespace(headers={})
# Forged guest token, no API key -> rejected (was HTTP 200 before the fix).
with pytest.raises(HTTPException) as rejected:
await dependency(
request=request,
response=response,
token=forged_guest,
api_key_header_value=None,
)
assert rejected.value.status_code == HTTP_403_FORBIDDEN
# Guest token together with the real API key -> allowed (the WebUI sends
# both Authorization and X-API-Key headers).
result = await dependency(
request=request,
response=response,
token=forged_guest,
api_key_header_value=api_key,
)
assert result is None
finally:
sys.modules.pop("lightrag.api.utils_api", None)
sys.modules.pop("lightrag.api.auth", None)
async def test_combined_auth_allows_guest_token_when_fully_open(monkeypatch):
"""Fully-open mode (no API key, no AUTH_ACCOUNTS) keeps accepting guest tokens.
Guards against over-fixing: the bypass fix must not break zero-config guest
access, which is the intended convenience mode.
"""
_config, auth, utils_api = _load_api_key_only_modules(monkeypatch)
try:
guest_token = auth.auth_handler.create_token(username="guest", role="guest")
dependency = utils_api.get_combined_auth_dependency(api_key=None)
request = SimpleNamespace(url=SimpleNamespace(path="/documents"), scope={})
response = SimpleNamespace(headers={})
result = await dependency(
request=request,
response=response,
token=guest_token,
api_key_header_value=None,
)
assert result is None
finally:
sys.modules.pop("lightrag.api.utils_api", None)
sys.modules.pop("lightrag.api.auth", None)
def test_hash_password_returns_prefixed_value(auth_module):
hashed = hash_password("new_password")
assert hashed.startswith(BCRYPT_PASSWORD_PREFIX)
raw_hash = hashed[len(BCRYPT_PASSWORD_PREFIX) :]
assert bcrypt.checkpw("new_password".encode("utf-8"), raw_hash.encode("utf-8"))
def test_hash_password_cli_outputs_auth_accounts_entry(capsys):
exit_code = hash_password_main(["--username", "admin", "secret"])
assert exit_code == 0
output = capsys.readouterr().out.strip()
username, hashed = output.split(":", 1)
assert username == "admin"
assert hashed.startswith(BCRYPT_PASSWORD_PREFIX)
raw_hash = hashed[len(BCRYPT_PASSWORD_PREFIX) :]
assert bcrypt.checkpw("secret".encode("utf-8"), raw_hash.encode("utf-8"))
+403
View File
@@ -0,0 +1,403 @@
"""
Pytest unit tests for token auto-renewal functionality
Tests:
1. Backend token renewal logic
2. Rate limiting for token renewals
3. Token renewal state tracking
"""
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import Mock
from fastapi import Response
import time
# Create a simple token renewal cache for testing
_token_renewal_cache = {}
_RENEWAL_MIN_INTERVAL = 60
@pytest.mark.offline
class TestTokenRenewal:
"""Tests for token auto-renewal logic"""
@pytest.fixture
def mock_auth_handler(self):
"""Mock authentication handler"""
handler = Mock()
handler.guest_expire_hours = 24
handler.expire_hours = 24
handler.create_token = Mock(return_value="new-token-12345")
return handler
@pytest.fixture
def mock_global_args(self):
"""Mock global configuration"""
args = Mock()
args.token_auto_renew = True
args.token_renew_threshold = 0.5
return args
@pytest.fixture
def mock_token_info_guest(self):
"""Mock token info for guest user"""
# Token with 10 hours remaining (below 50% of 24 hours)
exp_time = datetime.now(timezone.utc) + timedelta(hours=10)
return {
"username": "guest",
"role": "guest",
"exp": exp_time,
"metadata": {"auth_mode": "disabled"},
}
@pytest.fixture
def mock_token_info_user(self):
"""Mock token info for regular user"""
# Token with 10 hours remaining (below 50% of 24 hours)
exp_time = datetime.now(timezone.utc) + timedelta(hours=10)
return {
"username": "testuser",
"role": "user",
"exp": exp_time,
"metadata": {"auth_mode": "enabled"},
}
@pytest.fixture
def mock_token_info_above_threshold(self):
"""Mock token info with time above renewal threshold"""
# Token with 20 hours remaining (above 50% of 24 hours)
exp_time = datetime.now(timezone.utc) + timedelta(hours=20)
return {
"username": "testuser",
"role": "user",
"exp": exp_time,
"metadata": {"auth_mode": "enabled"},
}
def test_token_renewal_when_below_threshold(
self, mock_auth_handler, mock_global_args, mock_token_info_user
):
"""Test that token is renewed when remaining time < threshold"""
# Use global cache
global _token_renewal_cache
# Clear cache
_token_renewal_cache.clear()
response = Mock(spec=Response)
response.headers = {}
# Simulate the renewal logic
expire_time = mock_token_info_user["exp"]
now = datetime.now(timezone.utc)
remaining_seconds = (expire_time - now).total_seconds()
role = mock_token_info_user["role"]
total_hours = (
mock_auth_handler.expire_hours
if role == "user"
else mock_auth_handler.guest_expire_hours
)
total_seconds = total_hours * 3600
# Should renew because remaining_seconds < total_seconds * 0.5
should_renew = (
remaining_seconds < total_seconds * mock_global_args.token_renew_threshold
)
assert should_renew is True
# Simulate renewal
username = mock_token_info_user["username"]
current_time = time.time()
last_renewal = _token_renewal_cache.get(username, 0)
time_since_last_renewal = current_time - last_renewal
# Should pass rate limit (first renewal)
assert time_since_last_renewal >= 60 or last_renewal == 0
# Perform renewal
new_token = mock_auth_handler.create_token(
username=username, role=role, metadata=mock_token_info_user["metadata"]
)
response.headers["X-New-Token"] = new_token
_token_renewal_cache[username] = current_time
# Verify
assert "X-New-Token" in response.headers
assert response.headers["X-New-Token"] == "new-token-12345"
assert username in _token_renewal_cache
def test_token_no_renewal_when_above_threshold(
self, mock_auth_handler, mock_global_args, mock_token_info_above_threshold
):
"""Test that token is NOT renewed when remaining time > threshold"""
response = Mock(spec=Response)
response.headers = {}
expire_time = mock_token_info_above_threshold["exp"]
now = datetime.now(timezone.utc)
remaining_seconds = (expire_time - now).total_seconds()
mock_token_info_above_threshold["role"]
total_hours = mock_auth_handler.expire_hours
total_seconds = total_hours * 3600
# Should NOT renew because remaining_seconds > total_seconds * 0.5
should_renew = (
remaining_seconds < total_seconds * mock_global_args.token_renew_threshold
)
assert should_renew is False
# No renewal should happen
assert "X-New-Token" not in response.headers
def test_token_renewal_disabled(
self, mock_auth_handler, mock_global_args, mock_token_info_user
):
"""Test that no renewal happens when TOKEN_AUTO_RENEW=false"""
mock_global_args.token_auto_renew = False
response = Mock(spec=Response)
response.headers = {}
# Auto-renewal is disabled, so even if below threshold, no renewal
if not mock_global_args.token_auto_renew:
# Skip renewal logic
pass
assert "X-New-Token" not in response.headers
def test_token_renewal_for_guest_mode(
self, mock_auth_handler, mock_global_args, mock_token_info_guest
):
"""Test that guest tokens are renewed correctly"""
# Use global cache
global _token_renewal_cache
_token_renewal_cache.clear()
response = Mock(spec=Response)
response.headers = {}
expire_time = mock_token_info_guest["exp"]
now = datetime.now(timezone.utc)
remaining_seconds = (expire_time - now).total_seconds()
role = mock_token_info_guest["role"]
total_hours = mock_auth_handler.guest_expire_hours
total_seconds = total_hours * 3600
should_renew = (
remaining_seconds < total_seconds * mock_global_args.token_renew_threshold
)
assert should_renew is True
# Renewal for guest
username = mock_token_info_guest["username"]
new_token = mock_auth_handler.create_token(
username=username, role=role, metadata=mock_token_info_guest["metadata"]
)
response.headers["X-New-Token"] = new_token
_token_renewal_cache[username] = time.time()
assert "X-New-Token" in response.headers
assert username in _token_renewal_cache
@pytest.mark.offline
class TestRateLimiting:
"""Tests for token renewal rate limiting"""
@pytest.fixture
def mock_auth_handler(self):
"""Mock authentication handler"""
handler = Mock()
handler.expire_hours = 24
handler.create_token = Mock(return_value="new-token-12345")
return handler
def test_rate_limit_prevents_rapid_renewals(self, mock_auth_handler):
"""Test that second renewal within 60s is blocked"""
# Use global cache and constant
global _token_renewal_cache, _RENEWAL_MIN_INTERVAL
username = "testuser"
_token_renewal_cache.clear()
# First renewal
current_time_1 = time.time()
_token_renewal_cache[username] = current_time_1
response_1 = Mock(spec=Response)
response_1.headers = {}
response_1.headers["X-New-Token"] = "new-token-12345"
# Immediate second renewal attempt (within 60s)
current_time_2 = time.time() # Almost same time
last_renewal = _token_renewal_cache.get(username, 0)
time_since_last_renewal = current_time_2 - last_renewal
# Should be blocked by rate limit
assert time_since_last_renewal < _RENEWAL_MIN_INTERVAL
response_2 = Mock(spec=Response)
response_2.headers = {}
# No new token should be issued
if time_since_last_renewal < _RENEWAL_MIN_INTERVAL:
# Rate limited, skip renewal
pass
assert "X-New-Token" not in response_2.headers
def test_rate_limit_allows_renewal_after_interval(self, mock_auth_handler):
"""Test that renewal succeeds after 60s interval"""
# Use global cache and constant
global _token_renewal_cache, _RENEWAL_MIN_INTERVAL
username = "testuser"
_token_renewal_cache.clear()
# First renewal at time T
first_renewal_time = time.time() - 61 # 61 seconds ago
_token_renewal_cache[username] = first_renewal_time
# Second renewal attempt now
current_time = time.time()
last_renewal = _token_renewal_cache.get(username, 0)
time_since_last_renewal = current_time - last_renewal
# Should pass rate limit (>60s elapsed)
assert time_since_last_renewal >= _RENEWAL_MIN_INTERVAL
response = Mock(spec=Response)
response.headers = {}
if time_since_last_renewal >= _RENEWAL_MIN_INTERVAL:
new_token = mock_auth_handler.create_token(
username=username, role="user", metadata={}
)
response.headers["X-New-Token"] = new_token
_token_renewal_cache[username] = current_time
assert "X-New-Token" in response.headers
assert response.headers["X-New-Token"] == "new-token-12345"
def test_rate_limit_per_user(self, mock_auth_handler):
"""Test that different users have independent rate limits"""
# Use global cache
global _token_renewal_cache
_token_renewal_cache.clear()
user1 = "user1"
user2 = "user2"
current_time = time.time()
# User1 gets renewal
_token_renewal_cache[user1] = current_time
# User2 should still be able to get renewal (independent cache)
last_renewal_user2 = _token_renewal_cache.get(user2, 0)
assert last_renewal_user2 == 0 # No previous renewal
# User2 can renew
_token_renewal_cache[user2] = current_time
# Both users should have entries
assert user1 in _token_renewal_cache
assert user2 in _token_renewal_cache
assert _token_renewal_cache[user1] == _token_renewal_cache[user2]
@pytest.mark.offline
class TestTokenExpirationCalculation:
"""Tests for token expiration time calculation"""
def test_expiration_extraction_from_jwt(self):
"""Test extracting expiration time from JWT token"""
import base64
import json
# Create a mock JWT payload
exp_timestamp = int(
(datetime.now(timezone.utc) + timedelta(hours=24)).timestamp()
)
payload = {"sub": "testuser", "role": "user", "exp": exp_timestamp}
# Encode as base64 (simulating JWT structure: header.payload.signature)
payload_b64 = base64.b64encode(json.dumps(payload).encode()).decode()
mock_token = f"header.{payload_b64}.signature"
# Simulate extraction
parts = mock_token.split(".")
assert len(parts) == 3
decoded_payload = json.loads(base64.b64decode(parts[1]))
assert decoded_payload["exp"] == exp_timestamp
assert decoded_payload["sub"] == "testuser"
def test_remaining_time_calculation(self):
"""Test calculation of remaining token time"""
# Token expires in 10 hours
exp_time = datetime.now(timezone.utc) + timedelta(hours=10)
now = datetime.now(timezone.utc)
remaining_seconds = (exp_time - now).total_seconds()
# Should be approximately 10 hours (36000 seconds)
assert 35990 < remaining_seconds < 36010
# Calculate percentage remaining (for 24-hour token)
total_seconds = 24 * 3600
percentage_remaining = remaining_seconds / total_seconds
# Should be approximately 41.67% remaining
assert 0.41 < percentage_remaining < 0.42
def test_threshold_comparison(self):
"""Test threshold-based renewal decision"""
threshold = 0.5
total_hours = 24
total_seconds = total_hours * 3600
# Scenario 1: 10 hours remaining -> should renew
remaining_seconds_1 = 10 * 3600
should_renew_1 = remaining_seconds_1 < total_seconds * threshold
assert should_renew_1 is True
# Scenario 2: 20 hours remaining -> should NOT renew
remaining_seconds_2 = 20 * 3600
should_renew_2 = remaining_seconds_2 < total_seconds * threshold
assert should_renew_2 is False
# Scenario 3: Exactly 12 hours remaining (at threshold) -> should NOT renew
remaining_seconds_3 = 12 * 3600
should_renew_3 = remaining_seconds_3 < total_seconds * threshold
assert should_renew_3 is False
@pytest.mark.offline
def test_renewal_cache_cleanup():
"""Test that renewal cache can be cleared"""
# Use global cache
global _token_renewal_cache
# Clear first
_token_renewal_cache.clear()
# Add some entries
_token_renewal_cache["user1"] = time.time()
_token_renewal_cache["user2"] = time.time()
assert len(_token_renewal_cache) == 2
# Clear cache
_token_renewal_cache.clear()
assert len(_token_renewal_cache) == 0
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
View File
+141
View File
@@ -0,0 +1,141 @@
import sys
import pytest
from lightrag.api.config import get_default_host, parse_args
pytestmark = pytest.mark.offline
def _clear_bedrock_auth_env(monkeypatch):
for key in (
"LLM_BINDING",
"EMBEDDING_BINDING",
"QUERY_LLM_BINDING",
"QUERY_LLM_BINDING_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_BEARER_TOKEN_BEDROCK",
"QUERY_AWS_ACCESS_KEY_ID",
"QUERY_AWS_SECRET_ACCESS_KEY",
):
monkeypatch.delenv(key, raising=False)
def test_bedrock_default_host_uses_sdk_default_endpoint(monkeypatch):
monkeypatch.delenv("LLM_BINDING_HOST", raising=False)
assert get_default_host("bedrock") == "DEFAULT_BEDROCK_ENDPOINT"
def test_bedrock_custom_host_is_returned(monkeypatch):
monkeypatch.setenv("LLM_BINDING_HOST", "https://proxy.example.com")
assert get_default_host("bedrock") == "https://proxy.example.com"
def test_bedrock_env_binding_alias_is_normalized(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "aws_bedrock")
monkeypatch.setenv("EMBEDDING_BINDING", "aws_bedrock")
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "absk-test")
args = parse_args()
assert args.llm_binding == "bedrock"
assert args.embedding_binding == "bedrock"
def test_bedrock_cli_binding_alias_is_not_supported(monkeypatch):
monkeypatch.setattr(
sys, "argv", ["lightrag-server", "--llm-binding", "aws_bedrock"]
)
with pytest.raises(SystemExit):
parse_args()
def test_bedrock_role_env_binding_alias_is_normalized(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("QUERY_LLM_BINDING", "aws_bedrock")
monkeypatch.setenv("QUERY_LLM_MODEL", "us.amazon.nova-lite-v1:0")
monkeypatch.setenv("QUERY_AWS_REGION", "us-west-2")
monkeypatch.setenv("QUERY_AWS_ACCESS_KEY_ID", "akid")
monkeypatch.setenv("QUERY_AWS_SECRET_ACCESS_KEY", "secret")
args = parse_args()
assert args.query_llm_binding == "bedrock"
assert args.query_aws_region == "us-west-2"
def test_bedrock_role_api_key_is_rejected(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("QUERY_LLM_BINDING", "bedrock")
monkeypatch.setenv("QUERY_LLM_MODEL", "us.amazon.nova-lite-v1:0")
monkeypatch.setenv("QUERY_LLM_BINDING_API_KEY", "absk-role")
with pytest.raises(SystemExit, match="does not support QUERY_LLM_BINDING_API_KEY"):
parse_args()
def test_bedrock_binding_requires_sigv4_pair_or_bearer_token(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "bedrock")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
with pytest.raises(ValueError, match="Bedrock LLM binding requires"):
parse_args()
def test_bedrock_binding_rejects_partial_sigv4_pair(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "bedrock")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "akid")
with pytest.raises(ValueError, match="Bedrock LLM binding requires"):
parse_args()
def test_bedrock_binding_accepts_sigv4_pair(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "bedrock")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "akid")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "secret")
args = parse_args()
assert args.llm_binding == "bedrock"
def test_bedrock_binding_accepts_bearer_token(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "bedrock")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "absk-test")
args = parse_args()
assert args.llm_binding == "bedrock"
def test_bedrock_role_requires_complete_role_sigv4_pair(monkeypatch):
_clear_bedrock_auth_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("QUERY_LLM_BINDING", "bedrock")
monkeypatch.setenv("QUERY_LLM_MODEL", "us.amazon.nova-lite-v1:0")
monkeypatch.setenv("QUERY_AWS_ACCESS_KEY_ID", "akid")
with pytest.raises(ValueError, match="Bedrock role 'query' requires"):
parse_args()
@@ -0,0 +1,12 @@
import pytest
from lightrag.api.config import get_default_host
pytestmark = pytest.mark.offline
def test_gemini_default_host_uses_sdk_default_endpoint(monkeypatch):
monkeypatch.delenv("LLM_BINDING_HOST", raising=False)
assert get_default_host("gemini") == "DEFAULT_GEMINI_ENDPOINT"
@@ -0,0 +1,95 @@
import sys
import pytest
from lightrag.api.config import parse_args
from lightrag.constants import DEFAULT_MAX_ASYNC
pytestmark = pytest.mark.offline
ROLE_MAX_ASYNC_ENV_KEYS = (
"MAX_ASYNC_LLM",
"MAX_ASYNC",
"EXTRACT_MAX_ASYNC_LLM",
"KEYWORD_MAX_ASYNC_LLM",
"QUERY_MAX_ASYNC_LLM",
"VLM_MAX_ASYNC_LLM",
)
def _clear_max_async_env(monkeypatch):
for key in ROLE_MAX_ASYNC_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
def test_role_max_async_defaults_none_when_env_unset(monkeypatch):
_clear_max_async_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("MAX_ASYNC", "10")
args = parse_args()
assert args.max_async == 10
assert args.extract_llm_max_async is None
assert args.keyword_llm_max_async is None
assert args.query_llm_max_async is None
assert args.vlm_llm_max_async is None
def test_role_max_async_env_override_keeps_other_roles_none(monkeypatch):
_clear_max_async_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("MAX_ASYNC", "10")
monkeypatch.setenv("EXTRACT_MAX_ASYNC_LLM", "7")
args = parse_args()
assert args.max_async == 10
assert args.extract_llm_max_async == 7
assert args.keyword_llm_max_async is None
assert args.query_llm_max_async is None
assert args.vlm_llm_max_async is None
def test_role_max_async_literal_none_string_is_preserved(monkeypatch):
_clear_max_async_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("MAX_ASYNC", "10")
monkeypatch.setenv("QUERY_MAX_ASYNC_LLM", "None")
args = parse_args()
assert args.max_async == 10
assert args.query_llm_max_async is None
def test_max_async_llm_new_name(monkeypatch):
_clear_max_async_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("MAX_ASYNC_LLM", "8")
args = parse_args()
assert args.max_async == 8
def test_max_async_llm_takes_precedence_over_legacy(monkeypatch):
_clear_max_async_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("MAX_ASYNC_LLM", "8")
monkeypatch.setenv("MAX_ASYNC", "10")
args = parse_args()
assert args.max_async == 8
def test_max_async_defaults_when_unset(monkeypatch):
_clear_max_async_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
args = parse_args()
assert args.max_async == DEFAULT_MAX_ASYNC
@@ -0,0 +1,111 @@
"""Offline tests for VLM_PROCESS_ENABLE and renamed role timeout vars."""
from __future__ import annotations
import sys
import pytest
from lightrag.api.config import parse_args
pytestmark = pytest.mark.offline
def _reset_vlm_env(monkeypatch):
for key in (
"VLM_PROCESS_ENABLE",
"VLM_LLM_BINDING",
"VLM_LLM_MODEL",
"VLM_LLM_BINDING_HOST",
"VLM_LLM_BINDING_API_KEY",
"VLM_LLM_TIMEOUT",
"EXTRACT_LLM_TIMEOUT",
"KEYWORD_LLM_TIMEOUT",
"QUERY_LLM_TIMEOUT",
"LLM_TIMEOUT_VLM_LLM",
"LLM_TIMEOUT_EXTRACT_LLM",
"LLM_TIMEOUT_KEYWORD_LLM",
"LLM_TIMEOUT_QUERY_LLM",
):
monkeypatch.delenv(key, raising=False)
def test_vlm_process_enable_defaults_to_false(monkeypatch):
_reset_vlm_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "openai")
args = parse_args()
assert args.vlm_process_enable is False
def test_vlm_process_enable_true_with_openai_passes(monkeypatch):
_reset_vlm_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "openai")
monkeypatch.setenv("VLM_PROCESS_ENABLE", "true")
args = parse_args()
assert args.vlm_process_enable is True
def test_vlm_process_enable_rejects_lollms_base_binding(monkeypatch):
_reset_vlm_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "lollms")
monkeypatch.setenv("VLM_PROCESS_ENABLE", "true")
with pytest.raises(SystemExit) as exc:
parse_args()
assert "lollms" in str(exc.value).lower()
def test_vlm_process_enable_rejects_lollms_role_binding(monkeypatch):
_reset_vlm_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "openai")
monkeypatch.setenv("VLM_PROCESS_ENABLE", "true")
monkeypatch.setenv("VLM_LLM_BINDING", "lollms")
# Cross-provider validation needs model + key; fill them so the lollms
# branch is the only failure path.
monkeypatch.setenv("VLM_LLM_MODEL", "anything")
monkeypatch.setenv("VLM_LLM_BINDING_HOST", "http://localhost:9600")
monkeypatch.setenv("VLM_LLM_BINDING_API_KEY", "placeholder")
with pytest.raises(SystemExit) as exc:
parse_args()
assert "lollms" in str(exc.value).lower()
def test_role_timeout_uses_new_variable_names(monkeypatch):
_reset_vlm_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "openai")
monkeypatch.setenv("EXTRACT_LLM_TIMEOUT", "240")
monkeypatch.setenv("KEYWORD_LLM_TIMEOUT", "120")
monkeypatch.setenv("QUERY_LLM_TIMEOUT", "60")
monkeypatch.setenv("VLM_LLM_TIMEOUT", "300")
args = parse_args()
assert args.extract_llm_timeout == 240
assert args.keyword_llm_timeout == 120
assert args.query_llm_timeout == 60
assert args.vlm_llm_timeout == 300
def test_role_timeout_legacy_variables_no_longer_have_effect(monkeypatch):
"""The breaking-change migration: legacy LLM_TIMEOUT_{ROLE}_LLM is silently ignored."""
_reset_vlm_env(monkeypatch)
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
monkeypatch.setenv("LLM_BINDING", "openai")
monkeypatch.setenv("LLM_TIMEOUT_EXTRACT_LLM", "999")
monkeypatch.setenv("LLM_TIMEOUT_VLM_LLM", "888")
args = parse_args()
assert args.extract_llm_timeout is None
assert args.vlm_llm_timeout is None
@@ -0,0 +1,80 @@
"""Offline tests for whitelist_exposes_api_routes (GHSA-mmg5-8x8q-v934 banner).
The startup banner uses this helper to warn when WHITELIST_PATHS leaves the
Ollama-compatible /api/* routes unauthenticated on a network-exposed bind. It
must mirror the prefix/exact matching in get_combined_auth_dependency so a
catch-all entry like "/*" is detected, not just literal "/api..." entries.
"""
from __future__ import annotations
import importlib
import sys
from types import SimpleNamespace
import pytest
pytestmark = pytest.mark.offline
@pytest.fixture
def whitelist_exposes_api_routes(monkeypatch):
"""Import utils_api with a stub global_args (consumed at import time)."""
config = importlib.import_module("lightrag.api.config")
monkeypatch.setattr(
config,
"global_args",
SimpleNamespace(
token_secret=None, # -> AuthHandler falls back to DEFAULT_TOKEN_SECRET
jwt_algorithm="HS256",
token_expire_hours=48,
guest_token_expire_hours=24,
auth_accounts="",
whitelist_paths="/health", # consumed by utils_api at import time
token_auto_renew=False,
),
)
sys.modules.pop("lightrag.api.auth", None)
importlib.reload(importlib.import_module("lightrag.api.auth"))
sys.modules.pop("lightrag.api.utils_api", None)
utils_api = importlib.import_module("lightrag.api.utils_api")
try:
yield utils_api.whitelist_exposes_api_routes
finally:
sys.modules.pop("lightrag.api.utils_api", None)
@pytest.mark.parametrize(
"whitelist",
[
"/health,/api/*", # default whitelist
"/api/*",
"/api",
"/api/chat", # exact Ollama route
"/*", # catch-all: empty prefix matches every path
" /* ", # catch-all with surrounding whitespace
"/health,/*",
"/a/*", # prefix that /api also starts with
],
)
def test_exposes_api_routes(whitelist_exposes_api_routes, whitelist: str) -> None:
assert whitelist_exposes_api_routes(whitelist) is True
@pytest.mark.parametrize(
"whitelist",
[
"/health",
"/health,/docs",
"/health/*", # unrelated prefix
"/apiary/*", # prefix under /api... but not /api/ — exempts only /apiary
"/apidocs", # exact path that merely shares the /api substring
"",
" ",
],
)
def test_does_not_expose_api_routes(
whitelist_exposes_api_routes, whitelist: str
) -> None:
assert whitelist_exposes_api_routes(whitelist) is False
View File
@@ -0,0 +1,781 @@
#!/usr/bin/env python3
"""
Test script: Demonstrates usage of aquery_data FastAPI endpoint
Query content: Who is the author of LightRAG
Updated to handle the new data format where:
- Response includes status, message, data, and metadata fields at top level
- Actual query results (entities, relationships, chunks, references) are nested under 'data' field
- Includes backward compatibility with legacy format
"""
import pytest
import requests
import time
import json
from typing import Dict, Any, List, Optional
# API configuration
API_KEY = "your-secure-api-key-here-123"
BASE_URL = "http://localhost:9621"
# Unified authentication headers
AUTH_HEADERS = {"Content-Type": "application/json", "X-API-Key": API_KEY}
def validate_references_format(references: List[Dict[str, Any]]) -> bool:
"""Validate the format of references list"""
if not isinstance(references, list):
print(f"❌ References should be a list, got {type(references)}")
return False
for i, ref in enumerate(references):
if not isinstance(ref, dict):
print(f"❌ Reference {i} should be a dict, got {type(ref)}")
return False
required_fields = ["reference_id", "file_path"]
for field in required_fields:
if field not in ref:
print(f"❌ Reference {i} missing required field: {field}")
return False
if not isinstance(ref[field], str):
print(
f"❌ Reference {i} field '{field}' should be string, got {type(ref[field])}"
)
return False
return True
def parse_streaming_response(
response_text: str,
) -> tuple[Optional[List[Dict]], List[str], List[str]]:
"""Parse streaming response and extract references, response chunks, and errors"""
references = None
response_chunks = []
errors = []
lines = response_text.strip().split("\n")
for line in lines:
line = line.strip()
if not line or line.startswith("data: "):
if line.startswith("data: "):
line = line[6:] # Remove 'data: ' prefix
if not line:
continue
try:
data = json.loads(line)
if "references" in data:
references = data["references"]
if "response" in data:
response_chunks.append(data["response"])
if "error" in data:
errors.append(data["error"])
except json.JSONDecodeError:
# Skip non-JSON lines (like SSE comments)
continue
return references, response_chunks, errors
@pytest.mark.integration
@pytest.mark.requires_api
def test_query_endpoint_references():
"""Test /query endpoint references functionality"""
print("\n" + "=" * 60)
print("Testing /query endpoint references functionality")
print("=" * 60)
query_text = "who authored LightRAG"
endpoint = f"{BASE_URL}/query"
# Test 1: References enabled (default)
print("\n🧪 Test 1: References enabled (default)")
print("-" * 40)
try:
response = requests.post(
endpoint,
json={"query": query_text, "mode": "mix", "include_references": True},
headers=AUTH_HEADERS,
timeout=30,
)
if response.status_code == 200:
data = response.json()
# Check response structure
if "response" not in data:
print("❌ Missing 'response' field")
return False
if "references" not in data:
print("❌ Missing 'references' field when include_references=True")
return False
references = data["references"]
if references is None:
print("❌ References should not be None when include_references=True")
return False
if not validate_references_format(references):
return False
print(f"✅ References enabled: Found {len(references)} references")
print(f" Response length: {len(data['response'])} characters")
# Display reference list
if references:
print(" 📚 Reference List:")
for i, ref in enumerate(references, 1):
ref_id = ref.get("reference_id", "Unknown")
file_path = ref.get("file_path", "Unknown")
print(f" {i}. ID: {ref_id} | File: {file_path}")
else:
print(f"❌ Request failed: {response.status_code}")
print(f" Error: {response.text}")
return False
except Exception as e:
print(f"❌ Test 1 failed: {str(e)}")
return False
# Test 2: References disabled
print("\n🧪 Test 2: References disabled")
print("-" * 40)
try:
response = requests.post(
endpoint,
json={"query": query_text, "mode": "mix", "include_references": False},
headers=AUTH_HEADERS,
timeout=30,
)
if response.status_code == 200:
data = response.json()
# Check response structure
if "response" not in data:
print("❌ Missing 'response' field")
return False
references = data.get("references")
if references is not None:
print("❌ References should be None when include_references=False")
return False
print("✅ References disabled: No references field present")
print(f" Response length: {len(data['response'])} characters")
else:
print(f"❌ Request failed: {response.status_code}")
print(f" Error: {response.text}")
return False
except Exception as e:
print(f"❌ Test 2 failed: {str(e)}")
return False
print("\n✅ /query endpoint references tests passed!")
return True
@pytest.mark.integration
@pytest.mark.requires_api
def test_query_stream_endpoint_references():
"""Test /query/stream endpoint references functionality"""
print("\n" + "=" * 60)
print("Testing /query/stream endpoint references functionality")
print("=" * 60)
query_text = "who authored LightRAG"
endpoint = f"{BASE_URL}/query/stream"
# Test 1: Streaming with references enabled
print("\n🧪 Test 1: Streaming with references enabled")
print("-" * 40)
try:
response = requests.post(
endpoint,
json={"query": query_text, "mode": "mix", "include_references": True},
headers=AUTH_HEADERS,
timeout=30,
stream=True,
)
if response.status_code == 200:
# Collect streaming response
full_response = ""
for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
if chunk:
# Ensure chunk is string type
if isinstance(chunk, bytes):
chunk = chunk.decode("utf-8")
full_response += chunk
# Parse streaming response
references, response_chunks, errors = parse_streaming_response(
full_response
)
if errors:
print(f"❌ Errors in streaming response: {errors}")
return False
if references is None:
print("❌ No references found in streaming response")
return False
if not validate_references_format(references):
return False
if not response_chunks:
print("❌ No response chunks found in streaming response")
return False
print(f"✅ Streaming with references: Found {len(references)} references")
print(f" Response chunks: {len(response_chunks)}")
print(
f" Total response length: {sum(len(chunk) for chunk in response_chunks)} characters"
)
# Display reference list
if references:
print(" 📚 Reference List:")
for i, ref in enumerate(references, 1):
ref_id = ref.get("reference_id", "Unknown")
file_path = ref.get("file_path", "Unknown")
print(f" {i}. ID: {ref_id} | File: {file_path}")
else:
print(f"❌ Request failed: {response.status_code}")
print(f" Error: {response.text}")
return False
except Exception as e:
print(f"❌ Test 1 failed: {str(e)}")
return False
# Test 2: Streaming with references disabled
print("\n🧪 Test 2: Streaming with references disabled")
print("-" * 40)
try:
response = requests.post(
endpoint,
json={"query": query_text, "mode": "mix", "include_references": False},
headers=AUTH_HEADERS,
timeout=30,
stream=True,
)
if response.status_code == 200:
# Collect streaming response
full_response = ""
for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
if chunk:
# Ensure chunk is string type
if isinstance(chunk, bytes):
chunk = chunk.decode("utf-8")
full_response += chunk
# Parse streaming response
references, response_chunks, errors = parse_streaming_response(
full_response
)
if errors:
print(f"❌ Errors in streaming response: {errors}")
return False
if references is not None:
print("❌ References should be None when include_references=False")
return False
if not response_chunks:
print("❌ No response chunks found in streaming response")
return False
print("✅ Streaming without references: No references present")
print(f" Response chunks: {len(response_chunks)}")
print(
f" Total response length: {sum(len(chunk) for chunk in response_chunks)} characters"
)
else:
print(f"❌ Request failed: {response.status_code}")
print(f" Error: {response.text}")
return False
except Exception as e:
print(f"❌ Test 2 failed: {str(e)}")
return False
print("\n✅ /query/stream endpoint references tests passed!")
return True
@pytest.mark.integration
@pytest.mark.requires_api
def test_references_consistency():
"""Test references consistency across all endpoints"""
print("\n" + "=" * 60)
print("Testing references consistency across endpoints")
print("=" * 60)
query_text = "who authored LightRAG"
query_params = {
"query": query_text,
"mode": "mix",
"top_k": 10,
"chunk_top_k": 8,
"include_references": True,
}
references_data = {}
# Test /query endpoint
print("\n🧪 Testing /query endpoint")
print("-" * 40)
try:
response = requests.post(
f"{BASE_URL}/query", json=query_params, headers=AUTH_HEADERS, timeout=30
)
if response.status_code == 200:
data = response.json()
references_data["query"] = data.get("references", [])
print(f"✅ /query: {len(references_data['query'])} references")
else:
print(f"❌ /query failed: {response.status_code}")
return False
except Exception as e:
print(f"❌ /query test failed: {str(e)}")
return False
# Test /query/stream endpoint
print("\n🧪 Testing /query/stream endpoint")
print("-" * 40)
try:
response = requests.post(
f"{BASE_URL}/query/stream",
json=query_params,
headers=AUTH_HEADERS,
timeout=30,
stream=True,
)
if response.status_code == 200:
full_response = ""
for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
if chunk:
# Ensure chunk is string type
if isinstance(chunk, bytes):
chunk = chunk.decode("utf-8")
full_response += chunk
references, _, errors = parse_streaming_response(full_response)
if errors:
print(f"❌ Errors: {errors}")
return False
references_data["stream"] = references or []
print(f"✅ /query/stream: {len(references_data['stream'])} references")
else:
print(f"❌ /query/stream failed: {response.status_code}")
return False
except Exception as e:
print(f"❌ /query/stream test failed: {str(e)}")
return False
# Test /query/data endpoint
print("\n🧪 Testing /query/data endpoint")
print("-" * 40)
try:
response = requests.post(
f"{BASE_URL}/query/data",
json=query_params,
headers=AUTH_HEADERS,
timeout=30,
)
if response.status_code == 200:
data = response.json()
query_data = data.get("data", {})
references_data["data"] = query_data.get("references", [])
print(f"✅ /query/data: {len(references_data['data'])} references")
else:
print(f"❌ /query/data failed: {response.status_code}")
return False
except Exception as e:
print(f"❌ /query/data test failed: {str(e)}")
return False
# Compare references consistency
print("\n🔍 Comparing references consistency")
print("-" * 40)
# Convert to sets of (reference_id, file_path) tuples for comparison
def refs_to_set(refs):
return set(
(ref.get("reference_id", ""), ref.get("file_path", "")) for ref in refs
)
query_refs = refs_to_set(references_data["query"])
stream_refs = refs_to_set(references_data["stream"])
data_refs = refs_to_set(references_data["data"])
# Check consistency
consistency_passed = True
if query_refs != stream_refs:
print("❌ References mismatch between /query and /query/stream")
print(f" /query only: {query_refs - stream_refs}")
print(f" /query/stream only: {stream_refs - query_refs}")
consistency_passed = False
if query_refs != data_refs:
print("❌ References mismatch between /query and /query/data")
print(f" /query only: {query_refs - data_refs}")
print(f" /query/data only: {data_refs - query_refs}")
consistency_passed = False
if stream_refs != data_refs:
print("❌ References mismatch between /query/stream and /query/data")
print(f" /query/stream only: {stream_refs - data_refs}")
print(f" /query/data only: {data_refs - stream_refs}")
consistency_passed = False
if consistency_passed:
print("✅ All endpoints return consistent references")
print(f" Common references count: {len(query_refs)}")
# Display common reference list
if query_refs:
print(" 📚 Common Reference List:")
for i, (ref_id, file_path) in enumerate(sorted(query_refs), 1):
print(f" {i}. ID: {ref_id} | File: {file_path}")
return consistency_passed
@pytest.mark.integration
@pytest.mark.requires_api
def test_aquery_data_endpoint():
"""Test the /query/data endpoint"""
# Use unified configuration
endpoint = f"{BASE_URL}/query/data"
# Query request
query_request = {
"query": "who authored LighRAG",
"mode": "mix", # Use mixed mode to get the most comprehensive results
"top_k": 20,
"chunk_top_k": 15,
"max_entity_tokens": 4000,
"max_relation_tokens": 4000,
"max_total_tokens": 16000,
"enable_rerank": True,
}
print("=" * 60)
print("LightRAG aquery_data endpoint test")
print(
" Returns structured data including entities, relationships and text chunks"
)
print(" Can be used for custom processing and analysis")
print("=" * 60)
print(f"Query content: {query_request['query']}")
print(f"Query mode: {query_request['mode']}")
print(f"API endpoint: {endpoint}")
print("-" * 60)
try:
# Send request
print("Sending request...")
start_time = time.time()
response = requests.post(
endpoint, json=query_request, headers=AUTH_HEADERS, timeout=30
)
end_time = time.time()
response_time = end_time - start_time
print(f"Response time: {response_time:.2f} seconds")
print(f"HTTP status code: {response.status_code}")
if response.status_code == 200:
data = response.json()
print_query_results(data)
else:
print(f"Request failed: {response.status_code}")
print(f"Error message: {response.text}")
except requests.exceptions.ConnectionError:
print("❌ Connection failed: Please ensure LightRAG API service is running")
print(" Start command: python -m lightrag.api.lightrag_server")
except requests.exceptions.Timeout:
print("❌ Request timeout: Query processing took too long")
except Exception as e:
print(f"❌ Error occurred: {str(e)}")
def print_query_results(data: Dict[str, Any]):
"""Format and print query results"""
# Check for new data format with status and message
status = data.get("status", "unknown")
message = data.get("message", "")
print(f"\n📋 Query Status: {status}")
if message:
print(f"📋 Message: {message}")
# Handle new nested data format
query_data = data.get("data", {})
# Fallback to old format if new format is not present
if not query_data and any(
key in data for key in ["entities", "relationships", "chunks"]
):
print(" (Using legacy data format)")
query_data = data
entities = query_data.get("entities", [])
relationships = query_data.get("relationships", [])
chunks = query_data.get("chunks", [])
references = query_data.get("references", [])
print("\n📊 Query result statistics:")
print(f" Entity count: {len(entities)}")
print(f" Relationship count: {len(relationships)}")
print(f" Text chunk count: {len(chunks)}")
print(f" Reference count: {len(references)}")
# Print metadata (now at top level in new format)
metadata = data.get("metadata", {})
if metadata:
print("\n🔍 Query metadata:")
print(f" Query mode: {metadata.get('query_mode', 'unknown')}")
keywords = metadata.get("keywords", {})
if keywords:
high_level = keywords.get("high_level", [])
low_level = keywords.get("low_level", [])
if high_level:
print(f" High-level keywords: {', '.join(high_level)}")
if low_level:
print(f" Low-level keywords: {', '.join(low_level)}")
processing_info = metadata.get("processing_info", {})
if processing_info:
print(" Processing info:")
for key, value in processing_info.items():
print(f" {key}: {value}")
# Print entity information
if entities:
print("\n👥 Retrieved entities (first 5):")
for i, entity in enumerate(entities[:5]):
entity_name = entity.get("entity_name", "Unknown")
entity_type = entity.get("entity_type", "Unknown")
description = entity.get("description", "No description")
file_path = entity.get("file_path", "Unknown source")
reference_id = entity.get("reference_id", "No reference")
print(f" {i + 1}. {entity_name} ({entity_type})")
print(
f" Description: {description[:100]}{'...' if len(description) > 100 else ''}"
)
print(f" Source: {file_path}")
print(f" Reference ID: {reference_id}")
print()
# Print relationship information
if relationships:
print("🔗 Retrieved relationships (first 5):")
for i, rel in enumerate(relationships[:5]):
src = rel.get("src_id", "Unknown")
tgt = rel.get("tgt_id", "Unknown")
description = rel.get("description", "No description")
keywords = rel.get("keywords", "No keywords")
file_path = rel.get("file_path", "Unknown source")
reference_id = rel.get("reference_id", "No reference")
print(f" {i + 1}. {src}{tgt}")
print(f" Keywords: {keywords}")
print(
f" Description: {description[:100]}{'...' if len(description) > 100 else ''}"
)
print(f" Source: {file_path}")
print(f" Reference ID: {reference_id}")
print()
# Print text chunk information
if chunks:
print("📄 Retrieved text chunks (first 3):")
for i, chunk in enumerate(chunks[:3]):
content = chunk.get("content", "No content")
file_path = chunk.get("file_path", "Unknown source")
chunk_id = chunk.get("chunk_id", "Unknown ID")
reference_id = chunk.get("reference_id", "No reference")
print(f" {i + 1}. Text chunk ID: {chunk_id}")
print(f" Source: {file_path}")
print(f" Reference ID: {reference_id}")
print(
f" Content: {content[:200]}{'...' if len(content) > 200 else ''}"
)
print()
# Print references information (new in updated format)
if references:
print("📚 References:")
for i, ref in enumerate(references):
reference_id = ref.get("reference_id", "Unknown ID")
file_path = ref.get("file_path", "Unknown source")
print(f" {i + 1}. Reference ID: {reference_id}")
print(f" File Path: {file_path}")
print()
print("=" * 60)
@pytest.mark.integration
@pytest.mark.requires_api
def compare_with_regular_query():
"""Compare results between regular query and data query"""
query_text = "LightRAG的作者是谁"
print("\n🔄 Comparison test: Regular query vs Data query")
print("-" * 60)
# Regular query
try:
print("1. Regular query (/query):")
regular_response = requests.post(
f"{BASE_URL}/query",
json={"query": query_text, "mode": "mix"},
headers=AUTH_HEADERS,
timeout=30,
)
if regular_response.status_code == 200:
regular_data = regular_response.json()
response_text = regular_data.get("response", "No response")
print(
f" Generated answer: {response_text[:300]}{'...' if len(response_text) > 300 else ''}"
)
else:
print(f" Regular query failed: {regular_response.status_code}")
if regular_response.status_code == 403:
print(" Authentication failed - Please check API Key configuration")
elif regular_response.status_code == 401:
print(" Unauthorized - Please check authentication information")
print(f" Error details: {regular_response.text}")
except Exception as e:
print(f" Regular query error: {str(e)}")
@pytest.mark.integration
@pytest.mark.requires_api
def run_all_reference_tests():
"""Run all reference-related tests"""
print("\n" + "🚀" * 20)
print("LightRAG References Test Suite")
print("🚀" * 20)
all_tests_passed = True
# Test 1: /query endpoint references
try:
if not test_query_endpoint_references():
all_tests_passed = False
except Exception as e:
print(f"❌ /query endpoint test failed with exception: {str(e)}")
all_tests_passed = False
# Test 2: /query/stream endpoint references
try:
if not test_query_stream_endpoint_references():
all_tests_passed = False
except Exception as e:
print(f"❌ /query/stream endpoint test failed with exception: {str(e)}")
all_tests_passed = False
# Test 3: References consistency across endpoints
try:
if not test_references_consistency():
all_tests_passed = False
except Exception as e:
print(f"❌ References consistency test failed with exception: {str(e)}")
all_tests_passed = False
# Final summary
print("\n" + "=" * 60)
print("TEST SUITE SUMMARY")
print("=" * 60)
if all_tests_passed:
print("🎉 ALL TESTS PASSED!")
print("✅ /query endpoint references functionality works correctly")
print("✅ /query/stream endpoint references functionality works correctly")
print("✅ References are consistent across all endpoints")
print("\n🔧 System is ready for production use with reference support!")
else:
print("❌ SOME TESTS FAILED!")
print("Please check the error messages above and fix the issues.")
print("\n🔧 System needs attention before production deployment.")
return all_tests_passed
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "--references-only":
# Run only the new reference tests
success = run_all_reference_tests()
sys.exit(0 if success else 1)
else:
# Run original tests plus new reference tests
print("Running original aquery_data endpoint test...")
test_aquery_data_endpoint()
print("\nRunning comparison test...")
compare_with_regular_query()
print("\nRunning new reference tests...")
run_all_reference_tests()
print("\n💡 Usage tips:")
print("1. Ensure LightRAG API service is running")
print("2. Adjust base_url and authentication information as needed")
print("3. Modify query parameters to test different retrieval strategies")
print("4. Data query results can be used for further analysis and processing")
print("5. Run with --references-only flag to test only reference functionality")
+271
View File
@@ -0,0 +1,271 @@
#!/bin/bash
# LightRAG aquery_data endpoint test script
# Use curl command to test the new /query/data endpoint and validate the new data format
echo "🚀 LightRAG aquery_data Endpoint Test (New Data Format Validation)"
echo "=================================================="
# Base URL (adjust according to actual deployment)
BASE_URL="http://localhost:9621"
# Color definitions
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Test result statistics
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
# Function to validate success response format
validate_success_response() {
local response="$1"
local test_name="$2"
local expected_mode="$3"
echo -e "${BLUE}Validating $test_name response format...${NC}"
# Check if valid JSON
if ! echo "$response" | jq . >/dev/null 2>&1; then
echo -e "${RED}❌ Response is not valid JSON format${NC}"
return 1
fi
# Validate required fields
local status=$(echo "$response" | jq -r '.status // "missing"')
local message=$(echo "$response" | jq -r '.message // "missing"')
local data_exists=$(echo "$response" | jq 'has("data")')
local metadata_exists=$(echo "$response" | jq 'has("metadata")')
echo " Status: $status"
echo " Message: $message"
# Validate data structure
if [[ "$data_exists" == "true" ]]; then
local entities_count=$(echo "$response" | jq '.data.entities | length // 0')
local relationships_count=$(echo "$response" | jq '.data.relationships | length // 0')
local chunks_count=$(echo "$response" | jq '.data.chunks | length // 0')
local references_count=$(echo "$response" | jq '.data.references | length // 0')
echo " Data.entities: $entities_count"
echo " Data.relationships: $relationships_count"
echo " Data.chunks: $chunks_count"
echo " Data.references: $references_count"
else
echo -e "${RED} ❌ Missing 'data' field${NC}"
return 1
fi
# Validate metadata
if [[ "$metadata_exists" == "true" ]]; then
local query_mode=$(echo "$response" | jq -r '.metadata.query_mode // "missing"')
local keywords_exists=$(echo "$response" | jq 'has("metadata") and (.metadata | has("keywords"))')
local processing_info_exists=$(echo "$response" | jq 'has("metadata") and (.metadata | has("processing_info"))')
echo " Metadata.query_mode: $query_mode"
echo " Metadata.keywords: $keywords_exists"
echo " Metadata.processing_info: $processing_info_exists"
# Validate if query mode matches
if [[ "$expected_mode" != "" && "$query_mode" != "$expected_mode" ]]; then
echo -e "${YELLOW} ⚠️ Query mode mismatch: expected '$expected_mode', actual '$query_mode'${NC}"
fi
else
echo -e "${RED} ❌ Missing 'metadata' field${NC}"
return 1
fi
# Validate status
if [[ "$status" == "success" ]]; then
echo -e "${GREEN} ✅ Response format validation passed${NC}"
return 0
else
echo -e "${RED} ❌ Status is not 'success': $status${NC}"
return 1
fi
}
# Function to validate error response format
validate_error_response() {
local response="$1"
local test_name="$2"
echo -e "${BLUE}Validating $test_name response format...${NC}"
# Check if valid JSON
if ! echo "$response" | jq . >/dev/null 2>&1; then
echo -e "${RED}❌ Response is not valid JSON format${NC}"
return 1
fi
# Validate required fields
local status=$(echo "$response" | jq -r '.status // "missing"')
local message=$(echo "$response" | jq -r '.message // "missing"')
local data_exists=$(echo "$response" | jq 'has("data")')
local metadata_exists=$(echo "$response" | jq 'has("metadata")')
echo " Status: $status"
echo " Message: $message"
# Validate basic structure exists
if [[ "$data_exists" != "true" ]]; then
echo -e "${RED} ❌ Missing 'data' field${NC}"
return 1
fi
if [[ "$metadata_exists" != "true" ]]; then
echo -e "${RED} ❌ Missing 'metadata' field${NC}"
return 1
fi
echo " Data: {}"
echo " Metadata: {}"
# Validate status should be failure
if [[ "$status" == "failure" ]]; then
echo -e "${GREEN} ✅ Error response format validation passed${NC}"
return 0
else
echo -e "${RED} ❌ Status is not 'failure': $status${NC}"
return 1
fi
}
# Function to run success test
run_success_test() {
local test_name="$1"
local query_data="$2"
local expected_mode="$3"
local print_json="${4:-false}" # Optional parameter: whether to print JSON response (default: false)
echo ""
echo "=================================="
echo -e "${BLUE}$test_name${NC}"
echo "=================================="
TOTAL_TESTS=$((TOTAL_TESTS + 1))
# Send request
echo "Sending request..."
local response=$(curl -s -X POST "${BASE_URL}/query/data" \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secure-api-key-here-123" \
-d "$query_data")
# Check if curl succeeded
if [[ $? -ne 0 ]]; then
echo -e "${RED}❌ Request failed - cannot connect to server${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
# Print JSON response if requested
if [[ "$print_json" == "true" ]]; then
echo ""
echo "Response JSON:"
echo "$response" | jq '.' 2>/dev/null || echo "$response"
echo ""
fi
# Validate response
if validate_success_response "$response" "$test_name" "$expected_mode"; then
PASSED_TESTS=$((PASSED_TESTS + 1))
echo -e "${GREEN}$test_name test passed${NC}"
else
FAILED_TESTS=$((FAILED_TESTS + 1))
echo -e "${RED}$test_name test failed${NC}"
echo "Raw response:"
echo "$response" | jq '.' 2>/dev/null || echo "$response"
fi
}
# Function to run error test
run_error_test() {
local test_name="$1"
local query_data="$2"
echo ""
echo "=================================="
echo -e "${BLUE}$test_name${NC}"
echo "=================================="
TOTAL_TESTS=$((TOTAL_TESTS + 1))
# Send request
echo "Sending request..."
local response=$(curl -s -X POST "${BASE_URL}/query/data" \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secure-api-key-here-123" \
-d "$query_data")
# Check if curl succeeded
if [[ $? -ne 0 ]]; then
echo -e "${RED}❌ Request failed - cannot connect to server${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
# Validate response
if validate_error_response "$response" "$test_name"; then
PASSED_TESTS=$((PASSED_TESTS + 1))
echo -e "${GREEN}$test_name test passed${NC}"
else
FAILED_TESTS=$((FAILED_TESTS + 1))
echo -e "${RED}$test_name test failed${NC}"
echo "Raw response:"
echo "$response" | jq '.' 2>/dev/null || echo "$response"
fi
}
# Start tests
echo "Starting tests for new /query/data endpoint data format..."
echo ""
# Test 1: Basic query test (mix mode)
run_success_test "1. Basic Query Test (mix mode)" '{
"query": "What is GraphRAG",
"mode": "mix",
"top_k": 5
}' "mix" "true" # Output full JSON
# Test 2: Detailed parameter query test (hybrid mode)
run_success_test "2. Detailed Parameter Query Test (hybrid mode)" '{
"query": "What is GraphRAG",
"mode": "hybrid",
"top_k": 5,
"chunk_top_k": 8,
"max_entity_tokens": 4000,
"max_relation_tokens": 4000,
"max_total_tokens": 16000,
"enable_rerank": true,
"response_type": "Multiple Paragraphs"
}' "hybrid"
# Output test result statistics
echo ""
echo "=================================================="
echo -e "${BLUE}Test Result Statistics${NC}"
echo "=================================================="
echo -e "Total tests: ${BLUE}$TOTAL_TESTS${NC}"
echo -e "Passed tests: ${GREEN}$PASSED_TESTS${NC}"
echo -e "Failed tests: ${RED}$FAILED_TESTS${NC}"
if [[ $FAILED_TESTS -eq 0 ]]; then
echo -e "${GREEN}🎉 All tests passed! New data format adaptation successful!${NC}"
exit 0
else
echo -e "${RED}⚠️ $FAILED_TESTS test(s) failed, please check the issues${NC}"
exit 1
fi
echo ""
echo "💡 Usage Instructions:"
echo "1. Ensure LightRAG API service is running (python -m lightrag.api.lightrag_server)"
echo "2. Adjust BASE_URL as needed"
echo "3. If authentication is required, add -H \"Authorization: Bearer your-token\""
echo "4. Install jq for better JSON formatting output: brew install jq (macOS) or apt install jq (Ubuntu)"
echo "5. Script will automatically validate new data format structure: status, message, data, metadata"
@@ -0,0 +1,403 @@
import pytest
from lightrag.constants import SOURCE_IDS_LIMIT_METHOD_KEEP
from lightrag.constants import GRAPH_FIELD_SEP
from lightrag.operate import (
_handle_single_entity_extraction,
_merge_nodes_then_upsert,
_normalize_text_extraction_record_attributes,
_handle_single_relationship_extraction,
)
from lightrag import utils_graph
from lightrag.utils import VectorStorageConsistencyError
class DummyGraphStorage:
def __init__(self, node=None):
self.node = node
self.upserted_nodes = []
async def get_node(self, node_id):
return self.node
async def upsert_node(self, node_id, node_data):
self.upserted_nodes.append((node_id, node_data))
self.node = dict(node_data)
class DummyVectorStorage:
def __init__(self):
self.global_config = {"workspace": "test"}
self.upserts = []
self.deletes = []
async def upsert(self, data):
self.upserts.append(data)
return None
async def delete(self, ids):
self.deletes.append(ids)
return None
async def get_by_id(self, id_):
return None
async def index_done_callback(self):
return True
class DummyAsyncContext:
async def __aenter__(self):
return None
async def __aexit__(self, exc_type, exc, tb):
return False
class DummyMergeGraphStorage:
def __init__(self):
self.nodes = {
"Canonical": {
"entity_id": "Canonical",
"description": "canonical desc",
"entity_type": "ORG",
"source_id": "chunk-1",
"file_path": "canonical.md",
},
"Alias": {
"entity_id": "Alias",
"description": "alias desc",
"entity_type": "ORG",
"source_id": "chunk-2",
"file_path": "alias.md",
},
"Neighbor": {
"entity_id": "Neighbor",
"description": "neighbor desc",
"entity_type": "ORG",
"source_id": "chunk-3",
"file_path": "neighbor.md",
},
}
self.edges = {
("Alias", "Neighbor"): {
"description": "rel desc",
"keywords": "alias",
"source_id": "chunk-rel",
"weight": 1.0,
"file_path": "rel.md",
}
}
async def has_node(self, node_id):
return node_id in self.nodes
async def get_node(self, node_id):
return self.nodes[node_id]
async def upsert_node(self, node_id, node_data):
self.nodes[node_id] = dict(node_data)
async def get_node_edges(self, node_id):
results = []
for src, tgt in self.edges:
if src == node_id or tgt == node_id:
results.append((src, tgt))
return results
async def get_edge(self, src, tgt):
return self.edges.get((src, tgt)) or self.edges.get((tgt, src))
async def upsert_edge(self, src, tgt, edge_data):
self.edges[(src, tgt)] = dict(edge_data)
async def delete_node(self, node_id):
self.nodes.pop(node_id, None)
self.edges = {
(src, tgt): data
for (src, tgt), data in self.edges.items()
if src != node_id and tgt != node_id
}
async def index_done_callback(self):
return True
@pytest.mark.asyncio
async def test_merge_nodes_then_upsert_handles_missing_legacy_description():
graph = DummyGraphStorage(node={"source_id": "chunk-1"})
global_config = {
"source_ids_limit_method": SOURCE_IDS_LIMIT_METHOD_KEEP,
"max_source_ids_per_entity": 20,
}
result = await _merge_nodes_then_upsert(
entity_name="LegacyEntity",
nodes_data=[],
knowledge_graph_inst=graph,
entity_vdb=None,
global_config=global_config,
)
assert result["description"] == "Entity LegacyEntity"
assert graph.upserted_nodes[-1][1]["description"] == "Entity LegacyEntity"
@pytest.mark.asyncio
async def test_acreate_entity_rejects_empty_description():
with pytest.raises(ValueError, match="description cannot be empty"):
await utils_graph.acreate_entity(
chunk_entity_relation_graph=None,
entities_vdb=None,
relationships_vdb=None,
entity_name="EntityA",
entity_data={"description": " "},
)
@pytest.mark.asyncio
async def test_acreate_relation_rejects_empty_description():
with pytest.raises(ValueError, match="description cannot be empty"):
await utils_graph.acreate_relation(
chunk_entity_relation_graph=None,
entities_vdb=None,
relationships_vdb=None,
source_entity="A",
target_entity="B",
relation_data={"description": ""},
)
@pytest.mark.asyncio
async def test_aedit_entity_rejects_empty_description():
with pytest.raises(ValueError, match="description cannot be empty"):
await utils_graph.aedit_entity(
chunk_entity_relation_graph=None,
entities_vdb=None,
relationships_vdb=None,
entity_name="EntityA",
updated_data={"description": None},
)
@pytest.mark.asyncio
async def test_aedit_relation_rejects_empty_description():
with pytest.raises(ValueError, match="description cannot be empty"):
await utils_graph.aedit_relation(
chunk_entity_relation_graph=None,
entities_vdb=None,
relationships_vdb=None,
source_entity="A",
target_entity="B",
updated_data={"description": " "},
)
@pytest.mark.asyncio
async def test_aedit_entity_allows_updates_without_description(monkeypatch):
async def fake_edit_impl(*args, **kwargs):
return {"entity_name": "EntityA", "description": "kept", "source_id": "chunk-1"}
monkeypatch.setattr(
utils_graph, "get_storage_keyed_lock", lambda *a, **k: DummyAsyncContext()
)
monkeypatch.setattr(utils_graph, "_edit_entity_impl", fake_edit_impl)
result = await utils_graph.aedit_entity(
chunk_entity_relation_graph=None,
entities_vdb=DummyVectorStorage(),
relationships_vdb=DummyVectorStorage(),
entity_name="EntityA",
updated_data={"entity_type": "ORG"},
)
assert result["operation_summary"]["operation_status"] == "success"
def test_handle_single_relationship_extraction_ignores_empty_description():
relation = _handle_single_relationship_extraction(
["relation", "Alice", "Bob", "works_with", " "],
chunk_key="chunk-1",
timestamp=1,
)
assert relation is None
def test_mis_prefixed_relation_row_is_recovered():
record = _normalize_text_extraction_record_attributes(
["entity", "Alice", "Acme Corp", "founded", "Alice founded Acme Corp."],
chunk_key="chunk-1",
)
relation = _handle_single_relationship_extraction(
record,
chunk_key="chunk-1",
timestamp=1,
)
assert relation is not None
assert relation["src_id"] == "Alice"
assert relation["tgt_id"] == "Acme Corp"
def test_four_part_entity_row_remains_entity():
record = _normalize_text_extraction_record_attributes(
["entity", "Alice", "Person", "Alice is the founder of Acme Corp."],
chunk_key="chunk-1",
)
entity = _handle_single_entity_extraction(
record,
chunk_key="chunk-1",
timestamp=1,
)
assert entity is not None
assert entity["entity_name"] == "Alice"
def test_malformed_recovered_relation_still_fails():
record = _normalize_text_extraction_record_attributes(
["entity", "Alice", "Acme Corp", "founded", " "],
chunk_key="chunk-1",
)
relation = _handle_single_relationship_extraction(
record,
chunk_key="chunk-1",
timestamp=1,
)
assert relation is None
def test_unrelated_five_part_prefix_remains_invalid():
record = _normalize_text_extraction_record_attributes(
["edge", "Alice", "Acme Corp", "founded", "Alice founded Acme Corp."],
chunk_key="chunk-1",
)
relation = _handle_single_relationship_extraction(
record,
chunk_key="chunk-1",
timestamp=1,
)
assert relation is None
@pytest.mark.asyncio
async def test_merge_entities_preserves_file_path_in_vector_updates(monkeypatch):
graph = DummyMergeGraphStorage()
entities_vdb = DummyVectorStorage()
relationships_vdb = DummyVectorStorage()
async def fake_get_entity_info(*args, **kwargs):
return {"entity_name": "Canonical"}
monkeypatch.setattr(utils_graph, "get_entity_info", fake_get_entity_info)
await utils_graph._merge_entities_impl(
chunk_entity_relation_graph=graph,
entities_vdb=entities_vdb,
relationships_vdb=relationships_vdb,
source_entities=["Alias", "Canonical"],
target_entity="Canonical",
)
relationship_payload = relationships_vdb.upserts[-1]
entity_payload = entities_vdb.upserts[-1]
assert next(iter(relationship_payload.values()))["file_path"] == "rel.md"
assert set(
next(iter(entity_payload.values()))["file_path"].split(GRAPH_FIELD_SEP)
) == {
"alias.md",
"canonical.md",
}
@pytest.mark.asyncio
async def test_merge_response_is_built_from_graph_without_reading_vdb():
# The merge response is built from graph data only. Reading the vector
# store is redundant (the graph is authoritative) and previously leaked a
# non-JSON-serializable embedding (pgvector -> numpy) into the API
# response, causing a 500 on /graph/entity/edit with all-PostgreSQL.
graph = DummyMergeGraphStorage()
entities_vdb = DummyVectorStorage()
relationships_vdb = DummyVectorStorage()
vdb_reads = []
inner_get_by_id = entities_vdb.get_by_id
async def _tracked_get_by_id(id_):
vdb_reads.append(id_)
return await inner_get_by_id(id_)
entities_vdb.get_by_id = _tracked_get_by_id
result = await utils_graph._merge_entities_impl(
chunk_entity_relation_graph=graph,
entities_vdb=entities_vdb,
relationships_vdb=relationships_vdb,
source_entities=["Alias", "Canonical"],
target_entity="Canonical",
)
assert "graph_data" in result
assert "vector_data" not in result
# The response did not read the entity vector store.
assert vdb_reads == []
@pytest.mark.asyncio
async def test_aedit_entity_merge_propagates_consistency_error(monkeypatch):
"""rename-with-merge must surface VectorStorageConsistencyError, not swallow it.
The graph is updated during the merge, then the (deferred) vector-store
flush fails. aedit_entity used to fold every merge exception into a
partial-success summary and return normally, so the /graph/entity/edit
route reported HTTP 200 "success" and hid the consistency failure that the
fail-loud merge path exists to surface. It must re-raise instead.
"""
graph = DummyMergeGraphStorage()
entities_vdb = DummyVectorStorage()
relationships_vdb = DummyVectorStorage()
# Deferred-embedding flush failure: upsert buffers, index_done_callback raises.
async def _boom():
raise RuntimeError("embedder down")
relationships_vdb.index_done_callback = _boom
# Single-attempt VDB wrapper (no retry delays) + a no-op storage lock.
async def _single_attempt(operation, **kwargs):
await operation()
monkeypatch.setattr(
utils_graph, "safe_vdb_operation_with_exception", _single_attempt
)
monkeypatch.setattr(
utils_graph, "get_storage_keyed_lock", lambda *a, **k: DummyAsyncContext()
)
async def fake_get_entity_info(*args, **kwargs):
return {"entity_name": "Canonical"}
monkeypatch.setattr(utils_graph, "get_entity_info", fake_get_entity_info)
with pytest.raises(VectorStorageConsistencyError) as excinfo:
await utils_graph.aedit_entity(
chunk_entity_relation_graph=graph,
entities_vdb=entities_vdb,
relationships_vdb=relationships_vdb,
entity_name="Alias",
updated_data={"entity_name": "Canonical"},
allow_rename=True,
allow_merge=True,
)
assert "lightrag-rebuild-vdb" in str(excinfo.value)
# Fail-loud happened before source deletion: 'Alias' is still in the graph.
assert "Alias" in graph.nodes
@@ -0,0 +1,694 @@
"""Tests for the `/documents/text(s)` ``chunking`` request object.
Three concerns:
1. **Synchronous validation**: malformed ``chunking`` is rejected at
request-parse time (HTTP 422 / ``ValidationError``) — never deferred to
the background indexing task, where the HTTP response is already sent.
The per-strategy typed params models do full type + value checking, not
just unknown-key detection.
2. **``_resolve_text_chunking``**: a validated ``chunking`` config is frozen
into ``(process_options, chunk_options)``; ``chunk_token_size`` and the
strategy params land in the selected strategy's sub-dict, overriding any
env-derived value, while the other strategy sub-dicts are dropped (slim).
3. **Route forwarding**: ``/documents/text`` and ``/documents/texts`` forward
``request.chunking`` to ``pipeline_index_texts`` and return 422 (without
scheduling any background work) for a malformed body.
"""
import importlib
import sys
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
_original_argv = sys.argv[:]
sys.argv = [sys.argv[0]]
_dr = importlib.import_module("lightrag.api.routers.document_routes")
sys.argv = _original_argv
TextChunkingConfig = _dr.TextChunkingConfig
InsertTextRequest = _dr.InsertTextRequest
_resolve_text_chunking = _dr._resolve_text_chunking
create_document_routes = _dr.create_document_routes
from lightrag.constants import ( # noqa: E402
PROCESS_OPTION_CHUNK_FIXED,
PROCESS_OPTION_CHUNK_PARAGRAH,
PROCESS_OPTION_CHUNK_RECURSIVE,
PROCESS_OPTION_CHUNK_VECTOR,
)
from lightrag.parser.routing import default_chunker_config # noqa: E402
pytestmark = pytest.mark.offline
_ALL_STRATEGY_KEYS = {
"fixed_token",
"recursive_character",
"semantic_vector",
"paragraph_semantic",
}
# ---------------------------------------------------------------------------
# 1. Synchronous validation
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"body",
[
# wrong types (strict rejects lax coercion)
{"strategy": "fixed_token", "params": {"chunk_token_size": True}},
{"strategy": "fixed_token", "params": {"chunk_token_size": "5"}},
{"strategy": "fixed_token", "params": {"chunk_token_size": 1.5}},
{"strategy": "fixed_token", "params": {"chunk_overlap_token_size": "bad"}},
{"strategy": "fixed_token", "params": {"split_by_character": 123}},
{"strategy": "fixed_token", "params": {"split_by_character_only": 1}},
{"strategy": "recursive_character", "params": {"separators": "abc"}},
{"strategy": "recursive_character", "params": {"separators": [1, 2]}},
# value / range
{"strategy": "fixed_token", "params": {"chunk_token_size": 0}},
{"strategy": "recursive_character", "params": {"chunk_overlap_token_size": -1}},
{"strategy": "semantic_vector", "params": {"buffer_size": 0}},
{"strategy": "semantic_vector", "params": {"buffer_size": True}},
{
"strategy": "semantic_vector",
"params": {"breakpoint_threshold_type": "p99"},
},
{
"strategy": "semantic_vector",
"params": {"breakpoint_threshold_amount": 0},
},
{
# strict float rejects strings (no lax numeric-string coercion)
"strategy": "semantic_vector",
"params": {"breakpoint_threshold_amount": "95"},
},
{
# strict float rejects bool (bool is an int subclass, undesirable here)
"strategy": "semantic_vector",
"params": {"breakpoint_threshold_amount": True},
},
{
# > 100 with an explicit percentile/gradient type is rejected at
# parse time (both fields present, no inheritance ambiguity).
"strategy": "semantic_vector",
"params": {
"breakpoint_threshold_type": "percentile",
"breakpoint_threshold_amount": 150,
},
},
{
# malformed regex must be compiled/rejected at parse time
"strategy": "semantic_vector",
"params": {"sentence_split_regex": "("},
},
# cross-field
{
"strategy": "fixed_token",
"params": {"chunk_token_size": 100, "chunk_overlap_token_size": 200},
},
# unknown / wrong-for-strategy keys
{"strategy": "fixed_token", "params": {"bogus": 1}},
{"strategy": "fixed_token", "params": {"separators": ["x"]}},
{"strategy": "recursive_character", "params": {"buffer_size": 1}},
],
)
def test_chunking_config_rejects_malformed(body):
with pytest.raises(ValidationError):
TextChunkingConfig.model_validate(body)
def test_chunking_config_defaults_to_fixed_token():
cfg = TextChunkingConfig.model_validate({"params": {"chunk_token_size": 500}})
assert cfg.strategy == "fixed_token"
assert cfg.params == {"chunk_token_size": 500}
def test_chunking_config_normalizes_to_supplied_keys_only():
# int amount is coerced to float; only the supplied key survives.
cfg = TextChunkingConfig.model_validate(
{"strategy": "semantic_vector", "params": {"breakpoint_threshold_amount": 95}}
)
assert cfg.params == {"breakpoint_threshold_amount": 95.0}
def test_chunking_config_amount_in_range_for_std_deviation():
# standard_deviation only requires > 0 (no [0, 100] ceiling).
cfg = TextChunkingConfig.model_validate(
{
"strategy": "semantic_vector",
"params": {
"breakpoint_threshold_type": "standard_deviation",
"breakpoint_threshold_amount": 3.5,
},
}
)
assert cfg.params["breakpoint_threshold_amount"] == 3.5
def test_chunking_config_amount_over_100_without_type_is_deferred():
# Type omitted -> the (0, 100] ceiling cannot be decided at parse time
# (the effective type may be inherited), so the model must NOT assume
# percentile and reject. _resolve_text_chunking applies the ceiling later.
cfg = TextChunkingConfig.model_validate(
{"strategy": "semantic_vector", "params": {"breakpoint_threshold_amount": 150}}
)
assert cfg.params == {"breakpoint_threshold_amount": 150.0}
def test_chunking_config_accepts_int_amount_widened_to_float():
# Strict float accepts an int (JSON 95) and widens it to 95.0 — the common
# documented threshold magnitude. (str/bool are rejected; see the
# rejection matrix above.) Exercised via both python and JSON validation
# modes so the FastAPI request path (which parses JSON) stays covered.
cfg = TextChunkingConfig.model_validate(
{"strategy": "semantic_vector", "params": {"breakpoint_threshold_amount": 95}}
)
assert cfg.params == {"breakpoint_threshold_amount": 95.0}
assert isinstance(cfg.params["breakpoint_threshold_amount"], float)
cfg_json = TextChunkingConfig.model_validate_json(
'{"strategy": "semantic_vector", "params": {"breakpoint_threshold_amount": 95}}'
)
assert cfg_json.params == {"breakpoint_threshold_amount": 95.0}
def test_chunking_config_accepts_valid_sentence_split_regex():
cfg = TextChunkingConfig.model_validate(
{
"strategy": "semantic_vector",
"params": {"sentence_split_regex": r"(?<=[.?!])\s+"},
}
)
assert cfg.params == {"sentence_split_regex": r"(?<=[.?!])\s+"}
def test_chunking_config_drops_explicit_null():
# Explicit null means "inherit the default" (every param field is
# Optional/None=inherit), so it must be dropped — not merged over the
# resolved default, which would later make the chunker do int(None).
cfg = TextChunkingConfig.model_validate(
{"strategy": "fixed_token", "params": {"chunk_token_size": None}}
)
assert cfg.params == {}
def test_chunking_config_keeps_real_value_drops_sibling_null():
cfg = TextChunkingConfig.model_validate(
{
"strategy": "fixed_token",
"params": {"chunk_token_size": 500, "split_by_character": None},
}
)
assert cfg.params == {"chunk_token_size": 500}
def test_insert_text_request_rejects_malformed_chunking():
with pytest.raises(ValidationError):
InsertTextRequest.model_validate(
{
"text": "hi",
"file_source": "a.md",
"chunking": {
"strategy": "recursive_character",
"params": {"separators": "notalist"},
},
}
)
# ---------------------------------------------------------------------------
# 2. _resolve_text_chunking
# ---------------------------------------------------------------------------
def _stub_rag(addon_params=None):
return SimpleNamespace(
addon_params=addon_params if addon_params is not None else {}
)
def test_resolve_none_keeps_default_fixed():
process_options, chunk_options = _resolve_text_chunking(None, _stub_rag())
assert process_options == PROCESS_OPTION_CHUNK_FIXED
assert "fixed_token" in chunk_options
@pytest.mark.parametrize(
"strategy,expected_po,key",
[
("fixed_token", PROCESS_OPTION_CHUNK_FIXED, "fixed_token"),
("recursive_character", PROCESS_OPTION_CHUNK_RECURSIVE, "recursive_character"),
("semantic_vector", PROCESS_OPTION_CHUNK_VECTOR, "semantic_vector"),
("paragraph_semantic", PROCESS_OPTION_CHUNK_PARAGRAH, "paragraph_semantic"),
],
)
def test_resolve_maps_strategy_and_writes_size_into_subdict(strategy, expected_po, key):
cfg = TextChunkingConfig.model_validate(
{"strategy": strategy, "params": {"chunk_token_size": 777}}
)
process_options, chunk_options = _resolve_text_chunking(cfg, _stub_rag())
assert process_options == expected_po
# chunk_token_size lands in the strategy sub-dict for ALL strategies
# (F included, post-cleanup) — that's where process_single_document reads it.
assert chunk_options[key]["chunk_token_size"] == 777
# slim contract: other strategies' sub-dicts are dropped
for other in _ALL_STRATEGY_KEYS - {key}:
assert other not in chunk_options
def test_resolve_merges_strategy_params():
cfg = TextChunkingConfig.model_validate(
{
"strategy": "recursive_character",
"params": {"separators": ["A", "B"], "chunk_overlap_token_size": 0},
}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag())
assert chunk_options["recursive_character"]["separators"] == ["A", "B"]
assert chunk_options["recursive_character"]["chunk_overlap_token_size"] == 0
def test_resolve_size_overrides_env_for_recursive(monkeypatch):
monkeypatch.setenv("CHUNK_R_SIZE", "999")
addon = {"chunker": default_chunker_config()}
# sanity: env baked into the R sub-dict
assert addon["chunker"]["recursive_character"]["chunk_token_size"] == 999
cfg = TextChunkingConfig.model_validate(
{"strategy": "recursive_character", "params": {"chunk_token_size": 1234}}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag(addon))
# API value wins over the env-derived sub-dict value.
assert chunk_options["recursive_character"]["chunk_token_size"] == 1234
def test_resolve_split_by_character_only_false_overrides_env(monkeypatch):
# The API path can express an explicit False (a plain dict merge), unlike
# the ainsert positional-arg path. Prove it overrides an env-True default.
monkeypatch.setenv("CHUNK_F_SPLIT_BY_CHARACTER_ONLY", "true")
addon = {"chunker": default_chunker_config()}
assert addon["chunker"]["fixed_token"]["split_by_character_only"] is True
cfg = TextChunkingConfig.model_validate(
{"strategy": "fixed_token", "params": {"split_by_character_only": False}}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag(addon))
assert chunk_options["fixed_token"]["split_by_character_only"] is False
def test_resolve_drop_references_request_false_overrides_env_true(monkeypatch):
# The JSON text API can express an explicit ``drop_references=False`` that
# overrides ``CHUNK_P_DROP_REFERENCES=true`` (resolved via slim_chunk_options
# then overlaid by the request params). Mirrors the split_by_character_only
# override above for the paragraph-semantic switch.
monkeypatch.setenv("CHUNK_P_DROP_REFERENCES", "true")
cfg = TextChunkingConfig.model_validate(
{"strategy": "paragraph_semantic", "params": {"drop_references": False}}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag())
assert chunk_options["paragraph_semantic"]["drop_references"] is False
def test_resolve_drop_references_env_true_without_request_param(monkeypatch):
# No request param → the env-true default survives into the snapshot.
monkeypatch.setenv("CHUNK_P_DROP_REFERENCES", "true")
cfg = TextChunkingConfig.model_validate({"strategy": "paragraph_semantic"})
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag())
assert chunk_options["paragraph_semantic"]["drop_references"] is True
def test_resolve_rejects_size_below_inherited_overlap(monkeypatch):
# Overlap is inherited from addon_params (not in the request), so the
# request model can't catch it — _resolve_text_chunking must.
monkeypatch.setenv("CHUNK_F_OVERLAP_SIZE", "100")
addon = {"chunker": default_chunker_config()}
assert addon["chunker"]["fixed_token"]["chunk_overlap_token_size"] == 100
cfg = TextChunkingConfig.model_validate(
{"strategy": "fixed_token", "params": {"chunk_token_size": 50}}
)
with pytest.raises(ValueError, match="chunk_overlap_token_size"):
_resolve_text_chunking(cfg, _stub_rag(addon))
def test_resolve_allows_size_above_inherited_overlap(monkeypatch):
monkeypatch.setenv("CHUNK_F_OVERLAP_SIZE", "100")
addon = {"chunker": default_chunker_config()}
cfg = TextChunkingConfig.model_validate(
{"strategy": "fixed_token", "params": {"chunk_token_size": 400}}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag(addon))
assert chunk_options["fixed_token"]["chunk_token_size"] == 400
def test_resolve_skips_overlap_check_for_delimiter_only(monkeypatch):
# Delimiter-only fixed-token chunking never uses overlap, so a small
# chunk_token_size below the inherited overlap must NOT be rejected.
monkeypatch.setenv("CHUNK_F_OVERLAP_SIZE", "100")
addon = {"chunker": default_chunker_config()}
cfg = TextChunkingConfig.model_validate(
{
"strategy": "fixed_token",
"params": {
"split_by_character": "\n\n",
"split_by_character_only": True,
"chunk_token_size": 50,
},
}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag(addon))
assert chunk_options["fixed_token"]["chunk_token_size"] == 50
def test_resolve_enforces_overlap_when_only_flag_without_delimiter(monkeypatch):
# split_by_character_only is a no-op without split_by_character: the chunker
# falls back to normal token windowing, which DOES use overlap — so the
# overlap < size check must still fire here.
monkeypatch.setenv("CHUNK_F_OVERLAP_SIZE", "100")
monkeypatch.delenv("CHUNK_F_SPLIT_BY_CHARACTER", raising=False)
addon = {"chunker": default_chunker_config()}
cfg = TextChunkingConfig.model_validate(
{
"strategy": "fixed_token",
"params": {"split_by_character_only": True, "chunk_token_size": 50},
}
)
with pytest.raises(ValueError, match="chunk_overlap_token_size"):
_resolve_text_chunking(cfg, _stub_rag(addon))
def test_resolve_allows_amount_over_100_with_inherited_std_type():
# Request overrides only the amount; the standard_deviation type is
# inherited from addon_params. std/iqr have no (0, 100] ceiling, so this
# must NOT be rejected (the request model deferred the check here).
addon = {
"chunker": {
"semantic_vector": {"breakpoint_threshold_type": "standard_deviation"}
}
}
cfg = TextChunkingConfig.model_validate(
{"strategy": "semantic_vector", "params": {"breakpoint_threshold_amount": 150}}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag(addon))
assert chunk_options["semantic_vector"]["breakpoint_threshold_amount"] == 150
assert (
chunk_options["semantic_vector"]["breakpoint_threshold_type"]
== "standard_deviation"
)
def test_resolve_rejects_amount_over_100_with_inherited_percentile_type():
# Same partial override, but the effective (inherited) type is percentile,
# which feeds np.percentile and requires the (0, 100] ceiling.
addon = {
"chunker": {"semantic_vector": {"breakpoint_threshold_type": "percentile"}}
}
cfg = TextChunkingConfig.model_validate(
{"strategy": "semantic_vector", "params": {"breakpoint_threshold_amount": 150}}
)
with pytest.raises(ValueError, match="breakpoint_threshold_amount"):
_resolve_text_chunking(cfg, _stub_rag(addon))
def test_resolve_null_size_does_not_erase_inherited_default(monkeypatch):
# An explicit null in the request must not overwrite the resolved size
# with None (which would make the chunker do int(None) in the background).
monkeypatch.setenv("CHUNK_F_SIZE", "640")
addon = {"chunker": default_chunker_config()}
cfg = TextChunkingConfig.model_validate(
{"strategy": "fixed_token", "params": {"chunk_token_size": None}}
)
_, chunk_options = _resolve_text_chunking(cfg, _stub_rag(addon))
# null dropped by the model -> inherited CHUNK_F_SIZE survives, no None.
assert chunk_options["fixed_token"]["chunk_token_size"] == 640
# ---------------------------------------------------------------------------
# 3. Route forwarding + synchronous 422
# ---------------------------------------------------------------------------
class _FwdDocStatus:
async def get_doc_by_file_basename(self, basename):
return None
class _FwdRag:
workspace = "chunk-fwd-test"
addon_params: dict = {}
def __init__(self):
self.doc_status = _FwdDocStatus()
_HEADERS = {"X-API-Key": "test-key"}
def _make_client(monkeypatch, addon_params=None):
"""Build a TestClient whose enqueue-slot guards are no-ops and whose
``pipeline_index_texts`` is a spy recording the forwarded args.
``addon_params`` seeds the rag the routes resolve chunking against; the
handler calls the real ``_resolve_text_chunking`` synchronously, so the
effective-overlap validation runs against this snapshot.
"""
captured: dict = {}
async def _spy(rag, texts, file_sources=None, track_id=None, chunking=None):
captured["texts"] = texts
captured["file_sources"] = file_sources
captured["chunking"] = chunking
async def _noop_reserve(rag):
return False
async def _noop_release(rag):
return None
monkeypatch.setattr(_dr, "pipeline_index_texts", _spy)
monkeypatch.setattr(_dr, "_reserve_enqueue_slot", _noop_reserve)
monkeypatch.setattr(_dr, "_release_enqueue_slot", _noop_release)
rag = _FwdRag()
rag.addon_params = addon_params if addon_params is not None else {}
app = FastAPI()
app.include_router(
create_document_routes(rag, SimpleNamespace(), api_key="test-key")
)
return TestClient(app), captured
def test_insert_text_forwards_chunking(monkeypatch):
client, captured = _make_client(monkeypatch)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello world",
"file_source": "a.md",
"chunking": {
"strategy": "recursive_character",
"params": {"chunk_token_size": 1000, "separators": ["X"]},
},
},
)
assert resp.status_code == 200
assert captured["chunking"] is not None
assert captured["chunking"].strategy == "recursive_character"
assert captured["chunking"].params == {
"chunk_token_size": 1000,
"separators": ["X"],
}
def test_insert_texts_forwards_chunking(monkeypatch):
client, captured = _make_client(monkeypatch)
resp = client.post(
"/documents/texts",
headers=_HEADERS,
json={
"texts": ["one", "two"],
"file_sources": ["a.md", "b.md"],
"chunking": {"strategy": "semantic_vector", "params": {"buffer_size": 2}},
},
)
assert resp.status_code == 200
assert captured["chunking"].strategy == "semantic_vector"
assert captured["chunking"].params == {"buffer_size": 2}
def test_insert_text_without_chunking_forwards_none(monkeypatch):
client, captured = _make_client(monkeypatch)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={"text": "hello", "file_source": "a.md"},
)
assert resp.status_code == 200
assert captured["chunking"] is None
def test_insert_text_returns_422_on_malformed_chunking_without_scheduling(monkeypatch):
client, captured = _make_client(monkeypatch)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {
"strategy": "recursive_character",
"params": {"separators": "notalist"},
},
},
)
assert resp.status_code == 422
# Body validation fails before the endpoint body runs: no background
# indexing is scheduled, so the spy never fires.
assert captured == {}
def test_insert_text_returns_422_when_size_below_inherited_overlap(monkeypatch):
# chunk_token_size=50 in the request, overlap=100 inherited from the
# rag's addon_params (not in the request). The model can't catch this;
# the handler's synchronous _resolve_text_chunking must, BEFORE any
# background work is scheduled.
addon = {
"chunker": {
"chunk_token_size": 1200,
"fixed_token": {"chunk_overlap_token_size": 100},
}
}
client, captured = _make_client(monkeypatch, addon_params=addon)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {"strategy": "fixed_token", "params": {"chunk_token_size": 50}},
},
)
assert resp.status_code == 422
assert "chunk_overlap_token_size" in resp.json()["detail"]
# Rejected synchronously: background indexing never scheduled.
assert captured == {}
def test_insert_text_allows_amount_override_inheriting_std_type(monkeypatch):
# Reviewer scenario: deployment sets standard_deviation; a request
# overrides only breakpoint_threshold_amount (> 100). This must be
# accepted (not 422), since std has no (0, 100] ceiling.
addon = {
"chunker": {
"semantic_vector": {"breakpoint_threshold_type": "standard_deviation"}
}
}
client, captured = _make_client(monkeypatch, addon_params=addon)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {
"strategy": "semantic_vector",
"params": {"breakpoint_threshold_amount": 150},
},
},
)
assert resp.status_code == 200
assert captured["chunking"].params == {"breakpoint_threshold_amount": 150.0}
def test_insert_text_rejects_amount_over_100_inheriting_percentile_type(monkeypatch):
addon = {
"chunker": {"semantic_vector": {"breakpoint_threshold_type": "percentile"}}
}
client, captured = _make_client(monkeypatch, addon_params=addon)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {
"strategy": "semantic_vector",
"params": {"breakpoint_threshold_amount": 150},
},
},
)
assert resp.status_code == 422
assert "breakpoint_threshold_amount" in resp.json()["detail"]
assert captured == {}
def test_insert_text_rejects_malformed_sentence_split_regex(monkeypatch):
# Malformed regex must 422 at request parse time, before scheduling.
client, captured = _make_client(monkeypatch)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {
"strategy": "semantic_vector",
"params": {"sentence_split_regex": "("},
},
},
)
assert resp.status_code == 422
assert captured == {}
def test_insert_text_drops_explicit_null_param(monkeypatch):
# "chunk_token_size": null must be treated as "inherit" (dropped), so the
# request succeeds and the forwarded params carry no None that would later
# crash the chunker with int(None).
client, captured = _make_client(monkeypatch)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {
"strategy": "fixed_token",
"params": {"chunk_token_size": None, "chunk_overlap_token_size": 50},
},
},
)
assert resp.status_code == 200
assert captured["chunking"].params == {"chunk_overlap_token_size": 50}
def test_insert_text_allows_small_size_for_delimiter_only(monkeypatch):
# Paragraph splitting with a small chunk_token_size: overlap is inherited
# (100) but unused in delimiter-only mode, so this must succeed, not 422.
addon = {"chunker": {"fixed_token": {"chunk_overlap_token_size": 100}}}
client, captured = _make_client(monkeypatch, addon_params=addon)
resp = client.post(
"/documents/text",
headers=_HEADERS,
json={
"text": "hello",
"file_source": "a.md",
"chunking": {
"strategy": "fixed_token",
"params": {
"split_by_character": "\n\n",
"split_by_character_only": True,
"chunk_token_size": 50,
},
},
},
)
assert resp.status_code == 200
assert captured["chunking"].params["chunk_token_size"] == 50
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
import importlib
import sys
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
_original_argv = sys.argv[:]
sys.argv = [sys.argv[0]]
_document_routes = importlib.import_module("lightrag.api.routers.document_routes")
_base = importlib.import_module("lightrag.base")
sys.argv = _original_argv
create_document_routes = _document_routes.create_document_routes
DocProcessingStatus = _base.DocProcessingStatus
DocStatus = _base.DocStatus
DocStatusStorage = _base.DocStatusStorage
pytestmark = pytest.mark.offline
def _doc(status: DocStatus, suffix: str) -> DocProcessingStatus:
return DocProcessingStatus(
content_summary=f"{status.value} summary",
content_length=10,
file_path=f"{suffix}.pdf",
status=status,
created_at="2024-01-01T00:00:00+00:00",
updated_at="2024-01-01T00:00:00+00:00",
metadata={},
)
class _FakeDocStatusStorage:
def __init__(self):
self.docs = {
"processed-doc": _doc(DocStatus.PROCESSED, "processed"),
"parsing-doc": _doc(DocStatus.PARSING, "parsing"),
"analyzing-doc": _doc(DocStatus.ANALYZING, "analyzing"),
}
async def get_docs_paginated(
self,
status_filter=None,
status_filters=None,
page=1,
page_size=50,
sort_field="updated_at",
sort_direction="desc",
):
selected_statuses = DocStatusStorage.resolve_status_filter_values(
status_filter=status_filter,
status_filters=status_filters,
)
documents = [
(doc_id, doc)
for doc_id, doc in self.docs.items()
if selected_statuses is None or doc.status.value in selected_statuses
]
return documents[:page_size], len(documents)
async def get_all_status_counts(self):
return {"processed": 1, "parsing": 1, "analyzing": 1}
_fake_doc_status = _FakeDocStatusStorage()
_app = FastAPI()
_app.include_router(
create_document_routes(
SimpleNamespace(doc_status=_fake_doc_status),
SimpleNamespace(),
api_key="test-key",
)
)
_client = TestClient(_app)
_headers = {"X-API-Key": "test-key"}
def test_documents_paginated_accepts_status_filter():
response = _client.post(
"/documents/paginated",
headers=_headers,
json={
"status_filter": "processed",
"page": 1,
"page_size": 10,
"sort_field": "updated_at",
"sort_direction": "desc",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["pagination"]["total_count"] == 1
assert [doc["id"] for doc in payload["documents"]] == ["processed-doc"]
def test_documents_paginated_status_filters_override_status_filter():
response = _client.post(
"/documents/paginated",
headers=_headers,
json={
"status_filter": "processed",
"status_filters": ["parsing", "analyzing"],
"page": 1,
"page_size": 10,
"sort_field": "updated_at",
"sort_direction": "desc",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["pagination"]["total_count"] == 2
assert [doc["id"] for doc in payload["documents"]] == [
"parsing-doc",
"analyzing-doc",
]
@@ -0,0 +1,298 @@
"""Pipeline-busy guard tests for graph mutation endpoints.
These tests verify that all 7 graph-mutation endpoints refuse to operate
with HTTP 409 while the document pipeline is busy:
- POST /graph/entity/edit (graph_routes)
- POST /graph/relation/edit (graph_routes)
- POST /graph/entity/create (graph_routes)
- POST /graph/relation/create (graph_routes)
- POST /graph/entities/merge (graph_routes)
- DELETE /graph/entity/delete (graph_routes)
- DELETE /graph/relation/delete (graph_routes)
The guard logic itself lives in
``lightrag.api.routers.document_routes.check_pipeline_busy_or_raise`` and is
exercised both at the endpoint integration layer (via monkeypatch, no
shared-storage dependency) and at the unit layer (against a real
``pipeline_status`` namespace).
"""
import importlib
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
# Importing routers loads ``lightrag.api.config`` which parses ``sys.argv`` via
# argparse. Stash argv so pytest's CLI flags don't trip the parser.
_original_argv = sys.argv[:]
sys.argv = [sys.argv[0]]
_graph_routes = importlib.import_module("lightrag.api.routers.graph_routes")
_document_routes = importlib.import_module("lightrag.api.routers.document_routes")
sys.argv = _original_argv
create_graph_routes = _graph_routes.create_graph_routes
create_document_routes = _document_routes.create_document_routes
check_pipeline_busy_or_raise = _document_routes.check_pipeline_busy_or_raise
pytestmark = pytest.mark.offline
_API_KEY = "test-key"
_HEADERS = {"X-API-Key": _API_KEY}
# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------
def _make_mock_rag() -> SimpleNamespace:
"""Build a minimal LightRAG stand-in with the 7 mutation methods stubbed.
Each ``AsyncMock`` returns a payload shaped enough to satisfy the
endpoint's response model so the idle pass-through test can verify the
full request path. Busy tests don't rely on these return values; the
guard short-circuits before they're reached.
"""
return SimpleNamespace(
workspace="",
aedit_entity=AsyncMock(
return_value={
"entity_name": "Alice",
"description": "updated",
"operation_summary": {
"merged": False,
"merge_status": "not_attempted",
"merge_error": None,
"operation_status": "success",
"target_entity": None,
"final_entity": "Alice",
"renamed": False,
},
}
),
aedit_relation=AsyncMock(return_value={"description": "updated"}),
acreate_entity=AsyncMock(return_value={"entity_name": "Alice"}),
acreate_relation=AsyncMock(return_value={"src_id": "a", "tgt_id": "b"}),
amerge_entities=AsyncMock(return_value={"merged_entity": "Alice"}),
adelete_by_entity=AsyncMock(
return_value=SimpleNamespace(
status="success", message="deleted", doc_id="ignored"
)
),
adelete_by_relation=AsyncMock(
return_value=SimpleNamespace(
status="success", message="deleted", doc_id="ignored"
)
),
)
def _build_client(rag: SimpleNamespace) -> TestClient:
app = FastAPI()
app.include_router(create_graph_routes(rag, api_key=_API_KEY))
app.include_router(create_document_routes(rag, SimpleNamespace(), api_key=_API_KEY))
return TestClient(app)
async def _force_busy_guard(_rag) -> None:
"""Stand-in for ``check_pipeline_busy_or_raise`` that always refuses."""
raise HTTPException(
status_code=409,
detail=(
"Pipeline is busy with another operation. "
"Wait for the running job to finish before editing "
"the knowledge graph."
),
)
async def _noop_guard(_rag) -> None:
"""Stand-in for ``check_pipeline_busy_or_raise`` that always permits."""
return None
def _patch_guard(monkeypatch, replacement) -> None:
"""Replace the guard reference in BOTH consumer modules.
``graph_routes`` re-binds the name via ``from .document_routes import ...``
so patching only ``document_routes`` would miss the graph endpoints.
"""
monkeypatch.setattr(_graph_routes, "check_pipeline_busy_or_raise", replacement)
monkeypatch.setattr(_document_routes, "check_pipeline_busy_or_raise", replacement)
# ---------------------------------------------------------------------------
# Part A: endpoint integration -- guard refuses with 409
# ---------------------------------------------------------------------------
_ENDPOINTS = [
pytest.param(
"POST",
"/graph/entity/edit",
{"entity_name": "Alice", "updated_data": {"description": "x"}},
id="update_entity",
),
pytest.param(
"POST",
"/graph/relation/edit",
{
"source_id": "Alice",
"target_id": "Bob",
"updated_data": {"description": "x"},
},
id="update_relation",
),
pytest.param(
"POST",
"/graph/entity/create",
{"entity_name": "Alice", "entity_data": {"description": "x"}},
id="create_entity",
),
pytest.param(
"POST",
"/graph/relation/create",
{
"source_entity": "Alice",
"target_entity": "Bob",
"relation_data": {"description": "x"},
},
id="create_relation",
),
pytest.param(
"POST",
"/graph/entities/merge",
{"entities_to_change": ["Alic"], "entity_to_change_into": "Alice"},
id="merge_entities",
),
pytest.param(
"DELETE",
"/graph/entity/delete",
{"entity_name": "Alice"},
id="delete_entity",
),
pytest.param(
"DELETE",
"/graph/relation/delete",
{"source_entity": "Alice", "target_entity": "Bob"},
id="delete_relation",
),
]
@pytest.mark.parametrize("method, path, body", _ENDPOINTS)
def test_endpoint_refuses_with_409_when_pipeline_busy(method, path, body, monkeypatch):
rag = _make_mock_rag()
client = _build_client(rag)
_patch_guard(monkeypatch, _force_busy_guard)
response = client.request(method, path, json=body, headers=_HEADERS)
assert response.status_code == 409, response.text
payload = response.json()
assert "Pipeline is busy" in payload["detail"]
# Guard must short-circuit before the underlying mutation runs.
for attr in (
"aedit_entity",
"aedit_relation",
"acreate_entity",
"acreate_relation",
"amerge_entities",
"adelete_by_entity",
"adelete_by_relation",
):
getattr(rag, attr).assert_not_awaited()
def test_endpoint_passes_through_when_pipeline_idle(monkeypatch):
"""Sanity check: with an idle guard, the request reaches ``rag.aedit_entity``."""
rag = _make_mock_rag()
client = _build_client(rag)
_patch_guard(monkeypatch, _noop_guard)
response = client.post(
"/graph/entity/edit",
json={"entity_name": "Alice", "updated_data": {"description": "x"}},
headers=_HEADERS,
)
assert response.status_code == 200, response.text
rag.aedit_entity.assert_awaited_once()
# ---------------------------------------------------------------------------
# Part B: helper unit -- against real pipeline_status namespace
# ---------------------------------------------------------------------------
async def _with_pipeline_status(action):
"""Bootstrap pipeline_status, run ``action(pipeline_status)``, then tear down.
``initialize_share_data`` is idempotent within a process but
``finalize_share_data`` is required to release the Manager/lock state so
repeated calls in subsequent tests start clean.
"""
from lightrag.kg.shared_storage import (
finalize_share_data,
get_namespace_data,
initialize_pipeline_status,
initialize_share_data,
)
initialize_share_data()
try:
await initialize_pipeline_status(workspace="")
pipeline_status = await get_namespace_data("pipeline_status", workspace="")
await action(pipeline_status)
finally:
finalize_share_data()
async def test_helper_raises_409_when_busy_flag_set():
async def _do(pipeline_status):
pipeline_status["busy"] = True
rag = SimpleNamespace(workspace="")
with pytest.raises(HTTPException) as exc_info:
await check_pipeline_busy_or_raise(rag)
assert exc_info.value.status_code == 409
assert "Pipeline is busy" in exc_info.value.detail
await _with_pipeline_status(_do)
async def test_helper_returns_silently_when_pipeline_idle():
async def _do(pipeline_status):
pipeline_status["busy"] = False
rag = SimpleNamespace(workspace="")
# Should not raise.
await check_pipeline_busy_or_raise(rag)
await _with_pipeline_status(_do)
async def test_helper_is_noop_when_pipeline_status_uninitialized():
"""When pipeline_status namespace was never bootstrapped the helper must pass.
``get_namespace_data`` raises ``PipelineNotInitializedError`` when the
pipeline_status namespace is missing (share data initialized but the
pipeline namespace never created); the helper swallows that error so test
rigs without an end-to-end RAG bootstrap stay green. Mirrors the existing
contract of ``_acquire_destructive_busy``.
"""
from lightrag.kg.shared_storage import (
finalize_share_data,
initialize_share_data,
)
initialize_share_data()
try:
rag = SimpleNamespace(workspace="__never_bootstrapped__")
# Intentionally skip ``initialize_pipeline_status``: helper should
# catch ``PipelineNotInitializedError`` and return silently.
await check_pipeline_busy_or_raise(rag)
finally:
finalize_share_data()
@@ -0,0 +1,174 @@
"""
Verify the /query and /query/stream endpoint response types.
Ensures:
- /query → application/json (no streaming, backward-compatible)
- /query/stream → application/x-ndjson
"""
import sys
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
_ENV_VARS_TO_ISOLATE = (
"LLM_BINDING",
"EMBEDDING_BINDING",
"LLM_BINDING_HOST",
"LLM_BINDING_API_KEY",
"LLM_MODEL",
"EMBEDDING_BINDING_HOST",
"EMBEDDING_BINDING_API_KEY",
"EMBEDDING_MODEL",
"LIGHTRAG_API_PREFIX",
"LIGHTRAG_KV_STORAGE",
"LIGHTRAG_VECTOR_STORAGE",
"LIGHTRAG_GRAPH_STORAGE",
"LIGHTRAG_DOC_STATUS_STORAGE",
)
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
for var in _ENV_VARS_TO_ISOLATE:
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("LLM_BINDING", "ollama")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
def _build_client():
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server"]
from lightrag.api.config import parse_args
from lightrag.api.lightrag_server import create_app
args = parse_args()
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
return TestClient(create_app(args))
finally:
sys.argv = original_argv
class TestQueryRouteJsonOnly:
"""The /query endpoint must stay JSON-only to preserve backward compatibility."""
def test_openapi_spec_declares_json_response(self):
client = _build_client()
response = client.get("/openapi.json")
assert response.status_code == 200
spec = response.json()
paths = spec.get("paths", {})
query_path = paths.get("/query", {})
assert query_path, "/query must be in OpenAPI paths"
post_op = query_path.get("post", {})
responses = post_op.get("responses", {})
ok_resp = responses.get("200", {})
content = ok_resp.get("content", {})
# The /query endpoint must declare application/json — NOT ndjson
assert "application/json" in content, (
"/query must declare application/json in OpenAPI spec"
)
assert "application/x-ndjson" not in content, (
"/query must NOT declare application/x-ndjson — streaming belongs to /query/stream"
)
def test_query_route_exists_and_accepts_post(self):
client = _build_client()
# A minimal POST to /query should reach the route (it'll 422 or 500
# since we don't have a real LLM, but it should NOT 404/405)
response = client.post("/query", json={"query": "test", "mode": "mix"})
assert response.status_code not in (
404,
405,
), "/query route must exist and accept POST"
class TestQueryStreamRoute:
"""The /query/stream endpoint must serve application/x-ndjson."""
def test_openapi_spec_declares_ndjson_response(self):
client = _build_client()
response = client.get("/openapi.json")
assert response.status_code == 200
spec = response.json()
paths = spec.get("paths", {})
stream_path = paths.get("/query/stream", {})
assert stream_path, "/query/stream must be in OpenAPI paths"
post_op = stream_path.get("post", {})
responses = post_op.get("responses", {})
ok_resp = responses.get("200", {})
content = ok_resp.get("content", {})
# The /query/stream endpoint must declare application/x-ndjson
assert "application/x-ndjson" in content, (
"/query/stream must declare application/x-ndjson in OpenAPI spec"
)
def test_stream_route_exists_and_accepts_post(self):
client = _build_client()
response = client.post("/query/stream", json={"query": "test", "mode": "mix"})
assert response.status_code not in (
404,
405,
), "/query/stream route must exist and accept POST"
class TestQueryStreamResponseContentType:
"""When the mock LLM returns a non-streaming result, /query/stream must
still set the correct Content-Type header."""
def test_stream_response_has_ndjson_content_type(self):
"""Even without a real LLM, the streaming response must carry the
correct media type header."""
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server"]
from lightrag.api.config import parse_args
from lightrag.api.lightrag_server import create_app
args = parse_args()
mock_rag = MagicMock()
mock_result = {
"llm_response": {
"is_streaming": False,
"content": "test response",
},
"data": {"references": []},
}
# Return a coroutine
mock_rag.aquery_llm = MagicMock()
async def _fake_aquery(*a, **kw):
return mock_result
mock_rag.aquery_llm.side_effect = _fake_aquery
with patch("lightrag.api.lightrag_server.LightRAG", return_value=mock_rag):
app = create_app(args)
client = TestClient(app)
response = client.post(
"/query/stream",
json={
"query": "test",
"mode": "mix",
"include_references": True,
},
)
content_type = response.headers.get("content-type", "")
assert "application/x-ndjson" in content_type, (
f"/query/stream must return application/x-ndjson, got: {content_type}"
)
finally:
sys.argv = original_argv
@@ -0,0 +1,48 @@
"""Upload allowlist exposes the native markdown suffixes (.md / .textpack).
``DocumentManager.supported_extensions`` derives the uploadable suffix set
live from the parser registry + routing: a suffix is advertised only when an
unhinted ``x.<suffix>`` routes to an engine that actually supports it. These
tests pin that ``.md`` and ``.textpack`` are uploadable (and routable), and
that ``.textpack`` resolves to the native engine.
"""
import importlib
import sys
import pytest
# document_routes parses argv at import time; guard it like the sibling tests.
_original_argv = sys.argv[:]
sys.argv = [sys.argv[0]]
_document_routes = importlib.import_module("lightrag.api.routers.document_routes")
_parser_routing = importlib.import_module("lightrag.parser.routing")
sys.argv = _original_argv
DocumentManager = _document_routes.DocumentManager
resolve_file_parser_engine = _parser_routing.resolve_file_parser_engine
@pytest.fixture
def doc_manager(tmp_path, monkeypatch):
# Clear any ambient LIGHTRAG_PARSER so the default routing applies:
# .md -> legacy (opt-in for native), .textpack -> native.
monkeypatch.delenv("LIGHTRAG_PARSER", raising=False)
return DocumentManager(str(tmp_path))
def test_textpack_and_md_are_uploadable(doc_manager):
extensions = doc_manager.supported_extensions
assert ".textpack" in extensions
assert ".md" in extensions
def test_bare_textpack_is_supported_file_via_native(doc_manager, monkeypatch):
monkeypatch.delenv("LIGHTRAG_PARSER", raising=False)
assert doc_manager.is_supported_file("note.textpack") is True
assert resolve_file_parser_engine("note.textpack") == "native"
def test_md_is_supported_file(doc_manager, monkeypatch):
monkeypatch.delenv("LIGHTRAG_PARSER", raising=False)
assert doc_manager.is_supported_file("doc.md") is True
+187
View File
@@ -0,0 +1,187 @@
"""
Tests for CORS middleware configuration on the LightRAG API server.
LightRAG authenticates exclusively via request headers (Authorization Bearer
token and X-API-Key) and never via cookies or other ambient credentials. The
Fetch spec forbids pairing the wildcard origin "*" with credentialed responses
("Access-Control-Allow-Origin: *" together with
"Access-Control-Allow-Credentials: true"). These tests pin the resulting
behavior:
- When CORS_ORIGINS is the wildcard "*" (the default), credentials are disabled
and the server responds with a clean "Access-Control-Allow-Origin: *" and no
"Access-Control-Allow-Credentials" header.
- When CORS_ORIGINS is an explicit allowlist, the matching origin is reflected
and credentials are enabled.
"""
import sys
from unittest.mock import patch, MagicMock
import pytest
from fastapi.testclient import TestClient
# Env vars that the project's `.env` may have populated (via load_dotenv at
# import time of lightrag.api.config). Tests must be hermetic and not depend on
# developer-local .env values, so we clear/override anything that affects
# parse_args() / create_app().
_ENV_VARS_TO_ISOLATE = (
"LLM_BINDING",
"EMBEDDING_BINDING",
"LLM_BINDING_HOST",
"LLM_BINDING_API_KEY",
"LLM_MODEL",
"EMBEDDING_BINDING_HOST",
"EMBEDDING_BINDING_API_KEY",
"EMBEDDING_MODEL",
"CORS_ORIGINS",
"LIGHTRAG_API_PREFIX",
"LIGHTRAG_KV_STORAGE",
"LIGHTRAG_VECTOR_STORAGE",
"LIGHTRAG_GRAPH_STORAGE",
"LIGHTRAG_DOC_STATUS_STORAGE",
)
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
"""Isolate tests from developer-local .env pollution and global config state.
Clear env vars that affect parse_args()/create_app(), then set the minimal
viable defaults (ollama bindings) so create_app's binding validation passes
without touching real services. The global config singleton in
lightrag.api.config is reset before and after each test so neither developer
.env values nor other tests' parsed args leak into our CORS assertions.
"""
for var in _ENV_VARS_TO_ISOLATE:
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("LLM_BINDING", "ollama")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
import lightrag.api.config as config
config._global_args = None
config._initialized = False
yield
config._global_args = None
config._initialized = False
def _build_client(cors_origins, monkeypatch):
"""Create a TestClient for an app configured with the given CORS_ORIGINS."""
from lightrag.api.config import parse_args, initialize_config
monkeypatch.setenv("CORS_ORIGINS", cors_origins)
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server"]
args = parse_args()
finally:
sys.argv = original_argv
# create_app / get_cors_origins read the module-level global_args proxy
# (not the passed args), which otherwise lazily re-parses sys.argv — by then
# polluted with pytest's argv. Pin the parsed args explicitly.
initialize_config(args, force=True)
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(args)
return TestClient(app)
def _preflight(client, origin):
"""Issue a CORS preflight request and return the response."""
return client.options(
"/query",
headers={
"Origin": origin,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization,content-type",
},
)
class TestWildcardCorsDisablesCredentials:
"""With CORS_ORIGINS="*" the config must stay spec-compliant."""
def test_preflight_wildcard_no_credentials(self, monkeypatch):
client = _build_client("*", monkeypatch)
resp = _preflight(client, "https://evil.example.com")
assert resp.headers["access-control-allow-origin"] == "*"
# Wildcard origin must NOT be paired with credentials.
assert "access-control-allow-credentials" not in resp.headers
def test_simple_request_wildcard_no_credentials(self, monkeypatch):
client = _build_client("*", monkeypatch)
# /health is whitelisted (no auth) so this returns a real response with
# CORS headers attached by the middleware.
resp = client.get("/health", headers={"Origin": "https://evil.example.com"})
assert resp.headers.get("access-control-allow-origin") == "*"
assert "access-control-allow-credentials" not in resp.headers
def test_wildcard_mixed_with_explicit_origin_no_credentials(self, monkeypatch):
# Starlette treats any list containing "*" as allow-all, so a mixed config
# is still allow-all and must NOT enable credentials.
client = _build_client("*,https://app.example.com", monkeypatch)
resp = _preflight(client, "https://evil.example.com")
assert "access-control-allow-credentials" not in resp.headers
def test_wildcard_trailing_comma_no_credentials(self, monkeypatch):
# A trailing comma yields ["*", ""]; the empty entry is dropped and the
# wildcard still disables credentials.
client = _build_client("*,", monkeypatch)
resp = _preflight(client, "https://evil.example.com")
assert "access-control-allow-credentials" not in resp.headers
class TestEmptyCorsFailsClosed:
"""An explicitly empty CORS_ORIGINS disables cross-origin access (fail closed)."""
def test_empty_string_allows_no_origin(self, monkeypatch):
# CORS_ORIGINS= is an explicit "disable cross-origin" config and must not
# silently widen to "*".
client = _build_client("", monkeypatch)
resp = _preflight(client, "https://evil.example.com")
assert "access-control-allow-origin" not in resp.headers
assert "access-control-allow-credentials" not in resp.headers
def test_only_commas_allows_no_origin(self, monkeypatch):
# A value with no real origins (e.g. ",,") also fails closed.
client = _build_client(",,", monkeypatch)
resp = _preflight(client, "https://evil.example.com")
assert "access-control-allow-origin" not in resp.headers
assert "access-control-allow-credentials" not in resp.headers
class TestExplicitAllowlistEnablesCredentials:
"""An explicit origin allowlist reflects the origin and allows credentials."""
def test_preflight_allowed_origin_reflected_with_credentials(self, monkeypatch):
client = _build_client(
"https://app.example.com,https://dash.example.com", monkeypatch
)
resp = _preflight(client, "https://app.example.com")
assert resp.headers["access-control-allow-origin"] == "https://app.example.com"
assert resp.headers["access-control-allow-credentials"] == "true"
def test_preflight_disallowed_origin_not_reflected(self, monkeypatch):
client = _build_client("https://app.example.com", monkeypatch)
resp = _preflight(client, "https://evil.example.com")
# A non-allowlisted origin is never echoed back.
assert (
resp.headers.get("access-control-allow-origin")
!= "https://evil.example.com"
)
+272
View File
@@ -0,0 +1,272 @@
"""Tests for credential-gated configuration disclosure on ``GET /health``.
Issue #3294: ``/health`` is whitelisted by default so it answers unauthenticated
liveness probes (HTTP 200). It must therefore reveal sensitive runtime
configuration (filesystem paths, LLM/embedding provider + model + host, storage
backends, queue status, ...) ONLY to authenticated callers. These tests pin
that behavior across the three authentication modes:
- fully open (no AUTH_ACCOUNTS, no API key): everything is open anyway, so the
full configuration is returned to every caller.
- password auth (AUTH_ACCOUNTS): only a valid non-guest JWT (or a configured
API key) unlocks the configuration; anonymous probes get liveness only.
- API-key-only: only a valid X-API-Key unlocks the configuration.
In every case ``/health`` returns HTTP 200 so external liveness probes keep
working.
"""
import sys
from unittest.mock import AsyncMock
import pytest
from fastapi import APIRouter
from fastapi.testclient import TestClient
# Fields that must NEVER appear in an unauthenticated response.
_SENSITIVE_TOP_LEVEL = ("working_directory", "input_directory", "configuration")
# Liveness fields that must always be present (safe; already exposed by the
# unauthenticated /auth-status endpoint or pure liveness signals).
_LIVENESS_FIELDS = ("status", "auth_mode", "core_version", "api_version")
_ENV_VARS_TO_ISOLATE = (
"LLM_BINDING",
"EMBEDDING_BINDING",
"AUTH_ACCOUNTS",
"TOKEN_SECRET",
"LIGHTRAG_API_KEY",
"WHITELIST_PATHS",
"LIGHTRAG_API_PREFIX",
)
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
"""Keep tests hermetic from developer-local .env and global config state."""
for var in _ENV_VARS_TO_ISOLATE:
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("AUTH_ACCOUNTS", "")
monkeypatch.setenv("LIGHTRAG_API_KEY", "")
monkeypatch.setenv("TOKEN_SECRET", "")
monkeypatch.setenv("WHITELIST_PATHS", "/health,/api/*")
monkeypatch.setenv("LLM_BINDING", "ollama")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
import lightrag.api.config as config
config._global_args = None
config._initialized = False
yield
config._global_args = None
config._initialized = False
class _FakeLightRAG:
"""Minimal stand-in implementing the async surface /health touches."""
def __init__(self, *_args, **_kwargs):
pass
def register_role_llm_builder(self, _builder):
return None
def set_role_llm_metadata(self, _role, **_metadata):
return None
def get_llm_role_config(self):
return {}
async def get_llm_queue_status(self, include_base=True):
return {}
async def get_embedding_queue_status(self):
return {}
async def get_rerank_queue_status(self):
return {}
class _FakeOllamaAPI:
def __init__(self, *_args, **_kwargs):
self.router = APIRouter()
def _build_client(monkeypatch, *, api_key=None):
"""Build a /health-capable TestClient with all backend I/O mocked out."""
from lightrag.api.config import parse_args, initialize_config
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server"]
args = parse_args()
finally:
sys.argv = original_argv
if api_key is not None:
args.key = api_key
initialize_config(args, force=True)
import lightrag.api.lightrag_server as lightrag_server
monkeypatch.setattr(lightrag_server, "LightRAG", _FakeLightRAG)
monkeypatch.setattr(lightrag_server, "check_frontend_build", lambda: (True, False))
monkeypatch.setattr(
lightrag_server, "create_document_routes", lambda *_a, **_k: APIRouter()
)
monkeypatch.setattr(
lightrag_server, "create_query_routes", lambda *_a, **_k: APIRouter()
)
monkeypatch.setattr(
lightrag_server, "create_graph_routes", lambda *_a, **_k: APIRouter()
)
monkeypatch.setattr(lightrag_server, "OllamaAPI", _FakeOllamaAPI)
monkeypatch.setattr(
lightrag_server, "get_namespace_data", AsyncMock(return_value={"busy": False})
)
monkeypatch.setattr(lightrag_server, "get_default_workspace", lambda: "default")
monkeypatch.setattr(
lightrag_server,
"cleanup_keyed_lock",
lambda: {"cleanup_performed": {}, "current_status": {}},
)
app = lightrag_server.create_app(args)
return TestClient(app)
def _set_auth_mode(monkeypatch, *, auth_configured):
"""Override the module-level auth flags the /health gate reads at runtime.
Also pin a whitelist that exempts /health so combined_auth keeps returning
200 for anonymous callers (the gate, not combined_auth, hides the config).
"""
import lightrag.api.utils_api as utils_api
monkeypatch.setattr(utils_api, "auth_configured", auth_configured)
monkeypatch.setattr(
utils_api, "whitelist_patterns", [("/health", False), ("/api", True)]
)
def _assert_liveness_only(body):
for field in _LIVENESS_FIELDS:
assert field in body, f"liveness field {field!r} missing"
for field in _SENSITIVE_TOP_LEVEL:
assert field not in body, f"sensitive field {field!r} leaked"
def _assert_full_config(body):
assert "configuration" in body
assert "working_directory" in body
assert "llm_binding" in body["configuration"]
# --------------------------------------------------------------------------- #
# Fully open mode: everything is open, so config is returned to everyone.
# --------------------------------------------------------------------------- #
def test_open_mode_returns_full_config_to_anonymous(monkeypatch):
client = _build_client(monkeypatch)
_set_auth_mode(monkeypatch, auth_configured=False)
resp = client.get("/health")
assert resp.status_code == 200
_assert_full_config(resp.json())
# --------------------------------------------------------------------------- #
# Password auth: anonymous gets liveness only; a valid token unlocks config.
# --------------------------------------------------------------------------- #
def test_password_mode_anonymous_gets_liveness_only(monkeypatch):
client = _build_client(monkeypatch)
_set_auth_mode(monkeypatch, auth_configured=True)
resp = client.get("/health")
assert resp.status_code == 200 # liveness probe must stay green
_assert_liveness_only(resp.json())
def test_password_mode_valid_token_unlocks_config(monkeypatch):
import lightrag.api.utils_api as utils_api
client = _build_client(monkeypatch)
_set_auth_mode(monkeypatch, auth_configured=True)
monkeypatch.setattr(
utils_api.auth_handler,
"validate_token",
lambda token: (
{"username": "admin", "role": "user"}
if token == "valid-user-token"
else (_ for _ in ()).throw(ValueError("bad token"))
),
)
resp = client.get("/health", headers={"Authorization": "Bearer valid-user-token"})
assert resp.status_code == 200
_assert_full_config(resp.json())
def test_password_mode_guest_token_stays_liveness_only(monkeypatch):
import lightrag.api.utils_api as utils_api
client = _build_client(monkeypatch)
_set_auth_mode(monkeypatch, auth_configured=True)
monkeypatch.setattr(
utils_api.auth_handler,
"validate_token",
lambda token: {"username": "guest", "role": "guest"},
)
resp = client.get("/health", headers={"Authorization": "Bearer guest-token"})
assert resp.status_code == 200
_assert_liveness_only(resp.json())
# --------------------------------------------------------------------------- #
# API-key-only mode: only a valid X-API-Key unlocks the configuration.
# --------------------------------------------------------------------------- #
def test_api_key_mode_anonymous_gets_liveness_only(monkeypatch):
client = _build_client(monkeypatch, api_key="secret-key")
_set_auth_mode(monkeypatch, auth_configured=False)
resp = client.get("/health")
assert resp.status_code == 200
_assert_liveness_only(resp.json())
def test_api_key_mode_valid_key_unlocks_config(monkeypatch):
client = _build_client(monkeypatch, api_key="secret-key")
_set_auth_mode(monkeypatch, auth_configured=False)
resp = client.get("/health", headers={"X-API-Key": "secret-key"})
assert resp.status_code == 200
_assert_full_config(resp.json())
# --------------------------------------------------------------------------- #
# Combined mode (AUTH_ACCOUNTS + LIGHTRAG_API_KEY): either a valid JWT or a
# valid X-API-Key unlocks the configuration; an anonymous probe gets liveness.
# --------------------------------------------------------------------------- #
def test_combined_mode_anonymous_gets_liveness_only(monkeypatch):
client = _build_client(monkeypatch, api_key="secret-key")
_set_auth_mode(monkeypatch, auth_configured=True)
resp = client.get("/health")
assert resp.status_code == 200
_assert_liveness_only(resp.json())
def test_combined_mode_valid_api_key_unlocks_config(monkeypatch):
client = _build_client(monkeypatch, api_key="secret-key")
_set_auth_mode(monkeypatch, auth_configured=True)
resp = client.get("/health", headers={"X-API-Key": "secret-key"})
assert resp.status_code == 200
_assert_full_config(resp.json())
+875
View File
@@ -0,0 +1,875 @@
"""
LightRAG Ollama Compatibility Interface Test Script
This script tests the LightRAG's Ollama compatibility interface, including:
1. Basic functionality tests (streaming and non-streaming responses)
2. Query mode tests (local, global, naive, hybrid)
3. Error handling tests (including streaming and non-streaming scenarios)
All responses use the JSON Lines format, complying with the Ollama API specification.
"""
import pytest
import requests
import json
import argparse
import time
from typing import Dict, Any, Optional, List, Callable
from dataclasses import dataclass, asdict
from datetime import datetime
from pathlib import Path
from enum import Enum, auto
class ErrorCode(Enum):
"""Error codes for MCP errors"""
InvalidRequest = auto()
InternalError = auto()
class McpError(Exception):
"""Base exception class for MCP errors"""
def __init__(self, code: ErrorCode, message: str):
self.code = code
self.message = message
super().__init__(message)
DEFAULT_CONFIG = {
"server": {
"host": "localhost",
"port": 9621,
"model": "lightrag:latest",
"timeout": 300,
"max_retries": 1,
"retry_delay": 1,
},
"test_cases": {
"basic": {"query": "唐僧有几个徒弟"},
"generate": {"query": "电视剧西游记导演是谁"},
},
}
# Example conversation history for testing
EXAMPLE_CONVERSATION = [
{"role": "user", "content": "你好"},
{"role": "assistant", "content": "你好!我是一个AI助手,很高兴为你服务。"},
{"role": "user", "content": "Who are you?"},
{"role": "assistant", "content": "I'm a Knowledge base query assistant."},
]
class OutputControl:
"""Output control class, manages the verbosity of test output"""
_verbose: bool = False
@classmethod
def set_verbose(cls, verbose: bool) -> None:
cls._verbose = verbose
@classmethod
def is_verbose(cls) -> bool:
return cls._verbose
@dataclass
class ExecutionResult:
"""Test execution result data class"""
name: str
success: bool
duration: float
error: Optional[str] = None
timestamp: str = ""
def __post_init__(self):
if not self.timestamp:
self.timestamp = datetime.now().isoformat()
class ExecutionStats:
"""Test execution statistics"""
def __init__(self):
self.results: List[ExecutionResult] = []
self.start_time = datetime.now()
def add_result(self, result: ExecutionResult):
self.results.append(result)
def export_results(self, path: str = "test_results.json"):
"""Export test results to a JSON file
Args:
path: Output file path
"""
results_data = {
"start_time": self.start_time.isoformat(),
"end_time": datetime.now().isoformat(),
"results": [asdict(r) for r in self.results],
"summary": {
"total": len(self.results),
"passed": sum(1 for r in self.results if r.success),
"failed": sum(1 for r in self.results if not r.success),
"total_duration": sum(r.duration for r in self.results),
},
}
with open(path, "w", encoding="utf-8") as f:
json.dump(results_data, f, ensure_ascii=False, indent=2)
print(f"\nTest results saved to: {path}")
def print_summary(self):
total = len(self.results)
passed = sum(1 for r in self.results if r.success)
failed = total - passed
duration = sum(r.duration for r in self.results)
print("\n=== Test Summary ===")
print(f"Start time: {self.start_time.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Total duration: {duration:.2f} seconds")
print(f"Total tests: {total}")
print(f"Passed: {passed}")
print(f"Failed: {failed}")
if failed > 0:
print("\nFailed tests:")
for result in self.results:
if not result.success:
print(f"- {result.name}: {result.error}")
def make_request(
url: str, data: Dict[str, Any], stream: bool = False, check_status: bool = True
) -> requests.Response:
"""Send an HTTP request with retry mechanism
Args:
url: Request URL
data: Request data
stream: Whether to use streaming response
check_status: Whether to check HTTP status code (default: True)
Returns:
requests.Response: Response object
Raises:
requests.exceptions.RequestException: Request failed after all retries
requests.exceptions.HTTPError: HTTP status code is not 200 (when check_status is True)
"""
server_config = CONFIG["server"]
max_retries = server_config["max_retries"]
retry_delay = server_config["retry_delay"]
timeout = server_config["timeout"]
for attempt in range(max_retries):
try:
response = requests.post(url, json=data, stream=stream, timeout=timeout)
if check_status and response.status_code != 200:
response.raise_for_status()
return response
except requests.exceptions.RequestException as e:
if attempt == max_retries - 1: # Last retry
raise
print(f"\nRequest failed, retrying in {retry_delay} seconds: {str(e)}")
time.sleep(retry_delay)
def load_config() -> Dict[str, Any]:
"""Load configuration file
First try to load from config.json in the current directory,
if it doesn't exist, use the default configuration
Returns:
Configuration dictionary
"""
config_path = Path("config.json")
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
return DEFAULT_CONFIG
def print_json_response(data: Dict[str, Any], title: str = "", indent: int = 2) -> None:
"""Format and print JSON response data
Args:
data: Data dictionary to print
title: Title to print
indent: Number of spaces for JSON indentation
"""
if OutputControl.is_verbose():
if title:
print(f"\n=== {title} ===")
print(json.dumps(data, ensure_ascii=False, indent=indent))
# Global configuration
CONFIG = load_config()
def get_base_url(endpoint: str = "chat") -> str:
"""Return the base URL for specified endpoint
Args:
endpoint: API endpoint name (chat or generate)
Returns:
Complete URL for the endpoint
"""
server = CONFIG["server"]
return f"http://{server['host']}:{server['port']}/api/{endpoint}"
def create_chat_request_data(
content: str,
stream: bool = False,
model: str = None,
conversation_history: List[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""Create chat request data
Args:
content: User message content
stream: Whether to use streaming response
model: Model name
conversation_history: List of previous conversation messages
Returns:
Dictionary containing complete chat request data
"""
messages = conversation_history or []
messages.append({"role": "user", "content": content})
return {
"model": model or CONFIG["server"]["model"],
"messages": messages,
"stream": stream,
}
def create_generate_request_data(
prompt: str,
system: str = None,
stream: bool = False,
model: str = None,
options: Dict[str, Any] = None,
) -> Dict[str, Any]:
"""Create generate request data
Args:
prompt: Generation prompt
system: System prompt
stream: Whether to use streaming response
model: Model name
options: Additional options
Returns:
Dictionary containing complete generate request data
"""
data = {
"model": model or CONFIG["server"]["model"],
"prompt": prompt,
"stream": stream,
}
if system:
data["system"] = system
if options:
data["options"] = options
return data
# Global test statistics
STATS = ExecutionStats()
def run_test(func: Callable, name: str) -> None:
"""Run a test and record the results
Args:
func: Test function
name: Test name
"""
start_time = time.time()
try:
func()
duration = time.time() - start_time
STATS.add_result(ExecutionResult(name, True, duration))
except Exception as e:
duration = time.time() - start_time
STATS.add_result(ExecutionResult(name, False, duration, str(e)))
raise
@pytest.mark.integration
@pytest.mark.requires_api
def test_non_stream_chat() -> None:
"""Test non-streaming call to /api/chat endpoint"""
url = get_base_url()
# Send request with conversation history
data = create_chat_request_data(
CONFIG["test_cases"]["basic"]["query"],
stream=False,
conversation_history=EXAMPLE_CONVERSATION,
)
response = make_request(url, data)
# Print response
if OutputControl.is_verbose():
print("\n=== Non-streaming call response ===")
response_json = response.json()
# Print response content
print_json_response(
{"model": response_json["model"], "message": response_json["message"]},
"Response content",
)
@pytest.mark.integration
@pytest.mark.requires_api
def test_stream_chat() -> None:
"""Test streaming call to /api/chat endpoint
Use JSON Lines format to process streaming responses, each line is a complete JSON object.
Response format:
{
"model": "lightrag:latest",
"created_at": "2024-01-15T00:00:00Z",
"message": {
"role": "assistant",
"content": "Partial response content",
"images": null
},
"done": false
}
The last message will contain performance statistics, with done set to true.
"""
url = get_base_url()
# Send request with conversation history
data = create_chat_request_data(
CONFIG["test_cases"]["basic"]["query"],
stream=True,
conversation_history=EXAMPLE_CONVERSATION,
)
response = make_request(url, data, stream=True)
if OutputControl.is_verbose():
print("\n=== Streaming call response ===")
output_buffer = []
try:
for line in response.iter_lines():
if line: # Skip empty lines
try:
# Decode and parse JSON
data = json.loads(line.decode("utf-8"))
if data.get("done", True): # If it's the completion marker
if (
"total_duration" in data
): # Final performance statistics message
# print_json_response(data, "Performance statistics")
break
else: # Normal content message
message = data.get("message", {})
content = message.get("content", "")
if content: # Only collect non-empty content
output_buffer.append(content)
print(
content, end="", flush=True
) # Print content in real-time
except json.JSONDecodeError:
print("Error decoding JSON from response line")
finally:
response.close() # Ensure the response connection is closed
# Print a newline
print()
@pytest.mark.integration
@pytest.mark.requires_api
def test_query_modes() -> None:
"""Test different query mode prefixes
Supported query modes:
- /local: Local retrieval mode, searches only in highly relevant documents
- /global: Global retrieval mode, searches across all documents
- /naive: Naive mode, does not use any optimization strategies
- /hybrid: Hybrid mode (default), combines multiple strategies
- /mix: Mix mode
Each mode will return responses in the same format, but with different retrieval strategies.
"""
url = get_base_url()
modes = ["local", "global", "naive", "hybrid", "mix"]
for mode in modes:
if OutputControl.is_verbose():
print(f"\n=== Testing /{mode} mode ===")
data = create_chat_request_data(
f"/{mode} {CONFIG['test_cases']['basic']['query']}", stream=False
)
# Send request
response = make_request(url, data)
response_json = response.json()
# Print response content
print_json_response(
{"model": response_json["model"], "message": response_json["message"]}
)
def create_error_test_data(error_type: str) -> Dict[str, Any]:
"""Create request data for error testing
Args:
error_type: Error type, supported:
- empty_messages: Empty message list
- invalid_role: Invalid role field
- missing_content: Missing content field
Returns:
Request dictionary containing error data
"""
error_data = {
"empty_messages": {"model": "lightrag:latest", "messages": [], "stream": True},
"invalid_role": {
"model": "lightrag:latest",
"messages": [{"invalid_role": "user", "content": "Test message"}],
"stream": True,
},
"missing_content": {
"model": "lightrag:latest",
"messages": [{"role": "user"}],
"stream": True,
},
}
return error_data.get(error_type, error_data["empty_messages"])
@pytest.mark.integration
@pytest.mark.requires_api
def test_stream_error_handling() -> None:
"""Test error handling for streaming responses
Test scenarios:
1. Empty message list
2. Message format error (missing required fields)
Error responses should be returned immediately without establishing a streaming connection.
The status code should be 4xx, and detailed error information should be returned.
"""
url = get_base_url()
if OutputControl.is_verbose():
print("\n=== Testing streaming response error handling ===")
# Test empty message list
if OutputControl.is_verbose():
print("\n--- Testing empty message list (streaming) ---")
data = create_error_test_data("empty_messages")
response = make_request(url, data, stream=True, check_status=False)
print(f"Status code: {response.status_code}")
if response.status_code != 200:
print_json_response(response.json(), "Error message")
response.close()
# Test invalid role field
if OutputControl.is_verbose():
print("\n--- Testing invalid role field (streaming) ---")
data = create_error_test_data("invalid_role")
response = make_request(url, data, stream=True, check_status=False)
print(f"Status code: {response.status_code}")
if response.status_code != 200:
print_json_response(response.json(), "Error message")
response.close()
# Test missing content field
if OutputControl.is_verbose():
print("\n--- Testing missing content field (streaming) ---")
data = create_error_test_data("missing_content")
response = make_request(url, data, stream=True, check_status=False)
print(f"Status code: {response.status_code}")
if response.status_code != 200:
print_json_response(response.json(), "Error message")
response.close()
@pytest.mark.integration
@pytest.mark.requires_api
def test_error_handling() -> None:
"""Test error handling for non-streaming responses
Test scenarios:
1. Empty message list
2. Message format error (missing required fields)
Error response format:
{
"detail": "Error description"
}
All errors should return appropriate HTTP status codes and clear error messages.
"""
url = get_base_url()
if OutputControl.is_verbose():
print("\n=== Testing error handling ===")
# Test empty message list
if OutputControl.is_verbose():
print("\n--- Testing empty message list ---")
data = create_error_test_data("empty_messages")
data["stream"] = False # Change to non-streaming mode
response = make_request(url, data, check_status=False)
print(f"Status code: {response.status_code}")
print_json_response(response.json(), "Error message")
# Test invalid role field
if OutputControl.is_verbose():
print("\n--- Testing invalid role field ---")
data = create_error_test_data("invalid_role")
data["stream"] = False # Change to non-streaming mode
response = make_request(url, data, check_status=False)
print(f"Status code: {response.status_code}")
print_json_response(response.json(), "Error message")
# Test missing content field
if OutputControl.is_verbose():
print("\n--- Testing missing content field ---")
data = create_error_test_data("missing_content")
data["stream"] = False # Change to non-streaming mode
response = make_request(url, data, check_status=False)
print(f"Status code: {response.status_code}")
print_json_response(response.json(), "Error message")
@pytest.mark.integration
@pytest.mark.requires_api
def test_non_stream_generate() -> None:
"""Test non-streaming call to /api/generate endpoint"""
url = get_base_url("generate")
data = create_generate_request_data(
CONFIG["test_cases"]["generate"]["query"], stream=False
)
# Send request
response = make_request(url, data)
# Print response
if OutputControl.is_verbose():
print("\n=== Non-streaming generate response ===")
response_json = response.json()
# Print response content
print(json.dumps(response_json, ensure_ascii=False, indent=2))
@pytest.mark.integration
@pytest.mark.requires_api
def test_stream_generate() -> None:
"""Test streaming call to /api/generate endpoint"""
url = get_base_url("generate")
data = create_generate_request_data(
CONFIG["test_cases"]["generate"]["query"], stream=True
)
# Send request and get streaming response
response = make_request(url, data, stream=True)
if OutputControl.is_verbose():
print("\n=== Streaming generate response ===")
output_buffer = []
try:
for line in response.iter_lines():
if line: # Skip empty lines
try:
# Decode and parse JSON
data = json.loads(line.decode("utf-8"))
if data.get("done", True): # If it's the completion marker
if (
"total_duration" in data
): # Final performance statistics message
break
else: # Normal content message
content = data.get("response", "")
if content: # Only collect non-empty content
output_buffer.append(content)
print(
content, end="", flush=True
) # Print content in real-time
except json.JSONDecodeError:
print("Error decoding JSON from response line")
finally:
response.close() # Ensure the response connection is closed
# Print a newline
print()
@pytest.mark.integration
@pytest.mark.requires_api
def test_generate_with_system() -> None:
"""Test generate with system prompt"""
url = get_base_url("generate")
data = create_generate_request_data(
CONFIG["test_cases"]["generate"]["query"],
system="你是一个知识渊博的助手",
stream=False,
)
# Send request
response = make_request(url, data)
# Print response
if OutputControl.is_verbose():
print("\n=== Generate with system prompt response ===")
response_json = response.json()
# Print response content
print_json_response(
{
"model": response_json["model"],
"response": response_json["response"],
"done": response_json["done"],
},
"Response content",
)
@pytest.mark.integration
@pytest.mark.requires_api
def test_generate_error_handling() -> None:
"""Test error handling for generate endpoint"""
url = get_base_url("generate")
# Test empty prompt
if OutputControl.is_verbose():
print("\n=== Testing empty prompt ===")
data = create_generate_request_data("", stream=False)
response = make_request(url, data, check_status=False)
print(f"Status code: {response.status_code}")
print_json_response(response.json(), "Error message")
# Test invalid options
if OutputControl.is_verbose():
print("\n=== Testing invalid options ===")
data = create_generate_request_data(
CONFIG["test_cases"]["basic"]["query"],
options={"invalid_option": "value"},
stream=False,
)
response = make_request(url, data, check_status=False)
print(f"Status code: {response.status_code}")
print_json_response(response.json(), "Error message")
@pytest.mark.integration
@pytest.mark.requires_api
def test_generate_concurrent() -> None:
"""Test concurrent generate requests"""
import asyncio
import aiohttp
from contextlib import asynccontextmanager
@asynccontextmanager
async def get_session():
async with aiohttp.ClientSession() as session:
yield session
async def make_request(session, prompt: str, request_id: int):
url = get_base_url("generate")
data = create_generate_request_data(prompt, stream=False)
try:
async with session.post(url, json=data) as response:
if response.status != 200:
error_msg = (
f"Request {request_id} failed with status {response.status}"
)
if OutputControl.is_verbose():
print(f"\n{error_msg}")
raise McpError(ErrorCode.InternalError, error_msg)
result = await response.json()
if "error" in result:
error_msg = (
f"Request {request_id} returned error: {result['error']}"
)
if OutputControl.is_verbose():
print(f"\n{error_msg}")
raise McpError(ErrorCode.InternalError, error_msg)
return result
except Exception as e:
error_msg = f"Request {request_id} failed: {str(e)}"
if OutputControl.is_verbose():
print(f"\n{error_msg}")
raise McpError(ErrorCode.InternalError, error_msg)
async def run_concurrent_requests():
prompts = ["第一个问题", "第二个问题", "第三个问题", "第四个问题", "第五个问题"]
async with get_session() as session:
tasks = [
make_request(session, prompt, i + 1) for i, prompt in enumerate(prompts)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
success_results = []
error_messages = []
for i, result in enumerate(results):
if isinstance(result, Exception):
error_messages.append(f"Request {i + 1} failed: {str(result)}")
else:
success_results.append((i + 1, result))
if error_messages:
for req_id, result in success_results:
if OutputControl.is_verbose():
print(f"\nRequest {req_id} succeeded:")
print_json_response(result)
error_summary = "\n".join(error_messages)
raise McpError(
ErrorCode.InternalError,
f"Some concurrent requests failed:\n{error_summary}",
)
return results
if OutputControl.is_verbose():
print("\n=== Testing concurrent generate requests ===")
# Run concurrent requests
try:
results = asyncio.run(run_concurrent_requests())
# all success, print out results
for i, result in enumerate(results, 1):
print(f"\nRequest {i} result:")
print_json_response(result)
except McpError:
# error message already printed
raise
def get_test_cases() -> Dict[str, Callable]:
"""Get all available test cases
Returns:
A dictionary mapping test names to test functions
"""
return {
"non_stream": test_non_stream_chat,
"stream": test_stream_chat,
"modes": test_query_modes,
"errors": test_error_handling,
"stream_errors": test_stream_error_handling,
"non_stream_generate": test_non_stream_generate,
"stream_generate": test_stream_generate,
"generate_with_system": test_generate_with_system,
"generate_errors": test_generate_error_handling,
"generate_concurrent": test_generate_concurrent,
}
def create_default_config():
"""Create a default configuration file"""
config_path = Path("config.json")
if not config_path.exists():
with open(config_path, "w", encoding="utf-8") as f:
json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2)
print(f"Default configuration file created: {config_path}")
def parse_args() -> argparse.Namespace:
"""Parse command line arguments"""
parser = argparse.ArgumentParser(
description="LightRAG Ollama Compatibility Interface Testing",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Configuration file (config.json):
{
"server": {
"host": "localhost", # Server address
"port": 9621, # Server port
"model": "lightrag:latest" # Default model name
},
"test_cases": {
"basic": {
"query": "Test query", # Basic query text
"stream_query": "Stream query" # Stream query text
}
}
}
""",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Silent mode, only display test result summary",
)
parser.add_argument(
"-a",
"--ask",
type=str,
help="Specify query content, which will override the query settings in the configuration file",
)
parser.add_argument(
"--init-config", action="store_true", help="Create default configuration file"
)
parser.add_argument(
"--output",
type=str,
default="",
help="Test result output file path, default is not to output to a file",
)
parser.add_argument(
"--tests",
nargs="+",
choices=list(get_test_cases().keys()) + ["all"],
default=["all"],
help="Test cases to run, options: %(choices)s. Use 'all' to run all tests except error tests)",
)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
# Set output mode
OutputControl.set_verbose(not args.quiet)
# If query content is specified, update the configuration
if args.ask:
CONFIG["test_cases"]["basic"]["query"] = args.ask
# If specified to create a configuration file
if args.init_config:
create_default_config()
exit(0)
test_cases = get_test_cases()
try:
if "all" in args.tests:
# Run all tests except error handling tests
if OutputControl.is_verbose():
print("\n【Chat API Tests】")
run_test(test_non_stream_chat, "Non-streaming Chat Test")
run_test(test_stream_chat, "Streaming Chat Test")
run_test(test_query_modes, "Chat Query Mode Test")
if OutputControl.is_verbose():
print("\n【Generate API Tests】")
run_test(test_non_stream_generate, "Non-streaming Generate Test")
run_test(test_stream_generate, "Streaming Generate Test")
run_test(test_generate_with_system, "Generate with System Prompt Test")
run_test(test_generate_concurrent, "Generate Concurrent Test")
else:
# Run specified tests
for test_name in args.tests:
if OutputControl.is_verbose():
print(f"\n【Running Test: {test_name}")
run_test(test_cases[test_name], test_name)
except Exception as e:
print(f"\nAn error occurred: {str(e)}")
finally:
# Print test statistics
STATS.print_summary()
# If an output file path is specified, export the results
if args.output:
STATS.export_results(args.output)
+655
View File
@@ -0,0 +1,655 @@
"""
Integration tests for API and WebUI path prefix support via root_path.
With the root_path approach, routes always stay at their natural paths
(/docs, /health, /query, /documents/...). The api_prefix is passed to
FastAPI's root_path parameter, which controls the servers URL in the
OpenAPI spec for correct reverse proxy operation.
"""
import os
import sys
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
import pytest
# Env vars that the project's `.env` may have populated (via load_dotenv at
# import time of lightrag.api.config). Tests must be hermetic and not depend
# on developer-local .env values, so we clear/override anything that affects
# parse_args() / create_app().
_ENV_VARS_TO_ISOLATE = (
"LLM_BINDING",
"EMBEDDING_BINDING",
"LLM_BINDING_HOST",
"LLM_BINDING_API_KEY",
"LLM_MODEL",
"EMBEDDING_BINDING_HOST",
"EMBEDDING_BINDING_API_KEY",
"EMBEDDING_MODEL",
"LIGHTRAG_API_PREFIX",
"LIGHTRAG_KV_STORAGE",
"LIGHTRAG_VECTOR_STORAGE",
"LIGHTRAG_GRAPH_STORAGE",
"LIGHTRAG_DOC_STATUS_STORAGE",
)
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
"""Isolate tests from developer-local .env pollution.
The lightrag.api.config module loads .env at import time, which can leave
bindings/hosts/keys in os.environ that mismatch what these tests assume.
Clear them, then set the minimal viable defaults (ollama bindings) so
create_app's binding validation passes without touching real services.
"""
for var in _ENV_VARS_TO_ISOLATE:
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("LLM_BINDING", "ollama")
monkeypatch.setenv("EMBEDDING_BINDING", "ollama")
@pytest.fixture
def mock_args_api_prefix():
"""Create mock args with API prefix."""
from lightrag.api.config import parse_args
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server", "--api-prefix", "/test-api"]
args = parse_args()
yield args
finally:
sys.argv = original_argv
@pytest.fixture
def mock_args_no_prefix():
"""Create mock args without API prefix."""
from lightrag.api.config import parse_args
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server"]
args = parse_args()
yield args
finally:
sys.argv = original_argv
class TestRootPathConfiguration:
"""Test that root_path is set correctly on the FastAPI app."""
def test_root_path_set_when_prefix_provided(self, mock_args_api_prefix):
"""Test app.root_path reflects api_prefix."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_api_prefix)
assert app.root_path == "/test-api"
def test_root_path_none_when_no_prefix(self, mock_args_no_prefix):
"""Test app.root_path is not set when no prefix is configured."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_no_prefix)
# When no prefix, root_path is None (not passed to FastAPI)
# FastAPI stores None as-is, which means no root_path injection
assert not app.root_path
class TestRoutesAtNaturalPaths:
"""Test that routes stay at their natural paths regardless of root_path."""
def test_routes_accessible_at_both_paths_with_prefix(self, mock_args_api_prefix):
"""With root_path, routes work at both prefixed and natural paths.
FastAPI injects root_path into the ASGI scope, and Starlette strips
it from the path before matching. So /test-api/docs and /docs both work.
"""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_api_prefix)
client = TestClient(app)
# Natural path works
response = client.get("/docs")
assert response.status_code == 200
response = client.get("/openapi.json")
assert response.status_code == 200
# Prefixed path also works (FastAPI strips root_path from scope)
response = client.get("/test-api/docs")
assert response.status_code == 200
response = client.get("/test-api/openapi.json")
assert response.status_code == 200
def test_document_routes_at_natural_path(self, mock_args_api_prefix):
"""Test document routes are at /documents/ (their router-level prefix)."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_api_prefix)
client = TestClient(app)
response = client.post(
"/documents/paginated",
json={},
headers={"Authorization": "Bearer test"},
)
# The route is mounted; the mocked LightRAG may cause 401/422/500,
# but a missing route (404) or wrong method (405) means routing
# itself broke and is what we want to catch here.
assert response.status_code not in (404, 405)
def test_routes_accessible_at_root_no_prefix(self, mock_args_no_prefix):
"""Test routes are at root when no prefix is set (default)."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_no_prefix)
client = TestClient(app)
# API docs accessible at root
response = client.get("/docs")
assert response.status_code == 200
# openapi.json at root
response = client.get("/openapi.json")
assert response.status_code == 200
# Prefixed paths return 404 when no root_path is configured
response = client.get("/test-api/docs")
assert response.status_code == 404
class TestOpenAPISpecIntegration:
"""Test that OpenAPI spec uses root_path for servers URL."""
def test_openapi_spec_has_servers_url_with_prefix(self, mock_args_api_prefix):
"""Test OpenAPI spec servers URL includes the prefix via root_path."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_api_prefix)
client = TestClient(app)
# OpenAPI JSON is served at the natural path
response = client.get("/openapi.json")
assert response.status_code == 200
spec = response.json()
# Servers URL should include the prefix
servers = spec.get("servers", [])
assert len(servers) > 0, (
"OpenAPI spec should have servers entry when root_path is set"
)
assert servers[0].get("url") == "/test-api", (
f"Expected servers URL to be exactly /test-api, got: {servers[0].get('url')}"
)
def test_openapi_spec_no_servers_without_prefix(self, mock_args_no_prefix):
"""Test OpenAPI spec has no servers entry when no root_path."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_no_prefix)
client = TestClient(app)
response = client.get("/openapi.json")
assert response.status_code == 200
spec = response.json()
# No servers when root_path is None/empty
assert "servers" not in spec or spec["servers"] is None
def test_openapi_spec_paths_at_natural_paths(self, mock_args_api_prefix):
"""Test OpenAPI spec paths are at natural paths (not prefixed)."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_api_prefix)
client = TestClient(app)
response = client.get("/openapi.json")
assert response.status_code == 200
spec = response.json()
paths = spec.get("paths", {})
# Paths should be at natural paths
for path in paths:
if path == "/":
continue
assert not path.startswith("/test-api/"), (
f"Path {path} should not be prefixed with /test-api/ in root_path mode"
)
class TestWebUIPrefixIntegration:
"""Test that the WebUI is served at the expected (fixed) /webui path,
composed with `root_path` when an API prefix is set."""
def test_webui_at_prefixed_path(self, mock_args_api_prefix):
"""With root_path="/test-api" the WebUI lives at /test-api/webui/
because FastAPI injects root_path into the ASGI scope."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_api_prefix)
client = TestClient(app)
response = client.get("/test-api/webui/")
assert response.status_code in [200, 307]
def test_webui_without_api_prefix(self, mock_args_no_prefix):
"""Without an API prefix the WebUI is served at /webui/."""
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
from lightrag.api.lightrag_server import create_app
app = create_app(mock_args_no_prefix)
client = TestClient(app)
response = client.get("/webui/")
assert response.status_code in [200, 307]
class TestEnvironmentVariables:
"""Test that environment variables are read correctly."""
def test_env_api_prefix(self):
"""Test LIGHTRAG_API_PREFIX environment variable."""
from lightrag.api.config import get_env_value
os.environ["LIGHTRAG_API_PREFIX"] = "unit-test-back/api"
try:
value = get_env_value("LIGHTRAG_API_PREFIX", "")
assert value == "unit-test-back/api"
finally:
del os.environ["LIGHTRAG_API_PREFIX"]
class TestPathNormalization:
"""User input for `--api-prefix` may contain trailing slashes, a missing
leading slash, or be just '/'. create_app must canonicalize these before
passing to FastAPI's `root_path`, which doesn't accept arbitrary strings."""
def _build(self, *cli_args):
# sys.argv must be the lightrag-server form *before* lightrag_server is
# imported, because importing lightrag.api.utils_api evaluates
# `global_args.whitelist_paths` at module top level, which triggers
# parse_args() against whatever sys.argv currently holds.
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server", *cli_args]
from lightrag.api.config import parse_args
from lightrag.api.lightrag_server import create_app
args = parse_args()
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
return create_app(args)
finally:
sys.argv = original_argv
def test_api_prefix_slash_only_treated_as_empty(self):
"""`--api-prefix /` is degenerate; must collapse to no prefix."""
app = self._build("--api-prefix", "/")
assert not app.root_path
def test_api_prefix_trailing_slash_stripped(self):
"""Trailing slash on api_prefix is stripped to keep OpenAPI servers
URL clean and avoid double-slash artifacts."""
app = self._build("--api-prefix", "/api/v1/")
assert app.root_path == "/api/v1"
def test_api_prefix_missing_leading_slash_added(self):
app = self._build("--api-prefix", "api/v1")
assert app.root_path == "/api/v1"
class TestRuntimeConfigInjection:
"""End-to-end tests for the WebUI runtime-config injection.
The browser-visible URL prefixes are no longer baked into the bundle.
Instead, the server replaces a placeholder comment in index.html with
a `<script>window.__LIGHTRAG_CONFIG__ = {...}</script>` snippet on
every HTML response, so one build can serve any reverse-proxy mount.
These tests stage a minimal index.html in a tmp dir, patch
`lightrag_server.__file__` so both `check_frontend_build()` and the
static-files mount resolve to it, then drive the app via TestClient
and assert that the body contains the expected injected JSON.
"""
PLACEHOLDER = "<!-- __LIGHTRAG_RUNTIME_CONFIG__ -->"
def _stage_index_html(self, tmp_path, *, with_placeholder=True):
"""Mirror what Vite emits: a tiny index.html with the runtime-config
placeholder in <head> plus a hashed asset reference.
with_placeholder=False simulates a stale build that pre-dates this
feature — the server should serve it untouched, not crash.
"""
webui_dir = tmp_path / "webui"
webui_dir.mkdir()
placeholder = self.PLACEHOLDER if with_placeholder else ""
(webui_dir / "index.html").write_text(
"<!doctype html><html><head>"
f"{placeholder}"
'<script type="module" crossorigin src="./assets/index-X.js"></script>'
'<link rel="stylesheet" href="./assets/index-X.css">'
"</head><body><div id=root></div></body></html>",
encoding="utf-8",
)
def _build_app(self, tmp_path, monkeypatch, *cli_args):
# Force benign argv before the (potentially fresh) module import —
# see TestPathNormalization._build for the rationale.
monkeypatch.setattr(sys, "argv", ["lightrag-server", *cli_args])
from lightrag.api.config import parse_args
from lightrag.api import lightrag_server
from lightrag.api.lightrag_server import create_app
# Redirect both check_frontend_build() and the StaticFiles mount to
# our staged tmp directory.
monkeypatch.setattr(
lightrag_server, "__file__", str(tmp_path / "lightrag_server.py")
)
args = parse_args()
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
return create_app(args)
def test_injection_populates_window_config_with_prefix(self, tmp_path, monkeypatch):
"""With api_prefix=/site01, the injected script must carry both the
api prefix and the composed webui prefix the browser will see."""
self._stage_index_html(tmp_path)
app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/site01")
client = TestClient(app)
response = client.get("/site01/webui/")
assert response.status_code == 200
body = response.text
# Placeholder must be gone and replaced with the runtime config.
assert self.PLACEHOLDER not in body
assert "window.__LIGHTRAG_CONFIG__" in body
assert '"apiPrefix": "/site01"' in body or '"apiPrefix":"/site01"' in body
assert (
'"webuiPrefix": "/site01/webui/"' in body
or '"webuiPrefix":"/site01/webui/"' in body
)
def test_injection_default_prefixes_when_unconfigured(self, tmp_path, monkeypatch):
"""No CLI flags → empty api prefix and the default webui mount.
The injected JSON must reflect this so the SPA falls through to
same-origin requests."""
self._stage_index_html(tmp_path)
app = self._build_app(tmp_path, monkeypatch)
client = TestClient(app)
response = client.get("/webui/")
assert response.status_code == 200
body = response.text
assert '"apiPrefix": ""' in body or '"apiPrefix":""' in body
assert '"webuiPrefix": "/webui/"' in body or '"webuiPrefix":"/webui/"' in body
def test_missing_placeholder_serves_original_html(self, tmp_path, monkeypatch):
"""Older builds without the placeholder must still serve cleanly —
no 500, no partial replacement, just the original HTML. Avoids
breaking anyone whose pre-built bundle is in use during an upgrade."""
self._stage_index_html(tmp_path, with_placeholder=False)
app = self._build_app(tmp_path, monkeypatch)
client = TestClient(app)
response = client.get("/webui/")
assert response.status_code == 200
# No placeholder was present, so no injected script either.
assert "window.__LIGHTRAG_CONFIG__" not in response.text
def test_injection_idempotent_across_requests(self, tmp_path, monkeypatch):
"""Each request reads the file fresh; the placeholder must be
present in the *file* even after replies (we don't mutate it)."""
self._stage_index_html(tmp_path)
app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/abc")
client = TestClient(app)
first = client.get("/abc/webui/").text
second = client.get("/abc/webui/").text
assert first == second
# Source file untouched.
on_disk = (tmp_path / "webui" / "index.html").read_text(encoding="utf-8")
assert self.PLACEHOLDER in on_disk
def test_html_response_keeps_no_cache_headers(self, tmp_path, monkeypatch):
"""Injection must not regress the existing no-cache behaviour for
HTML — otherwise an updated runtime config could be cached client-
side and never picked up."""
self._stage_index_html(tmp_path)
app = self._build_app(tmp_path, monkeypatch, "--api-prefix", "/x")
client = TestClient(app)
response = client.get("/x/webui/")
assert response.status_code == 200
cache_control = response.headers.get("cache-control", "")
assert "no-cache" in cache_control
assert "no-store" in cache_control
class TestUvicornRootPathSemantics:
"""Lock in the deployment contract that both proxy-strip and verbatim
forwarding work through FastAPI's app-level ``root_path`` plus a
``_RootPathNormalizationMiddleware`` — without ever passing
``root_path`` through to uvicorn/gunicorn.
Background:
- uvicorn builds ``scope["path"] = uvicorn.root_path + <incoming http
path>`` and ``scope["root_path"] = uvicorn.root_path``
(uvicorn/protocols/http/h11_impl.py).
- FastAPI's ``__call__`` overrides ``scope["root_path"]`` from
``app.root_path`` but does NOT touch ``scope["path"]``
(fastapi/applications.py:1131-1134).
- Starlette's ``Mount.matches`` mutates the child scope's root_path
to ``original + matched_path`` and again leaves ``scope["path"]``
untouched (starlette/routing.py:401-432). The inner sub-app
(e.g. StaticFiles) then sees ``scope["path"]`` and
``scope["root_path"]`` that do not overlap, and 404s.
Concrete failure mode without the middleware (proxy strips /site01,
backend sees ``/webui/``):
outer get_route_path: /webui/ does not start with /site01 → /webui/
Mount.matches: path_regex "^/webui/(?P<path>.*)$" matches
child scope.root_path = /site01/webui
child scope.path = /webui/ (unchanged)
StaticFiles.get_path: /webui/ does not start with /site01/webui →
returns /webui/ → looked up as filename
"webui" inside the webui static dir → 404
``_RootPathNormalizationMiddleware`` runs before routing and prepends
``root_path`` to ``scope["path"]`` whenever the latter does not already
start with it. This makes both modes converge to the canonical ASGI
form (path always contains root_path) without requiring uvicorn's
--root-path — which would break verbatim mode by double-prefixing.
"""
@staticmethod
async def _call_with_scope(app, http_path, *, uvicorn_root_path=""):
"""Drive the ASGI app the way uvicorn would.
Simulates uvicorn's scope construction so we can catch the
path-doubling bug that TestClient hides.
"""
full_path = uvicorn_root_path + http_path
scope = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"server": ("testserver", 80),
"client": ("testclient", 50000),
"root_path": uvicorn_root_path,
"path": full_path,
"raw_path": full_path.encode("ascii"),
"query_string": b"",
"headers": [],
"state": {},
}
status = {"code": None}
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
if message["type"] == "http.response.start":
status["code"] = message["status"]
await app(scope, receive, send)
return status["code"]
def _build_app_with_prefix(self, prefix):
original_argv = sys.argv.copy()
try:
sys.argv = ["lightrag-server", "--api-prefix", prefix]
from lightrag.api.config import parse_args
from lightrag.api.lightrag_server import create_app
args = parse_args()
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
return create_app(args)
finally:
sys.argv = original_argv
@pytest.mark.asyncio
async def test_route_strip_mode_matches(self):
"""Plain Route, proxy-strip mode: backend receives /openapi.json."""
app = self._build_app_with_prefix("/site01")
status = await self._call_with_scope(app, "/openapi.json")
assert status == 200
@pytest.mark.asyncio
async def test_route_verbatim_mode_matches(self):
"""Plain Route, verbatim mode: backend receives /site01/openapi.json.
Locks in the contract that PR #3128's original fix broke: setting
uvicorn root_path would have made this case 404 by doubling the
prefix in scope["path"].
"""
app = self._build_app_with_prefix("/site01")
status = await self._call_with_scope(app, "/site01/openapi.json")
assert status == 200
@pytest.mark.asyncio
async def test_mount_strip_mode_matches(self):
"""WebUI Mount, proxy-strip mode: backend receives /webui/.
This is the bug the middleware fixes. Without normalization,
StaticFiles.get_path sees path=/webui/ and root_path=/site01/webui
(mutated by Mount.matches) and serves the literal "webui" filename
lookup → 404. With normalization, scope.path becomes
/site01/webui/ before routing and the lookup resolves to
index.html.
"""
app = self._build_app_with_prefix("/site01")
status = await self._call_with_scope(app, "/webui/")
assert status == 200
@pytest.mark.asyncio
async def test_mount_verbatim_mode_matches(self):
"""WebUI Mount, verbatim mode: backend receives /site01/webui/.
Already canonical; the middleware is a no-op for this case. The
test guards against accidentally regressing verbatim while fixing
strip — symmetric to test_route_verbatim_mode_matches but exercises
the Mount path with its nested ``get_route_path`` resolution.
"""
app = self._build_app_with_prefix("/site01")
status = await self._call_with_scope(app, "/site01/webui/")
assert status == 200
@pytest.mark.asyncio
async def test_simulated_uvicorn_root_path_breaks_verbatim(self):
"""Document the failure mode that the revert prevents.
If uvicorn's root_path were also "/site01", uvicorn would build
scope.path = "/site01" + "/site01/openapi.json". Starlette strips
one prefix; the remaining "/site01/openapi.json" has no route.
"""
app = self._build_app_with_prefix("/site01")
status = await self._call_with_scope(
app, "/site01/openapi.json", uvicorn_root_path="/site01"
)
assert status == 404
def test_launcher_does_not_pass_root_path_to_uvicorn(self, monkeypatch, tmp_path):
"""Guard against re-adding root_path to the uvicorn launcher kwargs.
Mocks uvicorn.run and exercises lightrag_server.main() far enough
to capture the config dict, then asserts root_path is absent.
"""
monkeypatch.setenv("LIGHTRAG_API_PREFIX", "/site01")
monkeypatch.setattr(sys, "argv", ["lightrag-server"])
from lightrag.api import lightrag_server
captured = {}
def fake_run(**kwargs):
captured.update(kwargs)
monkeypatch.setattr(lightrag_server, "uvicorn", MagicMock(run=fake_run))
monkeypatch.setattr(lightrag_server, "check_env_file", lambda: True)
monkeypatch.setattr(
lightrag_server, "check_and_install_dependencies", lambda: None
)
monkeypatch.setattr(lightrag_server, "configure_logging", lambda: None)
monkeypatch.setattr(lightrag_server, "update_uvicorn_mode_config", lambda: None)
monkeypatch.setattr(lightrag_server, "display_splash_screen", lambda *_: None)
with patch("lightrag.api.lightrag_server.LightRAG") as mock_rag:
mock_rag.return_value = MagicMock()
# Re-parse args under the patched env so global_args picks up the prefix.
from lightrag.api.config import parse_args, global_args as _ga
new_args = parse_args()
for attr in vars(new_args):
setattr(_ga, attr, getattr(new_args, attr))
lightrag_server.main()
assert "root_path" not in captured, (
"uvicorn_config must not include root_path; rely on FastAPI's "
"app-level root_path only — see TestUvicornRootPathSemantics docstring."
)
def test_gunicorn_uses_upstream_uvicorn_worker(self):
"""Symmetric guard for the multi-worker launcher.
gunicorn_config.worker_class must remain the upstream
``uvicorn.workers.UvicornWorker`` — a custom subclass injecting
root_path via CONFIG_KWARGS would re-introduce the same
path-doubling regression in worker processes.
"""
from lightrag.api import gunicorn_config
assert gunicorn_config.worker_class == "uvicorn.workers.UvicornWorker"
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
"""Unit tests for ``chunking_by_recursive_character`` (process_options=R)."""
import pytest
pytest.importorskip("langchain_text_splitters")
from langchain_text_splitters import RecursiveCharacterTextSplitter # noqa: E402
from lightrag.chunker import chunking_by_recursive_character # noqa: E402
from lightrag.chunker.recursive_character import _split_text_with_spans # noqa: E402
from lightrag.utils import Tokenizer, TokenizerInterface # noqa: E402
class _CharTokenizer(TokenizerInterface):
"""1 char ≈ 1 token; lets assertions reason in terms of input length."""
def encode(self, content: str):
return [ord(ch) for ch in content]
def decode(self, tokens):
return "".join(chr(t) for t in tokens)
def _tok() -> Tokenizer:
return Tokenizer("char-tokenizer", _CharTokenizer())
@pytest.mark.offline
def test_empty_input_returns_empty_list():
chunks = chunking_by_recursive_character(_tok(), "")
assert chunks == []
@pytest.mark.offline
def test_short_input_single_chunk():
body = "Para A.\n\nPara B."
chunks = chunking_by_recursive_character(_tok(), body, chunk_token_size=1000)
assert len(chunks) == 1
assert chunks[0]["content"] == body
assert chunks[0]["_source_span"] == {"start": 0, "end": len(body)}
assert chunks[0]["chunk_order_index"] == 0
assert chunks[0]["tokens"] == len(body)
@pytest.mark.offline
def test_paragraph_separator_used_first():
"""``\\n\\n`` is the first separator in the default cascade — three
paragraphs that each fit under the cap should split exactly there."""
body = "Alpha section.\n\nBeta section.\n\nGamma section."
chunks = chunking_by_recursive_character(
_tok(),
body,
chunk_token_size=20,
chunk_overlap_token_size=0,
)
assert [c["chunk_order_index"] for c in chunks] == list(range(len(chunks)))
assert all(c["content"].strip() for c in chunks)
# Reconstructed (joined with the splitter's separator semantics) must
# at least contain each original paragraph as a substring.
joined = "\n\n".join(c["content"] for c in chunks)
for para in ("Alpha section.", "Beta section.", "Gamma section."):
assert para in joined
@pytest.mark.offline
def test_token_field_matches_tokenizer_encode_length():
chunks = chunking_by_recursive_character(
_tok(),
"X" * 50 + "\n\n" + "Y" * 50,
chunk_token_size=40,
chunk_overlap_token_size=5,
)
tok = _tok()
for c in chunks:
assert c["tokens"] == len(tok.encode(c["content"]))
@pytest.mark.offline
def test_custom_separators_are_honored():
body = "alpha|beta|gamma|delta"
chunks = chunking_by_recursive_character(
_tok(),
body,
chunk_token_size=10,
chunk_overlap_token_size=0,
separators=["|", ""],
)
contents = [c["content"] for c in chunks]
# With "|" as the primary separator and a 10-token cap, each 5-char
# token name must land in its own chunk.
assert any("alpha" in c for c in contents)
assert any("delta" in c for c in contents)
# Every chunk fits the cap.
for c in chunks:
assert c["tokens"] <= 10
@pytest.mark.offline
def test_recursive_chunks_carry_exact_source_spans_with_overlap():
body = "Alpha section.\n\nBeta section.\n\nGamma section."
chunks = chunking_by_recursive_character(
_tok(),
body,
chunk_token_size=22,
chunk_overlap_token_size=6,
)
assert len(chunks) >= 2
for chunk in chunks:
span = chunk["_source_span"]
assert body[span["start"] : span["end"]] == chunk["content"]
@pytest.mark.offline
def test_long_unique_text_keeps_every_source_span():
"""Every chunk of a long, non-repeating document must carry a span.
Regression for the LangChain ``add_start_index`` unit mismatch: with a
token-based length function its search cursor overshot each chunk's true
start, dropping ``_source_span`` (and failing span-first backfill) on
roughly half of a unique document's chunks.
"""
body = " ".join(f"word{i}" for i in range(800))
chunks = chunking_by_recursive_character(
_tok(),
body,
chunk_token_size=50,
chunk_overlap_token_size=10,
)
assert len(chunks) > 5
for chunk in chunks:
span = chunk["_source_span"]
assert body[span["start"] : span["end"]] == chunk["content"]
@pytest.mark.offline
def test_repeated_text_spans_tile_whole_document():
"""Spans over heavily repeated text must tile the document, not cluster.
Regression for the repeated-content ambiguity: a naive forward ``find``
matches every identical overlapping chunk to the nearest repetition, so all
spans collapse into the document head and the tail gets no provenance. The
offset-aware splitter mirror must instead advance spans across the whole text.
"""
unit = "ABCDE fghij. "
body = unit * 60
chunks = chunking_by_recursive_character(
_tok(),
body,
chunk_token_size=40,
chunk_overlap_token_size=10,
)
spans = [c["_source_span"] for c in chunks]
assert len(spans) == len(chunks) # no chunk lost its span
# Each span is exact and starts are monotonically non-decreasing.
for chunk, span in zip(chunks, spans):
assert body[span["start"] : span["end"]] == chunk["content"]
starts = [s["start"] for s in spans]
assert starts == sorted(starts)
# Spans reach the document tail rather than clustering at the head. Under
# the old nearest-repetition behaviour the furthest end stalled near the
# first few hundred chars; tiling reaches within one ``unit`` of the end.
assert max(s["end"] for s in spans) >= len(body) - len(unit)
@pytest.mark.offline
def test_span_mirror_matches_langchain_split_text():
"""Drift guard for the offset-aware mirror of LangChain's splitter."""
body = (
"Alpha repeats.\n\n"
"ff hh aa hh ee\n"
"ff hh aa hh ee\n\n"
"Tail repeats. Tail repeats. Tail repeats."
)
tok = _tok()
def length_function(text: str) -> int:
return len(tok.encode(text))
splitter = RecursiveCharacterTextSplitter(
chunk_size=36,
chunk_overlap=14,
length_function=length_function,
strip_whitespace=True,
)
mirrored = _split_text_with_spans(
body,
base_offset=0,
separators=splitter._separators,
chunk_size=splitter._chunk_size,
chunk_overlap=splitter._chunk_overlap,
length_function=length_function,
keep_separator=splitter._keep_separator,
is_separator_regex=splitter._is_separator_regex,
strip_whitespace=splitter._strip_whitespace,
)
assert [piece for piece, _, _ in mirrored] == splitter.split_text(body)
for piece, start, end in mirrored:
assert body[start:end] == piece
@pytest.mark.offline
def test_chunk_repeating_an_earlier_block_maps_to_its_own_block():
"""A chunk whose text also appears in an earlier, different block must map
to *its own* occurrence, not the earlier copy.
Regression for cross-block duplicate provenance: merged text ``"ff aa\\n\\naa"``
splits into ``["ff aa", "aa"]``. The second chunk ``"aa"`` is block 2, so its
span must point at the *second* ``"aa"`` (offset 7), not the ``"aa"`` inside
``"ff aa"`` (offset 3). The earlier locator anchored on a predicted offset
and snapped to the nearest copy, silently emitting the wrong — but
legal-looking — span that strict backfill would trust.
"""
merged = "ff aa\n\naa"
chunks = chunking_by_recursive_character(
_tok(),
merged,
chunk_token_size=5,
chunk_overlap_token_size=1,
)
assert [c["content"] for c in chunks] == ["ff aa", "aa"]
assert chunks[0]["_source_span"] == {"start": 0, "end": 5}
# The second "aa" lives in block 2 at offset 7, after the "\n\n" separator —
# not the "aa" embedded in "ff aa" at offset 3.
assert chunks[1]["_source_span"] == {"start": 7, "end": 9}
assert merged[7:9] == "aa"
@pytest.mark.offline
def test_repeated_block_window_does_not_shift_to_previous_duplicate():
"""A repeated multi-block chunk must not slide back into the prior duplicate."""
block = "ff hh aa hh ee"
merged = "\n\n".join([block] * 4)
chunks = chunking_by_recursive_character(
_tok(),
merged,
chunk_token_size=36,
chunk_overlap_token_size=14,
)
assert [c["content"] for c in chunks] == [
f"{block}\n\n{block}",
f"{block}\n\n{block}",
]
assert chunks[0]["_source_span"] == {"start": 0, "end": 30}
assert chunks[1]["_source_span"] == {"start": 32, "end": 62}
@@ -0,0 +1,232 @@
"""Unit tests for ``chunking_by_semantic_vector`` (process_options=V)."""
import asyncio
import logging
import numpy as np
import pytest
pytest.importorskip("langchain_experimental")
from lightrag.chunker import chunking_by_semantic_vector # noqa: E402
from lightrag.utils import EmbeddingFunc, Tokenizer, TokenizerInterface # noqa: E402
class _CharTokenizer(TokenizerInterface):
"""1 char ≈ 1 token."""
def encode(self, content: str):
return [ord(ch) for ch in content]
def decode(self, tokens):
return "".join(chr(t) for t in tokens)
def _tok() -> Tokenizer:
return Tokenizer("char-tokenizer", _CharTokenizer())
def _make_deterministic_embedding(dim: int = 8) -> EmbeddingFunc:
"""A toy async embedding func that hashes each input text into a
stable unit vector — enough to drive SemanticChunker without needing
a real model."""
async def _embed(texts, **kwargs):
rng = np.random.default_rng(seed=0)
# Use a simple hash → seeded rng to get reproducible vectors per text.
rows = []
for text in texts:
seed = abs(hash(text)) % (2**32)
rng = np.random.default_rng(seed=seed)
vec = rng.normal(size=dim).astype(np.float32)
vec /= np.linalg.norm(vec) or 1.0
rows.append(vec)
return np.vstack(rows)
return EmbeddingFunc(embedding_dim=dim, max_token_size=4096, func=_embed)
@pytest.mark.offline
def test_v_chunker_runs_with_stub_embedding():
"""Async chunker should split a multi-sentence body into ≥1 chunk
when given a working embedding func."""
body = (
"Quantum mechanics describes nature at small scales. "
"It contradicts classical intuition. "
"Bread is baked from flour. "
"Sourdough requires a long fermentation. "
)
async def _run():
chunks = await chunking_by_semantic_vector(
_tok(),
body,
chunk_token_size=200,
embedding_func=_make_deterministic_embedding(),
)
return chunks
chunks = asyncio.run(_run())
assert len(chunks) >= 1
# Each chunk dict has the canonical schema.
assert all({"tokens", "content", "chunk_order_index"} <= set(c) for c in chunks)
# chunk_order_index is contiguous starting at 0.
assert [c["chunk_order_index"] for c in chunks] == list(range(len(chunks)))
# No empty content rows.
assert all(c["content"].strip() for c in chunks)
for chunk in chunks:
span = chunk["_source_span"]
assert body[span["start"] : span["end"]] == chunk["content"]
class _ListHandler(logging.Handler):
def __init__(self) -> None:
super().__init__()
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
@pytest.mark.offline
def test_v_chunker_falls_back_to_recursive_when_no_embedding():
"""When ``embedding_func`` is None, V must log a warning and route
to chunking_by_recursive_character (R) — V's only differentiator
is embeddings, so without them R is the closest neighbour."""
body = "Para A.\n\nPara B for fallback test.\n\nPara C."
lightrag_logger = logging.getLogger("lightrag")
handler = _ListHandler()
handler.setLevel(logging.WARNING)
lightrag_logger.addHandler(handler)
try:
async def _run():
return await chunking_by_semantic_vector(
_tok(),
body,
chunk_token_size=20,
embedding_func=None,
)
chunks = asyncio.run(_run())
finally:
lightrag_logger.removeHandler(handler)
assert len(chunks) >= 1
assert any(
"embedding_func is None" in rec.getMessage()
for rec in handler.records
if rec.levelno == logging.WARNING
)
@pytest.mark.offline
def test_v_chunker_empty_input_returns_empty_list():
async def _run():
return await chunking_by_semantic_vector(_tok(), "")
assert asyncio.run(_run()) == []
@pytest.mark.offline
def test_v_chunker_spans_repeated_sentences_and_number_of_chunks():
body = (
"Repeat sentence.\n"
"Repeat sentence. "
"A distant topic appears. "
"Another distant topic appears."
)
async def _run():
return await chunking_by_semantic_vector(
_tok(),
body,
chunk_token_size=400,
embedding_func=_make_deterministic_embedding(),
number_of_chunks=2,
)
chunks = asyncio.run(_run())
assert chunks
for chunk in chunks:
span = chunk["_source_span"]
assert body[span["start"] : span["end"]] == chunk["content"]
@pytest.mark.offline
def test_v_oversized_group_resplit_preserves_child_source_spans():
body = " ".join(f"word{i:02d}" for i in range(30)) + "."
async def _run():
return await chunking_by_semantic_vector(
_tok(),
body,
chunk_token_size=35,
embedding_func=_make_deterministic_embedding(),
)
chunks = asyncio.run(_run())
assert len(chunks) > 1
for chunk in chunks:
span = chunk["_source_span"]
assert body[span["start"] : span["end"]] == chunk["content"]
assert chunk["tokens"] <= 35
@pytest.mark.offline
def test_semantic_groups_mirror_matches_upstream_split_text():
"""Drift guard: ``_semantic_groups_with_spans`` re-implements the private body
of ``SemanticChunker.split_text`` to keep verbatim source spans. If an upstream
langchain-experimental release changes that grouping (or a private member it
relies on), this catches it — the mirror must yield the same group count and the
same content modulo whitespace (it keeps the verbatim slice; upstream reflows
sentences with single spaces)."""
from langchain_core.embeddings import Embeddings
from langchain_experimental.text_splitter import SemanticChunker
from lightrag.chunker.semantic_vector import _semantic_groups_with_spans
from lightrag.constants import DEFAULT_SENTENCE_SPLIT_REGEX
class _SyncEmbeddings(Embeddings):
"""Deterministic per-text vectors, mirroring _make_deterministic_embedding
but synchronous so SemanticChunker can be driven directly."""
def embed_documents(self, texts):
rows = []
for text in texts:
seed = abs(hash(text)) % (2**32)
rng = np.random.default_rng(seed=seed)
vec = rng.normal(size=8).astype(np.float64)
vec /= np.linalg.norm(vec) or 1.0
rows.append(vec.tolist())
return rows
def embed_query(self, text):
return self.embed_documents([text])[0]
text = (
"Quantum mechanics describes nature at small scales. "
"It contradicts classical intuition. "
"Bread is baked from flour. "
"Sourdough requires a long fermentation."
)
splitter = SemanticChunker(
_SyncEmbeddings(),
buffer_size=1,
breakpoint_threshold_type="percentile",
sentence_split_regex=DEFAULT_SENTENCE_SPLIT_REGEX,
)
# Same process → identical deterministic embeddings on both calls → identical
# distances/breakpoints, so any divergence is a real grouping drift.
upstream = splitter.split_text(text)
mirror = _semantic_groups_with_spans(splitter, text)
assert len(mirror) == len(upstream)
for (grp_text, start, end), up in zip(mirror, upstream):
assert grp_text == text[start:end] # mirror keeps the verbatim source slice
assert "".join(grp_text.split()) == "".join(up.split())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,715 @@
"""F-chunking parity between raw and lightrag formats.
After the F-chunking unification, ``apipeline_process_enqueue_documents``
strips the ``{{LRdoc}}`` marker from lightrag-format content and feeds the
result into the same ``chunking_func`` used by raw documents. These tests
guard the contract end-to-end:
* T1: identical input text produces identical chunking inputs whether it
arrives as raw or as an already-parsed lightrag full_docs row pulled
back through the parse queue on resume (ReuseParser) — the only
production path for lightrag rows now that the ``docs_format="lightrag"``
enqueue entrypoint is removed (the in-run parse → chunk path is T6).
* T2: after a pending_parse document is parsed by the native engine,
``full_docs.content`` carries the *full* merged text with the
``{{LRdoc}}`` marker (written by ``_persist_parsed_full_docs``), while
``doc_status`` reports the bare body length / summary (no marker
leakage).
* T3: ``extraction_meta["parse_format"]`` (surfaced via
``doc_status.metadata``) is ``"lightrag"`` for natively-parsed docs —
previously a structured-parse fallback always tagged ``raw`` and
silently mislabelled the persisted record.
* T4: a raw document whose body coincidentally *looks* like structured
JSONL is still tokenised as plain text — guards against re-introducing
dropped structured-format detection in the raw path.
* T5: ``process_options`` selecting R/V/P logs the deferred-strategy
warning and falls back to fixed-token chunking.
* T6: a ``pending_parse`` document that resolves to lightrag at parse
time ends up with a real ``content_summary`` after PROCESSED — the
ANALYZING transition refreshes the summary from the parsed body so
pending-parse rows no longer carry the empty enqueue-time placeholder
through to the user-facing list APIs.
* T7: a raw document whose body *literally* starts with ``{{LRdoc}}``
is chunked verbatim — guards against accidental re-introduction of an
unconditional ``strip_lightrag_doc_prefix`` at the chunking boundary
(which would silently drop the user's first 9 characters).
"""
import asyncio
import json
import logging
from pathlib import Path
import numpy as np
import pytest
from lightrag import LightRAG, ROLES, RoleLLMConfig
from lightrag.constants import (
FULL_DOCS_FORMAT_LIGHTRAG,
FULL_DOCS_FORMAT_PENDING_PARSE,
LIGHTRAG_DOC_CONTENT_PREFIX,
)
from lightrag.utils import (
EmbeddingFunc,
Tokenizer,
compute_mdhash_id,
get_content_summary,
)
from lightrag.utils_pipeline import make_lightrag_doc_content
# ---------------------------------------------------------------------------
# Shared fixtures (mirrors the harness used by test_pipeline_release_closure)
# ---------------------------------------------------------------------------
class _SimpleTokenizerImpl:
"""Char-level tokenizer so 1 char ≈ 1 token; keeps assertions readable."""
def encode(self, content: str) -> list[int]:
return [ord(ch) for ch in content]
def decode(self, tokens: list[int]) -> str:
return "".join(chr(t) for t in tokens)
async def _mock_embedding(texts: list[str]) -> np.ndarray:
return np.random.rand(len(texts), 32)
async def _mock_llm(prompt, **kwargs):
return '{"name":"x","summary":"s","detail_description":"d"}'
_ROLE_FIELD_SUFFIXES = (
("_llm_model_func", "func"),
("_llm_model_kwargs", "kwargs"),
("_llm_model_max_async", "max_async"),
("_llm_timeout", "timeout"),
)
def _new_rag(tmp_path: Path, **kwargs) -> LightRAG:
role_configs: dict[str, RoleLLMConfig] = {}
for spec in ROLES:
bucket = {}
for suffix, target in _ROLE_FIELD_SUFFIXES:
key = f"{spec.name}{suffix}"
if key in kwargs:
bucket[target] = kwargs.pop(key)
if bucket:
role_configs[spec.name] = RoleLLMConfig(**bucket)
if role_configs:
kwargs["role_llm_configs"] = role_configs
return LightRAG(
working_dir=str(tmp_path),
workspace=f"chunking-parity-{tmp_path.name}",
llm_model_func=_mock_llm,
embedding_func=EmbeddingFunc(
embedding_dim=32,
max_token_size=4096,
func=_mock_embedding,
),
tokenizer=Tokenizer("mock-tokenizer", _SimpleTokenizerImpl()),
**kwargs,
)
def _attach_chunking_spy(rag: LightRAG) -> dict:
"""Replace ``rag.chunking_func`` with a recording wrapper.
Returns a dict whose ``input`` key receives the second positional arg
(the content string) at every chunking call. The original chunker
runs normally so the pipeline reaches PROCESSED.
"""
captured: dict = {"input": None, "calls": 0}
real = rag.chunking_func
def _spy(tokenizer, content, *args, **kwargs):
captured["input"] = content
captured["calls"] += 1
return real(tokenizer, content, *args, **kwargs)
rag.chunking_func = _spy
return captured
# ---------------------------------------------------------------------------
# T1 — parity: raw vs lightrag produce identical chunking input
# ---------------------------------------------------------------------------
async def _seed_lightrag_row(rag: LightRAG, doc_id: str, *, body: str, file_path: str):
"""Seed an already-parsed lightrag full_docs row + a PENDING doc_status.
Mirrors how lightrag-format rows exist in production: written by the
parsers (``_persist_parsed_full_docs``) and pulled back through the
parse queue (→ ReuseParser) only on resume/retry.
"""
from datetime import datetime, timezone
await rag.full_docs.upsert(
{
doc_id: {
"content": make_lightrag_doc_content(body),
"file_path": file_path,
"parse_format": FULL_DOCS_FORMAT_LIGHTRAG,
}
}
)
now = datetime.now(timezone.utc).isoformat()
await rag.doc_status.upsert(
{
doc_id: {
"status": "pending",
"content_summary": get_content_summary(body),
"content_length": len(body),
"file_path": file_path,
"created_at": now,
"updated_at": now,
"track_id": "track-lr",
}
}
)
@pytest.mark.offline
def test_chunking_input_parity_raw_vs_lightrag(tmp_path):
"""Same body text must reach ``chunking_func`` with byte-identical input
whether it arrives as raw or as a resumed lightrag full_docs row."""
paragraphs = [
"Alpha paragraph with enough words to make it look real.",
"Beta paragraph extends the body so chunking has substance.",
"Gamma paragraph closes the document with a few more sentences.",
]
expected_merged = "\n\n".join(paragraphs)
async def _run():
# ---- RAW path ----
rag_raw = _new_rag(tmp_path / "raw")
await rag_raw.initialize_storages()
spy_raw = _attach_chunking_spy(rag_raw)
try:
await rag_raw.apipeline_enqueue_documents(
expected_merged,
file_paths="parity_raw.txt",
track_id="track-raw",
)
await rag_raw.apipeline_process_enqueue_documents()
finally:
await rag_raw.finalize_storages()
# ---- LIGHTRAG path (resume: seeded parsed row → ReuseParser) ----
rag_lr = _new_rag(tmp_path / "lr")
await rag_lr.initialize_storages()
spy_lr = _attach_chunking_spy(rag_lr)
try:
await _seed_lightrag_row(
rag_lr,
"doc-parity-lr",
body=expected_merged,
file_path="parity.lightrag",
)
await rag_lr.apipeline_process_enqueue_documents()
finally:
await rag_lr.finalize_storages()
assert spy_raw["calls"] >= 1, "raw doc never reached chunking_func"
assert spy_lr["calls"] >= 1, "lightrag doc never reached chunking_func"
assert spy_lr["input"] == spy_raw["input"] == expected_merged, (
"chunking_func received different inputs for raw vs lightrag; "
f"raw={spy_raw['input']!r}\nlr={spy_lr['input']!r}"
)
assert not spy_lr["input"].startswith(LIGHTRAG_DOC_CONTENT_PREFIX), (
"{{LRdoc}} marker leaked into chunking_func input"
)
asyncio.run(_run())
# ---------------------------------------------------------------------------
# T2 — full_docs.content carries full text; doc_status reports bare body
# ---------------------------------------------------------------------------
def _stub_docx_blocks(monkeypatch, body_paragraphs: list[str]) -> None:
"""Stub the docx extractor so native parse yields deterministic blocks;
the adapter still writes the canonical .blocks.jsonl + sidecars and
persists the lightrag-format full_docs row."""
def _stub_extract(file_path, drawing_context=None, **kwargs):
return [
{
"uuid": f"para-{i}",
"uuid_end": f"para-{i}",
"heading": "",
"content": para,
"type": "text",
"parent_headings": [],
"level": 0,
"table_chunk_role": "none",
}
for i, para in enumerate(body_paragraphs)
]
monkeypatch.setattr(
"lightrag.parser.docx.parse_document.extract_docx_blocks",
_stub_extract,
)
@pytest.mark.offline
def test_full_docs_content_carries_full_merged_text(tmp_path, monkeypatch):
"""After the native engine parses a pending_parse upload,
``_persist_parsed_full_docs`` stores the *full* merged text with the
``{{LRdoc}}`` marker while doc_status reports bare-body semantics."""
body = "x" * 5000 # single paragraph, 5000 chars
async def _run():
input_dir = tmp_path / "input"
input_dir.mkdir()
monkeypatch.setenv("INPUT_DIR", str(input_dir))
(input_dir / "big.docx").write_bytes(b"fake docx bytes")
_stub_docx_blocks(monkeypatch, [body])
rag = _new_rag(tmp_path / "work")
await rag.initialize_storages()
try:
await rag.apipeline_enqueue_documents(
"",
file_paths="big.docx",
docs_format=FULL_DOCS_FORMAT_PENDING_PARSE,
parse_engine="native",
track_id="track-big",
)
await rag.apipeline_process_enqueue_documents()
doc_id = compute_mdhash_id("big.docx", prefix="doc-")
full_doc = await rag.full_docs.get_by_id(doc_id)
assert full_doc is not None
# full_docs preserves the marker AND the full merged text.
assert full_doc["content"] == LIGHTRAG_DOC_CONTENT_PREFIX + body
assert full_doc.get("parse_format") == FULL_DOCS_FORMAT_LIGHTRAG
assert full_doc.get("sidecar_location"), (
"native parse must record the sidecar_location on the "
"lightrag full_docs row"
)
# doc_status reports body-length semantics (no marker leakage).
status_doc = await rag.doc_status.get_by_id(doc_id)
assert status_doc is not None
length = (
status_doc.get("content_length")
if isinstance(status_doc, dict)
else getattr(status_doc, "content_length", None)
)
summary = (
status_doc.get("content_summary")
if isinstance(status_doc, dict)
else getattr(status_doc, "content_summary", "")
)
assert length == 5000, f"content_length should match body, got {length}"
assert not summary.startswith(LIGHTRAG_DOC_CONTENT_PREFIX)
finally:
await rag.finalize_storages()
asyncio.run(_run())
# ---------------------------------------------------------------------------
# T3 — extraction_meta.parse_format reflects persisted format (regression guard)
# ---------------------------------------------------------------------------
@pytest.mark.offline
def test_extraction_meta_records_lightrag_parse_format(tmp_path, monkeypatch):
"""Before the unification, a structured-parse fallback tagged
``extraction_meta.parse_format = raw`` for lightrag docs, silently
mislabelling them in ``doc_status.metadata``. Assert the tag now
reflects the persisted format end-to-end (pending_parse upload →
native parse → lightrag full_docs row)."""
paragraphs = ["Body paragraph for parse_format tagging test."]
async def _run():
input_dir = tmp_path / "input"
input_dir.mkdir()
monkeypatch.setenv("INPUT_DIR", str(input_dir))
(input_dir / "tag.docx").write_bytes(b"fake docx bytes")
_stub_docx_blocks(monkeypatch, paragraphs)
rag = _new_rag(tmp_path / "work")
await rag.initialize_storages()
try:
await rag.apipeline_enqueue_documents(
"",
file_paths="tag.docx",
docs_format=FULL_DOCS_FORMAT_PENDING_PARSE,
parse_engine="native",
track_id="track-tag",
)
await rag.apipeline_process_enqueue_documents()
doc_id = compute_mdhash_id("tag.docx", prefix="doc-")
status_doc = await rag.doc_status.get_by_id(doc_id)
assert status_doc is not None
metadata = (
status_doc.get("metadata")
if isinstance(status_doc, dict)
else getattr(status_doc, "metadata", None)
)
assert isinstance(metadata, dict), (
f"doc_status.metadata should be a dict, got {type(metadata)!r}"
)
assert metadata.get("parse_format") == FULL_DOCS_FORMAT_LIGHTRAG, (
f"doc_status.metadata.parse_format="
f"{metadata.get('parse_format')!r}; "
f"expected {FULL_DOCS_FORMAT_LIGHTRAG!r} so the multimodal "
f"sidecar merge path opens"
)
finally:
await rag.finalize_storages()
asyncio.run(_run())
# ---------------------------------------------------------------------------
# T4 — JSONL-shaped raw text is still treated as plain text
# ---------------------------------------------------------------------------
@pytest.mark.offline
def test_jsonl_shaped_raw_text_chunks_as_plain_text(tmp_path):
"""A raw document whose body coincidentally resembles structured JSONL
must be tokenised plainly — guarding against accidental
re-introduction of removed structured-format detection."""
# No trailing newline — sanitize_text_for_encoding strips trailing
# whitespace on raw enqueue, and that pre-chunking cleanup is unrelated
# to structured-format detection.
pseudo_jsonl = (
json.dumps({"type": "meta", "format_version": "1.0"})
+ "\n"
+ json.dumps(
{
"type": "text",
"chunk_id": "c0",
"chunk_order_index": 0,
"content": "fake structured line",
}
)
)
async def _run():
rag = _new_rag(tmp_path)
await rag.initialize_storages()
spy = _attach_chunking_spy(rag)
try:
await rag.apipeline_enqueue_documents(
pseudo_jsonl,
file_paths="pseudo.txt",
track_id="track-pseudo",
)
await rag.apipeline_process_enqueue_documents()
finally:
await rag.finalize_storages()
# The full pseudo-jsonl text reaches chunking_func; nothing parses
# it as JSONL and hijacks the chunks list.
assert spy["input"] == pseudo_jsonl
asyncio.run(_run())
# ---------------------------------------------------------------------------
# T5 — R/V/P process_options trigger the deferred-strategy warning
# ---------------------------------------------------------------------------
class _ListHandler(logging.Handler):
"""Capture log records into an in-memory list.
The ``lightrag`` logger has ``propagate = False`` so pytest's caplog
fixture cannot intercept its records via the root logger; this handler
attaches directly to the logger we care about.
"""
def __init__(self) -> None:
super().__init__()
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
@pytest.mark.offline
def test_explicit_R_dispatches_to_recursive_character(tmp_path, monkeypatch):
"""``process_options=R`` must invoke
:func:`chunking_by_recursive_character` (the new file-chunker
contract) rather than the legacy ``chunking_func``.
Verifies the explicit-selector dispatch contract:
1. ``chunking_by_recursive_character`` runs at least once.
2. The legacy ``chunking_func`` is bypassed entirely.
3. The deprecated "R/V not yet implemented" warning no longer
appears (now that R has a real implementation).
"""
pytest.importorskip("langchain_text_splitters")
import lightrag.chunker as chunker_pkg
from lightrag.chunker import chunking_by_recursive_character as real_r
captured = {"calls": 0}
def _r_spy(*args, **kwargs):
captured["calls"] += 1
return real_r(*args, **kwargs)
# The dispatcher does ``from lightrag.chunker import …`` inside the
# function body, which re-resolves the name from the package each
# call — patching the package attribute is enough to intercept it.
monkeypatch.setattr(chunker_pkg, "chunking_by_recursive_character", _r_spy)
async def _run():
rag = _new_rag(tmp_path)
await rag.initialize_storages()
legacy_spy = _attach_chunking_spy(rag)
lightrag_logger = logging.getLogger("lightrag")
list_handler = _ListHandler()
list_handler.setLevel(logging.WARNING)
lightrag_logger.addHandler(list_handler)
try:
await rag.apipeline_enqueue_documents(
"Body paragraph one.\n\nBody paragraph two for R dispatch test.",
file_paths="rs.[native-R].txt",
track_id="track-rs",
process_options="R",
)
await rag.apipeline_process_enqueue_documents()
finally:
lightrag_logger.removeHandler(list_handler)
await rag.finalize_storages()
assert captured["calls"] >= 1, "R must route to chunking_by_recursive_character"
assert legacy_spy["calls"] == 0, (
"explicit process_options selector must bypass legacy "
"chunking_func; got "
f"{legacy_spy['calls']} calls"
)
warning_messages = [
rec.getMessage()
for rec in list_handler.records
if rec.levelno == logging.WARNING
]
assert not any(
"R/V strategies are not yet implemented" in msg for msg in warning_messages
), (
"deprecated 'not yet implemented' warning must be gone now "
f"that R is wired up; saw: {warning_messages!r}"
)
asyncio.run(_run())
@pytest.mark.offline
def test_explicit_V_dispatches_to_semantic_vector(tmp_path, monkeypatch):
"""``process_options=V`` must invoke
:func:`chunking_by_semantic_vector` and bypass the legacy
``chunking_func``. The test installs a stub embedding (the spy
short-circuits before the real LangChain SemanticChunker runs) so
the assertion is purely about dispatch routing, not chunk quality.
"""
pytest.importorskip("langchain_experimental")
import lightrag.chunker as chunker_pkg
captured = {"calls": 0}
async def _v_spy(*args, **kwargs):
# Short-circuit: skip langchain SemanticChunker entirely and
# return one synthetic chunk. We're only verifying that the
# dispatcher routed here with the right keyword args.
captured["calls"] += 1
captured["embedding_func"] = kwargs.get("embedding_func")
captured["chunk_token_size"] = args[2] if len(args) > 2 else None
return [
{"tokens": 5, "content": "stub", "chunk_order_index": 0},
]
monkeypatch.setattr(chunker_pkg, "chunking_by_semantic_vector", _v_spy)
async def _run():
rag = _new_rag(tmp_path)
await rag.initialize_storages()
legacy_spy = _attach_chunking_spy(rag)
try:
await rag.apipeline_enqueue_documents(
"Body for V dispatch test. Sentence one. Sentence two.",
file_paths="vs.[native-V].txt",
track_id="track-vs",
process_options="V",
)
await rag.apipeline_process_enqueue_documents()
finally:
await rag.finalize_storages()
assert captured["calls"] >= 1, "V must route to chunking_by_semantic_vector"
assert captured.get("embedding_func") is rag.embedding_func, (
"dispatcher must hand the LightRAG embedding_func to the V chunker"
)
assert legacy_spy["calls"] == 0, (
"explicit process_options selector must bypass legacy chunking_func"
)
asyncio.run(_run())
# ---------------------------------------------------------------------------
# T6 — pending_parse → lightrag summary is populated after PROCESSED
# ---------------------------------------------------------------------------
@pytest.mark.offline
def test_pending_parse_lightrag_summary_populated_after_processed(
tmp_path, monkeypatch
):
"""A document enqueued as ``pending_parse`` has empty content at
enqueue time, so ``content_summary`` starts empty. After
``parse_native`` produces ``.blocks.jsonl`` and the state machine
moves through ANALYZING → PROCESSING → PROCESSED, the summary must
reflect the parsed body — not the enqueue-time placeholder."""
body_paragraphs = [
"Pending-parse summary regression body paragraph one.",
"Body paragraph two carries enough text for a meaningful preview.",
"Body paragraph three closes the document.",
]
async def _run():
input_dir = tmp_path / "input"
input_dir.mkdir()
monkeypatch.setenv("INPUT_DIR", str(input_dir))
source_path = input_dir / "summary.docx"
source_path.write_bytes(b"fake docx bytes")
_stub_docx_blocks(monkeypatch, body_paragraphs)
rag = _new_rag(tmp_path / "work")
await rag.initialize_storages()
try:
await rag.apipeline_enqueue_documents(
"",
file_paths="summary.docx",
docs_format=FULL_DOCS_FORMAT_PENDING_PARSE,
parse_engine="native",
track_id="track-summary",
)
doc_id = compute_mdhash_id("summary.docx", prefix="doc-")
pending = await rag.doc_status.get_by_id(doc_id)
assert pending is not None
pending_summary = (
pending.get("content_summary")
if isinstance(pending, dict)
else getattr(pending, "content_summary", "")
)
# At enqueue time pending_parse content is "" so summary is empty.
assert pending_summary == "", (
f"pending_parse should start with empty summary, got "
f"{pending_summary!r}"
)
await rag.apipeline_process_enqueue_documents()
final = await rag.doc_status.get_by_id(doc_id)
assert final is not None
final_summary = (
final.get("content_summary")
if isinstance(final, dict)
else getattr(final, "content_summary", "")
)
final_length = (
final.get("content_length")
if isinstance(final, dict)
else getattr(final, "content_length", 0)
)
assert final_summary, (
"content_summary still empty after PROCESSED; ANALYZING "
"refresh did not propagate"
)
assert not final_summary.startswith(LIGHTRAG_DOC_CONTENT_PREFIX), (
f"{{LRdoc}} marker leaked into doc_status summary: {final_summary!r}"
)
# The parser stub produces these paragraphs verbatim; the
# blocks.jsonl writer joins them with a blank line, so the
# summary must be a prefix of that merged text.
merged_text = "\n\n".join(body_paragraphs)
assert final_summary == get_content_summary(merged_text), (
f"summary should match get_content_summary(merged_text); "
f"got {final_summary!r} vs "
f"{get_content_summary(merged_text)!r}"
)
assert final_length == len(merged_text), (
f"content_length should equal len(merged_text)={len(merged_text)}, "
f"got {final_length}"
)
finally:
await rag.finalize_storages()
asyncio.run(_run())
# ---------------------------------------------------------------------------
# T7 — raw text starting with {{LRdoc}} must not be stripped at chunking
# ---------------------------------------------------------------------------
@pytest.mark.offline
def test_raw_text_starting_with_marker_chunked_verbatim(tmp_path):
"""A raw document whose body literally begins with ``{{LRdoc}}`` is a
legitimate user input — the chunking branch must not strip those 9
characters. ``strip_lightrag_doc_prefix`` is a lightrag-only contract
enforced by ``parse_native``; raw paths return ``content_data["content"]``
verbatim, so chunking must hand the body to ``chunking_func`` unchanged."""
body_with_marker = LIGHTRAG_DOC_CONTENT_PREFIX + (
"literal-marker-prefix raw document body that should survive "
"the chunking boundary intact."
)
async def _run():
rag = _new_rag(tmp_path)
await rag.initialize_storages()
spy = _attach_chunking_spy(rag)
try:
await rag.apipeline_enqueue_documents(
body_with_marker,
file_paths="marker_raw.txt",
track_id="track-marker",
)
await rag.apipeline_process_enqueue_documents()
finally:
await rag.finalize_storages()
assert spy["calls"] >= 1, "raw doc never reached chunking_func"
# The full body — including the literal {{LRdoc}} prefix — must
# reach chunking_func; nothing in the chunking branch should
# treat the marker as a stripping signal for raw content.
assert spy["input"] == body_with_marker, (
"chunking_func received corrupted input: "
f"got {spy['input']!r}, expected {body_with_marker!r}"
)
assert spy["input"].startswith(LIGHTRAG_DOC_CONTENT_PREFIX), (
"literal marker prefix lost at chunking boundary"
)
asyncio.run(_run())
+113
View File
@@ -0,0 +1,113 @@
"""
Test for overlap_tokens validation to prevent infinite loop.
This test validates the fix for the bug where overlap_tokens >= max_tokens
causes an infinite loop in the chunking function.
"""
from lightrag.rerank import chunk_documents_for_rerank
class TestOverlapValidation:
"""Test suite for overlap_tokens validation"""
def test_overlap_greater_than_max_tokens(self):
"""Test that overlap_tokens > max_tokens is clamped and doesn't hang"""
documents = [" ".join([f"word{i}" for i in range(100)])]
# This should clamp overlap_tokens to 29 (max_tokens - 1)
chunked_docs, doc_indices = chunk_documents_for_rerank(
documents, max_tokens=30, overlap_tokens=32
)
# Should complete without hanging
assert len(chunked_docs) > 0
assert all(idx == 0 for idx in doc_indices)
def test_overlap_equal_to_max_tokens(self):
"""Test that overlap_tokens == max_tokens is clamped and doesn't hang"""
documents = [" ".join([f"word{i}" for i in range(100)])]
# This should clamp overlap_tokens to 29 (max_tokens - 1)
chunked_docs, doc_indices = chunk_documents_for_rerank(
documents, max_tokens=30, overlap_tokens=30
)
# Should complete without hanging
assert len(chunked_docs) > 0
assert all(idx == 0 for idx in doc_indices)
def test_overlap_slightly_less_than_max_tokens(self):
"""Test that overlap_tokens < max_tokens works normally"""
documents = [" ".join([f"word{i}" for i in range(100)])]
# This should work without clamping
chunked_docs, doc_indices = chunk_documents_for_rerank(
documents, max_tokens=30, overlap_tokens=29
)
# Should complete successfully
assert len(chunked_docs) > 0
assert all(idx == 0 for idx in doc_indices)
def test_small_max_tokens_with_large_overlap(self):
"""Test edge case with very small max_tokens"""
documents = [" ".join([f"word{i}" for i in range(50)])]
# max_tokens=5, overlap_tokens=10 should clamp to 4
chunked_docs, doc_indices = chunk_documents_for_rerank(
documents, max_tokens=5, overlap_tokens=10
)
# Should complete without hanging
assert len(chunked_docs) > 0
assert all(idx == 0 for idx in doc_indices)
def test_multiple_documents_with_invalid_overlap(self):
"""Test multiple documents with overlap_tokens >= max_tokens"""
documents = [
" ".join([f"word{i}" for i in range(50)]),
"short document",
" ".join([f"word{i}" for i in range(75)]),
]
# overlap_tokens > max_tokens
chunked_docs, doc_indices = chunk_documents_for_rerank(
documents, max_tokens=25, overlap_tokens=30
)
# Should complete successfully and chunk the long documents
assert len(chunked_docs) >= len(documents)
# Short document should not be chunked
assert "short document" in chunked_docs
def test_normal_operation_unaffected(self):
"""Test that normal cases continue to work correctly"""
documents = [
" ".join([f"word{i}" for i in range(100)]),
"short doc",
]
# Normal case: overlap_tokens (10) < max_tokens (50)
chunked_docs, doc_indices = chunk_documents_for_rerank(
documents, max_tokens=50, overlap_tokens=10
)
# Long document should be chunked, short one should not
assert len(chunked_docs) > 2 # At least 3 chunks (2 from long doc + 1 short)
assert "short doc" in chunked_docs
# Verify doc_indices maps correctly
assert doc_indices[-1] == 1 # Last chunk is from second document
def test_edge_case_max_tokens_one(self):
"""Test edge case where max_tokens=1"""
documents = [" ".join([f"word{i}" for i in range(20)])]
# max_tokens=1, overlap_tokens=5 should clamp to 0
chunked_docs, doc_indices = chunk_documents_for_rerank(
documents, max_tokens=1, overlap_tokens=5
)
# Should complete without hanging
assert len(chunked_docs) > 0
assert all(idx == 0 for idx in doc_indices)
@@ -0,0 +1,310 @@
"""Tests for the paragraph-semantic ``drop_references`` option (chunking=P).
A reference block is dropped only when it BOTH sits within the last
``references_tail_n`` content blocks AND its heading matches a reference prefix.
The switch is the only knob that flows through ``chunk_options``; the tail
window and heading prefixes are read live from env at chunk time (verified here
by mutating env between calls, proving they are NOT snapshotted).
Assertions check the *content* of the emitted chunks (each block carries a
distinctive marker) rather than heading names, because LevelMerge may merge
small blocks and rewrite the surviving heading.
"""
import json
import logging
import pytest
from lightrag.chunker.paragraph_semantic import (
_format_dropped_headings,
_is_reference_heading,
chunking_by_paragraph_semantic,
)
from lightrag.constants import DEFAULT_P_REFERENCES_HEADINGS
from lightrag.utils import Tokenizer, TokenizerInterface, logger as _lightrag_logger
class _CharTokenizer(TokenizerInterface):
"""1:1 character-to-token mapping — keeps math obvious in assertions."""
def encode(self, content: str):
return [ord(ch) for ch in content]
def decode(self, tokens):
return "".join(chr(t) for t in tokens)
def _make_tokenizer() -> Tokenizer:
return Tokenizer(model_name="char", tokenizer=_CharTokenizer())
def _row(heading: str, content: str, *, level: int = 1) -> dict:
return {
"type": "content",
"blockid": heading or "blk",
"format": "plain_text",
"content": content,
"heading": heading,
"parent_headings": [],
"level": level,
"session_type": "body",
"table_slice": "none",
"positions": [],
}
def _write_blocks_jsonl(tmp_path, rows: list[dict]) -> str:
path = tmp_path / "doc.blocks.jsonl"
path.write_text(
"\n".join(json.dumps(row, ensure_ascii=False) for row in rows),
encoding="utf-8",
)
return str(path)
def _all_content(chunks: list[dict]) -> str:
return "\n".join(c["content"] for c in chunks)
# --------------------------------------------------------------------------- #
# _is_reference_heading (word boundary vs. plain CJK prefix)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"heading,expected",
[
("References", True),
("references", True), # case-insensitive
("REFERENCES", True),
("Bibliography", True),
("References [1-50]", True), # word boundary: next char is non-alnum
("参考文献", True),
("参考文献列表", True), # CJK: plain prefix, no word boundary
("Referenced work", False), # ASCII word boundary excludes this
("Related Work", False),
("", False),
],
)
def test_is_reference_heading(heading, expected):
assert _is_reference_heading(heading, DEFAULT_P_REFERENCES_HEADINGS) is expected
# --------------------------------------------------------------------------- #
# _format_dropped_headings (length-bounded log rendering)
# --------------------------------------------------------------------------- #
def test_format_dropped_headings_short_list():
assert _format_dropped_headings(["References"]) == "'References'"
assert (
_format_dropped_headings(["References", "Bibliography"])
== "'References', 'Bibliography'"
)
def test_format_dropped_headings_truncates_long_heading():
out = _format_dropped_headings(["X" * 200], max_each=60)
# 60 kept chars + an ellipsis, all within one repr'd string.
assert "X" * 60 + "" in out
assert "X" * 61 not in out
def test_format_dropped_headings_caps_item_count():
out = _format_dropped_headings([f"H{i}" for i in range(12)], max_items=5)
assert out.count("'H") == 5 # only 5 listed
assert "(+7 more)" in out
# --------------------------------------------------------------------------- #
# Filtering behaviour (assert on content markers, robust to LevelMerge)
# --------------------------------------------------------------------------- #
@pytest.mark.offline
def test_drops_trailing_reference_block(tmp_path):
tokenizer = _make_tokenizer()
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("Method", "METHOD_MARKER method body"),
_row("References", "REF_MARKER [1] Foo. [2] Bar."),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
body = _all_content(
chunking_by_paragraph_semantic(
tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
)
)
assert "REF_MARKER" not in body
assert "INTRO_MARKER" in body and "METHOD_MARKER" in body
@pytest.mark.offline
def test_default_keeps_reference_block(tmp_path):
tokenizer = _make_tokenizer()
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("References", "REF_MARKER [1] Foo."),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
# drop_references defaults to False — references survive.
body = _all_content(
chunking_by_paragraph_semantic(tokenizer, "", 2000, blocks_path=blocks_path)
)
assert "REF_MARKER" in body
@pytest.mark.offline
def test_mid_document_reference_section_outside_window_kept(tmp_path):
tokenizer = _make_tokenizer()
# A "References" heading mid-document, with two non-reference blocks after
# it, so it falls outside the last-2 window and must NOT be dropped.
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("References", "REF_MARKER discusses references"),
_row("Method", "METHOD_MARKER method body"),
_row("Results", "RESULTS_MARKER results body"),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
body = _all_content(
chunking_by_paragraph_semantic(
tokenizer,
"",
2000,
blocks_path=blocks_path,
drop_references=True,
references_tail_n=2,
)
)
assert "REF_MARKER" in body
@pytest.mark.offline
def test_word_boundary_keeps_referenced(tmp_path):
tokenizer = _make_tokenizer()
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("参考文献列表", "CJKREF_MARKER [1] Foo."),
_row("Referenced datasets", "DATASET_MARKER dataset details"),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
body = _all_content(
chunking_by_paragraph_semantic(
tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
)
)
# CJK prefix block dropped; "Referenced ..." kept (ASCII word boundary).
assert "CJKREF_MARKER" not in body
assert "DATASET_MARKER" in body
@pytest.mark.offline
def test_all_blocks_match_keeps_to_avoid_empty(tmp_path):
tokenizer = _make_tokenizer()
rows = [
_row("References", "REF1_MARKER [1] Foo."),
_row("Bibliography", "REF2_MARKER [2] Bar."),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
# Both blocks match within the last-2 window; dropping all would leave no
# content, so the chunker keeps them and warns instead.
chunks = chunking_by_paragraph_semantic(
tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
)
body = _all_content(chunks)
assert chunks # not an empty document
assert "REF1_MARKER" in body and "REF2_MARKER" in body
# --------------------------------------------------------------------------- #
# Detection knobs are read LIVE from env (not snapshotted)
# --------------------------------------------------------------------------- #
@pytest.mark.offline
def test_tail_n_read_live_from_env(tmp_path, monkeypatch):
tokenizer = _make_tokenizer()
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("References", "REF_MARKER [1] Foo."),
_row("Appendix", "APPENDIX_MARKER appendix body"),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
# tail_n=1: only the last block ("Appendix") is in the window → References
# is outside it and survives.
monkeypatch.setenv("CHUNK_P_REFERENCES_TAIL_N", "1")
body = _all_content(
chunking_by_paragraph_semantic(
tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
)
)
assert "REF_MARKER" in body
# Widen the window to 2 → References now falls inside and is dropped. Same
# call, only the env changed: proves the knob is read live, not snapshotted.
monkeypatch.setenv("CHUNK_P_REFERENCES_TAIL_N", "2")
body = _all_content(
chunking_by_paragraph_semantic(
tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
)
)
assert "REF_MARKER" not in body
@pytest.mark.offline
def test_drop_references_emits_info_log(tmp_path, caplog):
tokenizer = _make_tokenizer()
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("References", "REF_MARKER [1] Foo."),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
# The lightrag logger sets propagate=False, so caplog can't see it by
# default — enable propagation for the duration of the call.
original_propagate = _lightrag_logger.propagate
_lightrag_logger.propagate = True
try:
with caplog.at_level(logging.INFO, logger=_lightrag_logger.name):
chunking_by_paragraph_semantic(
tokenizer,
"",
2000,
blocks_path=blocks_path,
drop_references=True,
doc_id="doc-xyz",
)
finally:
_lightrag_logger.propagate = original_propagate
info_msgs = [r.getMessage() for r in caplog.records if r.levelno == logging.INFO]
# Log names the dropped heading and attributes it to the doc_id.
assert any("References" in m and "doc_id: doc-xyz" in m for m in info_msgs), (
info_msgs
)
@pytest.mark.offline
def test_headings_read_live_from_env(tmp_path, monkeypatch):
tokenizer = _make_tokenizer()
rows = [
_row("Introduction", "INTRO_MARKER intro body"),
_row("Literature", "LIT_MARKER [1] Foo. [2] Bar."),
]
blocks_path = _write_blocks_jsonl(tmp_path, rows)
# Custom prefix list makes "Literature" a reference heading.
monkeypatch.setenv("CHUNK_P_REFERENCES_HEADINGS", "Literature|参考文献")
body = _all_content(
chunking_by_paragraph_semantic(
tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
)
)
assert "LIT_MARKER" not in body
@@ -0,0 +1,766 @@
"""Regression tests for paragraph-semantic LevelMerge merging and the top-level R fallback."""
import pytest
from lightrag.chunker.paragraph_semantic import (
_glue_heading_only_blocks,
_is_heading_only,
_merge_small_blocks,
chunking_by_paragraph_semantic,
)
from lightrag.utils import Tokenizer, TokenizerInterface
class _CharTokenizer(TokenizerInterface):
"""1:1 character-to-token mapping — keeps math obvious in assertions."""
def encode(self, content: str):
return [ord(ch) for ch in content]
def decode(self, tokens):
return "".join(chr(t) for t in tokens)
def _make_tokenizer() -> Tokenizer:
return Tokenizer(model_name="char", tokenizer=_CharTokenizer())
def _make_block(text: str, *, tokenizer: Tokenizer, level: int = 1) -> dict:
return {
"heading": "H",
"parent_headings": [],
"level": level,
"paragraphs": [{"text": text, "is_table": False}],
"content": text,
"tokens": len(tokenizer.encode(text)),
"table_chunk_role": "none",
}
def _pblock(
text: str,
*,
heading: str,
parent_headings: list[str],
level: int,
tokenizer: Tokenizer,
role: str = "none",
) -> dict:
"""Block carrying an explicit heading path — for parent-path gate tests."""
return {
"heading": heading,
"parent_headings": list(parent_headings),
"level": level,
"paragraphs": [{"text": text, "is_table": False}],
"content": text,
"tokens": len(tokenizer.encode(text)),
"table_chunk_role": role,
}
@pytest.mark.offline
def test_tail_absorption_rejects_when_separator_pushes_over_cap():
# Tail absorption joins blocks with ``"\n\n"`` but the original
# predicate only summed per-block tokens. With cur=99 and tail=1
# the raw sum equals target_max=100, but the actual joined
# ``"x"*99 + "\n\n" + "y"*1`` measures 102 tokens — the absorbed
# block silently overflowed before the fix re-measured the joined
# content.
tokenizer = _make_tokenizer()
blocks = [
_make_block("x" * 99, tokenizer=tokenizer),
_make_block("y" * 1, tokenizer=tokenizer),
]
merged = _merge_small_blocks(
blocks,
tokenizer=tokenizer,
target_max=100,
target_ideal=80,
small_tail_threshold=12,
)
assert all(b["tokens"] <= 100 for b in merged), [b["tokens"] for b in merged]
@pytest.mark.offline
def test_tail_absorption_still_fires_when_joined_size_fits():
# Sanity check: when the joined content (including separators)
# genuinely fits target_max, absorption still happens. cur=80 +
# "\n\n" (2 tokens) + tail=1 = 83 ≤ 100.
tokenizer = _make_tokenizer()
blocks = [
_make_block("x" * 80, tokenizer=tokenizer),
_make_block("y" * 1, tokenizer=tokenizer),
]
merged = _merge_small_blocks(
blocks,
tokenizer=tokenizer,
target_max=100,
target_ideal=80,
small_tail_threshold=12,
)
assert len(merged) == 1
assert merged[0]["tokens"] == 83
assert merged[0]["content"] == "x" * 80 + "\n\n" + "y" * 1
@pytest.mark.offline
def test_phase_a_merges_same_parent_path_siblings():
# True siblings under one parent (identical parent_headings) merge.
tokenizer = _make_tokenizer()
blocks = [
_pblock(
"a" * 40,
heading="2.4.1",
parent_headings=["2", "2.4"],
level=3,
tokenizer=tokenizer,
),
_pblock(
"b" * 40,
heading="2.4.2",
parent_headings=["2", "2.4"],
level=3,
tokenizer=tokenizer,
),
]
merged = _merge_small_blocks(
blocks,
tokenizer=tokenizer,
target_max=200,
target_ideal=150,
small_tail_threshold=12,
)
assert len(merged) == 1
@pytest.mark.offline
def test_phase_a_keeps_different_parent_path_siblings_separate():
# Same level but different parents (2.4.x vs 2.5.x) must NOT merge —
# the anti-cross-topic-pollution guarantee (§9.1 #4).
tokenizer = _make_tokenizer()
blocks = [
_pblock(
"a" * 40,
heading="2.4.1",
parent_headings=["2", "2.4"],
level=3,
tokenizer=tokenizer,
),
_pblock(
"b" * 40,
heading="2.5.1",
parent_headings=["2", "2.5"],
level=3,
tokenizer=tokenizer,
),
]
merged = _merge_small_blocks(
blocks,
tokenizer=tokenizer,
target_max=200,
target_ideal=150,
small_tail_threshold=12,
)
assert len(merged) == 2
@pytest.mark.offline
def test_phase_b_shallow_absorbs_descendant_deeper():
# Cross-level absorption is allowed when the deep block is nested under the
# shallow one: 2.4 (parents [2]) absorbs its child 2.4.1 (parents [2, 2.4]).
tokenizer = _make_tokenizer()
blocks = [
_pblock(
"a" * 40,
heading="2.4",
parent_headings=["2"],
level=2,
tokenizer=tokenizer,
),
_pblock(
"b" * 40,
heading="2.4.1",
parent_headings=["2", "2.4"],
level=3,
tokenizer=tokenizer,
),
]
merged = _merge_small_blocks(
blocks,
tokenizer=tokenizer,
target_max=200,
target_ideal=150,
small_tail_threshold=12,
)
assert len(merged) == 1
@pytest.mark.offline
def test_phase_b_refuses_nondescendant_deeper():
# 2.4 must NOT absorb a deeper block from a different branch (2.5.1) even
# though it is shallower — that would be cross-topic pollution.
tokenizer = _make_tokenizer()
blocks = [
_pblock(
"a" * 40,
heading="2.4",
parent_headings=["2"],
level=2,
tokenizer=tokenizer,
),
_pblock(
"b" * 40,
heading="2.5.1",
parent_headings=["2", "2.5"],
level=3,
tokenizer=tokenizer,
),
]
merged = _merge_small_blocks(
blocks,
tokenizer=tokenizer,
target_max=200,
target_ideal=150,
small_tail_threshold=12,
)
assert len(merged) == 2
@pytest.mark.offline
def test_tail_absorption_stops_at_divergent_parent_path():
# A saturated block absorbs the same-parent sliver that follows but stops
# the run at the first block whose parent path diverges.
tokenizer = _make_tokenizer()
blocks = [
_pblock(
"a" * 160,
heading="2.4.1",
parent_headings=["2", "2.4"],
level=3,
tokenizer=tokenizer,
),
_pblock(
"b" * 5,
heading="2.4.2",
parent_headings=["2", "2.4"],
level=3,
tokenizer=tokenizer,
),
_pblock(
"c" * 5,
heading="2.5.1",
parent_headings=["2", "2.5"],
level=3,
tokenizer=tokenizer,
),
]
merged = _merge_small_blocks(
blocks,
tokenizer=tokenizer,
target_max=200,
target_ideal=150,
small_tail_threshold=50,
)
assert len(merged) == 2
assert "b" * 5 in merged[0]["content"] # same-parent sliver absorbed
assert merged[1]["content"] == "c" * 5 # divergent-parent block untouched
@pytest.mark.offline
def test_paragraph_semantic_fallback_passes_configured_recursive_overlap(monkeypatch):
# When ``blocks_path`` is missing, paragraph-semantic chunking
# delegates to ``chunking_by_recursive_character``. P now permits
# overlap for long text under one JSONL row, so the fallback must
# pass through the configured overlap rather than forcing zero.
captured: dict[str, object] = {}
def fake_chunker(
tokenizer,
content,
chunk_token_size: int = 1200,
*,
chunk_overlap_token_size: int = 100,
separators=None,
):
captured["chunk_overlap_token_size"] = chunk_overlap_token_size
captured["chunk_token_size"] = chunk_token_size
return [
{
"tokens": len(tokenizer.encode(content)),
"content": content,
"chunk_order_index": 0,
}
]
import lightrag.chunker.recursive_character as rc_mod
monkeypatch.setattr(rc_mod, "chunking_by_recursive_character", fake_chunker)
tokenizer = _make_tokenizer()
chunking_by_paragraph_semantic(
tokenizer,
"fallback corpus",
chunk_token_size=500,
blocks_path=None,
chunk_overlap_token_size=37,
)
assert captured.get("chunk_overlap_token_size") == 37, (
"P→R fallback must pass the configured chunk_overlap_token_size"
)
assert captured.get("chunk_token_size") == 500
# ---------------------------------------------------------------------------
# HeadingGlue — body-less heading glue (forward into child / backward into prev).
# ---------------------------------------------------------------------------
def _hblock(
content: str,
*,
heading: str,
level: int,
tokenizer: Tokenizer,
table_chunk_role: str = "none",
) -> dict:
"""Build a block whose ``content`` keeps the markdown heading line(s).
Unlike ``_make_block`` (heading-less ``content``), heading-only detection
needs the ``#``-prefixed heading line preserved verbatim in ``content``.
"""
return {
"heading": heading,
"parent_headings": [],
"level": level,
"paragraphs": [
{"text": line, "is_table": False}
for line in content.split("\n")
if line.strip()
],
"content": content,
"tokens": len(tokenizer.encode(content)),
"table_chunk_role": table_chunk_role,
"blockids": [],
}
@pytest.mark.offline
def test_is_heading_only_detection():
tokenizer = _make_tokenizer()
assert _is_heading_only(
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer)
)
# A glued accumulation of bare headings is still heading-only.
assert _is_heading_only(
_hblock("## 2.4\n\n### 2.4.1", heading="2.4", level=2, tokenizer=tokenizer)
)
# Heading + body is NOT heading-only.
assert not _is_heading_only(
_hblock("## 2.3\nbody text", heading="2.3", level=2, tokenizer=tokenizer)
)
# Preamble (no heading) is excluded by the heading guard.
assert not _is_heading_only(
_hblock("preamble text", heading="", level=1, tokenizer=tokenizer)
)
@pytest.mark.offline
def test_heading_only_glues_forward_into_deeper_child():
# `## 2.4` (heading-only) must bond with its deeper child `### 2.4.1`,
# NOT get appended to the previous same-level block `## 2.3`.
tokenizer = _make_tokenizer()
blocks = [
_hblock("## 2.3\n" + "a" * 40, heading="2.3", level=2, tokenizer=tokenizer),
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer),
_hblock(
"### 2.4.1\n" + "b" * 40, heading="2.4.1", level=3, tokenizer=tokenizer
),
]
out = _glue_heading_only_blocks(
blocks, tokenizer=tokenizer, target_max=10000, target_ideal=7500
)
assert len(out) == 2
# 2.3 stays untouched — the lone heading was NOT glued onto its tail.
assert out[0]["heading"] == "2.3"
assert "## 2.4" not in out[0]["content"]
# The bonded group keeps the shallower parent identity (2.4 / level 2)
# but carries the child content.
assert out[1]["heading"] == "2.4"
assert out[1]["level"] == 2
assert "## 2.4" in out[1]["content"]
assert "### 2.4.1" in out[1]["content"]
@pytest.mark.offline
def test_heading_only_glue_respects_target_max_when_child_near_cap():
# The child fits target_max on its own, but prepending the heading-only
# parent line would tip the bonded block over the hard cap. The pre-pass
# must re-split so every emitted piece stays within target_max, while the
# parent heading still rides with the first piece (never detached).
tokenizer = _make_tokenizer()
blocks = [
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer),
_hblock(
"### 2.4.1\n" + "b" * 86, heading="2.4.1", level=3, tokenizer=tokenizer
),
]
# child alone = 10 + 86 = 96 ≤ 100; bonded = 6 + 2 + 96 = 104 > 100.
assert blocks[1]["tokens"] <= 100
out = _glue_heading_only_blocks(
blocks,
tokenizer=tokenizer,
target_max=100,
target_ideal=75,
chunk_overlap_token_size=0,
)
assert len(out) >= 2
assert all(b["tokens"] <= 100 for b in out), [b["tokens"] for b in out]
# Parent heading is not detached — it leads the first emitted piece.
assert "## 2.4" in out[0]["content"]
@pytest.mark.offline
def test_heading_only_cap_split_does_not_orphan_when_body_has_no_anchor():
# Regression: child is near the cap and its body is ONE long paragraph
# (> _MAX_ANCHOR_CANDIDATE_LENGTH chars), so the only anchor candidate in
# the glued block is the child heading at index 1. The naive
# split-the-whole-block path sliced off `[## 2.4]` alone — a heading-only
# orphan that LevelMerge then re-absorbs backward, recreating the separation.
# The prefix-aware re-split must keep the heading with real body content.
tokenizer = _make_tokenizer()
blocks = [
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer),
_hblock(
"### 2.4.1\n" + "b" * 110, heading="2.4.1", level=3, tokenizer=tokenizer
),
]
# child alone = 10 + 110 = 120 (near cap); bonded = 6 + 2 + 120 = 128 > 120.
assert blocks[1]["tokens"] <= 120
out = _glue_heading_only_blocks(
blocks,
tokenizer=tokenizer,
target_max=120,
target_ideal=90,
chunk_overlap_token_size=0,
)
assert all(b["tokens"] <= 120 for b in out), [b["tokens"] for b in out]
# No piece is a heading-only orphan.
assert not any(_is_heading_only(b) for b in out)
# The heading lines ride with real body content in the first piece.
assert "## 2.4" in out[0]["content"]
assert "### 2.4.1" in out[0]["content"]
assert "b" in out[0]["content"]
@pytest.mark.offline
def test_heading_only_cap_split_does_not_shrink_later_body_chunks():
# When a glued block must be re-split, only the FIRST piece carries the
# heading prefix. The body must split at the FULL target_max so later
# body-only chunks keep the full budget; only the first piece reserves room
# for the prefix. (Earlier code split the whole body at the reduced budget,
# over-fragmenting every later chunk to the leftover first-chunk budget.)
tokenizer = _make_tokenizer()
# Large heading prefix -> a small leftover budget if wrongly applied to all.
parent = "## " + "P" * 47 # 50 tokens
body = "\n".join("y" * 28 for _ in range(6))
blocks = [
_hblock(parent, heading="P", level=2, tokenizer=tokenizer),
_hblock("### c\n" + body, heading="c", level=3, tokenizer=tokenizer),
]
target_max = 100
# prefix = "## P*47" + "### c" = 50 + 1 + 5 = 56 tokens; sep = 1.
reduced_max = target_max - 56 - 1 # = 43, the over-shrunk budget to beat.
out = _glue_heading_only_blocks(
blocks, tokenizer=tokenizer, target_max=target_max, target_ideal=75
)
# Cap still honoured everywhere.
assert all(b["tokens"] <= target_max for b in out), [b["tokens"] for b in out]
# The prefix rides with the first piece.
assert out[0]["content"].startswith(parent)
# Body-only pieces (everything after the first) keep the FULL budget — at
# least one exceeds the reduced prefix budget, proving they were not shrunk.
assert max(b["tokens"] for b in out[1:]) > reduced_max, [
b["tokens"] for b in out[1:]
]
@pytest.mark.offline
def test_heading_only_cap_split_handles_prefix_larger_than_cap():
# Degenerate: the heading prefix alone exceeds target_max (a very long
# title, or a tiny chunk_token_size). There is no room to keep it whole, so
# the fused heading+body paragraph is itself character-split — every emitted
# piece must still honour the hard cap (no over-cap chunk escapes).
tokenizer = _make_tokenizer()
parent = "## " + "P" * 40 # 43 tokens, alone already > target_max below
blocks = [
_hblock(parent, heading="P", level=2, tokenizer=tokenizer),
_hblock("### c\n" + "y" * 20, heading="c", level=3, tokenizer=tokenizer),
]
target_max = 30
out = _glue_heading_only_blocks(
blocks, tokenizer=tokenizer, target_max=target_max, target_ideal=22
)
# The hard cap is enforced on every piece (the buggy path emitted a ~51-token
# chunk because reduced_max clamped to 1 and the prefix was prepended whole).
assert all(b["tokens"] <= target_max for b in out), [b["tokens"] for b in out]
# Content is not dropped: heading text and body both survive across pieces.
joined = "".join(b["content"] for b in out)
assert "P" in joined and "y" in joined
@pytest.mark.offline
def test_heading_only_chain_collapses_to_shallowest_identity():
# `# 2` -> `## 2.4` -> `### 2.4.1` (body) collapses into one block whose
# identity is the shallowest heading (level 1).
tokenizer = _make_tokenizer()
blocks = [
_hblock("# 2", heading="2", level=1, tokenizer=tokenizer),
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer),
_hblock(
"### 2.4.1\n" + "c" * 30, heading="2.4.1", level=3, tokenizer=tokenizer
),
]
out = _glue_heading_only_blocks(
blocks, tokenizer=tokenizer, target_max=10000, target_ideal=7500
)
assert len(out) == 1
assert out[0]["heading"] == "2"
assert out[0]["level"] == 1
content = out[0]["content"]
assert "# 2" in content and "## 2.4" in content and "### 2.4.1" in content
@pytest.mark.offline
def test_heading_only_no_glue_when_next_not_deeper():
# Next block is same level -> no forced forward glue; left for LevelMerge.
tokenizer = _make_tokenizer()
blocks = [
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer),
_hblock("## 2.5\nbody", heading="2.5", level=2, tokenizer=tokenizer),
]
out = _glue_heading_only_blocks(
blocks, tokenizer=tokenizer, target_max=10000, target_ideal=7500
)
assert len(out) == 2
@pytest.mark.offline
def test_heading_only_no_glue_into_middle_or_last_table_slice():
# A deeper next block that is a `middle`/`last` table slice must not absorb
# the heading-only block (only `none` and `first` are glue targets — see
# test_heading_only_glues_into_first_table_slice for the `first` case).
tokenizer = _make_tokenizer()
for role in ("middle", "last"):
blocks = [
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer),
_hblock(
'<table id="t" format="json">[[1]]</table>',
heading="2.4.1",
level=3,
tokenizer=tokenizer,
table_chunk_role=role,
),
]
out = _glue_heading_only_blocks(
blocks, tokenizer=tokenizer, target_max=10000, target_ideal=7500
)
assert len(out) == 2, role
@pytest.mark.offline
def test_heading_only_group_stays_separate_when_prev_is_saturated():
# Rule 1 end-to-end: `## 2.3` already reached target_ideal AND the bonded
# `2.4 + 2.4.1` group exceeds small_tail_threshold, so neither peer merging
# nor tail absorption pulls it backward — it stays its own chunk, with 2.4
# bonded to 2.4.1 (not to 2.3). (A group below small_tail_threshold could
# still be tail-absorbed into a saturated 2.3, which is acceptable since it
# would carry 2.4.1 along.)
tokenizer = _make_tokenizer()
blocks = [
_hblock("## 2.3\n" + "a" * 200, heading="2.3", level=2, tokenizer=tokenizer),
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer),
_hblock(
"### 2.4.1\n" + "b" * 40, heading="2.4.1", level=3, tokenizer=tokenizer
),
]
glued = _glue_heading_only_blocks(
blocks, tokenizer=tokenizer, target_max=10000, target_ideal=7500
)
final = _merge_small_blocks(
glued,
tokenizer=tokenizer,
target_max=2000,
target_ideal=150,
small_tail_threshold=12,
)
assert len(final) == 2
assert "## 2.4" not in final[0]["content"]
assert "## 2.4" in final[1]["content"] and "### 2.4.1" in final[1]["content"]
@pytest.mark.offline
def test_heading_only_group_backfills_into_unsaturated_prev():
# Rule 2 end-to-end: when `## 2.3` is still below target_ideal and the
# join fits target_max, LevelMerge packs 2.3 + 2.4 + 2.4.1 into one chunk.
tokenizer = _make_tokenizer()
blocks = [
_hblock("## 2.3\n" + "a" * 40, heading="2.3", level=2, tokenizer=tokenizer),
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer),
_hblock(
"### 2.4.1\n" + "b" * 40, heading="2.4.1", level=3, tokenizer=tokenizer
),
]
glued = _glue_heading_only_blocks(
blocks, tokenizer=tokenizer, target_max=10000, target_ideal=7500
)
final = _merge_small_blocks(
glued,
tokenizer=tokenizer,
target_max=200,
target_ideal=150,
small_tail_threshold=12,
)
assert len(final) == 1
content = final[0]["content"]
assert "## 2.3" in content
assert "## 2.4" in content
assert "### 2.4.1" in content
@pytest.mark.offline
def test_heading_only_not_glued_into_deeper_prev():
# `## 2.4` (L2) has no deeper child after it; its previous block is the
# DEEPER `### 2.3.9` (L3). It must NOT be pulled backward into that deeper
# block — absorbing a shallower heading into a deeper chunk would invert the
# hierarchy. It stays separate, left for LevelMerge.
tokenizer = _make_tokenizer()
blocks = [
_hblock(
"### 2.3.9\n" + "a" * 40, heading="2.3.9", level=3, tokenizer=tokenizer
),
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer),
]
out = _glue_heading_only_blocks(
blocks, tokenizer=tokenizer, target_max=10000, target_ideal=7500
)
assert len(out) == 2
assert out[0]["heading"] == "2.3.9"
assert "## 2.4" not in out[0]["content"]
assert out[1]["heading"] == "2.4"
@pytest.mark.offline
def test_heading_only_not_glued_into_same_level_prev():
# The previous block `## 2.3` is the SAME level (a sibling), not deeper, so
# the body-less `## 2.4` is not glued backward into it — that is the original
# mis-merge. It stays standalone for LevelMerge to handle.
tokenizer = _make_tokenizer()
blocks = [
_hblock("## 2.3\n" + "a" * 40, heading="2.3", level=2, tokenizer=tokenizer),
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer),
]
out = _glue_heading_only_blocks(
blocks, tokenizer=tokenizer, target_max=10000, target_ideal=7500
)
assert len(out) == 2
assert "## 2.4" not in out[0]["content"]
assert out[1]["heading"] == "2.4"
_TABLE_FIRST = '<table id="t" format="json">[["a","b"],["c","d"]]</table>'
@pytest.mark.offline
def test_heading_only_glues_into_first_table_slice():
# The deeper child's first emitted block is the "first" slice of a split
# table (its body is an oversized table). The pre-pass must glue the
# body-less `## 2.4` into it AND keep the "first" role so LevelMerge still
# cannot absorb it backward.
tokenizer = _make_tokenizer()
blocks = [
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer),
_hblock(
"### 2.4.1\n" + _TABLE_FIRST,
heading="2.4.1",
level=3,
tokenizer=tokenizer,
table_chunk_role="first",
),
]
out = _glue_heading_only_blocks(
blocks, tokenizer=tokenizer, target_max=10000, target_ideal=7500
)
assert len(out) == 1
assert "## 2.4" in out[0]["content"]
assert "### 2.4.1" in out[0]["content"]
assert _TABLE_FIRST in out[0]["content"]
# "first" role preserved so LevelMerge keeps the table boundary protected.
assert out[0]["table_chunk_role"] == "first"
@pytest.mark.offline
def test_heading_only_with_first_table_child_not_separated_by_stage_d():
# End-to-end: `## 2.4` whose child starts with a "first" table slice must
# NOT be left on `## 2.3`. After the glue keeps the merged block "first",
# LevelMerge cannot pull it backward into the previous sibling.
tokenizer = _make_tokenizer()
blocks = [
_hblock("## 2.3\n" + "a" * 40, heading="2.3", level=2, tokenizer=tokenizer),
_hblock("## 2.4", heading="2.4", level=2, tokenizer=tokenizer),
_hblock(
"### 2.4.1\n" + _TABLE_FIRST,
heading="2.4.1",
level=3,
tokenizer=tokenizer,
table_chunk_role="first",
),
]
glued = _glue_heading_only_blocks(
blocks, tokenizer=tokenizer, target_max=10000, target_ideal=7500
)
final = _merge_small_blocks(
glued,
tokenizer=tokenizer,
target_max=10000,
target_ideal=150,
small_tail_threshold=12,
)
# `## 2.4` rides with its table child, never glued onto `## 2.3`.
chunk_23 = next(b for b in final if b["content"].startswith("## 2.3"))
assert "## 2.4" not in chunk_23["content"]
chunk_with_table = next(b for b in final if _TABLE_FIRST in b["content"])
assert "## 2.4" in chunk_with_table["content"]
assert "### 2.4.1" in chunk_with_table["content"]
@@ -0,0 +1,323 @@
"""Regression tests for paragraph-semantic AnchorSplit anchor selection."""
import json
import pytest
from lightrag.chunker.paragraph_semantic import (
_split_long_block,
chunking_by_paragraph_semantic,
)
from lightrag.utils import Tokenizer, TokenizerInterface
class _CharTokenizer(TokenizerInterface):
"""1:1 character-to-token mapping — keeps math obvious in assertions."""
def encode(self, content: str):
return [ord(ch) for ch in content]
def decode(self, tokens):
return "".join(chr(t) for t in tokens)
def _make_tokenizer() -> Tokenizer:
return Tokenizer(model_name="char", tokenizer=_CharTokenizer())
def _write_blocks_jsonl(tmp_path, rows: list[dict]) -> str:
path = tmp_path / "doc.blocks.jsonl"
path.write_text(
"\n".join(json.dumps(row, ensure_ascii=False) for row in rows),
encoding="utf-8",
)
return str(path)
@pytest.mark.offline
def test_split_long_block_short_lead_then_huge_does_not_recurse():
# Reproduces the case where the only ≤100-char paragraph is at index 0:
# before the fix, the anchor at idx=0 was selected, slice_paras was
# empty, the tail was the original input, and the recursive guard
# re-entered _split_long_block with the same arguments forever.
tokenizer = _make_tokenizer()
paragraphs = [
{"text": "Short lead anchor."}, # idx 0 — short, but unusable as a divider
{"text": "x" * 4000}, # idx 1 — huge, no anchor inside
]
blocks = _split_long_block(
paragraphs,
heading="Heading",
parent_headings=[],
level=2,
table_chunk_role="none",
tokenizer=tokenizer,
target_max=1000,
target_ideal=750,
)
# Falls through to the "no eligible anchor" branch and now defers to
# recursive-character splitting so ``target_max`` is honored without
# relying on the embedding-time hard fallback (which uses a different
# threshold). The original recursion-guard contract still holds: the
# function returns a finite list rather than recursing forever.
assert len(blocks) > 1
assert all(b["tokens"] <= 1000 for b in blocks)
# Heading hierarchy is preserved on every R-derived sub-block.
assert all(b["heading"] == "Heading" for b in blocks)
@pytest.mark.offline
def test_split_long_block_no_anchor_pack_accounts_for_separator():
# The no-anchor greedy pack joins pieces with ``"\n"``, which costs
# tokens on its own. Without debiting that separator from the buffer
# budget, two pieces summing to exactly target_max produced a final
# chunk of ``target_max + 1`` tokens — silently violating the cap.
tokenizer = _make_tokenizer()
# Two paragraphs both > _MAX_ANCHOR_CANDIDATE_LENGTH (100 chars), so
# neither qualifies as an anchor and the no-anchor branch fires.
# Their lengths sum exactly to ``target_max`` (101 + 101 = 202),
# so before the fix the joined output overflowed by the "\n" token.
paragraphs = [
{"text": "a" * 101},
{"text": "b" * 101},
]
blocks = _split_long_block(
paragraphs,
heading="Heading",
parent_headings=[],
level=2,
table_chunk_role="none",
tokenizer=tokenizer,
target_max=202,
target_ideal=150,
)
assert blocks, "expected at least one sub-block"
assert all(b["tokens"] <= 202 for b in blocks), [b["tokens"] for b in blocks]
@pytest.mark.offline
def test_split_long_block_single_paragraph_oversized_is_character_split():
# A single oversized paragraph used to trigger the early-return at
# ``len(paragraphs) <= 1`` and the recursive-guard's ``> 1`` clause,
# so the function emitted one ~total-token block that silently
# blew past target_max. With both gates relaxed, the no-anchor
# branch's character fallback honors the cap on this case too.
tokenizer = _make_tokenizer()
paragraphs = [{"text": "x" * 4000}]
blocks = _split_long_block(
paragraphs,
heading="Heading",
parent_headings=[],
level=2,
table_chunk_role="none",
tokenizer=tokenizer,
target_max=1000,
target_ideal=750,
)
assert len(blocks) > 1, "single oversized paragraph must be split, not kept whole"
assert all(b["tokens"] <= 1000 for b in blocks), [b["tokens"] for b in blocks]
# Heading hierarchy is preserved on every R-derived sub-block.
assert all(b["heading"] == "Heading" for b in blocks)
@pytest.mark.offline
def test_split_long_block_character_fallback_keeps_configured_overlap(monkeypatch):
tokenizer = _make_tokenizer()
captured: dict[str, int] = {}
def fake_chunker(
tokenizer,
content,
chunk_token_size: int = 1200,
*,
chunk_overlap_token_size: int = 100,
separators=None,
):
captured["chunk_overlap_token_size"] = chunk_overlap_token_size
step = max(chunk_token_size - chunk_overlap_token_size, 1)
tokens = tokenizer.encode(content)
chunks = []
for start in range(0, len(tokens), step):
piece = tokenizer.decode(tokens[start : start + chunk_token_size])
chunks.append(
{
"tokens": len(tokenizer.encode(piece)),
"content": piece,
"chunk_order_index": len(chunks),
}
)
return chunks
import lightrag.chunker.recursive_character as rc_mod
monkeypatch.setattr(rc_mod, "chunking_by_recursive_character", fake_chunker)
blocks = _split_long_block(
[{"text": "x" * 260}],
heading="Heading",
parent_headings=[],
level=2,
table_chunk_role="none",
tokenizer=tokenizer,
target_max=100,
target_ideal=75,
chunk_overlap_token_size=25,
)
assert captured["chunk_overlap_token_size"] == 25
assert len(blocks) > 1
assert blocks[0]["content"][-25:] == blocks[1]["content"][:25]
@pytest.mark.offline
def test_split_long_block_uses_later_short_anchor():
# Sanity check: a short paragraph at idx>0 IS still a valid divider.
tokenizer = _make_tokenizer()
paragraphs = [
{"text": "x" * 1500}, # idx 0 — huge
{"text": "Mid anchor."}, # idx 1 — short, eligible
{"text": "y" * 1500}, # idx 2 — huge
]
blocks = _split_long_block(
paragraphs,
heading="Heading",
parent_headings=[],
level=2,
table_chunk_role="none",
tokenizer=tokenizer,
target_max=1000,
target_ideal=750,
)
assert len(blocks) >= 2
# Anchor paragraph becomes the heading of the post-split sub-block.
assert any(b["heading"] == "Mid anchor." for b in blocks)
@pytest.mark.offline
def test_public_chunking_keeps_unsplit_heading_without_part_suffix(tmp_path):
tokenizer = _make_tokenizer()
blocks_path = _write_blocks_jsonl(
tmp_path,
[
{
"type": "content",
"heading": "Heading",
"parent_headings": [],
"level": 2,
"content": "short body",
}
],
)
chunks = chunking_by_paragraph_semantic(
tokenizer,
"short body",
chunk_token_size=100,
blocks_path=blocks_path,
)
assert len(chunks) == 1
assert chunks[0]["heading"]["heading"] == "Heading"
@pytest.mark.offline
def test_public_chunking_adds_part_suffixes_for_anchor_split(tmp_path):
tokenizer = _make_tokenizer()
body = "\n".join(["x" * 800, "Mid anchor.", "y" * 800])
blocks_path = _write_blocks_jsonl(
tmp_path,
[
{
"type": "content",
"heading": "Heading",
"parent_headings": [],
"level": 2,
"content": body,
}
],
)
chunks = chunking_by_paragraph_semantic(
tokenizer,
body,
chunk_token_size=1000,
blocks_path=blocks_path,
chunk_overlap_token_size=0,
)
assert [chunk["heading"]["heading"] for chunk in chunks] == [
"Heading [part 1]",
"Mid anchor. [part 2]",
]
assert all(
all("[part " not in parent for parent in chunk["heading"]["parent_headings"])
for chunk in chunks
)
@pytest.mark.offline
def test_public_chunking_adds_part_suffixes_for_long_text_fallback(
tmp_path, monkeypatch
):
tokenizer = _make_tokenizer()
def fake_chunker(
tokenizer,
content,
chunk_token_size: int = 1200,
*,
chunk_overlap_token_size: int = 100,
separators=None,
):
tokens = tokenizer.encode(content)
chunks = []
for start in range(0, len(tokens), chunk_token_size):
piece = tokenizer.decode(tokens[start : start + chunk_token_size])
chunks.append(
{
"tokens": len(tokenizer.encode(piece)),
"content": piece,
"chunk_order_index": len(chunks),
}
)
return chunks
import lightrag.chunker.recursive_character as rc_mod
monkeypatch.setattr(rc_mod, "chunking_by_recursive_character", fake_chunker)
body = "z" * 260
blocks_path = _write_blocks_jsonl(
tmp_path,
[
{
"type": "content",
"heading": "Heading",
"parent_headings": [],
"level": 2,
"content": body,
}
],
)
chunks = chunking_by_paragraph_semantic(
tokenizer,
body,
chunk_token_size=100,
blocks_path=blocks_path,
chunk_overlap_token_size=0,
)
assert [chunk["heading"]["heading"] for chunk in chunks] == [
"Heading [part 1]",
"Heading [part 2]",
"Heading [part 3]",
]
File diff suppressed because it is too large Load Diff
+564
View File
@@ -0,0 +1,564 @@
"""
Unit tests for rerank document chunking functionality.
Tests the chunk_documents_for_rerank and aggregate_chunk_scores functions
in lightrag/rerank.py to ensure proper document splitting and score aggregation.
"""
import pytest
from unittest.mock import Mock, patch, AsyncMock
from lightrag.rerank import (
chunk_documents_for_rerank,
aggregate_chunk_scores,
cohere_rerank,
)
class TestChunkDocumentsForRerank:
"""Test suite for chunk_documents_for_rerank function"""
def test_no_chunking_needed_for_short_docs(self):
"""Documents shorter than max_tokens should not be chunked"""
documents = [
"Short doc 1",
"Short doc 2",
"Short doc 3",
]
chunked_docs, doc_indices = chunk_documents_for_rerank(
documents, max_tokens=100, overlap_tokens=10
)
# No chunking should occur
assert len(chunked_docs) == 3
assert chunked_docs == documents
assert doc_indices == [0, 1, 2]
def test_chunking_with_character_fallback(self):
"""Test chunking falls back to character-based when tokenizer unavailable"""
# Create a very long document that exceeds character limit
long_doc = "a" * 2000 # 2000 characters
documents = [long_doc, "short doc"]
with patch("lightrag.utils.TiktokenTokenizer", side_effect=ImportError):
chunked_docs, doc_indices = chunk_documents_for_rerank(
documents,
max_tokens=100, # 100 tokens = ~400 chars
overlap_tokens=10, # 10 tokens = ~40 chars
)
# First doc should be split into chunks, second doc stays whole
assert len(chunked_docs) > 2 # At least one chunk from first doc + second doc
assert chunked_docs[-1] == "short doc" # Last chunk is the short doc
# Verify doc_indices maps chunks to correct original document
assert doc_indices[-1] == 1 # Last chunk maps to document 1
def test_chunking_with_tiktoken_tokenizer(self):
"""Test chunking with actual tokenizer"""
# Create document with known token count
# Approximate: "word " = ~1 token, so 200 words ~ 200 tokens
long_doc = " ".join([f"word{i}" for i in range(200)])
documents = [long_doc, "short"]
chunked_docs, doc_indices = chunk_documents_for_rerank(
documents, max_tokens=50, overlap_tokens=10
)
# Long doc should be split, short doc should remain
assert len(chunked_docs) > 2
assert doc_indices[-1] == 1 # Last chunk is from second document
# Verify overlapping chunks contain overlapping content
if len(chunked_docs) > 2:
# Check that consecutive chunks from same doc have some overlap
for i in range(len(doc_indices) - 1):
if doc_indices[i] == doc_indices[i + 1] == 0:
# Both chunks from first doc, should have overlap
chunk1_words = chunked_docs[i].split()
chunk2_words = chunked_docs[i + 1].split()
# At least one word should be common due to overlap
assert any(word in chunk2_words for word in chunk1_words[-5:])
def test_empty_documents(self):
"""Test handling of empty document list"""
documents = []
chunked_docs, doc_indices = chunk_documents_for_rerank(documents)
assert chunked_docs == []
assert doc_indices == []
def test_single_document_chunking(self):
"""Test chunking of a single long document"""
# Create document with ~100 tokens
long_doc = " ".join([f"token{i}" for i in range(100)])
documents = [long_doc]
chunked_docs, doc_indices = chunk_documents_for_rerank(
documents, max_tokens=30, overlap_tokens=5
)
# Should create multiple chunks
assert len(chunked_docs) > 1
# All chunks should map to document 0
assert all(idx == 0 for idx in doc_indices)
class TestAggregateChunkScores:
"""Test suite for aggregate_chunk_scores function"""
def test_no_chunking_simple_aggregation(self):
"""Test aggregation when no chunking occurred (1:1 mapping)"""
chunk_results = [
{"index": 0, "relevance_score": 0.9},
{"index": 1, "relevance_score": 0.7},
{"index": 2, "relevance_score": 0.5},
]
doc_indices = [0, 1, 2] # 1:1 mapping
num_original_docs = 3
aggregated = aggregate_chunk_scores(
chunk_results, doc_indices, num_original_docs, aggregation="max"
)
# Results should be sorted by score
assert len(aggregated) == 3
assert aggregated[0]["index"] == 0
assert aggregated[0]["relevance_score"] == 0.9
assert aggregated[1]["index"] == 1
assert aggregated[1]["relevance_score"] == 0.7
assert aggregated[2]["index"] == 2
assert aggregated[2]["relevance_score"] == 0.5
def test_max_aggregation_with_chunks(self):
"""Test max aggregation strategy with multiple chunks per document"""
# 5 chunks: first 3 from doc 0, last 2 from doc 1
chunk_results = [
{"index": 0, "relevance_score": 0.5},
{"index": 1, "relevance_score": 0.8},
{"index": 2, "relevance_score": 0.6},
{"index": 3, "relevance_score": 0.7},
{"index": 4, "relevance_score": 0.4},
]
doc_indices = [0, 0, 0, 1, 1]
num_original_docs = 2
aggregated = aggregate_chunk_scores(
chunk_results, doc_indices, num_original_docs, aggregation="max"
)
# Should take max score for each document
assert len(aggregated) == 2
assert aggregated[0]["index"] == 0
assert aggregated[0]["relevance_score"] == 0.8 # max of 0.5, 0.8, 0.6
assert aggregated[1]["index"] == 1
assert aggregated[1]["relevance_score"] == 0.7 # max of 0.7, 0.4
def test_mean_aggregation_with_chunks(self):
"""Test mean aggregation strategy"""
chunk_results = [
{"index": 0, "relevance_score": 0.6},
{"index": 1, "relevance_score": 0.8},
{"index": 2, "relevance_score": 0.4},
]
doc_indices = [0, 0, 1] # First two chunks from doc 0, last from doc 1
num_original_docs = 2
aggregated = aggregate_chunk_scores(
chunk_results, doc_indices, num_original_docs, aggregation="mean"
)
assert len(aggregated) == 2
assert aggregated[0]["index"] == 0
assert aggregated[0]["relevance_score"] == pytest.approx(0.7) # (0.6 + 0.8) / 2
assert aggregated[1]["index"] == 1
assert aggregated[1]["relevance_score"] == 0.4
def test_first_aggregation_with_chunks(self):
"""Test first aggregation strategy"""
chunk_results = [
{"index": 0, "relevance_score": 0.6},
{"index": 1, "relevance_score": 0.8},
{"index": 2, "relevance_score": 0.4},
]
doc_indices = [0, 0, 1]
num_original_docs = 2
aggregated = aggregate_chunk_scores(
chunk_results, doc_indices, num_original_docs, aggregation="first"
)
assert len(aggregated) == 2
# First should use first score seen for each doc
assert aggregated[0]["index"] == 0
assert aggregated[0]["relevance_score"] == 0.6 # First score for doc 0
assert aggregated[1]["index"] == 1
assert aggregated[1]["relevance_score"] == 0.4
def test_empty_chunk_results(self):
"""Test handling of empty results"""
aggregated = aggregate_chunk_scores([], [], 3, aggregation="max")
assert aggregated == []
def test_documents_with_no_scores(self):
"""Test when some documents have no chunks/scores"""
chunk_results = [
{"index": 0, "relevance_score": 0.9},
{"index": 1, "relevance_score": 0.7},
]
doc_indices = [0, 0] # Both chunks from document 0
num_original_docs = 3 # But we have 3 documents total
aggregated = aggregate_chunk_scores(
chunk_results, doc_indices, num_original_docs, aggregation="max"
)
# Only doc 0 should appear in results
assert len(aggregated) == 1
assert aggregated[0]["index"] == 0
def test_unknown_aggregation_strategy(self):
"""Test that unknown strategy falls back to max"""
chunk_results = [
{"index": 0, "relevance_score": 0.6},
{"index": 1, "relevance_score": 0.8},
]
doc_indices = [0, 0]
num_original_docs = 1
# Use invalid strategy
aggregated = aggregate_chunk_scores(
chunk_results, doc_indices, num_original_docs, aggregation="invalid"
)
# Should fall back to max
assert aggregated[0]["relevance_score"] == 0.8
@pytest.mark.offline
class TestTopNWithChunking:
"""Tests for top_n behavior when chunking is enabled (Bug fix verification)"""
@pytest.mark.asyncio
async def test_top_n_limits_documents_not_chunks(self):
"""
Test that top_n correctly limits documents (not chunks) when chunking is enabled.
Bug scenario: 10 docs expand to 50 chunks. With old behavior, top_n=5 would
return scores for only 5 chunks (possibly all from 1-2 docs). After aggregation,
fewer than 5 documents would be returned.
Fixed behavior: top_n=5 should return exactly 5 documents after aggregation.
"""
# Setup: 5 documents, each producing multiple chunks when chunked
# Using small max_tokens to force chunking
long_docs = [" ".join([f"doc{i}_word{j}" for j in range(50)]) for i in range(5)]
query = "test query"
# First, determine how many chunks will be created by actual chunking
_, doc_indices = chunk_documents_for_rerank(
long_docs, max_tokens=50, overlap_tokens=10
)
num_chunks = len(doc_indices)
# Mock API returns scores for ALL chunks (simulating disabled API-level top_n)
# Give different scores to ensure doc 0 gets highest, doc 1 second, etc.
# Assign scores based on original document index (lower doc index = higher score)
mock_chunk_scores = []
for i in range(num_chunks):
original_doc = doc_indices[i]
# Higher score for lower doc index, with small variation per chunk
base_score = 0.9 - (original_doc * 0.1)
mock_chunk_scores.append({"index": i, "relevance_score": base_score})
mock_response = Mock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={"results": mock_chunk_scores})
mock_response.request_info = None
mock_response.history = None
mock_response.headers = {}
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=None)
mock_session = Mock()
mock_session.post = Mock(return_value=mock_response)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
with patch("lightrag.rerank.aiohttp.ClientSession", return_value=mock_session):
result = await cohere_rerank(
query=query,
documents=long_docs,
api_key="test-key",
base_url="http://test.com/rerank",
enable_chunking=True,
max_tokens_per_doc=50, # Match chunking above
top_n=3, # Request top 3 documents
)
# Verify: should get exactly 3 documents (not unlimited chunks)
assert len(result) == 3
# All results should have valid document indices (0-4)
assert all(0 <= r["index"] < 5 for r in result)
# Results should be sorted by score (descending)
assert all(
result[i]["relevance_score"] >= result[i + 1]["relevance_score"]
for i in range(len(result) - 1)
)
# The top 3 docs should be 0, 1, 2 (highest scores)
result_indices = [r["index"] for r in result]
assert set(result_indices) == {0, 1, 2}
@pytest.mark.asyncio
async def test_api_receives_no_top_n_when_chunking_enabled(self):
"""
Test that the API request does NOT include top_n when chunking is enabled.
This ensures all chunk scores are retrieved for proper aggregation.
"""
documents = [" ".join([f"word{i}" for i in range(100)]), "short doc"]
query = "test query"
captured_payload = {}
mock_response = Mock()
mock_response.status = 200
mock_response.json = AsyncMock(
return_value={
"results": [
{"index": 0, "relevance_score": 0.9},
{"index": 1, "relevance_score": 0.8},
{"index": 2, "relevance_score": 0.7},
]
}
)
mock_response.request_info = None
mock_response.history = None
mock_response.headers = {}
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=None)
def capture_post(*args, **kwargs):
captured_payload.update(kwargs.get("json", {}))
return mock_response
mock_session = Mock()
mock_session.post = Mock(side_effect=capture_post)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
with patch("lightrag.rerank.aiohttp.ClientSession", return_value=mock_session):
await cohere_rerank(
query=query,
documents=documents,
api_key="test-key",
base_url="http://test.com/rerank",
enable_chunking=True,
max_tokens_per_doc=30,
top_n=1, # User wants top 1 document
)
# Verify: API payload should NOT have top_n (disabled for chunking)
assert "top_n" not in captured_payload
@pytest.mark.asyncio
async def test_top_n_not_modified_when_chunking_disabled(self):
"""
Test that top_n is passed through to API when chunking is disabled.
"""
documents = ["doc1", "doc2"]
query = "test query"
captured_payload = {}
mock_response = Mock()
mock_response.status = 200
mock_response.json = AsyncMock(
return_value={
"results": [
{"index": 0, "relevance_score": 0.9},
]
}
)
mock_response.request_info = None
mock_response.history = None
mock_response.headers = {}
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=None)
def capture_post(*args, **kwargs):
captured_payload.update(kwargs.get("json", {}))
return mock_response
mock_session = Mock()
mock_session.post = Mock(side_effect=capture_post)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
with patch("lightrag.rerank.aiohttp.ClientSession", return_value=mock_session):
await cohere_rerank(
query=query,
documents=documents,
api_key="test-key",
base_url="http://test.com/rerank",
enable_chunking=False, # Chunking disabled
top_n=1,
)
# Verify: API payload should have top_n when chunking is disabled
assert captured_payload.get("top_n") == 1
@pytest.mark.offline
class TestCohereRerankChunking:
"""Integration tests for cohere_rerank with chunking enabled"""
@pytest.mark.asyncio
async def test_cohere_rerank_with_chunking_disabled(self):
"""Test that chunking can be disabled"""
documents = ["doc1", "doc2"]
query = "test query"
# Mock the generic_rerank_api
with patch(
"lightrag.rerank.generic_rerank_api", new_callable=AsyncMock
) as mock_api:
mock_api.return_value = [
{"index": 0, "relevance_score": 0.9},
{"index": 1, "relevance_score": 0.7},
]
result = await cohere_rerank(
query=query,
documents=documents,
api_key="test-key",
enable_chunking=False,
max_tokens_per_doc=100,
)
# Verify generic_rerank_api was called with correct parameters
mock_api.assert_called_once()
call_kwargs = mock_api.call_args[1]
assert call_kwargs["enable_chunking"] is False
assert call_kwargs["max_tokens_per_doc"] == 100
# Result should mirror mocked scores
assert len(result) == 2
assert result[0]["index"] == 0
assert result[0]["relevance_score"] == 0.9
assert result[1]["index"] == 1
assert result[1]["relevance_score"] == 0.7
@pytest.mark.asyncio
async def test_cohere_rerank_with_chunking_enabled(self):
"""Test that chunking parameters are passed through"""
documents = ["doc1", "doc2"]
query = "test query"
with patch(
"lightrag.rerank.generic_rerank_api", new_callable=AsyncMock
) as mock_api:
mock_api.return_value = [
{"index": 0, "relevance_score": 0.9},
{"index": 1, "relevance_score": 0.7},
]
result = await cohere_rerank(
query=query,
documents=documents,
api_key="test-key",
enable_chunking=True,
max_tokens_per_doc=480,
)
# Verify parameters were passed
call_kwargs = mock_api.call_args[1]
assert call_kwargs["enable_chunking"] is True
assert call_kwargs["max_tokens_per_doc"] == 480
# Result should mirror mocked scores
assert len(result) == 2
assert result[0]["index"] == 0
assert result[0]["relevance_score"] == 0.9
assert result[1]["index"] == 1
assert result[1]["relevance_score"] == 0.7
@pytest.mark.asyncio
async def test_cohere_rerank_default_parameters(self):
"""Test default parameter values for cohere_rerank"""
documents = ["doc1"]
query = "test"
with patch(
"lightrag.rerank.generic_rerank_api", new_callable=AsyncMock
) as mock_api:
mock_api.return_value = [{"index": 0, "relevance_score": 0.9}]
result = await cohere_rerank(
query=query, documents=documents, api_key="test-key"
)
# Verify default values
call_kwargs = mock_api.call_args[1]
assert call_kwargs["enable_chunking"] is False
assert call_kwargs["max_tokens_per_doc"] == 4096
assert call_kwargs["model"] == "rerank-v3.5"
# Result should mirror mocked scores
assert len(result) == 1
assert result[0]["index"] == 0
assert result[0]["relevance_score"] == 0.9
@pytest.mark.offline
class TestEndToEndChunking:
"""End-to-end tests for chunking workflow"""
@pytest.mark.asyncio
async def test_end_to_end_chunking_workflow(self):
"""Test complete chunking workflow from documents to aggregated results"""
# Create documents where first one needs chunking
long_doc = " ".join([f"word{i}" for i in range(100)])
documents = [long_doc, "short doc"]
query = "test query"
# Mock the HTTP call inside generic_rerank_api
mock_response = Mock()
mock_response.status = 200
mock_response.json = AsyncMock(
return_value={
"results": [
{"index": 0, "relevance_score": 0.5}, # chunk 0 from doc 0
{"index": 1, "relevance_score": 0.8}, # chunk 1 from doc 0
{"index": 2, "relevance_score": 0.6}, # chunk 2 from doc 0
{"index": 3, "relevance_score": 0.7}, # doc 1 (short)
]
}
)
mock_response.request_info = None
mock_response.history = None
mock_response.headers = {}
# Make mock_response an async context manager (for `async with session.post() as response`)
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=None)
mock_session = Mock()
# session.post() returns an async context manager, so return mock_response which is now one
mock_session.post = Mock(return_value=mock_response)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
with patch("lightrag.rerank.aiohttp.ClientSession", return_value=mock_session):
result = await cohere_rerank(
query=query,
documents=documents,
api_key="test-key",
base_url="http://test.com/rerank",
enable_chunking=True,
max_tokens_per_doc=30, # Force chunking of long doc
)
# Should get 2 results (one per original document)
# The long doc's chunks should be aggregated
assert len(result) <= len(documents)
# Results should be sorted by score
assert all(
result[i]["relevance_score"] >= result[i + 1]["relevance_score"]
for i in range(len(result) - 1)
)
@@ -0,0 +1,391 @@
"""Integration tests: real F chunker + hard-split + sidecar backfill + chunks dict.
Unlike ``tests/sidecar/test_backfill.py`` (which hand-builds chunk lists), these
drive the actual ``chunking_by_fixed_token`` chunker over the exact merged text a
parsed document would yield, then apply the real
``enforce_chunk_token_limit_before_embedding`` hard-split and
``build_chunks_dict_from_chunking_result`` persistence step — verifying that
backfilled sidecars are precise per final slice and survive into the chunks dict.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from lightrag.chunker import chunking_by_fixed_token
from lightrag.sidecar import backfill_chunk_sidecars
from lightrag.utils import (
Tokenizer,
enforce_chunk_token_limit_before_embedding,
)
from lightrag.utils_pipeline import build_chunks_dict_from_chunking_result
_BLOCK_SEPARATOR = "\n\n"
class _CharTokenizerImpl:
"""Deterministic char-per-token tokenizer; decode(encode(x)) == x so F
chunks are verbatim substrings and token sizes are character counts."""
def encode(self, content: str) -> list[int]:
return [ord(ch) for ch in content]
def decode(self, tokens: list[int]) -> str:
return "".join(chr(t) for t in tokens)
def _tokenizer() -> Tokenizer:
return Tokenizer("char", _CharTokenizerImpl())
def _write_blocks(tmp_path: Path, blocks: list[tuple[str, str]]) -> tuple[str, str]:
"""Write blocks.jsonl; return (path, merged_text)."""
path = tmp_path / "doc.blocks.jsonl"
lines = [json.dumps({"type": "meta", "format": "lightrag", "version": "1.0"})]
parts: list[str] = []
for blockid, content in blocks:
lines.append(
json.dumps(
{
"type": "content",
"blockid": blockid,
"content": content,
"heading": "",
"parent_headings": [],
"level": 1,
}
)
)
if content.strip():
parts.append(content)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return str(path), _BLOCK_SEPARATOR.join(parts)
@pytest.mark.offline
def test_real_overlap_tail_chunk_maps_to_next_block(tmp_path: Path) -> None:
# End-to-end reproduction of the overlap-tail ambiguity with the real chunker.
# Simple case: b1="aa", b2="a", chunk_size=3, overlap=1 -> ["aa", "a", "a"]. The
# middle window [2:5] = "\n\na" strips to b2's "a" (the overlap landed on the
# separator), so the tail chunks belong to b2 — not the earlier "a" inside b1.
blocks_path, merged = _write_blocks(tmp_path, [("b1", "aa"), ("b2", "a")])
tok = _tokenizer()
chunks = chunking_by_fixed_token(
tok,
merged,
chunk_token_size=3,
chunk_overlap_token_size=1,
_emit_source_span=True,
)
assert [c["content"] for c in chunks] == ["aa", "a", "a"]
backfill_chunk_sidecars(chunks, blocks_path)
assert [r["id"] for r in chunks[0]["sidecar"]["refs"]] == ["b1"]
assert [r["id"] for r in chunks[1]["sidecar"]["refs"]] == ["b2"]
assert [r["id"] for r in chunks[2]["sidecar"]["refs"]] == ["b2"]
@pytest.mark.offline
def test_real_overlap_on_stripped_separator_does_not_strand_chunks(
tmp_path: Path,
) -> None:
# Harder end-to-end case: b1="a", b2="abab", chunk_size=2, overlap=1 ->
# ["a", "", "a", "ab", "ba", "ab", "b"]. The token overlap repeatedly lands on the
# stripped separator, so consecutive non-empty chunks can share a normalized start
# and only the end advances. The empty chunk is skipped; every other tail chunk
# must resolve into b2 without raising ChunkBlockMatchError.
blocks_path, merged = _write_blocks(tmp_path, [("b1", "a"), ("b2", "abab")])
tok = _tokenizer()
chunks = chunking_by_fixed_token(
tok,
merged,
chunk_token_size=2,
chunk_overlap_token_size=1,
_emit_source_span=True,
)
assert [c["content"] for c in chunks] == ["a", "", "a", "ab", "ba", "ab", "b"]
backfill_chunk_sidecars(chunks, blocks_path)
assert [r["id"] for r in chunks[0]["sidecar"]["refs"]] == ["b1"]
# The empty chunk is skipped (no sidecar); all remaining chunks belong to b2.
assert "sidecar" not in chunks[1]
for ch in chunks[2:]:
assert [r["id"] for r in ch["sidecar"]["refs"]] == ["b2"]
@pytest.mark.offline
def test_real_identical_adjacent_blocks_no_cross_block_artifact(
tmp_path: Path,
) -> None:
# End-to-end: identical adjacent blocks b1="aa", b2="aa", chunk_size=2, overlap=0
# -> ["aa", "", "aa"]. Stripping glues them to "aaaa", where "aa" also matches at
# offset 1 across the (removed) separator. The final chunk must reference only b2,
# not spuriously span both blocks.
blocks_path, merged = _write_blocks(tmp_path, [("b1", "aa"), ("b2", "aa")])
tok = _tokenizer()
chunks = chunking_by_fixed_token(
tok,
merged,
chunk_token_size=2,
chunk_overlap_token_size=0,
_emit_source_span=True,
)
assert [c["content"] for c in chunks] == ["aa", "", "aa"]
backfill_chunk_sidecars(chunks, blocks_path)
assert chunks[0]["_source_span"] == {"start": 0, "end": 2}
assert chunks[2]["_source_span"] == {"start": 4, "end": 6}
assert [r["id"] for r in chunks[0]["sidecar"]["refs"]] == ["b1"]
assert "sidecar" not in chunks[1]
assert [r["id"] for r in chunks[2]["sidecar"]["refs"]] == ["b2"]
@pytest.mark.offline
def test_real_fixed_token_chunks_get_full_coverage(tmp_path: Path) -> None:
blocks = [(f"b{i}", f"Block number {i} body content here.") for i in range(6)]
blocks_path, merged = _write_blocks(tmp_path, blocks)
tok = _tokenizer()
chunks = chunking_by_fixed_token(
tok,
merged,
chunk_token_size=40,
chunk_overlap_token_size=5,
_emit_source_span=True,
)
assert len(chunks) >= 2 # multiple blocks per chunk at this size
backfill_chunk_sidecars(chunks, blocks_path)
# Every chunk is located and carries block provenance.
for ch in chunks:
assert ch["sidecar"]["type"] == "block"
assert ch["sidecar"]["refs"]
# Union of all refs covers every block.
seen = {r["id"] for ch in chunks for r in ch["sidecar"]["refs"]}
assert seen == {b for b, _ in blocks}
@pytest.mark.offline
def test_hard_split_slices_get_precise_refs(tmp_path: Path) -> None:
# One large block followed by a small one. A single fixed-token chunk covers
# both; the hard-split then breaks the big-content chunk into slices that
# each lie inside one block, so refs must NOT all inherit the full set.
big = "A" * 120
blocks_path, merged = _write_blocks(tmp_path, [("big", big), ("small", "tail.")])
tok = _tokenizer()
chunks = chunking_by_fixed_token(
tok,
merged,
chunk_token_size=200,
chunk_overlap_token_size=0,
_emit_source_span=True,
)
assert len(chunks) == 1 # everything in one chunk pre-split
assert chunks[0]["_source_span"] == {"start": 0, "end": len(merged)}
chunks = enforce_chunk_token_limit_before_embedding(chunks, tok, max_tokens=30)
assert len(chunks) > 1 # hard-split fired
backfill_chunk_sidecars(chunks, blocks_path)
# The early slices live entirely inside "big" -> single ref, not both blocks.
assert chunks[0]["sidecar"]["refs"] == [{"type": "block", "id": "big"}]
# "small" is referenced only by the slice that actually reaches its content.
small_refs = [
ch for ch in chunks if any(r["id"] == "small" for r in ch["sidecar"]["refs"])
]
assert len(small_refs) == 1
@pytest.mark.offline
def test_hard_split_multi_sentence_rejoin_keeps_provenance(tmp_path: Path) -> None:
# A single block whose sentences are separated by single spaces. The hard
# split regroups whole sentence units and rejoins them with "\n\n", so the
# resulting slice content is NOT byte-verbatim in the source. Span propagation
# must fall back to whitespace-normalized matching instead of dropping the span
# — otherwise span-first backfill would wrongly FAIL the document.
block = "ab. cd. ef. gh. ij. kl."
blocks_path, merged = _write_blocks(tmp_path, [("b1", block)])
tok = _tokenizer()
chunks = chunking_by_fixed_token(
tok,
merged,
chunk_token_size=200,
chunk_overlap_token_size=0,
_emit_source_span=True,
)
assert len(chunks) == 1
assert chunks[0]["_source_span"] == {"start": 0, "end": len(merged)}
chunks = enforce_chunk_token_limit_before_embedding(chunks, tok, max_tokens=7)
assert len(chunks) > 1 # hard split fired into multiple slices
# At least one slice rejoined sentence units with "\n\n" (not byte-verbatim),
# which is exactly the case the normalized span fallback must cover.
assert any("\n\n" in ch["content"] for ch in chunks)
# Must NOT raise: every slice keeps a span via the normalized fallback, and
# each maps back to the single source block it came from.
backfill_chunk_sidecars(chunks, blocks_path)
for ch in chunks:
assert [r["id"] for r in ch["sidecar"]["refs"]] == ["b1"]
@pytest.mark.offline
def test_real_tiktoken_token_windows_match_verbatim(tmp_path: Path) -> None:
# The char tokenizer guarantees decode(encode(x)) == x; a real BPE tokenizer
# does not split on character boundaries, so this exercises that tiktoken's
# decoded token windows (after the chunker's .strip()) are still locatable in
# the reconstructed merged text — including across the token-overlap fallback.
tiktoken = pytest.importorskip("tiktoken")
del tiktoken # only needed to gate the test
from lightrag.utils import TiktokenTokenizer
tok = TiktokenTokenizer()
blocks = [
("b1", "The quick brown fox jumps over the lazy dog repeatedly."),
("b2", "Pack my box with five dozen liquor jugs, said the printer."),
("b3", "How vexingly quick daft zebras jump across the meadow."),
]
blocks_path, merged = _write_blocks(tmp_path, blocks)
chunks = chunking_by_fixed_token(
tok,
merged,
chunk_token_size=12,
chunk_overlap_token_size=3,
_emit_source_span=True,
)
assert len(chunks) >= 2 # small window + overlap -> multiple chunks
backfill_chunk_sidecars(chunks, blocks_path)
for ch in chunks:
assert ch["sidecar"]["type"] == "block"
assert ch["sidecar"]["refs"]
# Union of all refs covers every block.
seen = {r["id"] for ch in chunks for r in ch["sidecar"]["refs"]}
assert seen == {b for b, _ in blocks}
@pytest.mark.offline
def test_real_tiktoken_multibyte_boundary_degrades_not_fails(tmp_path: Path) -> None:
# Regression: tiktoken is byte-level, so a 4-byte UTF-8 char (emoji / rare CJK
# extension) can have its bytes split across a token-window boundary. Decoding the
# partial window yields U+FFFD in BOTH the chunk content and its span probe, so the
# chunk is unlocatable by span or by text. Span-first backfill must skip provenance
# for the corrupt chunks while still attributing the clean ones, not FAIL the whole
# document.
pytest.importorskip("tiktoken")
from lightrag.utils import TiktokenTokenizer
tok = TiktokenTokenizer()
# Emoji are supplementary-plane (4-byte) chars that force byte-fallback tokens.
block = "Status update 🎉🚀 progress 😀😁😂 and more text 🔥💡✅ keep going. " * 20
blocks_path, merged = _write_blocks(tmp_path, [("b1", block)])
chunks = chunking_by_fixed_token(
tok,
merged,
chunk_token_size=18,
chunk_overlap_token_size=4,
_emit_source_span=True,
)
# The window splits at least one emoji -> some chunks carry U+FFFD and lack a span.
assert any("" in c["content"] for c in chunks)
assert any("_source_span" not in c for c in chunks)
# Must NOT raise: corrupt chunks are skipped, the rest are attributed.
backfill_chunk_sidecars(chunks, blocks_path)
for ch in chunks:
if "" in ch["content"]:
assert "sidecar" not in ch # provenance degraded, document not failed
elif ch["content"].strip(): # empty tail chunks are skipped entirely
assert ch["sidecar"]["refs"] == [{"type": "block", "id": "b1"}]
# At least the clean chunks resolved into the single source block.
assert any("sidecar" in ch for ch in chunks)
@pytest.mark.offline
def test_backfilled_sidecar_persists_into_chunks_dict(tmp_path: Path) -> None:
blocks_path, merged = _write_blocks(
tmp_path, [("b1", "Alpha body."), ("b2", "Beta body.")]
)
tok = _tokenizer()
chunks = chunking_by_fixed_token(
tok, merged, chunk_token_size=200, _emit_source_span=True
)
backfill_chunk_sidecars(chunks, blocks_path)
chunks_dict = build_chunks_dict_from_chunking_result(
chunks, doc_id="doc-xyz", file_path="doc.docx"
)
assert chunks_dict # non-empty
for record in chunks_dict.values():
assert "sidecar" in record
assert "_source_span" not in record
assert record["sidecar"]["type"] == "block"
assert record["sidecar"]["refs"]
@pytest.mark.offline
def test_span_first_disambiguates_repeated_cross_block_text(tmp_path: Path) -> None:
# Latest pathological case: the real first chunk is b1 + separator + b2
# ("ab\n\ncd"), but stripping whitespace makes it textually equal to b3
# ("abcd"). Span-first backfill must map by source coverage, not by the
# ambiguous stripped string.
blocks_path, merged = _write_blocks(
tmp_path, [("b1", "ab"), ("b2", "cd"), ("b3", "abcd")]
)
tok = _tokenizer()
chunks = chunking_by_fixed_token(
tok,
merged,
chunk_token_size=6,
chunk_overlap_token_size=0,
_emit_source_span=True,
)
assert [c["content"] for c in chunks] == ["ab\n\ncd", "abcd"]
backfill_chunk_sidecars(chunks, blocks_path)
assert [r["id"] for r in chunks[0]["sidecar"]["refs"]] == ["b1", "b2"]
assert [r["id"] for r in chunks[1]["sidecar"]["refs"]] == ["b3"]
@pytest.mark.offline
def test_split_by_character_only_emits_exact_source_spans() -> None:
tok = _tokenizer()
content = " alpha|beta|gamma "
chunks = chunking_by_fixed_token(
tok,
content,
chunk_token_size=20,
split_by_character="|",
split_by_character_only=True,
_emit_source_span=True,
)
assert [c["content"] for c in chunks] == ["alpha", "beta", "gamma"]
assert [c["_source_span"] for c in chunks] == [
{"start": 2, "end": 7},
{"start": 8, "end": 12},
{"start": 13, "end": 18},
]
@@ -0,0 +1,201 @@
"""Regression tests for the O(N) delta-decode in ``_token_window_source_span``.
The fixed-token chunker maps each decoded token window back to its exact source
span. The offset of a window's start used to be recomputed with a full
``decode(tokens[:start_token])`` prefix decode on every window — O(N) per window,
O(N²) over a document. It now decodes only the delta since the previous *verified*
anchor (O(N) total). These tests pin three properties:
1. **Equivalence** — the delta path yields byte-identical spans to a reference
full-prefix implementation, window for window (incl. a non-1:1 tokenizer and a
real tiktoken BPE tokenizer).
2. **Exactness** — every emitted span is a verbatim slice of the source.
3. **Linear decode budget** — total tokens handed to ``decode`` scales ~O(N), not
O(N²); the pre-optimization prefix decode would blow well past the bound.
"""
from __future__ import annotations
import pytest
from lightrag.chunker import chunking_by_fixed_token
from lightrag.chunker.token_size import _source_span, _token_window_source_span
from lightrag.utils import Tokenizer, TokenizerInterface
class _CharTokenizer(TokenizerInterface):
"""1:1 char-per-token; ``decode(encode(x)) == x`` so windows are verbatim."""
def encode(self, content: str) -> list[int]:
return [ord(ch) for ch in content]
def decode(self, tokens: list[int]) -> str:
return "".join(chr(t) for t in tokens)
class _MultiTokenTokenizer(TokenizerInterface):
"""Non-uniform char→token ratio: uppercase = 2 tokens, ``.?!`` = 3, else 1.
Exercises the offset arithmetic where token count != char count, so a bug that
confuses the two surfaces immediately.
"""
def encode(self, content: str) -> list[int]:
tokens: list[int] = []
for ch in content:
if ch.isupper():
tokens.extend([ord(ch), ord(ch) + 1000])
elif ch in (".", "?", "!"):
tokens.extend([ord(ch), ord(ch) + 2000, ord(ch) + 3000])
else:
tokens.append(ord(ch))
return tokens
def decode(self, tokens: list[int]) -> str:
result: list[str] = []
i = 0
while i < len(tokens):
base = tokens[i]
if (
i + 2 < len(tokens)
and tokens[i + 1] == base + 2000
and tokens[i + 2] == base + 3000
):
result.append(chr(base))
i += 3
elif i + 1 < len(tokens) and tokens[i + 1] == base + 1000:
result.append(chr(base))
i += 2
else:
result.append(chr(base))
i += 1
return "".join(result)
class _CountingTokenizer(TokenizerInterface):
"""Wraps a char tokenizer and tallies every token handed to ``decode``."""
def __init__(self) -> None:
self.decoded_tokens = 0
def encode(self, content: str) -> list[int]:
return [ord(ch) for ch in content]
def decode(self, tokens: list[int]) -> str:
self.decoded_tokens += len(tokens)
return "".join(chr(t) for t in tokens)
def _ref_full_prefix_span(
tokenizer: Tokenizer,
content: str,
tokens: list[int],
start_token: int,
end_token: int,
) -> dict[str, int] | None:
"""The pre-optimization implementation: full ``decode(tokens[:start_token])``."""
window = tokenizer.decode(tokens[start_token:end_token])
start = len(tokenizer.decode(tokens[:start_token]))
end = start + len(window)
if content[start:end] != window:
found = content.find(
window, max(0, start - 32), min(len(content), end + 32 + len(window))
)
if found < 0:
return None
start, end = found, found + len(window)
return _source_span(content, start, end)
def _windows(n_tokens: int, chunk_size: int, overlap: int) -> list[tuple[int, int]]:
step = chunk_size - overlap
return [
(start, min(start + chunk_size, n_tokens)) for start in range(0, n_tokens, step)
]
@pytest.mark.offline
@pytest.mark.parametrize(
"content",
[
" ".join(f"word{i:03d}" for i in range(400)),
"Alpha. BETA gamma? Delta! " * 120,
"Repeated phrase. " * 200, # heavily repeated — exact offsets must not drift
],
)
def test_delta_decode_matches_full_prefix_reference(content: str) -> None:
for impl in (_CharTokenizer(), _MultiTokenTokenizer()):
tok = Tokenizer(model_name="t", tokenizer=impl)
tokens = tok.encode(content)
chunk_size, overlap = 60, 12
anchor = (0, 0)
for start_token, end_token in _windows(len(tokens), chunk_size, overlap):
got, anchor = _token_window_source_span(
tok, content, tokens, start_token, end_token, anchor=anchor
)
ref = _ref_full_prefix_span(tok, content, tokens, start_token, end_token)
assert got == ref
@pytest.mark.offline
def test_delta_decode_matches_full_prefix_with_tiktoken() -> None:
pytest.importorskip("tiktoken")
from lightrag.utils import TiktokenTokenizer
tok = TiktokenTokenizer()
content = (
"The quick brown fox jumps over the lazy dog. "
"Pack my box with five dozen liquor jugs. "
"How vexingly quick daft zebras jump. "
) * 40
tokens = tok.encode(content)
anchor = (0, 0)
for start_token, end_token in _windows(len(tokens), 24, 6):
got, anchor = _token_window_source_span(
tok, content, tokens, start_token, end_token, anchor=anchor
)
ref = _ref_full_prefix_span(tok, content, tokens, start_token, end_token)
assert got == ref
@pytest.mark.offline
def test_fixed_token_spans_are_exact_on_long_doc() -> None:
content = " ".join(f"token{i:04d}" for i in range(1500))
tok = Tokenizer(model_name="char", tokenizer=_CharTokenizer())
chunks = chunking_by_fixed_token(
tok,
content,
chunk_token_size=120,
chunk_overlap_token_size=20,
_emit_source_span=True,
)
assert len(chunks) > 5
for chunk in chunks:
span = chunk["_source_span"]
assert content[span["start"] : span["end"]] == chunk["content"]
@pytest.mark.offline
def test_decode_budget_is_linear_not_quadratic() -> None:
# char tokenizer => len(tokens) == len(content). With the old full-prefix
# decode the helper alone would decode ~sum(starts) == N^2/(2*step) tokens;
# the delta path decodes ~one step per window plus each window once. Assert
# the total stays under a small linear multiple of N, a bound the quadratic
# implementation cannot meet.
content = "x" * 8000
counting = _CountingTokenizer()
tok = Tokenizer(model_name="counting", tokenizer=counting)
n = len(tok.encode(content)) # 8000
chunking_by_fixed_token(
tok,
content,
chunk_token_size=200,
chunk_overlap_token_size=20,
_emit_source_span=True,
)
# Empirically ~3.2*N for the delta path; the old prefix decode is ~24*N here.
assert counting.decoded_tokens <= 6 * n
+191
View File
@@ -0,0 +1,191 @@
"""
Pytest configuration for LightRAG tests.
This file provides command-line options and fixtures for test configuration.
"""
import pytest
@pytest.fixture(autouse=True)
def _hermetic_mineru_env(monkeypatch):
"""Make every test start with parser-routing env vars in their unset state.
``lightrag/api/{auth,config}.py`` call ``load_dotenv(override=False)``
at import time, leaking the developer's local ``.env`` into the test
process. The MinerU test fixtures assume ``MINERU_API_MODE`` is unset
(so it defaults to ``"local"`` per ``MinerURawClient.__init__`` /
``parser_engine_endpoint_requirement``):
- A leaked ``MINERU_API_MODE=offical`` typo (or any invalid value)
makes ``MinerURawClient()`` raise at construction.
- A leaked ``MINERU_API_MODE=official`` flips
``parser_engine_endpoint_requirement`` to return
``"MINERU_API_TOKEN"`` instead of ``"MINERU_LOCAL_ENDPOINT"``,
breaking the validation-error string match.
``LIGHTRAG_PARSER`` is cleared for the same reason: a routing rule
like ``docx:mineru-iet`` in the developer's ``.env`` forces
``parser_routing.validate_parser_routing_config`` to require the
corresponding endpoint (``MINERU_LOCAL_ENDPOINT`` /
``DOCLING_ENDPOINT``) at every ``create_app`` call, which then trips
unrelated API/FastAPI tests (``test_bedrock_llm.py``,
``test_path_prefixes.py``).
The ``MINERU_LOCAL_*`` parser options are stripped for the same reason:
a developer ``.env`` that pins e.g. ``MINERU_LOCAL_PARSE_METHOD=ocr``
leaks a non-default into tests that assume the built-in defaults
(``test_client_local_mode_round_trip`` expects ``parse_method=auto``;
``test_invalid_when_local_parser_options_change`` toggles each option
and expects the change to invalidate a bundle recorded with defaults).
Strip these variables globally; tests that need a specific mode can
still ``monkeypatch.setenv(...)`` themselves and monkeypatch will
restore the inherited value at teardown.
"""
monkeypatch.delenv("MINERU_API_MODE", raising=False)
monkeypatch.delenv("MINERU_API_TOKEN", raising=False)
monkeypatch.delenv("MINERU_LOCAL_ENDPOINT", raising=False)
monkeypatch.delenv("MINERU_OFFICIAL_ENDPOINT", raising=False)
monkeypatch.delenv("MINERU_LOCAL_BACKEND", raising=False)
monkeypatch.delenv("MINERU_LOCAL_PARSE_METHOD", raising=False)
monkeypatch.delenv("MINERU_LOCAL_IMAGE_ANALYSIS", raising=False)
monkeypatch.delenv("MINERU_LOCAL_START_PAGE_ID", raising=False)
monkeypatch.delenv("LIGHTRAG_PARSER", raising=False)
monkeypatch.delenv("DOCLING_ENDPOINT", raising=False)
def pytest_configure(config):
"""Register custom markers for LightRAG tests."""
config.addinivalue_line(
"markers", "offline: marks tests as offline (no external dependencies)"
)
config.addinivalue_line(
"markers",
"integration: marks tests requiring external services (skipped by default)",
)
config.addinivalue_line("markers", "requires_db: marks tests requiring database")
config.addinivalue_line(
"markers", "requires_api: marks tests requiring LightRAG API server"
)
def pytest_addoption(parser):
"""Add custom command-line options for LightRAG tests."""
parser.addoption(
"--keep-artifacts",
action="store_true",
default=False,
help="Keep test artifacts (temporary directories and files) after test completion for inspection",
)
parser.addoption(
"--stress-test",
action="store_true",
default=False,
help="Enable stress test mode with more intensive workloads",
)
parser.addoption(
"--test-workers",
action="store",
default=3,
type=int,
help="Number of parallel workers for stress tests (default: 3)",
)
parser.addoption(
"--run-integration",
action="store_true",
default=False,
help="Run integration tests that require external services (database, API server, etc.)",
)
def pytest_collection_modifyitems(config, items):
"""Modify test collection to skip integration tests by default.
Integration tests are skipped unless --run-integration flag is provided.
This allows running offline tests quickly without needing external services.
"""
if config.getoption("--run-integration"):
# If --run-integration is specified, run all tests
return
skip_integration = pytest.mark.skip(
reason="Requires external services(DB/API), use --run-integration to run"
)
for item in items:
if "integration" in item.keywords:
item.add_marker(skip_integration)
@pytest.fixture(scope="session")
def keep_test_artifacts(request):
"""
Fixture to determine whether to keep test artifacts.
Priority: CLI option > Environment variable > Default (False)
"""
import os
# Check CLI option first
if request.config.getoption("--keep-artifacts"):
return True
# Fall back to environment variable
return os.getenv("LIGHTRAG_KEEP_ARTIFACTS", "false").lower() == "true"
@pytest.fixture(scope="session")
def stress_test_mode(request):
"""
Fixture to determine whether stress test mode is enabled.
Priority: CLI option > Environment variable > Default (False)
"""
import os
# Check CLI option first
if request.config.getoption("--stress-test"):
return True
# Fall back to environment variable
return os.getenv("LIGHTRAG_STRESS_TEST", "false").lower() == "true"
@pytest.fixture(scope="session")
def parallel_workers(request):
"""
Fixture to determine the number of parallel workers for stress tests.
Priority: CLI option > Environment variable > Default (3)
"""
import os
# Check CLI option first
cli_workers = request.config.getoption("--test-workers")
if cli_workers != 3: # Non-default value provided
return cli_workers
# Fall back to environment variable
return int(os.getenv("LIGHTRAG_TEST_WORKERS", "3"))
@pytest.fixture(scope="session")
def run_integration_tests(request):
"""
Fixture to determine whether to run integration tests.
Priority: CLI option > Environment variable > Default (False)
"""
import os
# Check CLI option first
if request.config.getoption("--run-integration"):
return True
# Fall back to environment variable
return os.getenv("LIGHTRAG_RUN_INTEGRATION", "false").lower() == "true"
View File
@@ -0,0 +1,101 @@
import tempfile
import unittest
from pathlib import Path
from lightrag.evaluation.offline_retrieval_check import (
audit_samples,
load_cases,
load_documents,
load_oracle,
summarize,
)
class OfflineRetrievalCheckTests(unittest.TestCase):
def test_expected_document_ranks_first(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
docs_dir = root / "docs"
docs_dir.mkdir()
(docs_dir / "alpha.md").write_text(
"Alpha covers vector search and filtering.",
encoding="utf-8",
)
(docs_dir / "beta.md").write_text(
"Beta covers deployment and monitoring.",
encoding="utf-8",
)
dataset = root / "dataset.json"
dataset.write_text(
'{"test_cases":[{"question":"Which file explains vector search?"}]}',
encoding="utf-8",
)
oracle = root / "oracle.json"
oracle.write_text(
'{"oracle":[{"question":"Which file explains vector search?",'
'"expected_documents":["alpha.md"]}]}',
encoding="utf-8",
)
results = audit_samples(
load_cases(dataset),
load_oracle(oracle),
load_documents(docs_dir),
)
summary = summarize(results, top_k=1)
self.assertEqual(results[0].ranked[0], "alpha.md")
self.assertEqual(summary["queries"], 1)
self.assertEqual(summary["average_recall_at_k"], 1.0)
def test_zero_score_documents_do_not_count_as_hits(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
docs_dir = root / "docs"
docs_dir.mkdir()
(docs_dir / "alpha.md").write_text(
"Alpha covers deployment pipelines.",
encoding="utf-8",
)
(docs_dir / "beta.md").write_text(
"Beta covers monitoring dashboards.",
encoding="utf-8",
)
dataset = root / "dataset.json"
dataset.write_text(
'{"test_cases":[{"question":"Which file explains vector search?"}]}',
encoding="utf-8",
)
oracle = root / "oracle.json"
oracle.write_text(
'{"oracle":[{"question":"Which file explains vector search?",'
'"expected_documents":["alpha.md"]}]}',
encoding="utf-8",
)
results = audit_samples(
load_cases(dataset),
load_oracle(oracle),
load_documents(docs_dir),
)
summary = summarize(results, top_k=1)
self.assertEqual(results[0].ranked, [])
self.assertEqual(summary["average_recall_at_k"], 0.0)
self.assertEqual(summary["no_hit_queries"], 1)
def test_sample_oracle_has_full_recall_at_two(self):
results = audit_samples(
load_cases(Path("lightrag/evaluation/sample_dataset.json")),
load_oracle(Path("lightrag/evaluation/sample_retrieval_oracle.json")),
load_documents(Path("lightrag/evaluation/sample_documents")),
)
summary = summarize(results, top_k=2)
self.assertEqual(summary["queries"], 6)
self.assertEqual(summary["full_recall_queries"], 6)
self.assertEqual(summary["no_hit_queries"], 0)
if __name__ == "__main__":
unittest.main()
View File
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
"""Tests for entity extraction gleaning token limit guard."""
import logging
from unittest.mock import AsyncMock
import pytest
from lightrag.utils import Tokenizer, TokenizerInterface
@pytest.fixture
def _propagate_lightrag_logger(monkeypatch):
"""``lightrag.utils.logger`` sets ``propagate = False`` to avoid noisy
test output; restore propagation locally so ``caplog`` can capture
WARNING records emitted from inside ``lightrag.operate``."""
monkeypatch.setattr(logging.getLogger("lightrag"), "propagate", True)
class DummyTokenizer(TokenizerInterface):
"""Simple 1:1 character-to-token mapping for testing."""
def encode(self, content: str):
return [ord(ch) for ch in content]
def decode(self, tokens):
return "".join(chr(token) for token in tokens)
def _make_global_config(
entity_extract_max_gleaning: int = 1,
) -> dict:
"""Build a minimal global_config dict for extract_entities."""
tokenizer = Tokenizer("dummy", DummyTokenizer())
extract_func = AsyncMock(return_value="")
return {
"llm_model_func": extract_func,
"role_llm_funcs": {
"extract": extract_func,
"keyword": extract_func,
"query": extract_func,
"vlm": extract_func,
},
"entity_extract_max_gleaning": entity_extract_max_gleaning,
"entity_extract_max_records": 100,
"entity_extract_max_entities": 40,
"addon_params": {},
"tokenizer": tokenizer,
"llm_model_max_async": 1,
}
# Minimal valid extraction result that _process_extraction_result can parse
_EXTRACTION_RESULT = (
"(entity<|#|>TEST_ENTITY<|#|>CONCEPT<|#|>A test entity)<|COMPLETE|>"
)
def _make_chunks(content: str = "Test content.") -> dict[str, dict]:
return {
"chunk-001": {
"tokens": len(content),
"content": content,
"full_doc_id": "doc-001",
"chunk_order_index": 0,
}
}
@pytest.mark.offline
@pytest.mark.asyncio
async def test_gleaning_skipped_when_tokens_exceed_limit(
monkeypatch, caplog, _propagate_lightrag_logger
):
"""Gleaning must be skipped (with a WARNING) when the projected
gleaning input — system + history(user+assistant) + continue prompt —
exceeds ``MAX_EXTRACT_INPUT_TOKENS``. This prevents
``context_length_exceeded`` errors from the LLM provider on the second
round when the initial response was long.
"""
from lightrag.operate import extract_entities
# 10 tokens cannot fit any realistic prompt — guard must trip.
monkeypatch.setenv("MAX_EXTRACT_INPUT_TOKENS", "10")
global_config = _make_global_config(entity_extract_max_gleaning=1)
llm_func = global_config["llm_model_func"]
llm_func.return_value = _EXTRACTION_RESULT
with caplog.at_level("WARNING", logger="lightrag"):
await extract_entities(
chunks=_make_chunks(),
global_config=global_config,
)
# Only the initial extraction round ran; gleaning was skipped.
assert llm_func.await_count == 1
warnings_emitted = [
rec.getMessage()
for rec in caplog.records
if rec.levelname == "WARNING"
and rec.getMessage().startswith("Gleaning stopped for chunk chunk-001:")
]
assert warnings_emitted, (
"expected a WARNING log explaining gleaning was skipped due to "
"token limit; got: "
f"{[r.getMessage() for r in caplog.records]}"
)
# Message must surface both the measured token count and the limit so
# operators can size MAX_EXTRACT_INPUT_TOKENS appropriately.
msg = warnings_emitted[0]
assert "exceeded limit (10)" in msg
assert "Input tokens (" in msg
@pytest.mark.offline
@pytest.mark.asyncio
async def test_gleaning_proceeds_when_tokens_within_limit(monkeypatch):
"""Gleaning runs normally when the projected input fits the cap."""
from lightrag.operate import extract_entities
monkeypatch.setenv("MAX_EXTRACT_INPUT_TOKENS", "999999")
global_config = _make_global_config(entity_extract_max_gleaning=1)
llm_func = global_config["llm_model_func"]
llm_func.return_value = _EXTRACTION_RESULT
await extract_entities(
chunks=_make_chunks(),
global_config=global_config,
)
# Both rounds run: initial extraction + one gleaning pass.
assert llm_func.await_count == 2
@pytest.mark.offline
@pytest.mark.asyncio
async def test_no_gleaning_when_max_gleaning_zero(monkeypatch):
"""``entity_extract_max_gleaning=0`` disables gleaning regardless of
token budget — the guard is downstream of the feature flag."""
from lightrag.operate import extract_entities
monkeypatch.setenv("MAX_EXTRACT_INPUT_TOKENS", "999999")
global_config = _make_global_config(entity_extract_max_gleaning=0)
llm_func = global_config["llm_model_func"]
llm_func.return_value = _EXTRACTION_RESULT
await extract_entities(
chunks=_make_chunks(),
global_config=global_config,
)
assert llm_func.await_count == 1
@pytest.mark.offline
@pytest.mark.asyncio
async def test_gleaning_guard_disabled_when_max_tokens_zero(monkeypatch):
"""Setting ``MAX_EXTRACT_INPUT_TOKENS=0`` opts out of the guard so
gleaning always runs regardless of input size — useful for callers
whose provider has no hard input ceiling."""
from lightrag.operate import extract_entities
monkeypatch.setenv("MAX_EXTRACT_INPUT_TOKENS", "0")
global_config = _make_global_config(entity_extract_max_gleaning=1)
llm_func = global_config["llm_model_func"]
llm_func.return_value = _EXTRACTION_RESULT
await extract_entities(
chunks=_make_chunks(),
global_config=global_config,
)
# Guard disabled → gleaning still runs even with tight projected input.
assert llm_func.await_count == 2
@@ -0,0 +1,270 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from lightrag.llm.lmdeploy import lmdeploy_model_if_cache
from lightrag.llm.lollms import lollms_model_complete, lollms_model_if_cache
from lightrag.llm.ollama import _ollama_model_if_cache, ollama_model_complete
@pytest.mark.offline
@pytest.mark.asyncio
async def test_ollama_response_format_forwards_to_inner():
hashing_kv = SimpleNamespace(global_config={"llm_model_name": "ollama-model"})
with patch(
"lightrag.llm.ollama._ollama_model_if_cache",
AsyncMock(return_value="{}"),
) as mocked_complete:
await ollama_model_complete(
prompt="hello",
hashing_kv=hashing_kv,
response_format={"type": "json_object"},
)
assert mocked_complete.await_args.kwargs["response_format"] == {
"type": "json_object"
}
@pytest.mark.offline
@pytest.mark.asyncio
async def test_ollama_legacy_keyword_extraction_emits_deprecation_warning():
"""_ollama_model_if_cache is the canonical emission site for the shim."""
captured_kwargs = {}
class FakeAsyncClient:
def __init__(self, *args, **kwargs):
self._client = SimpleNamespace(aclose=AsyncMock())
async def chat(self, **kwargs):
captured_kwargs.update(kwargs)
return {"message": {"content": "{}"}}
with patch("lightrag.llm.ollama.ollama.AsyncClient", FakeAsyncClient):
with pytest.warns(DeprecationWarning):
await _ollama_model_if_cache(
model="ollama-model",
prompt="hello",
keyword_extraction=True,
)
assert captured_kwargs["format"] == "json"
assert "keyword_extraction" not in captured_kwargs
assert "response_format" not in captured_kwargs
@pytest.mark.offline
@pytest.mark.asyncio
async def test_ollama_complete_forwards_legacy_flag_downstream():
"""ollama_model_complete is a pure forwarder; the shim fires inside _if_cache."""
hashing_kv = SimpleNamespace(global_config={"llm_model_name": "ollama-model"})
with patch(
"lightrag.llm.ollama._ollama_model_if_cache",
AsyncMock(return_value="{}"),
) as mocked_complete:
await ollama_model_complete(
prompt="hello",
hashing_kv=hashing_kv,
keyword_extraction=True,
)
assert mocked_complete.await_args.kwargs.get("keyword_extraction") is True
@pytest.mark.offline
@pytest.mark.asyncio
async def test_ollama_translates_json_object_response_format_to_native_format():
captured_kwargs = {}
class FakeAsyncClient:
def __init__(self, *args, **kwargs):
self._client = SimpleNamespace(aclose=AsyncMock())
async def chat(self, **kwargs):
captured_kwargs.update(kwargs)
return {"message": {"content": "{}"}}
with patch("lightrag.llm.ollama.ollama.AsyncClient", FakeAsyncClient):
result = await _ollama_model_if_cache(
model="ollama-model",
prompt="hello",
response_format={"type": "json_object"},
)
assert result == "{}"
assert captured_kwargs["format"] == "json"
assert "response_format" not in captured_kwargs
@pytest.mark.offline
@pytest.mark.asyncio
async def test_ollama_unwraps_openai_json_schema_response_format():
captured_kwargs = {}
schema = {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
}
class FakeAsyncClient:
def __init__(self, *args, **kwargs):
self._client = SimpleNamespace(aclose=AsyncMock())
async def chat(self, **kwargs):
captured_kwargs.update(kwargs)
return {"message": {"content": "{}"}}
with patch("lightrag.llm.ollama.ollama.AsyncClient", FakeAsyncClient):
result = await _ollama_model_if_cache(
model="ollama-model",
prompt="hello",
response_format={
"type": "json_schema",
"json_schema": {"name": "answer_payload", "schema": schema},
},
)
assert result == "{}"
assert captured_kwargs["format"] == schema
assert "response_format" not in captured_kwargs
@pytest.mark.offline
@pytest.mark.asyncio
async def test_lollms_if_cache_strips_response_format_before_request():
"""lollms_model_if_cache drops response_format; lollms has no JSON mode."""
captured_requests = []
class FakeResponse:
def __init__(self):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def text(self):
return "{}"
class FakeSession:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
def post(self, url, json):
captured_requests.append(json)
return FakeResponse()
with patch("lightrag.llm.lollms.aiohttp.ClientSession", FakeSession):
result = await lollms_model_if_cache(
model="lollms-model",
prompt="hello",
response_format={"type": "json_object"},
)
assert result == "{}"
assert captured_requests
assert "response_format" not in captured_requests[0]
@pytest.mark.offline
@pytest.mark.asyncio
async def test_lollms_if_cache_emits_deprecation_warning():
class FakeResponse:
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def text(self):
return "{}"
class FakeSession:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
def post(self, url, json):
return FakeResponse()
with patch("lightrag.llm.lollms.aiohttp.ClientSession", FakeSession):
with pytest.warns(DeprecationWarning):
await lollms_model_if_cache(
model="lollms-model",
prompt="hello",
keyword_extraction=True,
)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_lollms_complete_forwards_legacy_flag_downstream():
hashing_kv = SimpleNamespace(global_config={"llm_model_name": "lollms-model"})
with patch(
"lightrag.llm.lollms.lollms_model_if_cache",
AsyncMock(return_value="{}"),
) as mocked_complete:
await lollms_model_complete(
prompt="hello",
hashing_kv=hashing_kv,
keyword_extraction=True,
)
assert mocked_complete.await_args.kwargs.get("keyword_extraction") is True
@pytest.mark.offline
@pytest.mark.asyncio
async def test_lmdeploy_strips_response_format_before_generation_config(monkeypatch):
captured_gen_config_kwargs = {}
class FakeGenerationConfig:
def __init__(self, **kwargs):
captured_gen_config_kwargs.update(kwargs)
class FakeVersion:
def __lt__(self, other):
return False
async def fake_generate(*_args, **_kwargs):
yield SimpleNamespace(response="{}")
monkeypatch.setattr(
"lightrag.llm.lmdeploy.initialize_lmdeploy_pipeline",
lambda **_kwargs: SimpleNamespace(generate=fake_generate),
)
import sys
sys.modules["lmdeploy"] = SimpleNamespace(
__version__="0.6.0",
version_info=FakeVersion(),
GenerationConfig=FakeGenerationConfig,
)
result = await lmdeploy_model_if_cache(
model="lmdeploy-model",
prompt="hello",
response_format={"type": "json_object"},
)
assert result == "{}"
assert "response_format" not in captured_gen_config_kwargs
assert "keyword_extraction" not in captured_gen_config_kwargs
+155
View File
@@ -0,0 +1,155 @@
import pytest
from unittest.mock import patch
from lightrag.base import QueryParam
from lightrag.operate import _parse_keywords_payload, extract_keywords_only
class _FakeKeywordModel:
def model_dump(self):
return {
"high_level_keywords": ["AI"],
"low_level_keywords": ["RAG", "Graph"],
}
class _FakeTokenizer:
def encode(self, content: str) -> list[int]:
return [ord(ch) for ch in content]
class _FakeKVStorage:
def __init__(self):
self.global_config = {"enable_llm_cache": True}
self._store = {}
async def get_by_id(self, key):
return self._store.get(key)
async def upsert(self, entries):
self._store.update(entries)
def _keyword_global_config(
model: str, binding: str = "openai", keyword_func=None
) -> dict:
return {
"addon_params": {"language": "en"},
"tokenizer": _FakeTokenizer(),
"role_llm_funcs": {"keyword": keyword_func} if keyword_func else {},
"llm_cache_identities": {
"keyword": {
"role": "keyword",
"binding": binding,
"model": model,
"host": "https://api.example.com/v1",
}
},
}
@pytest.mark.offline
def test_parse_keywords_payload_accepts_model_like_objects():
is_valid, hl_keywords, ll_keywords = _parse_keywords_payload(_FakeKeywordModel())
assert is_valid is True
assert hl_keywords == ["AI"]
assert ll_keywords == ["RAG", "Graph"]
@pytest.mark.offline
def test_parse_keywords_payload_extracts_json_from_wrapped_text():
result = """
analysis first
{"high_level_keywords":"AI, Agents","low_level_keywords":["RAG","LightRAG"]}
trailing note
"""
is_valid, hl_keywords, ll_keywords = _parse_keywords_payload(result)
assert is_valid is True
assert hl_keywords == ["AI", "Agents"]
assert ll_keywords == ["RAG", "LightRAG"]
@pytest.mark.offline
def test_parse_keywords_payload_warns_when_json_repair_is_used():
broken_result = (
'{"high_level_keywords":"AI, Agents","low_level_keywords":["RAG","LightRAG"]'
)
with patch("lightrag.operate.logger.warning") as mocked_warning:
is_valid, hl_keywords, ll_keywords = _parse_keywords_payload(broken_result)
assert is_valid is True
assert hl_keywords == ["AI", "Agents"]
assert ll_keywords == ["RAG", "LightRAG"]
mocked_warning.assert_called_once()
assert (
"Keyword extraction response required JSON repair"
in mocked_warning.call_args[0][0]
)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_extract_keywords_only_accepts_empty_keyword_cache_without_requery():
async def should_not_run(*_args, **_kwargs):
raise AssertionError(
"keyword LLM should not be called on a valid empty cache hit"
)
param = QueryParam()
global_config = _keyword_global_config("model-a", keyword_func=should_not_run)
with patch(
"lightrag.operate.handle_cache",
return_value=('{"high_level_keywords":[],"low_level_keywords":[]}', None),
):
hl_keywords, ll_keywords = await extract_keywords_only(
"hello",
param,
global_config,
hashing_kv=None,
)
assert hl_keywords == []
assert ll_keywords == []
@pytest.mark.offline
@pytest.mark.asyncio
async def test_extract_keywords_only_partitions_cache_by_keyword_llm_identity():
cache = _FakeKVStorage()
calls = 0
async def keyword_model(*_args, **_kwargs):
nonlocal calls
calls += 1
return (
'{"high_level_keywords":["model-'
+ str(calls)
+ '"],"low_level_keywords":["rag"]}'
)
param = QueryParam()
first_hl, first_ll = await extract_keywords_only(
"same query",
param,
_keyword_global_config("model-a", keyword_func=keyword_model),
hashing_kv=cache,
)
second_hl, second_ll = await extract_keywords_only(
"same query",
param,
_keyword_global_config("model-b", keyword_func=keyword_model),
hashing_kv=cache,
)
assert first_hl == ["model-1"]
assert first_ll == ["rag"]
assert second_hl == ["model-2"]
assert second_ll == ["rag"]
assert calls == 2
assert len(cache._store) == 2
@@ -0,0 +1,69 @@
import json
import re
import pytest
from lightrag.prompt import PROMPTS
@pytest.mark.offline
def test_keywords_extraction_prompt_template_formats_with_literal_json_braces():
rendered = PROMPTS["keywords_extraction"].format(
query="hello",
examples="example",
language="en",
)
assert "first character of your response must be `{`" in rendered
assert '{"high_level_keywords": [], "low_level_keywords": []}' in rendered
@pytest.mark.offline
def test_keywords_extraction_examples_are_format_only():
"""Keyword examples must be placeholder-only JSON templates, not sample demos.
Rather than denylisting specific sample queries (brittle: generic words like
"education" would both false-match unrelated content and let new samples slip
through), assert the structural shape: no ``Query:``/``Output:`` demo framing,
and every keyword is an angle-bracket placeholder.
"""
placeholder = re.compile(r"<[^<>]+>")
for example in PROMPTS["keywords_extraction_examples"]:
assert "Query:" not in example
assert "Output:" not in example
parsed = json.loads(example)
assert set(parsed) == {"high_level_keywords", "low_level_keywords"}
keywords = parsed["high_level_keywords"] + parsed["low_level_keywords"]
assert keywords
for keyword in keywords:
assert placeholder.fullmatch(keyword), keyword
@pytest.mark.offline
def test_keywords_extraction_prompt_labels_template_as_not_source_text():
prompt = PROMPTS["keywords_extraction"]
assert "---Output Format Template---" in prompt
assert "---Examples---" not in prompt
assert "Apple Inc." not in prompt
assert "output JSON format template only" in prompt
assert "not source text" in prompt
assert "must never be used as keyword extraction content" in prompt
assert (
"derived only from the `User Query` in the `---Real Data---` section" in prompt
)
@pytest.mark.offline
def test_keywords_extraction_prompt_keeps_single_real_user_query_section():
rendered = PROMPTS["keywords_extraction"].format(
query="How did LightRAG improve retrieval?",
examples="\n".join(PROMPTS["keywords_extraction_examples"]),
language="English",
)
assert rendered.count("User Query:") == 1
assert "User Query: How did LightRAG improve retrieval?" in rendered
assert "User Query:" not in "\n".join(PROMPTS["keywords_extraction_examples"])
@@ -0,0 +1,115 @@
"""Regression tests for the map-reduce chunking path of
``_handle_entity_relation_summary``.
The map phase tokenizes every description to decide (a) whether the list
needs splitting and (b) where to cut the chunks. Historically the same
description was encoded twice per map-reduce iteration — once to sum the
total token count and again while building the chunks — which doubled the
tiktoken BPE cost on every entity/edge merge. The counts from the first
pass are now memoized and reused by the second.
These tests guard:
* the chunking output is unchanged (behavioral), and
* each description is encoded exactly once per map-reduce iteration
(the perf property the fix introduced).
"""
from unittest.mock import AsyncMock
import pytest
from lightrag.utils import Tokenizer, TokenizerInterface
class _CountingTokenizer(TokenizerInterface):
"""1:1 character-to-token mapping that records every ``encode`` input."""
def __init__(self) -> None:
self.encoded: list[str] = []
def encode(self, content: str):
self.encoded.append(content)
return [ord(ch) for ch in content]
def decode(self, tokens):
return "".join(chr(token) for token in tokens)
def _make_chunking_config(mock_extract) -> tuple[dict, _CountingTokenizer]:
"""``global_config`` whose ``summary_context_size`` forces the map-reduce
chunking path (4 five-token descriptions vs. a 10-token window)."""
counter = _CountingTokenizer()
tokenizer = Tokenizer("dummy", counter)
global_config = {
"role_llm_funcs": {"extract": mock_extract},
"addon_params": {},
"_resolved_summary_language": "English",
"summary_length_recommended": 100,
# 4 descriptions x 5 tokens = 20 tokens > 10-token window → chunking
"summary_context_size": 10,
"summary_max_tokens": 100_000,
"force_llm_summary_on_merge": 10,
"tokenizer": tokenizer,
}
return global_config, counter
@pytest.mark.offline
@pytest.mark.asyncio
async def test_map_reduce_chunking_is_preserved():
"""Forcing the chunking path must still split the descriptions into groups,
summarize each via the LLM, and join the results — i.e. memoizing the
token counts must not change the chunk boundaries or the final output."""
from lightrag.operate import _handle_entity_relation_summary
# Distinct summaries per reduce call so the joined result reflects the
# number of chunks produced.
mock_extract = AsyncMock(side_effect=["SUM_ONE", "SUM_TWO", "SUM_THREE"])
global_config, _ = _make_chunking_config(mock_extract)
description, llm_was_used = await _handle_entity_relation_summary(
"Entity",
"TEST_ENTITY",
["alpha", "beta", "gamma", "delta"],
"<SEP>",
global_config,
llm_response_cache=None,
)
# Reduce phase ran (the LLM was used to summarize at least one chunk).
assert llm_was_used is True
# 4 five-token descriptions under a 10-token window → 2 chunks → 2 summaries
# joined in the next iteration (len == 2 takes the no-LLM join exit).
assert description == "SUM_ONE<SEP>SUM_TWO"
@pytest.mark.offline
@pytest.mark.asyncio
async def test_map_phase_encodes_each_description_once():
"""The map phase must tokenize each description exactly once per
map-reduce iteration. Pre-fix the chunk-building pass re-encoded every
description, so each of the 4 inputs appeared 2x in the first iteration;
post-fix it appears exactly once (the second pass reuses the counts)."""
from lightrag.operate import _handle_entity_relation_summary
mock_extract = AsyncMock(side_effect=["SUM_ONE", "SUM_TWO", "SUM_THREE"])
global_config, counter = _make_chunking_config(mock_extract)
inputs = ["alpha", "beta", "gamma", "delta"]
await _handle_entity_relation_summary(
"Entity",
"TEST_ENTITY",
inputs,
"<SEP>",
global_config,
llm_response_cache=None,
)
# In the first (chunking) iteration each input description must be encoded
# exactly once by the map phase. Pre-fix this was 2 per description.
for desc in inputs:
assert counter.encoded.count(desc) == 1, (
f"description {desc!r} was encoded {counter.encoded.count(desc)} "
f"time(s) by the map phase; expected exactly 1 (memoized counts)"
)
@@ -0,0 +1,160 @@
"""Regression tests: every merge-stage description outcome must be
sanitized before it lands on graph nodes/edges.
Extraction-time descriptions are cleaned by
``sanitize_and_normalize_extracted_text``, but merge-stage descriptions
can bypass that: LLM re-summaries, descriptions read back from existing
graph nodes, and multimodal entity descriptions injected straight from
chunk content. Any of them carrying control characters / surrogates
(e.g. ``\\frac`` decoded as ``\\x0c`` + ``rac`` from unescaped LaTeX in
VLM JSON) would break GraphML (XML) serialization on the next NetworkX
flush with::
ValueError: All strings must be XML compatible: Unicode or ASCII,
no NULL bytes or control characters
``_handle_entity_relation_summary`` (single / join outcomes) and
``_summarize_descriptions`` (LLM outcome) now sanitize at every exit.
"""
from unittest.mock import AsyncMock
import networkx as nx
import pytest
from lightrag.utils import Tokenizer, TokenizerInterface
class DummyTokenizer(TokenizerInterface):
"""Simple 1:1 character-to-token mapping for testing."""
def encode(self, content: str):
return [ord(ch) for ch in content]
def decode(self, tokens):
return "".join(chr(token) for token in tokens)
def _make_global_config(summary_return: str) -> dict:
"""Minimal global_config for ``_summarize_descriptions`` whose ``extract``
role LLM returns ``summary_return`` verbatim."""
tokenizer = Tokenizer("dummy", DummyTokenizer())
extract_func = AsyncMock(return_value=summary_return)
return {
"role_llm_funcs": {"extract": extract_func},
"addon_params": {},
"_resolved_summary_language": "English",
"summary_length_recommended": 100,
"summary_context_size": 10_000,
"tokenizer": tokenizer,
}
# A description that the LLM "echoed" from a dirty source: NULL byte, bell,
# unit-separator and DEL are all illegal in XML 1.0; \t and \n are legal and
# must be preserved.
_DIRTY_SUMMARY = "Clean text\x00with\x07control\x1fchars\x7f\tand\nnewlines"
_EXPECTED_CLEAN = "Clean textwithcontrolchars\tand\nnewlines"
@pytest.mark.offline
@pytest.mark.asyncio
async def test_summarize_descriptions_strips_control_chars():
"""The LLM summary path must remove XML-incompatible control characters
while preserving legal whitespace."""
from lightrag.operate import _summarize_descriptions
global_config = _make_global_config(_DIRTY_SUMMARY)
summary = await _summarize_descriptions(
"Entity",
"TEST_ENTITY",
["first description", "second description"],
global_config,
llm_response_cache=None,
)
assert summary == _EXPECTED_CLEAN
assert "\x00" not in summary
assert "\x07" not in summary
assert "\x1f" not in summary
assert "\x7f" not in summary
@pytest.mark.offline
@pytest.mark.asyncio
async def test_single_description_early_return_is_sanitized():
"""The ``len(description_list) == 1`` early return skips the LLM
entirely — this is exactly the path a dirty multimodal entity
description (chunk content reused verbatim) takes on first insert."""
from lightrag.operate import _handle_entity_relation_summary
description, llm_was_used = await _handle_entity_relation_summary(
"Entity",
"TEST_ENTITY",
[_DIRTY_SUMMARY],
"<SEP>",
# Early return happens before any config key is read.
global_config={},
llm_response_cache=None,
)
assert llm_was_used is False
assert description == _EXPECTED_CLEAN
@pytest.mark.offline
@pytest.mark.asyncio
async def test_join_without_llm_is_sanitized():
"""The no-LLM join outcome must also sanitize: descriptions read back
from pre-existing (dirty) graph nodes re-enter the merge here."""
from lightrag.operate import _handle_entity_relation_summary
tokenizer = Tokenizer("dummy", DummyTokenizer())
global_config = {
"tokenizer": tokenizer,
"summary_context_size": 100_000,
"summary_max_tokens": 100_000,
"force_llm_summary_on_merge": 10,
}
description, llm_was_used = await _handle_entity_relation_summary(
"Entity",
"TEST_ENTITY",
[_DIRTY_SUMMARY, "clean second description"],
"<SEP>",
global_config,
llm_response_cache=None,
)
assert llm_was_used is False
assert description == f"{_EXPECTED_CLEAN}<SEP>clean second description"
@pytest.mark.offline
@pytest.mark.asyncio
async def test_summarized_description_is_graphml_writable(tmp_path):
"""End-to-end guard: a node whose description came from the summary path
must serialize to GraphML without the original ValueError."""
from lightrag.operate import _summarize_descriptions
global_config = _make_global_config(_DIRTY_SUMMARY)
description = await _summarize_descriptions(
"Entity",
"TEST_ENTITY",
["first description", "second description"],
global_config,
llm_response_cache=None,
)
graph = nx.Graph()
graph.add_node("TEST_ENTITY", description=description)
# Pre-fix, write_graphml raised:
# "All strings must be XML compatible: ... no NULL bytes or control characters"
out = tmp_path / "graph.graphml"
nx.write_graphml(graph, out)
reloaded = nx.read_graphml(out)
assert reloaded.nodes["TEST_ENTITY"]["description"] == _EXPECTED_CLEAN
View File
View File
@@ -0,0 +1,127 @@
"""Atomicity coverage for ``FaissVectorDBStorage._save_faiss_index``.
The save path produces two files (``.index`` and ``.meta.json``). Each one
must land via tmp + rename so a crash during either write preserves the
prior snapshot. Cross-file consistency between the two renames is
intentionally out of scope (declared in the PR).
"""
import json
import os
from unittest.mock import patch
import numpy as np
import pytest
faiss = pytest.importorskip("faiss") # noqa: F841 — needed before the import below
from lightrag.kg.faiss_impl import FaissVectorDBStorage # noqa: E402
from lightrag.utils import EmbeddingFunc # noqa: E402
def _make_storage(tmp_path, namespace: str = "vectors") -> FaissVectorDBStorage:
"""Construct a FaissVectorDBStorage that does not need real embeddings.
``_save_faiss_index`` only reads ``self._index`` and ``self._id_to_meta``,
so a dummy ``EmbeddingFunc`` with the right ``embedding_dim`` is enough.
"""
def _unused(*_args, **_kwargs): # pragma: no cover — never called here
raise AssertionError("embedding_func must not be invoked by save path")
embedding_func = EmbeddingFunc(embedding_dim=4, func=_unused)
return FaissVectorDBStorage(
namespace=namespace,
workspace="",
global_config={
"working_dir": str(tmp_path),
"embedding_batch_num": 1,
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.2},
},
embedding_func=embedding_func,
)
def _seed(storage: FaissVectorDBStorage, marker: str) -> None:
"""Push a single vector tagged with ``marker`` into the in-memory state."""
vec = np.ones((1, 4), dtype=np.float32)
storage._index.add(vec)
storage._id_to_meta = {
storage._index.ntotal - 1: {"__id__": marker, "content": marker}
}
@pytest.mark.offline
def test_save_faiss_index_publishes_both_files_cleanly(tmp_path):
storage = _make_storage(tmp_path)
_seed(storage, "v1")
storage._save_faiss_index()
assert os.path.exists(storage._faiss_index_file)
assert os.path.exists(storage._meta_file)
meta = json.load(open(storage._meta_file))
assert next(iter(meta.values()))["__id__"] == "v1"
leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p]
assert leftovers == [], f"Unexpected tmp residue: {leftovers}"
@pytest.mark.offline
def test_save_faiss_index_replace_crash_preserves_prior_index(tmp_path):
"""If ``os.replace`` raises while renaming the ``.index`` tmp, the old
``.index`` must remain loadable by ``faiss.read_index``."""
storage = _make_storage(tmp_path)
_seed(storage, "v1")
storage._save_faiss_index()
assert os.path.exists(storage._faiss_index_file)
# Bump in-memory state to v2 and then crash the .index rename.
storage._index.reset()
_seed(storage, "v2")
with patch(
"lightrag.file_atomic.os.replace",
side_effect=OSError("simulated crash"),
):
with pytest.raises(OSError, match="simulated crash"):
storage._save_faiss_index()
# Reload the destination — must still be the v1 single-vector index.
reloaded = faiss.read_index(storage._faiss_index_file)
assert reloaded.ntotal == 1
leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p]
assert leftovers == [], f"Python-exception path must clean tmp, got {leftovers}"
@pytest.mark.offline
def test_save_faiss_meta_write_failure_preserves_prior_meta(tmp_path):
"""A failure inside the meta ``write_fn`` (after the index has been
written) must leave the previous ``.meta.json`` intact."""
storage = _make_storage(tmp_path)
_seed(storage, "v1")
storage._save_faiss_index()
assert json.load(open(storage._meta_file))
real_dump = json.dump
seen: list[bool] = []
def explode_on_second_dump(*args, **kwargs):
# The first dump is from the v1 save above — we are past it because
# this patch is only installed for the v2 attempt. Raise immediately
# to simulate a serialization failure mid-write.
seen.append(True)
raise RuntimeError("simulated meta failure")
storage._index.reset()
_seed(storage, "v2")
with patch("lightrag.kg.faiss_impl.json.dump", side_effect=explode_on_second_dump):
with pytest.raises(RuntimeError, match="simulated meta failure"):
storage._save_faiss_index()
assert seen, "patched json.dump must have been invoked"
# .meta.json must still parse and still reflect v1.
meta = json.load(open(storage._meta_file))
assert any(entry["__id__"] == "v1" for entry in meta.values())
leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p]
assert leftovers == [], f"meta-write failure must clean tmp, got {leftovers}"
# Restore real json.dump so subsequent tests don't see the patch (defensive).
assert json.dump is real_dump
@@ -0,0 +1,824 @@
"""Deferred-embedding coverage for ``FaissVectorDBStorage``.
The storage no longer embeds eagerly in ``upsert``: it buffers a pending doc
and embeds once per id at flush time (``index_done_callback`` / ``finalize``).
These tests pin that contract using a counting mock embedding function — no
live model or network. They mirror the protocol proven for
``NanoVectorDBStorage`` (issue #2785) plus three Faiss-specific cases:
- ``test_reupsert_after_flush_replaces_single_fid`` — Faiss has no in-place
upsert; verify the rebuild keeps a single fid per custom id.
- ``test_index_done_callback_save_failure_raises`` — flush succeeds, save IO
fails: pending is empty, ``_index_dirty`` stays True, the materialized index
is preserved for a finalize retry.
- ``test_reload_warns_on_index_meta_skew`` — ``index > meta`` on-disk skew
(from a crash between the two atomic_writes) is logged on reload but **not**
auto-repaired.
"""
import json
import os
import numpy as np
import pytest
faiss = pytest.importorskip("faiss")
from lightrag.kg.faiss_impl import FaissVectorDBStorage # noqa: E402
from lightrag.kg.shared_storage import ( # noqa: E402
initialize_share_data,
finalize_share_data,
)
from lightrag.utils import EmbeddingFunc # noqa: E402
DIM = 8
@pytest.fixture(autouse=True)
def _shared_data():
finalize_share_data()
initialize_share_data()
yield
finalize_share_data()
class _CountingEmbed:
"""Async embedding callable that records how many texts it embedded and how
many times it was invoked (one invocation == one batch)."""
def __init__(self, dim: int = DIM):
self.dim = dim
self.call_count = 0
self.embedded_texts: list[str] = []
async def __call__(self, texts, **kwargs):
self.call_count += 1
self.embedded_texts.extend(texts)
# Deterministic per-text vector so duplicates are still 1-1.
return np.array(
[
np.full(self.dim, (abs(hash(t)) % 97) + 1, dtype=np.float32)
for t in texts
]
)
def _make_storage(tmp_path, embed: _CountingEmbed) -> FaissVectorDBStorage:
return FaissVectorDBStorage(
namespace="test_vectors",
workspace="ws",
global_config={
"working_dir": str(tmp_path),
"embedding_batch_num": 32,
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.2},
},
embedding_func=EmbeddingFunc(embedding_dim=DIM, max_token_size=512, func=embed),
meta_fields={"content"},
)
def _assert_consistent(storage: FaissVectorDBStorage) -> None:
"""Faiss has two structures (index + meta dict); the root failure mode is
them diverging. Every test that mutates state asserts they match."""
assert storage._index.ntotal == len(storage._id_to_meta), (
f"index ntotal ({storage._index.ntotal}) != meta length "
f"({len(storage._id_to_meta)})"
)
# ---------------------------------------------------------------------------
# (A) Nano-ported tests
# ---------------------------------------------------------------------------
@pytest.mark.offline
@pytest.mark.asyncio
async def test_upsert_defers_embedding_to_index_done_callback(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert(
{
"id1": {"content": "alpha"},
"id2": {"content": "beta"},
}
)
assert embed.call_count == 0, "upsert must not embed"
assert storage._index.ntotal == 0, "nothing should be materialized yet"
_assert_consistent(storage)
await storage.index_done_callback()
assert embed.call_count == 1, "flush should embed in a single batch"
assert sorted(embed.embedded_texts) == ["alpha", "beta"]
assert storage._index.ntotal == 2
_assert_consistent(storage)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_repeated_upserts_same_id_embed_once_per_flush(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "v1"}})
await storage.upsert({"id1": {"content": "v2"}})
await storage.upsert({"id1": {"content": "v3"}})
await storage.index_done_callback()
assert embed.call_count == 1
assert embed.embedded_texts == ["v3"], "only the latest content is embedded"
assert storage._index.ntotal == 1
_assert_consistent(storage)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_get_vectors_caches_and_flush_reuses(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
vecs = await storage.get_vectors_by_ids(["id1"])
assert "id1" in vecs and len(vecs["id1"]) == DIM
assert embed.call_count == 1, "get_vectors_by_ids embeds pending lazily"
# Flush must reuse the cached vector, not re-embed.
await storage.index_done_callback()
assert embed.call_count == 1, "flush should reuse the cached temp vector"
assert storage._index.ntotal == 1
_assert_consistent(storage)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_reupsert_after_get_vectors_clears_cached_vector(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "old"}})
await storage.get_vectors_by_ids(["id1"]) # caches a temp vector for "old"
assert embed.call_count == 1
# New content version must clear the cached vector and re-embed at flush.
await storage.upsert({"id1": {"content": "new"}})
await storage.index_done_callback()
assert embed.call_count == 2
assert embed.embedded_texts == ["old", "new"]
_assert_consistent(storage)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_delete_cancels_pending_and_removes_materialized(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
# Materialize id1; leave id2 only as a pending (unflushed) upsert.
await storage.upsert({"id1": {"content": "alpha"}})
await storage.index_done_callback()
await storage.upsert({"id2": {"content": "beta"}})
await storage.delete(["id1", "id2"])
assert "id2" not in storage._pending_upserts, "delete cancels pending upsert"
assert storage._index.ntotal == 0, "delete removes the materialized row"
assert await storage.get_by_id("id1") is None
assert await storage.get_by_id("id2") is None
_assert_consistent(storage)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_stale_client_reload_still_flushes_pending_upsert(tmp_path):
embed = _CountingEmbed()
writer = _make_storage(tmp_path, embed)
stale_writer = _make_storage(tmp_path, embed)
await writer.initialize()
await stale_writer.initialize()
await writer.upsert({"id1": {"content": "alpha"}})
assert await writer.index_done_callback() is True
assert stale_writer.storage_updated.value is True
await stale_writer.upsert({"id2": {"content": "beta"}})
assert await stale_writer.index_done_callback() is True
reader = _make_storage(tmp_path, embed)
await reader.initialize()
rows = await reader.get_by_ids(["id1", "id2"])
assert [row["id"] for row in rows] == ["id1", "id2"]
assert stale_writer._pending_upserts == {}
_assert_consistent(reader)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_delete_reloads_stale_client_before_mutating(tmp_path):
embed = _CountingEmbed()
writer = _make_storage(tmp_path, embed)
stale_deleter = _make_storage(tmp_path, embed)
await writer.initialize()
await stale_deleter.initialize()
await writer.upsert({"id1": {"content": "alpha"}})
assert await writer.index_done_callback() is True
assert stale_deleter.storage_updated.value is True
await stale_deleter.delete(["id1"])
assert stale_deleter.storage_updated.value is False
assert await stale_deleter.index_done_callback() is True
reader = _make_storage(tmp_path, embed)
await reader.initialize()
assert await reader.get_by_id("id1") is None
_assert_consistent(reader)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_finalize_reloads_stale_client_before_flushing(tmp_path):
embed = _CountingEmbed()
writer = _make_storage(tmp_path, embed)
stale_finalizer = _make_storage(tmp_path, embed)
await writer.initialize()
await stale_finalizer.initialize()
await writer.upsert({"id1": {"content": "alpha"}})
assert await writer.index_done_callback() is True
assert stale_finalizer.storage_updated.value is True
await stale_finalizer.upsert({"id2": {"content": "beta"}})
await stale_finalizer.finalize()
reader = _make_storage(tmp_path, embed)
await reader.initialize()
rows = await reader.get_by_ids(["id1", "id2"])
assert [row["id"] for row in rows] == ["id1", "id2"]
assert stale_finalizer._pending_upserts == {}
_assert_consistent(reader)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_read_your_writes_and_query_after_flush(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
# Before flush: read paths see the pending row, query does not.
hit = await storage.get_by_id("id1")
assert hit is not None and hit["id"] == "id1" and hit["content"] == "alpha"
by_ids = await storage.get_by_ids(["id1", "missing"])
assert by_ids[0]["id"] == "id1" and by_ids[1] is None
assert await storage.query("alpha", top_k=5) == [], "query ignores unflushed data"
# After flush: query returns the row.
await storage.index_done_callback()
results = await storage.query("alpha", top_k=5)
assert any(r["id"] == "id1" for r in results)
_assert_consistent(storage)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_finalize_flushes_pending(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
await storage.finalize()
assert embed.call_count == 1
assert storage._pending_upserts == {}
assert storage._index.ntotal == 1
_assert_consistent(storage)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_delete_entity_relation_cancels_pending(tmp_path):
embed = _CountingEmbed()
storage = FaissVectorDBStorage(
namespace="test_relations",
workspace="ws",
global_config={
"working_dir": str(tmp_path),
"embedding_batch_num": 32,
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.2},
},
embedding_func=EmbeddingFunc(embedding_dim=DIM, max_token_size=512, func=embed),
meta_fields={"content", "src_id", "tgt_id"},
)
await storage.initialize()
# Materialize r1 (A->B), leave r2 (A->C) and r3 (X->Y) as pending.
await storage.upsert({"r1": {"content": "rel1", "src_id": "A", "tgt_id": "B"}})
await storage.index_done_callback()
await storage.upsert(
{
"r2": {"content": "rel2", "src_id": "A", "tgt_id": "C"},
"r3": {"content": "rel3", "src_id": "X", "tgt_id": "Y"},
}
)
await storage.delete_entity_relation("A")
assert "r2" not in storage._pending_upserts, "incident pending entry cancelled"
assert "r3" in storage._pending_upserts, "unrelated pending entry preserved"
assert storage._index.ntotal == 0, "materialized A->B removed"
_assert_consistent(storage)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_flush_embedding_failure_raises_and_keeps_pending(tmp_path):
class _FailingEmbed:
def __init__(self):
self.call_count = 0
async def __call__(self, texts, **kwargs):
self.call_count += 1
raise RuntimeError("embed boom")
embed = _FailingEmbed()
storage = FaissVectorDBStorage(
namespace="test_vectors",
workspace="ws",
global_config={
"working_dir": str(tmp_path),
"embedding_batch_num": 32,
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.2},
},
embedding_func=EmbeddingFunc(embedding_dim=DIM, max_token_size=512, func=embed),
meta_fields={"content"},
)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
with pytest.raises(RuntimeError, match="embed boom"):
await storage.index_done_callback()
assert "id1" in storage._pending_upserts, "pending preserved for retry"
assert storage._index.ntotal == 0, "nothing materialized on embed failure"
# Embed failure happens before self._index.add in _flush_pending_locked,
# so _index_dirty must NOT be set. (A save-stage failure would leave it True
# — see test_index_done_callback_save_failure_raises.)
assert storage._index_dirty is False
_assert_consistent(storage)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_drop_discards_pending_without_embedding(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
assert "id1" in storage._pending_upserts
result = await storage.drop()
assert result["status"] == "success"
assert storage._pending_upserts == {}, "drop discards buffered upserts"
assert embed.call_count == 0, "drop must not embed"
assert storage._index_dirty is False
_assert_consistent(storage)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_finalize_retries_save_after_flush_failure(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
original_save = storage._save_faiss_index
save_calls = 0
def fail_once():
nonlocal save_calls
save_calls += 1
if save_calls == 1:
raise OSError("boom")
original_save()
storage._save_faiss_index = fail_once
with pytest.raises(OSError, match="boom"):
await storage.finalize()
assert storage._pending_upserts == {}
assert storage._index_dirty is True
await storage.finalize()
assert save_calls == 2
assert storage._index_dirty is False
reader = _make_storage(tmp_path, embed)
await reader.initialize()
hit = await reader.get_by_id("id1")
assert hit is not None and hit["id"] == "id1"
_assert_consistent(reader)
# ---------------------------------------------------------------------------
# (B) Faiss-specific tests
# ---------------------------------------------------------------------------
@pytest.mark.offline
@pytest.mark.asyncio
async def test_reupsert_after_flush_replaces_single_fid(tmp_path):
"""Faiss has no in-place upsert: re-upserting an already-materialized id
must rebuild the index without the old fid, so we still end up with
exactly one row per custom id."""
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "old"}})
await storage.index_done_callback()
assert storage._index.ntotal == 1
_assert_consistent(storage)
await storage.upsert({"id1": {"content": "new"}})
await storage.index_done_callback()
assert storage._index.ntotal == 1, "rebuild must remove old fid before adding new"
assert len(storage._id_to_meta) == 1
_assert_consistent(storage)
hit = await storage.get_by_id("id1")
assert hit is not None and hit["content"] == "new"
assert embed.call_count == 2, "each flush embeds the latest content once"
@pytest.mark.offline
@pytest.mark.asyncio
async def test_index_done_callback_save_failure_raises(tmp_path):
"""Save failure in index_done_callback must propagate, leave pending empty
(flush already succeeded), and keep _index_dirty=True so finalize retries."""
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
original_save = storage._save_faiss_index
def fail_save():
raise OSError("save boom")
storage._save_faiss_index = fail_save
with pytest.raises(OSError, match="save boom"):
await storage.index_done_callback()
assert storage._pending_upserts == {}, "flush succeeded so pending is empty"
assert storage._index_dirty is True, "save failure preserves dirty for retry"
assert storage._index.ntotal == 1, "materialized state is preserved"
_assert_consistent(storage)
# Restore real save; finalize must retry only the save (no re-embed).
storage._save_faiss_index = original_save
embed_before = embed.call_count
await storage.finalize()
assert embed.call_count == embed_before, "save retry must not re-embed"
assert storage._index_dirty is False
reader = _make_storage(tmp_path, embed)
await reader.initialize()
hit = await reader.get_by_id("id1")
assert hit is not None and hit["id"] == "id1"
_assert_consistent(reader)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_reload_warns_on_index_meta_skew(tmp_path, caplog):
"""A crash between the .index write and the .meta.json write leaves
``ntotal(.index) > rows(.meta)``. ``_load_faiss_index`` must log a warning
on reload; auto-repair is intentionally not in scope here."""
import logging
from lightrag.utils import logger as lightrag_logger
embed = _CountingEmbed()
writer = _make_storage(tmp_path, embed)
await writer.initialize()
await writer.upsert({"id1": {"content": "alpha"}, "id2": {"content": "beta"}})
await writer.index_done_callback()
# Corrupt the meta file: drop one entry so disk has index > meta.
with open(writer._meta_file, "r", encoding="utf-8") as f:
meta = json.load(f)
assert len(meta) == 2
dropped_key = next(iter(meta))
del meta[dropped_key]
with open(writer._meta_file, "w", encoding="utf-8") as f:
json.dump(meta, f)
# The lightrag logger sets propagate=False (lightrag/utils.py), so caplog —
# which attaches to root by default — never sees its records. Flip propagate
# for the duration of the reload, then restore.
caplog.clear()
old_propagate = lightrag_logger.propagate
lightrag_logger.propagate = True
try:
with caplog.at_level(logging.WARNING, logger="lightrag"):
reader = _make_storage(tmp_path, embed)
await reader.initialize()
finally:
lightrag_logger.propagate = old_propagate
# The reader's index still has 2 vectors but only 1 reachable via meta —
# this is the "known risk, not auto-repaired" state.
assert reader._index.ntotal == 2
assert len(reader._id_to_meta) == 1
skew_messages = [
rec.message
for rec in caplog.records
if "skew" in rec.message or "index > meta" in rec.message
]
assert skew_messages, (
f"expected an index>meta skew warning; got: "
f"{[r.message for r in caplog.records]}"
)
# Sanity: state files exist where we left them.
assert os.path.exists(writer._faiss_index_file)
assert os.path.exists(writer._meta_file)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_query_skips_orphan_faiss_hits(tmp_path):
"""After an ``index > meta`` skew the orphan vector is still searchable by
similarity, but ``query`` must skip it instead of leaking a ghost
``{"id": None, ...}`` row to the caller."""
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
# Materialize two rows.
await storage.upsert({"id1": {"content": "alpha"}, "id2": {"content": "beta"}})
await storage.index_done_callback()
assert storage._index.ntotal == 2
# Synthesize the skew: drop one meta row in memory, keeping the faiss
# index untouched. This mirrors what _load_faiss_index would surface on
# reload after a crash between the two atomic_writes.
orphan_fid = next(iter(storage._id_to_meta))
del storage._id_to_meta[orphan_fid]
assert storage._index.ntotal == 2
assert len(storage._id_to_meta) == 1
# The orphan vector still scores high in similarity search; query must
# filter it out instead of returning {"id": None, ...}.
results = await storage.query("anything", top_k=5)
for row in results:
assert row["id"] is not None, f"orphan hit leaked: {row}"
# And the surviving row is still returned.
surviving_id = next(iter(storage._id_to_meta.values()))["__id__"]
assert any(r["id"] == surviving_id for r in results)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_reupsert_cleans_duplicate_custom_id_rows(tmp_path):
"""Defends against legacy / externally corrupted stores where multiple
fids in ``_id_to_meta`` share the same ``__id__``. A re-upsert + flush
must collapse them to a single row; a ``delete`` must remove all of them."""
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
# Hand-craft a corrupt state: two fids carry the same custom id "dup".
matrix = np.array([[1.0] * DIM, [2.0] * DIM], dtype=np.float32)
faiss.normalize_L2(matrix)
storage._index.add(matrix)
storage._id_to_meta[0] = {
"__id__": "dup",
"__created_at__": 1,
"content": "v1",
"__vector__": matrix[0].tolist(),
}
storage._id_to_meta[1] = {
"__id__": "dup",
"__created_at__": 1,
"content": "v2",
"__vector__": matrix[1].tolist(),
}
_assert_consistent(storage)
assert storage._find_faiss_ids_by_custom_id("dup") == [0, 1]
# Re-upsert + flush: both duplicates must be removed in the rebuild
# before the new vector is added; final state is a single row.
await storage.upsert({"dup": {"content": "v3"}})
await storage.index_done_callback()
assert storage._index.ntotal == 1, "flush rebuild must drop both duplicates"
assert len(storage._id_to_meta) == 1
assert storage._find_faiss_ids_by_custom_id("dup") == list(
storage._id_to_meta.keys()
)
hit = await storage.get_by_id("dup")
assert hit is not None and hit["content"] == "v3"
_assert_consistent(storage)
# Re-seed two more duplicates and verify delete also removes them all.
matrix2 = np.array([[3.0] * DIM, [4.0] * DIM], dtype=np.float32)
faiss.normalize_L2(matrix2)
storage._index.add(matrix2)
next_fid = max(storage._id_to_meta) + 1
storage._id_to_meta[next_fid] = {
"__id__": "dup",
"__created_at__": 2,
"content": "dup-a",
"__vector__": matrix2[0].tolist(),
}
storage._id_to_meta[next_fid + 1] = {
"__id__": "dup",
"__created_at__": 2,
"content": "dup-b",
"__vector__": matrix2[1].tolist(),
}
assert len(storage._find_faiss_ids_by_custom_id("dup")) == 3
await storage.delete(["dup"])
assert storage._find_faiss_ids_by_custom_id("dup") == []
assert storage._index.ntotal == 0
_assert_consistent(storage)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_delete_propagates_errors(tmp_path, monkeypatch):
"""Faiss ``delete`` must NOT swallow errors — the caller (document
deletion / status update path) needs to abort if vectors weren't
actually removed. This intentionally diverges from Nano."""
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
await storage.index_done_callback()
def boom(_self, _fids):
raise RuntimeError("rebuild boom")
# _remove_faiss_ids_locked is what delete calls under the hood.
monkeypatch.setattr(
FaissVectorDBStorage, "_remove_faiss_ids_locked", boom, raising=True
)
with pytest.raises(RuntimeError, match="rebuild boom"):
await storage.delete(["id1"])
@pytest.mark.offline
@pytest.mark.asyncio
async def test_flush_recovers_from_index_add_failure_without_re_embedding(tmp_path):
"""Self-heal contract: if ``index.add`` raises mid-flush (after embedding
already succeeded), the pending buffer keeps the cached vectors and a
subsequent ``finalize`` retries the flush **without re-embedding**. Pins
the "pending is the source of truth on mid-write failure" invariant
documented on ``_flush_pending_locked``."""
class _AddFailsOnce:
"""Wraps a real faiss index, raising on the first ``.add`` call. After
the second add succeeds it swaps the storage's ``_index`` attribute
back to the real instance, so ``faiss.write_index`` (which requires a
real SWIG-wrapped object) can run during the retry's save step. This
is a test-only shim — in production ``self._index`` is always a real
faiss index throughout the retry.
"""
def __init__(self, storage, real):
self._storage = storage
self._real = real
self._calls = 0
def __getattr__(self, name):
return getattr(self._real, name)
def add(self, arr):
self._calls += 1
if self._calls == 1:
raise RuntimeError("add boom")
result = self._real.add(arr)
self._storage._index = self._real
return result
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
real_index = storage._index
storage._index = _AddFailsOnce(storage, real_index)
with pytest.raises(RuntimeError, match="add boom"):
await storage.index_done_callback()
# Embedding completed once (failure happened after embed, in index.add).
assert embed.call_count == 1
# Pending preserved with cached vectors — that's the self-healing key.
assert "id1" in storage._pending_upserts
assert storage._pending_upserts["id1"].vector is not None
# _index_dirty stays False: docstring says we deliberately don't flip it
# on mid-write failure (pending is the source of truth).
assert storage._index_dirty is False
assert storage._index.ntotal == 0
# Retry through the same public entry point. The wrapper's second add
# succeeds, unwraps itself, and the rest of finalize (save + notify)
# runs against the real index.
await storage.finalize()
assert embed.call_count == 1, "retry must reuse cached vectors, not re-embed"
assert storage._index is real_index, "wrapper unwrapped itself on the second add"
assert storage._index.ntotal == 1
assert storage._pending_upserts == {}
assert storage._index_dirty is False
_assert_consistent(storage)
# And the row was persisted to disk by the retry's save.
reader = _make_storage(tmp_path, embed)
await reader.initialize()
hit = await reader.get_by_id("id1")
assert hit is not None and hit["content"] == "alpha"
@pytest.mark.offline
@pytest.mark.asyncio
async def test_drop_pending_index_ops_clears_buffer(tmp_path):
"""An internal-error abort calls drop_pending_index_ops to discard the
not-yet-flushed buffer without materializing anything into the index."""
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}, "id2": {"content": "beta"}})
assert storage._pending_upserts, "upsert buffers, does not flush"
await storage.drop_pending_index_ops()
assert storage._pending_upserts == {}
assert embed.call_count == 0, "drop must not embed"
assert storage._index.ntotal == 0, "nothing was materialized"
_assert_consistent(storage)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_drop_pending_does_not_rollback_materialized(tmp_path):
"""drop_pending_index_ops discards ONLY the pending buffer; rows already
materialized into the index by a flush whose save then failed
(``_index_dirty=True``) are intentionally NOT rolled back."""
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
# Flush id1 into the index, then fail the save so it stays
# materialized-but-unsaved (dirty) and the pending buffer is emptied.
await storage.upsert({"id1": {"content": "alpha"}})
def fail_save():
raise OSError("save boom")
storage._save_faiss_index = fail_save
with pytest.raises(OSError, match="save boom"):
await storage.index_done_callback()
assert storage._pending_upserts == {}, "flush succeeded so pending is empty"
assert storage._index_dirty is True
assert storage._index.ntotal == 1, "id1 materialized, not saved"
# A new pending op arrives, then the batch aborts and drops pending.
await storage.upsert({"id2": {"content": "beta"}})
assert "id2" in storage._pending_upserts
await storage.drop_pending_index_ops()
assert storage._pending_upserts == {}, "pending id2 dropped"
assert storage._index.ntotal == 1, "materialized id1 NOT rolled back"
assert storage._index_dirty is True, "still dirty for a later save retry"
_assert_consistent(storage)
@@ -0,0 +1,163 @@
"""
Regression tests for Faiss meta/index inconsistency handling.
Verifies that FaissVectorDBStorage gracefully handles cases where
meta.json has more rows than the Faiss index (e.g., after a crash
during save), and that delete/upsert operations don't crash.
"""
import json
import os
import tempfile
import numpy as np
import pytest
faiss = pytest.importorskip("faiss")
@pytest.mark.offline
class TestFaissMetaInconsistency:
"""Test that stale metadata rows are handled gracefully."""
def _create_index_and_meta(self, tmp_dir, dim=4, n_vectors=3, n_extra_meta=2):
"""
Helper: create a Faiss index with `n_vectors` vectors and a meta.json
that has `n_vectors + n_extra_meta` entries (simulating a crash where
meta was written but index wasn't fully updated).
"""
index_file = os.path.join(tmp_dir, "faiss_index_test.index")
meta_file = index_file + ".meta.json"
# Build real index with n_vectors
index = faiss.IndexFlatIP(dim)
vectors = np.random.rand(n_vectors, dim).astype(np.float32)
# Normalize for cosine similarity
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
vectors = vectors / norms
index.add(vectors)
faiss.write_index(index, index_file)
# Build meta with extra rows beyond index.ntotal
meta = {}
for i in range(n_vectors):
meta[str(i)] = {"__id__": f"id_{i}", "content": f"text_{i}"}
for i in range(n_vectors, n_vectors + n_extra_meta):
meta[str(i)] = {"__id__": f"stale_{i}", "content": f"stale_{i}"}
with open(meta_file, "w", encoding="utf-8") as f:
json.dump(meta, f)
return index_file, meta_file, vectors
def test_load_skips_invalid_metadata_rows(self):
"""
Loading an index where meta.json has fids beyond index.ntotal
should skip those rows with a warning, not crash.
"""
with tempfile.TemporaryDirectory() as tmp_dir:
dim = 4
n_vectors = 3
n_extra = 2
index_file, meta_file, vectors = self._create_index_and_meta(
tmp_dir, dim=dim, n_vectors=n_vectors, n_extra_meta=n_extra
)
# Manually load and verify behavior
index = faiss.read_index(index_file)
with open(meta_file, "r", encoding="utf-8") as f:
stored_dict = json.load(f)
assert len(stored_dict) == n_vectors + n_extra
# Simulate the load logic from _load_faiss_index
id_to_meta = {}
skipped = 0
for fid_str, meta in stored_dict.items():
fid = int(fid_str)
if fid >= index.ntotal:
skipped += 1
continue
if "__vector__" not in meta:
meta["__vector__"] = index.reconstruct(fid).tolist()
id_to_meta[fid] = meta
assert len(id_to_meta) == n_vectors
assert skipped == n_extra
# Verify reconstructed vectors match originals
for fid in range(n_vectors):
reconstructed = np.array(
id_to_meta[fid]["__vector__"], dtype=np.float32
)
np.testing.assert_allclose(reconstructed, vectors[fid], atol=1e-6)
def test_remove_with_missing_vector_uses_reconstruct(self):
"""
_remove_faiss_ids should reconstruct vectors from the index
when __vector__ is not present in metadata.
"""
dim = 4
n_vectors = 3
index = faiss.IndexFlatIP(dim)
vectors = np.random.rand(n_vectors, dim).astype(np.float32)
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
vectors = vectors / norms
index.add(vectors)
# Metadata WITHOUT __vector__ (as stored on disk after our PR)
id_to_meta = {}
for i in range(n_vectors):
id_to_meta[i] = {"__id__": f"id_{i}", "content": f"text_{i}"}
# Simulate rebuild logic from _remove_faiss_ids (remove fid=1)
fid_list = [1]
keep_fids = [fid for fid in id_to_meta if fid not in fid_list]
vectors_to_keep = []
new_id_to_meta = {}
for new_fid, old_fid in enumerate(keep_fids):
vec_meta = id_to_meta[old_fid]
if "__vector__" in vec_meta:
vec = vec_meta["__vector__"]
elif old_fid < index.ntotal:
vec = index.reconstruct(old_fid).tolist()
vec_meta["__vector__"] = vec
else:
continue
vectors_to_keep.append(vec)
new_id_to_meta[new_fid] = vec_meta
assert len(vectors_to_keep) == 2
assert len(new_id_to_meta) == 2
# Verify the kept vectors match originals (fid 0 and 2)
np.testing.assert_allclose(
np.array(vectors_to_keep[0], dtype=np.float32), vectors[0], atol=1e-6
)
np.testing.assert_allclose(
np.array(vectors_to_keep[1], dtype=np.float32), vectors[2], atol=1e-6
)
def test_atomic_save_meta(self):
"""
_save_faiss_index should write meta.json atomically via temp file + os.replace.
Verify no .tmp file remains after save.
"""
with tempfile.TemporaryDirectory() as tmp_dir:
meta_file = os.path.join(tmp_dir, "test.meta.json")
tmp_meta_file = meta_file + ".tmp"
serializable_dict = {"0": {"__id__": "id_0", "content": "text_0"}}
# Simulate atomic write
with open(tmp_meta_file, "w", encoding="utf-8") as f:
json.dump(serializable_dict, f)
os.replace(tmp_meta_file, meta_file)
assert os.path.exists(meta_file)
assert not os.path.exists(tmp_meta_file)
with open(meta_file, "r", encoding="utf-8") as f:
loaded = json.load(f)
assert loaded == serializable_dict
View File
@@ -0,0 +1,73 @@
"""Atomicity coverage for ``lightrag.utils.write_json``.
The two storages that ride on this function (``JsonDocStatusStorage``,
``JsonKVStorage``) inherit crash safety from it, so the contract lives here:
- A crash during the rename leaves the prior snapshot intact.
- The sanitize fallback also lands atomically (one tmp, one rename).
"""
import json
import os
import threading
from unittest.mock import patch
import pytest
from lightrag.utils import write_json
@pytest.mark.offline
def test_write_json_publishes_clean_payload(tmp_path):
target = str(tmp_path / "kv.json")
needs_reload = write_json({"a": 1, "b": "hello"}, target)
assert needs_reload is False
assert json.load(open(target)) == {"a": 1, "b": "hello"}
assert [p for p in os.listdir(tmp_path) if ".tmp." in p] == []
@pytest.mark.offline
def test_write_json_replace_crash_preserves_prior_snapshot(tmp_path):
target = str(tmp_path / "kv.json")
write_json({"v": 1}, target)
with patch(
"lightrag.file_atomic.os.replace",
side_effect=OSError("simulated crash"),
):
with pytest.raises(OSError, match="simulated crash"):
write_json({"v": 2}, target)
assert json.load(open(target)) == {"v": 1}
leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p]
assert leftovers == [], f"write_json must clean tmp on crash, got {leftovers}"
@pytest.mark.offline
def test_write_json_concurrent_writers_land_intact(tmp_path):
"""Multiple threads racing on the same destination must each rename
cleanly. The final file must be valid JSON (one writer's payload)."""
target = str(tmp_path / "kv.json")
errors: list[BaseException] = []
barrier = threading.Barrier(5)
def writer(tid: int) -> None:
try:
barrier.wait()
write_json({"writer": tid}, target)
except BaseException as exc:
errors.append(exc)
threads = [threading.Thread(target=writer, args=(i,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Concurrent writers raised: {errors}"
payload = json.load(open(target))
assert payload.keys() == {"writer"}
assert payload["writer"] in range(5)
leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p]
assert leftovers == [], f"Unexpected tmp residue: {leftovers}"
@@ -0,0 +1,168 @@
import pytest
from lightrag.base import DocStatus
from lightrag.kg.json_doc_status_impl import JsonDocStatusStorage
from lightrag.kg.json_kv_impl import JsonKVStorage
from lightrag.kg.shared_storage import finalize_share_data, initialize_share_data
from lightrag.namespace import NameSpace
pytestmark = pytest.mark.offline
class _DummyEmbeddingFunc:
embedding_dim = 1
max_token_size = 1
async def __call__(self, texts, **kwargs):
return [[0.0] for _ in texts]
def _doc(status: str, file_path: str) -> dict:
return {
"content_summary": f"{status} summary",
"content_length": 10,
"file_path": file_path,
"status": status,
"created_at": "2024-01-01T00:00:00+00:00",
"updated_at": "2024-01-01T00:00:00+00:00",
"metadata": {},
"error_msg": None,
}
@pytest.fixture(autouse=True)
def setup_shared_data():
initialize_share_data()
yield
finalize_share_data()
@pytest.mark.asyncio
async def test_get_docs_paginated_with_status_filters(tmp_path):
storage = JsonDocStatusStorage(
namespace="doc_status",
global_config={"working_dir": str(tmp_path)},
embedding_func=_DummyEmbeddingFunc(),
workspace="test",
)
await storage.initialize()
async with storage._storage_lock:
storage._data.update(
{
"doc-1": _doc("preprocessed", "a.pdf"),
"doc-2": _doc("parsing", "b.pdf"),
"doc-3": _doc("analyzing", "c.pdf"),
"doc-4": _doc("processed", "d.pdf"),
}
)
docs, total = await storage.get_docs_paginated(
status_filter=DocStatus.PROCESSED,
status_filters=[
DocStatus.PREPROCESSED,
DocStatus.PARSING,
DocStatus.ANALYZING,
],
page=1,
page_size=10,
sort_field="id",
sort_direction="asc",
)
assert total == 3
assert [doc_id for doc_id, _ in docs] == ["doc-1", "doc-2", "doc-3"]
assert [doc.status for _, doc in docs] == [
DocStatus.PREPROCESSED,
DocStatus.PARSING,
DocStatus.ANALYZING,
]
@pytest.mark.asyncio
async def test_doc_status_upsert_preserves_caller_file_path(tmp_path):
storage = JsonDocStatusStorage(
namespace=NameSpace.DOC_STATUS,
global_config={"working_dir": str(tmp_path)},
embedding_func=_DummyEmbeddingFunc(),
workspace="test",
)
await storage.initialize()
await storage.upsert(
{
"doc-1": _doc(
DocStatus.PENDING.value,
"/tmp/uploads/report.[native-Fi].pdf",
)
}
)
assert (await storage.get_by_id("doc-1"))["file_path"] == (
"/tmp/uploads/report.[native-Fi].pdf"
)
assert await storage.get_doc_by_file_basename("report.pdf") is None
@pytest.mark.asyncio
async def test_doc_status_basename_lookup_requires_canonical_stored_path(tmp_path):
storage = JsonDocStatusStorage(
namespace=NameSpace.DOC_STATUS,
global_config={"working_dir": str(tmp_path)},
embedding_func=_DummyEmbeddingFunc(),
workspace="test",
)
await storage.initialize()
async with storage._storage_lock:
storage._data["doc-1"] = _doc(
DocStatus.PROCESSED.value,
"report.[native].pdf",
)
assert await storage.get_doc_by_file_basename("report.pdf") is None
@pytest.mark.asyncio
async def test_json_kv_upsert_preserves_caller_file_paths(tmp_path):
full_docs = JsonKVStorage(
namespace=NameSpace.KV_STORE_FULL_DOCS,
global_config={"working_dir": str(tmp_path)},
embedding_func=_DummyEmbeddingFunc(),
workspace="test",
)
text_chunks = JsonKVStorage(
namespace=NameSpace.KV_STORE_TEXT_CHUNKS,
global_config={"working_dir": str(tmp_path)},
embedding_func=_DummyEmbeddingFunc(),
workspace="test",
)
await full_docs.initialize()
await text_chunks.initialize()
await full_docs.upsert(
{
"doc-1": {
"content": "full text",
"file_path": "/tmp/uploads/report.[native-Fi].pdf",
}
}
)
await text_chunks.upsert(
{
"chunk-1": {
"content": "chunk text",
"tokens": 2,
"chunk_order_index": 0,
"full_doc_id": "doc-1",
"file_path": "/tmp/uploads/report.[native-Fi].pdf",
}
}
)
assert (await full_docs.get_by_id("doc-1"))["file_path"] == (
"/tmp/uploads/report.[native-Fi].pdf"
)
assert (await text_chunks.get_by_id("chunk-1"))["file_path"] == (
"/tmp/uploads/report.[native-Fi].pdf"
)
@@ -0,0 +1,490 @@
"""
Test suite for write_json optimization
This test verifies:
1. Fast path works for clean data (no sanitization)
2. Slow path applies sanitization for dirty data
3. Sanitization is done during encoding (memory-efficient)
4. Reloading updates shared memory with cleaned data
"""
import os
import json
import tempfile
import pytest
from lightrag.utils import (
write_json,
load_json,
SanitizingJSONEncoder,
sanitize_text_for_encoding,
)
@pytest.mark.offline
class TestWriteJsonOptimization:
"""Test write_json optimization with two-stage approach"""
def test_fast_path_clean_data(self):
"""Test that clean data takes the fast path without sanitization"""
clean_data = {
"name": "John Doe",
"age": 30,
"items": ["apple", "banana", "cherry"],
"nested": {"key": "value", "number": 42},
}
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
temp_file = f.name
try:
# Write clean data - should return False (no sanitization)
needs_reload = write_json(clean_data, temp_file)
assert not needs_reload, "Clean data should not require sanitization"
# Verify data was written correctly
loaded_data = load_json(temp_file)
assert loaded_data == clean_data, "Loaded data should match original"
finally:
os.unlink(temp_file)
def test_slow_path_dirty_data(self):
"""Test that dirty data triggers sanitization"""
# Create data with surrogate characters (U+D800 to U+DFFF)
dirty_string = "Hello\ud800World" # Contains surrogate character
dirty_data = {"text": dirty_string, "number": 123}
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
temp_file = f.name
try:
# Write dirty data - should return True (sanitization applied)
needs_reload = write_json(dirty_data, temp_file)
assert needs_reload, "Dirty data should trigger sanitization"
# Verify data was written and sanitized
loaded_data = load_json(temp_file)
assert loaded_data is not None, "Data should be written"
assert loaded_data["number"] == 123, "Clean fields should remain unchanged"
# Surrogate character should be removed
assert "\ud800" not in loaded_data["text"], (
"Surrogate character should be removed"
)
finally:
os.unlink(temp_file)
def test_sanitizing_encoder_removes_surrogates(self):
"""Test that SanitizingJSONEncoder removes surrogate characters"""
data_with_surrogates = {
"text": "Hello\ud800\udc00World", # Contains surrogate pair
"clean": "Clean text",
"nested": {"dirty_key\ud801": "value", "clean_key": "clean\ud802value"},
}
# Encode using custom encoder
encoded = json.dumps(
data_with_surrogates, cls=SanitizingJSONEncoder, ensure_ascii=False
)
# Verify no surrogate characters in output
assert "\ud800" not in encoded, "Surrogate U+D800 should be removed"
assert "\udc00" not in encoded, "Surrogate U+DC00 should be removed"
assert "\ud801" not in encoded, "Surrogate U+D801 should be removed"
assert "\ud802" not in encoded, "Surrogate U+D802 should be removed"
# Verify clean parts remain
assert "Clean text" in encoded, "Clean text should remain"
assert "clean_key" in encoded, "Clean keys should remain"
def test_nested_structure_sanitization(self):
"""Test sanitization of deeply nested structures"""
nested_data = {
"level1": {
"level2": {
"level3": {"dirty": "text\ud800here", "clean": "normal text"},
"list": ["item1", "item\ud801dirty", "item3"],
}
}
}
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
temp_file = f.name
try:
needs_reload = write_json(nested_data, temp_file)
assert needs_reload, "Nested dirty data should trigger sanitization"
# Verify nested structure is preserved
loaded_data = load_json(temp_file)
assert "level1" in loaded_data
assert "level2" in loaded_data["level1"]
assert "level3" in loaded_data["level1"]["level2"]
# Verify surrogates are removed
dirty_text = loaded_data["level1"]["level2"]["level3"]["dirty"]
assert "\ud800" not in dirty_text, "Nested surrogate should be removed"
# Verify list items are sanitized
list_items = loaded_data["level1"]["level2"]["list"]
assert "\ud801" not in list_items[1], (
"List item surrogates should be removed"
)
finally:
os.unlink(temp_file)
def test_unicode_non_characters_removed(self):
"""Test that Unicode non-characters (U+FFFE, U+FFFF) don't cause encoding errors
Note: U+FFFE and U+FFFF are valid UTF-8 characters (though discouraged),
so they don't trigger sanitization. They only get removed when explicitly
using the SanitizingJSONEncoder.
"""
data_with_nonchars = {"text1": "Hello\ufffeWorld", "text2": "Test\uffffString"}
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
temp_file = f.name
try:
# These characters are valid UTF-8, so they take the fast path
needs_reload = write_json(data_with_nonchars, temp_file)
assert not needs_reload, "U+FFFE/U+FFFF are valid UTF-8 characters"
loaded_data = load_json(temp_file)
# They're written as-is in the fast path
assert loaded_data == data_with_nonchars
finally:
os.unlink(temp_file)
def test_mixed_clean_dirty_data(self):
"""Test data with both clean and dirty fields"""
mixed_data = {
"clean_field": "This is perfectly fine",
"dirty_field": "This has\ud800issues",
"number": 42,
"boolean": True,
"null_value": None,
"clean_list": [1, 2, 3],
"dirty_list": ["clean", "dirty\ud801item"],
}
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
temp_file = f.name
try:
needs_reload = write_json(mixed_data, temp_file)
assert needs_reload, (
"Mixed data with dirty fields should trigger sanitization"
)
loaded_data = load_json(temp_file)
# Clean fields should remain unchanged
assert loaded_data["clean_field"] == "This is perfectly fine"
assert loaded_data["number"] == 42
assert loaded_data["boolean"]
assert loaded_data["null_value"] is None
assert loaded_data["clean_list"] == [1, 2, 3]
# Dirty fields should be sanitized
assert "\ud800" not in loaded_data["dirty_field"]
assert "\ud801" not in loaded_data["dirty_list"][1]
finally:
os.unlink(temp_file)
def test_empty_and_none_strings(self):
"""Test handling of empty and None values"""
data = {
"empty": "",
"none": None,
"zero": 0,
"false": False,
"empty_list": [],
"empty_dict": {},
}
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
temp_file = f.name
try:
needs_reload = write_json(data, temp_file)
assert not needs_reload, (
"Clean empty values should not trigger sanitization"
)
loaded_data = load_json(temp_file)
assert loaded_data == data, "Empty/None values should be preserved"
finally:
os.unlink(temp_file)
def test_specific_surrogate_udc9a(self):
"""Test specific surrogate character \\udc9a mentioned in the issue"""
# Test the exact surrogate character from the error message:
# UnicodeEncodeError: 'utf-8' codec can't encode character '\\udc9a'
data_with_udc9a = {
"text": "Some text with surrogate\udc9acharacter",
"position": 201, # As mentioned in the error
"clean_field": "Normal text",
}
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
temp_file = f.name
try:
# Write data - should trigger sanitization
needs_reload = write_json(data_with_udc9a, temp_file)
assert needs_reload, "Data with \\udc9a should trigger sanitization"
# Verify surrogate was removed
loaded_data = load_json(temp_file)
assert loaded_data is not None
assert "\udc9a" not in loaded_data["text"], "\\udc9a should be removed"
assert loaded_data["clean_field"] == "Normal text", (
"Clean fields should remain"
)
finally:
os.unlink(temp_file)
def test_migration_with_surrogate_sanitization(self):
"""Test that migration process handles surrogate characters correctly
This test simulates the scenario where legacy cache contains surrogate
characters and ensures they are cleaned during migration.
"""
# Simulate legacy cache data with surrogate characters
legacy_data_with_surrogates = {
"cache_entry_1": {
"return": "Result with\ud800surrogate",
"cache_type": "extract",
"original_prompt": "Some\udc9aprompt",
},
"cache_entry_2": {
"return": "Clean result",
"cache_type": "query",
"original_prompt": "Clean prompt",
},
}
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
temp_file = f.name
try:
# First write the dirty data directly (simulating legacy cache file)
# Use custom encoder to force write even with surrogates
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(
legacy_data_with_surrogates,
f,
cls=SanitizingJSONEncoder,
ensure_ascii=False,
)
# Load and verify surrogates were cleaned during initial write
loaded_data = load_json(temp_file)
assert loaded_data is not None
# The data should be sanitized
assert "\ud800" not in loaded_data["cache_entry_1"]["return"], (
"Surrogate in return should be removed"
)
assert "\udc9a" not in loaded_data["cache_entry_1"]["original_prompt"], (
"Surrogate in prompt should be removed"
)
# Clean data should remain unchanged
assert loaded_data["cache_entry_2"]["return"] == "Clean result", (
"Clean data should remain"
)
finally:
os.unlink(temp_file)
def test_empty_values_after_sanitization(self):
"""Test that data with empty values after sanitization is properly handled
Critical edge case: When sanitization results in data with empty string values,
we must use 'if cleaned_data is not None' instead of 'if cleaned_data' to ensure
proper reload, since truthy check on dict depends on content, not just existence.
"""
# Create data where ALL values are only surrogate characters
all_dirty_data = {
"key1": "\ud800\udc00\ud801",
"key2": "\ud802\ud803",
}
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
temp_file = f.name
try:
# Write dirty data - should trigger sanitization
needs_reload = write_json(all_dirty_data, temp_file)
assert needs_reload, "All-dirty data should trigger sanitization"
# Load the sanitized data
cleaned_data = load_json(temp_file)
# Critical assertions for the edge case
assert cleaned_data is not None, "Cleaned data should not be None"
# Sanitization removes surrogates but preserves keys with empty values
assert cleaned_data == {
"key1": "",
"key2": "",
}, "Surrogates should be removed, keys preserved"
# This dict is truthy because it has keys (even with empty values)
assert cleaned_data, "Dict with keys is truthy"
# Test the actual edge case: empty dict
empty_data = {}
needs_reload2 = write_json(empty_data, temp_file)
assert not needs_reload2, "Empty dict is clean"
reloaded_empty = load_json(temp_file)
assert reloaded_empty is not None, "Empty dict should not be None"
assert reloaded_empty == {}, "Empty dict should remain empty"
assert not reloaded_empty, (
"Empty dict evaluates to False (the critical check)"
)
finally:
os.unlink(temp_file)
@pytest.mark.offline
class TestSanitizeTextForEncoding:
"""Direct unit tests for sanitize_text_for_encoding function."""
def test_empty_string_returns_empty(self):
assert sanitize_text_for_encoding("") == ""
def test_none_like_falsy_returns_as_is(self):
# The function checks `if not text`, so empty string returns early
assert sanitize_text_for_encoding("") == ""
def test_whitespace_only_returns_empty(self):
assert sanitize_text_for_encoding(" ") == ""
def test_clean_text_unchanged(self):
assert sanitize_text_for_encoding("hello world") == "hello world"
def test_strips_leading_trailing_whitespace(self):
assert sanitize_text_for_encoding(" hello ") == "hello"
def test_lone_surrogate_removed(self):
assert sanitize_text_for_encoding("hello\ud800world") == "helloworld"
def test_lone_surrogate_with_replacement_char(self):
assert (
sanitize_text_for_encoding("hello\ud800world", replacement_char="?")
== "hello?world"
)
def test_surrogate_range_boundaries(self):
# U+D800 and U+DFFF are the surrogate range boundaries
assert "\ud800" not in sanitize_text_for_encoding("\ud800")
assert "\udfff" not in sanitize_text_for_encoding("\udfff")
def test_non_characters_fffe_ffff_removed(self):
# U+FFFE and U+FFFF are included in _SURROGATE_PATTERN
assert sanitize_text_for_encoding("a\ufffeb") == "ab"
assert sanitize_text_for_encoding("a\uffffb") == "ab"
def test_html_entities_unescaped(self):
assert sanitize_text_for_encoding("&amp;") == "&"
assert sanitize_text_for_encoding("&lt;p&gt;") == "<p>"
assert sanitize_text_for_encoding("&quot;hello&quot;") == '"hello"'
def test_html_entity_that_becomes_surrogate_is_removed(self):
# &#xD800; — Python's html.unescape follows HTML5 spec and maps surrogate code
# points to U+FFFD (replacement character), so \uD800 never appears in output.
# Either way the result must not contain an actual lone surrogate.
result = sanitize_text_for_encoding("&#xD800;")
assert "\ud800" not in result
def test_control_chars_removed(self):
# C0 control characters (excluding \t \n \r)
assert sanitize_text_for_encoding("\x01hello\x1fworld") == "helloworld"
assert sanitize_text_for_encoding("\x00null") == "null"
assert sanitize_text_for_encoding("del\x7f") == "del"
def test_control_chars_with_replacement_char(self):
# replacement_char must apply to control chars, not just surrogates.
# Note: \x1f is treated as Unicode whitespace by Python's str.strip(),
# so place control chars in the middle to avoid them being stripped first.
result = sanitize_text_for_encoding("a\x01b\x08c", replacement_char="?")
assert result == "a?b?c"
def test_common_whitespace_preserved(self):
# \t, \n, \r must NOT be removed (excluded from control char pattern)
assert sanitize_text_for_encoding("line1\nline2") == "line1\nline2"
assert sanitize_text_for_encoding("col1\tcol2") == "col1\tcol2"
assert sanitize_text_for_encoding("line1\rline2") == "line1\rline2"
def test_c1_control_chars_not_removed(self):
# \x80-\x9F range must NOT be removed (restored original behavior).
# These are valid in Latin-1 encoded European language text.
result = sanitize_text_for_encoding("caf\x85e")
assert "\x85" in result
def test_replacement_char_default_is_deletion(self):
# Default replacement_char="" means characters are deleted, not replaced
assert sanitize_text_for_encoding("\ud800hello\x01") == "hello"
def test_mixed_issues_in_one_string(self):
# Surrogate + control char + HTML entity + clean text
text = "\ud800&amp;\x01clean"
result = sanitize_text_for_encoding(text)
assert result == "&clean"
def test_large_text_with_scattered_surrogates(self):
# Regression guard: regex must handle large inputs correctly
clean_segment = "a" * 10000
text = f"prefix\ud800{clean_segment}\udfffsuffix"
result = sanitize_text_for_encoding(text)
assert "\ud800" not in result
assert "\udfff" not in result
assert clean_segment in result
if __name__ == "__main__":
# Run tests
test = TestWriteJsonOptimization()
print("Running test_fast_path_clean_data...")
test.test_fast_path_clean_data()
print("✓ Passed")
print("Running test_slow_path_dirty_data...")
test.test_slow_path_dirty_data()
print("✓ Passed")
print("Running test_sanitizing_encoder_removes_surrogates...")
test.test_sanitizing_encoder_removes_surrogates()
print("✓ Passed")
print("Running test_nested_structure_sanitization...")
test.test_nested_structure_sanitization()
print("✓ Passed")
print("Running test_unicode_non_characters_removed...")
test.test_unicode_non_characters_removed()
print("✓ Passed")
print("Running test_mixed_clean_dirty_data...")
test.test_mixed_clean_dirty_data()
print("✓ Passed")
print("Running test_empty_and_none_strings...")
test.test_empty_and_none_strings()
print("✓ Passed")
print("Running test_specific_surrogate_udc9a...")
test.test_specific_surrogate_udc9a()
print("✓ Passed")
print("Running test_migration_with_surrogate_sanitization...")
test.test_migration_with_surrogate_sanitization()
print("✓ Passed")
print("Running test_empty_values_after_sanitization...")
test.test_empty_values_after_sanitization()
print("✓ Passed")
print("\n✅ All tests passed!")
View File
@@ -0,0 +1,137 @@
import pytest
from lightrag.kg.memgraph_impl import MemgraphStorage
pytestmark = pytest.mark.offline
class _FakeNode(dict):
def __init__(self, node_id: int, entity_id: str, **properties):
super().__init__(entity_id=entity_id, **properties)
self.id = node_id
class _FakeResult:
def __init__(self, record):
self._record = record
async def single(self):
return self._record
async def consume(self):
return None
class _FakeSession:
def __init__(self, record, calls):
self._record = record
self._calls = calls
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def run(self, query, parameters=None, **kwargs):
if parameters is None:
parameters = kwargs
self._calls.append((query, parameters))
return _FakeResult(self._record)
class _FakeDriver:
def __init__(self, record, calls):
self._record = record
self._calls = calls
def session(self, **kwargs):
return _FakeSession(self._record, self._calls)
def _make_storage(record):
calls = []
storage = MemgraphStorage(
namespace="chunk_entity_relation",
global_config={"max_graph_nodes": 1000},
embedding_func=None,
workspace="test",
)
storage._driver = _FakeDriver(record, calls)
storage._DATABASE = "memgraph"
return storage, calls
@pytest.mark.asyncio
async def test_get_knowledge_graph_preserves_isolated_start_node():
start_node = _FakeNode(1, "Start", description="isolated")
storage, calls = _make_storage(
{
"node_info": [{"node": start_node}],
"relationships": [],
"is_truncated": False,
}
)
result = await storage.get_knowledge_graph("Start", max_depth=0, max_nodes=1)
# Verify result data: isolated node must appear with correct labels and properties
assert len(result.nodes) == 1
assert result.nodes[0].labels == ["Start"]
assert result.nodes[0].properties["entity_id"] == "Start"
assert result.edges == []
assert result.is_truncated is False
# Verify query parameters: max_other_nodes must reserve a slot for the start node
assert len(calls) == 1
_, params = calls[0]
assert params["entity_id"] == "Start"
assert params["max_nodes"] == 1
assert (
params["max_other_nodes"] == 0
) # max_nodes - 1 = 0, start node occupies the only slot
@pytest.mark.asyncio
async def test_get_knowledge_graph_reserves_capacity_for_start_node_when_truncating():
start_node = _FakeNode(1, "Start")
storage, calls = _make_storage(
{
"node_info": [{"node": start_node}],
"relationships": [],
"is_truncated": True,
}
)
result = await storage.get_knowledge_graph("Start", max_depth=2, max_nodes=2)
# Verify truncation is reflected in result
assert result.is_truncated is True
assert len(result.nodes) == 1
assert result.edges == []
# Verify max_other_nodes leaves exactly one slot for the start node
assert len(calls) == 1
_, params = calls[0]
assert params["max_nodes"] == 2
assert (
params["max_other_nodes"] == 1
) # max_nodes - 1 = 1, start node always included
@pytest.mark.asyncio
async def test_get_knowledge_graph_max_nodes_zero_does_not_underflow():
"""max_other_nodes must not go negative when max_nodes=0."""
storage, calls = _make_storage(
{
"node_info": [],
"relationships": [],
"is_truncated": False,
}
)
await storage.get_knowledge_graph("Start", max_depth=1, max_nodes=0)
_, params = calls[0]
assert params["max_other_nodes"] == 0 # max(0 - 1, 0) = 0, no underflow
View File
@@ -0,0 +1,720 @@
"""Unit tests for MilvusVectorDBStorage's deferred-embedding flush pipeline.
All tests use mocks — no running Milvus instance required.
Mirrors the structure of tests/kg/opensearch_impl/test_opensearch_storage.py's
TestVectorStorageBatching to keep behaviour aligned across backends.
"""
import asyncio
import os
import numpy as np
import pytest
from unittest.mock import MagicMock, patch
from lightrag.kg.milvus_impl import MILVUS_MAX_VARCHAR_BYTES, MilvusVectorDBStorage
pytestmark = pytest.mark.offline
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
class MockEmbeddingFunc:
"""Mock embedding function that returns random vectors."""
def __init__(self, dim=8):
self.embedding_dim = dim
self.max_token_size = 512
self.model_name = "mock-embed"
async def __call__(self, texts, **kwargs):
return np.random.rand(len(texts), self.embedding_dim).astype(np.float32)
class CountingEmbeddingFunc(MockEmbeddingFunc):
"""Embedding test double that records calls and can fail a fixed number of times."""
def __init__(self, dim=8, fail_times=0):
super().__init__(dim=dim)
self.fail_times = fail_times
self.call_count = 0
self.batches: list[list[str]] = []
self.texts: list[str] = []
async def __call__(self, texts, **kwargs):
self.call_count += 1
batch = list(texts)
self.batches.append(batch)
self.texts.extend(batch)
if self.fail_times > 0:
self.fail_times -= 1
raise RuntimeError("embedding failed")
return await super().__call__(texts, **kwargs)
@pytest.fixture(autouse=True)
def patch_namespace_lock():
"""Cache real asyncio.Locks per (namespace, workspace) for shared semantics.
Two storage instances whose ``final_namespace`` matches must observe the
same Lock instance — this fixture lets us assert that and also exercises
real serialization between concurrent flush/upsert coroutines.
"""
cache: dict[tuple[str, str | None], asyncio.Lock] = {}
def factory(namespace, workspace=None, enable_logging=False):
key = (namespace, workspace or "")
lock = cache.get(key)
if lock is None:
lock = asyncio.Lock()
cache[key] = lock
return lock
with patch("lightrag.kg.milvus_impl.get_namespace_lock", side_effect=factory):
yield cache
def _make_storage(
embed_func,
*,
namespace="entities",
workspace="test",
meta_fields=None,
):
"""Build a MilvusVectorDBStorage skipping `initialize()` (no real client)."""
if meta_fields is None:
meta_fields = {"content", "entity_name", "src_id", "tgt_id"}
storage = MilvusVectorDBStorage(
namespace=namespace,
workspace=workspace,
global_config={
"embedding_batch_num": 10,
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.2},
},
embedding_func=embed_func,
meta_fields=meta_fields,
)
# Bypass real Milvus client; manually wire the bits initialize() would set.
# The flush lock is already constructed in __post_init__ via the patched
# get_namespace_lock factory, so no manual lock wiring is needed here.
storage._client = MagicMock()
storage._client.has_collection.return_value = True
storage._client.upsert = MagicMock(return_value={"upsert_count": 0})
storage._client.delete = MagicMock(return_value={"delete_count": 0})
storage._client.query = MagicMock(return_value=[])
storage._client.load_collection = MagicMock()
storage._initialized = True
return storage
# ---------------------------------------------------------------------------
# Tests: deferred embedding + batched flush
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_upsert_buffers_without_embedding():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello"}, "v2": {"content": "world"}})
assert embed.call_count == 0
assert set(s._pending_vector_docs.keys()) == {"v1", "v2"}
assert s._pending_vector_docs["v1"].vector is None
assert s._pending_vector_docs["v2"].vector is None
s._client.upsert.assert_not_called()
@pytest.mark.asyncio
async def test_index_done_callback_triggers_flush():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello"}, "v2": {"content": "world"}})
await s.index_done_callback()
assert embed.call_count == 1
s._client.upsert.assert_called_once()
call_kwargs = s._client.upsert.call_args.kwargs
assert call_kwargs["collection_name"] == s.final_namespace
upserted = call_kwargs["data"]
assert {row["id"] for row in upserted} == {"v1", "v2"}
assert all("vector" in row for row in upserted)
# Buffers cleared after a successful flush.
assert s._pending_vector_docs == {}
assert s._pending_vector_deletes == set()
@pytest.mark.asyncio
async def test_upsert_truncates_oversized_content_for_payload_only():
embed = CountingEmbeddingFunc()
s = _make_storage(embed, meta_fields={"content"})
content = "x" * (MILVUS_MAX_VARCHAR_BYTES + 10)
await s.upsert({"v1": {"content": content}})
await s.index_done_callback()
upserted = s._client.upsert.call_args.kwargs["data"][0]
assert len(upserted["content"].encode("utf-8")) == MILVUS_MAX_VARCHAR_BYTES
assert embed.texts == [content]
assert s._pending_vector_docs == {}
@pytest.mark.asyncio
async def test_upsert_truncates_multibyte_content_on_character_boundary():
embed = CountingEmbeddingFunc()
s = _make_storage(embed, meta_fields={"content"})
content = "" * (MILVUS_MAX_VARCHAR_BYTES // 3 + 10)
await s.upsert({"v1": {"content": content}})
await s.index_done_callback()
upserted = s._client.upsert.call_args.kwargs["data"][0]
assert len(upserted["content"].encode("utf-8")) <= MILVUS_MAX_VARCHAR_BYTES
upserted["content"].encode("utf-8").decode("utf-8")
assert embed.texts == [content]
@pytest.mark.asyncio
async def test_upsert_rejects_oversized_primary_id():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
long_id = "v" * 65
with pytest.raises(ValueError, match="primary keys cannot be truncated"):
await s.upsert({long_id: {"content": "hello"}})
assert s._pending_vector_docs == {}
assert embed.call_count == 0
s._client.upsert.assert_not_called()
@pytest.mark.asyncio
async def test_upsert_rejects_oversized_entity_name():
embed = CountingEmbeddingFunc()
s = _make_storage(embed, meta_fields={"content", "entity_name"})
long_entity_name = "e" * 513
with pytest.raises(ValueError, match="identity fields cannot be truncated"):
await s.upsert({"ent-1": {"content": "hello", "entity_name": long_entity_name}})
assert s._pending_vector_docs == {}
assert embed.call_count == 0
s._client.upsert.assert_not_called()
@pytest.mark.asyncio
async def test_upsert_rejects_oversized_relation_identity_fields():
embed = CountingEmbeddingFunc()
s = _make_storage(embed, namespace="relationships")
long_entity_id = "e" * 513
with pytest.raises(ValueError, match="identity fields cannot be truncated"):
await s.upsert(
{
"rel-1": {
"content": "hello",
"src_id": long_entity_id,
"tgt_id": "B",
}
}
)
assert s._pending_vector_docs == {}
assert embed.call_count == 0
s._client.upsert.assert_not_called()
@pytest.mark.asyncio
async def test_upsert_rejects_oversized_full_doc_id():
embed = CountingEmbeddingFunc()
s = _make_storage(embed, namespace="chunks", meta_fields={"content", "full_doc_id"})
long_doc_id = "d" * 65
with pytest.raises(ValueError, match="identity fields cannot be truncated"):
await s.upsert({"chunk-1": {"content": "hello", "full_doc_id": long_doc_id}})
assert s._pending_vector_docs == {}
assert embed.call_count == 0
s._client.upsert.assert_not_called()
@pytest.mark.asyncio
async def test_upsert_truncates_oversized_source_id():
embed = CountingEmbeddingFunc()
s = _make_storage(embed, meta_fields={"content", "source_id"})
source_id = "s" * (MILVUS_MAX_VARCHAR_BYTES + 10)
await s.upsert({"v1": {"content": "hello", "source_id": source_id}})
await s.index_done_callback()
upserted = s._client.upsert.call_args.kwargs["data"][0]
assert len(upserted["source_id"].encode("utf-8")) == MILVUS_MAX_VARCHAR_BYTES
@pytest.mark.asyncio
async def test_upsert_truncates_source_id_on_separator_boundary():
# source_id is a <SEP>-joined list of chunk ids; truncation must drop whole
# ids at a separator boundary rather than leave a dangling partial id.
embed = CountingEmbeddingFunc()
s = _make_storage(embed, meta_fields={"content", "source_id"})
# Each chunk id is comfortably sized; enough of them to overflow the limit.
chunk_id = "chunk-" + "a" * 50
chunk_ids = [chunk_id] * ((MILVUS_MAX_VARCHAR_BYTES // len(chunk_id)) + 5)
source_id = "<SEP>".join(chunk_ids)
assert len(source_id.encode("utf-8")) > MILVUS_MAX_VARCHAR_BYTES
await s.upsert({"v1": {"content": "hello", "source_id": source_id}})
await s.index_done_callback()
upserted = s._client.upsert.call_args.kwargs["data"][0]
stored = upserted["source_id"]
assert len(stored.encode("utf-8")) <= MILVUS_MAX_VARCHAR_BYTES
# No trailing partial id: every retained id is intact.
assert all(part == chunk_id for part in stored.split("<SEP>"))
@pytest.mark.asyncio
async def test_upsert_truncates_oversized_single_source_id_without_separator():
# A single id longer than the limit has no separator to back off to, so it
# falls back to the raw byte cut instead of dropping the value entirely.
embed = CountingEmbeddingFunc()
s = _make_storage(embed, meta_fields={"content", "source_id"})
source_id = "s" * (MILVUS_MAX_VARCHAR_BYTES + 10)
await s.upsert({"v1": {"content": "hello", "source_id": source_id}})
await s.index_done_callback()
upserted = s._client.upsert.call_args.kwargs["data"][0]
assert len(upserted["source_id"].encode("utf-8")) == MILVUS_MAX_VARCHAR_BYTES
@pytest.mark.asyncio
async def test_repeated_upsert_same_id_embeds_once():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "first"}})
await s.upsert({"v1": {"content": "second"}})
await s.upsert({"v1": {"content": "third"}})
await s.index_done_callback()
assert embed.call_count == 1
# Only the latest content survives and was embedded.
assert embed.texts == ["third"]
s._client.upsert.assert_called_once()
@pytest.mark.asyncio
async def test_deferred_embeddings_respect_batch_size():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._max_batch_size = 2
await s.upsert({f"v{i}": {"content": f"doc {i}"} for i in range(5)})
await s.index_done_callback()
# 5 docs / batch 2 → 3 batches → 3 embedding calls
assert embed.call_count == 3
assert [len(b) for b in embed.batches] == [2, 2, 1]
@pytest.mark.asyncio
async def test_get_vectors_by_ids_lazy_embed_then_reuse_in_flush():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello"}})
vectors = await s.get_vectors_by_ids(["v1"])
assert "v1" in vectors
assert embed.call_count == 1 # lazy embed inside get_vectors_by_ids
# The lazy-embedded vector is cached on the pending doc.
assert s._pending_vector_docs["v1"].vector is not None
await s.index_done_callback()
# Flush reused the cached vector — no extra embedding call.
assert embed.call_count == 1
s._client.upsert.assert_called_once()
@pytest.mark.asyncio
async def test_flush_failure_keeps_buffer_and_no_double_embed_on_retry():
embed = CountingEmbeddingFunc(fail_times=1) # first flush raises
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello"}})
with pytest.raises(RuntimeError, match="embedding failed"):
await s.index_done_callback()
# Buffer must remain so the next flush can retry.
assert "v1" in s._pending_vector_docs
assert s._pending_vector_docs["v1"].vector is None
s._client.upsert.assert_not_called()
# Second attempt succeeds; total embed calls is 2 (one failed + one ok),
# not 3 — the same content was retried exactly once.
await s.index_done_callback()
assert embed.call_count == 2
s._client.upsert.assert_called_once()
assert s._pending_vector_docs == {}
@pytest.mark.asyncio
async def test_server_upsert_failure_keeps_buffer():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._client.upsert.side_effect = RuntimeError("milvus down")
await s.upsert({"v1": {"content": "hello"}})
with pytest.raises(RuntimeError, match="milvus down"):
await s.index_done_callback()
# Embedding ran but server write failed; buffer must remain populated.
assert "v1" in s._pending_vector_docs
# Vector should be cached so retry doesn't re-embed.
assert s._pending_vector_docs["v1"].vector is not None
# On retry, no further embedding call; only the server write is reattempted.
s._client.upsert.side_effect = None
s._client.upsert.return_value = {"upsert_count": 1}
await s.index_done_callback()
assert embed.call_count == 1
assert s._pending_vector_docs == {}
@pytest.mark.asyncio
async def test_finalize_raises_when_buffer_unflushed():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._client.upsert.side_effect = RuntimeError("transient milvus error")
await s.upsert({"v1": {"content": "hello"}})
with pytest.raises(RuntimeError, match="finalize.*flush raised"):
await s.finalize()
# Buffer still populated — caller knows data was lost.
assert "v1" in s._pending_vector_docs
@pytest.mark.asyncio
async def test_delete_then_upsert_same_id_keeps_upsert():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.delete(["v1"])
assert "v1" in s._pending_vector_deletes
await s.upsert({"v1": {"content": "hello"}})
assert "v1" in s._pending_vector_docs
assert "v1" not in s._pending_vector_deletes
await s.index_done_callback()
s._client.upsert.assert_called_once()
s._client.delete.assert_not_called()
@pytest.mark.asyncio
async def test_upsert_then_delete_same_id_keeps_delete():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello"}})
await s.delete(["v1"])
assert "v1" not in s._pending_vector_docs
assert "v1" in s._pending_vector_deletes
await s.index_done_callback()
# No upsert payload, only the delete batch.
s._client.upsert.assert_not_called()
s._client.delete.assert_called_once()
assert s._client.delete.call_args.kwargs["pks"] == ["v1"]
@pytest.mark.asyncio
async def test_delete_entity_relation_raises_on_server_failure():
"""Server-side failure must bubble up — no log-and-swallow."""
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._client.query.return_value = [{"id": "rel1"}, {"id": "rel2"}]
s._client.delete.side_effect = RuntimeError("milvus delete failed")
with pytest.raises(RuntimeError, match="milvus delete failed"):
await s.delete_entity_relation("X")
@pytest.mark.asyncio
async def test_delete_entity_relation_prunes_pending_buffer():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert(
{
"rel-A-B": {"content": "A → B", "src_id": "A", "tgt_id": "B"},
"rel-C-D": {"content": "C → D", "src_id": "C", "tgt_id": "D"},
}
)
s._client.query.return_value = [] # no server-side hits
await s.delete_entity_relation("A")
# Pending doc whose src_id == A is pruned, the other survives.
assert "rel-A-B" not in s._pending_vector_docs
assert "rel-C-D" in s._pending_vector_docs
@pytest.mark.asyncio
async def test_delete_entity_relation_diverges_when_buffer_overwrites_persisted():
"""Pins the deferred ↔ eager semantic divergence documented on
``delete_entity_relation``.
Scenario: a persisted row ``rel-X-Y`` has ``src_id="X" / tgt_id="Y"``,
and a pending upsert is about to rewrite that same id so it would
instead carry ``src_id="A" / tgt_id="B"``. A call to
``delete_entity_relation("A")`` arrives before the buffer is flushed.
Expected (deferred mode, current implementation):
* server-side filter ``src_id == "A" or tgt_id == "A"`` does NOT
match the persisted row (its src/tgt are still X/Y), so the
server-side delete is a no-op;
* the buffered upsert IS pruned (its buffered src/tgt match);
* net effect: persisted ``rel-X-Y`` (old values) survives and the
pending overwrite is lost.
Under eager ordering (upsert → flush → delete) the persisted row
would have been rewritten first and then matched by the filter, so
the final state would have been a deleted ``rel-X-Y``. This test
locks in the divergence so a future refactor can't silently change
it without touching the docstring.
"""
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
# Buffered upsert rewriting an (assumed) already-persisted rel-X-Y
# so that its new src/tgt would match entity "A".
await s.upsert({"rel-X-Y": {"content": "A → B", "src_id": "A", "tgt_id": "B"}})
assert "rel-X-Y" in s._pending_vector_docs
# Server still sees the OLD persisted row (src_id="X" / tgt_id="Y"),
# so a filter on entity "A" returns nothing.
s._client.query.return_value = []
await s.delete_entity_relation("A")
# Buffered overwrite is pruned (matches buffered src/tgt view) …
assert "rel-X-Y" not in s._pending_vector_docs
# … but the server-side delete is not issued, because the filter
# didn't match the persisted row's actual src/tgt.
s._client.delete.assert_not_called()
@pytest.mark.asyncio
async def test_delete_entity_relation_eager_ordering_matches_persisted():
"""Counterpart to the divergence test: if the caller flushes before
invoking ``delete_entity_relation``, the persisted row reflects the
buffered overwrite and the server-side filter catches it.
This documents the recommended workaround called out in the
``delete_entity_relation`` docstring: ``index_done_callback()`` first
when eager-equivalent semantics are required.
"""
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"rel-X-Y": {"content": "A → B", "src_id": "A", "tgt_id": "B"}})
await s.index_done_callback() # buffered upsert is now persisted
assert s._pending_vector_docs == {}
s._client.upsert.assert_called_once()
# With the row persisted, the server filter on entity "A" now hits.
s._client.query.return_value = [{"id": "rel-X-Y"}]
await s.delete_entity_relation("A")
s._client.delete.assert_called_once()
assert s._client.delete.call_args.kwargs["pks"] == ["rel-X-Y"]
@pytest.mark.asyncio
async def test_get_by_id_reads_pending_buffer_without_vector():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello", "entity_name": "E1"}})
doc = await s.get_by_id("v1")
assert doc is not None
assert doc["id"] == "v1"
assert doc.get("entity_name") == "E1"
assert "vector" not in doc
# Server was not queried because the buffer answered the read.
s._client.query.assert_not_called()
@pytest.mark.asyncio
async def test_get_by_id_returns_none_for_pending_delete():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.delete(["v1"])
assert await s.get_by_id("v1") is None
s._client.query.assert_not_called()
@pytest.mark.asyncio
async def test_env_workspace_override_shares_flush_lock(patch_namespace_lock):
"""Two instances whose final_namespace collides must share the flush lock."""
cache = patch_namespace_lock
embed = CountingEmbeddingFunc()
with patch.dict(os.environ, {"MILVUS_WORKSPACE": "shared_ws"}, clear=False):
a = _make_storage(embed, workspace="caller_a")
b = _make_storage(embed, workspace="caller_b")
assert (
a.final_namespace == b.final_namespace == "shared_ws_entities_mock_embed_8d"
)
assert a._flush_lock is b._flush_lock
# Sanity: only one lock object was cached for that final_namespace.
assert len([k for k in cache if k[0] == a.final_namespace]) == 1
@pytest.mark.asyncio
async def test_distinct_namespaces_get_independent_locks(patch_namespace_lock):
"""Different final_namespaces must NOT share a lock."""
embed = CountingEmbeddingFunc()
# Two instances with no env override and different workspaces produce
# different final_namespaces ("a_entities" vs "b_entities").
a = _make_storage(embed, workspace="a")
b = _make_storage(embed, workspace="b")
assert a.final_namespace != b.final_namespace
assert a._flush_lock is not b._flush_lock
@pytest.mark.asyncio
async def test_mixed_upsert_and_delete_in_single_flush():
"""A flush carrying both pending upserts and pending deletes (on disjoint
ids) must dispatch one server upsert and one server delete in a single
pass, then clear both buffers."""
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"a": {"content": "alpha"}})
await s.delete(["b"])
assert set(s._pending_vector_docs.keys()) == {"a"}
assert s._pending_vector_deletes == {"b"}
await s.index_done_callback()
s._client.upsert.assert_called_once()
upsert_kwargs = s._client.upsert.call_args.kwargs
assert {row["id"] for row in upsert_kwargs["data"]} == {"a"}
s._client.delete.assert_called_once()
assert s._client.delete.call_args.kwargs["pks"] == ["b"]
# Both buffers cleared after a successful flush.
assert s._pending_vector_docs == {}
assert s._pending_vector_deletes == set()
@pytest.mark.asyncio
async def test_finalize_clean_flush_no_raise():
"""Happy-path counterpart to test_finalize_raises_when_buffer_unflushed:
a successful flush during finalize() must leave both buffers empty and
must not raise."""
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello"}})
await s.delete(["v2"])
await s.finalize() # must not raise
s._client.upsert.assert_called_once()
s._client.delete.assert_called_once()
assert s._pending_vector_docs == {}
assert s._pending_vector_deletes == set()
@pytest.mark.asyncio
async def test_drop_clears_pending_buffers():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._client.has_collection.return_value = False # skip drop_collection call
# Stub out the recreate path to avoid hitting MilvusIndexConfig logic.
with (
patch.object(s, "_create_collection_with_schema"),
patch.object(s, "_ensure_collection_loaded"),
):
await s.upsert({"v1": {"content": "hello"}})
await s.delete(["v2"])
assert s._pending_vector_docs and s._pending_vector_deletes
result = await s.drop()
assert result["status"] == "success"
assert s._pending_vector_docs == {}
assert s._pending_vector_deletes == set()
@pytest.mark.asyncio
async def test_drop_recreates_empty_without_legacy_migration():
# drop() must leave the collection EMPTY. Recreating via
# _create_collection_if_not_exist would re-run the legacy->suffixed
# migration (the legacy collection is intentionally kept after migration),
# pulling the just-dropped rows back in and forcing a needless full
# migration on every rebuild/clear. Regression for that path.
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._client.has_collection.return_value = True
with (
patch.object(s, "_create_collection_if_not_exist") as recreate_via_migration,
patch.object(s, "_create_collection_with_schema") as create_empty,
patch.object(s, "_ensure_collection_loaded") as load,
):
result = await s.drop()
assert result["status"] == "success"
s._client.drop_collection.assert_called_once_with(s.final_namespace)
create_empty.assert_called_once_with(s.final_namespace)
load.assert_called_once_with()
# Never the migration-capable path.
recreate_via_migration.assert_not_called()
s._client.query_iterator.assert_not_called()
@pytest.mark.offline
@pytest.mark.asyncio
async def test_drop_pending_index_ops_clears_buffers():
"""On an internal-error abort the pipeline calls drop_pending_index_ops to
discard buffered upserts/deletes without flushing them (PR #3187)."""
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "x"}, "v2": {"content": "y"}})
s._pending_vector_deletes.add("old-id")
assert s._pending_vector_docs
await s.drop_pending_index_ops()
assert not s._pending_vector_docs
assert not s._pending_vector_deletes
@pytest.mark.asyncio
async def test_finalize_closes_milvus_client():
"""finalize() must release the Milvus gRPC channel instead of leaving it
for GC — mirroring the close-on-release pattern of the other server-backed
storages and the in-file ``_rebuild_milvus_client``."""
s = _make_storage(MockEmbeddingFunc())
client = s._client
assert client is not None
# No pending writes → finalize must not raise.
await s.finalize()
client.close.assert_called_once()
@@ -0,0 +1,415 @@
"""
Tests for Milvus index configuration
This test suite validates the MilvusIndexConfig class and its integration
with MilvusVectorDBStorage.
"""
import pytest
import os
from unittest.mock import patch, MagicMock
from lightrag.kg.milvus_impl import (
MilvusIndexConfig,
SUPPORTED_INDEX_TYPES,
SUPPORTED_METRIC_TYPES,
SUPPORTED_SQ_TYPES,
SUPPORTED_REFINE_TYPES,
)
class TestMilvusIndexConfig:
"""MilvusIndexConfig unit tests"""
def test_default_values(self):
"""Test default configuration"""
config = MilvusIndexConfig()
assert config.index_type == "AUTOINDEX"
assert config.metric_type == "COSINE"
assert config.hnsw_m == 16
assert config.hnsw_ef_construction == 360
assert config.hnsw_ef == 200
assert config.sq_type == "SQ8"
assert not config.sq_refine
assert config.sq_refine_type == "FP32"
assert config.sq_refine_k == 10
assert config.ivf_nlist == 1024
assert config.ivf_nprobe == 16
def test_env_override(self):
"""Test environment variable override"""
with patch.dict(
os.environ,
{
"MILVUS_INDEX_TYPE": "HNSW",
"MILVUS_METRIC_TYPE": "L2",
"MILVUS_HNSW_M": "64",
},
):
config = MilvusIndexConfig()
assert config.index_type == "HNSW"
assert config.metric_type == "L2"
assert config.hnsw_m == 64
def test_init_param_priority(self):
"""Test initialization parameter priority over environment variables"""
with patch.dict(os.environ, {"MILVUS_INDEX_TYPE": "IVF_FLAT"}):
config = MilvusIndexConfig(index_type="HNSW")
assert config.index_type == "HNSW" # Init param takes precedence
def test_case_insensitive_index_type(self):
"""Test that index type is case insensitive"""
config = MilvusIndexConfig(index_type="hnsw")
assert config.index_type == "HNSW"
def test_case_insensitive_metric_type(self):
"""Test that metric type is case insensitive"""
config = MilvusIndexConfig(metric_type="cosine")
assert config.metric_type == "COSINE"
def test_invalid_index_type(self):
"""Test invalid index type raises exception"""
with pytest.raises(ValueError, match="Unsupported index type"):
MilvusIndexConfig(index_type="INVALID_INDEX")
def test_invalid_metric_type(self):
"""Test invalid metric type raises exception"""
with pytest.raises(ValueError, match="Unsupported metric type"):
MilvusIndexConfig(metric_type="INVALID_METRIC")
def test_invalid_hnsw_m_range_low(self):
"""Test HNSW M parameter range validation (too low)"""
with pytest.raises(ValueError, match="hnsw_m must be in"):
MilvusIndexConfig(hnsw_m=1) # Less than 2
def test_invalid_hnsw_m_range_high(self):
"""Test HNSW M parameter range validation (too high)"""
with pytest.raises(ValueError, match="hnsw_m must be in"):
MilvusIndexConfig(hnsw_m=3000) # Greater than 2048
def test_valid_hnsw_m_boundary(self):
"""Test HNSW M parameter boundary values"""
config_low = MilvusIndexConfig(hnsw_m=2)
assert config_low.hnsw_m == 2
config_high = MilvusIndexConfig(hnsw_m=2048)
assert config_high.hnsw_m == 2048
def test_invalid_hnsw_ef_construction(self):
"""Test HNSW efConstruction validation"""
with pytest.raises(ValueError, match="hnsw_ef_construction must be"):
MilvusIndexConfig(hnsw_ef_construction=0)
def test_invalid_ivf_nlist_low(self):
"""Test IVF nlist parameter range validation (too low)"""
with pytest.raises(ValueError, match="ivf_nlist must be in"):
MilvusIndexConfig(ivf_nlist=0)
def test_invalid_ivf_nlist_high(self):
"""Test IVF nlist parameter range validation (too high)"""
with pytest.raises(ValueError, match="ivf_nlist must be in"):
MilvusIndexConfig(ivf_nlist=70000)
def test_invalid_sq_type(self):
"""Test invalid sq_type"""
with pytest.raises(ValueError, match="Unsupported sq_type"):
MilvusIndexConfig(index_type="HNSW_SQ", sq_type="INVALID")
def test_invalid_refine_type(self):
"""Test invalid refine_type"""
with pytest.raises(ValueError, match="Unsupported refine_type"):
MilvusIndexConfig(
index_type="HNSW_SQ", sq_refine=True, sq_refine_type="INVALID"
)
def test_version_validation_hnsw_sq_pass(self):
"""Test HNSW_SQ version validation passes with valid versions"""
config = MilvusIndexConfig(index_type="HNSW_SQ")
# Version meets requirement
config.validate_milvus_version("2.6.8") # Exactly required
config.validate_milvus_version("2.6.9") # Above requirement
config.validate_milvus_version("2.7.0") # Higher version
def test_version_validation_hnsw_sq_fail(self):
"""Test HNSW_SQ version validation fails with invalid versions"""
config = MilvusIndexConfig(index_type="HNSW_SQ")
# Version does not meet requirement
with pytest.raises(ValueError, match="HNSW_SQ requires Milvus 2.6.8"):
config.validate_milvus_version("2.6.7") # Below 2.6.8
with pytest.raises(ValueError, match="HNSW_SQ requires Milvus 2.6.8"):
config.validate_milvus_version("2.5.0") # Much lower
def test_version_validation_hnsw_sq_with_sq4u(self):
"""Test HNSW_SQ + SQ4U version validation"""
config = MilvusIndexConfig(index_type="HNSW_SQ", sq_type="SQ4U")
# Passes with valid version
config.validate_milvus_version("2.6.9")
# Fails with invalid version
with pytest.raises(ValueError, match="HNSW_SQ requires Milvus 2.6.8"):
config.validate_milvus_version("2.6.0")
def test_version_validation_hnsw_no_requirement(self):
"""Test normal HNSW has no version restriction"""
config = MilvusIndexConfig(index_type="HNSW")
# No version restriction
config.validate_milvus_version("2.4.0") # Lower version OK
config.validate_milvus_version("2.6.9") # Higher version OK
def test_version_validation_with_dev_suffix(self):
"""Test version validation handles dev suffixes"""
config = MilvusIndexConfig(index_type="HNSW_SQ")
# Should handle "2.6.9-dev" format
config.validate_milvus_version("2.6.9-dev")
def test_build_index_params_autoindex(self):
"""Test AUTOINDEX generates explicit index parameters"""
config = MilvusIndexConfig(index_type="AUTOINDEX")
mock_index_params = MagicMock()
result = config.build_index_params(mock_index_params)
assert result is mock_index_params
mock_index_params.add_index.assert_called_once_with(
field_name="vector",
index_type="AUTOINDEX",
metric_type="COSINE",
params={},
)
def test_build_index_params_hnsw(self):
"""Test HNSW index parameters construction"""
config = MilvusIndexConfig(
index_type="HNSW",
metric_type="COSINE",
hnsw_m=32,
hnsw_ef_construction=256,
)
mock_index_params = MagicMock()
config.build_index_params(mock_index_params)
mock_index_params.add_index.assert_called_once()
call_kwargs = mock_index_params.add_index.call_args[1]
assert call_kwargs["index_type"] == "HNSW"
assert call_kwargs["metric_type"] == "COSINE"
assert call_kwargs["params"]["M"] == 32
assert call_kwargs["params"]["efConstruction"] == 256
def test_build_index_params_hnsw_sq(self):
"""Test HNSW_SQ index parameters construction"""
config = MilvusIndexConfig(
index_type="HNSW_SQ",
sq_type="SQ8",
sq_refine=True,
sq_refine_type="FP32",
)
mock_index_params = MagicMock()
config.build_index_params(mock_index_params)
call_kwargs = mock_index_params.add_index.call_args[1]
assert call_kwargs["index_type"] == "HNSW_SQ"
assert call_kwargs["params"]["sq_type"] == "SQ8"
assert call_kwargs["params"]["refine"] is True
assert call_kwargs["params"]["refine_type"] == "FP32"
def test_build_index_params_hnsw_sq_no_refine(self):
"""Test HNSW_SQ without refinement"""
config = MilvusIndexConfig(index_type="HNSW_SQ", sq_type="SQ8", sq_refine=False)
mock_index_params = MagicMock()
config.build_index_params(mock_index_params)
call_kwargs = mock_index_params.add_index.call_args[1]
assert call_kwargs["index_type"] == "HNSW_SQ"
assert call_kwargs["params"]["sq_type"] == "SQ8"
assert "refine" not in call_kwargs["params"]
assert "refine_type" not in call_kwargs["params"]
def test_build_index_params_ivf_flat(self):
"""Test IVF_FLAT index parameters construction"""
config = MilvusIndexConfig(index_type="IVF_FLAT", ivf_nlist=2048)
mock_index_params = MagicMock()
config.build_index_params(mock_index_params)
call_kwargs = mock_index_params.add_index.call_args[1]
assert call_kwargs["index_type"] == "IVF_FLAT"
assert call_kwargs["params"]["nlist"] == 2048
def test_build_index_params_with_none(self):
"""Test that RuntimeError is raised when index_params is None for custom types"""
config = MilvusIndexConfig(index_type="HNSW")
# Pass None to simulate when compatibility helper returns None
with pytest.raises(RuntimeError, match="IndexParams not available"):
config.build_index_params(None)
def test_build_search_params_hnsw(self):
"""Test HNSW search parameters construction"""
config = MilvusIndexConfig(index_type="HNSW", hnsw_ef=150)
params = config.build_search_params()
assert params["params"]["ef"] == 150
def test_build_search_params_hnsw_sq_with_refine(self):
"""Test HNSW_SQ with refinement search parameters"""
config = MilvusIndexConfig(
index_type="HNSW_SQ", hnsw_ef=200, sq_refine=True, sq_refine_k=20
)
params = config.build_search_params()
assert params["params"]["ef"] == 200
assert params["params"]["refine_k"] == 20
def test_build_search_params_hnsw_sq_no_refine(self):
"""Test HNSW_SQ without refinement search parameters"""
config = MilvusIndexConfig(index_type="HNSW_SQ", hnsw_ef=200, sq_refine=False)
params = config.build_search_params()
assert params["params"]["ef"] == 200
assert "refine_k" not in params["params"]
def test_build_search_params_ivf(self):
"""Test IVF search parameters construction"""
config = MilvusIndexConfig(index_type="IVF_FLAT", ivf_nprobe=32)
params = config.build_search_params()
assert params["params"]["nprobe"] == 32
def test_build_search_params_autoindex(self):
"""Test AUTOINDEX search parameters (empty)"""
config = MilvusIndexConfig(index_type="AUTOINDEX")
params = config.build_search_params()
assert params == {}
def test_to_dict_hnsw(self):
"""Test configuration export for HNSW"""
config = MilvusIndexConfig(index_type="HNSW")
d = config.to_dict()
assert d["index_type"] == "HNSW"
assert d["hnsw_m"] == 16
assert d["sq_type"] is None # Not HNSW_SQ
assert d["ivf_nlist"] is None # Not IVF
def test_to_dict_hnsw_sq(self):
"""Test configuration export for HNSW_SQ"""
config = MilvusIndexConfig(index_type="HNSW_SQ", sq_type="SQ8")
d = config.to_dict()
assert d["index_type"] == "HNSW_SQ"
assert d["sq_type"] == "SQ8"
assert d["ivf_nlist"] is None
def test_to_dict_ivf(self):
"""Test configuration export for IVF"""
config = MilvusIndexConfig(index_type="IVF_FLAT")
d = config.to_dict()
assert d["index_type"] == "IVF_FLAT"
assert d["ivf_nlist"] == 1024
assert d["sq_type"] is None
def test_env_bool_parsing(self):
"""Test boolean environment variable parsing"""
with patch.dict(os.environ, {"MILVUS_HNSW_SQ_REFINE": "true"}):
config = MilvusIndexConfig(index_type="HNSW_SQ")
assert config.sq_refine is True
with patch.dict(os.environ, {"MILVUS_HNSW_SQ_REFINE": "false"}):
config = MilvusIndexConfig(index_type="HNSW_SQ")
assert not config.sq_refine
with patch.dict(os.environ, {"MILVUS_HNSW_SQ_REFINE": "1"}):
config = MilvusIndexConfig(index_type="HNSW_SQ")
assert config.sq_refine is True
with patch.dict(os.environ, {"MILVUS_HNSW_SQ_REFINE": "0"}):
config = MilvusIndexConfig(index_type="HNSW_SQ")
assert not config.sq_refine
def test_env_int_parsing_invalid(self):
"""Test integer environment variable parsing with invalid value"""
with patch.dict(os.environ, {"MILVUS_HNSW_M": "invalid"}):
config = MilvusIndexConfig()
assert config.hnsw_m == 16 # Falls back to default (Milvus 2.4+)
def test_all_index_types_supported(self):
"""Test all supported index types can be configured"""
for index_type in SUPPORTED_INDEX_TYPES:
if index_type == "HNSW_SQ":
# HNSW_SQ requires special parameters
config = MilvusIndexConfig(index_type=index_type, sq_type="SQ8")
else:
config = MilvusIndexConfig(index_type=index_type)
assert config.index_type == index_type
def test_all_metric_types_supported(self):
"""Test all supported metric types can be configured"""
for metric_type in SUPPORTED_METRIC_TYPES:
config = MilvusIndexConfig(metric_type=metric_type)
assert config.metric_type == metric_type
def test_all_sq_types_supported(self):
"""Test all supported sq_types can be configured"""
for sq_type in SUPPORTED_SQ_TYPES:
config = MilvusIndexConfig(index_type="HNSW_SQ", sq_type=sq_type)
assert config.sq_type == sq_type
def test_all_refine_types_supported(self):
"""Test all supported refine_types can be configured"""
for refine_type in SUPPORTED_REFINE_TYPES:
config = MilvusIndexConfig(
index_type="HNSW_SQ", sq_refine=True, sq_refine_type=refine_type
)
assert config.sq_refine_type == refine_type
def test_get_config_field_names(self):
"""Test get_config_field_names() returns all dataclass fields"""
field_names = MilvusIndexConfig.get_config_field_names()
# Check that it's a set
assert isinstance(field_names, set)
# Check that all expected fields are present
expected_fields = {
"index_type",
"metric_type",
"hnsw_m",
"hnsw_ef_construction",
"hnsw_ef",
"sq_type",
"sq_refine",
"sq_refine_type",
"sq_refine_k",
"ivf_nlist",
"ivf_nprobe",
}
assert field_names == expected_fields
def test_get_config_field_names_single_source_of_truth(self):
"""Test that get_config_field_names() provides single source of truth for configuration parameters"""
# This test ensures that when we add new fields to MilvusIndexConfig,
# they are automatically included in get_config_field_names()
# without needing to update hardcoded lists elsewhere
from dataclasses import fields as dataclass_fields
# Get fields directly from dataclass
direct_fields = {f.name for f in dataclass_fields(MilvusIndexConfig)}
# Get fields via the method
method_fields = MilvusIndexConfig.get_config_field_names()
# They should be identical
assert direct_fields == method_fields, (
f"Method should return same fields as dataclass. "
f"Difference: {direct_fields.symmetric_difference(method_fields)}"
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,296 @@
"""
Tests for bridging vector_db_storage_cls_kwargs to MilvusIndexConfig
This test suite validates that MilvusIndexConfig parameters can be passed
through vector_db_storage_cls_kwargs and that backward compatibility is maintained.
"""
import pytest
from unittest.mock import patch, MagicMock
from lightrag.kg.milvus_impl import MilvusVectorDBStorage
@pytest.mark.offline
class TestMilvusKwargsParameterBridge:
"""Test parameter bridging from vector_db_storage_cls_kwargs to MilvusIndexConfig"""
def test_kwargs_to_index_config_basic(self):
"""Test that basic HNSW parameters are passed from kwargs to MilvusIndexConfig"""
# Mock the embedding function
mock_embedding_func = MagicMock()
mock_embedding_func.embedding_dim = 128
# Create storage instance with custom index config parameters in kwargs
storage = MilvusVectorDBStorage(
namespace="test_entities",
workspace="test_workspace",
global_config={
"embedding_batch_num": 100,
"vector_db_storage_cls_kwargs": {
"cosine_better_than_threshold": 0.3,
"hnsw_m": 32,
"hnsw_ef": 256,
"hnsw_ef_construction": 300,
},
},
embedding_func=mock_embedding_func,
meta_fields=set(),
)
# Verify that parameters were passed to index_config
assert storage.index_config.hnsw_m == 32
assert storage.index_config.hnsw_ef == 256
assert storage.index_config.hnsw_ef_construction == 300
def test_kwargs_to_index_config_index_and_metric_types(self):
"""Test that index_type and metric_type are passed from kwargs"""
mock_embedding_func = MagicMock()
mock_embedding_func.embedding_dim = 128
storage = MilvusVectorDBStorage(
namespace="test_entities",
workspace="test_workspace",
global_config={
"embedding_batch_num": 100,
"vector_db_storage_cls_kwargs": {
"cosine_better_than_threshold": 0.3,
"index_type": "IVF_FLAT",
"metric_type": "L2",
"ivf_nlist": 2048,
},
},
embedding_func=mock_embedding_func,
meta_fields=set(),
)
# Verify that parameters were passed to index_config
assert storage.index_config.index_type == "IVF_FLAT"
assert storage.index_config.metric_type == "L2"
assert storage.index_config.ivf_nlist == 2048
def test_kwargs_to_index_config_sq_parameters(self):
"""Test that HNSW_SQ parameters are passed from kwargs"""
mock_embedding_func = MagicMock()
mock_embedding_func.embedding_dim = 128
storage = MilvusVectorDBStorage(
namespace="test_entities",
workspace="test_workspace",
global_config={
"embedding_batch_num": 100,
"vector_db_storage_cls_kwargs": {
"cosine_better_than_threshold": 0.3,
"index_type": "HNSW_SQ",
"sq_type": "SQ8",
"sq_refine": True,
"sq_refine_type": "FP16",
"sq_refine_k": 20,
},
},
embedding_func=mock_embedding_func,
meta_fields=set(),
)
# Verify that parameters were passed to index_config
assert storage.index_config.index_type == "HNSW_SQ"
assert storage.index_config.sq_type == "SQ8"
assert storage.index_config.sq_refine is True
assert storage.index_config.sq_refine_type == "FP16"
assert storage.index_config.sq_refine_k == 20
def test_backward_compatibility_no_index_params(self):
"""Test backward compatibility when no index parameters are provided in kwargs"""
mock_embedding_func = MagicMock()
mock_embedding_func.embedding_dim = 128
# Create storage without any index config parameters in kwargs
storage = MilvusVectorDBStorage(
namespace="test_entities",
workspace="test_workspace",
global_config={
"embedding_batch_num": 100,
"vector_db_storage_cls_kwargs": {
"cosine_better_than_threshold": 0.3,
},
},
embedding_func=mock_embedding_func,
meta_fields=set(),
)
# Verify that default values are used (from environment variables or defaults)
# Defaults aligned with Milvus 2.4+ official documentation
assert storage.index_config.index_type == "AUTOINDEX" # Default
assert storage.index_config.metric_type == "COSINE" # Default
assert storage.index_config.hnsw_m == 16 # Default (Milvus 2.4+)
assert storage.index_config.hnsw_ef_construction == 360 # Default (Milvus 2.4+)
def test_kwargs_params_override_environment_variables(self):
"""Test that kwargs parameters take precedence over environment variables"""
mock_embedding_func = MagicMock()
mock_embedding_func.embedding_dim = 128
# Set environment variables
with patch.dict(
"os.environ",
{
"MILVUS_INDEX_TYPE": "IVF_FLAT",
"MILVUS_HNSW_M": "16",
},
):
# Create storage with kwargs parameters that should override env vars
storage = MilvusVectorDBStorage(
namespace="test_entities",
workspace="test_workspace",
global_config={
"embedding_batch_num": 100,
"vector_db_storage_cls_kwargs": {
"cosine_better_than_threshold": 0.3,
"index_type": "HNSW",
"hnsw_m": 64,
},
},
embedding_func=mock_embedding_func,
meta_fields=set(),
)
# Verify that kwargs parameters override environment variables
assert (
storage.index_config.index_type == "HNSW"
) # From kwargs, not IVF_FLAT
assert storage.index_config.hnsw_m == 64 # From kwargs, not 16
def test_non_index_params_ignored(self):
"""Test that non-index-config parameters in kwargs are ignored"""
mock_embedding_func = MagicMock()
mock_embedding_func.embedding_dim = 128
storage = MilvusVectorDBStorage(
namespace="test_entities",
workspace="test_workspace",
global_config={
"embedding_batch_num": 100,
"vector_db_storage_cls_kwargs": {
"cosine_better_than_threshold": 0.3,
"hnsw_m": 32,
"some_other_param": "ignored", # Should be ignored
"another_param": 123, # Should be ignored
},
},
embedding_func=mock_embedding_func,
meta_fields=set(),
)
# Verify that valid parameter was passed
assert storage.index_config.hnsw_m == 32
# Verify that invalid parameters were ignored (no AttributeError)
assert not hasattr(storage.index_config, "some_other_param")
assert not hasattr(storage.index_config, "another_param")
def test_raganything_framework_integration_scenario(self):
"""Test configuration passing through frameworks like RAGAnything
This test validates the use case where a framework (like RAGAnything)
sits on top of LightRAG and needs to pass Milvus index configuration
through to LightRAG without modifying environment variables.
The framework can pass all index config parameters via
vector_db_storage_cls_kwargs, and they will be properly extracted
and applied to MilvusIndexConfig.
"""
mock_embedding_func = MagicMock()
mock_embedding_func.embedding_dim = 128
# Simulate RAGAnything framework passing configuration to LightRAG
# All index configuration parameters are passed through kwargs
framework_config = {
"embedding_batch_num": 100,
"vector_db_storage_cls_kwargs": {
# Required for vector storage
"cosine_better_than_threshold": 0.2,
# Milvus index configuration - all parameters supported
"index_type": "HNSW",
"metric_type": "L2",
"hnsw_m": 48,
"hnsw_ef_construction": 400,
"hnsw_ef": 200,
# Framework-specific parameters (should be ignored by Milvus)
"framework_version": "1.0.0",
"custom_setting": "value",
},
}
# Create storage instance with framework configuration
storage = MilvusVectorDBStorage(
namespace="test_entities",
workspace="raganything_workspace",
global_config=framework_config,
embedding_func=mock_embedding_func,
meta_fields=set(),
)
# Verify all Milvus parameters were correctly extracted and applied
assert storage.index_config.index_type == "HNSW"
assert storage.index_config.metric_type == "L2"
assert storage.index_config.hnsw_m == 48
assert storage.index_config.hnsw_ef_construction == 400
assert storage.index_config.hnsw_ef == 200
# Verify framework-specific parameters were ignored
assert not hasattr(storage.index_config, "framework_version")
assert not hasattr(storage.index_config, "custom_setting")
# Verify workspace isolation is maintained
assert storage.workspace == "raganything_workspace"
def test_all_milvus_parameters_supported_via_kwargs(self):
"""Test that all 11 MilvusIndexConfig parameters can be configured via kwargs
This comprehensive test ensures that every single index configuration
parameter defined in MilvusIndexConfig can be passed through
vector_db_storage_cls_kwargs, which is critical for framework integration.
"""
mock_embedding_func = MagicMock()
mock_embedding_func.embedding_dim = 128
# Pass ALL 11 MilvusIndexConfig parameters via kwargs
storage = MilvusVectorDBStorage(
namespace="test_entities",
workspace="test_workspace",
global_config={
"embedding_batch_num": 100,
"vector_db_storage_cls_kwargs": {
"cosine_better_than_threshold": 0.3,
# All 11 MilvusIndexConfig parameters
"index_type": "HNSW_SQ",
"metric_type": "IP",
"hnsw_m": 64,
"hnsw_ef_construction": 512,
"hnsw_ef": 256,
"sq_type": "SQ8",
"sq_refine": True,
"sq_refine_type": "FP16",
"sq_refine_k": 30,
"ivf_nlist": 4096,
"ivf_nprobe": 64,
},
},
embedding_func=mock_embedding_func,
meta_fields=set(),
)
# Verify EVERY parameter was correctly applied
assert storage.index_config.index_type == "HNSW_SQ"
assert storage.index_config.metric_type == "IP"
assert storage.index_config.hnsw_m == 64
assert storage.index_config.hnsw_ef_construction == 512
assert storage.index_config.hnsw_ef == 256
assert storage.index_config.sq_type == "SQ8"
assert storage.index_config.sq_refine is True
assert storage.index_config.sq_refine_type == "FP16"
assert storage.index_config.sq_refine_k == 30
assert storage.index_config.ivf_nlist == 4096
assert storage.index_config.ivf_nprobe == 64
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,217 @@
"""
Tests for Milvus schema-migration memory back-pressure.
The bulk copy inserts the whole source collection into a temp collection with
no client-side throttle, so the Milvus data node accumulates insert-buffer
segments until its auto-flush catches up — a large migration can exhaust
server memory. These tests cover the periodic/final flush that bounds that
buffer and the optional inter-batch throttle.
"""
import pytest
from unittest.mock import MagicMock, patch
from pymilvus import MilvusException
from lightrag.kg.milvus_impl import MilvusVectorDBStorage
class _EmbeddingFunc:
def __init__(self, dim=128, model_name="text-embedding-3-small"):
self.embedding_dim = dim
self.model_name = model_name
def _make_model_storage(namespace="entities", workspace="test_workspace", dim=128):
return MilvusVectorDBStorage(
namespace=namespace,
workspace=workspace,
global_config={
"embedding_batch_num": 100,
"vector_db_storage_cls_kwargs": {
"cosine_better_than_threshold": 0.3,
},
},
embedding_func=_EmbeddingFunc(dim=dim),
meta_fields=set(),
)
def _wire_collection_state(storage, collections):
storage._client = MagicMock()
def has_collection(collection_name):
return collection_name in collections
def create_collection(collection_name, schema):
collections.add(collection_name)
def drop_collection(collection_name):
collections.discard(collection_name)
def rename_collection(source, target):
collections.discard(source)
collections.add(target)
storage._client.has_collection.side_effect = has_collection
storage._client.create_collection.side_effect = create_collection
storage._client.drop_collection.side_effect = drop_collection
storage._client.rename_collection.side_effect = rename_collection
return storage._client
def _wire_iterator(client, batches):
"""query_iterator yields the given list of row-batches then []."""
def make_iterator(**kwargs):
iterator = MagicMock()
iterator.next.side_effect = list(batches) + [[]]
return iterator
client.query_iterator.side_effect = make_iterator
def _rows(n, start=0):
return [
{"id": f"ent-{i}", "vector": [0.0] * 128, "content": "body"}
for i in range(start, start + n)
]
@pytest.mark.offline
class TestMigrationFlushBackpressure:
def test_periodic_and_final_flush_by_row_interval(self):
storage = _make_model_storage()
storage._migration_flush_interval_rows = 3
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
temp = f"{storage.final_namespace}_temp"
# Three iterator batches of 2 rows: cumulative 2, 4, 6.
# Periodic flush trips once (at 4 >= 3), then a final flush at the end.
_wire_iterator(client, [_rows(2), _rows(2, 2), _rows(2, 4)])
with patch.object(storage, "_create_indexes_after_collection"):
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
flush_targets = [call.args[0] for call in client.flush.call_args_list]
assert flush_targets == [temp, temp] # one periodic + one final
assert storage.final_namespace in collections
def test_flush_disabled_when_interval_zero(self):
storage = _make_model_storage()
storage._migration_flush_interval_rows = 0
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_iterator(client, [_rows(2), _rows(2, 2)])
with patch.object(storage, "_create_indexes_after_collection"):
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
client.flush.assert_not_called()
assert storage.final_namespace in collections
def test_no_final_flush_for_empty_source(self):
storage = _make_model_storage()
storage._migration_flush_interval_rows = 50000
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_iterator(client, []) # empty source
with patch.object(storage, "_create_indexes_after_collection"):
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
client.flush.assert_not_called()
def test_flush_connection_error_is_retryable(self):
# A flush failing with a connection error must be retried like any other
# connection-class migration failure (rebuild client, restart attempt).
storage = _make_model_storage()
storage._migration_flush_interval_rows = 1
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_iterator(client, [_rows(2)])
flush_calls = {"n": 0}
def flush(collection_name, **kwargs):
flush_calls["n"] += 1
if flush_calls["n"] == 1:
raise MilvusException(
code=2, message="Fail connecting to server on host:19530"
)
client.flush.side_effect = flush
with patch.object(storage, "_create_indexes_after_collection"):
with patch.object(storage, "_rebuild_milvus_client") as rebuild:
with patch("lightrag.kg.milvus_impl.time.sleep"):
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
rebuild.assert_called_once()
assert storage.final_namespace in collections
@pytest.mark.offline
class TestMigrationBatchThrottle:
def test_batch_sleep_throttles_between_batches(self):
storage = _make_model_storage()
storage._migration_flush_interval_rows = 0
storage._migration_batch_sleep = 0.05
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_iterator(client, [_rows(2), _rows(2, 2)])
with patch.object(storage, "_create_indexes_after_collection"):
with patch("lightrag.kg.milvus_impl.time.sleep") as sleep:
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
# One sleep per non-empty iterator batch, none for the terminating [].
assert [call.args[0] for call in sleep.call_args_list] == [0.05, 0.05]
def test_no_sleep_when_disabled(self):
storage = _make_model_storage()
storage._migration_flush_interval_rows = 0
storage._migration_batch_sleep = 0.0
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_iterator(client, [_rows(2)])
with patch.object(storage, "_create_indexes_after_collection"):
with patch("lightrag.kg.milvus_impl.time.sleep") as sleep:
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
sleep.assert_not_called()
@pytest.mark.offline
class TestMigrationBackpressureConfig:
def test_env_overrides(self, monkeypatch):
monkeypatch.setenv("MILVUS_MIGRATION_FLUSH_INTERVAL_ROWS", "1234")
monkeypatch.setenv("MILVUS_MIGRATION_BATCH_SLEEP", "0.25")
storage = _make_model_storage()
assert storage._migration_flush_interval_rows == 1234
assert storage._migration_batch_sleep == 0.25
def test_negative_values_clamped(self, monkeypatch):
monkeypatch.setenv("MILVUS_MIGRATION_FLUSH_INTERVAL_ROWS", "-5")
monkeypatch.setenv("MILVUS_MIGRATION_BATCH_SLEEP", "-1")
storage = _make_model_storage()
assert storage._migration_flush_interval_rows == 0
assert storage._migration_batch_sleep == 0.0
@@ -0,0 +1,681 @@
"""
Tests for Milvus schema-migration resilience.
This suite validates the failure-handling hardening added after a production
outage where a transient Milvus connection failure mid-migration permanently
killed worker startup:
1. Connection-class failures retry the whole migration with a rebuilt client.
2. Non-connection failures keep failing fast (single attempt).
3. _is_retryable_connection_error classifies errors through cause chains.
4. The force-create fallback never fires on a connection error.
5. The temp collection is not loaded during the bulk copy.
6. Backup collections are released from memory after a successful migration.
7. A stale _old backup is dropped so the in-place rename can succeed.
8. An orphaned temp collection (crash between drop-source and rename-temp)
is recovered instead of being shadowed by a fresh empty collection.
"""
import grpc
import pytest
from unittest.mock import MagicMock, patch
from pymilvus import MilvusException
from lightrag.kg.milvus_impl import MilvusVectorDBStorage
class _EmbeddingFunc:
def __init__(self, dim=128, model_name="text-embedding-3-small"):
self.embedding_dim = dim
self.model_name = model_name
def _make_model_storage(namespace="entities", workspace="test_workspace", dim=128):
return MilvusVectorDBStorage(
namespace=namespace,
workspace=workspace,
global_config={
"embedding_batch_num": 100,
"vector_db_storage_cls_kwargs": {
"cosine_better_than_threshold": 0.3,
},
},
embedding_func=_EmbeddingFunc(dim=dim),
meta_fields=set(),
)
def _wire_collection_state(storage, collections):
storage._client = MagicMock()
def has_collection(collection_name):
return collection_name in collections
def create_collection(collection_name, schema):
collections.add(collection_name)
def drop_collection(collection_name):
collections.discard(collection_name)
def rename_collection(source, target):
collections.discard(source)
collections.add(target)
storage._client.has_collection.side_effect = has_collection
storage._client.create_collection.side_effect = create_collection
storage._client.drop_collection.side_effect = drop_collection
storage._client.rename_collection.side_effect = rename_collection
return storage._client
def _rows():
return [{"id": "ent-1", "vector": [0.0] * 128, "content": "body"}]
def _wire_fresh_iterator_per_attempt(client, rows=None):
"""query_iterator returns a new exhausted-after-one-batch iterator per call."""
def make_iterator(**kwargs):
iterator = MagicMock()
iterator.next.side_effect = [rows if rows is not None else _rows(), []]
return iterator
client.query_iterator.side_effect = make_iterator
class _FakeRpcError(grpc.RpcError):
def __init__(self, status_code):
self._status_code = status_code
def code(self):
return self._status_code
_CONNECT_FAILED = MilvusException(
code=2, message="Fail connecting to server on host:19530"
)
@pytest.mark.offline
class TestMigrationRetry:
def test_retryable_error_retries_with_rebuilt_client(self):
storage = _make_model_storage()
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
insert_calls = {"n": 0}
def insert(collection_name, data):
insert_calls["n"] += 1
if insert_calls["n"] == 1:
raise MilvusException(
code=2, message="Fail connecting to server on host:19530"
)
client.insert.side_effect = insert
with patch.object(storage, "_create_indexes_after_collection"):
with patch.object(storage, "_rebuild_milvus_client") as rebuild:
with patch("lightrag.kg.milvus_impl.time.sleep") as sleep:
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
rebuild.assert_called_once()
sleep.assert_called_once()
assert sleep.call_args.args[0] == pytest.approx(5.0)
assert client.query_iterator.call_count == 2
assert storage.final_namespace in collections
assert f"{storage.final_namespace}_temp" not in collections
# The legacy source stays in place as the backup.
assert storage.legacy_namespace in collections
def test_non_retryable_error_fails_fast(self):
storage = _make_model_storage()
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
client.insert.side_effect = RuntimeError("insert failed")
with patch.object(storage, "_create_indexes_after_collection"):
with patch.object(storage, "_rebuild_milvus_client") as rebuild:
with patch("lightrag.kg.milvus_impl.time.sleep") as sleep:
with pytest.raises(
RuntimeError, match="Iterator-based migration failed"
):
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
rebuild.assert_not_called()
sleep.assert_not_called()
assert client.query_iterator.call_count == 1
assert storage.legacy_namespace in collections
assert storage.final_namespace not in collections
def test_retries_exhausted_raises_with_backoff_sequence(self):
storage = _make_model_storage()
storage._migration_max_retries = 2
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
client.insert.side_effect = ValueError("Cannot invoke RPC on closed channel!")
with patch.object(storage, "_create_indexes_after_collection"):
with patch.object(storage, "_rebuild_milvus_client"):
with patch("lightrag.kg.milvus_impl.time.sleep") as sleep:
with pytest.raises(
RuntimeError, match="Iterator-based migration failed"
):
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
assert client.query_iterator.call_count == 3
assert [call.args[0] for call in sleep.call_args_list] == [
pytest.approx(5.0),
pytest.approx(15.0),
]
assert storage.legacy_namespace in collections
def test_max_backoff_is_capped(self):
storage = _make_model_storage()
storage._migration_max_retries = 4
storage._migration_retry_max_backoff = 20.0
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
client.insert.side_effect = _CONNECT_FAILED
with patch.object(storage, "_create_indexes_after_collection"):
with patch.object(storage, "_rebuild_milvus_client"):
with patch("lightrag.kg.milvus_impl.time.sleep") as sleep:
with pytest.raises(RuntimeError):
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
assert [call.args[0] for call in sleep.call_args_list] == [
pytest.approx(5.0),
pytest.approx(15.0),
pytest.approx(20.0),
pytest.approx(20.0),
]
@pytest.mark.offline
class TestRetryableErrorClassification:
@pytest.mark.parametrize(
"error",
[
_FakeRpcError(grpc.StatusCode.UNAVAILABLE),
_FakeRpcError(grpc.StatusCode.DEADLINE_EXCEEDED),
MilvusException(code=2, message="Fail connecting to server on host:19530"),
MilvusException(code=1, message="server unavailable: ping timeout"),
ValueError("Cannot invoke RPC on closed channel!"),
],
ids=[
"grpc-unavailable",
"grpc-deadline-exceeded",
"milvus-connect-failed",
"milvus-connection-message",
"closed-channel-valueerror",
],
)
def test_retryable_errors(self, error):
assert MilvusVectorDBStorage._is_retryable_connection_error(error) is True
@pytest.mark.parametrize(
"error",
[
ValueError("primary keys cannot be truncated"),
MilvusException(code=1100, message="schema mismatch"),
RuntimeError("Target collection already exists: foo"),
KeyError("vector"),
],
ids=[
"value-error",
"milvus-schema-error",
"runtime-error",
"key-error",
],
)
def test_non_retryable_errors(self, error):
assert MilvusVectorDBStorage._is_retryable_connection_error(error) is False
def test_cause_chain_is_walked(self):
inner = ValueError("Cannot invoke RPC on closed channel!")
outer = RuntimeError("Iterator-based migration failed for collection foo")
outer.__cause__ = inner
assert MilvusVectorDBStorage._is_retryable_connection_error(outer) is True
def test_context_chain_is_walked(self):
inner = MilvusException(code=2, message="Fail connecting to server")
outer = RuntimeError("wrapper")
outer.__context__ = inner
assert MilvusVectorDBStorage._is_retryable_connection_error(outer) is True
def test_self_referencing_chain_terminates(self):
error = RuntimeError("loop")
error.__cause__ = error
assert MilvusVectorDBStorage._is_retryable_connection_error(error) is False
@pytest.mark.offline
class TestRebuildMilvusClient:
def test_rebuild_replaces_client_and_closes_old(self):
storage = _make_model_storage()
old_client = MagicMock()
new_client = MagicMock()
storage._client = old_client
with patch.object(
storage, "_create_milvus_client", return_value=new_client
) as create:
storage._rebuild_milvus_client()
old_client.close.assert_called_once()
create.assert_called_once()
assert storage._client is new_client
def test_rebuild_tolerates_close_failure_on_dead_client(self):
storage = _make_model_storage()
old_client = MagicMock()
old_client.close.side_effect = ValueError(
"Cannot invoke RPC on closed channel!"
)
new_client = MagicMock()
storage._client = old_client
with patch.object(storage, "_create_milvus_client", return_value=new_client):
storage._rebuild_milvus_client()
assert storage._client is new_client
@pytest.mark.offline
class TestForceCreateGuard:
def test_connection_error_propagates_without_force_create(self):
storage = _make_model_storage()
storage._client = MagicMock()
storage._client.has_collection.side_effect = MilvusException(
code=2, message="Fail connecting to server on host:19530"
)
with pytest.raises(MilvusException):
storage._create_collection_if_not_exist()
storage._client.create_collection.assert_not_called()
storage._client.drop_collection.assert_not_called()
def test_non_connection_error_still_force_creates(self):
storage = _make_model_storage()
storage._client = MagicMock()
storage._client.has_collection.side_effect = KeyError("boom")
with patch.object(storage, "_create_indexes_after_collection"):
with patch.object(storage, "_ensure_collection_loaded"):
storage._create_collection_if_not_exist()
storage._client.create_collection.assert_called_once()
@pytest.mark.offline
class TestMigrationMemoryFootprint:
def test_temp_collection_is_not_loaded_during_migration(self):
storage = _make_model_storage()
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
with patch.object(storage, "_create_indexes_after_collection"):
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
# The source must be loaded (query_iterator requires it), but the temp
# collection must never be loaded during the bulk copy.
loaded = [call.args[0] for call in client.load_collection.call_args_list]
assert f"{storage.final_namespace}_temp" not in loaded
assert storage.legacy_namespace in loaded
assert storage.final_namespace in collections
def test_source_is_loaded_before_query_iterator(self):
# query_iterator runs a server-side query that needs the source loaded;
# an unloaded legacy/suffix source otherwise fails with code 101
# "collection not loaded". The source must be loaded before the
# iterator is created.
storage = _make_model_storage()
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
order = []
client.load_collection.side_effect = lambda name, **kw: order.append(
("load", name)
)
def make_iterator(**kwargs):
order.append(("query_iterator", kwargs["collection_name"]))
iterator = MagicMock()
iterator.next.side_effect = [_rows(), []]
return iterator
client.query_iterator.side_effect = make_iterator
with patch.object(storage, "_create_indexes_after_collection"):
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
assert ("load", storage.legacy_namespace) in order
assert order.index(("load", storage.legacy_namespace)) < order.index(
("query_iterator", storage.legacy_namespace)
)
def test_failed_suffix_migration_releases_loaded_source(self):
# The source we loaded for a suffix migration must be released again on
# failure, so a failed startup/retry does not leave a full backup
# resident in query-node memory.
storage = _make_model_storage()
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
client.insert.side_effect = RuntimeError("insert failed") # non-retryable
with patch.object(storage, "_create_indexes_after_collection"):
with pytest.raises(RuntimeError, match="Iterator-based migration failed"):
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
client.release_collection.assert_any_call(storage.legacy_namespace)
def test_failed_inplace_migration_does_not_release_active_source(self):
# An in-place source is the live active collection; it must not be
# released on a pre-commit failure.
storage = _make_model_storage()
collections = {storage.final_namespace}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
client.insert.side_effect = RuntimeError("insert failed") # non-retryable
with patch.object(storage, "_create_indexes_after_collection"):
with pytest.raises(RuntimeError, match="Iterator-based migration failed"):
storage._migrate_collection_schema() # in-place
client.release_collection.assert_not_called()
def test_suffix_migration_releases_legacy_backup(self):
storage = _make_model_storage()
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
with patch.object(storage, "_create_indexes_after_collection"):
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
client.release_collection.assert_called_once_with(storage.legacy_namespace)
assert storage.legacy_namespace in collections
def test_in_place_migration_releases_old_backup(self):
storage = _make_model_storage()
collections = {storage.final_namespace}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
with patch.object(storage, "_create_indexes_after_collection"):
storage._migrate_collection_schema()
backup_name = f"{storage.final_namespace}_old"
client.release_collection.assert_called_once_with(backup_name)
assert backup_name in collections
assert storage.final_namespace in collections
def test_release_failure_does_not_fail_migration(self):
storage = _make_model_storage()
collections = {storage.legacy_namespace}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
client.release_collection.side_effect = MilvusException(
code=1, message="release failed"
)
with patch.object(storage, "_create_indexes_after_collection"):
storage._migrate_collection_schema(
source_collection_name=storage.legacy_namespace,
target_collection_name=storage.final_namespace,
)
assert storage.final_namespace in collections
@pytest.mark.offline
class TestInPlaceMigrationSafety:
def test_stale_old_backup_is_dropped_before_rename(self):
storage = _make_model_storage()
backup_name = f"{storage.final_namespace}_old"
collections = {storage.final_namespace, backup_name}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
with patch.object(storage, "_create_indexes_after_collection"):
storage._migrate_collection_schema()
# The stale backup was dropped so the rename could succeed; the source
# now lives on as the fresh backup instead of being dropped outright.
assert backup_name in collections
assert storage.final_namespace in collections
drop_calls = [call.args[0] for call in client.drop_collection.call_args_list]
assert backup_name in drop_calls
def test_orphaned_temp_collection_is_recovered_on_startup(self):
storage = _make_model_storage()
temp_name = f"{storage.final_namespace}_temp"
collections = {temp_name}
client = _wire_collection_state(storage, collections)
with patch.object(storage, "_ensure_collection_loaded"):
storage._create_collection_if_not_exist()
assert storage.final_namespace in collections
assert temp_name not in collections
client.create_collection.assert_not_called()
def test_startup_restores_old_backup_when_no_temp_survives(self):
# Target gone, no temp, only the _old backup left: the source was
# vacated but the migrated copy did not survive. Restore _old rather
# than creating an empty collection over the last copy.
storage = _make_model_storage()
old_name = f"{storage.final_namespace}_old"
collections = {old_name}
client = _wire_collection_state(storage, collections)
with patch.object(storage, "_ensure_collection_loaded"):
storage._create_collection_if_not_exist()
assert storage.final_namespace in collections
assert old_name not in collections
client.create_collection.assert_not_called()
def test_inplace_recovery_precedes_legacy_migration(self):
# A suffixed target was migrated in-place and interrupted after Step 3:
# final renamed to _old, the completed copy sits in _temp, and the old
# unsuffixed legacy backup still exists. Recovery MUST win over the
# legacy migration, which would otherwise drop _temp and overwrite the
# target with stale legacy data (losing every write since the split).
storage = _make_model_storage()
final = storage.final_namespace
legacy = storage.legacy_namespace
temp = f"{final}_temp"
old = f"{final}_old"
collections = {legacy, old, temp}
client = _wire_collection_state(storage, collections)
with patch.object(storage, "_ensure_collection_loaded"):
with patch.object(storage, "_migrate_collection_schema") as migrate:
storage._create_collection_if_not_exist()
migrate.assert_not_called()
assert final in collections # _temp promoted to the target
assert temp not in collections
assert legacy in collections # untouched
client.create_collection.assert_not_called()
drops = [call.args[0] for call in client.drop_collection.call_args_list]
assert temp not in drops
def test_partial_suffix_temp_with_legacy_is_remigrated_not_promoted(self):
# An aborted suffix copy (legacy -> final) left a PARTIAL _temp while
# legacy is intact and there is no _old. The partial temp must be
# treated as scratch and re-migrated from legacy, never promoted.
storage = _make_model_storage()
final = storage.final_namespace
legacy = storage.legacy_namespace
temp = f"{final}_temp"
collections = {legacy, temp}
client = _wire_collection_state(storage, collections)
with patch.object(storage, "_has_vector_field", return_value=True):
with patch.object(storage, "_check_vector_dimension"):
with patch.object(storage, "_migrate_collection_schema") as migrate:
with patch.object(storage, "_ensure_collection_loaded"):
storage._create_collection_if_not_exist()
migrate.assert_called_once_with(
source_collection_name=legacy,
target_collection_name=final,
)
# Recovery (which promotes/restores via rename) must not have run.
client.rename_collection.assert_not_called()
def _fail_specific_rename_once(client, collections, fail_source, fail_target, error):
"""Wrap the wired rename so one specific rename raises `error` on first call."""
state = {"n": 0}
def rename(source, target):
if source == fail_source and target == fail_target:
state["n"] += 1
if state["n"] == 1:
raise error
collections.discard(source)
collections.add(target)
client.rename_collection.side_effect = rename
@pytest.mark.offline
class TestInPlaceCommitWindowRecovery:
"""The in-place commit window (source vacated, temp not yet promoted) must
treat temp/_old as recoverable state, never as scratch to be dropped."""
def test_step4_connection_failure_retry_promotes_temp(self):
storage = _make_model_storage()
final = storage.final_namespace
temp = f"{final}_temp"
old = f"{final}_old"
collections = {final}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
# Step 4 (temp -> final) fails once with a connection error; the retry
# must promote the surviving temp copy, not drop and re-copy.
_fail_specific_rename_once(
client,
collections,
temp,
final,
MilvusException(code=2, message="Fail connecting to server on host:19530"),
)
with patch.object(storage, "_create_indexes_after_collection"):
with patch.object(storage, "_rebuild_milvus_client") as rebuild:
with patch("lightrag.kg.milvus_impl.time.sleep") as sleep:
storage._migrate_collection_schema()
rebuild.assert_called_once()
sleep.assert_called_once()
assert final in collections
assert temp not in collections
assert old in collections # backup preserved
assert storage.final_namespace == final
drops = [call.args[0] for call in client.drop_collection.call_args_list]
assert temp not in drops # never treated as scratch in the commit window
def test_drop_source_fallback_then_step4_failure_recovers_temp(self):
# rename(source -> _old) fails for a non-connection reason, so the
# drop-source fallback runs (NO _old backup); Step 4 then fails with a
# connection error. Without recovery this is total data loss; the retry
# must promote the only surviving copy (temp).
storage = _make_model_storage()
final = storage.final_namespace
temp = f"{final}_temp"
old = f"{final}_old"
collections = {final}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
state = {"temp_to_final": 0}
def rename(source, target):
if source == final and target == old:
raise RuntimeError("rename to _old unsupported") # forces drop-source
if source == temp and target == final:
state["temp_to_final"] += 1
if state["temp_to_final"] == 1:
raise MilvusException(
code=2, message="Fail connecting to server on host:19530"
)
collections.discard(source)
collections.add(target)
client.rename_collection.side_effect = rename
with patch.object(storage, "_create_indexes_after_collection"):
with patch.object(storage, "_rebuild_milvus_client") as rebuild:
with patch("lightrag.kg.milvus_impl.time.sleep"):
storage._migrate_collection_schema()
rebuild.assert_called_once()
assert final in collections
assert temp not in collections
assert old not in collections
assert storage.final_namespace == final
drops = [call.args[0] for call in client.drop_collection.call_args_list]
assert temp not in drops
def test_non_retryable_commit_failure_keeps_temp_for_startup_recovery(self):
# A non-retryable Step 4 failure must still preserve temp (the source is
# already vacated) so startup recovery can finish the commit.
storage = _make_model_storage()
final = storage.final_namespace
temp = f"{final}_temp"
old = f"{final}_old"
collections = {final}
client = _wire_collection_state(storage, collections)
_wire_fresh_iterator_per_attempt(client)
def rename(source, target):
if source == temp and target == final:
raise RuntimeError("non-retryable rename failure")
collections.discard(source)
collections.add(target)
client.rename_collection.side_effect = rename
with patch.object(storage, "_create_indexes_after_collection"):
with pytest.raises(RuntimeError, match="Iterator-based migration failed"):
storage._migrate_collection_schema()
assert temp in collections # preserved as recovery state
assert old in collections
drops = [call.args[0] for call in client.drop_collection.call_args_list]
assert temp not in drops
View File
@@ -0,0 +1,100 @@
"""Tests for ClientManager connection lifecycle (no MongoDB instance required).
These tests verify that ClientManager properly closes the underlying
AsyncMongoClient when all references are released, and keeps it alive
while references remain.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock
pytest.importorskip(
"pymongo",
reason="pymongo is required for Mongo ClientManager tests",
)
from lightrag.kg.mongo_impl import ClientManager
class TestClientManagerLifecycle:
"""Verify ClientManager connection open/close behavior."""
def _reset_manager(self):
"""Reset ClientManager class state between tests."""
ClientManager._instances = {"client": None, "db": None, "ref_count": 0}
def teardown_method(self):
self._reset_manager()
@pytest.mark.asyncio
async def test_release_client_closes_connection_when_ref_count_zero(self):
"""When ref_count drops to 0, the MongoClient should be closed and cleared."""
mock_client = AsyncMock()
mock_db = MagicMock()
ClientManager._instances = {
"client": mock_client,
"db": mock_db,
"ref_count": 1,
}
await ClientManager.release_client(mock_db)
mock_client.close.assert_awaited_once()
assert ClientManager._instances["client"] is None
assert ClientManager._instances["db"] is None
assert ClientManager._instances["ref_count"] == 0
@pytest.mark.asyncio
async def test_release_client_keeps_connection_with_multiple_refs(self):
"""When other references exist, the MongoClient must NOT be closed."""
mock_client = AsyncMock()
mock_db = MagicMock()
ClientManager._instances = {
"client": mock_client,
"db": mock_db,
"ref_count": 3,
}
await ClientManager.release_client(mock_db)
mock_client.close.assert_not_awaited()
assert ClientManager._instances["ref_count"] == 2
assert ClientManager._instances["client"] is mock_client
assert ClientManager._instances["db"] is mock_db
@pytest.mark.asyncio
async def test_release_client_noop_for_wrong_db(self):
"""Releasing a db that is not the tracked instance should do nothing."""
mock_client = AsyncMock()
mock_db = MagicMock()
other_db = MagicMock()
ClientManager._instances = {
"client": mock_client,
"db": mock_db,
"ref_count": 1,
}
await ClientManager.release_client(other_db)
mock_client.close.assert_not_awaited()
assert ClientManager._instances["ref_count"] == 1
@pytest.mark.asyncio
async def test_release_client_noop_for_none(self):
"""Releasing None should be a safe no-op."""
mock_client = AsyncMock()
mock_db = MagicMock()
ClientManager._instances = {
"client": mock_client,
"db": mock_db,
"ref_count": 1,
}
await ClientManager.release_client(None)
mock_client.close.assert_not_awaited()
assert ClientManager._instances["ref_count"] == 1
@@ -0,0 +1,567 @@
"""Unit tests for MongoVectorDBStorage's deferred-embedding flush pipeline.
All tests use mocks — no running MongoDB instance required.
Mirrors the structure of tests/kg/opensearch_impl/test_opensearch_storage.py's
TestVectorStorageBatching to keep behaviour aligned across backends.
"""
import asyncio
import os
import numpy as np
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
pytest.importorskip(
"pymongo",
reason="pymongo is required for Mongo storage tests",
)
from pymongo import UpdateOne # type: ignore
from lightrag.kg.mongo_impl import MongoVectorDBStorage
pytestmark = pytest.mark.offline
# ---------------------------------------------------------------------------
# Fixtures and helpers
# ---------------------------------------------------------------------------
class MockEmbeddingFunc:
def __init__(self, dim=8):
self.embedding_dim = dim
self.max_token_size = 512
self.model_name = "mock-embed"
async def __call__(self, texts, **kwargs):
return np.random.rand(len(texts), self.embedding_dim).astype(np.float32)
class CountingEmbeddingFunc(MockEmbeddingFunc):
def __init__(self, dim=8, fail_times=0):
super().__init__(dim=dim)
self.fail_times = fail_times
self.call_count = 0
self.batches: list[list[str]] = []
self.texts: list[str] = []
async def __call__(self, texts, **kwargs):
self.call_count += 1
batch = list(texts)
self.batches.append(batch)
self.texts.extend(batch)
if self.fail_times > 0:
self.fail_times -= 1
raise RuntimeError("embedding failed")
return await super().__call__(texts, **kwargs)
class _AsyncCursor:
def __init__(self, docs):
self._docs = list(docs)
async def to_list(self, length=None):
return list(self._docs)
@pytest.fixture(autouse=True)
def patch_namespace_lock(monkeypatch):
"""Cache real asyncio.Locks per (namespace, workspace) for shared semantics.
Also unconditionally clears ``MONGODB_WORKSPACE`` so tests are insulated
from shell-level env leakage: ``final_namespace`` depends on this var,
and a leaked value (e.g. ``space2``) silently collapses distinct
workspaces into one namespace. Tests that need an override set it
explicitly via ``patch.dict(os.environ, ...)``.
"""
monkeypatch.delenv("MONGODB_WORKSPACE", raising=False)
cache: dict[tuple[str, str | None], asyncio.Lock] = {}
def factory(namespace, workspace=None, enable_logging=False):
key = (namespace, workspace or "")
lock = cache.get(key)
if lock is None:
lock = asyncio.Lock()
cache[key] = lock
return lock
with patch("lightrag.kg.mongo_impl.get_namespace_lock", side_effect=factory):
yield cache
def _make_storage(
embed_func,
*,
namespace="entities",
workspace="test",
meta_fields=None,
):
if meta_fields is None:
meta_fields = {"content", "entity_name", "src_id", "tgt_id"}
storage = MongoVectorDBStorage(
namespace=namespace,
workspace=workspace,
global_config={
"embedding_batch_num": 10,
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.2},
},
embedding_func=embed_func,
meta_fields=meta_fields,
)
# Wire a fake AsyncCollection (the only Mongo surface our code touches).
storage._data = MagicMock()
storage._data.bulk_write = AsyncMock()
storage._data.delete_many = AsyncMock(return_value=MagicMock(deleted_count=0))
storage._data.find_one = AsyncMock(return_value=None)
storage._data.find = MagicMock(return_value=_AsyncCursor([]))
storage.db = MagicMock() # non-None so finalize releases it
from lightrag.kg.mongo_impl import get_namespace_lock
storage._flush_lock = get_namespace_lock(
namespace=storage.final_namespace, workspace=""
)
return storage
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_upsert_buffers_without_embedding():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello"}, "v2": {"content": "world"}})
assert embed.call_count == 0
assert set(s._pending_vector_docs.keys()) == {"v1", "v2"}
assert s._pending_vector_docs["v1"].vector is None
s._data.bulk_write.assert_not_called()
@pytest.mark.asyncio
async def test_index_done_callback_triggers_flush():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello"}, "v2": {"content": "world"}})
await s.index_done_callback()
assert embed.call_count == 1
s._data.bulk_write.assert_called_once()
ops, kwargs = (
s._data.bulk_write.call_args.args[0],
s._data.bulk_write.call_args.kwargs,
)
assert kwargs.get("ordered") is False
assert all(isinstance(op, UpdateOne) for op in ops)
assert len(ops) == 2
assert s._pending_vector_docs == {}
@pytest.mark.asyncio
async def test_repeated_upsert_same_id_embeds_once():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "first"}})
await s.upsert({"v1": {"content": "second"}})
await s.upsert({"v1": {"content": "third"}})
await s.index_done_callback()
assert embed.call_count == 1
assert embed.texts == ["third"]
s._data.bulk_write.assert_called_once()
@pytest.mark.asyncio
async def test_deferred_embeddings_respect_batch_size():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._max_batch_size = 2
await s.upsert({f"v{i}": {"content": f"doc {i}"} for i in range(5)})
await s.index_done_callback()
assert embed.call_count == 3
assert [len(b) for b in embed.batches] == [2, 2, 1]
@pytest.mark.asyncio
async def test_get_vectors_by_ids_lazy_embed_then_reuse_in_flush():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello"}})
vectors = await s.get_vectors_by_ids(["v1"])
assert "v1" in vectors
assert embed.call_count == 1
assert s._pending_vector_docs["v1"].vector is not None
await s.index_done_callback()
assert embed.call_count == 1
s._data.bulk_write.assert_called_once()
@pytest.mark.asyncio
async def test_flush_failure_keeps_buffer_no_double_embed_on_retry():
embed = CountingEmbeddingFunc(fail_times=1)
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello"}})
with pytest.raises(RuntimeError, match="embedding failed"):
await s.index_done_callback()
assert "v1" in s._pending_vector_docs
assert s._pending_vector_docs["v1"].vector is None
s._data.bulk_write.assert_not_called()
await s.index_done_callback()
assert embed.call_count == 2
s._data.bulk_write.assert_called_once()
assert s._pending_vector_docs == {}
@pytest.mark.asyncio
async def test_server_write_failure_keeps_buffer():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._data.bulk_write.side_effect = RuntimeError("mongo down")
await s.upsert({"v1": {"content": "hello"}})
with pytest.raises(RuntimeError, match="mongo down"):
await s.index_done_callback()
assert "v1" in s._pending_vector_docs
assert s._pending_vector_docs["v1"].vector is not None
s._data.bulk_write.side_effect = None
await s.index_done_callback()
assert embed.call_count == 1
assert s._pending_vector_docs == {}
@pytest.mark.asyncio
async def test_finalize_raises_when_buffer_unflushed_and_still_releases_client():
"""finalize() must release the Mongo client even when the flush fails."""
from lightrag.kg.mongo_impl import ClientManager
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._data.bulk_write.side_effect = RuntimeError("mongo down")
await s.upsert({"v1": {"content": "hello"}})
with patch.object(ClientManager, "release_client", new=AsyncMock()) as rel:
with pytest.raises(RuntimeError, match="finalize.*flush raised") as exc_info:
await s.finalize()
rel.assert_awaited_once()
# Operator-diagnostic counts must appear in the message so the buffered
# data loss is auditable from the log alone (1 upsert pre-loaded, 0 deletes).
msg = str(exc_info.value)
assert "1 pending upserts" in msg
assert "0 pending deletes" in msg
# Client references cleared so a second finalize doesn't release twice.
assert s.db is None
@pytest.mark.asyncio
async def test_delete_then_upsert_same_id_keeps_upsert():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.delete(["v1"])
assert "v1" in s._pending_vector_deletes
await s.upsert({"v1": {"content": "hello"}})
assert "v1" in s._pending_vector_docs
assert "v1" not in s._pending_vector_deletes
await s.index_done_callback()
ops = s._data.bulk_write.call_args.args[0]
assert all(isinstance(op, UpdateOne) for op in ops)
@pytest.mark.asyncio
async def test_upsert_then_delete_same_id_keeps_delete():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello"}})
await s.delete(["v1"])
assert "v1" not in s._pending_vector_docs
assert "v1" in s._pending_vector_deletes
await s.index_done_callback()
s._data.bulk_write.assert_not_called()
s._data.delete_many.assert_awaited_once_with({"_id": {"$in": ["v1"]}})
assert s._pending_vector_deletes == set()
@pytest.mark.asyncio
async def test_flush_uses_update_one_and_delete_many_mix():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"u1": {"content": "u1"}, "u2": {"content": "u2"}})
await s.delete(["d1", "d2"])
await s.index_done_callback()
ops = s._data.bulk_write.call_args.args[0]
assert sum(isinstance(op, UpdateOne) for op in ops) == 2
s._data.delete_many.assert_awaited_once()
delete_filter = s._data.delete_many.await_args.args[0]
assert set(delete_filter["_id"]["$in"]) == {"d1", "d2"}
@pytest.mark.asyncio
async def test_flush_chunks_deletes_by_record_count():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._max_delete_records_per_batch = 2
await s.delete([f"d{i}" for i in range(5)])
await s.index_done_callback()
assert s._data.delete_many.await_count == 3
chunk_sizes = sorted(
len(call.args[0]["_id"]["$in"]) for call in s._data.delete_many.await_args_list
)
assert chunk_sizes == [1, 2, 2]
assert s._pending_vector_deletes == set()
@pytest.mark.asyncio
async def test_delete_entity_relation_raises_on_server_failure():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._data.find = MagicMock(
return_value=_AsyncCursor([{"_id": "rel1"}, {"_id": "rel2"}])
)
s._data.delete_many = AsyncMock(side_effect=RuntimeError("mongo delete failed"))
with pytest.raises(RuntimeError, match="mongo delete failed"):
await s.delete_entity_relation("X")
@pytest.mark.asyncio
async def test_delete_entity_relation_prunes_pending_buffer():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert(
{
"rel-A-B": {"content": "A → B", "src_id": "A", "tgt_id": "B"},
"rel-C-D": {"content": "C → D", "src_id": "C", "tgt_id": "D"},
}
)
s._data.find = MagicMock(return_value=_AsyncCursor([]))
await s.delete_entity_relation("A")
assert "rel-A-B" not in s._pending_vector_docs
assert "rel-C-D" in s._pending_vector_docs
@pytest.mark.asyncio
async def test_get_by_id_buffer_excludes_vector():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "hello", "entity_name": "E1"}})
doc = await s.get_by_id("v1")
assert doc is not None
assert doc["id"] == "v1"
assert doc.get("entity_name") == "E1"
assert "vector" not in doc
s._data.find_one.assert_not_called()
@pytest.mark.asyncio
async def test_get_by_id_fallback_projects_out_vector():
"""Server-side find_one must request projection={'vector': 0}."""
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._data.find_one = AsyncMock(
return_value={"_id": "v9", "entity_name": "X", "created_at": 0}
)
doc = await s.get_by_id("v9")
assert doc is not None
assert "vector" not in doc
args, kwargs = s._data.find_one.call_args.args, s._data.find_one.call_args.kwargs
# projection is positional arg #2 in Mongo's API.
projection = args[1] if len(args) > 1 else kwargs.get("projection")
assert projection == {"vector": 0}
@pytest.mark.asyncio
async def test_get_by_ids_fallback_projects_out_vector():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
s._data.find = MagicMock(
return_value=_AsyncCursor(
[{"_id": "a", "entity_name": "A"}, {"_id": "b", "entity_name": "B"}]
)
)
docs = await s.get_by_ids(["a", "b"])
assert len(docs) == 2
assert all("vector" not in d for d in docs if d)
args, kwargs = s._data.find.call_args.args, s._data.find.call_args.kwargs
projection = args[1] if len(args) > 1 else kwargs.get("projection")
assert projection == {"vector": 0}
@pytest.mark.asyncio
async def test_get_by_id_returns_none_for_pending_delete():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.delete(["v1"])
assert await s.get_by_id("v1") is None
s._data.find_one.assert_not_called()
@pytest.mark.asyncio
async def test_env_workspace_override_shares_flush_lock(patch_namespace_lock):
cache = patch_namespace_lock
embed = CountingEmbeddingFunc()
with patch.dict(os.environ, {"MONGODB_WORKSPACE": "shared_ws"}, clear=False):
a = _make_storage(embed, workspace="caller_a")
b = _make_storage(embed, workspace="caller_b")
assert a.final_namespace == b.final_namespace == "shared_ws_entities"
assert a._flush_lock is b._flush_lock
assert len([k for k in cache if k[0] == "shared_ws_entities"]) == 1
@pytest.mark.asyncio
async def test_same_workspace_param_shares_flush_lock(patch_namespace_lock):
"""Plain ctor path (no MONGODB_WORKSPACE env): same workspace → shared lock.
Companion to ``test_env_workspace_override_shares_flush_lock``; together
they cover both ways two instances can land on the same final_namespace.
The autouse fixture clears MONGODB_WORKSPACE so this exercises the plain
constructor path, not the env-override path.
"""
cache = patch_namespace_lock
embed = CountingEmbeddingFunc()
a = _make_storage(embed, workspace="caller")
b = _make_storage(embed, workspace="caller")
assert a.final_namespace == b.final_namespace == "caller_entities"
assert a._flush_lock is b._flush_lock
assert len([k for k in cache if k[0] == "caller_entities"]) == 1
@pytest.mark.asyncio
async def test_distinct_namespaces_get_independent_locks():
embed = CountingEmbeddingFunc()
a = _make_storage(embed, workspace="a")
b = _make_storage(embed, workspace="b")
assert a.final_namespace != b.final_namespace
assert a._flush_lock is not b._flush_lock
@pytest.mark.asyncio
async def test_concurrent_upsert_and_flush_serialize_on_lock():
"""upsert() and index_done_callback() racing on the same namespace must
not corrupt the buffer or split a single doc across two embed calls.
Drives a slow embed (asyncio.Event-gated) so the flush genuinely holds
the lock while a second coroutine attempts upsert mid-flight. Asserts:
- the late upsert lands in the buffer (not silently dropped)
- it is *not* embedded by the in-flight flush (still pending after)
- a follow-up flush picks it up cleanly with exactly one extra embed
Note: we replace ``s.embedding_func`` directly (not ``patch.object`` on
``embed.__call__``) because Python dispatches ``embed(...)`` through
``type(embed).__call__``, bypassing any instance-level patch.
"""
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
embed_gate = asyncio.Event()
flush_entered = asyncio.Event()
original_embed = s.embedding_func
async def gated_embed(texts, **kwargs):
flush_entered.set()
await embed_gate.wait()
return await original_embed(texts, **kwargs)
await s.upsert({"v1": {"content": "first"}})
s.embedding_func = gated_embed
try:
flush_task = asyncio.create_task(s.index_done_callback())
await flush_entered.wait() # flush is now holding _flush_lock
# This upsert must wait on _flush_lock; schedule it concurrently.
late_upsert = asyncio.create_task(s.upsert({"v2": {"content": "late"}}))
# Give the event loop a chance to actually start late_upsert and
# confirm it is blocked on the lock (still no v2 in buffer).
for _ in range(5):
await asyncio.sleep(0)
assert "v2" not in s._pending_vector_docs
assert not late_upsert.done()
embed_gate.set()
await flush_task
await late_upsert
finally:
s.embedding_func = original_embed
# Flush embedded v1 only; v2 arrived after the docs_to_embed snapshot.
assert embed.call_count == 1
assert embed.batches == [["first"]]
assert "v1" not in s._pending_vector_docs # flushed
assert "v2" in s._pending_vector_docs # still buffered
s._data.bulk_write.assert_called_once()
# Next flush picks up the late upsert without re-embedding v1.
await s.index_done_callback()
assert embed.call_count == 2
assert embed.batches[-1] == ["late"]
assert s._pending_vector_docs == {}
assert s._data.bulk_write.call_count == 2
@pytest.mark.asyncio
async def test_drop_clears_pending_buffers():
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
with patch.object(s, "create_vector_index_if_not_exists", new=AsyncMock()):
await s.upsert({"v1": {"content": "hello"}})
await s.delete(["v2"])
assert s._pending_vector_docs and s._pending_vector_deletes
result = await s.drop()
assert result["status"] == "success"
assert s._pending_vector_docs == {}
assert s._pending_vector_deletes == set()
@pytest.mark.offline
@pytest.mark.asyncio
async def test_drop_pending_index_ops_clears_buffers():
"""On an internal-error abort the pipeline calls drop_pending_index_ops to
discard buffered upserts/deletes without flushing them (PR #3187)."""
embed = CountingEmbeddingFunc()
s = _make_storage(embed)
await s.upsert({"v1": {"content": "x"}, "v2": {"content": "y"}})
s._pending_vector_deletes.add("old-id")
assert s._pending_vector_docs
await s.drop_pending_index_ops()
assert not s._pending_vector_docs
assert not s._pending_vector_deletes
+656
View File
@@ -0,0 +1,656 @@
import pytest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
pytest.importorskip(
"pymongo",
reason="pymongo is required for Mongo storage tests",
)
from pymongo.errors import PyMongoError, BulkWriteError, DuplicateKeyError
from lightrag.kg.mongo_impl import (
MongoDocStatusStorage,
MongoGraphStorage,
_canonical_edge_endpoints,
)
pytestmark = pytest.mark.offline
class _AsyncCursor:
def __init__(self, docs):
self._docs = list(docs)
def limit(self, n: int):
self._docs = self._docs[:n]
return self
def __aiter__(self):
self._iter = iter(self._docs)
return self
async def __anext__(self):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration
class TestMongoGraphStorage:
def _make_storage(self):
storage = MongoGraphStorage.__new__(MongoGraphStorage)
storage.workspace = "test"
storage.global_config = {"max_graph_nodes": 1000}
storage._edge_collection_name = "test_edges"
storage.collection = SimpleNamespace()
storage.edge_collection = SimpleNamespace()
return storage
@pytest.mark.asyncio
async def test_get_knowledge_graph_all_backfills_isolated_nodes_when_truncated(
self,
):
storage = self._make_storage()
storage.collection.count_documents = AsyncMock(return_value=5)
storage.edge_collection.aggregate = AsyncMock(
return_value=_AsyncCursor(
[{"_id": "A", "degree": 1}, {"_id": "B", "degree": 1}]
)
)
def collection_find_side_effect(query, projection=None):
if query == {"_id": {"$nin": ["A", "B"]}}:
return _AsyncCursor(
[
{"_id": "C", "entity_type": "person"},
{"_id": "D", "entity_type": "person"},
{"_id": "E", "entity_type": "person"},
]
)
if query == {"_id": {"$in": ["A", "B", "C", "D"]}}:
return _AsyncCursor(
[
{"_id": "B", "entity_type": "person"},
{"_id": "D", "entity_type": "person"},
{"_id": "A", "entity_type": "person"},
{"_id": "C", "entity_type": "person"},
]
)
raise AssertionError(f"Unexpected node query: {query}")
storage.collection.find = Mock(side_effect=collection_find_side_effect)
storage.edge_collection.find = Mock(
return_value=_AsyncCursor(
[
{
"source_node_id": "A",
"target_node_id": "B",
"relationship": "knows",
}
]
)
)
result = await storage.get_knowledge_graph_all_by_degree(
max_depth=2, max_nodes=4
)
assert result.is_truncated is True
assert [node.id for node in result.nodes] == ["A", "B", "C", "D"]
assert len(result.edges) == 1
assert result.edges[0].source == "A"
assert result.edges[0].target == "B"
class TestMongoEdgeKey:
"""Canonical edge-endpoint writes, duplicate-key retries, and the migration."""
def _make_storage(self):
s = MongoGraphStorage.__new__(MongoGraphStorage)
s.workspace = "test"
s.namespace = "chunk_entity_relation"
s.global_config = {}
s._edge_collection_name = "test_edges"
s._max_upsert_payload_bytes = 16 * 1024 * 1024
s._max_upsert_records_per_batch = 128
s.collection = SimpleNamespace(update_one=AsyncMock())
s.edge_collection = SimpleNamespace()
return s
def test_canonical_edge_endpoints_are_direction_independent(self):
assert _canonical_edge_endpoints("B", "A") == _canonical_edge_endpoints(
"A", "B"
)
assert _canonical_edge_endpoints("B", "A") == ("A", "B")
def test_canonical_endpoints_never_collide_across_delimiter(self):
# Distinct pairs that a delimiter-joined key could conflate must stay
# distinct as separate (lo, hi) fields.
assert _canonical_edge_endpoints("A\x1fB", "C") != _canonical_edge_endpoints(
"A", "B\x1fC"
)
@pytest.mark.asyncio
async def test_upsert_edge_filters_and_sets_canonical_endpoints(self):
s = self._make_storage()
s.edge_collection.update_one = AsyncMock()
await s.upsert_edge("B", "A", {"weight": 1.0, "source_id": "c1<SEP>c2"})
args, kwargs = s.edge_collection.update_one.call_args
filt, update = args[0], args[1]
lo, hi = _canonical_edge_endpoints("B", "A")
assert filt == {"edge_lo": lo, "edge_hi": hi}
assert update["$set"]["edge_lo"] == lo
assert update["$set"]["edge_hi"] == hi
assert update["$set"]["source_node_id"] == "B"
assert update["$set"]["target_node_id"] == "A"
assert update["$set"]["source_ids"] == ["c1", "c2"]
assert kwargs.get("upsert") is True
@pytest.mark.asyncio
async def test_upsert_edge_retries_once_on_duplicate_key(self):
s = self._make_storage()
s.edge_collection.update_one = AsyncMock(
side_effect=[DuplicateKeyError("E11000 dup"), None]
)
await s.upsert_edge("A", "B", {"weight": 1.0})
assert s.edge_collection.update_one.await_count == 2
@pytest.mark.asyncio
async def test_upsert_edge_reraises_on_persistent_duplicate(self):
s = self._make_storage()
s.edge_collection.update_one = AsyncMock(
side_effect=DuplicateKeyError("E11000 dup")
)
with pytest.raises(DuplicateKeyError):
await s.upsert_edge("A", "B", {"weight": 1.0})
assert s.edge_collection.update_one.await_count == 2
@pytest.mark.asyncio
async def test_upsert_edges_batch_dedupes_reciprocal_and_sets_endpoints(self):
s = self._make_storage()
calls = []
async def fake_bulk(collection, ops, **kwargs):
calls.append((collection, ops))
with patch(
"lightrag.kg.mongo_impl._run_batched_bulk_write",
new=AsyncMock(side_effect=fake_bulk),
):
await s.upsert_edges_batch(
[("A", "B", {"weight": 1.0}), ("B", "A", {"weight": 2.0})]
)
# Last call is the edge bulk (first is the node-placeholder bulk).
edge_collection, edge_ops = calls[-1]
assert edge_collection is s.edge_collection
assert len(edge_ops) == 1 # reciprocal pair collapsed to one op
op, _bytes, _logid = edge_ops[0]
lo, hi = _canonical_edge_endpoints("A", "B")
assert op._filter == {"edge_lo": lo, "edge_hi": hi}
assert op._doc["$set"]["edge_lo"] == lo
assert op._doc["$set"]["edge_hi"] == hi
assert op._doc["$set"]["weight"] == 2.0 # last-write-wins
@pytest.mark.asyncio
async def test_upsert_edges_batch_retries_on_duplicate_bulk_error(self):
s = self._make_storage()
seq = []
async def fake_bulk(collection, ops, **kwargs):
seq.append(collection)
# Fail the first edge bulk with an all-11000 BulkWriteError.
if collection is s.edge_collection and seq.count(s.edge_collection) == 1:
raise BulkWriteError({"writeErrors": [{"code": 11000}]})
with patch(
"lightrag.kg.mongo_impl._run_batched_bulk_write",
new=AsyncMock(side_effect=fake_bulk),
):
await s.upsert_edges_batch([("A", "B", {"weight": 1.0})])
# node bulk + edge bulk (raises) + edge bulk (retry succeeds)
assert seq.count(s.edge_collection) == 2
@pytest.mark.asyncio
async def test_upsert_edges_batch_reraises_non_duplicate_bulk_error(self):
s = self._make_storage()
async def fake_bulk(collection, ops, **kwargs):
if collection is s.edge_collection:
raise BulkWriteError({"writeErrors": [{"code": 121}]})
with patch(
"lightrag.kg.mongo_impl._run_batched_bulk_write",
new=AsyncMock(side_effect=fake_bulk),
):
with pytest.raises(BulkWriteError):
await s.upsert_edges_batch([("A", "B", {"weight": 1.0})])
@pytest.mark.asyncio
async def test_upsert_edges_batch_surfaces_non_dup_error_hidden_behind_dup(self):
"""Under ordered=True the bulk aborts at the first failing op, so a
non-11000 error sitting *behind* a leading 11000 race is not reported on
the first pass. It must still surface — on the retry, where the leading
dup has resolved to an update and the real error now leads."""
s = self._make_storage()
edge_calls = []
async def fake_bulk(collection, ops, **kwargs):
if collection is s.edge_collection:
edge_calls.append(collection)
if len(edge_calls) == 1:
# First pass: ordered abort reports only the leading dup race.
raise BulkWriteError({"writeErrors": [{"code": 11000}]})
# Retry: dup resolved to an update, the hidden error now leads.
raise BulkWriteError({"writeErrors": [{"code": 121}]})
with patch(
"lightrag.kg.mongo_impl._run_batched_bulk_write",
new=AsyncMock(side_effect=fake_bulk),
):
with pytest.raises(BulkWriteError) as exc:
await s.upsert_edges_batch([("A", "B", {"weight": 1.0})])
# Retried once (dup-only first pass), then the non-dup error re-raised.
assert len(edge_calls) == 2
assert exc.value.details["writeErrors"][0]["code"] == 121
@pytest.mark.asyncio
async def test_upsert_edges_batch_reraises_write_concern_error(self):
"""A writeConcern-only BulkWriteError (empty writeErrors) is a durability
failure — it must surface, not be masked by the duplicate-key retry."""
s = self._make_storage()
edge_calls = []
async def fake_bulk(collection, ops, **kwargs):
if collection is s.edge_collection:
edge_calls.append(collection)
raise BulkWriteError(
{"writeErrors": [], "writeConcernErrors": [{"code": 64}]}
)
with patch(
"lightrag.kg.mongo_impl._run_batched_bulk_write",
new=AsyncMock(side_effect=fake_bulk),
):
with pytest.raises(BulkWriteError):
await s.upsert_edges_batch([("A", "B", {"weight": 1.0})])
assert len(edge_calls) == 1 # not retried
@pytest.mark.asyncio
async def test_edge_migration_skips_when_index_exists(self):
s = self._make_storage()
s.edge_collection.list_indexes = AsyncMock(
return_value=SimpleNamespace(
to_list=AsyncMock(return_value=[{"name": "test_edge_endpoints_unique"}])
)
)
s.edge_collection.aggregate = AsyncMock()
s.edge_collection.create_index = AsyncMock()
await s.create_edge_indexes_and_migrate_if_not_exists()
s.edge_collection.aggregate.assert_not_awaited()
s.edge_collection.create_index.assert_not_awaited()
@pytest.mark.asyncio
async def test_edge_migration_dedupes_backfills_and_builds_unique_index(self):
s = self._make_storage()
s.edge_collection.list_indexes = AsyncMock(
return_value=SimpleNamespace(
to_list=AsyncMock(return_value=[{"name": "_id_"}])
)
)
# One duplicate group for edge {A,B}: two docs with distinct provenance
# AND distinct relation payload (description/keywords/weight).
group = {
"_id": {"lo": "A", "hi": "B"},
"count": 2,
"docs": [
{
"_id": 1,
"source_id": "c1",
"source_ids": ["c1"],
"file_path": "f1",
"description": "d1",
"keywords": "alpha,beta",
"weight": 1.0,
"created_at": 10,
},
{
"_id": 2,
"source_id": "c2",
"source_ids": ["c2"],
"file_path": "f2",
"description": "d2",
"keywords": "beta,gamma",
"weight": 2.0,
"created_at": 20,
},
],
}
s.edge_collection.estimated_document_count = AsyncMock(return_value=4)
s.edge_collection.aggregate = AsyncMock(return_value=_AsyncCursor([group]))
s.edge_collection.update_one = AsyncMock()
s.edge_collection.delete_many = AsyncMock()
s.edge_collection.update_many = AsyncMock(
return_value=SimpleNamespace(modified_count=3)
)
s.edge_collection.create_index = AsyncMock()
with patch("lightrag.kg.mongo_impl.logger") as mock_logger:
await s.create_edge_indexes_and_migrate_if_not_exists()
# OpenSearch-aligned start/complete log wording.
info_lines = [c.args[0] for c in mock_logger.info.call_args_list]
assert any(
line.startswith("[test] Starting canonical edge migration for ")
and "(~4 edges to scan)" in line
for line in info_lines
)
assert any(
"Canonical edge migration complete for" in line
and "scanned 4, deduped 1, backfilled 3" in line
for line in info_lines
)
# Survivor is the newest (created_at 20 → _id 2); the full relation
# payload is merged in before the duplicate is deleted (no evidence lost).
surv_filter, surv_update = s.edge_collection.update_one.call_args[0]
set_fields = surv_update["$set"]
assert surv_filter == {"_id": 2}
assert set_fields["source_ids"] == ["c1", "c2"]
assert set_fields["file_path"] == "f1<SEP>f2"
assert set_fields["description"] == "d1<SEP>d2" # distinct descriptions joined
assert set_fields["keywords"] == "alpha,beta,gamma" # comma set-union, sorted
assert set_fields["weight"] == 3.0 # summed (1.0 + 2.0), idempotent
# The other duplicate is deleted.
assert s.edge_collection.delete_many.call_args[0][0] == {"_id": {"$in": [1]}}
# Compound unique partial index built as the completion flag.
ci_args = s.edge_collection.create_index.call_args.args
ci_kwargs = s.edge_collection.create_index.call_args.kwargs
assert ci_args[0] == [("edge_lo", 1), ("edge_hi", 1)]
assert ci_kwargs["name"] == "test_edge_endpoints_unique"
assert ci_kwargs["unique"] is True
assert set(ci_kwargs["partialFilterExpression"]) == {"edge_lo", "edge_hi"}
async def _run_dedupe(self, s, group):
"""Run _dedupe_legacy_edges over a single group; return the survivor $set."""
s.edge_collection.aggregate = AsyncMock(return_value=_AsyncCursor([group]))
s.edge_collection.update_one = AsyncMock()
s.edge_collection.delete_many = AsyncMock()
await s._dedupe_legacy_edges()
if s.edge_collection.update_one.call_args is None:
return None
return s.edge_collection.update_one.call_args[0][1]["$set"]
@pytest.mark.asyncio
async def test_dedupe_coerces_string_weights(self):
"""Legacy string weights (graph API is dict[str, str]) must not crash the
migration; they coerce to float and sum."""
s = self._make_storage()
group = {
"_id": {"lo": "A", "hi": "B"},
"count": 2,
"docs": [
{"_id": 1, "source_ids": ["c1"], "weight": "1.0", "created_at": 10},
{"_id": 2, "source_ids": ["c2"], "weight": "2.0", "created_at": 20},
],
}
set_fields = await self._run_dedupe(s, group)
assert set_fields["weight"] == 3.0 # coerced floats summed, no TypeError
@pytest.mark.asyncio
async def test_dedupe_merge_is_idempotent_on_retry(self):
"""A fail-fast retry that re-processes an already-merged survivor must
produce the same fields (no double-summed weight, no grown description)."""
s = self._make_storage()
original = [
{
"_id": 1,
"source_id": "c1",
"source_ids": ["c1"],
"file_path": "f1",
"description": "d1",
"keywords": "alpha,beta",
"weight": 1.0,
"created_at": 10,
},
{
"_id": 2,
"source_id": "c2",
"source_ids": ["c2"],
"file_path": "f2",
"description": "d2",
"keywords": "beta,gamma",
"weight": 2.0,
"created_at": 20,
},
]
first = await self._run_dedupe(
s, {"_id": {"lo": "A", "hi": "B"}, "count": 2, "docs": list(original)}
)
# Retry: survivor (_id 2) already carries the merged fields, the other
# duplicate is still present (the previous delete never committed).
survivor_merged = {"_id": 2, "created_at": 20, **first}
second = await self._run_dedupe(
s,
{
"_id": {"lo": "A", "hi": "B"},
"count": 2,
"docs": [original[0], survivor_merged],
},
)
# Summed once (1.0 + 2.0); the retry must NOT re-add the duplicate's
# weight (its source_ids are already folded into the survivor).
assert second["weight"] == first["weight"] == 3.0
assert second["description"] == first["description"] == "d1<SEP>d2"
assert second["source_ids"] == first["source_ids"] == ["c1", "c2"]
assert second["file_path"] == first["file_path"] == "f1<SEP>f2"
assert second["keywords"] == first["keywords"] == "alpha,beta,gamma"
class TestMongoEdgeReadCanonicalFilter:
"""Exact single-edge reads/deletes use the canonical (edge_lo, edge_hi)
filter so they hit the compound unique index instead of the bidirectional
``$or``. Per-node scans (node_degree/get_node_edges/delete_node) intentionally
keep ``$or`` — edge_hi is not a compound-index prefix, so they gain nothing."""
def _make_storage(self, max_delete_records_per_batch=1000):
s = MongoGraphStorage.__new__(MongoGraphStorage)
s.workspace = "test"
s.namespace = "chunk_entity_relation"
s._edge_collection_name = "test_edges"
s._max_delete_records_per_batch = max_delete_records_per_batch
s.edge_collection = SimpleNamespace()
return s
@pytest.mark.asyncio
async def test_has_edge_uses_canonical_filter(self):
s = self._make_storage()
s.edge_collection.find_one = AsyncMock(return_value={"_id": "x"})
assert await s.has_edge("B", "A") is True
filt, projection = s.edge_collection.find_one.call_args[0]
lo, hi = _canonical_edge_endpoints("B", "A")
assert filt == {"edge_lo": lo, "edge_hi": hi}
assert projection == {"_id": 1}
@pytest.mark.asyncio
async def test_has_edge_direction_independent(self):
s = self._make_storage()
s.edge_collection.find_one = AsyncMock(return_value=None)
await s.has_edge("A", "B")
forward = s.edge_collection.find_one.call_args[0][0]
await s.has_edge("B", "A")
backward = s.edge_collection.find_one.call_args[0][0]
assert forward == backward
@pytest.mark.asyncio
async def test_get_edge_uses_canonical_filter(self):
s = self._make_storage()
s.edge_collection.find_one = AsyncMock(return_value={"weight": 1.0})
await s.get_edge("B", "A")
filt = s.edge_collection.find_one.call_args[0][0]
lo, hi = _canonical_edge_endpoints("B", "A")
assert filt == {"edge_lo": lo, "edge_hi": hi}
@pytest.mark.asyncio
async def test_remove_edges_uses_canonical_pairs_and_collapses_reciprocals(self):
s = self._make_storage()
s.edge_collection.delete_many = AsyncMock()
# (A,B) and its reciprocal (B,A) must collapse to a single clause.
await s.remove_edges([("A", "B"), ("B", "A"), ("C", "D")])
query = s.edge_collection.delete_many.call_args[0][0]
lo_ab, hi_ab = _canonical_edge_endpoints("A", "B")
lo_cd, hi_cd = _canonical_edge_endpoints("C", "D")
assert query == {
"$or": [
{"edge_lo": lo_ab, "edge_hi": hi_ab},
{"edge_lo": lo_cd, "edge_hi": hi_cd},
]
}
@pytest.mark.asyncio
async def test_remove_edges_empty_is_noop(self):
s = self._make_storage()
s.edge_collection.delete_many = AsyncMock()
await s.remove_edges([])
s.edge_collection.delete_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_remove_edges_chunks_large_or_by_record_cap(self):
# 5 distinct edges with a cap of 2 → 3 delete_many calls (2 + 2 + 1),
# and every input edge is covered exactly once across the chunks.
s = self._make_storage(max_delete_records_per_batch=2)
s.edge_collection.delete_many = AsyncMock()
edges = [(f"n{i}", f"n{i + 1}") for i in range(5)]
await s.remove_edges(edges)
calls = s.edge_collection.delete_many.call_args_list
assert [len(c[0][0]["$or"]) for c in calls] == [2, 2, 1]
clauses = [clause for c in calls for clause in c[0][0]["$or"]]
expected = [
{"edge_lo": lo, "edge_hi": hi}
for lo, hi in (_canonical_edge_endpoints(src, tgt) for src, tgt in edges)
]
assert clauses == expected
@pytest.mark.asyncio
async def test_remove_edges_non_positive_cap_disables_chunking(self):
# A non-positive cap means "no record-count splitting": one delete_many.
s = self._make_storage(max_delete_records_per_batch=0)
s.edge_collection.delete_many = AsyncMock()
edges = [(f"n{i}", f"n{i + 1}") for i in range(5)]
await s.remove_edges(edges)
assert s.edge_collection.delete_many.await_count == 1
assert len(s.edge_collection.delete_many.call_args[0][0]["$or"]) == 5
class TestMongoDocStatusLookup:
"""Cover the Mongo-native overrides for basename / content_hash lookups."""
def _make_storage(self):
storage = MongoDocStatusStorage.__new__(MongoDocStatusStorage)
storage.workspace = "test"
storage.global_config = {}
storage._collection_name = "test_doc_status"
storage._data = SimpleNamespace()
return storage
@pytest.mark.asyncio
async def test_get_doc_by_file_basename_returns_tuple_on_hit(self):
storage = self._make_storage()
storage._data.find_one = AsyncMock(
return_value={
"_id": "doc-1",
"file_path": "report.pdf",
"status": "processed",
}
)
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"
storage._data.find_one.assert_awaited_once_with({"file_path": "report.pdf"})
@pytest.mark.asyncio
async def test_get_doc_by_file_basename_empty_returns_none_without_query(self):
storage = self._make_storage()
storage._data.find_one = AsyncMock()
assert await storage.get_doc_by_file_basename("") is None
storage._data.find_one.assert_not_called()
@pytest.mark.asyncio
async def test_get_doc_by_file_basename_unknown_source_sentinel(self):
# Lookup for the sentinel must not match real rows that happen to have
# file_path == "unknown_source".
storage = self._make_storage()
storage._data.find_one = AsyncMock()
assert await storage.get_doc_by_file_basename("unknown_source") is None
storage._data.find_one.assert_not_called()
@pytest.mark.asyncio
async def test_get_doc_by_file_basename_miss_returns_none(self):
storage = self._make_storage()
storage._data.find_one = AsyncMock(return_value=None)
assert await storage.get_doc_by_file_basename("missing.pdf") is None
@pytest.mark.asyncio
async def test_get_doc_by_content_hash_returns_tuple_on_hit(self):
storage = self._make_storage()
storage._data.find_one = AsyncMock(
return_value={
"_id": "doc-1",
"file_path": "report.pdf",
"content_hash": "abc123",
"status": "processed",
}
)
result = await storage.get_doc_by_content_hash("abc123")
assert result is not None
doc_id, doc = result
assert doc_id == "doc-1"
assert doc["content_hash"] == "abc123"
storage._data.find_one.assert_awaited_once_with({"content_hash": "abc123"})
@pytest.mark.asyncio
async def test_get_doc_by_content_hash_empty_returns_none_without_query(self):
# Empty hash must short-circuit so it cannot match legacy rows missing
# the field via accidental coercion.
storage = self._make_storage()
storage._data.find_one = AsyncMock()
assert await storage.get_doc_by_content_hash("") is None
storage._data.find_one.assert_not_called()
@pytest.mark.asyncio
async def test_get_doc_by_content_hash_miss_returns_none(self):
storage = self._make_storage()
storage._data.find_one = AsyncMock(return_value=None)
assert await storage.get_doc_by_content_hash("zzz999") is None
@pytest.mark.asyncio
async def test_lookup_swallows_pymongo_error_and_returns_none(self):
# PyMongoError must not propagate to the caller; the dedup path treats
# a storage failure as "no match" and the error is logged instead.
storage = self._make_storage()
storage._data.find_one = AsyncMock(side_effect=PyMongoError("boom"))
assert await storage.get_doc_by_file_basename("report.pdf") is None
assert await storage.get_doc_by_content_hash("abc123") is None
View File
@@ -0,0 +1,99 @@
"""Atomicity coverage for the NanoVectorDB save path.
``NanoVectorDB.save()`` is third-party and writes via plain
``open(self.storage_file, "w") + json.dump``. ``NanoVectorDBStorage`` wraps
that call by swapping ``storage_file`` to a per-writer tmp under
``atomic_write``. The contract we have to preserve is:
1. On success: the destination file is updated, no tmp residue remains.
2. On failure: the destination is unchanged, ``client.storage_file`` is
restored to the real path (so subsequent reads don't keep pointing at
the tmp).
"""
import json
import os
from unittest.mock import patch
import pytest
nano_vectordb = pytest.importorskip("nano_vectordb") # noqa: F841
from lightrag.file_atomic import atomic_write # noqa: E402
from nano_vectordb import NanoVectorDB # noqa: E402
def _make_client(tmp_path) -> tuple[NanoVectorDB, str]:
target = str(tmp_path / "vdb_test.json")
client = NanoVectorDB(4, storage_file=target)
return client, target
def _save_atomic(client: NanoVectorDB, target: str) -> None:
"""Mirrors the production callback's save lambda — kept inline so the
test exercises the exact swap-and-restore pattern."""
def _do(tmp: str) -> None:
original = client.storage_file
client.storage_file = tmp
try:
client.save()
finally:
client.storage_file = original
atomic_write(target, _do)
@pytest.mark.offline
def test_nano_save_atomic_publishes_file_and_restores_storage_file(tmp_path):
client, target = _make_client(tmp_path)
_save_atomic(client, target)
assert os.path.exists(target)
assert client.storage_file == target
# Sanity check the payload is valid JSON written by NanoVectorDB.
json.load(open(target))
leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p]
assert leftovers == [], f"Unexpected tmp residue: {leftovers}"
@pytest.mark.offline
def test_nano_save_atomic_replace_crash_preserves_prior(tmp_path):
client, target = _make_client(tmp_path)
_save_atomic(client, target)
original_payload = open(target).read()
with patch(
"lightrag.file_atomic.os.replace",
side_effect=OSError("simulated crash"),
):
with pytest.raises(OSError, match="simulated crash"):
_save_atomic(client, target)
# Destination unchanged.
assert open(target).read() == original_payload
# storage_file must have been restored even though the outer atomic_write
# raised — otherwise NanoVectorDB would silently start writing to a path
# that no longer exists.
assert client.storage_file == target
leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p]
assert leftovers == [], f"Python-exception path must clean tmp, got {leftovers}"
@pytest.mark.offline
def test_nano_save_failure_inside_save_restores_storage_file(tmp_path):
"""If ``NanoVectorDB.save()`` itself raises (e.g. encoding failure), the
inner ``finally`` must restore ``storage_file`` before the exception
bubbles up through ``atomic_write``."""
client, target = _make_client(tmp_path)
# Patch the third-party save to blow up.
with patch.object(NanoVectorDB, "save", side_effect=RuntimeError("boom")):
with pytest.raises(RuntimeError, match="boom"):
_save_atomic(client, target)
assert client.storage_file == target, (
"Inner save failure must still restore storage_file to the real path"
)
leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p]
assert leftovers == [], f"save failure must clean tmp, got {leftovers}"
@@ -0,0 +1,450 @@
"""Deferred-embedding coverage for ``NanoVectorDBStorage``.
The storage no longer embeds eagerly in ``upsert``: it buffers a pending doc
and embeds once per id at flush time (``index_done_callback`` / ``finalize``).
These tests pin that contract using a counting mock embedding function — no
live model or network. They mirror the protocol proven for
``OpenSearchVectorDBStorage`` (issue #2785).
"""
import numpy as np
import pytest
nano_vectordb = pytest.importorskip("nano_vectordb") # noqa: F841
from lightrag.kg.nano_vector_db_impl import NanoVectorDBStorage # noqa: E402
from lightrag.kg.shared_storage import ( # noqa: E402
initialize_share_data,
finalize_share_data,
)
from lightrag.utils import EmbeddingFunc # noqa: E402
DIM = 8
@pytest.fixture(autouse=True)
def _shared_data():
finalize_share_data()
initialize_share_data()
yield
finalize_share_data()
class _CountingEmbed:
"""Async embedding callable that records how many texts it embedded and how
many times it was invoked (one invocation == one batch)."""
def __init__(self, dim: int = DIM):
self.dim = dim
self.call_count = 0
self.embedded_texts: list[str] = []
async def __call__(self, texts, **kwargs):
self.call_count += 1
self.embedded_texts.extend(texts)
# Deterministic per-text vector so duplicates are still 1-1.
return np.array(
[
np.full(self.dim, (abs(hash(t)) % 97) + 1, dtype=np.float32)
for t in texts
]
)
def _make_storage(tmp_path, embed: _CountingEmbed) -> NanoVectorDBStorage:
return NanoVectorDBStorage(
namespace="test_vectors",
workspace="ws",
global_config={
"working_dir": str(tmp_path),
"embedding_batch_num": 32,
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.2},
},
embedding_func=EmbeddingFunc(embedding_dim=DIM, max_token_size=512, func=embed),
meta_fields={"content"},
)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_upsert_defers_embedding_to_index_done_callback(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert(
{
"id1": {"content": "alpha"},
"id2": {"content": "beta"},
}
)
assert embed.call_count == 0, "upsert must not embed"
assert len(storage._client) == 0, "nothing should be materialized yet"
await storage.index_done_callback()
assert embed.call_count == 1, "flush should embed in a single batch"
assert sorted(embed.embedded_texts) == ["alpha", "beta"]
assert len(storage._client) == 2
@pytest.mark.offline
@pytest.mark.asyncio
async def test_repeated_upserts_same_id_embed_once_per_flush(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "v1"}})
await storage.upsert({"id1": {"content": "v2"}})
await storage.upsert({"id1": {"content": "v3"}})
await storage.index_done_callback()
assert embed.call_count == 1
assert embed.embedded_texts == ["v3"], "only the latest content is embedded"
assert len(storage._client) == 1
@pytest.mark.offline
@pytest.mark.asyncio
async def test_get_vectors_caches_and_flush_reuses(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
vecs = await storage.get_vectors_by_ids(["id1"])
assert "id1" in vecs and len(vecs["id1"]) == DIM
assert embed.call_count == 1, "get_vectors_by_ids embeds pending lazily"
# Flush must reuse the cached vector, not re-embed.
await storage.index_done_callback()
assert embed.call_count == 1, "flush should reuse the cached temp vector"
assert len(storage._client) == 1
@pytest.mark.offline
@pytest.mark.asyncio
async def test_reupsert_after_get_vectors_clears_cached_vector(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "old"}})
await storage.get_vectors_by_ids(["id1"]) # caches a temp vector for "old"
assert embed.call_count == 1
# New content version must clear the cached vector and re-embed at flush.
await storage.upsert({"id1": {"content": "new"}})
await storage.index_done_callback()
assert embed.call_count == 2
assert embed.embedded_texts == ["old", "new"]
@pytest.mark.offline
@pytest.mark.asyncio
async def test_delete_cancels_pending_and_removes_materialized(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
# Materialize id1; leave id2 only as a pending (unflushed) upsert.
await storage.upsert({"id1": {"content": "alpha"}})
await storage.index_done_callback()
await storage.upsert({"id2": {"content": "beta"}})
await storage.delete(["id1", "id2"])
assert "id2" not in storage._pending_upserts, "delete cancels pending upsert"
assert len(storage._client) == 0, "delete removes the materialized row immediately"
assert await storage.get_by_id("id1") is None
assert await storage.get_by_id("id2") is None
@pytest.mark.offline
@pytest.mark.asyncio
async def test_stale_client_reload_still_flushes_pending_upsert(tmp_path):
embed = _CountingEmbed()
writer = _make_storage(tmp_path, embed)
stale_writer = _make_storage(tmp_path, embed)
await writer.initialize()
await stale_writer.initialize()
await writer.upsert({"id1": {"content": "alpha"}})
assert await writer.index_done_callback() is True
assert stale_writer.storage_updated.value is True
await stale_writer.upsert({"id2": {"content": "beta"}})
assert await stale_writer.index_done_callback() is True
reader = _make_storage(tmp_path, embed)
await reader.initialize()
rows = await reader.get_by_ids(["id1", "id2"])
assert [row["id"] for row in rows] == ["id1", "id2"]
assert stale_writer._pending_upserts == {}
@pytest.mark.offline
@pytest.mark.asyncio
async def test_delete_reloads_stale_client_before_mutating(tmp_path):
embed = _CountingEmbed()
writer = _make_storage(tmp_path, embed)
stale_deleter = _make_storage(tmp_path, embed)
await writer.initialize()
await stale_deleter.initialize()
await writer.upsert({"id1": {"content": "alpha"}})
assert await writer.index_done_callback() is True
assert stale_deleter.storage_updated.value is True
await stale_deleter.delete(["id1"])
assert stale_deleter.storage_updated.value is False
assert await stale_deleter.index_done_callback() is True
reader = _make_storage(tmp_path, embed)
await reader.initialize()
assert await reader.get_by_id("id1") is None
@pytest.mark.offline
@pytest.mark.asyncio
async def test_finalize_reloads_stale_client_before_flushing(tmp_path):
embed = _CountingEmbed()
writer = _make_storage(tmp_path, embed)
stale_finalizer = _make_storage(tmp_path, embed)
await writer.initialize()
await stale_finalizer.initialize()
await writer.upsert({"id1": {"content": "alpha"}})
assert await writer.index_done_callback() is True
assert stale_finalizer.storage_updated.value is True
await stale_finalizer.upsert({"id2": {"content": "beta"}})
await stale_finalizer.finalize()
reader = _make_storage(tmp_path, embed)
await reader.initialize()
rows = await reader.get_by_ids(["id1", "id2"])
assert [row["id"] for row in rows] == ["id1", "id2"]
assert stale_finalizer._pending_upserts == {}
@pytest.mark.offline
@pytest.mark.asyncio
async def test_read_your_writes_and_query_after_flush(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
# Before flush: read paths see the pending row, query does not.
hit = await storage.get_by_id("id1")
assert hit is not None and hit["id"] == "id1" and hit["content"] == "alpha"
by_ids = await storage.get_by_ids(["id1", "missing"])
assert by_ids[0]["id"] == "id1" and by_ids[1] is None
assert await storage.query("alpha", top_k=5) == [], "query ignores unflushed data"
# After flush: query returns the row.
await storage.index_done_callback()
results = await storage.query("alpha", top_k=5)
assert any(r["id"] == "id1" for r in results)
@pytest.mark.offline
@pytest.mark.asyncio
async def test_finalize_flushes_pending(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
await storage.finalize()
assert embed.call_count == 1
assert storage._pending_upserts == {}
assert len(storage._client) == 1
@pytest.mark.offline
@pytest.mark.asyncio
async def test_delete_entity_relation_cancels_pending(tmp_path):
embed = _CountingEmbed()
storage = NanoVectorDBStorage(
namespace="test_relations",
workspace="ws",
global_config={
"working_dir": str(tmp_path),
"embedding_batch_num": 32,
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.2},
},
embedding_func=EmbeddingFunc(embedding_dim=DIM, max_token_size=512, func=embed),
meta_fields={"content", "src_id", "tgt_id"},
)
await storage.initialize()
# Materialize r1 (A->B), leave r2 (A->C) and r3 (X->Y) as pending.
await storage.upsert({"r1": {"content": "rel1", "src_id": "A", "tgt_id": "B"}})
await storage.index_done_callback()
await storage.upsert(
{
"r2": {"content": "rel2", "src_id": "A", "tgt_id": "C"},
"r3": {"content": "rel3", "src_id": "X", "tgt_id": "Y"},
}
)
await storage.delete_entity_relation("A")
assert "r2" not in storage._pending_upserts, "incident pending entry cancelled"
assert "r3" in storage._pending_upserts, "unrelated pending entry preserved"
assert len(storage._client) == 0, "materialized A->B removed"
@pytest.mark.offline
@pytest.mark.asyncio
async def test_flush_embedding_failure_raises_and_keeps_pending(tmp_path):
class _FailingEmbed:
def __init__(self):
self.call_count = 0
async def __call__(self, texts, **kwargs):
self.call_count += 1
raise RuntimeError("embed boom")
embed = _FailingEmbed()
storage = NanoVectorDBStorage(
namespace="test_vectors",
workspace="ws",
global_config={
"working_dir": str(tmp_path),
"embedding_batch_num": 32,
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.2},
},
embedding_func=EmbeddingFunc(embedding_dim=DIM, max_token_size=512, func=embed),
meta_fields={"content"},
)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
with pytest.raises(RuntimeError, match="embed boom"):
await storage.index_done_callback()
assert "id1" in storage._pending_upserts, "pending preserved for retry"
assert len(storage._client) == 0, "nothing materialized on embed failure"
# Embed failure happens before self._client.upsert in _flush_pending_locked,
# so _client_dirty must NOT be set. (A save-stage failure would leave it True
# — see test_finalize_retries_save_after_flush_failure.)
assert storage._client_dirty is False
@pytest.mark.offline
@pytest.mark.asyncio
async def test_drop_discards_pending_without_embedding(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
assert "id1" in storage._pending_upserts
result = await storage.drop()
assert result["status"] == "success"
assert storage._pending_upserts == {}, "drop discards buffered upserts"
assert embed.call_count == 0, "drop must not embed"
assert storage._client_dirty is False
@pytest.mark.offline
@pytest.mark.asyncio
async def test_finalize_retries_save_after_flush_failure(tmp_path):
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}})
original_save = storage._save_to_disk_locked
save_calls = 0
def fail_once():
nonlocal save_calls
save_calls += 1
if save_calls == 1:
raise OSError("boom")
original_save()
storage._save_to_disk_locked = fail_once
with pytest.raises(OSError, match="boom"):
await storage.finalize()
assert storage._pending_upserts == {}
assert storage._client_dirty is True
await storage.finalize()
assert save_calls == 2
assert storage._client_dirty is False
reader = _make_storage(tmp_path, embed)
await reader.initialize()
hit = await reader.get_by_id("id1")
assert hit is not None and hit["id"] == "id1"
@pytest.mark.offline
@pytest.mark.asyncio
async def test_drop_pending_index_ops_clears_buffer(tmp_path):
"""An internal-error abort calls drop_pending_index_ops to discard the
not-yet-flushed buffer without materializing anything."""
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
await storage.upsert({"id1": {"content": "alpha"}, "id2": {"content": "beta"}})
assert storage._pending_upserts, "upsert buffers, does not flush"
await storage.drop_pending_index_ops()
assert storage._pending_upserts == {}
assert embed.call_count == 0, "drop must not embed"
assert len(storage._client) == 0, "nothing was materialized"
@pytest.mark.offline
@pytest.mark.asyncio
async def test_drop_pending_does_not_rollback_materialized(tmp_path):
"""drop_pending_index_ops discards ONLY the pending buffer; records already
materialized into self._client by a flush whose save then failed
(``_client_dirty=True``) are intentionally NOT rolled back."""
embed = _CountingEmbed()
storage = _make_storage(tmp_path, embed)
await storage.initialize()
# Flush id1 into the in-memory client, then fail the save so it stays
# materialized-but-unsaved (dirty) and the pending buffer is emptied.
await storage.upsert({"id1": {"content": "alpha"}})
def fail_save():
raise OSError("save boom")
storage._save_to_disk_locked = fail_save
with pytest.raises(OSError, match="save boom"):
await storage.index_done_callback()
assert storage._pending_upserts == {}, "flush succeeded so pending is empty"
assert storage._client_dirty is True
assert len(storage._client) == 1, "id1 materialized, not saved"
# A new pending op arrives, then the batch aborts and drops pending.
await storage.upsert({"id2": {"content": "beta"}})
assert "id2" in storage._pending_upserts
await storage.drop_pending_index_ops()
assert storage._pending_upserts == {}, "pending id2 dropped"
assert len(storage._client) == 1, "materialized id1 NOT rolled back"
assert storage._client_dirty is True, "still dirty for a later save retry"
View File
@@ -0,0 +1,361 @@
#!/usr/bin/env python
"""
Test Neo4j full-text index functionality, specifically:
1. Workspace-specific index naming
2. Legacy index migration
3. search_labels functionality with workspace-specific indexes
"""
import asyncio
import os
import sys
import pytest
import numpy as np
# Add the project root directory to the Python path
sys.path.append(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
)
)
from lightrag.kg.shared_storage import initialize_share_data
# Mock embedding function that returns random vectors
async def mock_embedding_func(texts):
return np.random.rand(len(texts), 10) # Return 10-dimensional random vectors
@pytest.fixture
async def neo4j_storage():
"""
Initialize Neo4j storage for testing.
Requires Neo4j to be running and configured via environment variables.
"""
# Check if Neo4j is configured
if not os.getenv("NEO4J_URI"):
pytest.skip("Neo4j not configured (NEO4J_URI not set)")
from lightrag.kg.neo4j_impl import Neo4JStorage
# Initialize shared_storage for locks
initialize_share_data()
global_config = {
"embedding_batch_num": 10,
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.5},
"working_dir": os.environ.get("WORKING_DIR", "./rag_storage"),
}
storage = Neo4JStorage(
namespace="test_fulltext_index",
workspace="test_workspace",
global_config=global_config,
embedding_func=mock_embedding_func,
)
# Initialize the connection
await storage.initialize()
# Clean up any existing data
await storage.drop()
yield storage
# Cleanup
await storage.drop()
await storage.finalize()
@pytest.mark.integration
@pytest.mark.requires_db
async def test_fulltext_index_creation(neo4j_storage):
"""
Test that the full-text index is created with the workspace-specific name.
"""
storage = neo4j_storage
workspace_label = storage._get_workspace_label()
expected_index_name = f"entity_id_fulltext_idx_{workspace_label}"
# Query Neo4j to check if the index exists
async with storage._driver.session(database=storage._DATABASE) as session:
result = await session.run("SHOW FULLTEXT INDEXES")
indexes = await result.data()
await result.consume()
# Check if the workspace-specific index exists
index_names = [idx["name"] for idx in indexes]
assert expected_index_name in index_names, (
f"Expected index '{expected_index_name}' not found. Found indexes: {index_names}"
)
# Check if the legacy index doesn't exist (should be migrated if it was there)
legacy_index_name = "entity_id_fulltext_idx"
if legacy_index_name in index_names:
# If legacy index exists, it should be for a different workspace
# or it means migration didn't happen
print(
f"Warning: Legacy index '{legacy_index_name}' still exists alongside '{expected_index_name}'"
)
@pytest.mark.integration
@pytest.mark.requires_db
async def test_search_labels_with_workspace_index(neo4j_storage):
"""
Test that search_labels uses the workspace-specific index and returns results.
"""
storage = neo4j_storage
# Insert test nodes
test_nodes = [
{
"entity_id": "Artificial Intelligence",
"description": "AI field",
"keywords": "AI,ML,DL",
"entity_type": "Technology",
},
{
"entity_id": "Machine Learning",
"description": "ML subfield",
"keywords": "supervised,unsupervised",
"entity_type": "Technology",
},
{
"entity_id": "Deep Learning",
"description": "DL subfield",
"keywords": "neural networks",
"entity_type": "Technology",
},
{
"entity_id": "Natural Language Processing",
"description": "NLP field",
"keywords": "text,language",
"entity_type": "Technology",
},
]
for node_data in test_nodes:
await storage.upsert_node(node_data["entity_id"], node_data)
# Give the index time to become consistent (eventually consistent index)
await asyncio.sleep(2)
# Test search_labels
results = await storage.search_labels("Learning", limit=10)
# Should find nodes with "Learning" in them
assert len(results) > 0, "search_labels should return results for 'Learning'"
assert any("Learning" in result for result in results), (
"Results should contain 'Learning'"
)
# Test case-insensitive search
results_lower = await storage.search_labels("learning", limit=10)
assert len(results_lower) > 0, "search_labels should be case-insensitive"
# Test partial match
results_partial = await storage.search_labels("Intelli", limit=10)
assert len(results_partial) > 0, (
"search_labels should support partial matching with wildcard"
)
assert any("Intelligence" in result for result in results_partial), (
"Should find 'Artificial Intelligence'"
)
@pytest.mark.integration
@pytest.mark.requires_db
async def test_search_labels_with_hyphen(neo4j_storage):
"""
Regression test: searching a label fragment containing a hyphen (or other
Lucene reserved character) must still return matches via the full-text index
instead of being misparsed into an empty result.
"""
storage = neo4j_storage
test_nodes = [
{"entity_id": "tb-alpha", "entity_type": "Test"},
{"entity_id": "tb-beta", "entity_type": "Test"},
{"entity_id": "tbcd", "entity_type": "Test"},
]
for node_data in test_nodes:
await storage.upsert_node(node_data["entity_id"], node_data)
# Give the eventually-consistent index time to catch up.
await asyncio.sleep(2)
# Plain prefix returns all three (baseline that already worked).
results = await storage.search_labels("tb", limit=10)
for expected in ("tb-alpha", "tb-beta", "tbcd"):
assert expected in results, f"'{expected}' should match 'tb', got {results}"
# The reported bug: a trailing hyphen previously cleared the dropdown.
results_hyphen = await storage.search_labels("tb-", limit=10)
assert "tb-alpha" in results_hyphen, (
f"'tb-' should match 'tb-alpha', got {results_hyphen}"
)
assert "tb-beta" in results_hyphen, (
f"'tb-' should match 'tb-beta', got {results_hyphen}"
)
# An exact hyphenated label resolves to itself.
results_exact = await storage.search_labels("tb-alpha", limit=10)
assert "tb-alpha" in results_exact, (
f"'tb-alpha' should match itself, got {results_exact}"
)
@pytest.mark.integration
@pytest.mark.requires_db
async def test_search_labels_chinese_text(neo4j_storage):
"""
Test that search_labels works with Chinese text using the CJK analyzer.
"""
storage = neo4j_storage
# Insert Chinese test nodes
chinese_nodes = [
{
"entity_id": "人工智能",
"description": "人工智能领域",
"keywords": "AI,机器学习",
"entity_type": "技术",
},
{
"entity_id": "机器学习",
"description": "机器学习子领域",
"keywords": "监督学习,无监督学习",
"entity_type": "技术",
},
{
"entity_id": "深度学习",
"description": "深度学习子领域",
"keywords": "神经网络",
"entity_type": "技术",
},
]
for node_data in chinese_nodes:
await storage.upsert_node(node_data["entity_id"], node_data)
# Give the index time to become consistent
await asyncio.sleep(2)
# Test Chinese text search
results = await storage.search_labels("学习", limit=10)
# Should find nodes with "学习" in them
assert len(results) > 0, "search_labels should return results for Chinese text"
assert any("学习" in result for result in results), (
"Results should contain Chinese characters '学习'"
)
@pytest.mark.integration
@pytest.mark.requires_db
async def test_search_labels_fallback_to_contains(neo4j_storage):
"""
Test that search_labels falls back to CONTAINS search if the index fails.
This can happen with older Neo4j versions or if the index is not yet available.
"""
storage = neo4j_storage
# Insert test nodes
test_nodes = [
{
"entity_id": "Test Node Alpha",
"description": "Test node",
"keywords": "test",
"entity_type": "Test",
},
{
"entity_id": "Test Node Beta",
"description": "Test node",
"keywords": "test",
"entity_type": "Test",
},
]
for node_data in test_nodes:
await storage.upsert_node(node_data["entity_id"], node_data)
# Even if the full-text index is not available, CONTAINS should work
results = await storage.search_labels("Alpha", limit=10)
# Should find the node using fallback CONTAINS search
assert len(results) > 0, "Fallback CONTAINS search should return results"
assert "Test Node Alpha" in results, "Should find 'Test Node Alpha'"
@pytest.mark.integration
@pytest.mark.requires_db
async def test_multiple_workspaces_have_separate_indexes(neo4j_storage):
"""
Test that different workspaces have their own separate indexes.
"""
from lightrag.kg.neo4j_impl import Neo4JStorage
# Create storage for workspace 1
storage1 = neo4j_storage
# Create storage for workspace 2
global_config = {
"embedding_batch_num": 10,
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.5},
"working_dir": os.environ.get("WORKING_DIR", "./rag_storage"),
}
storage2 = Neo4JStorage(
namespace="test_fulltext_index",
workspace="test_workspace_2",
global_config=global_config,
embedding_func=mock_embedding_func,
)
await storage2.initialize()
await storage2.drop()
try:
# Check that both workspaces have their own indexes
async with storage1._driver.session(database=storage1._DATABASE) as session:
result = await session.run("SHOW FULLTEXT INDEXES")
indexes = await result.data()
await result.consume()
index_names = [idx["name"] for idx in indexes]
workspace1_index = (
f"entity_id_fulltext_idx_{storage1._get_workspace_label()}"
)
workspace2_index = (
f"entity_id_fulltext_idx_{storage2._get_workspace_label()}"
)
assert workspace1_index in index_names, (
f"Workspace 1 index '{workspace1_index}' should exist"
)
assert workspace2_index in index_names, (
f"Workspace 2 index '{workspace2_index}' should exist"
)
finally:
# Clean up: drop the fulltext index created for workspace 2 to prevent accumulation
try:
async with storage2._driver.session(database=storage2._DATABASE) as session:
index_name = storage2._get_fulltext_index_name(
storage2._get_workspace_label()
)
drop_query = f"DROP INDEX {index_name} IF EXISTS"
result = await session.run(drop_query)
await result.consume()
except Exception:
pass # Ignore errors during cleanup
await storage2.drop()
await storage2.finalize()
if __name__ == "__main__":
# Run tests with pytest
pytest.main([__file__, "-v", "--run-integration"])
@@ -0,0 +1,48 @@
#!/usr/bin/env python
"""
Unit tests for Neo4JStorage._sanitize_fulltext_query.
These run without a live Neo4j instance and guard the regression where a label
search containing Lucene reserved characters (e.g. "tb-") was misparsed by the
full-text query parser ('-' as NOT) and silently returned nothing.
"""
import os
import sys
import pytest
# Add the project root directory to the Python path
sys.path.append(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
)
)
from lightrag.kg.neo4j_impl import Neo4JStorage
@pytest.mark.parametrize(
"raw, expected",
[
# Reserved characters are replaced with spaces and whitespace collapsed.
("tb-", "tb"),
("tb-foo", "tb foo"),
("node:x", "node x"),
("a (b)", "a b"),
("C++", "C"),
("foo && bar", "foo bar"),
("name*", "name"),
# Composed entirely of reserved characters -> empty (caller returns []).
("---", ""),
("+++", ""),
("()[]{}", ""),
# No reserved characters -> unchanged (modulo whitespace collapsing).
("machine learning", "machine learning"),
(" spaced out ", "spaced out"),
("学习", "学习"),
("机器-学习", "机器 学习"),
],
)
def test_sanitize_fulltext_query(raw, expected):
assert Neo4JStorage._sanitize_fulltext_query(raw) == expected
@@ -0,0 +1,178 @@
#!/usr/bin/env python
"""
Unit tests guarding against Cypher injection through the workspace label.
The workspace label is interpolated into two different syntactic contexts in
``neo4j_impl.py``:
1. Backtick-quoted identifiers, e.g. ``MATCH (n:`{label}`)`` — here backticks
must be doubled and all other characters (including single quotes) are
literal.
2. The APOC ``labelFilter`` config, which lives inside a single-quoted Cypher
string literal. Cypher string literals are NOT escaped by doubling quotes
(that is SQL); they use backslash escaping. Rather than escape by hand, the
label is bound as a query parameter so Neo4j handles it safely.
These tests run without a live Neo4j instance.
"""
import os
import sys
import pytest
# Add the project root directory to the Python path
sys.path.append(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
)
)
from lightrag.kg.neo4j_impl import Neo4JStorage
def _make_storage(workspace: str) -> Neo4JStorage:
"""Create a Neo4JStorage with a given workspace (no connection)."""
return Neo4JStorage(
namespace="test",
global_config={},
embedding_func=None,
workspace=workspace if workspace else None,
)
# ---------------------------------------------------------------------------
# _get_workspace_label: backtick-identifier context
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"workspace, expected",
[
# Normal names pass through unchanged.
("my-project", "my-project"),
("base", "base"),
("workspace_123", "workspace_123"),
# Whitespace is stripped; empty falls back to "base".
(" trimmed ", "trimmed"),
("", "base"),
(" ", "base"),
# Backticks are doubled for identifier context.
("ws`name", "ws``name"),
("a`b`c", "a``b``c"),
("ws`} RETURN 0", "ws``} RETURN 0"),
# Single quotes are NOT touched here: inside backticks they are literal,
# so doubling them would corrupt the label name.
("ws'name", "ws'name"),
("team'a", "team'a"),
("base' OR 1=1", "base' OR 1=1"),
# Mixed: only backticks are escaped.
("ws'`name", "ws'``name"),
],
)
def test_get_workspace_label(workspace, expected):
storage = _make_storage(workspace)
assert storage._get_workspace_label() == expected
def test_label_safe_in_backtick_identifier():
"""The returned label cannot break out of a backtick-quoted identifier."""
storage = _make_storage("ws`} OR 1=1")
identifier = f"`{storage._get_workspace_label()}`"
# Every ` in the label was doubled to ``, so the quoting stays balanced.
assert identifier.count("`") % 2 == 0
# ---------------------------------------------------------------------------
# _get_raw_workspace_label: the actual label name, bound as a parameter
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"workspace, expected",
[
("my-project", "my-project"),
(" trimmed ", "trimmed"),
("", "base"),
(" ", "base"),
# No escaping of any kind: the raw value is bound, never interpolated.
("ws`name", "ws`name"),
("team'a", "team'a"),
("x'); CALL apoc.util.sleep(1);", "x'); CALL apoc.util.sleep(1);"),
],
)
def test_get_raw_workspace_label(workspace, expected):
storage = _make_storage(workspace)
assert storage._get_raw_workspace_label() == expected
# ---------------------------------------------------------------------------
# get_knowledge_graph: labelFilter must be parameterized, never interpolated
# ---------------------------------------------------------------------------
class _FakeResult:
def __init__(self, record):
self._record = record
async def single(self):
return self._record
async def consume(self):
return None
class _FakeSession:
"""Records every (query, params) pair passed to ``run``."""
def __init__(self):
self.calls = []
async def run(self, query, params=None):
self.calls.append((query, params or {}))
# total_nodes within limit -> the full result is used directly and the
# truncated/limited branch is never taken (a single run call).
return _FakeResult({"total_nodes": 0, "node_info": [], "relationships": []})
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
class _FakeDriver:
def __init__(self, session):
self._session = session
def session(self, **kwargs):
return self._session
@pytest.mark.asyncio
async def test_label_filter_is_parameterized_not_interpolated():
"""A malicious workspace must reach APOC labelFilter as a bound parameter.
If the label were string-interpolated (the old behaviour), a single quote
would close the string literal and inject Cypher. Binding it as a parameter
makes interpolation — and therefore injection — impossible by construction.
"""
payload = "x'); CALL apoc.util.sleep(1);"
storage = _make_storage(payload)
session = _FakeSession()
storage._driver = _FakeDriver(session)
storage._DATABASE = None
await storage.get_knowledge_graph(node_label="some-entity", max_depth=2)
# The subgraphAll query is the one carrying labelFilter.
subgraph_calls = [(q, p) for q, p in session.calls if "apoc.path.subgraphAll" in q]
assert subgraph_calls, "expected an apoc.path.subgraphAll query"
for query, params in subgraph_calls:
# labelFilter is bound, not interpolated: there is no single-quoted
# string literal for the payload to break out of.
assert "labelFilter: $label_filter" in query
assert "labelFilter: '" not in query
# The raw (un-escaped) label is passed as the bound value.
assert params.get("label_filter") == payload
View File
@@ -0,0 +1,194 @@
"""Regression tests for the atomic `write_nx_graph` path in NetworkXStorage.
After the migration to the shared ``lightrag.file_atomic`` helper, two
failure modes have distinct tmp-residue semantics:
- Python-level exceptions (``write_fn`` raised, ``os.replace`` raised, ...):
``atomic_write``'s ``finally`` removes the tmp best-effort and the prior
snapshot remains on disk.
- Process-level kills (SIGKILL, OOM, hard reboot): ``finally`` does not run
and the tmp survives as an orphan; the startup reaper handles it.
Covers concerns introduced when migrating from direct in-place write to
temp-file + os.replace:
1. Concurrent writers (multi-thread) all succeed and the final file parses.
2. A crash between ``write_fn(tmp)`` and ``os.replace`` leaves the prior
snapshot intact on disk (atomicity guarantee, Python-exception path).
3. The startup orphan-tmp reaper deletes tmp files older than the threshold
without touching tmp files that may belong to a live concurrent writer.
This is what salvages real SIGKILL/OOM residue.
4. The rename preserves the destination file's existing permission bits
instead of widening them to the umask default.
5. The orphan reaper handles glob metacharacters in file_name correctly
(workspace/namespace strings can legitimately contain '[', '*', '?').
"""
import os
import stat
import sys
import threading
import time
from unittest.mock import patch
import networkx as nx
import pytest
from lightrag.file_atomic import TMP_REAP_AGE_SECONDS, reap_orphan_tmp_files
from lightrag.kg.networkx_impl import NetworkXStorage
@pytest.mark.offline
def test_write_nx_graph_concurrent_writers_no_race(tmp_path):
"""5 threads racing on the same destination must all succeed; the final
file must parse, and no per-writer .tmp sibling may be left behind."""
dst = str(tmp_path / "graph_concurrent.graphml")
errors: list[BaseException] = []
barrier = threading.Barrier(5)
def writer(tid: int) -> None:
try:
g = nx.Graph()
g.add_node(f"node-{tid}", label=f"label-{tid}")
barrier.wait()
NetworkXStorage.write_nx_graph(g, dst, workspace=f"w{tid}")
except BaseException as exc:
errors.append(exc)
threads = [threading.Thread(target=writer, args=(i,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Concurrent writers raised: {errors}"
# Final file must parse cleanly.
loaded = nx.read_graphml(dst)
assert loaded.number_of_nodes() == 1
# No per-writer tmp residue.
leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p]
assert leftovers == [], f"Unexpected tmp residue: {leftovers}"
@pytest.mark.offline
def test_write_nx_graph_crash_preserves_prior_snapshot(tmp_path):
"""If ``os.replace`` raises after the tmp is written, the destination
must still hold the prior snapshot — never a torn/partial file. Because
the failure is a Python-level exception, ``atomic_write`` runs its
``finally`` and removes the tmp; the startup-reaper path is exercised
separately in ``test_reap_orphan_tmp_files_respects_age_threshold``."""
dst = str(tmp_path / "graph_crash.graphml")
# Commit v1.
v1 = nx.Graph()
v1.add_node("v1-node")
NetworkXStorage.write_nx_graph(v1, dst, workspace="crash")
assert os.path.exists(dst)
# Attempt v2 with os.replace raising. Patch the symbol in file_atomic
# because the rename now lives in the shared helper, not in
# networkx_impl.
v2 = nx.Graph()
v2.add_node("v2-node")
with patch(
"lightrag.file_atomic.os.replace",
side_effect=OSError("simulated crash"),
):
with pytest.raises(OSError, match="simulated crash"):
NetworkXStorage.write_nx_graph(v2, dst, workspace="crash")
# Destination must still be v1 — atomicity guarantee.
reloaded = nx.read_graphml(dst)
assert "v1-node" in reloaded.nodes
assert "v2-node" not in reloaded.nodes
# Python-exception path: tmp must have been cleaned up by atomic_write's
# finally. Real SIGKILL/OOM residue is the reaper's job, not this one.
leftovers = [p for p in os.listdir(tmp_path) if ".tmp." in p]
assert leftovers == [], f"Python-exception path must clean tmp, got {leftovers}"
@pytest.mark.offline
def test_reap_orphan_tmp_files_respects_age_threshold(tmp_path):
"""`reap_orphan_tmp_files` must delete tmp siblings older than the
threshold and leave younger ones (potentially belonging to a live
concurrent writer) untouched."""
dst = str(tmp_path / "graph_reap.graphml")
old_tmp = f"{dst}.tmp.111.222.333"
young_tmp = f"{dst}.tmp.444.555.666"
unrelated = str(tmp_path / "graph_other.graphml.tmp.999")
for p in (old_tmp, young_tmp, unrelated):
with open(p, "w") as fh:
fh.write("partial xml")
# Age old_tmp past the threshold; leave young_tmp and unrelated fresh.
aged_mtime = time.time() - (TMP_REAP_AGE_SECONDS + 60)
os.utime(old_tmp, (aged_mtime, aged_mtime))
reap_orphan_tmp_files(dst, workspace="reap")
assert not os.path.exists(old_tmp), "Aged orphan should have been reaped"
assert os.path.exists(young_tmp), "Fresh tmp may be in-flight — must not be reaped"
assert os.path.exists(unrelated), (
"Unrelated path must not be touched by this reaper"
)
@pytest.mark.offline
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX chmod semantics")
def test_write_nx_graph_preserves_existing_file_mode(tmp_path):
"""If the destination already exists with restrictive mode (e.g. 0600),
the atomic write must not silently widen it to umask defaults — the
inode swap done by os.replace would otherwise inherit the tmp file's
fresh permissions."""
dst = str(tmp_path / "graph_mode.graphml")
g = nx.Graph()
g.add_node("a")
NetworkXStorage.write_nx_graph(g, dst, workspace="mode")
os.chmod(dst, 0o600)
assert stat.S_IMODE(os.stat(dst).st_mode) == 0o600
# Second write — mode must survive.
g.add_node("b")
NetworkXStorage.write_nx_graph(g, dst, workspace="mode")
assert stat.S_IMODE(os.stat(dst).st_mode) == 0o600, (
"atomic write must preserve dst permissions across the rename"
)
# And content was actually updated.
assert nx.read_graphml(dst).number_of_nodes() == 2
@pytest.mark.offline
def test_reap_orphan_tmp_files_handles_glob_metacharacters(tmp_path):
"""`file_name` is composed from `working_dir` and `namespace`, both of
which can legitimately contain glob metacharacters on POSIX. The reaper
must match literally — not (a) miss the real orphan because '[v2]' was
parsed as a character class, nor (b) widen its pattern to match
unrelated tmp files belonging to a sibling storage."""
dst = str(tmp_path / "graph_[v2].graphml")
real_orphan = f"{dst}.tmp.111.222.333"
decoy = str(tmp_path / "graph_v.graphml.tmp.unrelated")
for p in (real_orphan, decoy):
with open(p, "w") as fh:
fh.write("partial xml")
# Age both past the threshold so age is not the discriminator here —
# the only thing protecting the decoy is correct pattern escaping.
aged_mtime = time.time() - (TMP_REAP_AGE_SECONDS + 60)
for p in (real_orphan, decoy):
os.utime(p, (aged_mtime, aged_mtime))
reap_orphan_tmp_files(dst, workspace="meta")
assert not os.path.exists(real_orphan), (
"Reaper must match the real orphan even when path contains '['"
)
assert os.path.exists(decoy), (
"Reaper must not match tmp files belonging to unrelated paths"
)
@@ -0,0 +1,65 @@
"""NetworkXStorage.index_done_callback must RAISE on a graph-save failure
(PR #3187), not swallow it and return False.
A swallowed save error would let _insert_done's _flush_one treat the flush as
successful (it only detects failures via exceptions), so the document would be
marked PROCESSED with the graph changes unpersisted — silent data loss. This
locks the raise-on-failure behavior that aligns NetworkX with the other
backends (faiss/nano raise too).
"""
from __future__ import annotations
import numpy as np
import pytest
from lightrag.kg.networkx_impl import NetworkXStorage
from lightrag.kg.shared_storage import finalize_share_data, initialize_share_data
from lightrag.utils import EmbeddingFunc
pytestmark = pytest.mark.offline
@pytest.fixture(autouse=True)
def _shared_data():
finalize_share_data()
initialize_share_data()
yield
finalize_share_data()
async def _embed(texts):
return np.random.rand(len(texts), 8)
def _make_storage(tmp_path) -> NetworkXStorage:
return NetworkXStorage(
namespace="test_graph",
workspace="ws",
global_config={
"working_dir": str(tmp_path),
"embedding_batch_num": 10,
"vector_db_storage_cls_kwargs": {"cosine_better_than_threshold": 0.5},
},
embedding_func=EmbeddingFunc(embedding_dim=8, max_token_size=512, func=_embed),
)
@pytest.mark.asyncio
async def test_index_done_callback_raises_on_save_failure(tmp_path, monkeypatch):
storage = _make_storage(tmp_path)
await storage.initialize()
try:
await storage.upsert_node("n1", {"entity_id": "n1", "description": "x"})
def boom(graph, file_name, workspace):
raise OSError("save boom")
# write_nx_graph is invoked as NetworkXStorage.write_nx_graph(...).
monkeypatch.setattr(NetworkXStorage, "write_nx_graph", staticmethod(boom))
# Must surface the error (NOT return False / swallow it).
with pytest.raises(OSError, match="save boom"):
await storage.index_done_callback()
finally:
await storage.finalize()
@@ -0,0 +1,233 @@
"""
PoC test: CWE-89 OpenSearch injection via unsanitized entity names in query construction.
The test validates that:
1. Wildcard special characters (*, ?) in user input to search_labels are escaped
before being used in OpenSearch wildcard queries, preventing DoS via expensive
wildcard patterns.
2. PPL escape handles control characters and additional metacharacters beyond
just backslash and single-quote.
Run with: pytest tests/kg/opensearch_impl/test_cwe89_opensearch_injection.py -v
"""
import re
import pytest
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, patch
import numpy as np
pytest.importorskip(
"opensearchpy",
reason="opensearchpy is required for OpenSearch storage tests",
)
from lightrag.kg.opensearch_impl import (
OpenSearchGraphStorage,
ClientManager,
)
pytestmark = pytest.mark.offline
@asynccontextmanager
async def _mock_lock():
yield
def _mock_lock_factory():
return _mock_lock()
@pytest.fixture(autouse=True)
def patch_data_init_lock():
with patch(
"lightrag.kg.opensearch_impl.get_data_init_lock", side_effect=_mock_lock_factory
):
yield
class MockEmbeddingFunc:
def __init__(self, dim=128):
self.embedding_dim = dim
self.max_token_size = 512
self.model_name = "mock-embed"
async def __call__(self, texts, **kwargs):
return np.random.rand(len(texts), self.embedding_dim).astype(np.float32)
@pytest.fixture
def global_config():
return {
"embedding_batch_num": 10,
"max_graph_nodes": 1000,
}
@pytest.fixture
def embed_func():
return MockEmbeddingFunc()
def _make_client():
from opensearchpy import AsyncOpenSearch
client = AsyncMock(spec=AsyncOpenSearch)
# opensearchpy decorates client methods with @query_params, which hides their
# coroutine nature from inspect.iscoroutinefunction. AsyncMock(spec=...) then
# creates plain MagicMocks for them, so callers awaiting client.search(...) hit
# "object dict can't be used in 'await' expression". Force AsyncMock explicitly.
client.search = AsyncMock()
client.indices = AsyncMock()
client.indices.exists = AsyncMock(return_value=True)
client.indices.refresh = AsyncMock()
client.transport = AsyncMock()
return client
@pytest.fixture
def graph_storage(global_config, embed_func):
with patch.object(ClientManager, "get_client") as mock_get:
client = _make_client()
mock_get.return_value = client
storage = OpenSearchGraphStorage(
namespace="test_graph",
global_config=global_config,
embedding_func=embed_func,
)
storage.client = client
storage._indices_ready = True
storage._ppl_graphlookup_available = True
yield storage
class TestWildcardInjection:
"""Test that wildcard special chars are escaped in search_labels."""
@pytest.mark.asyncio
async def test_wildcard_chars_escaped_in_search_labels(self, graph_storage):
"""Wildcard metacharacters *, ? in user input must be escaped."""
client = graph_storage.client
# Setup mock to return empty results
client.search.return_value = {"hits": {"hits": []}}
# Malicious query with wildcard chars that could cause expensive patterns
malicious_query = "test*?foo"
await graph_storage.search_labels(malicious_query)
# Inspect the query body that was sent to OpenSearch
assert client.search.called, "search should have been called"
call_kwargs = client.search.call_args
body = call_kwargs.kwargs.get("body") or call_kwargs[1].get("body")
# Extract the wildcard clause
should_clauses = body["query"]["bool"]["should"]
wildcard_clause = None
for clause in should_clauses:
if "wildcard" in clause:
wildcard_clause = clause["wildcard"]["entity_id"]["value"]
break
assert wildcard_clause is not None, "wildcard clause should exist"
# The wildcard value should NOT contain unescaped * or ? from the user input
# The outer * wrapping is fine (those are the intentional wildcards),
# but the inner user-provided * and ? must be escaped
# Expected: *test\*\?foo* (with the user's * and ? escaped)
inner_value = wildcard_clause[1:-1] # strip leading and trailing *
assert "\\*" in inner_value, (
f"User's '*' should be escaped as '\\*' in wildcard, got: {wildcard_clause}"
)
assert "\\?" in inner_value, (
f"User's '?' should be escaped as '\\?' in wildcard, got: {wildcard_clause}"
)
@pytest.mark.asyncio
async def test_wildcard_heavy_pattern_not_exploitable(self, graph_storage):
"""A series of ? chars should be escaped, not passed raw to OpenSearch."""
client = graph_storage.client
client.search.return_value = {"hits": {"hits": []}}
# Attack: many single-char wildcards cause exponential matching
attack_query = "?" * 50
await graph_storage.search_labels(attack_query)
call_kwargs = client.search.call_args
body = call_kwargs.kwargs.get("body") or call_kwargs[1].get("body")
should_clauses = body["query"]["bool"]["should"]
wildcard_clause = None
for clause in should_clauses:
if "wildcard" in clause:
wildcard_clause = clause["wildcard"]["entity_id"]["value"]
break
# None of the user's ? should appear as unescaped wildcards
# The value between the outer * delimiters should have all ? escaped
inner = wildcard_clause[1:-1]
# Count unescaped ? (i.e., ? not preceded by \)
unescaped_q = re.findall(r"(?<!\\)\?", inner)
assert len(unescaped_q) == 0, (
f"Found {len(unescaped_q)} unescaped '?' in wildcard pattern: {wildcard_clause}"
)
@pytest.mark.asyncio
async def test_backslash_escaped_in_wildcard(self, graph_storage):
"""Backslashes in user input must be double-escaped for the wildcard query."""
client = graph_storage.client
client.search.return_value = {"hits": {"hits": []}}
attack_query = "test\\*"
await graph_storage.search_labels(attack_query)
call_kwargs = client.search.call_args
body = call_kwargs.kwargs.get("body") or call_kwargs[1].get("body")
should_clauses = body["query"]["bool"]["should"]
wildcard_clause = None
for clause in should_clauses:
if "wildcard" in clause:
wildcard_clause = clause["wildcard"]["entity_id"]["value"]
break
# The backslash should be escaped first, then the * — so we get \\\\\\*
# In the final pattern between outer *...*, user's \ becomes \\ and * becomes \*
inner = wildcard_clause[1:-1]
assert "\\\\" in inner or "\\*" in inner, (
f"Backslash and * from user should be escaped in wildcard: {wildcard_clause}"
)
class TestPPLInjection:
"""Test that PPL string escape handles additional metacharacters."""
def test_escape_ppl_basic_quote(self, graph_storage):
"""Single quotes should be escaped."""
result = graph_storage._escape_ppl("it's a test")
assert "'" not in result.replace("\\'", ""), (
f"Unescaped quote found in: {result}"
)
def test_escape_ppl_backslash(self, graph_storage):
"""Backslashes should be escaped."""
result = graph_storage._escape_ppl("test\\path")
assert result == "test\\\\path"
def test_escape_ppl_newline_and_control_chars(self, graph_storage):
"""Newlines and control characters should be escaped/stripped."""
result = graph_storage._escape_ppl("line1\nline2\rline3\t")
# Control chars should either be stripped or escaped — no raw newlines
assert "\n" not in result, f"Raw newline in PPL literal: {repr(result)}"
assert "\r" not in result, f"Raw carriage return in PPL literal: {repr(result)}"
def test_escape_ppl_pipe_in_quotes_safe(self, graph_storage):
"""Pipe character inside a quoted string literal poses no injection risk."""
result = graph_storage._escape_ppl("entity | stats count()")
assert isinstance(result, str)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,185 @@
"""
Offline tests for OpenSearch support in LLM cache tools.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
pytest.importorskip(
"opensearchpy",
reason="opensearchpy is required for OpenSearch tool tests",
)
from lightrag.tools.clean_llm_query_cache import CleanupStats, CleanupTool
from lightrag.tools.migrate_llm_cache import MigrationTool
pytestmark = pytest.mark.offline
class FakeOpenSearchStorage:
def __init__(self, batches, workspace="test-workspace"):
self._batches = batches
self.workspace = workspace
self.deleted_batches = []
async def _iter_raw_docs(self, batch_size=1000):
for batch in self._batches:
yield batch
async def delete(self, ids):
self.deleted_batches.append(list(ids))
def _flatten(batches):
return [item for batch in batches for item in batch]
class TestCleanupToolOpenSearch:
@pytest.mark.asyncio
async def test_count_query_caches_opensearch(self):
tool = CleanupTool()
storage = FakeOpenSearchStorage(
[
[
{"_id": "mix:query:1", "_source": {}},
{"_id": "mix:keywords:1", "_source": {}},
{"_id": "default:extract:1", "_source": {}},
],
[
{"_id": "hybrid:query:1", "_source": {}},
{"_id": "local:keywords:1", "_source": {}},
{"_id": "other:key:1", "_source": {}},
],
]
)
counts = await tool.count_query_caches(storage, "OpenSearchKVStorage")
assert counts["mix"] == {"query": 1, "keywords": 1}
assert counts["hybrid"] == {"query": 1, "keywords": 0}
assert counts["local"] == {"query": 0, "keywords": 1}
assert counts["global"] == {"query": 0, "keywords": 0}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("cleanup_type", "expected_ids"),
[
(
"all",
[
"mix:query:1",
"mix:keywords:1",
"global:query:1",
"local:keywords:1",
],
),
("query", ["mix:query:1", "global:query:1"]),
("keywords", ["mix:keywords:1", "local:keywords:1"]),
],
)
async def test_delete_query_caches_opensearch(self, cleanup_type, expected_ids):
tool = CleanupTool()
tool.batch_size = 2
storage = FakeOpenSearchStorage(
[
[
{"_id": "mix:query:1", "_source": {}},
{"_id": "mix:keywords:1", "_source": {}},
],
[
{"_id": "global:query:1", "_source": {}},
{"_id": "local:keywords:1", "_source": {}},
{"_id": "default:extract:1", "_source": {}},
],
]
)
stats = CleanupStats()
await tool.delete_query_caches(
storage, "OpenSearchKVStorage", cleanup_type, stats
)
assert _flatten(storage.deleted_batches) == expected_ids
assert all(len(batch) <= 2 for batch in storage.deleted_batches)
assert stats.successfully_deleted == len(expected_ids)
assert stats.successful_batches == len(storage.deleted_batches)
def test_check_config_ini_for_storage_opensearch(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "config.ini").write_text("[opensearch]\nhosts = localhost:9200\n")
assert CleanupTool().check_config_ini_for_storage("OpenSearchKVStorage")
def test_get_storage_class_opensearch(self):
cleanup_cls = CleanupTool().get_storage_class("OpenSearchKVStorage")
migrate_cls = MigrationTool().get_storage_class("OpenSearchKVStorage")
assert cleanup_cls.__name__ == "OpenSearchKVStorage"
assert migrate_cls.__name__ == "OpenSearchKVStorage"
class TestMigrationToolOpenSearch:
@pytest.mark.asyncio
async def test_count_and_stream_default_caches_opensearch(self):
tool = MigrationTool()
storage = FakeOpenSearchStorage(
[
[
{"_id": "default:extract:1", "_source": {"return": "a"}},
{"_id": "mix:query:1", "_source": {"return": "ignored"}},
],
[
{"_id": "default:summary:1", "_source": {"return": "b"}},
{"_id": "default:extract:2", "_source": {"return": "c"}},
],
]
)
count = await tool.count_default_caches(storage, "OpenSearchKVStorage")
streamed = [
batch
async for batch in tool.stream_default_caches(
storage, "OpenSearchKVStorage", batch_size=2
)
]
assert count == 3
assert streamed == [
{
"default:extract:1": {"return": "a"},
"default:summary:1": {"return": "b"},
},
{"default:extract:2": {"return": "c"}},
]
def test_count_available_storage_types_includes_opensearch(
self, tmp_path, monkeypatch
):
monkeypatch.chdir(tmp_path)
(tmp_path / "config.ini").write_text("[opensearch]\nhosts = localhost:9200\n")
with patch.dict("os.environ", {}, clear=True):
assert MigrationTool().count_available_storage_types() == 2
@pytest.mark.asyncio
async def test_setup_storage_returns_effective_workspace(self, monkeypatch):
tool = MigrationTool()
fake_storage = SimpleNamespace(workspace="forced-workspace")
monkeypatch.setattr(tool, "check_env_vars", lambda _: True)
monkeypatch.setattr(
tool, "initialize_storage", AsyncMock(return_value=fake_storage)
)
monkeypatch.setattr(tool, "count_default_caches", AsyncMock(return_value=3))
with patch("builtins.input", return_value="5"):
storage, storage_name, workspace, total_count = await tool.setup_storage(
"Source", use_streaming=True
)
assert storage is fake_storage
assert storage_name == "OpenSearchKVStorage"
assert workspace == "forced-workspace"
assert total_count == 3
File diff suppressed because it is too large Load Diff
View File
@@ -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)

Some files were not shown because too many files have changed in this diff Show More