| 152 |
152 |
accept: str | None = None) -> FetchResult | None: |
| 153 |
153 |
res.requests += 1 |
| 154 |
154 |
try: |
| 155 |
|
− return await fetcher.get(url, max_bytes=max_bytes, respect_robots=respect_robots, retries=0, accept=accept) |
|
155 |
+ return await fetcher.get(url, max_bytes=max_bytes, respect_robots=respect_robots, retries=0, accept=accept, rate_per_min=settings.discovery_rate_per_min) |
| 156 |
156 |
except NotModified: |
| 157 |
157 |
return None |
| 158 |
158 |
except (FetchError, BlockedError) as exc: |
| 259 |
259 |
break |
| 260 |
260 |
res.requests += 1 |
| 261 |
261 |
try: |
| 262 |
|
− r = await fetcher.get(url, max_bytes=HOMEPAGE_MAX_BYTES, retries=1) |
|
262 |
+ r = await fetcher.get(url, max_bytes=HOMEPAGE_MAX_BYTES, retries=1, rate_per_min=settings.discovery_rate_per_min) |
| 263 |
263 |
except BlockedError as exc: |
| 264 |
264 |
res.notes.append(f"homepage blocked: {exc.failure}") |
| 265 |
265 |
res.error = f"{exc.failure}: {exc}"[:300] |
| 689 |
689 |
run_at = now() + make_interval(mins => :mins) where id = :id""", id=job_id, e=(error or "")[:500], mins=30 * attempts) |
| 690 |
690 |
|
| 691 |
691 |
|
| 692 |
|
−async def onboard_pending(limit: int = 50, concurrency: int | None = None, *, fetcher: Fetcher | None = None, company_slug: str | None = None, |
|
692 |
+async def onboard_pending(limit: int = 0, concurrency: int | None = None, *, fetcher: Fetcher | None = None, company_slug: str | None = None, |
| 693 |
693 |
fetch_now: bool = False, dry_run: bool = False, worker: str = "onboard") -> dict[str, Any]: |
| 694 |
|
− """Discover pending companies: queue jobs first (SKIP LOCKED), then `companies.onboarding_status='pending'` without a job.""" |
|
694 |
+ """Discover pending companies with a rolling pool of `concurrency` workers (no batch barrier): each worker claims ONE target at a |
|
695 |
+ time — a `discover` queue job first (SKIP LOCKED), else a `companies.onboarding_status='pending'` row — until `limit` companies |
|
696 |
+ have been processed (`limit <= 0` = everything pending) or nothing is left. Safe to run from several processes/machines.""" |
| 695 |
697 |
concurrency = concurrency or settings.onboarding_concurrency |
| 696 |
698 |
own_fetcher = fetcher is None |
| 697 |
699 |
fetcher = fetcher or Fetcher() |
| 698 |
700 |
if own_fetcher: |
| 699 |
701 |
await fetcher.open() |
| 700 |
702 |
stats = {"claimed": 0, "active": 0, "failed": 0, "no_website": 0, "sensors": 0} |
| 701 |
|
− try: |
| 702 |
|
− targets: list[tuple[dict[str, Any], dict[str, Any] | None]] = [] |
|
703 |
+ lock = asyncio.Lock() |
|
704 |
+ budget = {"left": limit if limit and limit > 0 else float("inf")} |
|
705 |
+ |
|
706 |
+ async def process(comp: dict[str, Any], job: dict[str, Any] | None) -> None: |
|
707 |
+ res = await discover_company(comp, fetcher=fetcher, dry_run=dry_run, fetch_now=fetch_now) |
|
708 |
+ async with lock: |
|
709 |
+ stats[str(res.status)] = stats.get(str(res.status), 0) + 1 |
|
710 |
+ stats["sensors"] += len(res.sensors) |
|
711 |
+ if job is not None: |
|
712 |
+ await finish_discover_job(job["id"], ok=res.status == OnboardingStatus.ACTIVE, error=res.error, attempts=job["attempts"], max_attempts=job["max_attempts"]) |
|
713 |
+ log.info("company discovered", extra={"company": comp.get("slug"), "status": str(res.status), "sensors": len(res.sensors), "requests": res.requests, |
|
714 |
+ "ms": res.duration_ms, "error": res.error}) |
|
715 |
+ if fetch_now and not dry_run and res.sensors: |
|
716 |
+ from companyatlas.services.pipeline import run_sensor_ids |
|
717 |
+ |
|
718 |
+ await run_sensor_ids([s["id"] for s in res.sensors], fetcher=fetcher, worker=worker) |
|
719 |
+ |
|
720 |
+ async def claim_one() -> tuple[dict[str, Any], dict[str, Any] | None] | None: |
|
721 |
+ async with lock: |
|
722 |
+ if budget["left"] <= 0: |
|
723 |
+ return None |
|
724 |
+ budget["left"] -= 1 |
|
725 |
+ for j in await claim_discover_jobs(1, worker): |
|
726 |
+ cid = (j.get("payload") or {}).get("company_id") or j["key"].removeprefix("discover:") |
|
727 |
+ async with transaction() as conn: |
|
728 |
+ comp = await fetch_one(conn, "select * from companies where id = :id or slug = :id", id=cid) |
|
729 |
+ if comp is None: |
|
730 |
+ await finish_discover_job(j["id"], ok=False, error="company not found", attempts=j["attempts"], max_attempts=j["max_attempts"]) |
|
731 |
+ continue |
|
732 |
+ return comp, j |
| 703 |
733 |
async with transaction() as conn: |
| 704 |
|
− if company_slug: |
|
734 |
+ row = await fetch_one(conn, """ |
|
735 |
+ update companies set onboarding_status = 'discovering', updated_at = now() |
|
736 |
+ where id = (select id from companies where onboarding_status = 'pending' order by importance desc, created_at limit 1 for update skip locked) |
|
737 |
+ returning *""") |
|
738 |
+ if row is None: |
|
739 |
+ async with lock: |
|
740 |
+ budget["left"] += 1 |
|
741 |
+ return None |
|
742 |
+ return row, None |
|
743 |
+ |
|
744 |
+ async def worker_loop() -> None: |
|
745 |
+ while True: |
|
746 |
+ target = await claim_one() |
|
747 |
+ if target is None: |
|
748 |
+ return |
|
749 |
+ async with lock: |
|
750 |
+ stats["claimed"] += 1 |
|
751 |
+ try: |
|
752 |
+ await process(*target) |
|
753 |
+ except Exception: # one company must never stop the pool |
|
754 |
+ log.exception("onboarding worker failed", extra={"company": target[0].get("slug")}) |
|
755 |
+ |
|
756 |
+ try: |
|
757 |
+ if company_slug: |
|
758 |
+ async with transaction() as conn: |
| 705 |
759 |
comp = await fetch_one(conn, "select * from companies where slug = :s or id = :s or canonical_domain = :s", s=company_slug) |
| 706 |
|
− if comp is None: |
| 707 |
|
− raise LookupError(f"company {company_slug!r} not found") |
| 708 |
|
− targets.append((comp, None)) |
| 709 |
|
− if not company_slug: |
| 710 |
|
− jobs = await claim_discover_jobs(limit, worker) |
| 711 |
|
− for j in jobs: |
| 712 |
|
− cid = (j.get("payload") or {}).get("company_id") or j["key"].removeprefix("discover:") |
| 713 |
|
− async with transaction() as conn: |
| 714 |
|
− comp = await fetch_one(conn, "select * from companies where id = :id or slug = :id", id=cid) |
| 715 |
|
− if comp is None: |
| 716 |
|
− await finish_discover_job(j["id"], ok=False, error="company not found", attempts=j["attempts"], max_attempts=j["max_attempts"]) |
| 717 |
|
− continue |
| 718 |
|
− targets.append((comp, j)) |
| 719 |
|
− if len(targets) < limit: |
| 720 |
|
− async with transaction() as conn: |
| 721 |
|
− rows = await fetch_all(conn, """ |
| 722 |
|
− update companies set onboarding_status = 'discovering', updated_at = now() |
| 723 |
|
− where id in (select id from companies where onboarding_status = 'pending' and id <> all(cast(:skip as text[])) |
| 724 |
|
− order by importance desc, created_at limit :n for update skip locked) |
| 725 |
|
− returning *""", n=limit - len(targets), skip=[c["id"] for c, _ in targets] or ["-"]) |
| 726 |
|
− targets.extend((r, None) for r in rows) |
| 727 |
|
− stats["claimed"] = len(targets) |
| 728 |
|
− sem = asyncio.Semaphore(max(1, concurrency)) |
| 729 |
|
− |
| 730 |
|
− async def one(comp: dict[str, Any], job: dict[str, Any] | None) -> None: |
| 731 |
|
− async with sem: |
| 732 |
|
− res = await discover_company(comp, fetcher=fetcher, dry_run=dry_run, fetch_now=fetch_now) |
| 733 |
|
− stats[str(res.status)] = stats.get(str(res.status), 0) + 1 |
| 734 |
|
− stats["sensors"] += len(res.sensors) |
| 735 |
|
− if job is not None: |
| 736 |
|
− await finish_discover_job(job["id"], ok=res.status == OnboardingStatus.ACTIVE, error=res.error, attempts=job["attempts"], max_attempts=job["max_attempts"]) |
| 737 |
|
− log.info("company discovered", extra={"company": comp.get("slug"), "status": str(res.status), "sensors": len(res.sensors), "requests": res.requests, |
| 738 |
|
− "ms": res.duration_ms, "error": res.error}) |
| 739 |
|
− if fetch_now and not dry_run and res.sensors: |
| 740 |
|
− from companyatlas.services.pipeline import run_sensor_ids |
| 741 |
|
− |
| 742 |
|
− await run_sensor_ids([s["id"] for s in res.sensors], fetcher=fetcher, worker=worker) |
| 743 |
|
− |
| 744 |
|
− await asyncio.gather(*(one(c, j) for c, j in targets)) |
|
760 |
+ if comp is None: |
|
761 |
+ raise LookupError(f"company {company_slug!r} not found") |
|
762 |
+ stats["claimed"] = 1 |
|
763 |
+ await process(comp, None) |
|
764 |
+ else: |
|
765 |
+ await asyncio.gather(*(worker_loop() for _ in range(max(1, concurrency)))) |
| 745 |
766 |
finally: |
| 746 |
767 |
if own_fetcher: |
| 747 |
768 |
await fetcher.close() |
| 748 |
769 |
|