Adds the seven §10.2 hook-point modules + five env vars so Astrolabe Cloud can offload document processing to the external document-processor / embedding gateway. Purely additive: with every setting unset the server behaves exactly as today, so self-hosters are unaffected (Deck #92). Hook points (all default to current monolith behavior): - config: EMBEDDING_PROVIDER, INGEST_MODE, STATUS_BACKEND, COLLECTION_METADATA_SOURCE, FACT_EVENT_EMITTER (+ supporting settings), validated in Settings.__post_init__ (fail-fast STATUS_BACKEND=local with INGEST_MODE=external); shared canonical.py. - vector/payload_keys.py + acl_hash.py: cross-impl NAMESPACE/point_id (§2.2) and BLAKE2b-128 ACL hash (§11), pinned by fixtures shared with the document-processor repo. - embedding/gateway_client.py: OpenAI-compatible GatewayProvider authenticating via M2M OIDC client-credentials (separate realm); manual-only registry entry. - vector/collection_metadata.py: sentinel-point / API metadata source with env fallback. - vector/queue/: hexagonal ingest producer ports + memory/NATS adapters (Postgres seam); INGEST_MODE=external publishes mcp.ingest.requested.{tenant} instead of the in-memory stream and skips the in-process processor pool. The lifespan becomes a composition root across both deployment branches. - vector/queue/status.py: STATUS_BACKEND=bus subscriber feeding a StatusStore the vector-sync status endpoint reads. - admin/payload_backfill.py: POST /api/v1/admin/payload-backfill (admin scope); processor writes the new payload keys; query-side ACL pre-filter gated behind ACL_PREFILTER_ENABLED (default off). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
71 lines
2.2 KiB
Python
71 lines
2.2 KiB
Python
"""StatusStore + NATS status message handling (design §10.1, STATUS_BACKEND=bus)."""
|
|
|
|
import json
|
|
|
|
from nextcloud_mcp_server.vector.queue.status import (
|
|
NatsStatusSubscriber,
|
|
StatusStore,
|
|
state_from_subject,
|
|
)
|
|
|
|
|
|
def test_store_records_and_counts():
|
|
store = StatusStore()
|
|
store.record("d1", "ready", content_hash="h1")
|
|
store.record("d2", "failed")
|
|
store.record("d1", "ready", content_hash="h1") # idempotent overwrite
|
|
assert len(store) == 2
|
|
assert store.counts() == {"ready": 1, "failed": 1}
|
|
assert store.get("d1")["content_hash"] == "h1"
|
|
|
|
|
|
def test_store_is_bounded_lru():
|
|
store = StatusStore(max_size=2)
|
|
store.record("d1", "ready")
|
|
store.record("d2", "ready")
|
|
store.record("d3", "ready") # evicts d1
|
|
assert len(store) == 2
|
|
assert store.get("d1") is None
|
|
assert store.get("d3") is not None
|
|
|
|
|
|
def test_state_from_subject():
|
|
assert state_from_subject("mcp.document.ready.tenant-1") == "ready"
|
|
assert state_from_subject("mcp.document.failed.tenant-1") == "failed"
|
|
assert state_from_subject("mcp.document.reparsed.tenant-1") == "reparsed"
|
|
assert state_from_subject("mcp.document.bogus.tenant-1") is None
|
|
assert state_from_subject("mcp.ingest.requested.tenant-1") is None
|
|
|
|
|
|
def test_handle_message_records_state():
|
|
store = StatusStore()
|
|
events = []
|
|
sub = NatsStatusSubscriber(
|
|
nc=None,
|
|
js=None,
|
|
tenant_id="t1",
|
|
store=store,
|
|
on_event=lambda d, s: events.append((d, s)),
|
|
)
|
|
payload = json.dumps(
|
|
{
|
|
"tenant_id": "t1",
|
|
"doc_id": "doc-9",
|
|
"content_hash": "abc",
|
|
"transitioned_at": "2026-05-27T00:00:00Z",
|
|
}
|
|
).encode()
|
|
sub.handle_message("mcp.document.ready.t1", payload)
|
|
entry = store.get("doc-9")
|
|
assert entry["state"] == "ready"
|
|
assert entry["content_hash"] == "abc"
|
|
assert events == [("doc-9", "ready")]
|
|
|
|
|
|
def test_handle_message_ignores_bad_payload_and_subject():
|
|
store = StatusStore()
|
|
sub = NatsStatusSubscriber(nc=None, js=None, tenant_id="t1", store=store)
|
|
sub.handle_message("mcp.document.ready.t1", b"not json")
|
|
sub.handle_message("mcp.ingest.requested.t1", b'{"doc_id":"x"}')
|
|
assert len(store) == 0
|