mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-09-28 11:43:00 +08:00
* fix(session): 避免归档任务阻塞队列 Worker 后序归档不再占用 Worker 等待前序任务,并根据 QueueFS work 识别和跳过无法恢复的孤儿归档。 * fix(session): 仅调度队首归档任务 同一 Session 只将最早的未完成归档放入 QueueFS,后续归档在前序结束后再依次入队,并兼容升级前已入队任务。 * fix(session): 降低队首归档调度的存储读取 用 QueueFS 运行时索引判断 Session 是否已有归档任务,正常完成后直接调度相邻 Archive,避免每次 Commit 和任务结束都扫描完整历史目录。 * fix(session): 恢复每个归档任务独立入队
313 lines
11 KiB
Python
313 lines
11 KiB
Python
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
|
|
# SPDX-License-Identifier: AGPL-3.0
|
|
|
|
"""Shared fixtures for OpenViking server tests."""
|
|
|
|
import asyncio
|
|
import shutil
|
|
import socket
|
|
import threading
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
import uvicorn
|
|
|
|
from openviking.models.embedder.base import DenseEmbedderBase, EmbedResult
|
|
from openviking.server.app import create_app
|
|
from openviking.server.config import ServerConfig
|
|
from openviking.server.identity import RequestContext, Role
|
|
from openviking.service.core import OpenVikingService
|
|
from openviking.service.task_tracker import get_task_tracker
|
|
from openviking.storage.queuefs import QueueManager, SessionCommitMsg, get_queue_manager
|
|
from openviking_cli.session.user_id import UserIdentifier
|
|
from openviking_cli.utils.config.embedding_config import EmbeddingConfig
|
|
from openviking_cli.utils.config.vlm_config import VLMConfig
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
|
TEST_TMP_DIR = PROJECT_ROOT / "test_data" / "tmp_server"
|
|
SDK_ROOT_API_KEY = "test-root-api-key"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sample data
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SAMPLE_MD_CONTENT = """\
|
|
# Sample Document
|
|
|
|
## Introduction
|
|
This is a sample markdown document for server testing.
|
|
|
|
## Features
|
|
- Feature 1: Resource management
|
|
- Feature 2: Semantic search
|
|
"""
|
|
|
|
|
|
def _install_fake_embedder(monkeypatch):
|
|
"""Use an in-process fake embedder so server tests never hit external APIs."""
|
|
|
|
class FakeEmbedder(DenseEmbedderBase):
|
|
def __init__(self, dimension: int = 2048):
|
|
super().__init__(model_name="test-fake-embedder")
|
|
self._dimension = dimension
|
|
|
|
def embed(self, text: str, is_query: bool = False) -> EmbedResult:
|
|
return EmbedResult(dense_vector=[0.1] * self._dimension)
|
|
|
|
def get_dimension(self) -> int:
|
|
return self._dimension
|
|
|
|
monkeypatch.setattr(EmbeddingConfig, "get_embedder", lambda self: FakeEmbedder(self.dimension))
|
|
return FakeEmbedder
|
|
|
|
|
|
def _install_fake_vlm(monkeypatch):
|
|
"""Use a fake VLM so server tests never hit external LLM APIs."""
|
|
|
|
async def _fake_get_completion(self, prompt, thinking=False):
|
|
if "extract user-private configuration items" in prompt:
|
|
return '{"values": {"api_key": "secret-xyz", "base_url": "https://example.com"}}'
|
|
return "# Test Summary\n\nFake summary for testing.\n\n## Details\nTest content."
|
|
|
|
async def _fake_get_vision_completion(self, prompt, images, thinking=False):
|
|
return "Fake image description for testing."
|
|
|
|
monkeypatch.setattr(VLMConfig, "is_available", lambda self: True)
|
|
monkeypatch.setattr(VLMConfig, "get_completion_async", _fake_get_completion)
|
|
monkeypatch.setattr(VLMConfig, "get_vision_completion_async", _fake_get_vision_completion)
|
|
|
|
|
|
def _install_session_commit_queue_fallback(service: OpenVikingService, monkeypatch) -> None:
|
|
"""Execute SessionCommit jobs when MockLocalAGFS cannot dequeue QueueFS entries."""
|
|
queue_manager = get_queue_manager()
|
|
original_enqueue = queue_manager.enqueue
|
|
queued_commit_tasks: set[str] = set()
|
|
tracker = get_task_tracker()
|
|
original_has_work = tracker.has_work
|
|
monkeypatch.setattr(
|
|
tracker,
|
|
"has_work",
|
|
lambda task_id: task_id in queued_commit_tasks or original_has_work(task_id),
|
|
)
|
|
|
|
async def enqueue_with_session_commit_fallback(queue_name, data):
|
|
if queue_name != QueueManager.SESSION_COMMIT:
|
|
return await original_enqueue(queue_name, data)
|
|
|
|
queued_commit_tasks.add(data["task_id"])
|
|
|
|
async def process_commit() -> None:
|
|
await asyncio.sleep(0)
|
|
msg = SessionCommitMsg(**data)
|
|
ctx = RequestContext(
|
|
user=UserIdentifier.from_dict(msg.user),
|
|
role=Role.USER,
|
|
)
|
|
queued_session = service.sessions.session(
|
|
ctx,
|
|
msg.session_id,
|
|
session_uri=msg.session_uri,
|
|
)
|
|
while True:
|
|
phase1 = await queued_session._read_phase1_meta(msg.archive_uri)
|
|
if phase1.get("status") == "ready" or await queued_session._archive_file_exists(
|
|
msg.archive_uri,
|
|
".failed.json",
|
|
):
|
|
break
|
|
await asyncio.sleep(0)
|
|
await queued_session.load()
|
|
try:
|
|
while not await queued_session.resume_queued_commit(msg):
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
queued_commit_tasks.discard(msg.task_id)
|
|
|
|
asyncio.create_task(process_commit())
|
|
return data["task_id"]
|
|
|
|
monkeypatch.setattr(queue_manager, "enqueue", enqueue_with_session_commit_fallback)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core fixtures: service + app + async client (HTTP API tests, in-process)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def temp_dir():
|
|
"""Create a unique temp directory per test, auto-cleanup."""
|
|
import uuid
|
|
|
|
unique_dir = TEST_TMP_DIR / uuid.uuid4().hex[:8]
|
|
unique_dir.mkdir(parents=True, exist_ok=True)
|
|
yield unique_dir
|
|
shutil.rmtree(unique_dir, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def sample_markdown_file(temp_dir: Path) -> Path:
|
|
"""Create a sample markdown file for resource tests."""
|
|
f = temp_dir / "sample.md"
|
|
f.write_text(SAMPLE_MD_CONTENT)
|
|
return f
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def upload_temp_dir(temp_dir: Path, monkeypatch) -> Path:
|
|
"""Use the per-test temp directory as the HTTP upload temp dir."""
|
|
config = SimpleNamespace(
|
|
storage=SimpleNamespace(get_upload_temp_dir=lambda: temp_dir),
|
|
)
|
|
monkeypatch.setattr(
|
|
"openviking.server.routers.resources.get_openviking_config",
|
|
lambda: config,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"openviking.server.routers.pack.get_openviking_config",
|
|
lambda: config,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"openviking.server.temp_upload_store.get_openviking_config",
|
|
lambda: config,
|
|
)
|
|
return temp_dir
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def service(temp_dir: Path, monkeypatch):
|
|
"""Create and initialize an OpenVikingService for in-process API tests."""
|
|
fake_embedder_cls = _install_fake_embedder(monkeypatch)
|
|
_install_fake_vlm(monkeypatch)
|
|
svc = OpenVikingService(
|
|
path=str(temp_dir / "data"), user=UserIdentifier.the_default_user("test_user")
|
|
)
|
|
await svc.initialize()
|
|
svc.viking_fs.query_embedder = fake_embedder_cls()
|
|
_install_session_commit_queue_fallback(svc, monkeypatch)
|
|
yield svc
|
|
await svc.close()
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def app(service: OpenVikingService):
|
|
"""Create FastAPI app with pre-initialized service (no auth)."""
|
|
from openviking.server.auth.plugins import DevAuthPlugin
|
|
from openviking.server.auth.registry import get_registry
|
|
from openviking.server.dependencies import set_service
|
|
|
|
config = ServerConfig()
|
|
fastapi_app = create_app(config=config, service=service)
|
|
# ASGITransport doesn't trigger lifespan, so wire up the service manually
|
|
set_service(service)
|
|
# Manually initialize auth plugin (lifespan not triggered in ASGI tests)
|
|
registry = get_registry()
|
|
if registry.get("dev") is None:
|
|
registry.register(DevAuthPlugin)
|
|
plugin_cls = registry.get("dev")
|
|
if plugin_cls is not None:
|
|
fastapi_app.state.auth_plugin = plugin_cls()
|
|
return fastapi_app
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def client(app):
|
|
"""httpx AsyncClient bound to the ASGI app (no real network)."""
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c:
|
|
yield c
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def client_with_resource(client, service, sample_markdown_file):
|
|
"""Client + a resource already added and processed."""
|
|
ctx = RequestContext(user=UserIdentifier.the_default_user(), role=Role.ROOT)
|
|
result = await service.resources.add_resource(
|
|
path=str(sample_markdown_file),
|
|
ctx=ctx,
|
|
reason="test resource",
|
|
wait=True,
|
|
)
|
|
yield client, result.get("root_uri", "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SDK fixtures: real uvicorn server + AsyncHTTPClient (end-to-end tests)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest_asyncio.fixture(scope="function")
|
|
async def running_server(temp_dir: Path, monkeypatch):
|
|
"""Start a real uvicorn server in a background thread."""
|
|
fake_embedder_cls = _install_fake_embedder(monkeypatch)
|
|
_install_fake_vlm(monkeypatch)
|
|
|
|
@asynccontextmanager
|
|
async def _noop_mcp_lifespan():
|
|
yield
|
|
|
|
monkeypatch.setattr("openviking.server.mcp_endpoint.mcp_lifespan", _noop_mcp_lifespan)
|
|
|
|
svc = OpenVikingService(
|
|
path=str(temp_dir / "sdk_data"), user=UserIdentifier.the_default_user("sdk_test_user")
|
|
)
|
|
await svc.initialize()
|
|
svc.viking_fs.query_embedder = fake_embedder_cls()
|
|
_install_session_commit_queue_fallback(svc, monkeypatch)
|
|
|
|
config = ServerConfig(root_api_key=SDK_ROOT_API_KEY)
|
|
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
|
|
for _ in range(50):
|
|
try:
|
|
r = httpx.get(f"http://127.0.0.1:{port}/health", timeout=1)
|
|
if r.status_code == 200:
|
|
break
|
|
except Exception:
|
|
time.sleep(0.1)
|
|
|
|
for _ in range(50):
|
|
if getattr(fastapi_app.state, "api_key_manager", None) is not None:
|
|
break
|
|
time.sleep(0.1)
|
|
else:
|
|
raise RuntimeError("APIKeyManager did not initialize for SDK server test")
|
|
|
|
manager = fastapi_app.state.api_key_manager
|
|
sdk_account_id = "sdk_test_account"
|
|
sdk_user_key = await manager.create_account(sdk_account_id, "sdk_test_user")
|
|
sdk_ctx = RequestContext(
|
|
user=UserIdentifier(sdk_account_id, "sdk_test_user"),
|
|
role=Role.ADMIN,
|
|
)
|
|
await svc.initialize_account_directories(sdk_ctx)
|
|
await svc.initialize_user_directories(sdk_ctx)
|
|
|
|
yield port, svc, sdk_user_key
|
|
|
|
server.should_exit = True
|
|
thread.join(timeout=5)
|
|
await svc.close()
|