mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-10-01 17:57:49 +08:00
* feat(grep): integrate VikingDB bm25 keyword search for grep engine * fix(grep): address CI review feedback: max-size eviction to _count_cache, use Literal, Split regex alternation into individual keywords for bm25 (max 10) * fix(schema): use dynamic __version__ for schema_version and handle dev suffixes in version comparison * fix(schema): upsert data to vikingdb lack of content * chore: add benchmark for retrieval * fix(grep): vikingdb return 200 and no results means no matching content, not necessary to fallback to local fs * fix(benchmark): sub uri args; add report * refactor: code format by ruff * optimize: move grep config (engine and switch_to_remote_threshold) to ov.conf * optimize: auto adapt remote_return_limit by agg API; rm unnecessary params in keywords search * fix: adjust benchmark scripts * fix(grep): store full content for BM25; use PathScope depth; reduce redundant API calls * refactor: new benchmark * fix: step1 add resource by real code data * feat(benchmark): split grep benchmark into effectiveness/performance suites with async reindex * optimize (benchmark): adjust keywords and ground truth for testing * fix: truncate 64KB for content field * optimize: effectiveness add resource plainly * optimize: change param use of SearchByKeywords from "keywords" to "query" * optimize(benchmark): refactor effectiveness scripts * optimize: ensure raw data for content field * optimize: fulltext analyzer's stop-words only use symbols * fix: adapt to new ov cli for benchmark * optimize: reuse file content to avoid re-read AGFS file * optimize: tune grep vikingdb defaults and refresh bm25 benchmark scripts * optimize: benchmark client timeout * update README * fix: rm unused param * fix: default values in docs * optimize: increase truncate byte size to 1MB for content field for VikingDB * fix(logger): harden queued stream logging (#2786) * fix(logger): replace StreamHandler with QueueHandler+QueueListener to prevent thread deadlock When log.output='stdout' (default) and the server is managed by systemd, concurrent log writes can deadlock because logging.StreamHandler holds a thread lock across stream.flush() which blocks on systemd-piped file I/O. During session.commit() phase 2, multiple async coroutines (memory extraction, summarization) concurrently call logger.info()/warning() with large payloads. The first thread's flush() blocks on the pipe, while all subsequent threads block on handler.acquire() forever. This permanently silences the server log and prevents _write_done_file() from executing, leaving phase 2 hanging without .done. Fix: use QueueHandler + QueueListener from stdlib logging.handlers (Python 3.2+). QueueHandler.emit() does queue.put(record) with no lock or I/O, returning immediately. QueueListener has a dedicated single thread as the sole consumer touching the real StreamHandler, making lock contention impossible. Changes in _create_log_handler(): stdout/stderr branches now create a shared QueueListener with unbounded queue, returning QueueHandler instances to callers. _build_standard_handler() delegates formatter and filter setup to the real handler in the listener thread. Closes: #2752 * fix(logger): harden queued stream logging --------- Co-authored-by: njuboy11 <njuboy11@users.noreply.github.com> --------- Co-authored-by: Qin Haojie <qinhaojie.exe@bytedance.com> Co-authored-by: njuboy11 <njuboy11@users.noreply.github.com>
257 lines
8.6 KiB
Python
257 lines
8.6 KiB
Python
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
|
|
# SPDX-License-Identifier: AGPL-3.0
|
|
|
|
"""Shared fixtures for integration tests.
|
|
|
|
Automatically starts an OpenViking server in a background thread so that
|
|
AsyncHTTPClient integration tests can run without a manually started server process.
|
|
"""
|
|
|
|
import copy
|
|
import math
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import threading
|
|
import time
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
import uvicorn
|
|
|
|
from openviking import AsyncOpenViking
|
|
from openviking.server.app import create_app
|
|
from openviking.server.config import ServerConfig
|
|
from openviking.service.core import OpenVikingService
|
|
from openviking_cli.session.user_id import UserIdentifier
|
|
from openviking_cli.utils.config.open_viking_config import OpenVikingConfigSingleton
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
|
TEST_TMP_DIR = PROJECT_ROOT / "test_data" / "tmp_integration"
|
|
|
|
# ── Gemini integration test helpers ──────────────────────────────────────────
|
|
GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "")
|
|
requires_api_key = pytest.mark.skipif(not GOOGLE_API_KEY, reason="GOOGLE_API_KEY not set")
|
|
|
|
# ── Vault integration test helpers ──────────────────────────────────────────
|
|
VAULT_ADDR = os.environ.get("VAULT_ADDR", "http://127.0.0.1:8200")
|
|
VAULT_TOKEN = os.environ.get("VAULT_TOKEN", "")
|
|
requires_vault = pytest.mark.skipif(not VAULT_TOKEN, reason="VAULT_TOKEN not set")
|
|
|
|
# ── Volcengine KMS integration test helpers ──────────────────────────────────────────
|
|
VOLCENGINE_ACCESS_KEY = os.environ.get("VOLCENGINE_ACCESS_KEY", "")
|
|
VOLCENGINE_SECRET_KEY = os.environ.get("VOLCENGINE_SECRET_KEY", "")
|
|
VOLCENGINE_KMS_KEY_ID = os.environ.get("VOLCENGINE_KMS_KEY_ID", "")
|
|
VOLCENGINE_KMS_REGION = os.environ.get("VOLCENGINE_KMS_REGION", "cn-beijing")
|
|
requires_volcengine_kms = pytest.mark.skipif(
|
|
not (VOLCENGINE_ACCESS_KEY and VOLCENGINE_SECRET_KEY and VOLCENGINE_KMS_KEY_ID),
|
|
reason="VOLCENGINE_ACCESS_KEY, VOLCENGINE_SECRET_KEY, or VOLCENGINE_KMS_KEY_ID not set",
|
|
)
|
|
|
|
# ── Qdrant integration test helpers ─────────────────────────────────────────
|
|
QDRANT_URL = os.environ.get("QDRANT_URL", "http://127.0.0.1:6333").rstrip("/")
|
|
QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", "")
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _qdrant_available() -> bool:
|
|
try:
|
|
headers = {"api-key": QDRANT_API_KEY} if QDRANT_API_KEY else None
|
|
response = httpx.get(
|
|
f"{QDRANT_URL}/collections",
|
|
headers=headers,
|
|
timeout=2.0,
|
|
)
|
|
return response.status_code == 200
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
requires_qdrant = pytest.mark.qdrant
|
|
|
|
|
|
def pytest_configure(config):
|
|
config.addinivalue_line("markers", "qdrant: requires a reachable Qdrant instance")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _skip_when_qdrant_unavailable(request):
|
|
if request.node.get_closest_marker("qdrant") is None:
|
|
return
|
|
if not _qdrant_available():
|
|
pytest.skip(f"Qdrant not available at {QDRANT_URL}")
|
|
|
|
|
|
# (model_name, default_dimension, token_limit)
|
|
GEMINI_MODELS = [
|
|
("gemini-embedding-2-preview", 3072, 8192),
|
|
]
|
|
|
|
|
|
def _local_engine_available() -> bool:
|
|
try:
|
|
from openviking.storage.vectordb.engine import ENGINE_VARIANT
|
|
except Exception:
|
|
return False
|
|
return ENGINE_VARIANT != "unavailable"
|
|
|
|
|
|
requires_engine = pytest.mark.skipif(
|
|
not _local_engine_available(),
|
|
reason="local vectordb engine unavailable",
|
|
)
|
|
|
|
|
|
def l2_norm(vec: list[float]) -> float:
|
|
"""Compute L2 norm of a vector."""
|
|
return math.sqrt(sum(v * v for v in vec))
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def gemini_embedder():
|
|
"""Session-scoped GeminiDenseEmbedder for integration tests."""
|
|
if not GOOGLE_API_KEY:
|
|
pytest.skip("GOOGLE_API_KEY not set")
|
|
try:
|
|
from openviking.models.embedder.gemini_embedders import GeminiDenseEmbedder
|
|
except (ImportError, ModuleNotFoundError, AttributeError):
|
|
pytest.skip("google-genai not installed")
|
|
return GeminiDenseEmbedder("gemini-embedding-2-preview", api_key=GOOGLE_API_KEY, dimension=768)
|
|
|
|
|
|
def gemini_config_dict(
|
|
model: str,
|
|
dim: int,
|
|
query_param: str | None = None,
|
|
doc_param: str | None = None,
|
|
) -> dict:
|
|
"""Build a minimal embedded-mode config for Gemini-backed integration tests."""
|
|
return {
|
|
"storage": {
|
|
"workspace": str(TEST_TMP_DIR / "gemini"),
|
|
"agfs": {"backend": "local"},
|
|
"vectordb": {"name": "test", "backend": "local", "project": "default"},
|
|
},
|
|
"embedding": {
|
|
"dense": {
|
|
"provider": "gemini",
|
|
"api_key": GOOGLE_API_KEY,
|
|
"model": model,
|
|
"dimension": dim,
|
|
**({"query_param": query_param} if query_param else {}),
|
|
**({"document_param": doc_param} if doc_param else {}),
|
|
}
|
|
},
|
|
}
|
|
|
|
|
|
async def teardown_ov_client() -> None:
|
|
"""Reset singleton client/config state used by embedded integration tests."""
|
|
await AsyncOpenViking.reset()
|
|
OpenVikingConfigSingleton.reset_instance()
|
|
|
|
|
|
async def make_ov_client(config_dict: dict, data_path: str) -> AsyncOpenViking:
|
|
"""Create an AsyncOpenViking client from an explicit config dict."""
|
|
if not GOOGLE_API_KEY:
|
|
pytest.skip("GOOGLE_API_KEY not set")
|
|
try:
|
|
from openviking.models.embedder.gemini_embedders import GeminiDenseEmbedder # noqa: F401
|
|
except (ImportError, ModuleNotFoundError, AttributeError):
|
|
pytest.skip("google-genai not installed")
|
|
|
|
await teardown_ov_client()
|
|
|
|
workspace = Path(data_path)
|
|
shutil.rmtree(workspace, ignore_errors=True)
|
|
workspace.mkdir(parents=True, exist_ok=True)
|
|
|
|
effective_config = copy.deepcopy(config_dict)
|
|
storage = effective_config.setdefault("storage", {})
|
|
storage["workspace"] = str(workspace)
|
|
storage.setdefault("agfs", {"backend": "local"})
|
|
storage.setdefault("vectordb", {"name": "test", "backend": "local", "project": "default"})
|
|
|
|
OpenVikingConfigSingleton.initialize(config_dict=effective_config)
|
|
|
|
client = AsyncOpenViking(path=str(workspace))
|
|
await client.initialize()
|
|
return client
|
|
|
|
|
|
def sample_markdown(base_dir: Path, slug: str, content: str) -> Path:
|
|
"""Write a markdown file for an integration test case."""
|
|
path = base_dir / f"{slug}.md"
|
|
path.write_text(content, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def gemini_ov_client(tmp_path):
|
|
"""Provide a Gemini-backed OpenViking client and its model metadata."""
|
|
model = "gemini-embedding-2-preview"
|
|
dim = 768
|
|
client = await make_ov_client(gemini_config_dict(model, dim), str(tmp_path / "ov_gemini"))
|
|
try:
|
|
yield client, model, dim
|
|
finally:
|
|
await teardown_ov_client()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def temp_dir():
|
|
"""Create temp directory for the whole test session."""
|
|
shutil.rmtree(TEST_TMP_DIR, ignore_errors=True)
|
|
TEST_TMP_DIR.mkdir(parents=True, exist_ok=True)
|
|
yield TEST_TMP_DIR
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def server_url(temp_dir):
|
|
"""Start a real uvicorn server in a background thread.
|
|
|
|
Returns the base URL (e.g. ``http://127.0.0.1:<port>``).
|
|
The server is automatically shut down after the test session.
|
|
"""
|
|
import asyncio
|
|
|
|
loop = asyncio.new_event_loop()
|
|
|
|
svc = OpenVikingService(
|
|
path=str(temp_dir / "data"), user=UserIdentifier.the_default_user("test_user")
|
|
)
|
|
loop.run_until_complete(svc.initialize())
|
|
|
|
config = ServerConfig()
|
|
fastapi_app = create_app(config=config, service=svc)
|
|
|
|
# Find a free port
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
port = s.getsockname()[1]
|
|
|
|
uvi_config = uvicorn.Config(fastapi_app, host="127.0.0.1", port=port, log_level="warning")
|
|
server = uvicorn.Server(uvi_config)
|
|
thread = threading.Thread(target=server.run, daemon=True)
|
|
thread.start()
|
|
|
|
# Wait for server ready
|
|
url = f"http://127.0.0.1:{port}"
|
|
for _ in range(50):
|
|
try:
|
|
r = httpx.get(f"{url}/health", timeout=1)
|
|
if r.status_code == 200:
|
|
break
|
|
except Exception:
|
|
time.sleep(0.1)
|
|
|
|
yield url
|
|
|
|
server.should_exit = True
|
|
thread.join(timeout=5)
|
|
loop.run_until_complete(svc.close())
|
|
loop.close()
|