SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
10.7 KB · 216 lines python
Raw Blame History
1"""`catlas schedule` — the long-running crawl process (spec §15–16, §62–63, §124–128).23Every `settings.scheduler_tick_s` the scheduler claims due sensors from Postgres (`for update skip locked`, so any number of4scheduler processes on any machines can share one database), runs them through `pipeline.run_sensor` under a global semaphore5(the per-domain governor lives in `fetch.Fetcher`), releases claims, writes a `crawl_runs` row and a heartbeat in `settings_kv`.6It also hosts the onboarding worker (`discovery.onboard_pending`) and every periodic task registered through7`services.periodic` (interval + cron via APScheduler in `settings.tz`). Claims older than `CLAIM_TTL` are considered abandoned.8"""9from __future__ import annotations1011import asyncio12import logging13import os14import signal15import socket16import time17from datetime import UTC, datetime18from typing import Any1920from companyatlas.config import settings21from companyatlas.db import execute, fetch_all, jsonb, transaction22from companyatlas.fetch import Fetcher23from companyatlas.ids import new_id24from companyatlas.services import periodic25from companyatlas.services.pipeline import RunOutcome, run_sensor2627log = logging.getLogger(__name__)2829CLAIM_TTL = "15 minutes"30IDLE_RUN_ROW_EVERY = 40            # write a crawl_runs row on idle ticks only every N ticks31ONBOARD_BATCH = 2032ONBOARD_EVERY_TICKS = 4333435def worker_name() -> str:36    return f"{socket.gethostname().split('.')[0]}:{os.getpid()}"373839async def claim_due_sensors(worker: str, limit: int) -> list[dict[str, Any]]:40    async with transaction() as conn:41        return await fetch_all(conn, f"""42            with due as (43                select id from sensors44                where status in ('active', 'failing', 'pending') and next_run_at <= now()45                  and (claimed_at is null or claimed_at < now() - interval '{CLAIM_TTL}')46                order by priority desc, next_run_at47                limit :limit for update skip locked)48            update sensors s set claimed_by = :worker, claimed_at = now() from due where s.id = due.id returning s.*49        """, limit=limit, worker=worker)505152async def release_claims(worker: str) -> None:53    async with transaction() as conn:54        await execute(conn, "update sensors set claimed_by = null, claimed_at = null where claimed_by = :w", w=worker)555657async def heartbeat(worker: str, payload: dict[str, Any]) -> None:58    async with transaction() as conn:59        for key in ("scheduler:heartbeat", f"scheduler:heartbeat:{worker}"):60            await execute(conn, """insert into settings_kv (key, value) values (:k, cast(:v as jsonb))61                                   on conflict (key) do update set value = excluded.value, updated_at = now()""", k=key, v=jsonb(payload))626364async def record_run(kind: str, worker: str, started_at: datetime, stats: dict[str, Any], error: str | None = None) -> None:65    async with transaction() as conn:66        await execute(conn, """insert into crawl_runs (id, kind, worker, started_at, finished_at, stats, error)67                               values (:id, :kind, :worker, :started, now(), cast(:stats as jsonb), :error)""",68                      id=new_id("crawl_run"), kind=kind, worker=worker, started=started_at, stats=jsonb(stats), error=error)697071class Scheduler:72    def __init__(self, *, concurrency: int | None = None, onboarding: bool = True, worker: str | None = None, claim_batch: int | None = None):73        self.concurrency = max(1, concurrency or settings.fetch_concurrency)74        self.onboarding = onboarding75        self.worker = worker or worker_name()76        self.claim_batch = claim_batch or settings.scheduler_claim_batch77        self.stop = asyncio.Event()78        self.inflight = 079        self.ticks = 080        self.fetcher = Fetcher()81        self._onboard_task: asyncio.Task[Any] | None = None82        self._aps: Any = None8384    # ------------------------------------------------------------------ one tick85    async def tick(self) -> dict[str, Any]:86        started = datetime.now(UTC)87        t0 = time.perf_counter()88        sensors = await claim_due_sensors(self.worker, self.claim_batch)89        stats = {"claimed": len(sensors), "ok": 0, "changed": 0, "meaningful": 0, "failed": 0, "not_modified": 0, "unchanged": 0, "skipped": 0, "redirected": 0}90        sem = asyncio.Semaphore(self.concurrency)9192        async def one(row: dict[str, Any]) -> None:93            async with sem:94                self.inflight += 195                try:96                    outcome: RunOutcome = await run_sensor(row, fetcher=self.fetcher, worker=self.worker)97                except Exception as exc:98                    log.exception("sensor run crashed", extra={"sensor_id": row.get("id")})99                    stats["failed"] += 1100                    async with transaction() as conn:101                        await execute(conn, """update sensors set claimed_by = null, claimed_at = null, last_error = :e,102                                               next_run_at = now() + interval '30 minutes' where id = :id""", id=row["id"], e=f"{exc.__class__.__name__}: {exc}"[:500])103                    return104                finally:105                    self.inflight -= 1106                if outcome.status in ("ok", "changed", "unchanged", "not_modified"):107                    stats["ok"] += 1108                if outcome.status == "changed":109                    stats["changed"] += 1110                    if outcome.kind in ("meaningful", "major", "critical"):111                        stats["meaningful"] += 1112                elif outcome.status in stats:113                    stats[outcome.status] += 1114115        if sensors:116            await asyncio.gather(*(one(s) for s in sensors))117        stats["tick_ms"] = int((time.perf_counter() - t0) * 1000)118        self.ticks += 1119        if sensors or self.ticks % IDLE_RUN_ROW_EVERY == 0:120            await record_run("scheduler_tick", self.worker, started, stats)121        due = await self._due_count()122        await heartbeat(self.worker, {"worker": self.worker, "at": datetime.now(UTC).isoformat(), "inflight": self.inflight, "due": due, "tick_ms": stats["tick_ms"],123                                      "claimed": stats["claimed"], "concurrency": self.concurrency, "tasks": periodic.snapshot()})124        log.info("tick", extra={"worker": self.worker, **stats, "due": due})125        return stats126127    async def _due_count(self) -> int:128        async with transaction() as conn:129            rows = await fetch_all(conn, "select count(*) as n from sensors where status in ('active','failing','pending') and next_run_at <= now()")130        return int(rows[0]["n"]) if rows else 0131132    # ------------------------------------------------------------------ onboarding133    async def _maybe_onboard(self) -> None:134        if not self.onboarding or (self._onboard_task and not self._onboard_task.done()):135            return136        from companyatlas.services.discovery import onboard_pending137138        async def go() -> None:139            try:140                stats = await onboard_pending(ONBOARD_BATCH, settings.onboarding_concurrency, fetcher=self.fetcher, worker=self.worker)141                if stats.get("claimed"):142                    await record_run("onboarding", self.worker, datetime.now(UTC), stats)143            except Exception:144                log.exception("onboarding batch failed")145146        self._onboard_task = asyncio.create_task(go())147148    # ------------------------------------------------------------------ periodic tasks149    def _start_periodic(self) -> None:150        loaded = periodic.load_task_modules()151        try:152            from apscheduler.schedulers.asyncio import AsyncIOScheduler153            from apscheduler.triggers.cron import CronTrigger154            from apscheduler.triggers.interval import IntervalTrigger155        except ImportError:  # pragma: no cover156            log.warning("apscheduler unavailable — periodic tasks disabled")157            return158        aps = AsyncIOScheduler(timezone=settings.tz)159        for task in periodic.tasks().values():160            if task.cron:161                trigger: Any = CronTrigger.from_crontab(task.cron, timezone=settings.tz)162            else:163                trigger = IntervalTrigger(seconds=float(task.every_s or 60), start_date=datetime.now(UTC).timestamp() + task.initial_delay_s and None)164            aps.add_job(task.run, trigger=trigger, id=task.name, name=task.name, max_instances=1, coalesce=True, misfire_grace_time=60)165        aps.start()166        self._aps = aps167        log.info("periodic tasks started", extra={"modules": loaded, "tasks": list(periodic.tasks())})168169    # ------------------------------------------------------------------ main loop170    async def run(self, *, once: bool = False) -> None:171        await self.fetcher.open()172        loop = asyncio.get_running_loop()173        for sig in (signal.SIGINT, signal.SIGTERM):174            try:175                loop.add_signal_handler(sig, self.stop.set)176            except (NotImplementedError, RuntimeError):  # pragma: no cover - non-unix177                pass178        try:179            from companyatlas.sdk.connector import sync_connectors_table180181            async with transaction() as conn:182                await sync_connectors_table(conn)183            if not once:184                self._start_periodic()185            log.info("scheduler started", extra={"worker": self.worker, "concurrency": self.concurrency, "tick_s": settings.scheduler_tick_s, "onboarding": self.onboarding})186            while not self.stop.is_set():187                try:188                    if self.onboarding and (self.ticks % ONBOARD_EVERY_TICKS == 0):189                        await self._maybe_onboard()190                    await self.tick()191                except Exception:192                    log.exception("tick failed")193                if once:194                    if self._onboard_task:195                        await self._onboard_task196                    break197                try:198                    await asyncio.wait_for(self.stop.wait(), settings.scheduler_tick_s)199                except TimeoutError:200                    pass201        finally:202            if self._aps is not None:203                self._aps.shutdown(wait=False)204            if self._onboard_task and not self._onboard_task.done():205                self._onboard_task.cancel()206            await release_claims(self.worker)207            await self.fetcher.close()208            log.info("scheduler stopped", extra={"worker": self.worker, "ticks": self.ticks})209210211async def run_scheduler(*, concurrency: int | None = None, onboarding: bool = True, once: bool = False, worker: str | None = None) -> None:212    await Scheduler(concurrency=concurrency, onboarding=onboarding, worker=worker).run(once=once)213214215__all__ = ["Scheduler", "claim_due_sensors", "heartbeat", "record_run", "release_claims", "run_scheduler", "worker_name"]216