gabriel / musehub public
musehub_wire_fetch.py python
1,355 lines 53.8 KB Hotspot
Raw
sha256:1c5b7a0aba79472f4b10e52326dc010bdab1a498c9e195593d0707860478a034 feat(#92): phase 3 — cache lookup in wire_fetch_mpack (FMC_… Sonnet 4.6 patch 34 days ago
1 """Fetch path — wire_fetch_presign, wire_fetch_mpack, wire_fetch, process_mpack_gc_job."""
2
3 import asyncio
4 import hashlib
5 import logging
6 import msgpack as _msgpack
7 import time as _time_module
8 from collections import deque
9 from datetime import datetime, timezone
10 from typing import TypedDict
11
12 from sqlalchemy import func, select, text as _sa_text
13 from sqlalchemy.dialects.postgresql import insert as _pg_insert
14 from sqlalchemy.ext.asyncio import AsyncSession
15
16 from musehub.db.musehub_repo_models import (
17 MusehubBranch,
18 MusehubCommit,
19 MusehubCommitGraph,
20 MusehubFetchMPackCache,
21 MusehubMPackIndex,
22 MusehubObject,
23 MusehubObjectRef,
24 MusehubRepo,
25 MusehubSnapshot,
26 )
27 from musehub.models.wire import WireFetchRequest
28 from muse.core.types import blob_id
29 from musehub.storage import get_backend
30
31 from musehub.services.musehub_wire_shared import (
32 FetchMPackResult,
33 FetchNotIndexedError,
34 MPackNotReadyError,
35 FetchPresignResult,
36 MPackValidationError,
37 _reconstruct_manifest,
38 _snap_row_to_wire_s3,
39 _commit_to_wire_s3,
40 _to_wire_commit,
41 _utc_now,
42 logger,
43 )
44 from musehub.services.musehub_wire_push import repair_corrupt_commit_generations
45
46
47 type _CommitDeltaMap = dict[str, MusehubCommitGraph | MusehubCommit]
48
49
50 async def _walk_commit_delta_dag(
51 session: AsyncSession,
52 starts: list[str],
53 have_set: frozenset[str],
54 ) -> _CommitDeltaMap:
55 """Authoritative DAG walk over MusehubCommit.parent_ids.
56
57 Called when the graph fast-path detects an incomplete BFS (a missing or
58 wrong-generation graph row). Walks the source-of-truth ``musehub_commits``
59 table directly and returns the full ancestor closure of ``starts`` minus
60 ``have_set``, in parents-first topological order. Bounded by ``max_nodes``
61 so a pathological corrupt graph cannot drive an unbounded scan.
62 """
63 from musehub.graph.walk import walk_dag_async
64
65 _row_cache: dict[str, MusehubCommit] = {}
66 _db_calls = 0
67
68 async def _adj(cid: str) -> list[str]:
69 nonlocal _db_calls
70 _db_calls += 1
71 row = await session.get(MusehubCommit, cid)
72 if row is not None:
73 _row_cache[cid] = row
74 return row.parent_ids or [] if row else []
75
76 reachable: list[str] = []
77 async for cid in walk_dag_async(starts, _adj, exclude=have_set, max_nodes=100_000):
78 reachable.append(cid)
79
80 if len(reachable) >= 100_000:
81 logger.warning(
82 "[MWP2] _walk_commit_delta_dag: max_nodes cap hit at %d — "
83 "history may be truncated; starts=%s",
84 100_000, [s[:16] for s in starts[:3]],
85 )
86
87 # Kahn's topo-sort — parents-first so every commit precedes its children.
88 # walk_dag_async BFS order is children-before-parents; the client applies
89 # commits sequentially and skips any whose parent is not yet applied, so
90 # ascending topological order avoids redundant skips.
91 reachable_set = set(reachable)
92 in_degree: dict[str, int] = {cid: 0 for cid in reachable}
93 children_map: dict[str, list[str]] = {cid: [] for cid in reachable}
94 for cid in reachable:
95 row = _row_cache.get(cid)
96 for pid in (row.parent_ids or [] if row else []):
97 if pid in reachable_set:
98 in_degree[cid] += 1
99 children_map[pid].append(cid)
100
101 queue: deque[str] = deque(cid for cid, deg in in_degree.items() if deg == 0)
102 sorted_cids: list[str] = []
103 while queue:
104 node = queue.popleft()
105 sorted_cids.append(node)
106 for child in children_map[node]:
107 in_degree[child] -= 1
108 if in_degree[child] == 0:
109 queue.append(child)
110
111 result: _CommitDeltaMap = {}
112 for cid in sorted_cids:
113 row = _row_cache.get(cid)
114 if row is not None:
115 result[cid] = row
116
117 logger.info(
118 "[_walk_commit_delta_dag] DONE commits=%d db_calls=%d reachable=%d",
119 len(result), _db_calls, len(reachable),
120 )
121 return result
122
123
124 async def _walk_commit_delta(
125 session: AsyncSession,
126 want: list[str] | set[str],
127 have: list[str] | set[str],
128 ) -> _CommitDeltaMap:
129 _wcd_t0 = _time_module.perf_counter()
130 _want_list = list(want)
131 _have_list = list(have)
132 logger.info("[_walk_commit_delta] START want=%d have=%d want_ids=%s",
133 len(_want_list), len(_have_list),
134 [cid[:16] for cid in _want_list[:5]])
135
136 have_set: frozenset[str] = frozenset(_have_list)
137 starts = [cid for cid in _want_list if cid not in have_set]
138 if not starts:
139 logger.info("[_walk_commit_delta] SKIP — all want in have, 0ms")
140 return {}
141
142 from sqlalchemy import func as _func
143
144 want_gen_q = await session.execute(
145 select(_func.max(MusehubCommitGraph.generation))
146 .where(MusehubCommitGraph.commit_id.in_(starts))
147 )
148 _want_gen_raw = want_gen_q.scalar()
149 max_want_gen: int = _want_gen_raw or 0
150
151 _missing_from_graph: list[str] = []
152 _found_in_graph: list[tuple[str, int]] = []
153 for _scid in starts[:10]:
154 _sg = await session.execute(
155 select(MusehubCommitGraph.generation)
156 .where(MusehubCommitGraph.commit_id == _scid)
157 )
158 _sg_val = _sg.scalar_one_or_none()
159 if _sg_val is None:
160 _missing_from_graph.append(_scid[:16])
161 else:
162 _found_in_graph.append((_scid[:16], _sg_val))
163 logger.info(
164 "[_walk_commit_delta] want_gen_raw=%s max_want_gen=%d "
165 "starts_in_graph=%s starts_missing_from_graph=%s",
166 _want_gen_raw, max_want_gen, _found_in_graph, _missing_from_graph,
167 )
168
169 min_have_gen: int = -1
170 if have_set:
171 have_gen_q = await session.execute(
172 select(_func.max(MusehubCommitGraph.generation))
173 .where(MusehubCommitGraph.commit_id.in_(list(have_set)))
174 )
175 min_have_gen = have_gen_q.scalar() or -1
176
177 range_q = await session.execute(
178 select(
179 MusehubCommitGraph.commit_id,
180 MusehubCommitGraph.parent_ids,
181 MusehubCommitGraph.snapshot_id,
182 MusehubCommitGraph.generation,
183 )
184 .where(MusehubCommitGraph.generation > min_have_gen)
185 .where(MusehubCommitGraph.generation <= max_want_gen)
186 )
187 graph_map: dict[str, tuple[list[str], str | None, int]] = {
188 cid: (pids or [], sid, gen) for cid, pids, sid, gen in range_q
189 }
190 logger.info(
191 "[_walk_commit_delta] range_scan gen=(%d,%d] returned %d rows "
192 "starts_in_map=%s",
193 min_have_gen, max_want_gen, len(graph_map),
194 [cid[:16] for cid in starts if cid in graph_map],
195 )
196
197 # [MWP2] Start detection: any want tip absent from graph_map means the range
198 # scan was starved (its generation was None/0) or the row was never written.
199 graph_incomplete: bool = any(s not in graph_map for s in starts)
200
201 visited_mem: set[str] = set(have_set)
202 frontier_mem = [cid for cid in starts if cid not in visited_mem]
203 reachable_cids: set[str] = set()
204 while frontier_mem:
205 next_mem: list[str] = []
206 for cid in frontier_mem:
207 if cid in visited_mem:
208 continue
209 visited_mem.add(cid)
210 reachable_cids.add(cid)
211 # [MWP2] BFS dead-end detection: a commit not in graph_map has no
212 # known parents — the walk cannot follow the true ancestry chain.
213 _entry = graph_map.get(cid)
214 if _entry is None:
215 graph_incomplete = True
216 pids_for_cid: list[str] = []
217 else:
218 pids_for_cid, _, _gen = _entry
219 for p in pids_for_cid:
220 if p not in visited_mem and p not in have_set:
221 next_mem.append(p)
222 frontier_mem = next_mem
223
224 # [MWP2] If any dead-end was detected, the fast-path result is incomplete.
225 # Fall back to the authoritative MusehubCommit DAG walk so the current
226 # response is always correct regardless of graph state.
227 if graph_incomplete:
228 logger.error(
229 "[MWP2] fast-path BFS incomplete — missing_starts=%d want=%d have=%d; "
230 "falling back to authoritative MusehubCommit DAG walk",
231 sum(1 for s in starts if s not in graph_map),
232 len(starts), len(have_set),
233 )
234 return await _walk_commit_delta_dag(session, starts, have_set)
235
236 from types import SimpleNamespace as _SN
237 # Sort by generation ASC so parents appear before children in the dict.
238 # reachable_cids is a set — iteration order is undefined — and the client
239 # applies commits sequentially, skipping any whose parent hasn't been applied
240 # yet. Ascending generation = topological order = every parent precedes its
241 # children in the mpack.
242 sorted_cids = sorted(reachable_cids, key=lambda c: graph_map.get(c, ([], None, 0))[2])
243 needed_graph: dict[str, _SN] = {}
244 for cid in sorted_cids:
245 pids_ns, sid_ns, _ = graph_map.get(cid, ([], None, 0))
246 needed_graph[cid] = _SN(commit_id=cid, snapshot_id=sid_ns, parent_ids=pids_ns)
247
248 _wcd_elapsed = (_time_module.perf_counter() - _wcd_t0) * 1000
249 logger.info(
250 "[_walk_commit_delta] DONE (graph) commits=%d elapsed=%.1fms (%.3fms/commit) "
251 "gen_range=(%d,%d] graph_rows=%d reachable=%d",
252 len(needed_graph), _wcd_elapsed, _wcd_elapsed / max(len(needed_graph), 1),
253 min_have_gen, max_want_gen, len(graph_map), len(reachable_cids),
254 )
255 return needed_graph # type: ignore[return-value]
256
257
258 async def wire_fetch_presign(
259 session: AsyncSession,
260 repo_id: str,
261 req: WireFetchRequest,
262 ttl_seconds: int = 3600,
263 ) -> FetchPresignResult:
264 import asyncio
265 from datetime import timedelta
266
267 _empty: FetchPresignResult = {
268 "presign": False,
269 "blob_urls": {},
270 "commits": [],
271 "snapshots": [],
272 "branch_heads": {},
273 "repo_id": repo_id,
274 "domain": "",
275 "default_branch": "main",
276 "expires_at": None,
277 "commit_count": 0,
278 "blob_count": 0,
279 }
280
281 if not req.want:
282 return _empty
283
284 repo_row = await session.get(MusehubRepo, repo_id)
285 if repo_row is None:
286 return _empty
287 _domain: str = repo_row.domain_id or ""
288 _default_branch: str = repo_row.default_branch if repo_row.default_branch else "main"
289 _empty["domain"] = _domain
290 _empty["default_branch"] = _default_branch
291 _empty["repo_id"] = repo_id
292
293 have_set = set(req.have)
294 needed_rows = await _walk_commit_delta(session, req.want, have_set)
295
296 if not needed_rows:
297 return {**_empty, "domain": _domain, "default_branch": _default_branch}
298
299 _presign_commit_rows: dict[str, MusehubCommit] = {}
300 _presign_any = next(iter(needed_rows.values()))
301 if not isinstance(_presign_any, MusehubCommit):
302 _PRESIGN_WIRE_BATCH = 2000
303 _presign_cids = list(needed_rows.keys())
304 for _pi in range(0, len(_presign_cids), _PRESIGN_WIRE_BATCH):
305 _pq = await session.execute(
306 select(MusehubCommit).where(MusehubCommit.commit_id.in_(_presign_cids[_pi : _pi + _PRESIGN_WIRE_BATCH]))
307 )
308 for _pr in _pq.scalars():
309 _presign_commit_rows[_pr.commit_id] = _pr
310 else:
311 _presign_commit_rows = needed_rows # type: ignore[assignment]
312
313 snap_ids = [r.snapshot_id for r in needed_rows.values() if r.snapshot_id]
314 all_oids: set[str] = set()
315 if snap_ids:
316 snaps_q = await session.execute(
317 select(MusehubSnapshot).where(MusehubSnapshot.snapshot_id.in_(snap_ids))
318 )
319 for snap in snaps_q.scalars().all():
320 manifest = (
321 _msgpack.unpackb(snap.manifest_blob, raw=False)
322 if snap.manifest_blob
323 else await _reconstruct_manifest(session, snap.snapshot_id)
324 )
325 all_oids.update(v for v in manifest.values() if v)
326
327 have_snap_ids: list[str] = []
328 if have_set:
329 have_commits_q = await session.execute(
330 select(MusehubCommit).where(MusehubCommit.commit_id.in_(have_set))
331 )
332 have_snap_ids = [r.snapshot_id for r in have_commits_q.scalars().all() if r.snapshot_id]
333 have_oids: set[str] = set()
334 if have_snap_ids:
335 have_snaps_q = await session.execute(
336 select(MusehubSnapshot).where(MusehubSnapshot.snapshot_id.in_(have_snap_ids))
337 )
338 for snap in have_snaps_q.scalars().all():
339 manifest = (
340 _msgpack.unpackb(snap.manifest_blob, raw=False)
341 if snap.manifest_blob
342 else await _reconstruct_manifest(session, snap.snapshot_id)
343 )
344 have_oids.update(v for v in manifest.values() if v)
345
346 new_oids = all_oids - have_oids
347 n_objects = len(new_oids)
348 n_commits = len(needed_rows)
349
350 total_size = 0
351 if new_oids:
352 size_q = await session.execute(
353 select(func.coalesce(func.sum(MusehubObject.size_bytes), 0)).where(
354 MusehubObject.object_id.in_(list(new_oids))
355 )
356 )
357 total_size = int(size_q.scalar() or 0)
358
359 backend = get_backend()
360
361 wire_commits = [
362 (await _commit_to_wire_s3(row, backend)).model_dump()
363 for row in _presign_commit_rows.values()
364 ]
365
366 snap_rows_q = await session.execute(
367 select(MusehubSnapshot).where(MusehubSnapshot.snapshot_id.in_(snap_ids))
368 )
369 wire_snaps = [
370 await _snap_row_to_wire_s3(snap, backend, session=session)
371 for snap in snap_rows_q.scalars().all()
372 ]
373
374 branch_rows_q = await session.execute(
375 select(MusehubBranch).where(MusehubBranch.repo_id == repo_id)
376 )
377 branch_heads = {
378 b.name: b.head_commit_id
379 for b in branch_rows_q.scalars().all()
380 if b.head_commit_id
381 }
382
383 sem = asyncio.Semaphore(50)
384
385 logger.info(
386 "fetch/presign: generating %d presigned GET URLs repo=%s/%s",
387 len(new_oids), repo_row.owner, repo_row.slug,
388 )
389
390 async def _presign_one(oid: str) -> tuple[str, str]:
391 async with sem:
392 url = await backend.presign_get(oid, ttl_seconds)
393 logger.debug("fetch/presign: presigned oid=%s", oid)
394 return oid, url
395
396 pairs = await asyncio.gather(*(_presign_one(oid) for oid in new_oids))
397 blob_urls = {oid: url for oid, url in pairs}
398 expires_at = (_utc_now() + timedelta(seconds=ttl_seconds)).isoformat()
399
400 return {
401 "presign": True,
402 "blob_urls": blob_urls,
403 "commits": wire_commits,
404 "snapshots": wire_snaps,
405 "branch_heads": branch_heads,
406 "repo_id": repo_id,
407 "domain": _domain,
408 "default_branch": _default_branch,
409 "expires_at": expires_at,
410 "commit_count": n_commits,
411 "blob_count": n_objects,
412 }
413
414 async def wire_fetch_mpack(
415 session: AsyncSession,
416 repo_id: str,
417 want: list[str],
418 have: list[str],
419 ttl_seconds: int = 3600,
420 force_build: bool = False,
421 ) -> FetchMPackResult:
422 import msgpack as _msgpack_local
423
424 _t0 = _time_module.perf_counter()
425 def _ms() -> float:
426 return (_time_module.perf_counter() - _t0) * 1000
427
428 logger.info("[wire_fetch_mpack] START repo_id=%s want=%d have=%d want_ids=%s",
429 repo_id, len(want), len(have), [cid[:16] for cid in want[:5]])
430
431 _up_to_date: FetchMPackResult = {
432 "mpack_url": None,
433 "mpack_id": None,
434 "commit_count": 0,
435 "blob_count": 0,
436 }
437
438 if not want:
439 logger.info("[wire_fetch_mpack] SKIP — want is empty")
440 return _up_to_date
441
442 backend = get_backend()
443
444 # FMC_09 / FMC_10 / FMC_12 — cache lookup for fresh clones (have=[]).
445 # For multi-tip requests, all tips must map to the same mpack_id (built by
446 # the prebuild as one combined mpack).
447 if not have:
448 _cache_t0 = _time_module.perf_counter()
449 _cached_rows = (await session.execute(
450 select(MusehubFetchMPackCache)
451 .where(MusehubFetchMPackCache.repo_id == repo_id)
452 .where(MusehubFetchMPackCache.tip_commit_id.in_(want))
453 .where(MusehubFetchMPackCache.expires_at > _utc_now())
454 )).scalars().all()
455 _cache_ms = (_time_module.perf_counter() - _cache_t0) * 1000
456 _cached_tips = {r.tip_commit_id: r.mpack_id for r in _cached_rows}
457 _mpack_ids = set(_cached_tips.values())
458 if len(_cached_tips) == len(want) and len(_mpack_ids) == 1:
459 _hit_mpack_id = next(iter(_mpack_ids))
460 _cached_url = await backend.presign_mpack_get(_hit_mpack_id, ttl_seconds)
461 logger.warning(
462 "[wire_fetch_mpack] cache=HIT tips=%d mpack_id=%s t=%.1fms",
463 len(want), _hit_mpack_id[:20], _cache_ms,
464 )
465 return {
466 "mpack_url": _cached_url,
467 "mpack_id": _hit_mpack_id,
468 "commit_count": 0,
469 "blob_count": 0,
470 }
471 if not force_build:
472 logger.warning(
473 "[wire_fetch_mpack] cache=MISS tips=%d cached_tips=%d t=%.1fms — raising MPackNotReadyError",
474 len(want), len(_cached_tips), _cache_ms,
475 )
476 raise MPackNotReadyError()
477 logger.warning(
478 "[wire_fetch_mpack] cache=MISS tips=%d cached_tips=%d t=%.1fms — force_build=True, continuing",
479 len(want), len(_cached_tips), _cache_ms,
480 )
481
482 have_set = set(have)
483 logger.info("[wire_fetch_mpack] step=1 DAG walk starting t=%.1fms", _ms())
484 needed_rows = await _walk_commit_delta(session, want, have_set)
485 logger.info("[wire_fetch_mpack] step=1 DAG walk done commits=%d t=%.1fms", len(needed_rows), _ms())
486
487 if not needed_rows:
488 logger.info("[wire_fetch_mpack] SKIP — client already up-to-date (needed_rows empty)")
489 return _up_to_date
490
491 commit_rows: dict[str, MusehubCommit] = {}
492 _any = next(iter(needed_rows.values()))
493 _is_proxy = not isinstance(_any, MusehubCommit)
494 logger.info("[wire_fetch_mpack] step=1b needed_rows=%d is_proxy=%s t=%.1fms",
495 len(needed_rows), _is_proxy, _ms())
496 if _is_proxy:
497 _cids = list(needed_rows.keys())
498 _q = await session.execute(
499 select(MusehubCommit).where(
500 _sa_text("commit_id = ANY(:ids)").bindparams(ids=_cids)
501 )
502 )
503 for _row in _q.scalars():
504 commit_rows[_row.commit_id] = _row
505 _missing_from_db = set(_cids) - set(commit_rows.keys())
506 logger.info(
507 "[wire_fetch_mpack] step=1b bulk fetch done commits_in_db=%d missing_from_db=%d "
508 "missing_ids=%s t=%.1fms",
509 len(commit_rows), len(_missing_from_db),
510 [cid[:16] for cid in list(_missing_from_db)[:5]], _ms(),
511 )
512 else:
513 commit_rows = needed_rows # type: ignore[assignment]
514 logger.info("[wire_fetch_mpack] step=1b using MusehubCommit rows directly commits=%d t=%.1fms",
515 len(commit_rows), _ms())
516
517 _proxy_snap_ids_raw = [r.snapshot_id for r in needed_rows.values()]
518 _graph_snap_ids = [sid for sid in _proxy_snap_ids_raw if sid]
519 _proxy_snap_none_count = sum(1 for s in _proxy_snap_ids_raw if not s)
520 _commit_row_snap_ids = [r.snapshot_id for r in commit_rows.values() if r.snapshot_id]
521 # Defensive fallback: CommitGraph rows from server-side merges may have snapshot_id=None
522 # (pre-fix state). The MusehubCommit row always has the correct snapshot_id — merge both.
523 snap_ids = list({*_graph_snap_ids, *_commit_row_snap_ids})
524 logger.info(
525 "[wire_fetch_mpack] step=2 snap_ids_from_graph=%d snap_ids_none_in_graph=%d "
526 "snap_ids_from_commit_rows=%d snap_ids_total=%d t=%.1fms",
527 len(_graph_snap_ids), _proxy_snap_none_count, len(_commit_row_snap_ids), len(snap_ids), _ms(),
528 )
529
530 snap_map: dict[str, dict] = {}
531 if snap_ids:
532 snaps_q = await session.execute(
533 select(MusehubSnapshot).where(
534 _sa_text("snapshot_id = ANY(:ids)").bindparams(ids=snap_ids)
535 )
536 )
537 for snap in snaps_q.scalars().all():
538 snap_map[snap.snapshot_id] = await _snap_row_to_wire_s3(snap, backend, session=session)
539 logger.info("[wire_fetch_mpack] step=2 snap_map loaded=%d t=%.1fms", len(snap_map), _ms())
540
541 all_oids: set[str] = set()
542 _needed_cids = list(needed_rows.keys())
543 logger.warning("[GRAPH-DEBUG] wire_fetch_mpack: needed_rows=%d needed_cids_sample=%s",
544 len(_needed_cids), [c[:16] for c in _needed_cids[:3]])
545 _debug_graph_q = await session.execute(
546 select(MusehubCommitGraph.commit_id, MusehubCommitGraph.generation, MusehubCommitGraph.snapshot_id)
547 .where(MusehubCommitGraph.commit_id.in_(_needed_cids))
548 .order_by(MusehubCommitGraph.generation.desc())
549 .limit(5)
550 )
551 _debug_graph_rows = _debug_graph_q.all()
552 logger.warning("[GRAPH-DEBUG] wire_fetch_mpack: CommitGraph has %d rows for needed_cids (top 5 by gen): %s",
553 len(_debug_graph_rows),
554 [(r[1], r[0][:16]) for r in _debug_graph_rows])
555 want_tip_snap_q = await session.execute(
556 select(MusehubCommitGraph.snapshot_id)
557 .where(MusehubCommitGraph.commit_id.in_(_needed_cids))
558 .order_by(MusehubCommitGraph.generation.desc())
559 .limit(1)
560 )
561 want_tip_snap_id = want_tip_snap_q.scalar_one_or_none()
562 logger.warning("[GRAPH-DEBUG] wire_fetch_mpack: want_tip_snap_id=%s",
563 want_tip_snap_id[:20] if want_tip_snap_id else "NONE")
564 logger.info("[wire_fetch_mpack] step=2 want_tip_snap_id=%s (from CommitGraph) t=%.1fms",
565 want_tip_snap_id[:16] if want_tip_snap_id else None, _ms())
566 # [BLOB-DEBUG] Compare want_tip_snap_id (from CommitGraph) vs actual want commits'
567 # snapshot IDs (from commit_rows). A mismatch means CommitGraph is stale for the
568 # newest push — the old snapshot's manifest_blob would be missing new blobs.
569 _want_snap_ids_from_commit_rows = {
570 commit_rows[cid].snapshot_id
571 for cid in want
572 if cid in commit_rows and commit_rows[cid].snapshot_id
573 }
574 logger.warning(
575 "[BLOB-DEBUG] want_tip_snap_id (CommitGraph max-gen)=%s "
576 "want_snap_ids_from_commit_rows=%s MATCH=%s",
577 want_tip_snap_id[:20] if want_tip_snap_id else "NONE",
578 [s[:20] for s in _want_snap_ids_from_commit_rows],
579 want_tip_snap_id in _want_snap_ids_from_commit_rows if want_tip_snap_id else False,
580 )
581
582 # [MWP1_13] Fetch-side guard: if the CommitGraph's max-gen snapshot disagrees
583 # with the authoritative MusehubCommit snapshot, the graph has a corrupt
584 # generation (RC-1 artefact). Repair in-place before assembling the mpack so
585 # a stale clone is never shipped. Full DAG-walk fallback is MWP-2.
586 if (
587 want_tip_snap_id is not None
588 and _want_snap_ids_from_commit_rows
589 and want_tip_snap_id not in _want_snap_ids_from_commit_rows
590 ):
591 logger.error(
592 "[MWP1] fetch-side guard: want_tip_snap_id=%s not in "
593 "commit_rows snapshot_ids=%s — repairing corrupt commit generations",
594 want_tip_snap_id[:20],
595 [s[:20] for s in _want_snap_ids_from_commit_rows],
596 )
597 _repair_result = await repair_corrupt_commit_generations(session)
598 logger.error("[MWP1] fetch-side repair complete: %s", _repair_result)
599 # Re-query to pick up the corrected tip snapshot after repair.
600 _requery = await session.execute(
601 select(MusehubCommitGraph.snapshot_id)
602 .where(MusehubCommitGraph.commit_id.in_(_needed_cids))
603 .order_by(MusehubCommitGraph.generation.desc())
604 .limit(1)
605 )
606 want_tip_snap_id = _requery.scalar_one_or_none()
607 logger.error(
608 "[MWP1] fetch-side guard: want_tip_snap_id after repair=%s",
609 want_tip_snap_id[:20] if want_tip_snap_id else "NONE",
610 )
611
612 if want_tip_snap_id:
613 wt_blob_q = await session.execute(
614 select(MusehubSnapshot.manifest_blob, MusehubSnapshot.entry_count)
615 .where(MusehubSnapshot.snapshot_id == want_tip_snap_id)
616 )
617 wt_row = wt_blob_q.one_or_none()
618 wt_blob = wt_row[0] if wt_row else None
619 wt_entry_count_db = wt_row[1] if wt_row else None
620 if wt_blob:
621 wt_manifest = _msgpack_local.unpackb(wt_blob, raw=False)
622 wt_entry_count_blob = len(wt_manifest)
623 else:
624 wt_entry_count_blob = 0
625 # Use the snap_map manifest (validated/reconstructed via _snap_row_to_wire_s3) instead
626 # of raw manifest_blob to populate all_oids. manifest_blob may have null OIDs for files
627 # pushed after the snapshot was indexed, causing those blobs to be silently omitted from
628 # the mpack. snap_map['manifest'] is always complete — rebuilt from delta chain if needed.
629 _tip_snap_entry = snap_map.get(want_tip_snap_id)
630 if _tip_snap_entry:
631 all_oids.update(v for v in (_tip_snap_entry.get("manifest") or {}).values() if v)
632 # [BLOB-DEBUG] Log discrepancy between manifest_blob and snap_map to confirm the fix.
633 # entry_count_blob vs snap_map_manifest_size should now match; all_oids should equal
634 # snap_map_manifest_size (minus any genuinely null entries).
635 logger.warning(
636 "[BLOB-DEBUG] want_tip_snap=%s manifest_blob=%s "
637 "entry_count_db=%s entry_count_blob=%d all_oids=%d "
638 "snap_map_has_tip=%s snap_map_manifest_size=%d",
639 want_tip_snap_id[:20] if want_tip_snap_id else "NONE",
640 "present" if wt_blob else "ABSENT",
641 wt_entry_count_db,
642 wt_entry_count_blob,
643 len(all_oids),
644 want_tip_snap_id in snap_map,
645 len((snap_map.get(want_tip_snap_id) or {}).get("manifest") or {}),
646 )
647 logger.info("[wire_fetch_mpack] step=2 want_tip manifest all_oids=%d wt_blob_present=%s t=%.1fms",
648 len(all_oids), wt_blob is not None, _ms())
649 else:
650 logger.warning(
651 "[wire_fetch_mpack] step=2 WARN want_tip_snap_id=None — CommitGraph missing tip "
652 "needed_cids=%s commit_rows_snap_ids=%s",
653 [cid[:16] for cid in list(needed_rows.keys())[:5]],
654 [sid[:16] for sid in _commit_row_snap_ids[:5]],
655 )
656
657 # musehub#113 fix: the block above only ever consults ONE snapshot —
658 # whichever commit has the single globally-highest generation across ALL
659 # walked commits (`_needed_cids`, which includes ancestors, not just the
660 # requested tips). That is correct only when `want` contains a single
661 # tip. A `want` set spanning multiple branch tips — which every real
662 # clone/fetch request does, since the client always requests every known
663 # branch tip regardless of `--branch` — needs the UNION of every
664 # requested tip's own manifest, not just one. A tie at the same
665 # generation (two sibling branches cut from the same base) picks one
666 # arbitrarily; a genuine depth difference picks the deeper one
667 # deterministically — either way every other tip's unique blobs were
668 # silently absent from the response. Resolve each want-tip's own
669 # snapshot from its authoritative `MusehubCommit` row (`commit_rows`,
670 # never from the generation-ranked pick above) so ties and depth
671 # differences can no longer drop content.
672 _pre_union_oids = len(all_oids)
673 for _want_cid in want:
674 _want_commit_row = commit_rows.get(_want_cid)
675 if _want_commit_row is None or not _want_commit_row.snapshot_id:
676 continue
677 _want_snap_entry = snap_map.get(_want_commit_row.snapshot_id)
678 if _want_snap_entry:
679 all_oids.update(v for v in (_want_snap_entry.get("manifest") or {}).values() if v)
680 logger.info(
681 "[wire_fetch_mpack] step=2b multi-tip manifest union: want=%d all_oids %d -> %d t=%.1fms",
682 len(want), _pre_union_oids, len(all_oids), _ms(),
683 )
684
685 # musehub#113 fix (mirror of the all_oids union above): the old
686 # implementation picked a single have-tip's manifest via the same
687 # generation-ranked query used for all_oids, and read it from the raw
688 # `manifest_blob` column rather than the safe snap_map reconstruction
689 # (`_snap_row_to_wire_s3` — the same class of gap the BLOB-DEBUG comment
690 # above already documented for all_oids: "manifest_blob may have null
691 # OIDs for files pushed after the snapshot was indexed"). A `have` set
692 # spanning multiple tips (any client tracking more than one branch) had
693 # everything but the single winning tip's content silently excluded
694 # from `have_oids`. Unlike the all_oids bug this cannot drop content —
695 # `new_oids = all_oids - have_oids` only grows when have_oids is
696 # undercounted — but it does mean the server redundantly re-sends
697 # objects the client explicitly reported already having, for every
698 # push/branch beyond whichever one wins the generation pick.
699 have_oids: set[str] = set()
700 if have_set:
701 have_commit_rows_q = await session.execute(
702 select(MusehubCommit).where(MusehubCommit.commit_id.in_(list(have_set)))
703 )
704 have_commit_rows = {r.commit_id: r for r in have_commit_rows_q.scalars().all()}
705 have_snap_ids = {r.snapshot_id for r in have_commit_rows.values() if r.snapshot_id}
706 if have_snap_ids:
707 have_snaps_q = await session.execute(
708 select(MusehubSnapshot).where(MusehubSnapshot.snapshot_id.in_(list(have_snap_ids)))
709 )
710 for _have_snap in have_snaps_q.scalars().all():
711 _have_snap_entry = await _snap_row_to_wire_s3(_have_snap, backend, session=session)
712 have_oids.update(v for v in (_have_snap_entry.get("manifest") or {}).values() if v)
713
714 new_oids = all_oids - have_oids
715 logger.info(
716 "[wire_fetch_mpack] step=2 done snap_map=%d all_oids=%d have_oids=%d new_oids=%d t=%.1fms",
717 len(snap_map), len(all_oids), len(have_oids), len(new_oids), _ms(),
718 )
719
720 if new_oids:
721 indexed_q = await session.execute(
722 select(MusehubMPackIndex.entity_id)
723 .where(MusehubMPackIndex.entity_id.in_(list(new_oids)))
724 .where(MusehubMPackIndex.entity_type == "object")
725 )
726 indexed_oids = {row[0] for row in indexed_q}
727 missing = new_oids - indexed_oids
728 if missing:
729 logger.warning(
730 "[wire_fetch_mpack] step=3 NOT INDEXED %d/%d objects — raising FetchNotIndexedError t=%.1fms",
731 len(missing), len(new_oids), _ms(),
732 )
733 raise FetchNotIndexedError(len(missing))
734 logger.info("[wire_fetch_mpack] step=3 index coverage OK oids=%d t=%.1fms", len(new_oids), _ms())
735
736 cache_hits: dict[str, bytes] = {}
737 if new_oids:
738 _CACHE_CHUNK = 10000
739 _new_oid_list = list(new_oids)
740 for _ci in range(0, len(_new_oid_list), _CACHE_CHUNK):
741 _chunk = _new_oid_list[_ci : _ci + _CACHE_CHUNK]
742 _cache_q = await session.execute(
743 select(MusehubObject.object_id, MusehubObject.content_cache)
744 .where(MusehubObject.object_id.in_(_chunk))
745 .where(MusehubObject.content_cache.isnot(None))
746 )
747 for _oid, _cached in _cache_q:
748 if _cached:
749 cache_hits[_oid] = bytes(_cached)
750
751 cache_miss_oids = [oid for oid in new_oids if oid not in cache_hits]
752
753 oid_to_mpack: dict[str, str] = {}
754 if cache_miss_oids:
755 _MIDX_CHUNK = 10000
756 for _ci in range(0, len(cache_miss_oids), _MIDX_CHUNK):
757 _chunk = cache_miss_oids[_ci : _ci + _MIDX_CHUNK]
758 _midx_q = await session.execute(
759 select(MusehubMPackIndex.entity_id, MusehubMPackIndex.mpack_id)
760 .where(MusehubMPackIndex.entity_id.in_(_chunk))
761 .where(MusehubMPackIndex.entity_type == "object")
762 )
763 for _oid, _mid in _midx_q:
764 oid_to_mpack[_oid] = _mid
765
766 mpack_to_oids: dict[str, list[str]] = {}
767 no_mpack_oids: list[str] = []
768 for oid in cache_miss_oids:
769 mid = oid_to_mpack.get(oid)
770 if mid:
771 mpack_to_oids.setdefault(mid, []).append(oid)
772 else:
773 no_mpack_oids.append(oid)
774
775 mpack_hits: dict[str, bytes] = {}
776 mpack_miss_oids: list[str] = []
777 _sem_mpack = asyncio.Semaphore(8)
778
779 async def _extract_from_mpack(mpack_id: str, oids: list[str]) -> None:
780 async with _sem_mpack:
781 raw = await backend.get_mpack(mpack_id)
782 if raw is None:
783 mpack_miss_oids.extend(oids)
784 return
785 import zstandard as _zstd_phase1
786 _dctx_phase1 = _zstd_phase1.ZstdDecompressor()
787 try:
788 if raw[:4] == b"MUSE":
789 from muse.core.mpack import parse_wire_mpack as _parse_wire_fetch
790 payload = _parse_wire_fetch(raw)
791 else:
792 payload = _msgpack_local.unpackb(raw, raw=False)
793 except Exception as _parse_err:
794 logger.warning(
795 "[_extract_from_mpack] failed to parse mpack=%s: %s",
796 mpack_id[:20], _parse_err,
797 )
798 mpack_miss_oids.extend(oids)
799 return
800 obj_index: dict[str, bytes] = {}
801 for o in payload.get("blobs", []):
802 oid_entry = o.get("object_id", "")
803 content = o.get("content") or b""
804 if not isinstance(content, bytes):
805 content = bytes(content)
806 _ZSTD_MAGIC = b"\x28\xb5\x2f\xfd"
807 if (o.get("encoding") == "zstd" or content[:4] == _ZSTD_MAGIC) and content:
808 try:
809 content = _dctx_phase1.decompress(content)
810 except Exception as _decomp_err:
811 logger.warning(
812 "[_extract_from_mpack] zstd decompress failed oid=%s: %s",
813 oid_entry[:20], _decomp_err,
814 )
815 continue
816 obj_index[oid_entry] = content
817 for oid in oids:
818 content = obj_index.get(oid)
819 if content is not None:
820 mpack_hits[oid] = content
821 else:
822 mpack_miss_oids.append(oid)
823
824 if mpack_to_oids:
825 await asyncio.gather(
826 *(_extract_from_mpack(mid, oids) for mid, oids in mpack_to_oids.items())
827 )
828
829 legacy_hits: dict[str, bytes] = {}
830 _fallback_oids = no_mpack_oids + mpack_miss_oids
831 if _fallback_oids:
832 _sem_legacy = asyncio.Semaphore(50)
833
834 async def _get_legacy(oid: str) -> None:
835 async with _sem_legacy:
836 data = await backend.get(oid)
837 if data:
838 legacy_hits[oid] = data
839
840 await asyncio.gather(*(_get_legacy(oid) for oid in _fallback_oids))
841
842 _all_blob_bytes: dict[str, bytes] = {**legacy_hits, **mpack_hits, **cache_hits}
843 blob_pairs = [(oid, _all_blob_bytes[oid]) for oid in new_oids if oid in _all_blob_bytes]
844 logger.info(
845 "[wire_fetch_mpack] step=4 fetched %d blobs (cache=%d mpack=%d legacy=%d) t=%.1fms",
846 len(blob_pairs), len(cache_hits), len(mpack_hits), len(legacy_hits), _ms(),
847 )
848
849 wire_commits = [
850 (await _commit_to_wire_s3(commit_rows[cid], backend)).model_dump()
851 for cid in needed_rows.keys()
852 if cid in commit_rows
853 ]
854 wire_snaps = [snap_map[sid] for sid in snap_ids if sid in snap_map]
855 wire_blobs = [
856 {"object_id": oid, "content": data}
857 for oid, data in blob_pairs
858 if data
859 ]
860 logger.info(
861 "[wire_fetch_mpack] step=5 assembly: wire_commits=%d wire_snaps=%d wire_blobs=%d "
862 "snap_ids_total=%d snap_ids_in_map=%d commit_rows=%d t=%.1fms",
863 len(wire_commits), len(wire_snaps), len(wire_blobs),
864 len(snap_ids), sum(1 for sid in snap_ids if sid in snap_map),
865 len(commit_rows), _ms(),
866 )
867
868 from muse.core.mpack import build_wire_mpack as _build_wire_mpack
869 _head_commit_id = want[0] if want else ""
870 mpack_bytes = _build_wire_mpack(
871 {
872 "commits": wire_commits,
873 "snapshots": wire_snaps,
874 "blobs": wire_blobs,
875 "tags": [],
876 },
877 meta={"repo_id": repo_id, "head_commit_id": _head_commit_id},
878 )
879 mpack_id = blob_id(mpack_bytes)
880
881 n_commits = len(wire_commits)
882 n_blobs = len(wire_blobs)
883 logger.info(
884 "[wire_fetch_mpack] step=5 assembled commits=%d snapshots=%d blobs=%d bytes=%d t=%.1fms",
885 n_commits, len(wire_snaps), n_blobs, len(mpack_bytes), _ms(),
886 )
887
888 await backend.put_mpack(mpack_id, mpack_bytes)
889 mpack_url = await backend.presign_mpack_get(mpack_id, ttl_seconds)
890 logger.info(
891 "[wire_fetch_mpack] step=6 mpack_id=%s mpack_url=%s t=%.1fms",
892 mpack_id[:20], mpack_url[:80] if mpack_url else None, _ms(),
893 )
894 logger.info("[wire_fetch_mpack] RETURN commits=%d blobs=%d TOTAL=%.1fms", n_commits, n_blobs, _ms())
895
896 # FMC_11 — on a fresh-clone miss, write a cache row per tip so the next clone is a hit.
897 # For multi-tip requests all tips get the same mpack_id so the multi-tip cache
898 # check (len(cached_tips)==len(want) and single mpack_id) will fire on the next request.
899 if not have:
900 from datetime import timedelta as _timedelta
901 _miss_expires = _utc_now() + _timedelta(days=7)
902 for _tip in want:
903 _miss_cache_id = blob_id((repo_id + _tip).encode()).replace("sha256:", "")
904 await session.execute(
905 _pg_insert(MusehubFetchMPackCache)
906 .values(
907 cache_id=_miss_cache_id,
908 repo_id=repo_id,
909 tip_commit_id=_tip,
910 mpack_id=mpack_id,
911 created_at=_utc_now(),
912 expires_at=_miss_expires,
913 )
914 .on_conflict_do_update(
915 index_elements=["repo_id", "tip_commit_id"],
916 set_={"mpack_id": mpack_id, "expires_at": _miss_expires},
917 )
918 )
919
920 return {
921 "mpack_url": mpack_url,
922 "mpack_id": mpack_id,
923 "commit_count": n_commits,
924 "blob_count": n_blobs,
925 }
926
927 async def _check_missing_objects(
928 session: AsyncSession,
929 needs_check: set[str],
930 ) -> set[str]:
931 if not needs_check:
932 return set()
933 from musehub.db.musehub_repo_models import MusehubObject
934 registered: set[str] = set(
935 (await session.execute(
936 select(MusehubObject.object_id).where(
937 MusehubObject.object_id.in_(list(needs_check)),
938 MusehubObject.deleted_at.is_(None),
939 )
940 )).scalars().all()
941 )
942 return needs_check - registered
943
944
945 class MPackGCResult(TypedDict):
946 skipped: bool
947 packs_before: int
948 packs_after: int
949 consolidated_key: str
950
951
952 async def process_mpack_gc_job(session: AsyncSession, repo_id: str) -> MPackGCResult:
953 import msgpack as _mp
954
955 _skipped: MPackGCResult = {
956 "skipped": True,
957 "packs_before": 0,
958 "packs_after": 0,
959 "consolidated_key": "",
960 }
961
962 repo_oids_q = await session.execute(
963 select(MusehubObjectRef.object_id)
964 .where(MusehubObjectRef.repo_id == repo_id)
965 )
966 repo_oid_set = {row[0] for row in repo_oids_q}
967 mpack_q = await session.execute(
968 select(MusehubMPackIndex.mpack_id)
969 .where(MusehubMPackIndex.entity_id.in_(list(repo_oid_set)))
970 .where(MusehubMPackIndex.entity_type == "object")
971 .distinct()
972 )
973 mpack_ids = [row[0] for row in mpack_q]
974 packs_before = len(mpack_ids)
975
976 if packs_before <= 1:
977 _skipped["packs_before"] = packs_before
978 if mpack_ids:
979 _skipped["consolidated_key"] = mpack_ids[0]
980 return _skipped
981
982 import musehub.storage.backends as _backends_mod
983 backend = _backends_mod.get_backend()
984
985 merged_objects: dict[str, bytes] = {}
986
987 async def _download(pid: str) -> None:
988 raw = await backend.get_mpack(pid)
989 if not raw:
990 logger.warning("[mpack_gc] mpack not found in storage: %s", pid)
991 return
992 if raw[:4] == b"MUSE":
993 from muse.core.mpack import parse_wire_mpack as _parse_gc
994 _parsed = _parse_gc(raw)
995 else:
996 _parsed = _mp.unpackb(raw, raw=False)
997 for obj in _parsed.get("blobs", []):
998 oid = obj.get("object_id", "")
999 content = obj.get("content", b"")
1000 if oid and oid not in merged_objects:
1001 merged_objects[oid] = content
1002
1003 await asyncio.gather(*(_download(pid) for pid in mpack_ids))
1004
1005 from muse.core.mpack import build_wire_mpack as _build_gc_mpack
1006 consolidated_bytes = _build_gc_mpack({
1007 "commits": [],
1008 "snapshots": [],
1009 "blobs": [
1010 {"object_id": oid, "content": merged_objects[oid]}
1011 for oid in sorted(merged_objects)
1012 ],
1013 "tags": [],
1014 })
1015 consolidated_key = "sha256:" + hashlib.sha256(consolidated_bytes).hexdigest()
1016
1017 await backend.put_mpack(consolidated_key, consolidated_bytes)
1018
1019 old_mpack_ids = [p for p in mpack_ids if p != consolidated_key]
1020 if old_mpack_ids:
1021 from sqlalchemy import delete as sa_delete
1022 await session.execute(
1023 sa_delete(MusehubMPackIndex)
1024 .where(MusehubMPackIndex.mpack_id.in_(old_mpack_ids))
1025 .where(MusehubMPackIndex.entity_type == "object")
1026 )
1027 _gc_now = datetime.now(timezone.utc)
1028 new_rows = [
1029 {
1030 "entity_id": oid,
1031 "mpack_id": consolidated_key,
1032 "entity_type": "object",
1033 "created_at": _gc_now,
1034 }
1035 for oid in merged_objects
1036 ]
1037 if new_rows:
1038 _GC_MIDX_CHUNK = 5000
1039 for _gmi in range(0, len(new_rows), _GC_MIDX_CHUNK):
1040 await session.execute(
1041 _pg_insert(MusehubMPackIndex)
1042 .values(new_rows[_gmi : _gmi + _GC_MIDX_CHUNK])
1043 .on_conflict_do_nothing(index_elements=["entity_id", "mpack_id"])
1044 )
1045
1046 logger.info(
1047 "[mpack_gc] repo=%s consolidated %d mpacks → 1 (objects=%d key=%s)",
1048 repo_id, packs_before, len(merged_objects), consolidated_key,
1049 )
1050
1051 return {
1052 "skipped": False,
1053 "packs_before": packs_before,
1054 "packs_after": 1,
1055 "consolidated_key": consolidated_key,
1056 }
1057
1058
1059 class FetchResult(TypedDict):
1060 mpack_id: str
1061 mpack_url: str | None
1062 commit_count: int
1063 blob_count: int
1064
1065
1066 class FetchMPackPrebuildResult(TypedDict):
1067 tips_requested: int
1068 tips_built: int
1069 tips_skipped: int
1070 elapsed_ms: float
1071
1072
1073 async def process_fetch_mpack_prebuild_job(
1074 session: AsyncSession,
1075 job_id: str,
1076 ) -> FetchMPackPrebuildResult:
1077 """Build and cache a fetch mpack for every current branch tip.
1078
1079 Called by the background worker after every push. Reads branch tips from
1080 ``MusehubBranch`` at run time (authoritative — not from the payload) so the
1081 job is self-coalescing: any number of pushes deduped into one pending job are
1082 all covered because we read live state here. ``payload["tip_commit_ids"]``
1083 is retained only for observability and is never used as the source of truth.
1084
1085 For each live tip, checks whether a fresh cache entry already exists in
1086 ``musehub_fetch_mpack_cache``; skips tips that are cached and builds the
1087 rest in one combined call to ``wire_fetch_mpack``. The returned mpack_id is
1088 written (or upserted) for every live tip so the multi-tip clone cache-hit
1089 check (``len(mpack_ids)==1``) fires on the next clone.
1090 """
1091 from datetime import timedelta
1092
1093 from musehub.db.musehub_jobs_models import MusehubBackgroundJob
1094 from sqlalchemy.dialects.postgresql import insert as _upsert
1095
1096 _t0 = _time_module.monotonic()
1097
1098 def _ms() -> float:
1099 return (_time_module.monotonic() - _t0) * 1000
1100
1101 job_row = (await session.execute(
1102 select(MusehubBackgroundJob).where(MusehubBackgroundJob.job_id == job_id)
1103 )).scalar_one_or_none()
1104 if job_row is None:
1105 raise ValueError(f"fetch.mpack.prebuild job not found: {job_id}")
1106
1107 repo_id: str = job_row.repo_id
1108 payload = job_row.payload or {}
1109
1110 # Re-read branch tips from the DB at run time — authoritative, not the
1111 # captured payload. This makes the job self-coalescing: any number of
1112 # pushes deduped into one pending job are all covered because we read
1113 # live state here rather than whatever was current at enqueue time.
1114 branch_tips_q = await session.execute(
1115 select(MusehubBranch.head_commit_id)
1116 .where(MusehubBranch.repo_id == repo_id)
1117 .where(MusehubBranch.head_commit_id.isnot(None))
1118 )
1119 tip_commit_ids: list[str] = [str(row[0]) for row in branch_tips_q]
1120
1121 # payload["tip_commit_ids"] is retained for observability only — log if stale.
1122 _payload_tips = [str(t) for t in (payload.get("tip_commit_ids") or [])]
1123 if set(_payload_tips) != set(tip_commit_ids):
1124 logger.info(
1125 "[fetch.mpack.prebuild] job=%s payload tips=%d differ from live tips=%d "
1126 "(coalesced pushes) — using live tips",
1127 job_id[:16], len(_payload_tips), len(tip_commit_ids),
1128 )
1129
1130 if not tip_commit_ids:
1131 logger.warning("[fetch.mpack.prebuild] job=%s repo=%s no branch tips in DB", job_id[:16], repo_id)
1132 return {"tips_requested": 0, "tips_built": 0, "tips_skipped": 0, "elapsed_ms": 0.0}
1133
1134 # Find which tips already have a fresh (non-expired) cache entry.
1135 now = _utc_now()
1136 cached_q = await session.execute(
1137 select(MusehubFetchMPackCache.tip_commit_id, MusehubFetchMPackCache.mpack_id)
1138 .where(MusehubFetchMPackCache.repo_id == repo_id)
1139 .where(MusehubFetchMPackCache.tip_commit_id.in_(tip_commit_ids))
1140 .where(MusehubFetchMPackCache.expires_at > now)
1141 )
1142 cached_tip_to_mpack: dict[str, str] = {row[0]: row[1] for row in cached_q}
1143 cached_mpack_ids = set(cached_tip_to_mpack.values())
1144
1145 # Skip the build only when ALL tips are cached AND they all share one mpack_id.
1146 # If mpack_ids differ (e.g. main cached from an older build, dev just pushed),
1147 # we must rebuild with ALL tips so every tip points to the same combined mpack —
1148 # the clone cache-hit check requires len(mpack_ids) == 1 across all want tips.
1149 all_tips_same_mpack = (
1150 len(cached_tip_to_mpack) == len(tip_commit_ids) and len(cached_mpack_ids) == 1
1151 )
1152 tips_skipped = len(cached_tip_to_mpack) if all_tips_same_mpack else 0
1153 tips_to_build = [] if all_tips_same_mpack else tip_commit_ids
1154
1155 logger.warning(
1156 "[fetch.mpack.prebuild] job=%s repo=%s tips=%d cached=%d mpack_ids=%d to_build=%d t=%.1fms",
1157 job_id[:16], repo_id, len(tip_commit_ids), len(cached_tip_to_mpack),
1158 len(cached_mpack_ids), len(tips_to_build), _ms(),
1159 )
1160
1161 tips_built = 0
1162 if tips_to_build:
1163 _build_t0 = _time_module.monotonic()
1164 try:
1165 # Build ONE combined mpack covering all uncached tips together.
1166 # wire_fetch_mpack writes a cache row per tip (all pointing to the same
1167 # mpack_id) so the multi-tip cache check fires on the next clone.
1168 result = await wire_fetch_mpack(session, repo_id, want=tips_to_build, have=[], force_build=True)
1169 mpack_id = result.get("mpack_id") or ""
1170 if not mpack_id:
1171 logger.warning(
1172 "[fetch.mpack.prebuild] combined build produced no mpack_id — skipping cache write",
1173 )
1174 else:
1175 tips_built = len(tips_to_build)
1176 _build_ms = (_time_module.monotonic() - _build_t0) * 1000
1177 logger.warning(
1178 "[fetch.mpack.prebuild] built tips=%d mpack_id=%s t=%.1fms",
1179 tips_built, mpack_id[:20], _build_ms,
1180 )
1181 # Explicitly upsert ALL tip cache entries to the new combined mpack_id.
1182 # wire_fetch_mpack (FMC_11) already upserts the tips it was given, but
1183 # any tips that were previously cached with a different mpack_id need to
1184 # be updated here so the clone cache-hit check (len(mpack_ids)==1) passes.
1185 _expires = _utc_now() + timedelta(days=7)
1186 for _tip in tip_commit_ids:
1187 _cache_id = blob_id((repo_id + _tip).encode()).replace("sha256:", "")
1188 await session.execute(
1189 _pg_insert(MusehubFetchMPackCache)
1190 .values(
1191 cache_id=_cache_id,
1192 repo_id=repo_id,
1193 tip_commit_id=_tip,
1194 mpack_id=mpack_id,
1195 created_at=_utc_now(),
1196 expires_at=_expires,
1197 )
1198 .on_conflict_do_update(
1199 index_elements=["repo_id", "tip_commit_id"],
1200 set_={"mpack_id": mpack_id, "expires_at": _expires},
1201 )
1202 )
1203 except FetchNotIndexedError:
1204 logger.warning(
1205 "[fetch.mpack.prebuild] index gap detected — re-raising for fail_job retry",
1206 )
1207 raise
1208 except Exception as exc:
1209 logger.error(
1210 "[fetch.mpack.prebuild] FAILED: %s", exc, exc_info=True,
1211 )
1212
1213 total_ms = _ms()
1214 logger.warning(
1215 "[fetch.mpack.prebuild] DONE job=%s repo=%s tips=%d built=%d skipped=%d TOTAL=%.1fms",
1216 job_id[:16], repo_id, len(tip_commit_ids), tips_built, tips_skipped, total_ms,
1217 )
1218 return {
1219 "tips_requested": len(tip_commit_ids),
1220 "tips_built": tips_built,
1221 "tips_skipped": tips_skipped,
1222 "elapsed_ms": total_ms,
1223 }
1224
1225
1226 class FetchCommitNotFound(Exception):
1227 """A want commit_id does not exist in musehub_commits."""
1228
1229
1230 class FetchNotReady(Exception):
1231 """Needed objects are absent from musehub_mpack_index — client must retry."""
1232
1233
1234 async def wire_fetch(
1235 session: AsyncSession,
1236 repo_id: str,
1237 want: list[str],
1238 have: list[str],
1239 ttl_seconds: int = 3600,
1240 ) -> FetchResult:
1241 import msgpack as _mp
1242
1243 _empty: FetchResult = {"mpack_id": "", "mpack_url": None, "commit_count": 0, "blob_count": 0}
1244
1245 for entry in want:
1246 if not (isinstance(entry, str) and entry.startswith("sha256:")):
1247 raise MPackValidationError(f"want entry is not a sha256: id: {entry!r}")
1248
1249 for entry in have:
1250 if not (isinstance(entry, str) and entry.startswith("sha256:")):
1251 raise MPackValidationError(f"have entry is not a sha256: id: {entry!r}")
1252
1253 if want:
1254 existing_q = await session.execute(
1255 select(MusehubCommit.commit_id).where(MusehubCommit.commit_id.in_(want))
1256 )
1257 found = {row[0] for row in existing_q}
1258 missing_want = [cid for cid in want if cid not in found]
1259 if missing_want:
1260 raise FetchCommitNotFound(missing_want[0])
1261
1262 have_set = set(have)
1263 needed = await _walk_commit_delta(session, want, have_set)
1264 if not needed:
1265 return _empty
1266
1267 cids = list(needed.keys())
1268 commit_rows: dict[str, MusehubCommit] = {}
1269 for i in range(0, len(cids), 2000):
1270 q = await session.execute(
1271 select(MusehubCommit).where(MusehubCommit.commit_id.in_(cids[i:i + 2000]))
1272 )
1273 for row in q.scalars():
1274 commit_rows[row.commit_id] = row
1275
1276 want_snap_ids = {r.snapshot_id for r in needed.values() if r.snapshot_id}
1277 have_snap_ids: set[str] = set()
1278 if have_set:
1279 have_commits_q = await session.execute(
1280 select(MusehubCommit.snapshot_id).where(MusehubCommit.commit_id.in_(list(have_set)))
1281 )
1282 have_snap_ids = {row[0] for row in have_commits_q if row[0]}
1283
1284 new_snap_ids = want_snap_ids - have_snap_ids
1285 snap_map: dict[str, dict] = {}
1286 new_oids: set[str] = set()
1287 backend = get_backend()
1288 if new_snap_ids:
1289 snaps_q = await session.execute(
1290 select(MusehubSnapshot).where(MusehubSnapshot.snapshot_id.in_(list(new_snap_ids)))
1291 )
1292 for snap in snaps_q.scalars():
1293 manifest = (
1294 _mp.unpackb(snap.manifest_blob, raw=False)
1295 if snap.manifest_blob
1296 else await _reconstruct_manifest(session, snap.snapshot_id)
1297 )
1298 new_oids.update(v for v in manifest.values() if v)
1299 snap_map[snap.snapshot_id] = await _snap_row_to_wire_s3(snap, backend, session=session)
1300
1301 if have_snap_ids:
1302 have_snaps_q = await session.execute(
1303 select(MusehubSnapshot).where(MusehubSnapshot.snapshot_id.in_(list(have_snap_ids)))
1304 )
1305 for snap in have_snaps_q.scalars():
1306 m = (
1307 _mp.unpackb(snap.manifest_blob, raw=False)
1308 if snap.manifest_blob
1309 else await _reconstruct_manifest(session, snap.snapshot_id)
1310 )
1311 new_oids -= {v for v in m.values() if v}
1312
1313 if new_oids:
1314 idx_q = await session.execute(
1315 select(MusehubMPackIndex.entity_id).where(
1316 MusehubMPackIndex.entity_id.in_(list(new_oids)),
1317 MusehubMPackIndex.entity_type == "object",
1318 )
1319 )
1320 indexed = {row[0] for row in idx_q}
1321 unindexed = new_oids - indexed
1322 if unindexed:
1323 raise FetchNotReady(f"{len(unindexed)} object(s) not yet in mpack_index")
1324
1325 objects: list[dict] = []
1326 if new_oids:
1327 obj_q = await session.execute(
1328 select(MusehubObject).where(MusehubObject.object_id.in_(list(new_oids)))
1329 )
1330 for obj_row in obj_q.scalars():
1331 if obj_row.content_cache is not None:
1332 content = obj_row.content_cache
1333 else:
1334 content = await backend.get(obj_row.object_id) or b""
1335 objects.append({"object_id": obj_row.object_id, "content": content})
1336
1337 wire_commits = [_to_wire_commit(r).model_dump() for r in commit_rows.values()]
1338 from muse.core.mpack import build_wire_mpack as _build_fetch_mpack
1339 wire_bytes = _build_fetch_mpack({
1340 "commits": wire_commits,
1341 "snapshots": list(snap_map.values()),
1342 "blobs": objects,
1343 "tags": [],
1344 })
1345 mpack_id = blob_id(wire_bytes)
1346
1347 await backend.put_mpack(mpack_id, wire_bytes)
1348 mpack_url = await backend.presign_mpack_get(mpack_id, ttl_seconds)
1349
1350 return {
1351 "mpack_id": mpack_id,
1352 "mpack_url": mpack_url,
1353 "commit_count": len(commit_rows),
1354 "blob_count": len(objects),
1355 }
File History 14 commits
sha256:1c5b7a0aba79472f4b10e52326dc010bdab1a498c9e195593d0707860478a034 feat(#92): phase 3 — cache lookup in wire_fetch_mpack (FMC_… Sonnet 4.6 patch 34 days ago
sha256:0e447fc3f6b7887d5d9e86b557c659ef7d0b05e2e09ddb0cb551ada240e48a51 feat(phase2): fetch.mpack.prebuild job handler + worker dis… Sonnet 4.6 patch 34 days ago
sha256:f4f731efef3f1cef7eeea99f8d5f49159110ec5c48137d9273b79e60a5aadf35 fix: fetch/presign tip-only blob coverage + timing instrume… Sonnet 4.6 patch 34 days ago
sha256:9acbc3291c00dc6dbad9685f88df517c29f130ac0238283278c1f2b46e6bc10b fix: send snapshots in full-manifest format; handle delta-o… Sonnet 4.6 patch 34 days ago
sha256:3f188050991b4e062a2dc0f2f99ee17cb85b7723dbeaedf5f766180582110d7b fix: reconstruct manifest for snapshots with null manifest_blob Sonnet 4.6 patch 38 days ago
sha256:e1171ab8958a8dc1f3a25fce5b7eedda123042373bc977a377c358ea429ef43e diag: add per-stage FETCH-DIAG logging to blob extraction path Sonnet 4.6 patch 38 days ago
sha256:9dbd6015bfeb02e1832f9bdbe80ed58ee9138566f0f317c5557bbd571d6946ca refactor: remove dual fetch paths in _walk_commit_delta and… Sonnet 4.6 patch 38 days ago
sha256:a68b91a9b1f1705e66730a0c328710192bc5e921c829892f0f9a11678341e148 fix: wire_fetch_mpack falls back to MusehubCommit.snapshot_… Sonnet 4.6 patch 42 days ago
sha256:ad616c6113d6c00f4efed6b2993734ca46d3e9b5bee25addd4ce8ae6b57136e5 chore: bump version to 0.2.0rc11; typing audit clean + all … Sonnet 4.6 minor 47 days ago
sha256:e35be48854f182f7bf02dc6cc0f58d22b3de3a544b570c0e2bc53f9e75a3607d feat(phase6): remove delta_blob path, dead imports, add fal… Sonnet 4.6 minor 48 days ago
sha256:9d0ffea20e344782dc6a969d4a240b3d7c96392b5dc03bbd9421890cb78c6f19 feat(phase4): wire fetch serves commits and snapshots from … Sonnet 4.6 patch 48 days ago
sha256:39e9c4e6f2134da0732e6983268a218178973936f8d7ca03c91f2b5ad42133c8 fix: use read_object_bytes in blob viewer; add zstd magic d… Sonnet 4.6 patch 48 days ago
sha256:ab9eda7b6479e1c35cdba9a54f62bacd2825de8faacec3ba67a9a8ef45914b7d fix: migration and wire protocol alignment Sonnet 4.6 minor 49 days ago
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 49 days ago