SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%

Worker sticks to one job kind while any remain; embeddings only queued when no extraction is pending (no model thrash on llm-api)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026) parent 021595d

2 changed files +13 −6

modified src/aiatlas/services/jobs.py +9 −4
@@ -39,13 +39,15 @@ async def enqueue(conn: AsyncConnection, kind: str, payload: dict[str, Any], *,
39 39 return row["id"] if row else None
40 40
41 41
42 −async def claim_next(conn: AsyncConnection, worker: str, kinds: list[str] | None = None) -> dict[str, Any] | None:
42 +async def claim_next(conn: AsyncConnection, worker: str, kinds: list[str] | None = None, *, sticky_kind: str | None = None) -> dict[str, Any] | None:
43 + """Claim the next job. `sticky_kind` keeps a worker on the kind it just ran while any remain: LLM jobs of one kind share a loaded
44 + model, and alternating kinds (extraction ↔ embeddings) makes the inference server swap models every job."""
43 45 kind_filter = "and kind = any(cast(:kinds as text[]))" if kinds else ""
44 46 row = await fetch_one(conn, f"""with next as (
45 47 select id from jobs where status = 'queued' and run_after <= now() {kind_filter}
46 − order by priority, run_after limit 1 for update skip locked)
48 + order by case when kind = cast(:sticky as text) then 0 else 1 end, priority, run_after limit 1 for update skip locked)
47 49 update jobs j set status = 'running', locked_by = :w, locked_at = now(), started_at = now(), attempts = attempts + 1
48 − from next where j.id = next.id returning j.*""", w=worker, kinds=kinds or [])
50 + from next where j.id = next.id returning j.*""", w=worker, kinds=kinds or [], sticky=sticky_kind or "")
49 51 return row
50 52
51 53
@@ -98,13 +100,16 @@ async def run_worker(*, concurrency: int | None = None, kinds: list[str] | None
98 100 sem.release()
99 101
100 102 tasks: set[asyncio.Task[None]] = set()
103 + last_kind: str | None = None
101 104 while not stop.is_set():
102 105 await sem.acquire() # hold a slot BEFORE claiming, so concurrency bounds claims too
103 106 if stop.is_set():
104 107 sem.release()
105 108 break
106 109 async with transaction() as conn:
107 − job = await claim_next(conn, worker, kinds)
110 + job = await claim_next(conn, worker, kinds, sticky_kind=last_kind)
111 + if job is not None:
112 + last_kind = job["kind"]
108 113 if job is None:
109 114 sem.release()
110 115 try:
modified src/aiatlas/services/scheduler.py +4 −2
@@ -12,7 +12,7 @@ from apscheduler.triggers.cron import CronTrigger
12 12
13 13 from aiatlas.config import settings
14 14 from aiatlas.connectors import get, registry
15 −from aiatlas.db import execute, fetch_all, transaction
15 +from aiatlas.db import execute, fetch_all, fetch_one, transaction
16 16 from aiatlas.services import cache
17 17 from aiatlas.services.jobs import enqueue, requeue_stale, run_worker
18 18
@@ -84,7 +84,9 @@ async def hourly() -> None:
84 84 n = await requeue_stale(conn)
85 85 if n:
86 86 log.info("requeued stale jobs", extra={"n": n})
87 − if settings.llm_available:
87 + async with transaction() as conn:
88 + extraction_pending = await fetch_one(conn, "select 1 from jobs where kind = 'llm_extract' and status in ('queued','running') limit 1")
89 + if settings.llm_available and not extraction_pending: # never interleave embeddings with extraction: the server would swap models
88 90 from aiatlas.services.embeddings import pending_entity_ids
89 91
90 92 ids = await pending_entity_ids(limit=400)
91 93