mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 12:25:29 +08:00
418 lines
17 KiB
Python
418 lines
17 KiB
Python
"""Real SDK objects; storage/HTTP are explicit local fixtures, not live services."""
|
|
import asyncio
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from langbot_plugin.api.entities.builtin.rag import (
|
|
DocumentStatus,
|
|
FileMetadata,
|
|
FileObject,
|
|
IngestionContext,
|
|
)
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
CONNECTORS = ['DifyDatasetsConnector', 'FastGPTConnector', 'RAGFlowConnector']
|
|
|
|
|
|
def load_plugin(name):
|
|
# Runtime uses one process per installation. Clear package names for this test loader.
|
|
for key in list(sys.modules):
|
|
if key == 'components' or key.startswith('components.'):
|
|
del sys.modules[key]
|
|
sys.path.insert(0, str(ROOT / name))
|
|
try:
|
|
spec = importlib.util.spec_from_file_location('candidate_main', ROOT / name / 'main.py')
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
from importlib import import_module
|
|
engine = import_module('components.knowledge_engine.' + ('langrag' if name == 'LangRAG' else 'engine'))
|
|
return getattr(mod, name), getattr(engine, name)
|
|
finally:
|
|
sys.path.pop(0)
|
|
|
|
|
|
class StorageFixture:
|
|
"""Explicit fixture for Host's installation-bound storage, not production storage."""
|
|
def __init__(self):
|
|
self.data = {}
|
|
self.fail = False
|
|
# Optional ``key -> exception`` fault injection for one durable write.
|
|
self.write_fault = None
|
|
self.started = asyncio.Event()
|
|
self.release = asyncio.Event()
|
|
self.release.set()
|
|
# Host-read instrumentation: how many key listings were served, and
|
|
# one-shot gates that suspend the next read of one key so a test can land
|
|
# a concurrent write inside that reader's window.
|
|
self.keys_reads = 0
|
|
self.gates = {}
|
|
self.gate_entered = asyncio.Event()
|
|
self.gate_release = asyncio.Event()
|
|
|
|
def gate_read(self, key):
|
|
"""Suspend the next ``get_plugin_storage(key)`` until ``gate_release``.
|
|
|
|
The gated reader captures the value it was answered with before it
|
|
blocks, so the caller can change that record while the read is in flight
|
|
exactly like a response computed before a concurrent write. Only the
|
|
first read of ``key`` is gated; later readers pass through.
|
|
"""
|
|
self.gate_entered = asyncio.Event()
|
|
self.gate_release = asyncio.Event()
|
|
self.gates[key] = self.gate_entered
|
|
|
|
async def get_plugin_storage_keys(self):
|
|
if self.fail:
|
|
raise RuntimeError('fixture storage offline')
|
|
self.keys_reads += 1
|
|
return list(self.data)
|
|
|
|
async def get_plugin_storage(self, key):
|
|
if self.fail:
|
|
raise RuntimeError('fixture storage offline')
|
|
value = self.data[key]
|
|
entered = self.gates.pop(key, None)
|
|
if entered is not None:
|
|
entered.set()
|
|
await self.gate_release.wait()
|
|
return value
|
|
|
|
async def set_plugin_storage(self, key, value):
|
|
# A detached Host commit survives cancellation of the caller, like real RPC.
|
|
async def commit():
|
|
self.started.set()
|
|
await self.release.wait()
|
|
if self.write_fault is not None:
|
|
fault = self.write_fault(key)
|
|
if fault is not None:
|
|
raise fault
|
|
if self.fail:
|
|
raise RuntimeError('fixture ambiguous commit')
|
|
self.data[key] = value
|
|
await asyncio.shield(asyncio.create_task(commit()))
|
|
|
|
|
|
def bind(plugin_cls, storage):
|
|
plugin = plugin_cls()
|
|
for name in ['get_plugin_storage_keys', 'get_plugin_storage', 'set_plugin_storage']:
|
|
setattr(plugin, name, getattr(storage, name))
|
|
return plugin
|
|
|
|
|
|
def configuration(marker):
|
|
return {'api_base_url': 'https://fixture.invalid', 'api_key': marker,
|
|
'dify_apikey': marker, 'dataset_id': marker, 'dataset_ids': marker}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('name', CONNECTORS)
|
|
async def test_connector_restart_isolation_and_delete(name, monkeypatch):
|
|
pc, ec = load_plugin(name)
|
|
calls = []
|
|
async def service(request):
|
|
calls.append(request)
|
|
if request.method == 'GET':
|
|
return httpx.Response(200, json={'code': 0, 'data': []})
|
|
return httpx.Response(200, json={'code': 200 if name == 'FastGPTConnector' else 0})
|
|
client = httpx.AsyncClient
|
|
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kw: client(transport=httpx.MockTransport(service), **kw))
|
|
a, b = StorageFixture(), StorageFixture()
|
|
ea, eb = ec(), ec()
|
|
ea.plugin, eb.plugin = bind(pc, a), bind(pc, b)
|
|
config = configuration('A')
|
|
await ea.on_knowledge_base_create('same-kb', config)
|
|
config['api_key'] = 'MUTATED'
|
|
await eb.on_knowledge_base_create('same-kb', configuration('B'))
|
|
restarted = ec()
|
|
restarted.plugin = bind(pc, a)
|
|
# No acknowledged ingestion mapping exists; guessing a provider ID is unsafe.
|
|
assert await restarted.delete_document('same-kb', 'doc') is False
|
|
assert await eb.delete_document('same-kb', 'doc') is False
|
|
assert not any(request.method == 'DELETE' for request in calls)
|
|
# Mappings record the upstream target they were created on; a delete only
|
|
# replays against that same target.
|
|
await restarted._save_document('same-kb', 'doc', {'upstream_id': 'upstream-A', 'dataset_id': 'A', 'status': 'created', 'api_base_url': 'https://fixture.invalid'})
|
|
await eb._save_document('same-kb', 'doc', {'upstream_id': 'upstream-B', 'dataset_id': 'B', 'status': 'created', 'api_base_url': 'https://fixture.invalid'})
|
|
assert await restarted.delete_document('same-kb', 'doc') is True
|
|
assert calls[-1].headers['authorization'] == 'Bearer A'
|
|
assert await eb.delete_document('same-kb', 'doc') is True
|
|
assert calls[-1].headers['authorization'] == 'Bearer B'
|
|
await restarted.on_knowledge_base_delete('same-kb')
|
|
assert await restarted.delete_document('same-kb', 'doc') is False
|
|
assert await eb.delete_document('same-kb', 'doc') is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('name', CONNECTORS)
|
|
async def test_cancelled_config_commit_cannot_resurrect_deleted_kb(name):
|
|
pc, ec = load_plugin(name)
|
|
store = StorageFixture()
|
|
engine = ec()
|
|
engine.plugin = bind(pc, store)
|
|
store.release.clear()
|
|
# Empty credentials avoid the optional RAGFlow validation request.
|
|
write = asyncio.create_task(engine.on_knowledge_base_create('kb', {'marker': 'A'}))
|
|
try:
|
|
await asyncio.wait_for(store.started.wait(), .3)
|
|
write.cancel()
|
|
await asyncio.sleep(0)
|
|
write.cancel()
|
|
delete = asyncio.create_task(engine.on_knowledge_base_delete('kb'))
|
|
await asyncio.sleep(.01)
|
|
# The dispatched Host commit already holds the lock and is settled before
|
|
# release, so the delete is ordered behind it rather than fenced: a
|
|
# settled commit is not an ambiguous mutation. Ordinary cancellation of
|
|
# the caller therefore does not fence the knowledge base.
|
|
assert not delete.done()
|
|
store.release.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await write
|
|
await delete
|
|
finally:
|
|
store.release.set()
|
|
await asyncio.gather(write, return_exceptions=True)
|
|
# The tombstone is the last acknowledged commit: the cancelled create cannot
|
|
# resurrect the deleted knowledge base, and the survivor is not fenced.
|
|
restarted = ec()
|
|
restarted.plugin = bind(pc, store)
|
|
assert await restarted._load_config('kb') is None
|
|
assert await restarted.delete_document('kb', 'doc') is False
|
|
await restarted.on_knowledge_base_create('kb', {'marker': 'B'})
|
|
assert (await restarted._load_config('kb'))['marker'] == 'B'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_langrag_telemetry_is_per_plugin_and_survives_restart():
|
|
pc, _ = load_plugin('LangRAG')
|
|
sa, sb = StorageFixture(), StorageFixture()
|
|
a, b = bind(pc, sa), bind(pc, sb)
|
|
await a.initialize()
|
|
await b.initialize()
|
|
assert hasattr(a, 'telemetry'), 'Telemetry must belong to the installation plugin'
|
|
await a.telemetry.record_delete(collection_id='same', document_id='private-A', status='completed', duration_ms=1)
|
|
assert (await b.telemetry.snapshot())['recent']['delete'] == []
|
|
metrics = await b.telemetry.prometheus()
|
|
warning_line = next(line for line in metrics.splitlines() if line.startswith('langrag_alerts_active{') and 'severity="warning"' in line)
|
|
assert warning_line.endswith(' 0'), 'SDK persistence must not report JSONL-disabled warning'
|
|
a2 = bind(pc, sa)
|
|
await a2.initialize()
|
|
assert (await a2.telemetry.snapshot())['recent']['delete'][0]['document_id'] == 'private-A'
|
|
await a2.telemetry.clear()
|
|
a3 = bind(pc, sa)
|
|
await a3.initialize()
|
|
assert (await a3.telemetry.snapshot())['recent']['delete'] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_parser_is_offloop(monkeypatch):
|
|
load_plugin('LangRAG')
|
|
from components.knowledge_engine.parser import FileParser
|
|
import threading
|
|
parser = FileParser()
|
|
main_thread = threading.get_ident()
|
|
monkeypatch.setattr(parser, '_decode_text', lambda data: str(threading.get_ident()))
|
|
for filename in ['doc.txt', 'doc.unknown']:
|
|
assert await parser.parse(b'x', filename) != str(main_thread)
|
|
|
|
|
|
def ingest_context(config):
|
|
return IngestionContext(
|
|
file_object=FileObject(
|
|
metadata=FileMetadata(filename='doc.txt', file_size=12, mime_type='text/plain',
|
|
document_id='local-doc', knowledge_base_id='kb'),
|
|
storage_path='fixture-file'),
|
|
knowledge_base_id='kb', creation_settings=config,
|
|
)
|
|
|
|
|
|
def bind_mock_http(monkeypatch, handler):
|
|
client = httpx.AsyncClient
|
|
monkeypatch.setattr(
|
|
httpx, 'AsyncClient',
|
|
lambda **kw: client(transport=httpx.MockTransport(handler), **kw),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('name', CONNECTORS)
|
|
async def test_deterministic_validation_error_does_not_fence(name):
|
|
pc, ec = load_plugin(name)
|
|
store = StorageFixture()
|
|
engine = ec()
|
|
engine.plugin = bind(pc, store)
|
|
# Raised before any Host or provider call: deterministic, must not fence.
|
|
with pytest.raises(ValueError, match='64 KiB'):
|
|
await engine.on_knowledge_base_create('kb', {'blob': 'x' * 70000})
|
|
await engine.on_knowledge_base_create('kb', {'marker': 'ok'})
|
|
restarted = ec()
|
|
restarted.plugin = bind(pc, store)
|
|
assert (await restarted._load_config('kb'))['marker'] == 'ok'
|
|
|
|
|
|
class GatedStorageFixture(StorageFixture):
|
|
"""Suspends the first key listing so cancellation lands before any dispatch."""
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.keys_entered = asyncio.Event()
|
|
self.keys_gate = asyncio.Event()
|
|
|
|
async def get_plugin_storage_keys(self):
|
|
self.keys_entered.set()
|
|
await self.keys_gate.wait()
|
|
return await super().get_plugin_storage_keys()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('name', CONNECTORS)
|
|
async def test_cancellation_before_dispatch_does_not_fence(name):
|
|
pc, ec = load_plugin(name)
|
|
store = GatedStorageFixture()
|
|
engine = ec()
|
|
engine.plugin = bind(pc, store)
|
|
task = asyncio.create_task(engine.on_knowledge_base_create('kb', {'marker': 'A'}))
|
|
await store.keys_entered.wait()
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
store.keys_gate.set()
|
|
# No mutation was dispatched, so the knowledge base stays usable.
|
|
await engine.on_knowledge_base_create('kb', {'marker': 'B'})
|
|
assert (await engine._load_config('kb'))['marker'] == 'B'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('name', CONNECTORS)
|
|
async def test_ambiguous_provider_response_fences_only_that_knowledge_base(name, monkeypatch):
|
|
pc, ec = load_plugin(name)
|
|
|
|
async def service(request):
|
|
# Provider reports success but omits the upstream id: dispatched, unknown.
|
|
if request.url.path.endswith('/create-by-file'):
|
|
return httpx.Response(200, json={'document': {}})
|
|
if request.url.path.endswith('/localFile'):
|
|
return httpx.Response(200, json={'code': 200, 'data': {'collectionId': ''}})
|
|
return httpx.Response(200, json={'code': 0, 'data': []})
|
|
|
|
bind_mock_http(monkeypatch, service)
|
|
store = StorageFixture()
|
|
engine = ec()
|
|
plugin = bind(pc, store)
|
|
|
|
async def read_file(path):
|
|
return b'fixture file'
|
|
|
|
plugin.get_knowledge_file_stream = read_file
|
|
engine.plugin = plugin
|
|
cfg = configuration('A')
|
|
with pytest.raises(RuntimeError, match='reconciliation'):
|
|
await engine.ingest(ingest_context(cfg))
|
|
# Only the failing knowledge base is fenced; its sibling still works.
|
|
with pytest.raises(RuntimeError, match='fenced'):
|
|
await engine.on_knowledge_base_create('kb', cfg)
|
|
await engine.on_knowledge_base_create('other-kb', cfg)
|
|
# The fence is persisted, so a restarted worker still refuses that KB only.
|
|
restarted = ec()
|
|
restarted.plugin = bind(pc, store)
|
|
with pytest.raises(RuntimeError, match='fenced'):
|
|
await restarted.on_knowledge_base_create('kb', cfg)
|
|
assert (await restarted._load_config('other-kb')) is not None
|
|
# Deleting the knowledge base is the operator path out of the fence.
|
|
await restarted.on_knowledge_base_delete('kb')
|
|
await restarted.on_knowledge_base_create('kb', cfg)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('name', CONNECTORS)
|
|
async def test_ambiguous_transport_failure_is_reported_and_fenced(name, monkeypatch):
|
|
pc, ec = load_plugin(name)
|
|
|
|
async def service(request):
|
|
raise httpx.ReadTimeout('fixture timeout')
|
|
|
|
bind_mock_http(monkeypatch, service)
|
|
store = StorageFixture()
|
|
engine = ec()
|
|
plugin = bind(pc, store)
|
|
|
|
async def read_file(path):
|
|
return b'fixture file'
|
|
|
|
plugin.get_knowledge_file_stream = read_file
|
|
engine.plugin = plugin
|
|
cfg = configuration('A')
|
|
result = await engine.ingest(ingest_context(cfg))
|
|
assert result.status == DocumentStatus.FAILED
|
|
# The caller still gets a FAILED result, but the dispatched upload is unknown.
|
|
with pytest.raises(RuntimeError, match='fenced'):
|
|
await engine.on_knowledge_base_create('kb', cfg)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('name', CONNECTORS + ['LangRAG'])
|
|
async def test_installation_revocation_releases_process_local_state(name):
|
|
pc, ec = load_plugin(name)
|
|
plugin = pc()
|
|
engine = ec()
|
|
engine.plugin = plugin
|
|
await engine.initialize()
|
|
binding = ('workspace', 'installation')
|
|
lock = asyncio.Lock()
|
|
engine._state._locks[(binding, 'kb')] = lock
|
|
engine._state._fenced_keys(binding).add('ke.fence.v1.deadbeef')
|
|
engine._state._fence_loaded.add(binding)
|
|
if hasattr(engine._state, '_fence_load_locks'):
|
|
# The single-flight load lock and the fence generation belong to the
|
|
# binding too, so a revoked installation must not keep a stale pair.
|
|
engine._state._fence_load_locks[binding] = asyncio.Lock()
|
|
engine._state._fence_generation[binding] = 3
|
|
await plugin.on_installation_revoked(binding)
|
|
assert (binding, 'kb') not in engine._state._locks
|
|
assert binding not in engine._state._fenced
|
|
assert binding not in engine._state._fence_loaded
|
|
if hasattr(engine._state, '_fence_load_locks'):
|
|
assert binding not in engine._state._fence_load_locks
|
|
assert binding not in engine._state._fence_generation
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_langrag_fence_is_scoped_to_one_collection_and_survives_restart():
|
|
"""One knowledge base's ambiguous mutation must not block its siblings."""
|
|
|
|
_, ec = load_plugin('LangRAG')
|
|
engine = ec()
|
|
plugin = StorageFixture()
|
|
engine.plugin = plugin
|
|
await engine.initialize()
|
|
binding = engine._state.binding(plugin)
|
|
|
|
async def boom():
|
|
raise RuntimeError('host offline after dispatch')
|
|
|
|
async def ok():
|
|
return 7
|
|
|
|
with pytest.raises(RuntimeError, match='host offline'):
|
|
await engine._state.mutate(plugin, 'kb-a', boom, 'Host vector_upsert outcome unknown')
|
|
|
|
# Only the failing knowledge base is fenced.
|
|
assert engine._state.is_fenced(binding, 'kb-a')
|
|
assert not engine._state.is_fenced(binding, 'kb-b')
|
|
with pytest.raises(RuntimeError, match='fenced'):
|
|
await engine._state.run(plugin, 'kb-a', ok)
|
|
assert await engine._state.mutate(plugin, 'kb-b', ok, 'unrelated') == 7
|
|
|
|
# A restarted worker still refuses the fenced knowledge base.
|
|
restarted = ec()
|
|
restarted.plugin = plugin
|
|
with pytest.raises(RuntimeError, match='fenced'):
|
|
await restarted._state.run(plugin, 'kb-a', ok)
|
|
assert not restarted._state.is_fenced(restarted._state.binding(plugin), 'kb-b')
|
|
|
|
# Deleting the knowledge base clears the fence.
|
|
await restarted._state.clear_fence(plugin, 'kb-a')
|
|
assert await restarted._state.run(plugin, 'kb-a', ok) == 7
|