Files
mcp-nextcloud/tests/unit/vector/test_tiered_escalation_strategy.py
T
Chris CoutinhoandClaude Opus 4.8 3b7e8d779b feat(ocr): opt-in batch OCR mode via the gateway's async batch routes
Add DOCUMENT_OCR_MODE=sync|batch (default sync). In batch mode the tier-3 OCR
processor submits documents to the embedding gateway's async Batch OCR routes
(POST /v1/ocr/batch + GET /v1/ocr/batch/{job_id}, astrolabe-cloud-website#372)
for ~50% cheaper large-corpus backfill. The direct Mistral OCR path is left
untouched. Tracked on Deck #332.

Batch jobs run minutes-hours, so the OCR tier cannot block (the procrastinate
worker reclaims jobs in `doing` after INGEST_STALLED_JOB_SECONDS). Instead it
submits, records the gateway job id in a new per-tenant `batch_ocr_jobs` table
(procrastinate args are immutable across retries), and raises a BatchPending
signal that TieredEscalationStrategy turns into a same-queue deferred re-poll —
releasing the worker slot between polls. On completion the per-page markdown is
indexed like the sync path; a failure or a job past
DOCUMENT_OCR_BATCH_MAX_WAIT_SECONDS marks the document parse-failed.

Batch is opt-in and gateway-only: with the direct mistral backend, no gateway
URL, or the inline/memory pipeline (which can't defer), it falls back to sync.
One batch job per document (coalescing N docs/job is a follow-up).

- embedding/gateway_batch_client.py: submit/poll client (reuses GatewayTokenProvider).
- vector/batch_ocr_store.py + migration 008: job tracking (portable SQLite+PG).
- document_processors/escalation.py: BatchPending control-flow signal.
- document_processors/ocr.py: batch state machine + sync fallback.
- vector/processor.py: thread doc identity to the OCR tier; raise BatchPending
  from the pending sentinel; propagate it as control flow (not a failure).
- vector/queue/procrastinate.py: BatchPending -> same-queue retry_in, exempt
  from the transient cap (bounded by the processor's deadline).
- config + docs; tests across client/store/processor/strategy/parse-tier.

1653 unit tests pass; ruff + ty green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:41:19 +02:00

151 lines
6.2 KiB
Python

"""Unit tests for the per-tier escalation primitives (Deck #323).
Covers the tier-ladder helpers + EscalateError (document_processors.escalation)
and the procrastinate TieredEscalationStrategy that turns a raised exception
into a queue-hop / same-tier retry / give-up decision.
"""
from datetime import datetime, timezone
import httpx
import pytest
from procrastinate.jobs import Job
import nextcloud_mcp_server.vector.queue.procrastinate as pq
from nextcloud_mcp_server.document_processors.escalation import (
TIER_LADDER,
BatchPending,
EscalateError,
next_tier,
)
pytestmark = pytest.mark.unit
def _job(queue: str = pq.INGEST_QUEUE_FAST, attempts: int = 1) -> Job:
return Job(
id=1,
queue=queue,
task_name=pq.INGEST_TASK_NAME,
lock=None,
queueing_lock=None,
attempts=attempts,
)
class TestLadder:
def test_next_tier_ordering(self):
assert next_tier("fast") == "structured"
assert next_tier("structured") == "ocr"
assert next_tier("ocr") is None # terminal
assert next_tier("unknown") is None
def test_ladder_is_cheapest_first(self):
assert TIER_LADDER == ("fast", "structured", "ocr")
def test_tier_for_queue(self):
assert pq.tier_for_queue(pq.INGEST_QUEUE_OCR) == "ocr"
assert pq.tier_for_queue(pq.INGEST_QUEUE_STRUCTURED) == "structured"
# Legacy / unknown / None all fall back to the cheapest tier.
assert pq.tier_for_queue(pq.LEGACY_INGEST_QUEUE) == "fast"
assert pq.tier_for_queue(None) == "fast"
class TestTieredEscalationStrategy:
def _strategy(self, max_transient: int = 5):
return pq.TieredEscalationStrategy(max_transient_attempts=max_transient)
def test_escalate_hops_to_target_queue(self):
exc = EscalateError(from_tier="fast", to_tier="ocr", reason="empty_text")
decision = self._strategy().get_retry_decision(exception=exc, job=_job())
assert decision is not None
assert decision.queue == pq.INGEST_QUEUE_OCR
def test_escalate_to_structured(self):
exc = EscalateError(
from_tier="fast", to_tier="structured", reason="low_confidence"
)
decision = self._strategy().get_retry_decision(exception=exc, job=_job())
assert decision is not None
assert decision.queue == pq.INGEST_QUEUE_STRUCTURED
def test_escalate_unknown_tier_gives_up(self):
exc = EscalateError(from_tier="ocr", to_tier="bogus", reason="low_confidence")
decision = self._strategy().get_retry_decision(exception=exc, job=_job())
assert decision is None
def test_escalate_unwraps_exception_group(self):
exc = EscalateError(from_tier="fast", to_tier="ocr", reason="empty_text")
group = ExceptionGroup("wrapped", [exc])
decision = self._strategy().get_retry_decision(exception=group, job=_job())
assert decision is not None
assert decision.queue == pq.INGEST_QUEUE_OCR
def test_transient_retries_same_queue_under_cap(self):
decision = self._strategy(max_transient=5).get_retry_decision(
exception=httpx.ConnectError("refused"), job=_job(attempts=1)
)
assert decision is not None
# Same-tier retry: no queue override (stays on its current queue).
assert decision.queue is None
assert decision.retry_at is not None
def test_transient_backoff_progression(self):
# min(4 * 2**(attempts-1), 300): 4, 8, 16, ... capped at 300s.
# procrastinate sets retry_at = utcnow() + wait at call time. Bracketing
# the call with before/after makes the assertion exact and independent of
# runner load: with before <= call_now <= after, we have
# (retry_at - after) <= wait <= (retry_at - before).
strat = self._strategy(max_transient=100)
for attempts, expected in [(1, 4), (2, 8), (3, 16), (4, 32), (20, 300)]:
before = datetime.now(timezone.utc)
decision = strat.get_retry_decision(
exception=httpx.ConnectError("x"), job=_job(attempts=attempts)
)
after = datetime.now(timezone.utc)
assert decision is not None and decision.retry_at is not None
lo = (decision.retry_at - after).total_seconds()
hi = (decision.retry_at - before).total_seconds()
assert lo <= expected <= hi, (
f"attempts={attempts}: expected={expected}s not in [{lo:.3f}, {hi:.3f}]"
)
def test_transient_gives_up_over_cap(self):
decision = self._strategy(max_transient=5).get_retry_decision(
exception=httpx.ConnectError("refused"), job=_job(attempts=5)
)
assert decision is None
def test_non_transient_error_gives_up(self):
decision = self._strategy().get_retry_decision(
exception=ValueError("permanent"), job=_job(attempts=1)
)
assert decision is None
def test_batch_pending_defers_same_queue(self):
# Batch OCR re-poll (Deck #332): same-queue deferral after retry_in.
before = datetime.now(timezone.utc)
decision = self._strategy().get_retry_decision(
exception=BatchPending(retry_in=120), job=_job(queue=pq.INGEST_QUEUE_OCR)
)
after = datetime.now(timezone.utc)
assert decision is not None
assert decision.queue is None # stays on its own tier queue
assert decision.retry_at is not None
lo = (decision.retry_at - after).total_seconds()
hi = (decision.retry_at - before).total_seconds()
assert lo <= 120 <= hi
def test_batch_pending_exempt_from_transient_cap(self):
# A batch can take hours -> many polls; the transient cap must NOT stop it
# (the OCR processor's own deadline terminates a stuck job instead).
decision = self._strategy(max_transient=5).get_retry_decision(
exception=BatchPending(retry_in=60), job=_job(attempts=999)
)
assert decision is not None and decision.retry_at is not None
def test_batch_pending_unwraps_exception_group(self):
group = ExceptionGroup("wrapped", [BatchPending(retry_in=60)])
decision = self._strategy().get_retry_decision(exception=group, job=_job())
assert decision is not None and decision.retry_at is not None