MWP-3 — Job-enqueue idempotency drops fresh payloads on back-to-back pushes (fixes RC-3)
Sub-ticket of the MWP MVP master tracker: muse#58 — https://staging.musehub.ai/gabriel/muse/issues/58 Predecessors: musehub#106 (MWP-1, generation authority) — closed; musehub#107 (MWP-2, walk fallback) — closed. (Formal cross-issue links are not available yet; this issue and #58 cross-reference by URL.)
Background
muse clone has no dedicated endpoint — it is a full POST /fetch/mpack with
have=[], served from the prebuilt-mpack cache. After every push the server
enqueues two clone-correctness-critical background jobs:
mpack.index— reads the just-pushed mpack from object storage and writes byte-range offsets intomusehub_mpack_indexso the fetch path can serve blobs via byte-range GETs. One job per pushed mpack (each mpack is distinct work).fetch.mpack.prebuild— rebuilds one combined mpack covering all current branch tips and upserts amusehub_fetch_mpack_cacherow per tip, so the next clone is a sub-second cache HIT.
A separate worker process polls the queue and runs these jobs. Reality check —
the worker today runs only these two job types; every other job type is a
no-op (musehub/worker.py:107-108: if job_type not in ("mpack.index", "fetch.mpack.prebuild"): pass). So RC-3's live blast radius is exactly these two
types — both of which gate clone correctness.
The bug (RC-3)
Both enqueue paths dedup pending jobs on the coarse key
(repo_id, job_type, status="pending"):
enqueue_job(musehub/services/musehub_jobs.py:72-84) — single-job path:SELECT job_id WHERE repo_id=? AND job_type=? AND status='pending' LIMIT 1; if a row exists it returnsNoneand inserts nothing.enqueue_push_intel(musehub/services/musehub_jobs.py:158-189) — batch path: oneSELECT job_type … WHERE job_type IN (…) AND status='pending', thenadd_all([... for job_type, payload in jobs if job_type not in existing]).
This coarse dedup assumes any pending job of a type covers all future work of that type. That assumption is false for both live job types, in two distinct ways:
Face 1 — mpack.index: push B's mpack is never indexed
mpack.index's payload is push-specific: {"mpack_key": <this push's mpack>, "head": …, "branch": …} (built at musehub_jobs.py:128 as mpack_payload).
Each push produces a different mpack_key. If push A's mpack.index is still
pending when push B lands, push B is deduped — and push B's mpack_key is never
indexed at all. The pending job carries push A's mpack_key; when it runs it
indexes A's mpack, not B's. B's blobs have no byte-range index until yet another
push happens to be processed while no index job is pending. Coarse dedup is simply
wrong for mpack.index: distinct mpacks are distinct, non-substitutable work.
Face 2 — fetch.mpack.prebuild: stale captured tip set
fetch.mpack.prebuild's payload captures the branch tips at enqueue time:
prebuild_payload = {"tip_commit_ids": [row[0] for row in branch_tips_q]}
(musehub_jobs.py:131-136). The handler
process_fetch_mpack_prebuild_job (musehub/services/musehub_wire_fetch.py:1034)
trusts that captured list verbatim (musehub_wire_fetch.py:1067:
tip_commit_ids = [str(t) for t in (payload.get("tip_commit_ids") or [])]).
When push A's prebuild is still pending and push B advances a branch, push B is
deduped. Push A's pending job still carries push A's tip set — missing push
B's new tip. When the single job finally runs, it builds a combined mpack for the
old tip set, and the clone cache for push B's new tip is never written (or is
written pointing at an mpack that predates B). Result: clone of the new tip is a
cache MISS forever (until another push coincidentally rebuilds), or worse, a
stale HIT.
Net effect: rapid / back-to-back pushes ⇒ permanently stale clone cache and unindexed mpacks. This is RC-3.
Why the two faces need different fixes
The two job types fail for structurally different reasons, so they take different remedies — this is the core design insight of MWP-3:
| Job type | Why coarse dedup fails | Correct fix |
|---|---|---|
fetch.mpack.prebuild |
Captured tip set goes stale | Re-read current branch tips at run time → the single pending job always builds the latest set. Coarse dedup becomes correct. |
mpack.index |
Each mpack is distinct, non-substitutable work | Dedup per mpack_key → distinct mpacks always enqueue; only an identical re-pushed mpack is deduped. |
After MWP-3, the invariant is: a pending job is only ever skipped when an existing pending job will do the exact same effective work.
Relationship to RC-4 (out of scope — MWP-4)
RC-4 is a separate bug: fetch.mpack.prebuild and mpack.index share a
created_at and claim_next_job (musehub_jobs.py:206-231) orders by
created_at with no tiebreaker, so prebuild can run before index. MWP-3 does
not fix ordering — it fixes which jobs exist and what payload they carry.
The two are independent and compose. Do not change _FOUNDATION_TYPES staggering
or claim_next_job ordering in this ticket. See "Out of scope".
Goal
- No fresh work is ever dropped on back-to-back pushes. Every pushed mpack is indexed; every branch-tip advance results in a prebuilt clone cache covering the latest tip.
fetch.mpack.prebuildis self-coalescing: the handler re-reads current branch tips at run time, so a single pending prebuild always reflects the latest state no matter how many pushes were deduped into it.mpack.indexdedup is keyed onmpack_key: two distinct mpacks always produce two index jobs; an identical re-pushed mpack is still deduped (idempotent).- A non-skipped regression test proves a two-push (A then B) sequence drained by the worker leaves both mpacks indexed and the clone cache pointing at B's tip.
- Steady-state single-push latency and behavior are unchanged (no duplicate-job explosion, no extra round-trips beyond what the per-key check costs).
Design
Fix 1 — fetch.mpack.prebuild re-reads branch tips at run time
File: musehub/services/musehub_wire_fetch.py::process_fetch_mpack_prebuild_job
Today the handler reads the tip set from the payload (musehub_wire_fetch.py:1067).
Change it to query the current branch tips authoritatively, mirroring the
query already used in enqueue_push_intel (musehub_jobs.py:131-135):
from musehub.db.musehub_repo_models import MusehubBranch
# Authoritative: the tips that exist RIGHT NOW, not whatever was captured at
# enqueue time. This makes the job self-coalescing — any number of pushes that
# were deduped into one pending job are covered, because we read live state here.
branch_tips_q = await session.execute(
select(MusehubBranch.head_commit_id)
.where(MusehubBranch.repo_id == repo_id)
.where(MusehubBranch.head_commit_id.isnot(None))
)
tip_commit_ids: list[str] = [str(row[0]) for row in branch_tips_q]
# payload["tip_commit_ids"] is retained ONLY for observability (logged below);
# it is NOT authoritative. A stale captured list must never shrink the build.
_payload_tips = [str(t) for t in (payload.get("tip_commit_ids") or [])]
if set(_payload_tips) != set(tip_commit_ids):
logger.info(
"[fetch.mpack.prebuild] job=%s payload tips=%d differ from live tips=%d "
"(coalesced pushes) — using live tips",
job_id[:16], len(_payload_tips), len(tip_commit_ids),
)
The rest of the handler (cache-freshness check, combined build, per-tip upsert)
is unchanged — it already builds one combined mpack covering all
tip_commit_ids and upserts a cache row per tip so the multi-tip clone cache-hit
check (len(mpack_ids) == 1 across all want tips) fires. Reading live tips simply
guarantees that set is current.
Consequence: the coarse dedup on (repo_id, "fetch.mpack.prebuild", pending)
in enqueue_push_intel is now correct and stays as-is. One pending prebuild
covers all branches at the moment it runs.
The "running" race is already safe: if push A's prebuild is mid-flight
(status="running", not "pending") when push B lands, push B's coarse check
finds no pending prebuild and enqueues a fresh one, which re-reads live tips
again. No tip can be lost.
Why we keep writing payload["tip_commit_ids"]: it is cheap, aids debugging,
and tests/test_fetch_mpack_prebuild.py::test_fmc_18_… asserts its presence. The
handler simply no longer trusts it as authoritative.
Fix 2 — mpack.index dedup keyed on mpack_key
File: musehub/services/musehub_jobs.py::enqueue_push_intel
Pull mpack.index out of the coarse job_type IN (...) dedup and give it a
per-mpack_key existence check. The cleanest shape: partition the job list into
coarse-deduped types and the per-key type, run the existing batch SELECT for
the coarse set, and a targeted SELECT for mpack.index keyed on the JSONB
mpack_key.
# mpack.index is per-mpack work: dedup on the exact mpack_key, never on job_type
# alone. Two distinct mpacks must both be indexed; an identical re-pushed mpack
# (same mpack_key) is still deduped.
_PER_KEY_TYPE = "mpack.index"
coarse_jobs = [(jt, p) for jt, p in jobs if jt != _PER_KEY_TYPE]
index_jobs = [(jt, p) for jt, p in jobs if jt == _PER_KEY_TYPE]
# --- coarse dedup (unchanged) ---
coarse_types = [jt for jt, _ in coarse_jobs]
existing_coarse = set((await session.execute(
select(MusehubBackgroundJob.job_type).where(
MusehubBackgroundJob.repo_id == repo_id,
MusehubBackgroundJob.job_type.in_(coarse_types),
MusehubBackgroundJob.status == "pending",
)
)).scalars().all())
# --- per-key dedup for mpack.index ---
index_to_add: list[tuple[str, JSONObject]] = []
for jt, payload in index_jobs:
mk = str(payload.get("mpack_key", ""))
if not mk:
continue # nothing to index; skip (matches process_mpack_index_job guard)
already = (await session.execute(
select(MusehubBackgroundJob.job_id).where(
MusehubBackgroundJob.repo_id == repo_id,
MusehubBackgroundJob.job_type == _PER_KEY_TYPE,
MusehubBackgroundJob.status == "pending",
MusehubBackgroundJob.payload["mpack_key"].astext == mk,
).limit(1)
)).scalar_one_or_none()
if already is None:
index_to_add.append((jt, payload))
The add_all then unions:
[coarse jobs not in existing_coarse] + index_to_add, preserving the existing
_FOUNDATION_TYPES / subtype created_at staggering verbatim (RC-4 territory —
do not touch).
JSONB note:
MusehubBackgroundJob.payloadis a PostgresJSONBcolumn (musehub/db/musehub_jobs_models.py:…).payload["mpack_key"].astext == mkcompiles topayload ->> 'mpack_key' = :mk. The test DB is Postgres, so this is exercised directly. (If a SQLite unit path is ever added, this comparison must be guarded — but the current suite runs on Postgres via thedb_sessionfixture.)
Optional generalization — enqueue_job(dedup_payload_key=…)
enqueue_job (musehub/services/musehub_jobs.py:57) is the generic single-job
path (currently used only by enqueue_profile_snapshot). It is not on the
mpack.index enqueue path today, so fixing it is not required for RC-3. To make
the per-key pattern reusable and prevent a future regression if a caller routes a
per-key job through enqueue_job, add an optional parameter:
async def enqueue_job(
session, repo_id, job_type, payload, *, dedup_payload_key: str | None = None,
) -> str | None:
"""... When dedup_payload_key is given, idempotency is keyed on
(repo_id, job_type, payload[dedup_payload_key], pending) instead of the
coarse (repo_id, job_type, pending) key."""
This is a small, well-tested addition (deliverable MWP3_08b). Keep it scoped:
do not reroute mpack.index through enqueue_job — the batch path stays the
single enqueue site for push jobs.
Phases (load-bearing; each green before the next)
Phase 0 — Reproduce (RED) ✅
- [x]
MWP3_01Unit: twoenqueue_push_intelcalls for the same repo with distinctmpack_keys (simulating push A then push B, A's index still pending) → assert twompack.indexpending rows exist. RED today (coarse dedup drops the second). Asserts Face 1. - [x]
MWP3_02Unit: create branchmain→tipA, callenqueue_push_intel; then advancemain→tipB (or add branchdev→tipB) and call again. The single pendingfetch.mpack.prebuildrow'spayload["tip_commit_ids"]still contains only{tipA}— and (the real defect) runningprocess_fetch_mpack_prebuild_jobagainst that pending row builds a cache that omits tipB. RED today (handler trusts the stale payload). Asserts Face 2. - [x]
MWP3_03Guard (must stay GREEN through all phases): a singleenqueue_push_intelcall enqueues exactly onempack.indexand exactly onefetch.mpack.prebuild— no duplicate explosion.
Phase 0 exit: MWP3_01 and MWP3_02 RED for the documented reasons; MWP3_03 GREEN. No production code changed. Committed
sha256:78ce8754bfa2b…ontask/mwp-3-job-idempotency.
Phase 1 — fetch.mpack.prebuild self-coalescing (runtime re-read) ✅
- [x]
MWP3_04Modifyprocess_fetch_mpack_prebuild_jobto read branch tips fromMusehubBranchat run time (query mirrorsenqueue_push_intel:131-135). Unit: insert a prebuild job with an emptypayload["tip_commit_ids"]but two liveMusehubBranchrows (tipA, tipB) + mockwire_fetch_mpack; assert the handler requests a build for{tipA, tipB}. - [x]
MWP3_05Unit: payload carries only{tipA}but DB has{tipA, tipB}→ handler builds{tipA, tipB}(live tips win over stale payload). Makes MWP3_02 GREEN. - [x]
MWP3_06Integration: enqueue prebuild for push A (payload{tipA}), advance branch to add tipB, then run the single pending handler against a real (or in-mem-backed) build →musehub_fetch_mpack_cachehas rows for both tipA and tipB pointing at onempack_id.
Phase 1 exit: prebuild is authoritative on live state; MWP3_02 GREEN; FMC_18 still GREEN (payload still written for observability). Committed
sha256:b63ee1a008d0af…ontask/mwp-3-job-idempotency.
Phase 2 — mpack.index per-mpack_key dedup ✅
- [x]
MWP3_07Modifyenqueue_push_intelto dedupmpack.indexon(repo_id, "mpack.index", payload->>'mpack_key', pending). Unit: two calls with distinctmpack_keys → twompack.indexpending. Makes MWP3_01 GREEN. - [x]
MWP3_08Unit: two calls with the samempack_key(idempotent re-push) → exactly onempack.indexpending (per-key dedup still collapses true duplicates). - [x]
MWP3_08b(optional generalization) Adddedup_payload_keyparam toenqueue_job; unit: twoenqueue_jobcalls, same type, distinctpayload[key]→ two rows; samepayload[key]→ one row. Default (no param) = unchanged coarse behavior. - [x]
MWP3_09Regression: across the same two distinct-mpack_keycalls, the coarse types (fetch.mpack.prebuild,intel.*,gc, …) remain deduped to one pending each — onlympack.indexmultiplied. Confirms the partition didn't loosen coarse dedup.
Phase 2 exit: MWP3_01 GREEN; distinct mpacks always indexed; coarse dedup intact; MWP3_03 still GREEN. Committed
sha256:58981a2606ddc4f96980b255b0f771d2a0f9f6f4786cebb0edb4e6640d3beea5ontask/mwp-3-job-idempotency.
Phase 3 — End-to-end back-to-back drain (the acceptance gate) ✅
- [x]
MWP3_10E2E: push A (C1) then push B (C2 on the same branch) throughwire_push_unpack_mpackwith an in-memory backend (mirrortests/test_mwp2_walk_fallback.py::_InMemBackend/_e2e_push). Between the two pushes do not drain the worker (so A's jobs are still pending when B lands). Then drain all pending jobs (claim → dispatch via the same handlers the worker calls). Assert: (a)musehub_mpack_indexhas rows for both A's and B'smpack_key; (b)musehub_fetch_mpack_cachefor the branch tip points at an mpack that contains C2. This is the acceptance gate. - [x]
MWP3_11Stress: N=10 rapid pushes on one branch with the worker draining opportunistically (interleave a partial drain mid-sequence) → after the final drain, every distinct pushedmpack_keyis indexed and the clone cache serves the final tip. No permanently-stale cache row remains.
Phase 3 exit: the originally-reported "rapid pushes ⇒ stale clone" symptom is impossible; both faces are closed end-to-end. All 14 tests GREEN.
Phase 4 — Lock & document ✅
- [x]
MWP3_12Confirm the full MWP-3 file is non-skipped and GREEN; run the adjacent suites that touch these symbols and confirm no regression:tests/test_enqueue_batch.py,tests/test_fetch_mpack_prebuild.py,tests/test_mpack_index_always_enqueued.py,tests/test_process_mpack_index_job.py. All 36 tests across 5 suites GREEN. Three tests intest_fetch_mpack_prebuild.py(FMC_07, FMC_07b, FMC_08) required a one-line fix each: they relied on the handler trusting the job payload's tip list, which the Phase 1 fix superseded. Fix: addMusehubBranchrows matching each test's tip IDs so the handler's live-state query returns the expected set. The semantics of each test are unchanged; only the setup is brought into alignment with the current handler contract. - [x] Update this issue body marking every phase ✅ and every acceptance criterion
[x], then close with a comment and update muse#58.
Acceptance criteria
- [x] Two back-to-back pushes with distinct mpacks both get indexed — no
mpack.indexjob is dropped by dedup (MWP3_01,MWP3_07,MWP3_10). - [x]
fetch.mpack.prebuildreflects the current branch tips whenever it runs, regardless of how many pushes were deduped into one pending job (MWP3_04,MWP3_05,MWP3_06). - [x] An identical re-pushed mpack (same
mpack_key) is still deduped to onempack.index(MWP3_08). - [x] Coarse dedup for all non-
mpack.indextypes is unchanged — a single push enqueues exactly one of each (MWP3_03,MWP3_09). - [x] A worker drain of a no-intermediate-drain A→B push sequence leaves both
mpacks indexed and the clone cache pointing at B's tip (
MWP3_10). Codified as a non-skipped regression test. - [x] RC-4 ordering and
_FOUNDATION_TYPESstaggering are untouched; existing job-enqueue suites stay green (MWP3_12).
Testing tiers
| Tier | Coverage |
|---|---|
| Unit | per-key index dedup; coarse-dedup preservation; handler live-tip read; dedup_payload_key |
| Integration | DB-backed two-call enqueue; single pending prebuild builds live tips |
| End-to-end | push A → push B (no drain) → worker drain → both indexed + cache at B's tip |
| Stress | N rapid pushes; every mpack indexed; final clone serves latest tip |
| Data integrity | no dropped index work; no stale/missing clone cache row under rapid pushes |
Out of scope
- RC-4 — prebuild/index claim ordering.
created_attiebreaker /_FOUNDATION_TYPESstagger /claim_next_jobORDER BY → MWP-4. Do not modify ordering here. - Intel/profile staleness — and do NOT try to re-enable the intel jobs.
intel.*,push.file_last_commits,profile.snapshotalso use coarse dedup, but (a) the worker currently no-ops them (worker.py:107-108) and (b) they are recomputed on the next push and are not clone-blocking. The ~17 intel job types were deliberately disabled in the worker because they failed or ran too long and were actively preventing debugging of thempack.index/fetch.mpack.prebuildpipeline. Getting them operational is a separate, deferred effort — stay out of that rabbit hole. This ticket touches only the two mpack jobs. Making intel re-read HEAD at run time is a future hardening, not RC-3. gccoalescing.gcis naturally idempotent on current state; coarse dedup is already correct for it.- Re-enabling the broad disabled wire suite → MWP-6.
Anchors
musehub/services/musehub_jobs.py::enqueue_push_intel(batch enqueue; add per-mpack_keydedup formpack.index, lines 156-189; keep_FOUNDATION_TYPESstagger at 170-189 intact)musehub/services/musehub_jobs.py::enqueue_job(generic path; optionaldedup_payload_keyparam —MWP3_08b)musehub/services/musehub_jobs.py::claim_next_job(worker claim — read only, do not change ordering: RC-4)musehub/services/musehub_wire_fetch.py::process_fetch_mpack_prebuild_job(re-readMusehubBranchtips at run time; replace trust ofpayload["tip_commit_ids"]at line 1067)musehub/services/musehub_wire_push.py::process_mpack_index_job(consumer ofmpack.index; readspayload["mpack_key"]at line 1557 — confirms why per-key dedup is required)musehub/services/musehub_intel_providers.py::job_types_for_push(source of the job-type list;mpack.index+fetch.mpack.prebuildat line 2716)musehub/db/musehub_jobs_models.py::MusehubBackgroundJob(JSONBpayloadcolumn enablingpayload->>'mpack_key'dedup)musehub/db/musehub_repo_models.py::MusehubBranch(head_commit_id— authoritative tip source for the prebuild handler)musehub/worker.py::_process_one(dispatch; the two live job types at lines 107-131 — mirror this in theMWP3_10E2E drain)- New test file:
musehub/tests/test_mwp3_job_idempotency.py(test IDsMWP3_01–MWP3_12) - Adjacent regression suites:
tests/test_enqueue_batch.py,tests/test_fetch_mpack_prebuild.py(FMC_18),tests/test_mpack_index_always_enqueued.py,tests/test_process_mpack_index_job.py