| 1 | """Parallel multi-entity fan-out for the --competitors flag. |
| 2 | |
| 3 | The orchestrator accepts a `main_runner()` for the topic and a |
| 4 | `competitor_runner(entity)` for each peer. It parallelizes their execution |
| 5 | via a `ThreadPoolExecutor` and collects per-entity Reports. Per-entity |
| 6 | failures are logged and dropped; the run survives as long as the main topic |
| 7 | plus at least one competitor succeed. |
| 8 | |
| 9 | This module owns no business logic about pipeline arguments — the caller |
| 10 | (scripts/last30days.py main) builds the closures with the appropriate |
| 11 | config, depth, and overrides for each entity. |
| 12 | """ |
| 13 | |
| 14 | from __future__ import annotations |
| 15 | |
| 16 | from concurrent.futures import ThreadPoolExecutor, as_completed |
| 17 | from typing import Callable |
| 18 | |
| 19 | from . import log, schema, youtube_yt |
| 20 | |
| 21 | # Sub-runs hit the same upstream APIs as the main topic. Cap parallelism so a |
| 22 | # 6-way fan-out does not stampede a single backend's rate limit. |
| 23 | MAX_PARALLEL_SUBRUNS = 6 |
| 24 | |
| 25 | |
| 26 | def _log(msg: str) -> None: |
| 27 | log.source_log("Fanout", msg, tty_only=False) |
| 28 | |
| 29 | |
| 30 | def run_competitor_fanout( |
| 31 | *, |
| 32 | main_topic: str, |
| 33 | main_runner: Callable[[], schema.Report], |
| 34 | competitors: list[str], |
| 35 | competitor_runner: Callable[[str], schema.Report], |
| 36 | ) -> list[tuple[str, schema.Report]]: |
| 37 | """Run main + competitor pipelines in parallel; return surviving reports. |
| 38 | |
| 39 | Args: |
| 40 | main_topic: Display label for the user's primary topic. |
| 41 | main_runner: Zero-arg callable returning the main topic's Report. |
| 42 | competitors: Ordered list of competitor entity names. |
| 43 | competitor_runner: Callable(entity_name) -> Report for each peer. |
| 44 | |
| 45 | Returns: |
| 46 | Ordered list of (entity_name, Report) tuples for runs that succeeded. |
| 47 | Empty list if every run raised; the caller decides how to surface |
| 48 | partial-failure modes. |
| 49 | """ |
| 50 | if not competitors: |
| 51 | report = main_runner() |
| 52 | return [(main_topic, report)] |
| 53 | |
| 54 | # One clear for the whole comparison so entity sub-runs share the YouTube |
| 55 | # search cache without inheriting a prior run's results in this process. |
| 56 | youtube_yt.reset_search_cache() |
| 57 | |
| 58 | workers = min(len(competitors) + 1, MAX_PARALLEL_SUBRUNS) |
| 59 | |
| 60 | def _run_one(label: str, fn: Callable[[], schema.Report]) -> tuple[str, schema.Report | None, Exception | None]: |
| 61 | try: |
| 62 | return label, fn(), None |
| 63 | except Exception as exc: |
| 64 | return label, None, exc |
| 65 | |
| 66 | submissions: list[tuple[str, Callable[[], schema.Report]]] = [ |
| 67 | (main_topic, main_runner), |
| 68 | ] |
| 69 | for entity in competitors: |
| 70 | submissions.append((entity, lambda e=entity: competitor_runner(e))) |
| 71 | |
| 72 | with ThreadPoolExecutor(max_workers=workers) as executor: |
| 73 | futures = { |
| 74 | executor.submit(_run_one, label, fn): label |
| 75 | for label, fn in submissions |
| 76 | } |
| 77 | results: dict[str, schema.Report] = {} |
| 78 | for future in as_completed(futures): |
| 79 | label, report, exc = future.result() |
| 80 | if exc is not None: |
| 81 | _log(f"Sub-run failed for {label!r}: {type(exc).__name__}: {exc}") |
| 82 | continue |
| 83 | assert report is not None |
| 84 | results[label] = report |
| 85 | |
| 86 | # Preserve the original submission order rather than completion order so |
| 87 | # the comparison render is deterministic across runs. |
| 88 | return [(label, results[label]) for label, _ in submissions if label in results] |
| 89 |