mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-31 04:45:23 +08:00
579 lines
24 KiB
Python
579 lines
24 KiB
Python
"""RAGFlow connector: fail-closed fences and upstream-bound document mappings.
|
|
|
|
Host storage and the provider are local fixtures; no live deployment is
|
|
contacted. The behaviour asserted here is scoped to RAGFlow, so it lives in its
|
|
own file rather than in the shared per-connector fixtures.
|
|
"""
|
|
import asyncio
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from test_shared_state import (
|
|
StorageFixture,
|
|
bind,
|
|
configuration,
|
|
ingest_context,
|
|
load_plugin,
|
|
)
|
|
|
|
from langbot_plugin.api.entities.builtin.rag import DocumentStatus
|
|
|
|
FENCE_KEY_PREFIX = 'ke.fence.v1.'
|
|
|
|
|
|
def fence_records(store):
|
|
"""Persisted fence markers that still require reconciliation."""
|
|
return [json.loads(value) for key, value in store.data.items()
|
|
if key.startswith(FENCE_KEY_PREFIX) and json.loads(value) is not None]
|
|
|
|
|
|
def provider(monkeypatch, handler):
|
|
"""Route the connector's HTTP through a recording mock transport."""
|
|
calls = []
|
|
|
|
async def service(request):
|
|
calls.append(request)
|
|
response = handler(request)
|
|
if asyncio.iscoroutine(response):
|
|
response = await response
|
|
return response
|
|
|
|
client = httpx.AsyncClient
|
|
monkeypatch.setattr(
|
|
httpx, 'AsyncClient',
|
|
lambda **kw: client(transport=httpx.MockTransport(service), **kw),
|
|
)
|
|
return calls
|
|
|
|
|
|
def acknowledge(request):
|
|
if request.method == 'POST' and request.url.path.endswith('/documents'):
|
|
return httpx.Response(200, json={'code': 0, 'data': [{'id': 'upstream-1'}]})
|
|
if request.method == 'DELETE':
|
|
return httpx.Response(200, json={'code': 0})
|
|
return httpx.Response(200, json={'code': 0, 'data': []})
|
|
|
|
|
|
def engine_with(monkeypatch, store, handler):
|
|
calls = provider(monkeypatch, handler)
|
|
pc, ec = load_plugin('RAGFlowConnector')
|
|
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
|
|
return engine, calls
|
|
|
|
|
|
def created_mapping(upstream_target, dataset_id='A'):
|
|
return {'upstream_id': 'upstream-1', 'dataset_id': dataset_id,
|
|
'api_base_url': upstream_target, 'status': 'created'}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unreadable_fence_state_refuses_without_dispatch_and_recovers(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
await engine._save_config('kb', configuration('A'))
|
|
await engine._save_document('kb', 'doc', created_mapping('https://fixture.invalid'))
|
|
|
|
store.fail = True
|
|
# An unreadable fence record is not the same as "no fences": the mutation is
|
|
# refused before anything is dispatched.
|
|
with pytest.raises(RuntimeError, match='Could not read persisted fences'):
|
|
await engine.delete_document('kb', 'doc')
|
|
assert calls == []
|
|
|
|
store.fail = False
|
|
# The failed read was not cached, so the next attempt re-reads and proceeds.
|
|
assert await engine.delete_document('kb', 'doc') is True
|
|
assert [request.method for request in calls] == ['DELETE']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_upload_dispatch_fences_and_keeps_the_pending_mapping(monkeypatch):
|
|
dispatched = asyncio.Event()
|
|
|
|
async def handler(request):
|
|
if request.method == 'POST' and request.url.path.endswith('/documents'):
|
|
dispatched.set()
|
|
await asyncio.sleep(30)
|
|
return acknowledge(request)
|
|
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, handler)
|
|
task = asyncio.create_task(engine.ingest(ingest_context(configuration('A'))))
|
|
await asyncio.wait_for(dispatched.wait(), 5)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
# The upload may have committed upstream, so the knowledge base stays fenced
|
|
# until an operator reconciles it, in process and in durable storage.
|
|
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
|
|
assert [record['kb_id'] for record in fence_records(store)] == ['kb']
|
|
assert [request.method for request in calls] == ['POST']
|
|
mapping = await engine._load_document('kb', 'local-doc')
|
|
assert mapping['status'] == 'pending', 'a cancelled upload must not advance the mapping'
|
|
assert mapping['upstream_id'] == ''
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_delete_dispatch_fences_and_keeps_the_created_mapping(monkeypatch):
|
|
dispatched = asyncio.Event()
|
|
|
|
async def handler(request):
|
|
if request.method == 'DELETE':
|
|
dispatched.set()
|
|
await asyncio.sleep(30)
|
|
return acknowledge(request)
|
|
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, handler)
|
|
await engine._save_config('kb', configuration('A'))
|
|
await engine._save_document('kb', 'doc', created_mapping('https://fixture.invalid'))
|
|
|
|
task = asyncio.create_task(engine.delete_document('kb', 'doc'))
|
|
await asyncio.wait_for(dispatched.wait(), 5)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
|
|
assert [record['kb_id'] for record in fence_records(store)] == ['kb']
|
|
assert [request.method for request in calls] == ['DELETE']
|
|
mapping = await engine._load_document('kb', 'doc')
|
|
assert mapping['status'] == 'created', 'an unacknowledged delete must not tombstone the mapping'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_rejection_leaves_no_fence(monkeypatch):
|
|
async def handler(request):
|
|
if request.method == 'POST' and request.url.path.endswith('/documents'):
|
|
return httpx.Response(400, text='rejected')
|
|
return acknowledge(request)
|
|
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, handler)
|
|
result = await engine.ingest(ingest_context(configuration('A')))
|
|
assert result.status == DocumentStatus.FAILED
|
|
|
|
# The provider refused the request, which is an unambiguous answer: the
|
|
# pre-dispatch intent was cleared again and the knowledge base is usable.
|
|
assert not engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
|
|
assert fence_records(store) == []
|
|
await engine.on_knowledge_base_create('kb', configuration('A'))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mapping_of_another_upstream_is_refused_before_dispatch(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
await engine._save_config('kb', configuration('A'))
|
|
await engine._save_document('kb', 'doc', created_mapping('https://other.invalid'))
|
|
|
|
with pytest.raises(RuntimeError, match='Upstream target changed'):
|
|
await engine.delete_document('kb', 'doc')
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mapping_without_a_recorded_upstream_is_refused_before_dispatch(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
await engine._save_config('kb', configuration('A'))
|
|
# Historical mapping, written before the upstream target was recorded.
|
|
await engine._save_document('kb', 'doc', {'upstream_id': 'upstream-1', 'dataset_id': 'A',
|
|
'status': 'created'})
|
|
|
|
with pytest.raises(RuntimeError, match='Upstream target changed'):
|
|
await engine.delete_document('kb', 'doc')
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_equivalent_configuration_of_the_recorded_upstream_is_accepted(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
config = configuration('A')
|
|
result = await engine.ingest(ingest_context(config))
|
|
assert result.status == DocumentStatus.PROCESSING
|
|
mapping = await engine._load_document('kb', 'local-doc')
|
|
assert mapping['api_base_url'] == 'https://fixture.invalid', 'the canonical target is recorded'
|
|
|
|
# Casing, a default port and a trailing slash never distinguish deployments.
|
|
await engine._save_config('kb', {**config, 'api_base_url': 'HTTPS://FIXTURE.INVALID:443/'})
|
|
assert await engine.delete_document('kb', 'local-doc') is True
|
|
assert [request.method for request in calls] == ['POST', 'POST', 'DELETE']
|
|
|
|
|
|
def fence_key_fault(shared_state, message):
|
|
def fault(key):
|
|
return RuntimeError(message) if key.startswith(shared_state.FENCE_KEY_PREFIX) else None
|
|
return fault
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unpersistable_fence_aborts_the_upload_before_the_request(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
from components import shared_state
|
|
|
|
store.write_fault = fence_key_fault(shared_state, 'fixture fence write rejected')
|
|
|
|
# The pre-written protection cannot be persisted, so the upload must not be
|
|
# dispatched at all.
|
|
with pytest.raises(shared_state.FencePersistError, match='Could not persist fence'):
|
|
await engine.ingest(ingest_context(configuration('A')))
|
|
assert calls == []
|
|
# A mutation that was never dispatched leaves no fence behind...
|
|
assert not engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
|
|
assert fence_records(store) == []
|
|
# ...only the durable pre-dispatch intent it had already recorded.
|
|
assert (await engine._load_document('kb', 'local-doc'))['status'] == 'pending'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unpersistable_fence_aborts_the_delete_before_the_request(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
from components import shared_state
|
|
|
|
await engine._save_config('kb', configuration('A'))
|
|
await engine._save_document('kb', 'doc', created_mapping('https://fixture.invalid'))
|
|
|
|
store.write_fault = fence_key_fault(shared_state, 'fixture fence write rejected')
|
|
with pytest.raises(shared_state.FencePersistError, match='Could not persist fence'):
|
|
await engine.delete_document('kb', 'doc')
|
|
assert calls == []
|
|
assert not engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
|
|
assert fence_records(store) == []
|
|
# The delete was never dispatched, so its mapping is still the recorded one.
|
|
assert (await engine._load_document('kb', 'doc'))['status'] == 'created'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_response_that_fails_validation_keeps_the_fence(monkeypatch):
|
|
async def handler(request):
|
|
if request.method == 'POST' and request.url.path.endswith('/documents'):
|
|
# The provider answered 200 with a body the connector cannot decode:
|
|
# the upload may have committed, so the outcome is unresolved.
|
|
return httpx.Response(200, text='not json')
|
|
return acknowledge(request)
|
|
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, handler)
|
|
result = await engine.ingest(ingest_context(configuration('A')))
|
|
assert result.status == DocumentStatus.FAILED
|
|
|
|
# The whole mutation — response validation included — is inside the fence, so
|
|
# the unparsable answer keeps the knowledge base fenced.
|
|
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
|
|
assert [record['kb_id'] for record in fence_records(store)] == ['kb']
|
|
assert [request.method for request in calls] == ['POST']
|
|
# The next mutation for this knowledge base is refused rather than dispatched
|
|
# over the unresolved upload.
|
|
with pytest.raises(RuntimeError, match='fenced'):
|
|
await engine.delete_document('kb', 'local-doc')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unresolved_graphrag_keeps_the_fence_and_blocks_raptor(monkeypatch):
|
|
async def handler(request):
|
|
if request.url.path.endswith('/run_graphrag'):
|
|
raise httpx.ReadTimeout('fixture graphrag timeout')
|
|
return acknowledge(request)
|
|
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, handler)
|
|
config = {**configuration('A'), 'auto_graphrag': True, 'auto_raptor': True}
|
|
result = await engine.ingest(ingest_context(config))
|
|
assert result.status == DocumentStatus.FAILED
|
|
|
|
# The unknown GraphRAG outcome is unresolved: the chain stops there instead of
|
|
# running RAPTOR, whose success would have cleared the shared fence.
|
|
paths = [request.url.path for request in calls]
|
|
assert paths == [
|
|
'/api/v1/datasets/A/documents',
|
|
'/api/v1/datasets/A/chunks',
|
|
'/api/v1/datasets/A/run_graphrag',
|
|
], 'the chain stops at the unresolved GraphRAG trigger'
|
|
assert not any(path.endswith('/run_raptor') for path in paths)
|
|
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
|
|
assert [record['kb_id'] for record in fence_records(store)] == ['kb']
|
|
with pytest.raises(RuntimeError, match='fenced'):
|
|
await engine.on_knowledge_base_create('kb', configuration('A'))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_cancellation_after_ack_before_the_tombstone_keeps_the_fence(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
await engine._save_config('kb', configuration('A'))
|
|
await engine._save_document('kb', 'doc', created_mapping('https://fixture.invalid'))
|
|
|
|
# The documented window: the provider has acknowledged the delete, the Host
|
|
# tombstone has not been written yet.
|
|
entered = asyncio.Event()
|
|
release = asyncio.Event()
|
|
record = engine._save_document
|
|
|
|
async def gated_record(kb_id, host_document_id, value):
|
|
if value.get('status') == 'deleted':
|
|
entered.set()
|
|
await release.wait()
|
|
return await record(kb_id, host_document_id, value)
|
|
|
|
monkeypatch.setattr(engine, '_save_document', gated_record)
|
|
task = asyncio.create_task(engine.delete_document('kb', 'doc'))
|
|
await asyncio.wait_for(entered.wait(), 5)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
release.set()
|
|
|
|
# The delete was acknowledged upstream but its Host record does not exist, so
|
|
# the protection must not have been released: a retry would replay the delete.
|
|
assert (await engine._load_document('kb', 'doc'))['status'] == 'created'
|
|
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
|
|
assert [record['kb_id'] for record in fence_records(store)] == ['kb']
|
|
with pytest.raises(RuntimeError, match='fenced'):
|
|
await engine.delete_document('kb', 'doc')
|
|
assert [request.method for request in calls] == ['DELETE']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_later_success_never_clears_an_unresolved_fence(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
from components import shared_state
|
|
|
|
plugin = engine.plugin
|
|
await engine._state.fence(plugin, 'kb', 'earlier upload outcome unknown')
|
|
|
|
ran = []
|
|
|
|
async def later_mutation():
|
|
ran.append(1)
|
|
return 'dispatched'
|
|
|
|
# The later mutation is refused instead of running and releasing the earlier
|
|
# operation's fence as if its own success had resolved it.
|
|
with pytest.raises(shared_state.FencedKnowledgeBaseError, match='unresolved'):
|
|
await engine._state.dispatched(plugin, 'kb', later_mutation, 'later mutation')
|
|
assert ran == []
|
|
assert engine._state.is_fenced(engine._state.binding(plugin), 'kb')
|
|
assert [record['kb_id'] for record in fence_records(store)] == ['kb']
|
|
|
|
|
|
def collection_context(config, collection_id):
|
|
"""An ingestion context whose collection id is not the knowledge-base id."""
|
|
context = ingest_context(config)
|
|
context.collection_id = collection_id
|
|
return context
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_differing_collection_and_knowledge_base_ids_share_one_identity(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
from components import shared_state
|
|
|
|
plugin = engine.plugin
|
|
binding = engine._state.binding(plugin)
|
|
# The fence is taken for the knowledge base the Host names; an ingestion
|
|
# context that carries a different collection id must observe the same one.
|
|
await engine._state.fence(plugin, 'kb', 'earlier upload outcome unknown')
|
|
context = collection_context(configuration('A'), 'collection-1')
|
|
with pytest.raises(shared_state.FencedKnowledgeBaseError):
|
|
await engine.ingest(context)
|
|
assert calls == [], 'a fenced knowledge base dispatches nothing'
|
|
|
|
await engine._state.clear_fence(plugin, 'kb')
|
|
result = await engine.ingest(context)
|
|
assert result.status == DocumentStatus.PROCESSING
|
|
|
|
# One identity for the mapping too: it is written under the knowledge-base
|
|
# key, so the deletion that names the knowledge base finds it.
|
|
assert (await engine._load_document('kb', 'local-doc'))['upstream_id'] == 'upstream-1'
|
|
assert await engine.delete_document('kb', 'local-doc') is True
|
|
assert [request.method for request in calls] == ['POST', 'POST', 'DELETE']
|
|
assert (await engine._load_document('kb', 'local-doc'))['status'] == 'deleted'
|
|
assert not engine._state.is_fenced(binding, 'kb')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_under_another_knowledge_base_never_adopts_its_mapping(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
|
|
# Knowledge base A's mapping and configuration name the upstream document;
|
|
# knowledge base B has neither.
|
|
cfg = configuration('A')
|
|
await engine._save_config('A', cfg)
|
|
await engine._save_document('A', 'shared-doc', created_mapping('https://fixture.invalid'))
|
|
|
|
# Deleting under B must not resolve, copy or tombstone A's record: the
|
|
# operation is refused with nothing dispatched and nothing written under B.
|
|
assert await engine.delete_document('B', 'shared-doc') is False
|
|
assert calls == []
|
|
assert engine._document_key('B', 'shared-doc') not in store.data
|
|
assert engine._key('B') not in store.data
|
|
assert (await engine._load_document('A', 'shared-doc'))['status'] == 'created'
|
|
|
|
# A's own delete still reaches the provider: B never consumed its record.
|
|
assert await engine.delete_document('A', 'shared-doc') is True
|
|
assert [request.method for request in calls] == ['DELETE']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pending_record_of_another_knowledge_base_does_not_block_ingest(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
|
|
# A pending record under knowledge base A is A's upload intent, not B's.
|
|
await engine._save_document('A', 'local-doc',
|
|
{'upstream_id': '', 'dataset_id': 'A',
|
|
'api_base_url': 'https://fixture.invalid', 'status': 'pending'})
|
|
before = store.data[engine._document_key('A', 'local-doc')]
|
|
|
|
context = ingest_context(configuration('B'))
|
|
context.knowledge_base_id = 'B'
|
|
result = await engine.ingest(context)
|
|
assert result.status == DocumentStatus.PROCESSING
|
|
|
|
# B recorded only its own mapping, and A's pending record is untouched: B
|
|
# neither treated A's intent as its own nor wrote under A.
|
|
assert (await engine._load_document('B', 'local-doc'))['status'] == 'created'
|
|
assert (await engine._load_document('A', 'local-doc'))['status'] == 'pending'
|
|
assert store.data[engine._document_key('A', 'local-doc')] == before
|
|
assert [request.method for request in calls] == ['POST', 'POST']
|
|
|
|
|
|
INGESTION_STEPS = [
|
|
pytest.param('/documents', {}, id='upload'),
|
|
pytest.param('/chunks', {}, id='parse'),
|
|
pytest.param('/run_graphrag', {'auto_graphrag': True}, id='graphrag'),
|
|
pytest.param('/run_raptor', {'auto_raptor': True}, id='raptor'),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('status, fenced', [(503, True), (400, False)])
|
|
@pytest.mark.parametrize('suffix, extra', INGESTION_STEPS)
|
|
async def test_ingestion_failure_classification_on_every_step(
|
|
monkeypatch, suffix, extra, status, fenced):
|
|
async def handler(request):
|
|
if request.url.path.endswith(suffix):
|
|
return httpx.Response(status, text='fixture answer')
|
|
return acknowledge(request)
|
|
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, handler)
|
|
result = await engine.ingest(ingest_context({**configuration('A'), **extra}))
|
|
assert result.status == DocumentStatus.FAILED
|
|
|
|
# A gateway failure is not proof that the request was not applied, so the
|
|
# knowledge base stays fenced; a rejection is a resolved answer.
|
|
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb') is fenced
|
|
assert bool(fence_records(store)) is fenced
|
|
|
|
if fenced:
|
|
# The next mutation is refused instead of being dispatched over the
|
|
# unresolved one.
|
|
with pytest.raises(RuntimeError, match='fenced'):
|
|
await engine.delete_document('kb', 'local-doc')
|
|
else:
|
|
# The rejection was resolved, so the knowledge base is usable again.
|
|
await engine.delete_document('kb', 'local-doc')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('status, fenced', [(503, True), (400, False)])
|
|
async def test_delete_failure_classification(monkeypatch, status, fenced):
|
|
async def handler(request):
|
|
if request.method == 'DELETE':
|
|
return httpx.Response(status, text='fixture answer')
|
|
return acknowledge(request)
|
|
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, handler)
|
|
await engine._save_config('kb', configuration('A'))
|
|
await engine._save_document('kb', 'doc', created_mapping('https://fixture.invalid'))
|
|
|
|
assert await engine.delete_document('kb', 'doc') is False
|
|
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb') is fenced
|
|
assert bool(fence_records(store)) is fenced
|
|
assert (await engine._load_document('kb', 'doc'))['status'] == 'created'
|
|
|
|
if fenced:
|
|
with pytest.raises(RuntimeError, match='fenced'):
|
|
await engine.delete_document('kb', 'doc')
|
|
assert [request.method for request in calls] == ['DELETE']
|
|
else:
|
|
# The rejection released the fence, so the delete may be retried.
|
|
assert await engine.delete_document('kb', 'doc') is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fence_snapshot_read_before_a_clear_is_discarded(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
from components import shared_state
|
|
|
|
state, plugin = engine._state, engine.plugin
|
|
binding = state.binding(plugin)
|
|
await state.fence(plugin, 'kb', 'earlier upload outcome unknown')
|
|
|
|
# The fence is read once for the installation, and the read is answered with
|
|
# the marker that existed before the clear below lands.
|
|
store.gate_read(shared_state._kb_fence_key('kb'))
|
|
load = asyncio.create_task(state._load_fences(plugin, binding))
|
|
await asyncio.wait_for(store.gate_entered.wait(), 5)
|
|
await state.clear_fence(plugin, 'kb')
|
|
assert not state.is_fenced(binding, 'kb')
|
|
store.gate_release.set()
|
|
await load
|
|
|
|
# The snapshot taken before the clear is discarded instead of re-fencing the
|
|
# knowledge base the operator just cleared.
|
|
assert not state.is_fenced(binding, 'kb')
|
|
|
|
async def operation():
|
|
return 'ran'
|
|
|
|
assert await state.run(plugin, 'kb', operation) == 'ran'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fence_state_is_read_once_per_installation(monkeypatch):
|
|
store = StorageFixture()
|
|
engine, calls = engine_with(monkeypatch, store, acknowledge)
|
|
from components import shared_state
|
|
|
|
state, plugin = engine._state, engine.plugin
|
|
# A persisted fence for another knowledge base of the same installation gives
|
|
# the first load a marker to read, and so a window inside that read.
|
|
await state.fence(plugin, 'other', 'earlier upload outcome unknown')
|
|
store.gate_read(shared_state._kb_fence_key('other'))
|
|
before = store.keys_reads
|
|
|
|
async def operation():
|
|
return 'ran'
|
|
|
|
first = asyncio.create_task(state.run(plugin, 'kb', operation))
|
|
await asyncio.wait_for(store.gate_entered.wait(), 5)
|
|
second = asyncio.create_task(state.run(plugin, 'kb-2', operation))
|
|
await asyncio.sleep(.05)
|
|
store.gate_release.set()
|
|
assert await asyncio.gather(first, second) == ['ran', 'ran']
|
|
|
|
# Both knowledge bases share one installation: the persisted fence state was
|
|
# read once for the pair, not once per knowledge base.
|
|
assert store.keys_reads - before == 1
|