diff --git a/src/launchpad/size/insights/apple/image_optimization.py b/src/launchpad/size/insights/apple/image_optimization.py index cb1b2a7d..3a3b4de7 100644 --- a/src/launchpad/size/insights/apple/image_optimization.py +++ b/src/launchpad/size/insights/apple/image_optimization.py @@ -2,6 +2,7 @@ import io import logging +import time from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor, as_completed @@ -46,6 +47,7 @@ class BaseImageOptimizationInsight(Insight[ImageOptimizationInsightResult], ABC) TARGET_JPEG_QUALITY = 85 TARGET_HEIC_QUALITY = 85 _MAX_WORKERS = 4 + _TIMEOUT_SECONDS = 300 @abstractmethod def _find_images(self, input: InsightsInput) -> List[FileInfo]: @@ -73,17 +75,32 @@ def generate(self, input: InsightsInput) -> ImageOptimizationInsightResult | Non return None results: List[OptimizableImageFile] = [] + timed_out = False + completed = 0 + deadline = time.monotonic() + self._TIMEOUT_SECONDS with ThreadPoolExecutor(max_workers=min(self._MAX_WORKERS, len(files))) as executor: future_to_file = {executor.submit(self._analyze_image_optimization, f): f for f in files} for future in as_completed(future_to_file): + completed += 1 try: result = future.result() if result and result.potential_savings >= self.MIN_SAVINGS_THRESHOLD: results.append(result) except Exception: # pragma: no cover logger.exception("Failed to analyze image in thread pool") + if completed < len(files) and time.monotonic() >= deadline: + timed_out = True + logger.warning( + "size.insight.image_optimization.timeout | completed=%d/%d timeout=%ds", + completed, + len(files), + self._TIMEOUT_SECONDS, + ) + for fut in future_to_file: + fut.cancel() + break - if not results: + if not results and not timed_out: return None results.sort(key=lambda x: x.potential_savings, reverse=True) @@ -92,6 +109,7 @@ def generate(self, input: InsightsInput) -> ImageOptimizationInsightResult | Non return ImageOptimizationInsightResult( optimizable_files=results, total_savings=total_savings, + timed_out=timed_out, ) def _analyze_image_optimization( diff --git a/src/launchpad/size/models/insights.py b/src/launchpad/size/models/insights.py index 78967c62..3e32567a 100644 --- a/src/launchpad/size/models/insights.py +++ b/src/launchpad/size/models/insights.py @@ -213,6 +213,7 @@ class ImageOptimizationInsightResult(BaseInsightResult): optimizable_files: List[OptimizableImageFile] = Field( ..., description="Files that can be optimized with potential savings" ) + timed_out: bool = Field(False, description="Whether analysis was cut short due to timeout") def get_file_paths(self) -> List[str]: return [f.file_path for f in self.optimizable_files]