Skip to content
File

Blob: src/worker/git/pack/indexer/connectivity.ts

typescript250 lines
1/**
2 * Pack-first connectivity checker for receive-pack validation.
3 *
4 * Validates that each updated ref's target object is reachable by searching
5 * both the newly indexed pack and the existing active pack catalog. Uses the
6 * project's pack-first object store (readObject / findObject), and avoids
7 * buffering the entire pack in memory.
8 *
9 * The key trick: we seed the CacheContext memo with the new pack's catalog row
10 * and IdxView so the existing object store automatically searches the new pack
11 * first โ€” no new R2-reading code is needed.
12 */
13 
14import type { CacheContext, RequestMemo } from "@/worker/cache";
15import type { PackCatalogRow } from "@/worker/git/object-store/types";
16import { parseCommitRefs, parseTagTarget } from "@/worker/git/core/object-parse";
17import { readObject, findObject } from "@/worker/git/object-store/store";
18 
19import type { ConnectivityCheckOptions } from "./types";
20 
21const MAX_TAG_DEPTH = 8;
22 
23export async function runPackConnectivityCheck(opts: ConnectivityCheckOptions): Promise<void> {
24 const {
25 env,
26 repoId,
27 newPackKey,
28 newIdxView,
29 newPackSize,
30 activeCatalog,
31 commands,
32 statuses,
33 log,
34 cacheCtx,
35 } = opts;
36 
37 const newRow: PackCatalogRow = {
38 packKey: newPackKey,
39 kind: "receive",
40 state: "active",
41 tier: 0,
42 seqLo: 0,
43 seqHi: 0,
44 objectCount: newIdxView.count,
45 packBytes: newPackSize,
46 idxBytes: 0, // not needed for lookup
47 createdAt: Date.now(),
48 supersededBy: null,
49 };
50 
51 // Keep staged-pack visibility scoped to this connectivity pass. The receive
52 // path may reuse the caller cache context after a rejection, so leaking the
53 // staged pack into the shared memo would make later reads observe an
54 // uncommitted pack as if it were active.
55 const scopedCacheCtx = createScopedConnectivityCacheContext(
56 cacheCtx,
57 repoId,
58 [newRow, ...activeCatalog],
59 newPackKey,
60 newIdxView
61 );
62 
63 // Per-run caches to avoid repeated reads.
64 const hasCache = new Map<string, boolean>();
65 const kindCache = new Map<string, FinalKind>();
66 
67 const hasObject = async (oid: string): Promise<boolean> => {
68 const lc = oid.toLowerCase();
69 const cached = hasCache.get(lc);
70 if (cached !== undefined) return cached;
71 const found = !!(await findObject(env, repoId, lc, scopedCacheCtx));
72 hasCache.set(lc, found);
73 return found;
74 };
75 
76 const readKind = async (oid: string): Promise<FinalKind> => {
77 const lc = oid.toLowerCase();
78 const cached = kindCache.get(lc);
79 if (cached) return cached;
80 
81 const obj = await readObject(env, repoId, lc, scopedCacheCtx);
82 if (!obj) {
83 const k: FinalKind = { type: "unknown", oid: lc };
84 kindCache.set(lc, k);
85 return k;
86 }
87 
88 if (obj.type === "commit") {
89 const refs = parseCommitRefs(obj.payload);
90 const k: FinalKind = {
91 type: "commit",
92 oid: lc,
93 tree: refs.tree || "",
94 parents: refs.parents,
95 };
96 kindCache.set(lc, k);
97 return k;
98 }
99 if (obj.type === "tree") {
100 const k: FinalKind = { type: "tree", oid: lc };
101 kindCache.set(lc, k);
102 return k;
103 }
104 if (obj.type === "blob") {
105 const k: FinalKind = { type: "blob", oid: lc };
106 kindCache.set(lc, k);
107 return k;
108 }
109 if (obj.type === "tag") {
110 const tag = parseTagTarget(obj.payload);
111 if (tag) {
112 const k: FinalKind = {
113 type: "tag",
114 oid: lc,
115 targetOid: tag.targetOid,
116 targetType: tag.targetType,
117 };
118 kindCache.set(lc, k);
119 return k;
120 }
121 }
122 
123 const k: FinalKind = { type: "unknown", oid: lc };
124 kindCache.set(lc, k);
125 return k;
126 };
127 
128 const unwrapTag = async (
129 initialTag: Extract<FinalKind, { type: "tag" }>,
130 maxDepth = MAX_TAG_DEPTH
131 ): Promise<FinalKind> => {
132 // The limit is "8 tag hops", not "8 readKind() calls". The caller already
133 // proved that `newOid` is a tag, so start from that tag and count only the
134 // edges we actually follow through a tag chain.
135 let currentTag = initialTag;
136 let tagHops = 1;
137 while (tagHops <= maxDepth) {
138 const next = await readKind(currentTag.targetOid);
139 if (next.type !== "tag") return next;
140 currentTag = next;
141 tagHops++;
142 }
143 return { type: "unknown", oid: currentTag.oid };
144 };
145 
146 try {
147 // ---- Validate each non-delete command ----
148 for (let i = 0; i < commands.length; i++) {
149 const cmd = commands[i];
150 const st = statuses[i];
151 if (!st?.ok) continue;
152 if (/^0{40}$/i.test(cmd.newOid)) continue; // delete โ€” skip
153 
154 try {
155 const newOidLc = cmd.newOid.toLowerCase();
156 const initialKind = await readKind(newOidLc);
157 const kind = initialKind.type === "tag" ? await unwrapTag(initialKind) : initialKind;
158 
159 switch (kind.type) {
160 case "commit": {
161 // Require root tree exists.
162 if (!kind.tree || !(await hasObject(kind.tree))) {
163 log.warn("connectivity:missing-tree", { ref: cmd.ref, tree: kind.tree });
164 statuses[i] = { ref: cmd.ref, ok: false, msg: "missing-objects" };
165 break;
166 }
167 // Require all parents exist.
168 for (const p of kind.parents) {
169 if (!(await hasObject(p))) {
170 log.warn("connectivity:missing-parent", { ref: cmd.ref, parent: p });
171 statuses[i] = { ref: cmd.ref, ok: false, msg: "missing-objects" };
172 break;
173 }
174 }
175 break;
176 }
177 case "tree":
178 case "blob":
179 // Already found by readObject โ€” accept.
180 break;
181 case "unknown":
182 log.warn("connectivity:unknown-type-or-missing", { ref: cmd.ref, oid: newOidLc });
183 statuses[i] = { ref: cmd.ref, ok: false, msg: "missing-objects" };
184 break;
185 }
186 } catch (e) {
187 log.warn("connectivity:check-error", { ref: cmd.ref, error: String(e) });
188 statuses[i] = { ref: cmd.ref, ok: false, msg: "missing-objects" };
189 }
190 }
191 } finally {
192 syncScopedSubrequestBudget(cacheCtx, scopedCacheCtx);
193 }
194}
195 
196// ---------------------------------------------------------------------------
197// Internal types
198// ---------------------------------------------------------------------------
199 
200type FinalKind =
201 | { type: "commit"; oid: string; tree: string; parents: string[] }
202 | { type: "tree"; oid: string }
203 | { type: "blob"; oid: string }
204 | { type: "tag"; oid: string; targetOid: string; targetType: string }
205 | { type: "unknown"; oid: string };
206 
207// ---------------------------------------------------------------------------
208// Helpers
209// ---------------------------------------------------------------------------
210 
211function createScopedConnectivityCacheContext(
212 cacheCtx: CacheContext,
213 repoId: string,
214 packCatalog: PackCatalogRow[],
215 newPackKey: string,
216 newIdxView: ConnectivityCheckOptions["newIdxView"]
217): CacheContext {
218 const parentMemo = cacheCtx.memo;
219 const sameRepo = !parentMemo?.repoId || parentMemo.repoId === repoId;
220 const childMemo: RequestMemo = {
221 repoId,
222 subreqBudget: parentMemo?.subreqBudget,
223 limiter: parentMemo?.limiter,
224 flags: parentMemo?.flags ? new Set(parentMemo.flags) : undefined,
225 // Clone only the request-scoped idx memo. Packed object results and pack
226 // catalog snapshots intentionally do not cross this boundary because those
227 // would make later reads observe the staged pack after this validation
228 // scope has ended.
229 idxViews: sameRepo && parentMemo?.idxViews ? new Map(parentMemo.idxViews) : new Map(),
230 };
231 
232 childMemo.packCatalog = packCatalog;
233 childMemo.idxViews!.set(newPackKey, newIdxView);
234 
235 return {
236 req: cacheCtx.req,
237 ctx: cacheCtx.ctx,
238 memo: childMemo,
239 };
240}
241 
242function syncScopedSubrequestBudget(parent: CacheContext, child: CacheContext): void {
243 if (child.memo?.subreqBudget === undefined) return;
244 parent.memo = parent.memo || {};
245 // Only the soft budget is synchronized back out. The scoped pack catalog and
246 // staged-pack idx view must stay isolated so a rejected receive cannot leak
247 // uncommitted visibility into the caller's memo.
248 parent.memo.subreqBudget = child.memo.subreqBudget;
249}