Spaces:
Running
Running
| import os | |
| import signal | |
| import time | |
| from threading import Thread, Lock, Event | |
| from logger import logger | |
| from models import StockSources, MacroSource, NEWS, STOCK | |
| from db_helpers import ( | |
| read_sources, | |
| update_last_fetched, | |
| record_failure, | |
| store_raw_html, | |
| get_sources_due_for_market, | |
| update_source_table, | |
| ) | |
| from config import ( | |
| SCRAPER_TICK_SECONDS, | |
| SCRAPE_INTERVAL_MS, | |
| MAX_CONSECUTIVE_FAILURES, | |
| WORKER_ERROR_BACKOFF_SECONDS, | |
| MARKET_CONFIG, | |
| ) | |
| from utils import ( | |
| fetch_html, | |
| seconds_until_next_market_close, | |
| market_already_closed_today, | |
| ) | |
| # ========================= | |
| # WORKER LIFECYCLE STATE | |
| # ========================= | |
| workers_lock = Lock() | |
| # Maps worker name → {"thread": Thread, "event": Event} | |
| _workers: dict[str, dict] = {} | |
| # ========================= | |
| # WORKER LOOP HARNESS | |
| # ========================= | |
| # Each worker is split into a public function (entered by the Thread) and a | |
| # private *_iteration that runs exactly one pass. The harness loops the | |
| # iteration, catching unhandled exceptions so a surprise error in any one | |
| # pass logs + backs off + continues, rather than killing the thread silently. | |
| def _safe_worker_loop(name: str, iteration_fn, stop_event: Event): | |
| logger.info(f"{name} loop started") | |
| while not stop_event.is_set(): | |
| try: | |
| iteration_fn(stop_event) | |
| except Exception: | |
| logger.exception(f"{name} crashed") | |
| stop_event.wait(WORKER_ERROR_BACKOFF_SECONDS) | |
| logger.info(f"{name} loop exiting (shutdown signaled)") | |
| # ========================= | |
| # SCRAPER WORKER (news) | |
| # ========================= | |
| def _scraping_iteration(stop_event: Event): | |
| """Scrape news sources and store their RAW HTML into the database.""" | |
| logger.info("scraper tick: reading source list") | |
| sources = read_sources("news") | |
| now_ms = int(time.time() * 1000) | |
| logger.info(f"scraper tick: evaluating {len(sources)} sources") | |
| skipped_disabled = 0 | |
| skipped_recent = 0 | |
| attempted = 0 | |
| for source in sources: | |
| if stop_event.is_set(): | |
| logger.info("scraper tick: shutdown signaled mid-loop, returning") | |
| return | |
| if (source.failure_count or 0) >= MAX_CONSECUTIVE_FAILURES: | |
| logger.info( | |
| f"scraper: skipping disabled source {source.name} " | |
| f"(failures={source.failure_count})" | |
| ) | |
| skipped_disabled += 1 | |
| continue | |
| if ( | |
| source.last_attempted_at is not None | |
| and now_ms - source.last_attempted_at < SCRAPE_INTERVAL_MS | |
| ): | |
| age_min = (now_ms - source.last_attempted_at) // 60000 | |
| logger.info( | |
| f"scraper: skipping {source.name}, last attempt {age_min}min ago " | |
| f"(interval {SCRAPE_INTERVAL_MS // 60000}min)" | |
| ) | |
| skipped_recent += 1 | |
| continue | |
| source_name = source.name | |
| url = source.website | |
| attempted += 1 | |
| logger.info(f"scraper: fetching {source_name} ({url})") | |
| try: | |
| html, error_reason = fetch_html(url) | |
| if html is None: | |
| logger.error(f"{source_name} fetch failed") | |
| logger.error(f"due to {error_reason}") | |
| record_failure(source.id, str(error_reason)) | |
| continue | |
| raw_id = store_raw_html(source_name, html, NEWS) | |
| update_last_fetched(source.id) | |
| logger.info(f"Stored {source_name} -> {raw_id}") | |
| except Exception as e: | |
| logger.exception(f"{source_name} fetch failed") | |
| record_failure(source.id, str(e)) | |
| logger.info( | |
| f"scraper tick done: attempted={attempted} " | |
| f"skipped_recent={skipped_recent} " | |
| f"skipped_disabled={skipped_disabled}; " | |
| f"sleeping {SCRAPER_TICK_SECONDS}s" | |
| ) | |
| stop_event.wait(SCRAPER_TICK_SECONDS) | |
| def scraping_worker(stop_event: Event): | |
| _safe_worker_loop("scraping_worker", _scraping_iteration, stop_event) | |
| # ========================= | |
| # SHARED FETCH HELPER (stock + macro) | |
| # ========================= | |
| # Both the stock and macro workers fetch a pre-built URL and store the raw HTML; | |
| # only the source label and raw_html.type differ. update_source_table stamps | |
| # last_fetched_at so the row drops out of "due" for the rest of the market day. | |
| def _fetch_and_store(row, model, source_label: str, type_: str): | |
| """Fetch row.url and store it as raw_html of `type_`. Stamps the source row.""" | |
| ident = getattr(row, "name", None) or getattr(row, "ticker", None) or row.id | |
| country = getattr(row, "country", None) | |
| url = row.url | |
| if not url: | |
| logger.error(f"{ident} has no pre-built url; skipping") | |
| update_source_table(model, row.id, "missing url") | |
| return | |
| logger.info(f"fetching {ident} ({type_})") | |
| try: | |
| html, error = fetch_html(url) | |
| if not html: | |
| update_source_table(model, row.id, error) | |
| logger.error(f"fetch failed for {ident}: {error}") | |
| return | |
| store_raw_html(source_label, html, type_, country=country) | |
| update_source_table(model, row.id) | |
| logger.info(f"{ident}: raw html stored ({type_})") | |
| except Exception: | |
| logger.exception(f"fetch failed for {ident}") | |
| update_source_table(model, row.id, "fetch exception") | |
| def _sleep_until_next_close(stop_event: Event, label: str): | |
| """Sleep until the soonest market close across all markets (weekend-aware).""" | |
| sleep_s = min(seconds_until_next_market_close(m) for m in MARKET_CONFIG) | |
| logger.info( | |
| f"{label} tick done; sleeping {sleep_s / 3600:.1f}h until next market close" | |
| ) | |
| stop_event.wait(sleep_s) | |
| # ========================= | |
| # STOCK ENHANCER WORKER (fetch-only) | |
| # ========================= | |
| # Once per market close (weekday), fetch every stock for that market that hasn't | |
| # been fetched today, store its Google Finance HTML (type=stock), then sleep to | |
| # the next close. All metric extraction happens on the LLM side. | |
| def _stock_market_iteration(stop_event: Event): | |
| for market in MARKET_CONFIG: | |
| if not market_already_closed_today(market): | |
| logger.info(f"stock: {market} not closed yet today (or weekend); skipping") | |
| continue | |
| due = get_sources_due_for_market(StockSources, market) | |
| logger.info(f"stock: {market} closed; {len(due)} stock(s) due") | |
| for stock in due: | |
| if stop_event.is_set(): | |
| logger.info("stock tick: shutdown signaled mid-loop, returning") | |
| return | |
| _fetch_and_store(stock, StockSources, "Google Finance", STOCK) | |
| _sleep_until_next_close(stop_event, "stock") | |
| def stock_enrichment_worker(stop_event: Event): | |
| _safe_worker_loop("stock_enrichment_worker", _stock_market_iteration, stop_event) | |
| # ========================= | |
| # MACRO WORKER (fetch-only) | |
| # ========================= | |
| # Same once-per-close cadence as the stock worker, but over MacroSource rows | |
| # (FII/DII, VIX, commodities, indices, FX, yields). The raw_html.type is taken | |
| # from each row so one worker covers every macro family. | |
| def _macro_iteration(stop_event: Event): | |
| for market in MARKET_CONFIG: | |
| if not market_already_closed_today(market): | |
| logger.info(f"macro: {market} not closed yet today (or weekend); skipping") | |
| continue | |
| due = get_sources_due_for_market(MacroSource, market) | |
| logger.info(f"macro: {market} closed; {len(due)} series due") | |
| for src in due: | |
| if stop_event.is_set(): | |
| logger.info("macro tick: shutdown signaled mid-loop, returning") | |
| return | |
| _fetch_and_store(src, MacroSource, src.name, src.type) | |
| _sleep_until_next_close(stop_event, "macro") | |
| def macro_worker(stop_event: Event): | |
| _safe_worker_loop("macro_worker", _macro_iteration, stop_event) | |
| # ========================= | |
| # WORKER REGISTRY | |
| # ========================= | |
| # Maps a stable name → the worker's entry-point function. | |
| # Registered actions: | |
| # START_SCRAPER / STOP_SCRAPER / RESTART_SCRAPER | |
| # START_STOCK_ENHANCER / STOP_STOCK_ENHANCER / RESTART_STOCK_ENHANCER | |
| # START_MACRO / STOP_MACRO / RESTART_MACRO | |
| _WORKER_REGISTRY: dict[str, callable] = { | |
| "scraper": scraping_worker, | |
| "stock_enhancer": stock_enrichment_worker, | |
| "macro": macro_worker, | |
| } | |
| # ========================= | |
| # INTERNAL HELPERS | |
| # ========================= | |
| def _launch_worker(name: str): | |
| """Spin up a single named worker and register it in _workers. | |
| Caller must hold workers_lock.""" | |
| if name not in _WORKER_REGISTRY: | |
| raise ValueError( | |
| f"Unknown worker: {name!r}. Valid names: {list(_WORKER_REGISTRY)}" | |
| ) | |
| stop_event = Event() | |
| thread = Thread( | |
| target=_WORKER_REGISTRY[name], | |
| args=(stop_event,), | |
| name=name, | |
| daemon=True, | |
| ) | |
| _workers[name] = {"thread": thread, "event": stop_event} | |
| thread.start() | |
| logger.info(f"_launch_worker: '{name}' started (tid={thread.ident})") | |
| def _signal_and_join(name: str, stop_event: Event, thread: Thread, timeout: float): | |
| """Signal a worker's event and wait for its thread to exit.""" | |
| logger.info(f"stop: signaling '{name}'") | |
| stop_event.set() | |
| thread.join(timeout=timeout) | |
| if thread.is_alive(): | |
| logger.warning(f"stop: '{name}' did not exit within {timeout}s") | |
| else: | |
| logger.info(f"stop: '{name}' exited cleanly") | |
| # ========================= | |
| # PUBLIC CONTROL API | |
| # ========================= | |
| def start_workers(names: list[str] | None = None): | |
| """Start workers. If *names* is omitted, all registered workers are started. | |
| Already-running workers are skipped with a warning.""" | |
| targets = names or list(_WORKER_REGISTRY.keys()) | |
| with workers_lock: | |
| for name in targets: | |
| entry = _workers.get(name) | |
| if entry and entry["thread"].is_alive(): | |
| logger.warning(f"start_workers: '{name}' is already running — skipping") | |
| continue | |
| _launch_worker(name) | |
| logger.info(f"start_workers: launched {targets}") | |
| def stop_worker(name: str, timeout: float = 5.0): | |
| """Stop a single named worker and remove it from the registry.""" | |
| with workers_lock: | |
| entry = _workers.get(name) | |
| if not entry: | |
| logger.warning(f"stop_worker: '{name}' is not running — nothing to do") | |
| return | |
| _signal_and_join(name, entry["event"], entry["thread"], timeout) | |
| del _workers[name] | |
| def stop_workers(names: list[str] | None = None, timeout: float = 5.0): | |
| """Stop workers. If *names* is omitted, all running workers are stopped.""" | |
| targets = names or list(_workers.keys()) | |
| for name in targets: | |
| stop_worker(name, timeout=timeout) | |
| logger.info(f"stop_workers: done — {targets}") | |
| def restart_worker(name: str, timeout: float = 5.0): | |
| """Stop a worker (if running) then immediately restart it.""" | |
| with workers_lock: | |
| entry = _workers.get(name) | |
| if entry: | |
| _signal_and_join(name, entry["event"], entry["thread"], timeout) | |
| del _workers[name] | |
| _launch_worker(name) | |
| logger.info(f"restart_worker: '{name}' restarted") | |
| def worker_status() -> dict[str, bool]: | |
| """Return a snapshot of {worker_name: is_alive} for every running worker.""" | |
| with workers_lock: | |
| return {name: entry["thread"].is_alive() for name, entry in _workers.items()} | |
| # ========================= | |
| # PROCESS SHUTDOWN | |
| # ========================= | |
| def shutdown_server(): | |
| logger.info("shutdown_server: stopping all workers then sending SIGTERM") | |
| stop_workers() | |
| # SIGTERM is the semantically correct "please exit" signal; friendlier to | |
| # process managers (systemd, supervisord, docker) than SIGINT. | |
| os.kill(os.getpid(), signal.SIGTERM) | |