File size: 7,767 Bytes
aac350d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""
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,
        )

    # ------------------------------------------------------------------ #
    # Public API
    # ------------------------------------------------------------------ #
    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)

    # ------------------------------------------------------------------ #
    # Per-provider invocation (with cache + retry + metrics)
    # ------------------------------------------------------------------ #
    async def _invoke_one(
        self,
        provider: Provider,
        pipeline_output: PipelineOutput,
        execution_id: str,
    ) -> ProviderResult:
        cache_key = self._cache_key(provider, pipeline_output)

        # Cache hit?
        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")

        # Invoke with retry
        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

        # Record metrics
        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)
            # Cache
            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

    # ------------------------------------------------------------------ #
    # Cache key
    # ------------------------------------------------------------------ #
    @staticmethod
    def _cache_key(provider: Provider, pipeline_output: PipelineOutput) -> str:
        return f"{provider.name}:{pipeline_output.image_hash}"