CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
cache-action.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| abfa9ad | 1 | /** |
| 2 | * `gluecron/cache@v1` — RESTORE-only cache action (v1 scope). | |
| 3 | * | |
| 4 | * Looks up `workflow_run_cache` by (repoId, key, scope='repo') and unpacks | |
| 5 | * the stored tar archive into `ctx.workspace/<path>`. If no exact key hit, | |
| 6 | * tries each `restoreKeys` entry as a prefix match ordered by most-recently | |
| 7 | * used. Sets `cache-hit` output to 'true' or 'false'. | |
| 8 | * | |
| 9 | * TODO (v2): cache SAVE on job success. The deferred design is for the | |
| 10 | * runner to honor a `save-cache: true` flag emitted by this action and | |
| 11 | * call a `saveCache(ctx, key, path)` helper at end-of-job. Implementing | |
| 12 | * save inline here is error-prone (we'd need a post-hook) and the spec | |
| 13 | * explicitly endorsed shipping restore-only for v1. Size cap logic is | |
| 14 | * stubbed below so it's trivial to wire up later. | |
| 15 | * | |
| 16 | * Failure tolerance: any error — DB miss, tar failure, unknown scope — | |
| 17 | * results in `cache-hit: false` with exitCode 0. Caching is an optimization; | |
| 18 | * losing it must never break a pipeline. | |
| 19 | */ | |
| 20 | ||
| 21 | import { and, eq, isNull, sql } from "drizzle-orm"; | |
| 22 | import { mkdir } from "fs/promises"; | |
| 23 | import { join } from "path"; | |
| 24 | import { tmpdir } from "os"; | |
| 25 | import type { ActionHandler, ActionContext } from "../action-registry"; | |
| 26 | import { db } from "../../db"; | |
| 27 | import { workflowRunCache } from "../../db/schema"; | |
| 28 | ||
| 29 | // 100MB cap — reserved for the eventual save path. Kept here so both | |
| 30 | // halves of the action share the constant when v2 lands. | |
| 31 | export const MAX_CACHE_BYTES = 100 * 1024 * 1024; | |
| 32 | ||
| 33 | function parseInputs(ctx: ActionContext): { | |
| 34 | key: string; | |
| 35 | path: string; | |
| 36 | restoreKeys: string[]; | |
| 37 | } | null { | |
| 38 | const w = ctx.with || {}; | |
| 39 | const key = typeof w.key === "string" ? w.key : ""; | |
| 40 | const path = typeof w.path === "string" ? w.path : ""; | |
| 41 | const restoreKeysRaw = w.restoreKeys ?? w["restore-keys"]; | |
| 42 | const restoreKeys = Array.isArray(restoreKeysRaw) | |
| 43 | ? restoreKeysRaw.filter((k): k is string => typeof k === "string") | |
| 44 | : []; | |
| 45 | if (!key || !path) return null; | |
| 46 | return { key, path, restoreKeys }; | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Find a cache row for this repo matching either the exact key or any | |
| 51 | * of the prefix restoreKeys. Returns the first hit (prefix matches are | |
| 52 | * ordered by `last_accessed_at DESC`). | |
| 53 | */ | |
| 54 | async function lookupCache( | |
| 55 | repoId: string, | |
| 56 | key: string, | |
| 57 | restoreKeys: string[] | |
| 58 | ): Promise< | |
| 59 | | { hit: true; id: string; content: Buffer; matchedKey: string; exact: boolean } | |
| 60 | | { hit: false } | |
| 61 | > { | |
| 62 | // Exact match first. | |
| 63 | const exact = await db | |
| 64 | .select() | |
| 65 | .from(workflowRunCache) | |
| 66 | .where( | |
| 67 | and( | |
| 68 | eq(workflowRunCache.repositoryId, repoId), | |
| 69 | eq(workflowRunCache.cacheKey, key), | |
| 70 | eq(workflowRunCache.scope, "repo"), | |
| 71 | isNull(workflowRunCache.scopeRef) | |
| 72 | ) | |
| 73 | ) | |
| 74 | .limit(1); | |
| 75 | const exactRow = exact[0]; | |
| 76 | if (exactRow) { | |
| 77 | return { | |
| 78 | hit: true, | |
| 79 | id: exactRow.id, | |
| 80 | content: normalizeBytea(exactRow.content), | |
| 81 | matchedKey: exactRow.cacheKey, | |
| 82 | exact: true, | |
| 83 | }; | |
| 84 | } | |
| 85 | ||
| 86 | // Prefix fallbacks (LRU order). | |
| 87 | for (const prefix of restoreKeys) { | |
| 88 | if (!prefix) continue; | |
| 89 | const rows = await db | |
| 90 | .select() | |
| 91 | .from(workflowRunCache) | |
| 92 | .where( | |
| 93 | and( | |
| 94 | eq(workflowRunCache.repositoryId, repoId), | |
| 95 | eq(workflowRunCache.scope, "repo"), | |
| 96 | isNull(workflowRunCache.scopeRef), | |
| 97 | sql`${workflowRunCache.cacheKey} LIKE ${prefix + "%"}` | |
| 98 | ) | |
| 99 | ) | |
| 100 | .orderBy(sql`${workflowRunCache.lastAccessedAt} DESC`) | |
| 101 | .limit(1); | |
| 102 | const row = rows[0]; | |
| 103 | if (row) { | |
| 104 | return { | |
| 105 | hit: true, | |
| 106 | id: row.id, | |
| 107 | content: normalizeBytea(row.content), | |
| 108 | matchedKey: row.cacheKey, | |
| 109 | exact: false, | |
| 110 | }; | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | return { hit: false }; | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * `content` arrives as a Buffer, Uint8Array, or (if the driver ran through | |
| 119 | * text serialization) a base64/hex string. Normalize to Buffer. | |
| 120 | */ | |
| 121 | function normalizeBytea(raw: unknown): Buffer { | |
| 122 | if (Buffer.isBuffer(raw)) return raw; | |
| 123 | if (raw instanceof Uint8Array) return Buffer.from(raw); | |
| 124 | if (typeof raw === "string") { | |
| 125 | // Postgres `bytea` text encoding can be `\x…` hex. Handle defensively. | |
| 126 | if (raw.startsWith("\\x")) return Buffer.from(raw.slice(2), "hex"); | |
| 127 | // Otherwise assume base64 (matches workflow-artifacts convention). | |
| 128 | try { | |
| 129 | return Buffer.from(raw, "base64"); | |
| 130 | } catch { | |
| 131 | return Buffer.alloc(0); | |
| 132 | } | |
| 133 | } | |
| 134 | return Buffer.alloc(0); | |
| 135 | } | |
| 136 | ||
| 137 | async function unpackTar(content: Buffer, destDir: string): Promise<void> { | |
| 138 | await mkdir(destDir, { recursive: true }); | |
| 139 | // Write the archive to a tmp file then untar. Piping through stdin works | |
| 140 | // but a tmp file avoids subtle Bun subprocess stdin EAGAIN edge cases. | |
| 141 | const tmpPath = join( | |
| 142 | tmpdir(), | |
| 2c3ba6e | 143 | `gluecron-cache-${Date.now()}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}.tar` |
| abfa9ad | 144 | ); |
| 145 | await Bun.write(tmpPath, content); | |
| 146 | try { | |
| 147 | const proc = Bun.spawn(["tar", "-xf", tmpPath, "-C", destDir], { | |
| 148 | stdout: "pipe", | |
| 149 | stderr: "pipe", | |
| 150 | }); | |
| 151 | await proc.exited; | |
| 152 | } finally { | |
| 153 | try { | |
| 154 | await Bun.file(tmpPath).exists(); | |
| 155 | // Best-effort cleanup; ignore errors. | |
| 156 | await import("fs/promises").then((fs) => fs.unlink(tmpPath)).catch(() => {}); | |
| 157 | } catch { | |
| 158 | /* noop */ | |
| 159 | } | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | async function touchLastAccessed(id: string): Promise<void> { | |
| 164 | try { | |
| 165 | await db | |
| 166 | .update(workflowRunCache) | |
| 167 | .set({ lastAccessedAt: new Date() }) | |
| 168 | .where(eq(workflowRunCache.id, id)); | |
| 169 | } catch { | |
| 170 | // Non-fatal — LRU accuracy is best-effort. | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | export const cacheAction: ActionHandler = { | |
| 175 | name: "gluecron/cache", | |
| 176 | version: "v1", | |
| 177 | async run(ctx): Promise<import("../action-registry").ActionResult> { | |
| 178 | // Every failure path below returns exitCode 0 with cache-hit=false so | |
| 179 | // the pipeline keeps flowing. This is load-bearing behaviour. | |
| 180 | try { | |
| 181 | const inputs = parseInputs(ctx); | |
| 182 | if (!inputs) { | |
| 183 | return { | |
| 184 | exitCode: 0, | |
| 185 | outputs: { "cache-hit": "false" }, | |
| 186 | stderr: "cache: missing required inputs `key` and `path` — skipping", | |
| 187 | }; | |
| 188 | } | |
| 189 | ||
| 190 | const result = await lookupCache( | |
| 191 | ctx.repoId, | |
| 192 | inputs.key, | |
| 193 | inputs.restoreKeys | |
| 194 | ); | |
| 195 | ||
| 196 | if (!result.hit) { | |
| 197 | return { | |
| 198 | exitCode: 0, | |
| 199 | outputs: { "cache-hit": "false" }, | |
| 200 | stdout: `cache miss for key=${inputs.key}`, | |
| 201 | }; | |
| 202 | } | |
| 203 | ||
| 204 | const dest = join(ctx.workspace, inputs.path); | |
| 205 | await unpackTar(result.content, dest); | |
| 206 | await touchLastAccessed(result.id); | |
| 207 | ||
| 208 | return { | |
| 209 | exitCode: 0, | |
| 210 | outputs: { | |
| 211 | "cache-hit": result.exact ? "true" : "false", | |
| 212 | "matched-key": result.matchedKey, | |
| 213 | }, | |
| 214 | stdout: `cache ${result.exact ? "hit" : "partial hit"} for key=${inputs.key} (matched=${result.matchedKey})`, | |
| 215 | }; | |
| 216 | } catch (err) { | |
| 217 | // Fail-open: swallow and report as miss. | |
| 218 | return { | |
| 219 | exitCode: 0, | |
| 220 | outputs: { "cache-hit": "false" }, | |
| 221 | stderr: | |
| 222 | "cache error (non-fatal): " + | |
| 223 | (err instanceof Error ? err.message : String(err)), | |
| 224 | }; | |
| 225 | } | |
| 226 | }, | |
| 227 | }; |