fix: harden request handling, pipeline containment, and startup boundaries
This commit is contained in:
parent
2cc33a721c
commit
3555256a02
18 changed files with 656 additions and 107 deletions
|
|
@ -7,8 +7,10 @@ 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
|
||||
|
|
@ -22,6 +24,7 @@ class ResolutionStatus(StrEnum):
|
|||
RESOLVED = "resolved"
|
||||
MISS = "miss"
|
||||
MISMATCH = "mismatch"
|
||||
FAILED = "failed"
|
||||
QUOTA = "quota"
|
||||
|
||||
|
||||
|
|
@ -32,6 +35,7 @@ class GroundingMetrics:
|
|||
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
|
||||
|
|
@ -111,6 +115,7 @@ class Grounder:
|
|||
settings.resolution_cache_ttl_seconds,
|
||||
settings.resolution_cache_max_entries,
|
||||
)
|
||||
self._semaphore = asyncio.Semaphore(settings.grounding_concurrency)
|
||||
|
||||
async def ground(
|
||||
self,
|
||||
|
|
@ -119,6 +124,7 @@ class Grounder:
|
|||
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] = {}
|
||||
|
|
@ -127,18 +133,18 @@ class Grounder:
|
|||
metrics = _MutableMetrics()
|
||||
pending: dict[asyncio.Task[_ResolutionAttempt], int] = {}
|
||||
next_index = 0
|
||||
deadline_at = time.monotonic() + self.settings.request_deadline_seconds
|
||||
semaphore = asyncio.Semaphore(self.settings.grounding_concurrency)
|
||||
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,
|
||||
semaphore,
|
||||
)
|
||||
if not pending:
|
||||
break
|
||||
|
|
@ -183,12 +189,9 @@ class Grounder:
|
|||
candidates: tuple[TrackCandidate, ...],
|
||||
pending: dict[asyncio.Task[_ResolutionAttempt], int],
|
||||
next_index: int,
|
||||
semaphore: asyncio.Semaphore,
|
||||
) -> 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], semaphore)
|
||||
)
|
||||
task = asyncio.create_task(self._resolve(catalog, next_index, candidates[next_index]))
|
||||
pending[task] = next_index
|
||||
next_index += 1
|
||||
return next_index
|
||||
|
|
@ -228,9 +231,8 @@ class Grounder:
|
|||
catalog: MusicCatalog,
|
||||
index: int,
|
||||
candidate: TrackCandidate,
|
||||
semaphore: asyncio.Semaphore,
|
||||
) -> _ResolutionAttempt:
|
||||
async with semaphore:
|
||||
async with self._semaphore:
|
||||
return await self._resolve_with_slot(catalog, index, candidate)
|
||||
|
||||
async def _resolve_with_slot(
|
||||
|
|
@ -265,6 +267,15 @@ class Grounder:
|
|||
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)
|
||||
|
|
@ -289,6 +300,7 @@ 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
|
||||
|
|
@ -297,6 +309,7 @@ class _MutableMetrics:
|
|||
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
|
||||
|
||||
|
|
@ -305,6 +318,7 @@ class _MutableMetrics:
|
|||
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,
|
||||
|
|
@ -340,6 +354,7 @@ def _log_grounding(result: GroundingResult) -> None:
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from contextlib import aclosing
|
|||
|
||||
import structlog
|
||||
|
||||
from app.adapters.spotify.errors import SpotifyError
|
||||
from app.config import Settings
|
||||
from app.domain.models import (
|
||||
CompressedTasteProfile,
|
||||
|
|
@ -27,7 +28,12 @@ from app.pipeline.event import (
|
|||
PipelineWarningEvent,
|
||||
)
|
||||
from app.pipeline.grounding import Grounder
|
||||
from app.ports.protocols import MusicCatalog, Recommender, RecommenderOutputError
|
||||
from app.ports.protocols import (
|
||||
CatalogQuotaExhaustedError,
|
||||
MusicCatalog,
|
||||
Recommender,
|
||||
RecommenderOutputError,
|
||||
)
|
||||
|
||||
RERANK_FALLBACK_CODE = "rerank_fallback"
|
||||
RERANK_FALLBACK_MESSAGE = "Ranking output was invalid, so grounded results are shown instead."
|
||||
|
|
@ -115,21 +121,43 @@ class RecommendationPipeline:
|
|||
) -> AsyncGenerator[PipelineEvent]:
|
||||
"""Yield ordered events for one recommendation request."""
|
||||
started_at = time.monotonic()
|
||||
taste = await self.taste_cache.get(session_id, catalog)
|
||||
intent = await self.recommender.create_intent(
|
||||
query,
|
||||
history,
|
||||
previous_recommendations,
|
||||
taste.text,
|
||||
self.settings.candidate_count,
|
||||
)
|
||||
deadline_at = started_at + self.settings.request_deadline_seconds
|
||||
try:
|
||||
taste = await self.taste_cache.get(session_id, catalog)
|
||||
intent = await self.recommender.create_intent(
|
||||
query,
|
||||
history,
|
||||
previous_recommendations,
|
||||
taste.text,
|
||||
self.settings.candidate_count,
|
||||
)
|
||||
except RecommenderOutputError:
|
||||
yield PipelineErrorEvent(
|
||||
code="intent_failed",
|
||||
message="Recommendation intent could not be generated from the model response.",
|
||||
)
|
||||
return
|
||||
except CatalogQuotaExhaustedError:
|
||||
yield PipelineErrorEvent(
|
||||
code="quota_exhausted",
|
||||
message=(
|
||||
"Spotify request quota was exhausted before recommendations could be prepared."
|
||||
),
|
||||
)
|
||||
return
|
||||
except SpotifyError:
|
||||
yield PipelineErrorEvent(
|
||||
code="spotify_unavailable",
|
||||
message="Spotify was unavailable while preparing recommendations.",
|
||||
)
|
||||
return
|
||||
yield PipelineMetadataEvent(
|
||||
request_id=request_id,
|
||||
intent_summary=intent.intent_summary,
|
||||
candidate_count=len(intent.candidates),
|
||||
)
|
||||
|
||||
pool = await self._grounded_pool(session_id, catalog, intent, taste)
|
||||
pool = await self._grounded_pool(session_id, catalog, intent, taste, deadline_at)
|
||||
if not pool:
|
||||
yield PipelineErrorEvent(
|
||||
code="no_grounded_results",
|
||||
|
|
@ -160,6 +188,7 @@ class RecommendationPipeline:
|
|||
catalog: MusicCatalog,
|
||||
intent: Intent,
|
||||
taste: CompressedTasteProfile,
|
||||
deadline_at: float,
|
||||
) -> tuple[Track, ...]:
|
||||
"""Reuse the session's pool on refinement, otherwise ground anew."""
|
||||
if intent.is_refinement:
|
||||
|
|
@ -172,6 +201,7 @@ class RecommendationPipeline:
|
|||
taste.known_track_ids,
|
||||
intent.familiarity,
|
||||
self.settings.rerank_count + self.settings.rerank_pool_buffer,
|
||||
deadline_at,
|
||||
)
|
||||
if result.tracks:
|
||||
self.last_pools[session_id] = result.tracks
|
||||
|
|
@ -290,19 +320,31 @@ class _TasteProfileCache:
|
|||
return profile
|
||||
|
||||
async def _fetch(self, catalog: MusicCatalog) -> CompressedTasteProfile:
|
||||
short_artists, long_artists, short_tracks, long_tracks, saved_tracks = await asyncio.gather(
|
||||
catalog.fetch_top_artists("short_term", self.settings.top_items_limit),
|
||||
catalog.fetch_top_artists("long_term", self.settings.top_items_limit),
|
||||
catalog.fetch_top_tracks("short_term", self.settings.top_items_limit),
|
||||
catalog.fetch_top_tracks("long_term", self.settings.top_items_limit),
|
||||
catalog.fetch_saved_tracks(self.settings.saved_tracks_limit),
|
||||
)
|
||||
try:
|
||||
async with asyncio.TaskGroup() as task_group:
|
||||
short_artists_task = task_group.create_task(
|
||||
catalog.fetch_top_artists("short_term", self.settings.top_items_limit)
|
||||
)
|
||||
long_artists_task = task_group.create_task(
|
||||
catalog.fetch_top_artists("long_term", self.settings.top_items_limit)
|
||||
)
|
||||
short_tracks_task = task_group.create_task(
|
||||
catalog.fetch_top_tracks("short_term", self.settings.top_items_limit)
|
||||
)
|
||||
long_tracks_task = task_group.create_task(
|
||||
catalog.fetch_top_tracks("long_term", self.settings.top_items_limit)
|
||||
)
|
||||
saved_tracks_task = task_group.create_task(
|
||||
catalog.fetch_saved_tracks(self.settings.saved_tracks_limit)
|
||||
)
|
||||
except ExceptionGroup as errors:
|
||||
raise errors.exceptions[0] from None
|
||||
return compress_taste_profile(
|
||||
TasteProfile(
|
||||
short_term_artists=tuple(short_artists),
|
||||
long_term_artists=tuple(long_artists),
|
||||
short_term_tracks=tuple(short_tracks),
|
||||
long_term_tracks=tuple(long_tracks),
|
||||
saved_tracks=tuple(saved_tracks),
|
||||
short_term_artists=tuple(short_artists_task.result()),
|
||||
long_term_artists=tuple(long_artists_task.result()),
|
||||
short_term_tracks=tuple(short_tracks_task.result()),
|
||||
long_term_tracks=tuple(long_tracks_task.result()),
|
||||
saved_tracks=tuple(saved_tracks_task.result()),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue