gabriel / musehub public
musehub_jobs.py python
414 lines 15.5 KB Cold
Raw
sha256:d50f9cf9829dfbe35721a23b81ad256c729ddf9dd565a0a9e56d27847e255632 feat(#92): phase 4 — enqueue fetch.mpack.prebuild on push (… Sonnet 4.6 patch 102 days ago
1 """Background job queue — enqueue, claim, and complete durable async jobs.
2
3 The web process (uvicorn) calls ``enqueue_push_intel`` (or ``enqueue_job``
4 directly) to insert rows into ``musehub_background_jobs`` and returns
5 immediately. A separate worker process calls ``claim_next_job`` in a polling
6 loop, executes the job via the provider registry, and calls ``complete_job``
7 or ``fail_job``.
8
9 Because the worker is a separate OS process, an OOM crash in the worker
10 cannot crash the web server. This is the fundamental correctness guarantee
11 — ``asyncio.create_task`` inside uvicorn provides no such isolation.
12
13 Job types (namespaced)
14 ----------------------
15 ``intel.structural``
16 Compute cross-domain structural intelligence (velocity, contributors).
17 Works for any repo domain. payload: {"head": "<commit_id>"}
18
19 ``intel.code``
20 Rebuild code symbol intelligence index.
21 payload: {"head": "<commit_id>"}
22
23 ``intel.midi``
24 Compute MIDI-domain intelligence (stub).
25 payload: {"head": "<commit_id>"}
26
27 ``gc``
28 Prune orphaned commits and snapshots after a force push.
29 payload: {}
30 """
31
32 import logging
33 from datetime import datetime, timedelta, timezone
34
35 from sqlalchemy import and_, exists, select, update
36 from sqlalchemy.orm import aliased
37 from sqlalchemy.ext.asyncio import AsyncSession
38
39 from muse.core.types import short_id
40 from musehub.core.genesis import compute_job_id
41 from musehub.db.musehub_domain_models import MusehubDomain
42 from musehub.db.musehub_jobs_models import MusehubBackgroundJob
43 from musehub.db.musehub_repo_models import MusehubBranch
44 from musehub.services.musehub_intel_providers import job_types_for_push
45 from musehub.types.json_types import JSONObject
46
47 logger = logging.getLogger(__name__)
48
49 _MAX_ATTEMPTS: int = 3
50 _STALE_CLAIM_MINUTES: int = 10
51
52 # Test spy — populated by the conftest stub; empty in production.
53 _test_enqueued_calls: list[tuple[str, str, JSONObject]] = []
54
55 def _utc_now() -> datetime:
56 return datetime.now(tz=timezone.utc)
57
58 async def enqueue_job(
59 session: AsyncSession,
60 repo_id: str,
61 job_type: str,
62 payload: JSONObject,
63 *,
64 dedup_payload_key: str | None = None,
65 ) -> str | None:
66 """Insert a background job row and return its job_id.
67
68 Idempotent: if a ``pending`` job of the same ``(repo_id, job_type)`` pair
69 already exists, the new insert is skipped and ``None`` is returned. The
70 existing pending row covers the work — no duplicate needed.
71
72 When *dedup_payload_key* is given, idempotency is keyed on
73 ``(repo_id, job_type, payload[dedup_payload_key], pending)`` instead of
74 the coarse ``(repo_id, job_type, pending)`` key. Use this when each
75 distinct payload value represents non-substitutable work (e.g. a per-mpack
76 index job where ``mpack_key`` identifies the specific object to index).
77
78 The caller must commit (or the surrounding transaction must commit) for
79 the worker to see the row.
80 """
81 q = select(MusehubBackgroundJob.job_id).where(
82 MusehubBackgroundJob.repo_id == repo_id,
83 MusehubBackgroundJob.job_type == job_type,
84 MusehubBackgroundJob.status == "pending",
85 )
86 pk_val: str | None = None
87 if dedup_payload_key is not None:
88 pk_val = str(payload.get(dedup_payload_key, ""))
89 q = q.where(MusehubBackgroundJob.payload[dedup_payload_key].astext == pk_val)
90
91 existing = (await session.execute(q.limit(1))).scalar_one_or_none()
92 if existing is not None:
93 logger.debug(
94 "Job already pending — skipping: type=%s repo=%s", job_type, repo_id
95 )
96 return None
97
98 created_at = _utc_now()
99 # When dedup_payload_key is given, include the key value in the job_id so
100 # two pending jobs of the same type but distinct payload values have unique
101 # primary keys and do not collide.
102 id_suffix = f":{pk_val}" if pk_val else ""
103 job_id = compute_job_id(repo_id, f"{job_type}{id_suffix}", created_at.isoformat())
104 session.add(MusehubBackgroundJob(
105 job_id=job_id,
106 repo_id=repo_id,
107 job_type=job_type,
108 payload=payload,
109 status="pending",
110 created_at=created_at,
111 attempt=0,
112 ))
113 logger.debug("Enqueued job type=%s repo=%s job_id=%s", job_type, short_id(repo_id), job_id)
114 return job_id
115
116 async def enqueue_push_intel(
117 session: AsyncSession,
118 repo_id: str,
119 head: str,
120 domain_id: str | None = None,
121 branch: str = "",
122 owner: str | None = None,
123 mpack_key: str = "",
124 ) -> None:
125 """Enqueue all intelligence jobs triggered by a push.
126
127 Batches push intel jobs + optional profile.snapshot into a single
128 SELECT IN + add_all — O(1) DB round-trips regardless of job count.
129
130 Pass ``owner`` to include profile.snapshot in the same batch (saves
131 one extra SELECT compared to calling enqueue_profile_snapshot separately).
132 """
133 import time as _t
134 _t0 = _t.monotonic()
135
136 # domain_id is a sha256 hash — resolve the slug for the provider dispatch.
137 domain_slug: str | None = None
138 if domain_id:
139 domain_slug = (await session.execute(
140 select(MusehubDomain.slug).where(MusehubDomain.domain_id == domain_id).limit(1)
141 )).scalar_one_or_none()
142
143 push_payload: JSONObject = {"head": head, "branch": branch}
144 mpack_payload: JSONObject = {"mpack_key": mpack_key, "head": head, "branch": branch}
145
146 # FMC_16 — collect all branch tips so the prebuild job covers every branch head.
147 branch_tips_q = await session.execute(
148 select(MusehubBranch.head_commit_id)
149 .where(MusehubBranch.repo_id == repo_id)
150 .where(MusehubBranch.head_commit_id.isnot(None))
151 )
152 prebuild_payload: JSONObject = {"tip_commit_ids": [row[0] for row in branch_tips_q]}
153
154 # (job_type, payload) pairs — gc, mpack.index, fetch.mpack.prebuild and profile.snapshot
155 # have distinct payloads; all other types share push_payload.
156 jobs: list[tuple[str, JSONObject]] = [
157 (
158 jt,
159 {} if jt == "gc" else
160 mpack_payload if jt == "mpack.index" else
161 prebuild_payload if jt == "fetch.mpack.prebuild" else
162 push_payload
163 )
164 for jt in job_types_for_push(domain_slug)
165 ]
166 if owner:
167 jobs.append(("profile.snapshot", {"handle": owner}))
168
169 if not jobs:
170 return
171
172 # Partition: mpack.index needs per-mpack_key dedup; all other types use coarse dedup.
173 coarse_jobs = [(jt, pl) for jt, pl in jobs if jt != "mpack.index"]
174 index_jobs = [(jt, pl) for jt, pl in jobs if jt == "mpack.index"]
175
176 # Coarse dedup — one SELECT for all non-index types.
177 coarse_types = [jt for jt, _ in coarse_jobs]
178 existing_coarse: set[str] = set()
179 if coarse_types:
180 existing_coarse = set((await session.execute(
181 select(MusehubBackgroundJob.job_type).where(
182 MusehubBackgroundJob.repo_id == repo_id,
183 MusehubBackgroundJob.job_type.in_(coarse_types),
184 MusehubBackgroundJob.status == "pending",
185 )
186 )).scalars().all())
187
188 # Per-key dedup for mpack.index — each distinct mpack_key is non-substitutable work.
189 # Empty mpack_key falls back to coarse dedup (covers legacy/no-key callers).
190 index_to_add: list[tuple[str, JSONObject]] = []
191 for jt, pl in index_jobs:
192 mk = str(pl.get("mpack_key", ""))
193 if mk:
194 dup = (await session.execute(
195 select(MusehubBackgroundJob.job_id).where(
196 MusehubBackgroundJob.repo_id == repo_id,
197 MusehubBackgroundJob.job_type == "mpack.index",
198 MusehubBackgroundJob.status == "pending",
199 MusehubBackgroundJob.payload["mpack_key"].astext == mk,
200 ).limit(1)
201 )).scalar_one_or_none()
202 else:
203 dup = (await session.execute(
204 select(MusehubBackgroundJob.job_id).where(
205 MusehubBackgroundJob.repo_id == repo_id,
206 MusehubBackgroundJob.job_type == "mpack.index",
207 MusehubBackgroundJob.status == "pending",
208 ).limit(1)
209 )).scalar_one_or_none()
210 if dup is None:
211 index_to_add.append((jt, pl))
212
213 # Three-tier created_at stagger — claim_next_job (ORDER BY created_at) processes
214 # jobs in dependency order even before the Phase 2 barrier is consulted:
215 # t+0s — foundation: intel.code, intel.structural, mpack.index, gc, ...
216 # t+1s — fetch.mpack.prebuild (must run after mpack.index, before subtypes)
217 # t+2s — intel.code.* subtypes that depend on intel.code output
218 created_at = _utc_now()
219 created_at_prebuild = created_at + timedelta(seconds=1)
220 created_at_subtype = created_at + timedelta(seconds=2)
221
222 _FOUNDATION_TYPES = frozenset({"intel.code", "intel.structural", "mpack.index", "gc",
223 "push.file_last_commits", "profile.snapshot"})
224
225 def _job_created_at(job_type: str) -> datetime:
226 if job_type in _FOUNDATION_TYPES:
227 return created_at
228 if job_type == "fetch.mpack.prebuild":
229 return created_at_prebuild
230 return created_at_subtype
231
232 coarse_rows = [
233 MusehubBackgroundJob(
234 job_id=compute_job_id(repo_id, job_type, created_at.isoformat()),
235 repo_id=repo_id,
236 job_type=job_type,
237 payload=payload,
238 status="pending",
239 created_at=_job_created_at(job_type),
240 attempt=0,
241 )
242 for job_type, payload in coarse_jobs
243 if job_type not in existing_coarse
244 ]
245 # Use mpack_key in the job_id to prevent PK collision when two distinct keys
246 # arrive at the same timestamp.
247 index_rows = [
248 MusehubBackgroundJob(
249 job_id=compute_job_id(
250 repo_id,
251 f"mpack.index:{str(pl.get('mpack_key', ''))}",
252 created_at.isoformat(),
253 ),
254 repo_id=repo_id,
255 job_type=jt,
256 payload=pl,
257 status="pending",
258 created_at=created_at,
259 attempt=0,
260 )
261 for jt, pl in index_to_add
262 ]
263 session.add_all(coarse_rows + index_rows)
264 logger.info(
265 "enqueue_push_intel: %d types (%d coarse, %d index), coarse_existing=%d, index_added=%d, %.3fs",
266 len(jobs), len(coarse_jobs), len(index_jobs),
267 len(existing_coarse), len(index_rows), _t.monotonic() - _t0,
268 )
269
270 async def enqueue_profile_snapshot(
271 session: AsyncSession,
272 repo_id: str,
273 handle: str,
274 ) -> str | None:
275 """Enqueue a profile snapshot job for *handle* after a push.
276
277 Uses *repo_id* as the FK-required job anchor (any valid repo owned by the
278 user works; idempotency is per ``(repo_id, job_type, pending)``). The
279 handle is stored in ``payload["handle"]`` so the worker knows which user
280 to compute for regardless of which repo triggered the push.
281 """
282 return await enqueue_job(session, repo_id, "profile.snapshot", {"handle": handle})
283
284 async def claim_next_job(session: AsyncSession) -> MusehubBackgroundJob | None:
285 """Atomically claim the oldest pending job.
286
287 Uses ``SELECT … FOR UPDATE SKIP LOCKED`` so multiple worker processes
288 can poll without racing — each worker claims a distinct row. Returns
289 ``None`` when the queue is empty.
290
291 Dependency barrier (RC-4 fix): a ``fetch.mpack.prebuild`` job is not
292 claimable while any ``mpack.index`` for the same ``repo_id`` is in
293 ``pending`` or ``running`` state. The correlated ``NOT EXISTS`` subquery
294 is a read-only existence check; ``FOR UPDATE SKIP LOCKED`` locks only the
295 outer selected row, so the barrier adds no extra lock contention.
296 """
297 _idx = aliased(MusehubBackgroundJob)
298 _index_blocking = (
299 select(_idx.job_id)
300 .where(
301 _idx.repo_id == MusehubBackgroundJob.repo_id,
302 _idx.job_type == "mpack.index",
303 _idx.status.in_(("pending", "running")),
304 )
305 )
306 result = await session.execute(
307 select(MusehubBackgroundJob)
308 .where(
309 MusehubBackgroundJob.status == "pending",
310 MusehubBackgroundJob.attempt < _MAX_ATTEMPTS,
311 ~and_(
312 MusehubBackgroundJob.job_type == "fetch.mpack.prebuild",
313 exists(_index_blocking),
314 ),
315 )
316 .order_by(MusehubBackgroundJob.created_at)
317 .limit(1)
318 .with_for_update(skip_locked=True)
319 )
320 job = result.scalar_one_or_none()
321 if job is None:
322 return None
323
324 job.status = "running"
325 job.claimed_at = _utc_now()
326 job.attempt += 1
327 await session.flush()
328 return job
329
330 async def reclaim_stale_jobs(session: AsyncSession) -> int:
331 """Reset jobs stuck in 'running' state back to 'pending'.
332
333 A job is considered stale when it has been running for longer than
334 ``_STALE_CLAIM_MINUTES``. This handles worker crashes that leave rows
335 in 'running' state indefinitely.
336
337 Returns the number of jobs reset.
338 """
339 cutoff = _utc_now() - timedelta(minutes=_STALE_CLAIM_MINUTES)
340 result = await session.execute(
341 update(MusehubBackgroundJob)
342 .where(
343 MusehubBackgroundJob.status == "running",
344 MusehubBackgroundJob.claimed_at < cutoff,
345 MusehubBackgroundJob.attempt < _MAX_ATTEMPTS,
346 )
347 .values(status="pending")
348 .returning(MusehubBackgroundJob.job_id)
349 )
350 reset = list(result.scalars())
351 if reset:
352 logger.warning(
353 "⚠️ Reclaimed %d stale job(s) stuck in 'running': %s",
354 len(reset), ", ".join(reset[:5]),
355 )
356 return len(reset)
357
358 async def complete_job(session: AsyncSession, job_id: str) -> None:
359 """Mark a job as successfully completed."""
360 await session.execute(
361 update(MusehubBackgroundJob)
362 .where(MusehubBackgroundJob.job_id == job_id)
363 .values(status="done", done_at=_utc_now())
364 )
365
366 async def fail_job(session: AsyncSession, job_id: str, error: str) -> None:
367 """Mark a job as failed.
368
369 If the job has remaining attempts it is reset to ``pending`` so the
370 worker will retry it on the next poll cycle. If attempts are exhausted
371 the status is set to ``failed`` permanently.
372 """
373 result = await session.execute(
374 select(MusehubBackgroundJob).where(MusehubBackgroundJob.job_id == job_id)
375 )
376 job = result.scalar_one_or_none()
377 if job is None:
378 return
379
380 job.error = error[:2000]
381 if job.attempt < _MAX_ATTEMPTS:
382 job.status = "pending"
383 logger.warning(
384 "⚠️ Job failed (attempt %d/%d), will retry: type=%s job_id=%s error=%s",
385 job.attempt, _MAX_ATTEMPTS, job.job_type, job_id, error[:200],
386 )
387 else:
388 job.status = "failed"
389 job.done_at = _utc_now()
390 logger.error(
391 "❌ Job permanently failed after %d attempts: type=%s job_id=%s error=%s",
392 job.attempt, job.job_type, job_id, error[:200],
393 )
394
395
396 async def quarantine_job(session: AsyncSession, job_id: str, reason: str) -> None:
397 """Mark a job as quarantined due to an mpack content validation failure.
398
399 Quarantined jobs are terminal — they will not be retried. The reason
400 is stored in quarantine_reason for forensic review.
401 """
402 result = await session.execute(
403 select(MusehubBackgroundJob).where(MusehubBackgroundJob.job_id == job_id)
404 )
405 job = result.scalar_one_or_none()
406 if job is None:
407 return
408 job.status = "quarantined"
409 job.quarantine_reason = reason[:2000]
410 job.done_at = _utc_now()
411 logger.warning(
412 "🔒 Job quarantined (mpack validation failure): type=%s job_id=%s reason=%s",
413 job.job_type, job_id, reason[:200],
414 )
File History 3 commits
sha256:d50f9cf9829dfbe35721a23b81ad256c729ddf9dd565a0a9e56d27847e255632 feat(#92): phase 4 — enqueue fetch.mpack.prebuild on push (… Sonnet 4.6 patch 102 days ago
sha256:65f2fd8d910e1eeb00b7bc8740d3cbf1b2e14dad83b2eb999fbbbc44e97cd936 getting intel jobs to run properly Human minor 111 days ago
sha256:e652528bc7ab28fc0a6799df687779ee296be645a7e1b4120e271df96aebf20a chore: carry uncommitted changes (mpack_key in enqueue_push… Sonnet 4.6 minor 113 days ago