From 1ea94ea5e915faba07ce95d77158d3933f79afcd Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Fri, 23 Oct 2026 13:41:27 +0800 Subject: [PATCH] feat(cache): shared sccache compile cache for contextful gate parts The dispatcher serves sccache's WebDAV backend at /v1/compile-cache from RUNS_STORAGE under compile-cache///, admitting a token the workflow mints per execution: an HMAC under a key derived from HMAC_SECRET, bound to the repository and execution, expiring at the instance's creation plus its queue age and maxDurationSec. Entries are bounded per object and per repository prefix; an hourly sweep deletes expired entries and evicts the oldest-written down to 90% of the ceiling. A run whose definition answers compileCache with true gets the endpoint and token; check wraps its command so rustc runs under sccache with CARGO_INCREMENTAL=0, prints sccache --show-stats at the end, and compiles locally when the sccache server does not start. contextful-gate asks for the cache on every part. The Rust sandbox variant bakes sha256-pinned sccache v0.18.0. Spec: specs/adr/0007-cost-efficient-execution.md --- apps/dispatcher/src/compile-cache-token.ts | 147 ++++++++ apps/dispatcher/src/cron-parity.test.ts | 14 +- apps/dispatcher/src/env.ts | 9 + apps/dispatcher/src/router.ts | 8 + .../src/routes/compile-cache.test.ts | 332 ++++++++++++++++++ apps/dispatcher/src/routes/compile-cache.ts | 321 +++++++++++++++++ apps/dispatcher/src/routes/scheduled.test.ts | 14 + apps/dispatcher/src/routes/scheduled.ts | 31 +- apps/dispatcher/src/workflow.ts | 21 ++ infra/Dockerfile.sandbox-demo | 30 ++ packages/core/src/define-run.ts | 12 +- .../core/src/primitives/compile-cache.test.ts | 104 ++++++ packages/core/src/primitives/compile-cache.ts | 89 +++++ packages/core/src/primitives/index.ts | 7 + runs/check.test.ts | 52 +++ runs/check.ts | 40 ++- runs/contextful-gate.test.ts | 2 + runs/contextful-gate.ts | 7 +- specs/adr/0007-cost-efficient-execution.md | 28 ++ wrangler.jsonc | 9 + 20 files changed, 1264 insertions(+), 13 deletions(-) create mode 100644 apps/dispatcher/src/compile-cache-token.ts create mode 100644 apps/dispatcher/src/routes/compile-cache.test.ts create mode 100644 apps/dispatcher/src/routes/compile-cache.ts create mode 100644 packages/core/src/primitives/compile-cache.test.ts create mode 100644 packages/core/src/primitives/compile-cache.ts diff --git a/apps/dispatcher/src/compile-cache-token.ts b/apps/dispatcher/src/compile-cache-token.ts new file mode 100644 index 00000000..89d95741 --- /dev/null +++ b/apps/dispatcher/src/compile-cache-token.ts @@ -0,0 +1,147 @@ +// FlareDispatch Dispatcher — compile-cache tokens. +// +// The compile-cache route (routes/compile-cache.ts) admits a request carrying +// a token minted for one repository and one execution, valid until that +// execution's ceiling: +// +// token = "cc1." "." base64url(executionId) "." mac +// mac = base64url( HMAC-SHA256( k_cc, "cc1\n" repo "\n" executionId "\n" expiresAt ) ) +// k_cc = HKDF-SHA256( HMAC_SECRET, salt="", info="flare-dispatch/compile-cache/v1" ) +// +// `repo` is lowercase `owner/name` and is not in the token: the route takes it +// from the request path, so a token presented under another repository's +// prefix fails the MAC. `expiresAt` is epoch seconds. The HKDF label keeps +// `k_cc` independent of the dispatch HMAC and of every other capability token +// derived from `HMAC_SECRET`. +// +// The workflow mints the token on every invocation of the execution and +// derives `expiresAt` from the instance's creation time, so each invocation +// mints the identical token: a check command's fingerprint covers its env, +// and a token that changed across a replay would refuse the command's launch. + +import { + COMPILE_CACHE_ENDPOINT_KEY, + COMPILE_CACHE_TOKEN_KEY, +} from "@fractalboxdev/flare-dispatch-core/primitives"; +import { deriveSecret } from "./capability-token"; +import type { Env } from "./env"; + +const HKDF_INFO = "flare-dispatch/compile-cache/v1"; +const VERSION = "cc1"; + +const encoder = new TextEncoder(); + +const base64url = (bytes: Uint8Array): string => { + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +}; + +const fromBase64url = (text: string): string | undefined => { + if (!/^[A-Za-z0-9_-]+$/.test(text)) return undefined; + try { + const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/")); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return undefined; + } +}; + +/** The execution id charset (log-token.ts `EXECUTION_ID_RE`). */ +const EXECUTION_ID = /^[A-Za-z0-9:_-]{3,128}$/; + +/** The token's scope: one repository, one execution, until `expiresAt` (epoch seconds). */ +export type CompileCacheScope = { + readonly repo: string; + readonly executionId: string; + readonly expiresAt: number; +}; + +const macKey = async (ikm: string): Promise => + crypto.subtle.importKey( + "raw", + encoder.encode(await deriveSecret(ikm, HKDF_INFO)), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); + +const message = (scope: CompileCacheScope): Uint8Array => + encoder.encode(`${VERSION}\n${scope.repo.toLowerCase()}\n${scope.executionId}\n${scope.expiresAt}`); + +/** The key material: the Worker's `HMAC_SECRET`; `undefined` when unset (the route then refuses). */ +export const resolveCompileCacheSecret = (env: Pick): string | undefined => + typeof env.HMAC_SECRET === "string" && env.HMAC_SECRET.length > 0 ? env.HMAC_SECRET : undefined; + +/** Mint the token for `scope`. */ +export const signCompileCacheToken = async (ikm: string, scope: CompileCacheScope): Promise => { + if (!EXECUTION_ID.test(scope.executionId)) throw new Error("compile-cache token: invalid execution id"); + if (!Number.isSafeInteger(scope.expiresAt) || scope.expiresAt <= 0) + throw new Error("compile-cache token: invalid expiry"); + const mac = new Uint8Array(await crypto.subtle.sign("HMAC", await macKey(ikm), message(scope))); + return `${VERSION}.${scope.expiresAt}.${base64url(encoder.encode(scope.executionId))}.${base64url(mac)}`; +}; + +/** Why a presented token was refused. */ +export type CompileCacheTokenRefusal = "malformed" | "expired" | "invalid"; + +/** + * Verify `presented` for `repo` at `nowSec`. The MAC is checked with + * `crypto.subtle.verify`, which compares in constant time. + */ +export const verifyCompileCacheToken = async ( + ikm: string, + repo: string, + presented: string, + nowSec: number, +): Promise<{ readonly ok: true; readonly scope: CompileCacheScope } | { readonly ok: false; readonly reason: CompileCacheTokenRefusal }> => { + const parts = presented.split("."); + if (parts.length !== 4 || parts[0] !== VERSION || !/^[1-9][0-9]{0,15}$/.test(parts[1]!)) + return { ok: false, reason: "malformed" }; + const expiresAt = Number(parts[1]); + const executionId = fromBase64url(parts[2]!); + if (executionId === undefined || !EXECUTION_ID.test(executionId) || !/^[A-Za-z0-9_-]{43}$/.test(parts[3]!)) + return { ok: false, reason: "malformed" }; + const mac = Uint8Array.from(atob(parts[3]!.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)); + const scope = { repo, executionId, expiresAt }; + const valid = await crypto.subtle.verify("HMAC", await macKey(ikm), mac, message(scope)); + if (!valid) return { ok: false, reason: "invalid" }; + if (nowSec >= expiresAt) return { ok: false, reason: "expired" }; + return { ok: true, scope }; +}; + +/** What the workflow knows when it decides whether to mint. */ +export type CompileCacheMint = { + /** The run's own answer (`RunSpec.compileCache`). */ + readonly wanted: boolean; + readonly repo: string; + readonly executionId: string; + /** The Workflow instance's creation time, epoch ms: stable across invocations. */ + readonly createdAtMs: number; + /** The run's admission queue age plus its `maxDurationSec`. */ + readonly ceilingSec: number; + readonly publicOrigin: string | undefined; + readonly secret: string | undefined; +}; + +/** + * The config overrides that hand a run its compile-cache endpoint and token, + * or `undefined` when the run does not want the cache or the deploy has no + * public origin or signing secret. + */ +export const compileCacheOverrides = async ( + mint: CompileCacheMint, +): Promise | undefined> => { + if (!mint.wanted || mint.publicOrigin === undefined || mint.publicOrigin === "" || mint.secret === undefined) + return undefined; + const token = await signCompileCacheToken(mint.secret, { + repo: mint.repo, + executionId: mint.executionId, + expiresAt: Math.floor(mint.createdAtMs / 1000) + Math.ceil(mint.ceilingSec), + }); + return { + [COMPILE_CACHE_ENDPOINT_KEY]: `${mint.publicOrigin.replace(/\/$/, "")}/v1/compile-cache`, + [COMPILE_CACHE_TOKEN_KEY]: token, + }; +}; diff --git a/apps/dispatcher/src/cron-parity.test.ts b/apps/dispatcher/src/cron-parity.test.ts index e6299e48..71e370b3 100644 --- a/apps/dispatcher/src/cron-parity.test.ts +++ b/apps/dispatcher/src/cron-parity.test.ts @@ -17,6 +17,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { DELIVERY_SWEEP_CRON } from "./delivery-sweep"; +import { COMPILE_CACHE_SWEEP_CRON } from "./routes/compile-cache"; import { runNames, schedulesByCron, scheduledRuns } from "./registry"; /** @@ -48,10 +49,13 @@ describe("cron parity", () => { // silently yields no crons passes every assertion below while checking // nothing. expect(armed.length).toBeGreaterThan(0); - // The reconcile sweep's cron is the one armed expression that binds no - // run: the scheduled handler routes it to the sweep. + // The two sweeps' crons are the armed expressions that bind no run: the + // scheduled handler routes each to its sweep. expect( - armed.filter((cron) => cron !== DELIVERY_SWEEP_CRON && schedulesByCron(cron).length === 0), + armed.filter( + (cron) => + cron !== DELIVERY_SWEEP_CRON && cron !== COMPILE_CACHE_SWEEP_CRON && schedulesByCron(cron).length === 0, + ), ).toEqual([]); }); @@ -59,6 +63,10 @@ describe("cron parity", () => { expect(readArmedCrons()).toContain(DELIVERY_SWEEP_CRON); }); + it("arms the compile-cache sweep", () => { + expect(readArmedCrons()).toContain(COMPILE_CACHE_SWEEP_CRON); + }); + it("every cron a scheduled run declares is armed unless deliberately left off", () => { const armed = new Set(readArmedCrons()); // EVERY declared expression, not merely one per run: a run that declares diff --git a/apps/dispatcher/src/env.ts b/apps/dispatcher/src/env.ts index 341d64a6..334dc187 100644 --- a/apps/dispatcher/src/env.ts +++ b/apps/dispatcher/src/env.ts @@ -333,6 +333,15 @@ export interface Env { */ readonly PUBLIC_ORIGIN?: string; + /** + * Compile-cache bounds (routes/compile-cache.ts): one entry's bytes, one + * repository prefix's bytes, and the entry age in days the sweep deletes. + * Unset or malformed → 100 MiB, 20 GiB, 14 days. + */ + readonly COMPILE_CACHE_MAX_OBJECT_BYTES?: string; + readonly COMPILE_CACHE_MAX_REPO_BYTES?: string; + readonly COMPILE_CACHE_TTL_DAYS?: string; + /** * Cloudflare Email Routing `send_email` binding — backs the `email` * capability + the Workflow's completion-notify (`notify.emails` in the diff --git a/apps/dispatcher/src/router.ts b/apps/dispatcher/src/router.ts index a14698a8..76bbb473 100644 --- a/apps/dispatcher/src/router.ts +++ b/apps/dispatcher/src/router.ts @@ -19,6 +19,7 @@ import type { Env } from "./env"; import { handleViaApp } from "./http-app"; import { handleBrowserCdp } from "./routes/browser-cdp"; +import { COMPILE_CACHE_ROUTE, handleCompileCache } from "./routes/compile-cache"; const json = (body: unknown, status: number): Response => new Response(JSON.stringify(body), { @@ -48,5 +49,12 @@ export const handleRequest = async (request: Request, env: Env): Promise }; + +/** An in-memory bucket with the R2 calls the route and the sweep make. */ +const makeBucket = (pageSize = 1000) => { + const store = new Map(); + const meta = (key: string, obj: Stored) => ({ + key, + size: obj.body.byteLength, + uploaded: obj.uploaded, + httpEtag: `"etag-${key}"`, + customMetadata: obj.customMetadata ?? {}, + }); + const bucket = { + get: async (key: string) => { + const obj = store.get(key); + return obj === undefined ? null : { ...meta(key, obj), body: new Response(obj.body).body }; + }, + head: async (key: string) => { + const obj = store.get(key); + return obj === undefined ? null : meta(key, obj); + }, + put: async (key: string, body: ReadableStream | string, opts?: { customMetadata?: Record }) => { + const bytes = new Uint8Array(await new Response(body).arrayBuffer()); + const obj: Stored = { body: bytes, uploaded: new Date(NOW_MS), ...(opts?.customMetadata ? { customMetadata: opts.customMetadata } : {}) }; + store.set(key, obj); + return meta(key, obj); + }, + delete: async (keys: string | string[]) => { + for (const key of Array.isArray(keys) ? keys : [keys]) store.delete(key); + }, + list: async (opts: { prefix?: string; cursor?: string; limit?: number }) => { + const keys = [...store.keys()].filter((key) => key.startsWith(opts.prefix ?? "")).sort(); + const start = opts.cursor === undefined ? 0 : Number(opts.cursor); + const page = keys.slice(start, start + Math.min(pageSize, opts.limit ?? 1000)); + const next = start + page.length; + return { + objects: page.map((key) => meta(key, store.get(key)!)), + truncated: next < keys.length, + ...(next < keys.length ? { cursor: String(next) } : {}), + }; + }, + } as unknown as CompileCacheBucket; + const seed = (key: string, size: number, uploadedMs: number) => + store.set(key, { body: new Uint8Array(size), uploaded: new Date(uploadedMs) }); + return { bucket, store, seed }; +}; + +const envWith = (bucket: CompileCacheBucket, over: Partial = {}) => ({ + HMAC_SECRET: SECRET, + RUNS_STORAGE: bucket, + ...over, +}); + +const token = (over: { repo?: string; expiresAt?: number } = {}) => + signCompileCacheToken(SECRET, { + repo: over.repo ?? "owner/name", + executionId: EXEC, + expiresAt: over.expiresAt ?? NOW + 3600, + }); + +const call = async ( + bucket: CompileCacheBucket, + method: string, + url: string, + opts: { token?: string | null; body?: string; headers?: Record; env?: Partial } = {}, +) => { + const bearer = opts.token === undefined ? await token() : opts.token; + const headers: Record = { ...opts.headers }; + if (bearer !== null) headers.authorization = `Bearer ${bearer}`; + if (opts.body !== undefined && headers["content-length"] === undefined) + headers["content-length"] = String(new TextEncoder().encode(opts.body).length); + return handleCompileCache( + new Request(url, { method, headers, ...(opts.body !== undefined ? { body: opts.body } : {}) }), + envWith(bucket, opts.env), + { now: () => NOW_MS }, + ); +}; + +describe("compile-cache auth", () => { + it("refuses a request without a token", async () => { + const res = await call(makeBucket().bucket, "GET", `${BASE}/a/b/c/abc`, { token: null }); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toContain("Bearer"); + }); + + it("refuses an expired token", async () => { + const res = await call(makeBucket().bucket, "GET", `${BASE}/a/b/c/abc`, { token: await token({ expiresAt: NOW }) }); + expect(res.status).toBe(403); + expect(await res.text()).toBe("token expired"); + }); + + it("refuses a token minted for another repository", async () => { + const res = await call(makeBucket().bucket, "GET", `${BASE}/a/b/c/abc`, { token: await token({ repo: "owner/other" }) }); + expect(res.status).toBe(403); + expect(await res.text()).toBe("token invalid"); + }); + + it("refuses a token whose expiry was rewritten", async () => { + const minted = await token(); + const [v, , exec, mac] = minted.split("."); + const res = await call(makeBucket().bucket, "GET", `${BASE}/a/b/c/abc`, { token: `${v}.${NOW + 999_999}.${exec}.${mac}` }); + expect(res.status).toBe(403); + }); + + it("refuses a malformed token", async () => { + const res = await call(makeBucket().bucket, "GET", `${BASE}/a/b/c/abc`, { token: "not-a-token" }); + expect(res.status).toBe(403); + expect(await res.text()).toBe("token malformed"); + }); + + it("answers 503 on a deploy without a signing secret", async () => { + const res = await call(makeBucket().bucket, "GET", `${BASE}/a/b/c/abc`, { env: { HMAC_SECRET: "" } }); + expect(res.status).toBe(503); + }); + + it("matches the repository case-insensitively", async () => { + const url = "https://dispatch.example/v1/compile-cache/Owner/Name/x/key"; + const res = await call(makeBucket().bucket, "GET", url); + expect(res.status).toBe(404); + }); + + it("verifies a minted token's scope", async () => { + const verdict = await verifyCompileCacheToken(SECRET, "owner/name", await token(), NOW); + expect(verdict).toEqual({ ok: true, scope: { repo: "owner/name", executionId: EXEC, expiresAt: NOW + 3600 } }); + }); +}); + +describe("compile-cache WebDAV", () => { + it("stores a PUT under the repository prefix and serves it to GET, HEAD and PROPFIND", async () => { + const { bucket, store } = makeBucket(); + const put = await call(bucket, "PUT", `${BASE}/a/b/c/abcdef`, { body: "rlib-bytes" }); + expect(put.status).toBe(201); + expect([...store.keys()]).toEqual(["compile-cache/owner/name/1.90.0/x86_64-unknown-linux-gnu/a/b/c/abcdef"]); + + const get = await call(bucket, "GET", `${BASE}/a/b/c/abcdef`); + expect(get.status).toBe(200); + expect(await get.text()).toBe("rlib-bytes"); + + const head = await call(bucket, "HEAD", `${BASE}/a/b/c/abcdef`); + expect(head.status).toBe(200); + expect(head.headers.get("content-length")).toBe("10"); + + const stat = await call(bucket, "PROPFIND", `${BASE}/a/b/c/abcdef`, { headers: { depth: "0" } }); + expect(stat.status).toBe(207); + const xml = await stat.text(); + expect(xml).toContain("10"); + expect(xml).toContain("HTTP/1.1 200 OK"); + expect(xml).toContain(""); + }); + + it("answers a miss with 404", async () => { + expect((await call(makeBucket().bucket, "GET", `${BASE}/a/b/c/missing`)).status).toBe(404); + expect((await call(makeBucket().bucket, "PROPFIND", `${BASE}/a/b/c/missing`)).status).toBe(404); + }); + + it("reports every collection as existing, so a PUT needs no MKCOL", async () => { + const res = await call(makeBucket().bucket, "PROPFIND", `${BASE}/a/b/c/`, { headers: { depth: "0" } }); + expect(res.status).toBe(207); + const xml = await res.text(); + expect(xml).toContain(""); + expect(xml).toContain(""); + expect((await call(makeBucket().bucket, "MKCOL", `${BASE}/a/b/c/`)).status).toBe(201); + }); + + it("serves only Depth: 0", async () => { + const res = await call(makeBucket().bucket, "PROPFIND", `${BASE}/`, { headers: { depth: "1" } }); + expect(res.status).toBe(403); + }); + + it("refuses a method sccache never issues", async () => { + const res = await call(makeBucket().bucket, "DELETE", `${BASE}/a/b/c/abcdef`); + expect(res.status).toBe(405); + }); + + it("refuses an entry over the object bound", async () => { + const { bucket, store } = makeBucket(); + const res = await call(bucket, "PUT", `${BASE}/a/b/c/big`, { + body: "0123456789", + env: { COMPILE_CACHE_MAX_OBJECT_BYTES: "9" }, + }); + expect(res.status).toBe(413); + expect(store.size).toBe(0); + }); + + it("refuses a PUT without a Content-Length", async () => { + const res = await call(makeBucket().bucket, "PUT", `${BASE}/a/b/c/x`, { headers: { "content-length": "" } }); + expect(res.status).toBe(411); + }); + + it("refuses a PUT once the measured prefix plus the entry exceeds the repository bound", async () => { + const { bucket, store } = makeBucket(); + await bucket.put(usageKey("owner/name"), "", { customMetadata: { bytes: "95" } }); + const res = await call(bucket, "PUT", `${BASE}/a/b/c/x`, { + body: "0123456789", + env: { COMPILE_CACHE_MAX_REPO_BYTES: "100" }, + }); + expect(res.status).toBe(507); + expect([...store.keys()]).toEqual([usageKey("owner/name")]); + }); + + it("rejects paths that escape or skip the repository", () => { + expect(parseCompileCachePath("/v1/compile-cache/owner/name/../other/x")).toBeUndefined(); + expect(parseCompileCachePath("/v1/compile-cache/owner/name/%2e%2e/x")).toBeUndefined(); + expect(parseCompileCachePath("/v1/compile-cache/owner/name/a//b")).toBeUndefined(); + expect(parseCompileCachePath("/v1/compile-cache/owner/name")).toBeUndefined(); + expect(parseCompileCachePath("/v1/compile-cache/owner")).toBeUndefined(); + expect(parseCompileCachePath("/v1/compile-cache/owner/name/")).toEqual({ + repo: "owner/name", + key: "compile-cache/owner/name/", + collection: true, + }); + expect(parseCompileCachePath("/v1/compile-cache/owner/name/.sccache_check")).toEqual({ + repo: "owner/name", + key: "compile-cache/owner/name/.sccache_check", + collection: false, + }); + }); + + it("is reachable through the dispatcher router", async () => { + const env = makeFakeEnv({ hmacSecret: SECRET, workflow: makeFakeWorkflow(), storage: makeFakeR2() }); + const res = await handleRequest(new Request(`${BASE}/a/b/c/abc`), env); + expect(res.status).toBe(401); + }); +}); + +describe("compile-cache limits", () => { + it("records its defaults and reads overrides from the Worker vars", () => { + expect(resolveCompileCacheLimits({})).toEqual({ + maxObjectBytes: 100 * 1024 * 1024, + maxRepoBytes: 20 * 1024 * 1024 * 1024, + ttlDays: 14, + }); + expect( + resolveCompileCacheLimits({ + COMPILE_CACHE_MAX_OBJECT_BYTES: "10", + COMPILE_CACHE_MAX_REPO_BYTES: "garbage", + COMPILE_CACHE_TTL_DAYS: "3", + }), + ).toEqual({ maxObjectBytes: 10, maxRepoBytes: 20 * 1024 * 1024 * 1024, ttlDays: 3 }); + }); +}); + +describe("sweepCompileCache", () => { + const DAY = 24 * 3600 * 1000; + const limits = { maxObjectBytes: 100, maxRepoBytes: 100, ttlDays: 7 }; + + it("deletes expired entries, evicts the oldest-written to 90% of the ceiling, and records the size", async () => { + const { bucket, store, seed } = makeBucket(2); + seed("compile-cache/owner/name/r/t/expired", 10, NOW_MS - 8 * DAY); + seed("compile-cache/owner/name/r/t/oldest", 40, NOW_MS - 3 * DAY); + seed("compile-cache/owner/name/r/t/older", 40, NOW_MS - 2 * DAY); + seed("compile-cache/owner/name/r/t/newest", 40, NOW_MS - 1 * DAY); + seed("compile-cache/other/repo/r/t/kept", 5, NOW_MS); + + const result = await sweepCompileCache(bucket, limits, NOW_MS); + + expect(result.truncated).toBe(false); + expect(result.repos).toEqual([ + { repo: "other/repo", bytes: 5, objects: 1, expired: 0, evicted: 0 }, + { repo: "owner/name", bytes: 80, objects: 2, expired: 1, evicted: 1 }, + ]); + expect([...store.keys()].filter((key) => key.startsWith("compile-cache/")).sort()).toEqual([ + "compile-cache/other/repo/r/t/kept", + "compile-cache/owner/name/r/t/newest", + "compile-cache/owner/name/r/t/older", + ]); + expect(store.get(usageKey("owner/name"))?.customMetadata?.bytes).toBe("80"); + }); + + it("deletes the usage record of a prefix that no longer holds entries", async () => { + const { bucket, store } = makeBucket(); + await bucket.put(usageKey("gone/repo"), "", { customMetadata: { bytes: "999" } }); + await sweepCompileCache(bucket, limits, NOW_MS); + expect(store.has(usageKey("gone/repo"))).toBe(false); + }); +}); + +describe("compileCacheOverrides", () => { + const mint = { + wanted: true, + repo: "owner/name", + executionId: EXEC, + createdAtMs: NOW_MS, + ceilingSec: 5400 + 4500, + publicOrigin: "https://dispatch.example/", + secret: SECRET, + }; + + it("hands the run the endpoint and a token valid until creation plus the ceiling", async () => { + const overrides = await compileCacheOverrides(mint); + expect(overrides?.["compile-cache.endpoint"]).toBe("https://dispatch.example/v1/compile-cache"); + const verdict = await verifyCompileCacheToken(SECRET, "owner/name", overrides!["compile-cache.token"]!, NOW); + expect(verdict).toEqual({ ok: true, scope: { repo: "owner/name", executionId: EXEC, expiresAt: NOW + 9900 } }); + }); + + it("mints the same token on every invocation of one execution", async () => { + expect(await compileCacheOverrides(mint)).toEqual(await compileCacheOverrides(mint)); + }); + + it("mints nothing for a run that does not want the cache or a deploy that cannot serve it", async () => { + expect(await compileCacheOverrides({ ...mint, wanted: false })).toBeUndefined(); + expect(await compileCacheOverrides({ ...mint, publicOrigin: undefined })).toBeUndefined(); + expect(await compileCacheOverrides({ ...mint, secret: undefined })).toBeUndefined(); + }); +}); diff --git a/apps/dispatcher/src/routes/compile-cache.ts b/apps/dispatcher/src/routes/compile-cache.ts new file mode 100644 index 00000000..1a3b80e9 --- /dev/null +++ b/apps/dispatcher/src/routes/compile-cache.ts @@ -0,0 +1,321 @@ +// FlareDispatch Dispatcher — `/v1/compile-cache///`: the +// shared compile cache sccache reaches through its WebDAV backend. +// +// sccache's WebDAV backend (OpenDAL `services-webdav`, sccache 0.18) issues +// four methods, and this route serves exactly those: +// +// GET read an entry; 404 is a miss +// PUT write an entry (`Content-Length` set) +// PROPFIND `Depth: 0` stat; before every PUT, of the entry's parent +// collection, to decide whether to MKCOL it +// MKCOL create a parent collection +// +// plus HEAD. Collections are virtual: every path ending in `/` exists, so a +// PUT costs one PROPFIND and never a MKCOL. Entries live in `RUNS_STORAGE` +// under `compile-cache///`; the path below the repository +// is the client's `SCCACHE_WEBDAV_KEY_PREFIX` remainder and key. +// +// Every request carries `Authorization: Bearer ` (compile-cache-token.ts) +// minted for the repository the path names. Missing → 401; a token for another +// repository, a forged or an expired one → 403; no `HMAC_SECRET` → 503. +// +// Bounds, read from the Worker vars (wrangler.jsonc) with these defaults: +// COMPILE_CACHE_MAX_OBJECT_BYTES one entry; a larger PUT is 413 +// COMPILE_CACHE_MAX_REPO_BYTES one repository's prefix; a PUT while the +// last measured size plus the entry exceeds +// it is 507 +// COMPILE_CACHE_TTL_DAYS entry age at which the sweep deletes it +// The sweep (`sweepCompileCache`, on COMPILE_CACHE_SWEEP_CRON) measures each +// prefix, deletes entries past the TTL, then deletes the oldest-written +// entries until the prefix is at most 90% of its ceiling, and records the +// measured size the PUT admission reads. A prefix can therefore exceed its +// ceiling by the writes of one sweep interval. +// +// Spec: specs/adr/0007-cost-efficient-execution.md § Shared compile cache. + +import { resolveCompileCacheSecret, verifyCompileCacheToken } from "../compile-cache-token"; +import type { Env } from "../env"; + +/** Path prefix of the route. */ +export const COMPILE_CACHE_ROUTE = "/v1/compile-cache"; +/** R2 prefix of the entries. */ +export const COMPILE_CACHE_PREFIX = "compile-cache/"; +/** R2 prefix of the per-repository measured sizes the sweep records. */ +export const COMPILE_CACHE_USAGE_PREFIX = "compile-cache-usage/"; +/** The sweep's cron (wrangler.jsonc `triggers.crons`). */ +export const COMPILE_CACHE_SWEEP_CRON = "41 * * * *"; + +export const DEFAULT_MAX_OBJECT_BYTES = 100 * 1024 * 1024; +export const DEFAULT_MAX_REPO_BYTES = 20 * 1024 * 1024 * 1024; +export const DEFAULT_TTL_DAYS = 14; +/** The sweep evicts down to this fraction of the ceiling, so the next writes have room. */ +const EVICT_TO_RATIO = 0.9; +/** Listing pages one sweep reads (1000 entries each). */ +const MAX_SWEEP_PAGES = 500; +const MAX_KEY_BYTES = 1024; + +const OWNER = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/; +const NAME = /^[A-Za-z0-9._-]{1,100}$/; +const SEGMENT = /^[A-Za-z0-9._+-]{1,255}$/; + +export type CompileCacheLimits = { + readonly maxObjectBytes: number; + readonly maxRepoBytes: number; + readonly ttlDays: number; +}; + +const positive = (raw: string | undefined, fallback: number): number => { + const n = Number(raw?.trim()); + return raw !== undefined && raw.trim() !== "" && Number.isSafeInteger(n) && n > 0 ? n : fallback; +}; + +/** The configured bounds; an absent or malformed var takes its default. */ +export const resolveCompileCacheLimits = ( + env: Pick, +): CompileCacheLimits => ({ + maxObjectBytes: positive(env.COMPILE_CACHE_MAX_OBJECT_BYTES, DEFAULT_MAX_OBJECT_BYTES), + maxRepoBytes: positive(env.COMPILE_CACHE_MAX_REPO_BYTES, DEFAULT_MAX_REPO_BYTES), + ttlDays: positive(env.COMPILE_CACHE_TTL_DAYS, DEFAULT_TTL_DAYS), +}); + +/** A request path resolved to its repository and R2 key. */ +export type CompileCachePath = { + /** `owner/name`, lowercase. */ + readonly repo: string; + /** The R2 key; ends in `/` for a collection. */ + readonly key: string; + readonly collection: boolean; +}; + +/** + * Resolve `pathname` (`/v1/compile-cache//[/…][/]`). + * `undefined` for anything else: a missing repository, an empty, `.` or `..` + * segment, a character outside the key charset, or a key over 1024 bytes. + */ +export const parseCompileCachePath = (pathname: string): CompileCachePath | undefined => { + if (!pathname.startsWith(`${COMPILE_CACHE_ROUTE}/`)) return undefined; + const rest = pathname.slice(COMPILE_CACHE_ROUTE.length + 1); + const collection = rest.endsWith("/"); + const raw = (collection ? rest.slice(0, -1) : rest).split("/"); + let segments: string[]; + try { + segments = raw.map((segment) => decodeURIComponent(segment)); + } catch { + return undefined; + } + if (segments.length < 2) return undefined; + const [owner, name, ...below] = segments as [string, string, ...string[]]; + if (!OWNER.test(owner) || !NAME.test(name) || name === "." || name === "..") return undefined; + if (below.some((segment) => !SEGMENT.test(segment) || segment === "." || segment === "..")) return undefined; + // `/` alone is the repository's root collection, never an entry. + if (below.length === 0 && !collection) return undefined; + const repo = `${owner}/${name}`.toLowerCase(); + const key = `${COMPILE_CACHE_PREFIX}${repo}/${below.join("/")}${below.length > 0 && collection ? "/" : ""}`; + if (new TextEncoder().encode(key).length > MAX_KEY_BYTES) return undefined; + return { repo, key, collection: collection || below.length === 0 }; +}; + +/** The R2 key of a repository's measured size. */ +export const usageKey = (repo: string): string => `${COMPILE_CACHE_USAGE_PREFIX}${repo.toLowerCase()}`; + +/** The R2 surface the route and the sweep use. */ +export type CompileCacheBucket = Pick; + +const text = (status: number, body: string, headers: Record = {}): Response => + new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8", ...headers } }); + +const ALLOW = "GET, HEAD, PUT, PROPFIND, MKCOL, OPTIONS"; + +const multistatus = (href: string, props: string): Response => + new Response( + `${href}${props}HTTP/1.1 200 OK`, + { status: 207, headers: { "content-type": "application/xml; charset=utf-8" } }, + ); + +const xmlEscape = (value: string): string => + value.replace(/&/g, "&").replace(//g, ">"); + +/** The last-modified of a virtual collection: a fixed instant, since it never changes. */ +const COLLECTION_MODIFIED = new Date(0).toUTCString(); + +/** Ports the handler reads: the clock (epoch ms). */ +export type CompileCachePorts = { readonly now: () => number }; + +/** Serve one compile-cache request. */ +export const handleCompileCache = async ( + request: Request, + env: Pick< + Env, + "HMAC_SECRET" | "COMPILE_CACHE_MAX_OBJECT_BYTES" | "COMPILE_CACHE_MAX_REPO_BYTES" | "COMPILE_CACHE_TTL_DAYS" + > & { readonly RUNS_STORAGE: CompileCacheBucket }, + ports: CompileCachePorts = { now: Date.now }, +): Promise => { + const url = new URL(request.url); + const path = parseCompileCachePath(url.pathname); + if (path === undefined) return text(404, "not found"); + + const secret = resolveCompileCacheSecret(env); + if (secret === undefined) return text(503, "compile cache unavailable: no signing secret"); + const auth = request.headers.get("authorization"); + const bearer = auth === null ? null : /^Bearer\s+(\S+)$/i.exec(auth.trim()); + if (bearer === null) return text(401, "missing bearer token", { "www-authenticate": 'Bearer realm="compile-cache"' }); + const verdict = await verifyCompileCacheToken(secret, path.repo, bearer[1]!, Math.floor(ports.now() / 1000)); + if (!verdict.ok) return text(403, `token ${verdict.reason}`); + + const bucket = env.RUNS_STORAGE; + const limits = resolveCompileCacheLimits(env); + switch (request.method) { + case "OPTIONS": + return new Response(null, { status: 200, headers: { allow: ALLOW, dav: "1" } }); + case "GET": + case "HEAD": { + if (path.collection) return text(404, "not found"); + const entryHeaders = (object: R2Object) => ({ + "content-type": "application/octet-stream", + "content-length": String(object.size), + etag: object.httpEtag, + "last-modified": object.uploaded.toUTCString(), + }); + if (request.method === "HEAD") { + const object = await bucket.head(path.key); + if (object === null) return text(404, "not found"); + return new Response(null, { status: 200, headers: entryHeaders(object) }); + } + const object = await bucket.get(path.key); + if (object === null) return text(404, "not found"); + return new Response(object.body, { status: 200, headers: entryHeaders(object) }); + } + case "PUT": { + if (path.collection) return text(405, "a collection takes no body", { allow: ALLOW }); + const declared = request.headers.get("content-length"); + if (declared === null || !/^[0-9]{1,16}$/.test(declared)) return text(411, "content-length required"); + const length = Number(declared); + if (length > limits.maxObjectBytes) + return text(413, `entry of ${length} bytes exceeds the ${limits.maxObjectBytes}-byte bound`); + const usage = await bucket.head(usageKey(path.repo)); + const measured = Number(usage?.customMetadata?.bytes ?? "0"); + if (Number.isFinite(measured) && measured + length > limits.maxRepoBytes) + return text(507, `repository cache at ${measured} bytes of its ${limits.maxRepoBytes}-byte bound`); + const stored = await bucket.put(path.key, length === 0 || request.body === null ? "" : request.body, { + httpMetadata: { contentType: "application/octet-stream" }, + }); + return new Response(null, { status: 201, headers: stored === null ? {} : { etag: stored.httpEtag } }); + } + case "MKCOL": + return new Response(null, { status: 201 }); + case "PROPFIND": { + const depth = request.headers.get("depth"); + if (depth !== null && depth.trim() !== "0") return text(403, "only Depth: 0 is served"); + const href = xmlEscape(url.pathname); + if (path.collection) + return multistatus( + href, + `${COLLECTION_MODIFIED}`, + ); + const object = await bucket.head(path.key); + if (object === null) return text(404, "not found"); + return multistatus( + href, + `${object.uploaded.toUTCString()}${object.size}${xmlEscape(object.httpEtag)}`, + ); + } + default: + return text(405, "method not allowed", { allow: ALLOW }); + } +}; + +/** One repository's sweep outcome. */ +export type CompileCacheSweepRepo = { + readonly repo: string; + readonly bytes: number; + readonly objects: number; + readonly expired: number; + readonly evicted: number; +}; + +export type CompileCacheSweepResult = { + readonly repos: readonly CompileCacheSweepRepo[]; + /** The listing stopped at the page bound; sizes are lower bounds. */ + readonly truncated: boolean; +}; + +const repoOf = (key: string): string | undefined => { + const parts = key.slice(COMPILE_CACHE_PREFIX.length).split("/"); + return parts.length >= 3 ? `${parts[0]}/${parts[1]}` : undefined; +}; + +const deleteKeys = async (bucket: CompileCacheBucket, keys: readonly string[]): Promise => { + for (let i = 0; i < keys.length; i += 1000) await bucket.delete(keys.slice(i, i + 1000)); +}; + +/** + * Measure every repository prefix, delete entries older than the TTL, evict + * the oldest-written entries of a prefix over its ceiling down to 90% of it, + * and record each prefix's remaining size for the PUT admission. A usage + * record whose prefix holds no entries is deleted. + */ +export const sweepCompileCache = async ( + bucket: CompileCacheBucket, + limits: CompileCacheLimits, + nowMs: number, +): Promise => { + const byRepo = new Map(); + let cursor: string | undefined; + let truncated = false; + for (let page = 0; ; page++) { + if (page >= MAX_SWEEP_PAGES) { + truncated = true; + break; + } + const listed = await bucket.list({ prefix: COMPILE_CACHE_PREFIX, limit: 1000, ...(cursor !== undefined ? { cursor } : {}) }); + for (const object of listed.objects) { + const repo = repoOf(object.key); + if (repo === undefined) continue; + const entries = byRepo.get(repo) ?? []; + entries.push({ key: object.key, size: object.size, uploaded: object.uploaded.getTime() }); + byRepo.set(repo, entries); + } + if (!listed.truncated) break; + cursor = listed.cursor; + } + + const cutoff = nowMs - limits.ttlDays * 24 * 3600 * 1000; + const target = Math.floor(limits.maxRepoBytes * EVICT_TO_RATIO); + const repos: CompileCacheSweepRepo[] = []; + for (const [repo, entries] of byRepo) { + const expired = entries.filter((entry) => entry.uploaded < cutoff); + const kept = entries.filter((entry) => entry.uploaded >= cutoff).sort((a, b) => a.uploaded - b.uploaded); + let bytes = kept.reduce((sum, entry) => sum + entry.size, 0); + const evicted: string[] = []; + if (bytes > limits.maxRepoBytes) { + for (const entry of kept) { + if (bytes <= target) break; + evicted.push(entry.key); + bytes -= entry.size; + } + } + await deleteKeys(bucket, [...expired.map((entry) => entry.key), ...evicted]); + const objects = kept.length - evicted.length; + await bucket.put(usageKey(repo), "", { + customMetadata: { bytes: String(bytes), objects: String(objects), measuredAt: new Date(nowMs).toISOString() }, + }); + repos.push({ repo, bytes, objects, expired: expired.length, evicted: evicted.length }); + } + + if (!truncated) { + const stale: string[] = []; + let usageCursor: string | undefined; + do { + const listed = await bucket.list({ + prefix: COMPILE_CACHE_USAGE_PREFIX, + limit: 1000, + ...(usageCursor !== undefined ? { cursor: usageCursor } : {}), + }); + for (const object of listed.objects) + if (!byRepo.has(object.key.slice(COMPILE_CACHE_USAGE_PREFIX.length))) stale.push(object.key); + usageCursor = listed.truncated ? listed.cursor : undefined; + } while (usageCursor !== undefined); + await deleteKeys(bucket, stale); + } + return { repos, truncated }; +}; diff --git a/apps/dispatcher/src/routes/scheduled.test.ts b/apps/dispatcher/src/routes/scheduled.test.ts index cd965836..08f1eca5 100644 --- a/apps/dispatcher/src/routes/scheduled.test.ts +++ b/apps/dispatcher/src/routes/scheduled.test.ts @@ -13,6 +13,7 @@ import { describe, expect, it, vi } from "vitest"; import { DELIVERY_SWEEP_CRON } from "../delivery-sweep"; +import { COMPILE_CACHE_SWEEP_CRON } from "./compile-cache"; import { handleScheduled, liveDeliverySweepPorts } from "./scheduled"; import type { Env } from "../env"; import { makeFakeR2, makeFakeWorkflow } from "../test-helpers"; @@ -173,6 +174,19 @@ describe("handleScheduled — verdict reconcile sweep", () => { warn.mockRestore(); }); + it("routes the compile-cache cron to the compile-cache sweep alone", async () => { + const workflow = makeFakeWorkflow(); + const delivery = vi.fn(async () => {}); + const compileCache = vi.fn(async () => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await handleScheduled(makeEnv(workflow), COMPILE_CACHE_SWEEP_CRON, FIRED_AT, undefined, delivery, compileCache); + expect(compileCache).toHaveBeenCalledTimes(1); + expect(delivery).not.toHaveBeenCalled(); + expect(workflow.calls).toHaveLength(0); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + it("leaves the sweep alone on every other cron", async () => { const sweep = vi.fn(async () => {}); await handleScheduled(makeEnv(makeFakeWorkflow()), "0 14 * * *", FIRED_AT, undefined, sweep); diff --git a/apps/dispatcher/src/routes/scheduled.ts b/apps/dispatcher/src/routes/scheduled.ts index 0ccff9e5..3ba4ba11 100644 --- a/apps/dispatcher/src/routes/scheduled.ts +++ b/apps/dispatcher/src/routes/scheduled.ts @@ -55,6 +55,7 @@ import { type UndeliveredRow, } from "../delivery-sweep"; import { toInstanceId } from "../instance-id"; +import { COMPILE_CACHE_SWEEP_CRON, resolveCompileCacheLimits, sweepCompileCache } from "./compile-cache"; import { type ScheduleMatch, schedulesByCron } from "../registry"; import type { Env } from "../env"; @@ -188,6 +189,24 @@ export const liveDeliverySweep = } }; +/** The compile-cache sweep (routes/compile-cache.ts); logs and swallows its own failure. */ +export const liveCompileCacheSweep = + (env: Env): RunDeliverySweep => + async () => { + try { + const result = await sweepCompileCache(env.RUNS_STORAGE, resolveCompileCacheLimits(env), Date.now()); + for (const repo of result.repos) { + console.info( + `[compile-cache-sweep] ${repo.repo} bytes=${repo.bytes} objects=${repo.objects} expired=${repo.expired} evicted=${repo.evicted}${result.truncated ? " (listing truncated)" : ""}`, + ); + } + } catch (cause) { + console.error( + `[compile-cache-sweep] tick failed: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + }; + /** Trim a long cron to fit inline in a log message. */ const fmt = (cron: string): string => (cron.length > 40 ? `${cron.slice(0, 37)}...` : cron); @@ -202,6 +221,7 @@ const fmt = (cron: string): string => (cron.length > 40 ? `${cron.slice(0, 37)}. * @param scheduledTime `controller.scheduledTime` — epoch ms. * @param resolveHead installation + branch-head lookup for runs that target a branch. * @param deliverySweep the verdict reconcile sweep, run on `DELIVERY_SWEEP_CRON`. + * @param compileCacheSweep the compile-cache sweep, run on `COMPILE_CACHE_SWEEP_CRON`. */ export const handleScheduled = async ( env: Env, @@ -209,10 +229,15 @@ export const handleScheduled = async ( scheduledTime: number, resolveHead: ResolveScheduledHead = githubScheduledHead(env), deliverySweep: RunDeliverySweep = liveDeliverySweep(env), + compileCacheSweep: RunDeliverySweep = liveCompileCacheSweep(env), ): Promise => { - // The verdict reconcile sweep owns its own cron (delivery-sweep.ts). - const isSweepTick = cron === DELIVERY_SWEEP_CRON; - if (isSweepTick) await deliverySweep(); + // The verdict reconcile sweep owns its own cron (delivery-sweep.ts), and the + // compile-cache sweep its own (routes/compile-cache.ts). + const isDeliveryTick = cron === DELIVERY_SWEEP_CRON; + const isCompileCacheTick = cron === COMPILE_CACHE_SWEEP_CRON; + const isSweepTick = isDeliveryTick || isCompileCacheTick; + if (isDeliveryTick) await deliverySweep(); + if (isCompileCacheTick) await compileCacheSweep(); const matches = schedulesByCron(cron); diff --git a/apps/dispatcher/src/workflow.ts b/apps/dispatcher/src/workflow.ts index 1e9318c8..2ed121d5 100644 --- a/apps/dispatcher/src/workflow.ts +++ b/apps/dispatcher/src/workflow.ts @@ -130,6 +130,7 @@ import { buildLogsUrl, resolveLogLinkSecret, signLogToken } from "./log-token"; import { logLinksSuffix, startedSummary } from "./summary-links"; import { resolveMailboxLinkSecret, signMailboxToken } from "./mailbox-token"; import { resolveAgentProxySecret, signAgentToken } from "./agent-token"; +import { compileCacheOverrides, resolveCompileCacheSecret } from "./compile-cache-token"; import { deliverSlackVerdict, emitSlackNotice, @@ -569,6 +570,26 @@ export class RunWorkflow extends WorkflowEntrypoint { } } + // Shared compile cache (routes/compile-cache.ts): a run whose definition + // asks for it gets the cache endpoint and a token scoped to this + // repository and execution. The token derives from the instance's + // creation time, so every invocation mints the same one and a check + // command's fingerprint, which covers its env, holds across replays. + const cacheQueueAgeSec = + typeof run.limits.admissionMaxQueueAgeSec === "function" + ? run.limits.admissionMaxQueueAgeSec(input) + : (run.limits.admissionMaxQueueAgeSec ?? ADMISSION_MAX_QUEUE_AGE_MS / 1000); + const cacheOverrides = await compileCacheOverrides({ + wanted: run.compileCache?.(input) === true, + repo: payload.github.repo, + executionId: payload.executionId, + createdAtMs: event.timestamp.getTime(), + ceilingSec: (Number.isFinite(cacheQueueAgeSec) ? cacheQueueAgeSec : 0) + run.limits.maxDurationSec, + publicOrigin, + secret: resolveCompileCacheSecret(this.env), + }); + if (cacheOverrides !== undefined) configOverrides = { ...configOverrides, ...cacheOverrides }; + // Per-repo container transport pin. It outranks the Worker-wide // `SANDBOX_TRANSPORT` in either direction, and with neither set containers // run on `rpc`, the one transport that streams a cache archive in diff --git a/infra/Dockerfile.sandbox-demo b/infra/Dockerfile.sandbox-demo index aa57f526..f78e5da7 100644 --- a/infra/Dockerfile.sandbox-demo +++ b/infra/Dockerfile.sandbox-demo @@ -192,6 +192,36 @@ RUN if [ "$WITH_RUST" = "true" ]; then \ echo "WITH_RUST=false — no prebuilt DuckDB baked. Lean image."; \ fi +# ── sccache (gated by the WITH_RUST build arg) ────────────────────────────── +# +# A run dispatched with `compileCache` (runs/check.ts) compiles through +# sccache against the dispatcher's compile-cache route +# (apps/dispatcher/src/routes/compile-cache.ts). The static musl release for +# the build architecture is pinned by SHA-256 and only the binary is +# extracted. Move SCCACHE_VERSION and both digests together; the route serves +# the WebDAV methods this release's OpenDAL backend issues. +ARG SCCACHE_VERSION=v0.18.0 +RUN if [ "$WITH_RUST" = "true" ]; then \ + set -eux; \ + arch="$(uname -m)"; \ + case "$arch" in \ + x86_64) sha256=45f1447fbe231e3037bde351ef70677dd212216c8d62ae7ca409fecc4d6acc89 ;; \ + aarch64) sha256=2b3284d5da3b46a47dc4229e75bb7b88ac4aa99c8d754fb7d2f84997e5a4354a ;; \ + *) echo "sccache: unsupported architecture $arch" >&2; exit 1 ;; \ + esac; \ + name="sccache-${SCCACHE_VERSION}-${arch}-unknown-linux-musl"; \ + archive="/tmp/${name}.tar.gz"; \ + curl -fsSL -o "$archive" \ + "https://github.com/mozilla/sccache/releases/download/${SCCACHE_VERSION}/${name}.tar.gz"; \ + echo "${sha256} ${archive}" | sha256sum -c -; \ + tar -xzf "$archive" --strip-components=1 -C /usr/local/bin "${name}/sccache"; \ + rm -f "$archive"; \ + chmod 0755 /usr/local/bin/sccache; \ + sccache --version; \ + else \ + echo "WITH_RUST=false — no sccache baked. Lean image."; \ + fi + # ── demo-agent layer ──────────────────────────────────────────────────────── # # The `product-demo` run's bundled CLI (record start/stop, play, gif, diff --git a/packages/core/src/define-run.ts b/packages/core/src/define-run.ts index 690499d6..111375f0 100644 --- a/packages/core/src/define-run.ts +++ b/packages/core/src/define-run.ts @@ -220,7 +220,17 @@ export type RunSpec = { * waiting on them leaves this unset, and its children outlive it. */ readonly joinsChildren?: boolean; - readonly run: (input: I) => Effect.Effect; + /** + * Whether this execution compiles through the shared compile cache. When it + * answers `true`, the dispatcher mints a token scoped to the execution's + * repository and execution, valid until the execution's queue age plus its + * `maxDurationSec` from creation, and injects it with the cache endpoint + * under `COMPILE_CACHE_TOKEN_KEY` / `COMPILE_CACHE_ENDPOINT_KEY` + * (primitives/compile-cache.ts). A deploy without a public origin or + * `HMAC_SECRET` injects neither, and the run compiles without the cache. + */ + readonly compileCache?: (input: I) => boolean; + readonly run:(input: I) => Effect.Effect; }; /** diff --git a/packages/core/src/primitives/compile-cache.test.ts b/packages/core/src/primitives/compile-cache.test.ts new file mode 100644 index 00000000..315ed3c9 --- /dev/null +++ b/packages/core/src/primitives/compile-cache.test.ts @@ -0,0 +1,104 @@ +// `compileCacheCommand` runs under `/bin/sh` against stub `rustc` and `sccache` +// binaries, so the fallback and the statistics are the shell's real behavior. + +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { compileCacheCommand, compileCacheEnv } from "./compile-cache"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +const RUSTC = `#!/bin/sh +printf 'rustc 1.90.0 (1159e78c4 2025-09-14)\\nbinary: rustc\\nhost: x86_64-unknown-linux-gnu\\nrelease: 1.90.0\\n' +`; + +/** A stub `sccache` that logs each invocation and its cache env; `--start-server` exits `startExit`. */ +const sccacheStub = (log: string, startExit: number): string => `#!/bin/sh +echo "$* prefix=$SCCACHE_WEBDAV_KEY_PREFIX incr=$CARGO_INCREMENTAL ignore=$SCCACHE_IGNORE_SERVER_IO_ERROR idle=$SCCACHE_IDLE_TIMEOUT" >> ${log} +case "$1" in + --start-server) exit ${startExit} ;; + --show-stats) echo "Compile requests 12" ;; +esac +exit 0 +`; + +const sandbox = (opts: { readonly sccache?: { readonly startExit: number } }) => { + const dir = mkdtempSync(join(tmpdir(), "compile-cache-")); + dirs.push(dir); + const log = join(dir, "sccache.log"); + writeFileSync(log, ""); + const rustc = join(dir, "rustc"); + writeFileSync(rustc, RUSTC); + chmodSync(rustc, 0o755); + if (opts.sccache !== undefined) { + const sccache = join(dir, "sccache"); + writeFileSync(sccache, sccacheStub(log, opts.sccache.startExit)); + chmodSync(sccache, 0o755); + } + const run = (command: string, env: Record = compileCacheEnv({ endpoint: "https://d.example/v1/compile-cache", token: "t" })) => + spawnSync("/bin/sh", ["-c", compileCacheCommand(command, "owner/name")], { + env: { PATH: `${dir}:/usr/bin:/bin`, ...env }, + encoding: "utf8", + }); + return { run, log: () => readFileSync(log, "utf8") }; +}; + +describe("compileCacheCommand", () => { + it("runs the command under sccache with the toolchain's prefix and prints the statistics", () => { + const box = sandbox({ sccache: { startExit: 0 } }); + const result = box.run('echo "wrapper=$RUSTC_WRAPPER incr=$CARGO_INCREMENTAL"; exit 3'); + expect(result.status).toBe(3); + expect(result.stdout).toContain("wrapper=sccache incr=0"); + expect(result.stdout).toContain("--- sccache --show-stats ---\nCompile requests 12"); + expect(box.log()).toContain( + "--start-server prefix=owner/name/1.90.0/x86_64-unknown-linux-gnu incr=0 ignore=1 idle=0", + ); + expect(box.log()).toContain("--stop-server"); + }); + + it("returns to print the statistics after a command that execs", () => { + const box = sandbox({ sccache: { startExit: 0 } }); + const result = box.run("exec sh -c 'exit 0'"); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Compile requests 12"); + }); + + it("compiles locally when the sccache server does not start", () => { + const box = sandbox({ sccache: { startExit: 2 } }); + const result = box.run('echo "wrapper=${RUSTC_WRAPPER:-none}"'); + expect(result.status).toBe(0); + expect(result.stdout).toContain("wrapper=none"); + expect(result.stdout).not.toContain("--show-stats"); + expect(result.stderr).toContain("sccache server did not start; compiling locally"); + }); + + it("compiles locally when sccache is not installed", () => { + const box = sandbox({}); + const result = box.run('echo "wrapper=${RUSTC_WRAPPER:-none}"'); + expect(result.status).toBe(0); + expect(result.stdout).toContain("wrapper=none"); + }); + + it("compiles locally when no endpoint or token reached the command", () => { + const box = sandbox({ sccache: { startExit: 0 } }); + const result = box.run('echo "wrapper=${RUSTC_WRAPPER:-none}"', {}); + expect(result.stdout).toContain("wrapper=none"); + expect(box.log()).toBe(""); + }); + + it("refuses a repository outside the owner/name charset", () => { + expect(() => compileCacheCommand("true", 'owner/name"; rm -rf /')).toThrow(/invalid repo/); + }); + + it("hands sccache the endpoint and token", () => { + expect(compileCacheEnv({ endpoint: "https://d.example/v1/compile-cache", token: "t" })).toEqual({ + SCCACHE_WEBDAV_ENDPOINT: "https://d.example/v1/compile-cache", + SCCACHE_WEBDAV_TOKEN: "t", + }); + }); +}); diff --git a/packages/core/src/primitives/compile-cache.ts b/packages/core/src/primitives/compile-cache.ts new file mode 100644 index 00000000..a2e01530 --- /dev/null +++ b/packages/core/src/primitives/compile-cache.ts @@ -0,0 +1,89 @@ +// Shared compile cache — the in-container half. +// +// A run whose definition declares `compileCache` gets two config values the +// dispatcher injects when the run starts (apps/dispatcher/src/workflow.ts): +// the WebDAV endpoint of the dispatcher's compile-cache route and a token +// scoped to the run's repository and execution. `compileCacheCommand` wraps a +// shell command so every `rustc` it invokes goes through sccache against that +// endpoint, and prints sccache's statistics into the command's log when the +// command ends. +// +// A cache outage never fails the command. sccache's server checks its storage +// when it starts and refuses to start when the storage read fails; the wrapper +// starts the server itself before exporting `RUSTC_WRAPPER`, so an unreachable +// cache, a rejected token or a missing binary leaves `RUSTC_WRAPPER` unset and +// rustc compiles locally. Once the server is up, sccache reads an unreadable +// entry as a miss and logs a failed write without failing the compile, and +// `SCCACHE_IGNORE_SERVER_IO_ERROR=1` compiles locally when the client loses +// its server mid-compile. +// +// Spec: specs/adr/0007-cost-efficient-execution.md § Shared compile cache. + +/** Config key carrying the WebDAV endpoint of the compile-cache route. */ +export const COMPILE_CACHE_ENDPOINT_KEY = "compile-cache.endpoint"; +/** Config key carrying the run's compile-cache token. */ +export const COMPILE_CACHE_TOKEN_KEY = "compile-cache.token"; + +/** The environment the wrapped command needs from the run. */ +export type CompileCacheAccess = { + readonly endpoint: string; + readonly token: string; +}; + +/** `owner/name` — the only characters the key prefix may carry from the repo. */ +const REPO_SLUG = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/; + +/** + * The env the wrapped command reads: endpoint and token for sccache's WebDAV + * backend. The key prefix is computed inside the container from the toolchain + * the checkout selects. + */ +export const compileCacheEnv = (access: CompileCacheAccess): Record => ({ + SCCACHE_WEBDAV_ENDPOINT: access.endpoint, + SCCACHE_WEBDAV_TOKEN: access.token, +}); + +/** + * Wrap `command` so its Rust compiles go through sccache. + * + * - `SCCACHE_WEBDAV_KEY_PREFIX` is `//`, + * read from `rustc -vV` in the checkout, so a `rust-toolchain.toml` pin + * selects its own prefix. + * - `CARGO_INCREMENTAL=0`: sccache exits 1 on any compile run with + * incremental compilation on. + * - `SCCACHE_IDLE_TIMEOUT=0` keeps one server, and its statistics, for the + * whole command. + * - The command runs in a subshell, so an `exec` inside it returns here and + * the statistics still print; the wrapper exits with the command's status. + */ +export const compileCacheCommand = (command: string, repo: string): string => { + if (!REPO_SLUG.test(repo)) throw new Error(`compileCacheCommand: invalid repo ${JSON.stringify(repo)}`); + return [ + "fd_cc_on=0", + "if command -v sccache >/dev/null 2>&1 && command -v rustc >/dev/null 2>&1 && [ -n \"${SCCACHE_WEBDAV_ENDPOINT:-}\" ] && [ -n \"${SCCACHE_WEBDAV_TOKEN:-}\" ]; then", + " fd_cc_vv=\"$(rustc -vV 2>/dev/null)\"", + " fd_cc_release=\"$(printf '%s\\n' \"$fd_cc_vv\" | sed -n 's/^release: //p')\"", + " fd_cc_host=\"$(printf '%s\\n' \"$fd_cc_vv\" | sed -n 's/^host: //p')\"", + " if [ -n \"$fd_cc_release\" ] && [ -n \"$fd_cc_host\" ]; then", + ` export SCCACHE_WEBDAV_KEY_PREFIX="${repo}/$fd_cc_release/$fd_cc_host"`, + " export CARGO_INCREMENTAL=0 SCCACHE_IDLE_TIMEOUT=0 SCCACHE_IGNORE_SERVER_IO_ERROR=1", + " if sccache --start-server >/dev/null 2>&1; then", + " export RUSTC_WRAPPER=sccache", + " fd_cc_on=1", + " else", + " echo 'compile cache: sccache server did not start; compiling locally' >&2", + " fi", + " fi", + "else", + " echo 'compile cache: sccache, rustc or cache access unavailable; compiling locally' >&2", + "fi", + `( ${command} )`, + "fd_cc_status=$?", + "if [ \"$fd_cc_on\" = 1 ]; then", + " echo '--- sccache --show-stats ---'", + " sccache --show-stats || true", + " sccache --stop-server >/dev/null 2>&1 || true", + "fi", + "exit $fd_cc_status", + ].join("\n"); +}; diff --git a/packages/core/src/primitives/index.ts b/packages/core/src/primitives/index.ts index f12abb67..944ec36b 100644 --- a/packages/core/src/primitives/index.ts +++ b/packages/core/src/primitives/index.ts @@ -34,6 +34,13 @@ export { type CacheEntry, type PrebuiltBinary, } from "./prebuilt-binary"; +export { + compileCacheCommand, + compileCacheEnv, + COMPILE_CACHE_ENDPOINT_KEY, + COMPILE_CACHE_TOKEN_KEY, + type CompileCacheAccess, +} from "./compile-cache"; export { sharded, type Shard } from "./sharded"; export { fanOut, type FanOutShard } from "./fan-out"; export { waitForAnyChildDurably, waitForChildren, waitForChildrenDurably } from "./wait-for-children"; diff --git a/runs/check.test.ts b/runs/check.test.ts index 51db7e11..443dae27 100644 --- a/runs/check.test.ts +++ b/runs/check.test.ts @@ -546,6 +546,58 @@ describe("check", () => { }, ); + describe("compile cache", () => { + const ENDPOINT = "https://dispatch.example/v1/compile-cache"; + const TOKEN = "cc1.1700000000.ZXhlYw.mac"; + + it.effect("wraps the command in sccache and hands it the injected endpoint and token", () => { + const { layer, handles } = makeCFRuntimeTest({ + sandboxProgram: { [CHECK_CMD]: { exitCode: 0, stdout: `token=${TOKEN}` } }, + config: { "compile-cache.endpoint": ENDPOINT, "compile-cache.token": TOKEN }, + }); + return Effect.gen(function* () { + const result = yield* check.run({ ...baseInput, compileCache: true, env: { SCCACHE_WEBDAV_TOKEN: "spoofed" } }); + expect(result.exitCode).toBe(0); + const exec = handles.sandbox.execs.find((e) => e.command.includes(CHECK_CMD)); + expect(exec?.env).toEqual({ SCCACHE_WEBDAV_ENDPOINT: ENDPOINT, SCCACHE_WEBDAV_TOKEN: TOKEN }); + expect(exec?.command).toContain("export RUSTC_WRAPPER=sccache"); + expect(exec?.command).toContain('export SCCACHE_WEBDAV_KEY_PREFIX="owner/name/$fd_cc_release/$fd_cc_host"'); + expect(exec?.command).toContain("CARGO_INCREMENTAL=0"); + expect(exec?.command).toContain("SCCACHE_IGNORE_SERVER_IO_ERROR=1"); + expect(exec?.command).toContain("sccache --show-stats"); + expect(exec?.command).toContain(`( ${CHECK_CMD} )`); + expect(exec?.stdout ?? "").not.toContain(TOKEN); + }).pipe(Effect.provide(layer)); + }); + + it.effect("runs the command unwrapped when no token was injected", () => { + const { layer, handles } = makeCFRuntimeTest({ sandboxProgram: { [CHECK_CMD]: { exitCode: 0 } } }); + return Effect.gen(function* () { + yield* check.run({ ...baseInput, compileCache: true }); + const exec = handles.sandbox.execs.find((e) => e.command.includes(CHECK_CMD)); + expect(exec?.command).toBe(CHECK_CMD); + expect(exec?.env ?? {}).toEqual({}); + }).pipe(Effect.provide(layer)); + }); + + it.effect("ignores injected access when the dispatch does not ask for the cache", () => { + const { layer, handles } = makeCFRuntimeTest({ + sandboxProgram: { [CHECK_CMD]: { exitCode: 0 } }, + config: { "compile-cache.endpoint": ENDPOINT, "compile-cache.token": TOKEN }, + }); + return Effect.gen(function* () { + yield* check.run(baseInput); + const exec = handles.sandbox.execs.find((e) => e.command.includes(CHECK_CMD)); + expect(exec?.command).toBe(CHECK_CMD); + }).pipe(Effect.provide(layer)); + }); + + it("asks the dispatcher for a token only when the input asks for the cache", () => { + expect(check.compileCache?.({ ...baseInput, compileCache: true } as never)).toBe(true); + expect(check.compileCache?.(baseInput as never)).toBe(false); + }); + }); + it.effect( "secrets — a named-but-unset secret fails with SecretsMissing before checkout or exec", () => { diff --git a/runs/check.ts b/runs/check.ts index 346257d5..27ec2b19 100644 --- a/runs/check.ts +++ b/runs/check.ts @@ -56,6 +56,10 @@ import { step, } from "@fractalboxdev/flare-dispatch-core"; import { + compileCacheCommand, + compileCacheEnv, + COMPILE_CACHE_ENDPOINT_KEY, + COMPILE_CACHE_TOKEN_KEY, durableExec, ensureWorkspace, isRestorableEntry, @@ -124,6 +128,13 @@ const CheckInput = Schema.Struct({ }).pipe(Schema.filter(isRestorableEntry)), ), ), + /** + * Compile Rust through the shared compile cache: the command runs under + * `compileCacheCommand` (sccache as `RUSTC_WRAPPER`, statistics printed at + * the end) with the endpoint and token the dispatcher injected. Without an + * injected token the command runs unwrapped. + */ + compileCache: Schema.optional(Schema.Boolean), /** * Non-sensitive env only. Dispatch inputs are persisted (executions row + * Workflow params) — never put credentials here. Same contract as @@ -241,7 +252,8 @@ export const check = defineRun({ // unlabelled dispatch resolves the same key, names the same check-run, and // gets the same instance id as under 1.0.0. // 1.2.0 — additive: `restore`. A dispatch without it runs as under 1.1.0. - version: "1.2.0", + // 1.3.0 — additive: `compileCache`. A dispatch without it runs as under 1.2.0. + version: "1.3.0", inputs: CheckInput, outputs: CheckOutput, @@ -284,6 +296,8 @@ export const check = defineRun({ optIn: ({ input }) => input.command !== undefined ? [] : commandKeys(input.repo, input.checkLabel), + compileCache: (input) => input.compileCache === true, + limits: { // Run-body wall-time ceiling, enforced by the dispatcher: one full 2700 s // exec (the per-stage ceiling a fan-out gate passes) plus checkout, upload @@ -356,6 +370,21 @@ export const check = defineRun({ required: true, }); + // Compile-cache access, read INLINE like the secrets above: the token is + // a credential and stays out of checkpointed state. The dispatcher mints + // the same token on every invocation, so the command's fingerprint holds. + const cacheEndpoint = input.compileCache === true ? yield* config.get(COMPILE_CACHE_ENDPOINT_KEY) : undefined; + const cacheToken = input.compileCache === true ? yield* config.get(COMPILE_CACHE_TOKEN_KEY) : undefined; + const cache = + cacheEndpoint !== undefined && cacheEndpoint !== "" && cacheToken !== undefined && cacheToken !== "" + ? { endpoint: cacheEndpoint, token: cacheToken } + : undefined; + if (input.compileCache === true && cache === undefined) { + yield* io.log("warn", "check: compile cache requested but not injected — compiling without it"); + } + const execCommand = cache === undefined ? command : compileCacheCommand(command, input.repo); + const cacheEnv = cache === undefined ? {} : compileCacheEnv(cache); + // checkout — container + clone at the SHA (+ optional cached install). const { container, dir } = yield* step("checkout", () => workspace({ @@ -394,12 +423,13 @@ export const check = defineRun({ ), exec: (ws) => ({ cwd: ws.dir, - command, - // Per-dispatch `env` wins over a same-named Worker secret. - env: { ...secretEnv, ...input.env }, + command: execCommand, + // Per-dispatch `env` wins over a same-named Worker secret; the + // compile-cache endpoint and token win over both. + env: { ...secretEnv, ...input.env, ...cacheEnv }, // Defense in depth (header): scrub secret VALUES from the captured // log before it's persisted, in case the command echoes its env. - redactValues: Object.values(secretEnv), + redactValues: [...Object.values(secretEnv), ...(cache === undefined ? [] : [cache.token])], timeoutSec: timeoutSec ?? DEFAULT_TIMEOUT_SEC, }), }); diff --git a/runs/contextful-gate.test.ts b/runs/contextful-gate.test.ts index d22805fb..4851ee07 100644 --- a/runs/contextful-gate.test.ts +++ b/runs/contextful-gate.test.ts @@ -321,6 +321,7 @@ describe("contextful-gate", () => { expect.objectContaining({ checkLabel: stage, install: true, + compileCache: true, restore, command: `if [ -x ${BIN} ] && ${BIN} --help >/dev/null 2>&1; then exec ${BIN} gate --predecessors --stage ${stage} --base ${BASE_SHA}; else exec ${CARGO_RUN} gate --predecessors --stage ${stage} --base ${BASE_SHA}; fi`, }), @@ -337,6 +338,7 @@ describe("contextful-gate", () => { const part = spawn.input as { checkLabel: string; command: string; install: boolean; restore?: unknown }; expect(part.command).toBe(`${CARGO_RUN} gate --predecessors --stage ${part.checkLabel} --base ${BASE_SHA}`); expect(part.install).toBe(true); + expect((part as { compileCache?: boolean }).compileCache).toBe(true); expect(part.restore).toBeUndefined(); } }).pipe(Effect.provide(layer)); diff --git a/runs/contextful-gate.ts b/runs/contextful-gate.ts index 3b62e221..47311493 100644 --- a/runs/contextful-gate.ts +++ b/runs/contextful-gate.ts @@ -336,10 +336,11 @@ export const planStages = ( export const contextfulGate = defineRun({ name: "contextful-gate", + // 1.4.0 — every part compiles through the shared compile cache. // 1.3.0 — two-tier gate: `gate.lightLabels:` splits light parts (pull // requests) from heavy parts (pushes to the default branch). // 1.2.2 — a reopen ranks above the close marker of its own second. - version: "1.3.0", + version: "1.4.0", inputs: Input, outputs: Output, triggers: [ @@ -633,6 +634,10 @@ export const contextfulGate = defineRun({ // the stage fetches its own crates is not decidable here // (specs/adr/0007-cost-efficient-execution.md § Fan-out parts). install: true, + // Every part compiles the workspace; the shared compile + // cache (sccache over the dispatcher's compile-cache route) + // replaces each part's cold compile with cache reads. + compileCache: true, ...(prebuilt === null ? {} : { restore: [{ key: prebuilt.key, base: prebuilt.base, paths: prebuilt.paths }] }), diff --git a/specs/adr/0007-cost-efficient-execution.md b/specs/adr/0007-cost-efficient-execution.md index 431f102b..686bae23 100644 --- a/specs/adr/0007-cost-efficient-execution.md +++ b/specs/adr/0007-cost-efficient-execution.md @@ -71,6 +71,26 @@ so a budget buys more checks. - The image's library version and the consumer's pinned crate version move together. +### Shared compile cache + +- A run whose definition answers `compileCache` with `true` compiles Rust + through sccache. Every contextful gate part does. The dispatcher's + `/v1/compile-cache` route serves sccache's WebDAV backend (GET, HEAD, PUT, + `Depth: 0` PROPFIND, MKCOL) from `RUNS_STORAGE` under + `compile-cache///`; the client's key prefix below that is + `//`. +- The dispatcher mints one token per execution: an HMAC under a key derived + from `HMAC_SECRET`, bound to the repository and execution, expiring at the + instance's creation plus its queue age and `maxDurationSec`. A token for + another repository, a forged one and an expired one are refused. +- One entry is at most `COMPILE_CACHE_MAX_OBJECT_BYTES`, one repository prefix + at most `COMPILE_CACHE_MAX_REPO_BYTES` as of the last hourly sweep, and an + entry older than `COMPILE_CACHE_TTL_DAYS` is deleted. The sweep evicts the + oldest-written entries down to 90% of the ceiling. +- A cache outage never fails a part: the wrapper starts the sccache server + first and compiles without `RUSTC_WRAPPER` when it does not start; a started + server reads an unreachable entry as a miss. + ## Rationale The receiver is the last point where skipping costs nothing: past it, a skip @@ -81,6 +101,11 @@ A content-addressed key makes a second upload pure waste, so the head check is always safe. Separating `unapplied` from `miss` keeps a broken restore visible in logs without paying for it in container time. +Thirty gate parts each compiled the same workspace cold; a content-addressed +compiler cache turns all but the first compile of each crate into a read. +sccache's WebDAV backend needs no cloud credentials in the container, so the +Worker's R2 binding stays the only storage authority. + Building a C++ engine from source in every part buys nothing a prebuilt library does not, and only the consumer can decide whether CI may link it dynamically while release builds stay static. @@ -92,6 +117,9 @@ dynamically while release builds stay static. - A broken restore still costs a cold install per part until the transport is fixed, but no longer a repeated pack and upload. - The lean image grows by the prebuilt libraries' size. +- Pull-request parts write to the repository's shared prefix, so a + same-repository pull request can place an entry a later default-branch + part reads. ## Revisit triggers diff --git a/wrangler.jsonc b/wrangler.jsonc index c219183e..512b55a1 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -48,6 +48,10 @@ // every finished execution GitHub never received (DELIVERY_SWEEP_CRON, // apps/dispatcher/src/delivery-sweep.ts). "*/10 * * * *", + // The compile-cache sweep, not a run: measures each repository's + // compile-cache prefix, deletes expired entries and evicts down to the + // ceiling (COMPILE_CACHE_SWEEP_CRON, apps/dispatcher/src/routes/compile-cache.ts). + "41 * * * *", ], }, @@ -99,6 +103,11 @@ "ADMISSION_CAP_AGENT": "8", "ADMISSION_CAP_BROWSER": "16", "ADMISSION_CAP_RELEASE": "8", + // Compile-cache bounds (apps/dispatcher/src/routes/compile-cache.ts): one + // entry, one repository prefix, and the entry age the hourly sweep deletes. + "COMPILE_CACHE_MAX_OBJECT_BYTES": "104857600", + "COMPILE_CACHE_MAX_REPO_BYTES": "21474836480", + "COMPILE_CACHE_TTL_DAYS": "14", // Opt the human-facing viewer surfaces (incl. the GitHub App install flow) // out of the Cloudflare Access gate for the initial dogfood. Configure // ACCESS_AUD and drop this to re-gate them. NOTE: the viewer surfaces still