"""Resolve proposed tracks with bounded concurrency and conservative matching.""" import asyncio import time from collections import OrderedDict from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum import httpx2 import structlog from app.adapters.spotify.errors import SpotifyError from app.config import Settings from app.domain.matching import candidate_key, judge_candidate_match, track_key from app.domain.models import Familiarity, Track, TrackCandidate from app.observability.timing import increment_cache_hits from app.ports.protocols import CatalogQuotaExhaustedError, MusicCatalog class ResolutionStatus(StrEnum): """Terminal outcome of one candidate resolution attempt.""" RESOLVED = "resolved" MISS = "miss" MISMATCH = "mismatch" FAILED = "failed" QUOTA = "quota" @dataclass(frozen=True) class GroundingMetrics: """Separate resolver outcomes for operational visibility.""" attempted_count: int miss_count: int mismatch_guard_count: int failed_count: int cache_hit_count: int did_reach_deadline: bool did_exhaust_quota: bool @property def miss_rate(self) -> float: """Return the share of attempted candidates with no search result.""" return self.miss_count / self.attempted_count if self.attempted_count else 0.0 @property def mismatch_guard_rate(self) -> float: """Return the share rejected by client-side identity checks.""" return self.mismatch_guard_count / self.attempted_count if self.attempted_count else 0.0 @dataclass(frozen=True) class GroundingResult: """Resolved pool and its operational metrics.""" tracks: tuple[Track, ...] metrics: GroundingMetrics @dataclass(frozen=True) class _ResolutionAttempt: index: int status: ResolutionStatus track: Track | None = None is_cache_hit: bool = False class ResolutionCache: """Bound successful name resolutions by age and least-recent use.""" def __init__( self, ttl_seconds: float, max_entries: int, clock: Callable[[], float] = time.monotonic, ) -> None: """Create an empty successful-resolution cache.""" self.ttl_seconds = ttl_seconds self.max_entries = max_entries self.clock = clock self._entries: OrderedDict[str, tuple[float, Track]] = OrderedDict() def get(self, key: str) -> Track | None: """Return a fresh cached track and refresh its recency.""" entry = self._entries.get(key) if entry is None: return None created_at, track = entry if self.clock() - created_at >= self.ttl_seconds: del self._entries[key] return None self._entries.move_to_end(key) increment_cache_hits() return track def put(self, key: str, track: Track) -> None: """Cache a successful unambiguous resolution.""" if self.max_entries <= 0 or self.ttl_seconds <= 0: return self._entries[key] = (self.clock(), track) self._entries.move_to_end(key) while len(self._entries) > self.max_entries: self._entries.popitem(last=False) class Grounder: """Build a safe Spotify pool with early stop and bounded fan-out.""" def __init__(self, settings: Settings, cache: ResolutionCache | None = None) -> None: """Bind resolver settings and a process-local resolution cache.""" self.settings = settings self.cache = cache or ResolutionCache( settings.resolution_cache_ttl_seconds, settings.resolution_cache_max_entries, ) self._semaphore = asyncio.Semaphore(settings.grounding_concurrency) async def ground( self, catalog: MusicCatalog, candidates: tuple[TrackCandidate, ...], known_track_ids: frozenset[str], familiarity: Familiarity, pool_target: int, deadline_at: float, ) -> GroundingResult: """Resolve candidates until the target, deadline, or quota boundary.""" accepted: dict[int, Track] = {} seen_ids: set[str] = set() seen_keys: set[str] = set() metrics = _MutableMetrics() pending: dict[asyncio.Task[_ResolutionAttempt], int] = {} next_index = 0 try: while next_index < len(candidates) or pending: if len(accepted) >= pool_target: break if time.monotonic() >= deadline_at: metrics.did_reach_deadline = True break next_index = self._launch_tasks( catalog, candidates, pending, next_index, ) if not pending: break remaining_seconds = deadline_at - time.monotonic() if remaining_seconds <= 0: metrics.did_reach_deadline = True break done, _ = await asyncio.wait( pending, timeout=remaining_seconds, return_when=asyncio.FIRST_COMPLETED, ) if not done: metrics.did_reach_deadline = True break self._collect_done( done, pending, accepted, seen_ids, seen_keys, known_track_ids, familiarity, pool_target, metrics, ) if metrics.did_exhaust_quota: break finally: await _cancel_tasks(tuple(pending)) tracks = tuple(track for _, track in sorted(accepted.items())) if familiarity is Familiarity.FAMILIAR: tracks = tuple(sorted(tracks, key=lambda track: track.id not in known_track_ids)) result = GroundingResult(tracks=tracks, metrics=metrics.freeze()) _log_grounding(result) return result def _launch_tasks( self, catalog: MusicCatalog, candidates: tuple[TrackCandidate, ...], pending: dict[asyncio.Task[_ResolutionAttempt], int], next_index: int, ) -> int: while next_index < len(candidates) and len(pending) < self.settings.grounding_concurrency: task = asyncio.create_task(self._resolve(catalog, next_index, candidates[next_index])) pending[task] = next_index next_index += 1 return next_index def _collect_done( self, done: set[asyncio.Task[_ResolutionAttempt]], pending: dict[asyncio.Task[_ResolutionAttempt], int], accepted: dict[int, Track], seen_ids: set[str], seen_keys: set[str], known_track_ids: frozenset[str], familiarity: Familiarity, pool_target: int, metrics: "_MutableMetrics", ) -> None: for task in sorted(done, key=pending.__getitem__): del pending[task] attempt = task.result() metrics.record(attempt) if attempt.status is ResolutionStatus.QUOTA: continue track = attempt.track if track is None or len(accepted) >= pool_target: continue if familiarity is Familiarity.NEW and track.id in known_track_ids: continue normalized_key = track_key(track) if track.id in seen_ids or normalized_key in seen_keys: continue seen_ids.add(track.id) seen_keys.add(normalized_key) accepted[attempt.index] = track async def _resolve( self, catalog: MusicCatalog, index: int, candidate: TrackCandidate, ) -> _ResolutionAttempt: async with self._semaphore: return await self._resolve_with_slot(catalog, index, candidate) async def _resolve_with_slot( self, catalog: MusicCatalog, index: int, candidate: TrackCandidate, ) -> _ResolutionAttempt: key = candidate_key(candidate) cached_track = self.cache.get(key) if cached_track is not None: return _ResolutionAttempt( index=index, status=ResolutionStatus.RESOLVED, track=cached_track, is_cache_hit=True, ) try: field_results = await catalog.search_tracks(_field_query(candidate)) matched_track = _best_match( candidate, field_results, self.settings.title_similarity_threshold ) if matched_track is None: bare_results = await catalog.search_tracks(f"{candidate.title} {candidate.artist}") matched_track = _best_match( candidate, bare_results, self.settings.title_similarity_threshold, ) else: bare_results = [] except CatalogQuotaExhaustedError: return _ResolutionAttempt(index=index, status=ResolutionStatus.QUOTA) except (SpotifyError, httpx2.HTTPError) as error: structlog.get_logger().info( "candidate_unresolved", title=candidate.title, artist=candidate.artist, status=ResolutionStatus.FAILED, error_type=type(error).__name__, ) return _ResolutionAttempt(index=index, status=ResolutionStatus.FAILED) if matched_track is not None: self.cache.put(key, matched_track) return _ResolutionAttempt( index=index, status=ResolutionStatus.RESOLVED, track=matched_track ) status = ( ResolutionStatus.MISMATCH if field_results or bare_results else ResolutionStatus.MISS ) structlog.get_logger().info( "candidate_unresolved", title=candidate.title, artist=candidate.artist, status=status, result_count=len(field_results) + len(bare_results), ) return _ResolutionAttempt(index=index, status=status) @dataclass class _MutableMetrics: attempted_count: int = 0 miss_count: int = 0 mismatch_guard_count: int = 0 failed_count: int = 0 cache_hit_count: int = 0 did_reach_deadline: bool = False did_exhaust_quota: bool = False def record(self, attempt: _ResolutionAttempt) -> None: self.attempted_count += 1 self.miss_count += attempt.status is ResolutionStatus.MISS self.mismatch_guard_count += attempt.status is ResolutionStatus.MISMATCH self.failed_count += attempt.status is ResolutionStatus.FAILED self.cache_hit_count += attempt.is_cache_hit self.did_exhaust_quota = self.did_exhaust_quota or attempt.status is ResolutionStatus.QUOTA def freeze(self) -> GroundingMetrics: return GroundingMetrics( attempted_count=self.attempted_count, miss_count=self.miss_count, mismatch_guard_count=self.mismatch_guard_count, failed_count=self.failed_count, cache_hit_count=self.cache_hit_count, did_reach_deadline=self.did_reach_deadline, did_exhaust_quota=self.did_exhaust_quota, ) def _best_match(candidate: TrackCandidate, tracks: list[Track], threshold: float) -> Track | None: verdicts = ((judge_candidate_match(candidate, track, threshold), track) for track in tracks) accepted = [ (verdict.title_similarity, track) for verdict, track in verdicts if verdict.is_match ] return max(accepted, key=lambda item: item[0])[1] if accepted else None def _field_query(candidate: TrackCandidate) -> str: title = candidate.title.replace('"', " ") artist = candidate.artist.replace('"', " ") return f'track:"{title}" artist:"{artist}"' async def _cancel_tasks(tasks: tuple[asyncio.Task[_ResolutionAttempt], ...]) -> None: for task in tasks: task.cancel() if tasks: await asyncio.gather(*tasks, return_exceptions=True) def _log_grounding(result: GroundingResult) -> None: metrics = result.metrics structlog.get_logger().info( "grounding_complete", track_count=len(result.tracks), attempted_count=metrics.attempted_count, miss_rate=metrics.miss_rate, mismatch_guard_rate=metrics.mismatch_guard_rate, failed_count=metrics.failed_count, cache_hits=metrics.cache_hit_count, deadline_reached=metrics.did_reach_deadline, quota_exhausted=metrics.did_exhaust_quota, )