| """ |
| Orchestrator — async fan-out across providers. |
| |
| Responsibilities: |
| - Receive a PipelineOutput (never raw user input). |
| - For each target provider: |
| * Skip if circuit breaker is open. |
| * Check cache; if hit, return cached ProviderResult. |
| * Otherwise invoke provider (sync → asyncio.to_thread). |
| * Apply retry policy. |
| * Record metrics (latency, success/failure, retry count). |
| * Cache successful results. |
| - Return a dict mapping provider name → ProviderResult. |
| |
| All dependencies (registry, cache, metrics, health, retry policy) are |
| injected via the constructor. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| from typing import Awaitable, Callable, Dict, Iterable, Optional |
|
|
| from loguru import logger |
|
|
| from config.settings import Settings |
| from metrics.collector import MetricsCollector |
| from orchestrator.health import HealthMonitor |
| from orchestrator.retry import RetryPolicy, with_retry_sync |
| from pipeline.feature_extraction import PipelineOutput |
| from providers.base import Provider, ProviderCapability, ProviderResult |
| from providers.registry import ProviderRegistry |
| from storage.cache import Cache |
| from utils.logging import execution_context, new_execution_id |
|
|
|
|
| class Orchestrator: |
| """Async fan-out across providers. Injectable.""" |
|
|
| def __init__( |
| self, |
| registry: ProviderRegistry, |
| cache: Cache, |
| metrics: MetricsCollector, |
| health: HealthMonitor, |
| settings: Settings, |
| retry_policy: Optional[RetryPolicy] = None, |
| ) -> None: |
| self._registry = registry |
| self._cache = cache |
| self._metrics = metrics |
| self._health = health |
| self._settings = settings |
| self._retry = retry_policy or RetryPolicy( |
| max_attempts=settings.retry_max_attempts, |
| initial_backoff_seconds=settings.retry_initial_backoff_seconds, |
| max_backoff_seconds=settings.retry_max_backoff_seconds, |
| ) |
|
|
| |
| |
| |
| async def run( |
| self, |
| pipeline_output: PipelineOutput, |
| capabilities: Iterable[ProviderCapability], |
| provider_whitelist: Optional[list[str]] = None, |
| execution_id: Optional[str] = None, |
| ) -> Dict[str, ProviderResult]: |
| """Fan out across all providers matching the given capabilities. |
| |
| Args: |
| pipeline_output: normalized image + face crops. |
| capabilities: which capability buckets to invoke. |
| provider_whitelist: optional list of provider names; if set, |
| only those providers are invoked. |
| execution_id: optional trace id for structured logging. |
| |
| Returns: |
| dict mapping provider name → ProviderResult. |
| """ |
| eid = execution_id or new_execution_id() |
| targets: list[Provider] = [] |
| for cap in capabilities: |
| for p in self._registry.list_by_capability(cap): |
| if provider_whitelist and p.name not in provider_whitelist: |
| continue |
| if not self._health.is_available(p.name): |
| logger.info(f"[orchestrator] skipping {p.name}: circuit open") |
| continue |
| targets.append(p) |
|
|
| if not targets: |
| logger.warning("[orchestrator] no providers to invoke") |
| return {} |
|
|
| semaphore = asyncio.Semaphore(self._settings.orchestrator_max_concurrency) |
|
|
| async def _wrapped(provider: Provider) -> tuple[str, ProviderResult]: |
| async with semaphore: |
| return provider.name, await self._invoke_one(provider, pipeline_output, eid) |
|
|
| tasks = [_wrapped(p) for p in targets] |
| gathered = await asyncio.gather(*tasks, return_exceptions=False) |
| return dict(gathered) |
|
|
| |
| |
| |
| async def _invoke_one( |
| self, |
| provider: Provider, |
| pipeline_output: PipelineOutput, |
| execution_id: str, |
| ) -> ProviderResult: |
| cache_key = self._cache_key(provider, pipeline_output) |
|
|
| |
| if self._settings.cache_enabled: |
| cached = self._cache.get(cache_key) |
| if cached is not None: |
| self._metrics.counters.inc("cache.hits") |
| logger.debug(f"[orchestrator] cache hit for {provider.name}") |
| cached.metadata["cache_hit"] = True |
| return cached |
| self._metrics.counters.inc("cache.misses") |
|
|
| |
| retry_count = 0 |
|
|
| def _on_retry(attempt: int, exc: Exception) -> None: |
| nonlocal retry_count |
| retry_count = attempt |
| self._metrics.providers.record_retry(provider.name) |
| self._metrics.counters.inc(f"retries.{provider.name}") |
|
|
| with execution_context(execution_id=execution_id, |
| provider_id=provider.name, |
| retry_count=retry_count): |
| logger.info(f"[orchestrator] invoking {provider.name}") |
|
|
| def _call_sync() -> ProviderResult: |
| return with_retry_sync( |
| lambda: provider.execute(pipeline_output), |
| self._retry, |
| label=provider.name, |
| on_retry=_on_retry, |
| ) |
|
|
| try: |
| result = await asyncio.wait_for( |
| asyncio.to_thread(_call_sync), |
| timeout=self._settings.orchestrator_timeout_seconds, |
| ) |
| except asyncio.TimeoutError: |
| result = ProviderResult( |
| provider=provider.name, |
| capability=provider.capability, |
| success=False, |
| elapsed_ms=self._settings.orchestrator_timeout_seconds * 1000, |
| error="Orchestrator timeout", |
| error_type="TimeoutError", |
| retry_count=retry_count, |
| ) |
| except Exception as e: |
| result = ProviderResult( |
| provider=provider.name, |
| capability=provider.capability, |
| success=False, |
| elapsed_ms=0.0, |
| error=str(e), |
| error_type=type(e).__name__, |
| retry_count=retry_count, |
| ) |
|
|
| result.retry_count = retry_count |
|
|
| |
| self._metrics.providers.record_invocation(provider.name) |
| if result.success: |
| self._metrics.providers.record_success(provider.name, result.elapsed_ms) |
| self._health.record_success(provider.name, result.elapsed_ms) |
| self._metrics.timings.record(f"provider.{provider.name}", result.elapsed_ms) |
| |
| if self._settings.cache_enabled: |
| self._cache.set(cache_key, result) |
| else: |
| self._metrics.providers.record_failure(provider.name, result.error or "") |
| self._health.record_failure(provider.name) |
| self._metrics.counters.inc(f"failures.{provider.name}") |
|
|
| return result |
|
|
| |
| |
| |
| @staticmethod |
| def _cache_key(provider: Provider, pipeline_output: PipelineOutput) -> str: |
| return f"{provider.name}:{pipeline_output.image_hash}" |
|
|