Public: anyone can read this; the crew can change it.
deploy_pull is trimmed to recent deploys and rebuilt from git when an older one is asked for #12022
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12009-deploy-pull-is-trimmed-to-recent-deploys → main 5 commits fork 2 threads · 2 unresolved
8 files
changed +497
−22
docs/features/deploy-pull-retention.md added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | # What an older deploy carries | |
| 2 | ||
| 3 | `deploy_pull` records which merged pulls each production deploy carries. The run page, the run API (`GET /api/v1/repos/<owner>/<repo>/runs/<id>`, field `carries`) and the terminal's deploys screen all read it. The table is an index over git, so it does not need to keep every deploy forever (#12009, part of #11973). | |
| 4 | ||
| 5 | ## The rule | |
| 6 | ||
| 7 | `src/system/retention_deploys.ts` keeps the rows of: | |
| 8 | ||
| 9 | - deploys from the last `DEPLOY_PULL_KEEP_DAYS` (14) days, and | |
| 10 | - the deploy each app is running now (its newest successful production deploy), however old. | |
| 11 | ||
| 12 | Every other deploy's rows are deleted by the crew tick in batches of at most `DEPLOY_PULL_BATCH` (5,000) rows. The tick reads the candidate rows first, then deletes them by rowid in one short transaction. It runs one batch per tick while each batch comes back full, then one batch an hour after that. When it deletes something, the log says `trimmed deploy_pull`. | |
| 13 | ||
| 14 | ## Asking for an older deploy | |
| 15 | ||
| 16 | You don't have to do anything. Open `/<owner>/<repo>/runs/<id>` for a production deploy older than 14 days, or call the run API, or open the app's deploys screen in the terminal. The forge walks that deploy's commit in git (`carriedFor` / `carriedForRuns` in `src/core/carried.ts`), finds the merged pulls it contains and saves them again. You get the same list as before the trim, and later views read the saved rows. | |
| 17 | ||
| 18 | Each running forge walks a deploy at most once at a time; views of the same deploy that arrive together share one walk. If a deploy carries no pulls, it is walked once and the empty answer is remembered for as long as the forge runs, for up to 10,000 deploys. If git can't walk the commit, the forge waits a minute before trying again. Preview deploys and deploys that failed or are still running are never walked. |
src/core/carried.ts +109 −9
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | * merge commit is among them is recorded against the run. A pull merged |
| 7 | 7 | * after the deployed commit is not in the walk, so it is not recorded. The |
| 8 | 8 | * table is an index over git (`deploy_pull`): rebuilding it is running this |
| 9 | * again for the deploy. | |
| 9 | * again for the deploy. Retention (`system/retention_deploys.ts`) keeps | |
| 10 | * recent deploys' rows; `carriedFor` rebuilds an older one's when asked. | |
| 10 | 11 | */ |
| 11 | 12 | |
| 12 | 13 | import type { Database } from "bun:sqlite"; |
| @@ -44,20 +45,30 @@ function mergedAmong( | ||
| 44 | 45 | */ |
| 45 | 46 | export async function recordCarried( |
| 46 | 47 | db: Database, |
| 47 | input: { | |
| 48 | runId: string; | |
| 49 | repoId: string; | |
| 50 | commitOid: string; | |
| 51 | /** The repository's directory. */ | |
| 52 | dir: string; | |
| 53 | }, | |
| 48 | input: CarriedInput, | |
| 54 | 49 | ): Promise<number[]> { |
| 50 | return (await walkCarried(db, input)) ?? []; | |
| 51 | } | |
| 52 | ||
| 53 | interface CarriedInput { | |
| 54 | runId: string; | |
| 55 | repoId: string; | |
| 56 | commitOid: string; | |
| 57 | /** The repository's directory. */ | |
| 58 | dir: string; | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * `recordCarried`, but undefined when git could not walk the commit, so a | |
| 63 | * caller can tell a failed walk from "carries none". | |
| 64 | */ | |
| 65 | async function walkCarried(db: Database, input: CarriedInput): Promise<number[] | undefined> { | |
| 55 | 66 | if (!FULL_OID.test(input.commitOid)) return []; |
| 56 | 67 | const walked = await runLocal( |
| 57 | 68 | ["rev-list", `--max-count=${WALK_MAX}`, input.commitOid], |
| 58 | 69 | { cwd: input.dir }, |
| 59 | 70 | ); |
| 60 | if (!walked.ok) return []; | |
| 71 | if (!walked.ok) return undefined; | |
| 61 | 72 | const contained = new Set(walked.value.split("\n").filter((l) => l !== "")); |
| 62 | 73 | const carried = mergedAmong(db, input.repoId, contained); |
| 63 | 74 | const insert = db.query( |
| @@ -119,6 +130,95 @@ export function carriedBy(db: Database, runId: string): number[] { | ||
| 119 | 130 | .map((row) => row.pull_number); |
| 120 | 131 | } |
| 121 | 132 | |
| 133 | /** A deploy run as `carriedFor` reads it. */ | |
| 134 | export interface CarriedRun { | |
| 135 | readonly id: string; | |
| 136 | readonly repoId: string; | |
| 137 | readonly kind: string; | |
| 138 | readonly env: string; | |
| 139 | readonly state: string; | |
| 140 | readonly commitOid: string; | |
| 141 | } | |
| 142 | ||
| 143 | /** How long a walk git could not finish is not retried (a broken repo is not walked per view). */ | |
| 144 | export const WALK_FAILED_MS = 60_000; | |
| 145 | /** Most runs remembered as carrying none, per database; the oldest goes first. */ | |
| 146 | const EMPTY_MAX = 10_000; | |
| 147 | ||
| 148 | /** | |
| 149 | * Per database: the walks under way, by run (two views of one run share | |
| 150 | * one walk), and the runs a walk found carrying none (until when, in ms; | |
| 151 | * Infinity for a finished walk — a finished deploy's commit and the pulls | |
| 152 | * merged before it do not change, so an empty answer stays empty). | |
| 153 | */ | |
| 154 | const walking = new WeakMap<Database, Map<string, Promise<number[]>>>(); | |
| 155 | const carriesNone = new WeakMap<Database, Map<string, number>>(); | |
| 156 | ||
| 157 | const rebuildable = (run: CarriedRun): boolean => | |
| 158 | run.kind === "deploy" && run.env === "production" && run.state === "ok"; | |
| 159 | ||
| 160 | /** | |
| 161 | * The rebuild of one trimmed deploy: walked at most once at a time, and not | |
| 162 | * again once it found none. | |
| 163 | */ | |
| 164 | function rebuild(db: Database, run: CarriedRun, dir: string): Promise<number[]> { | |
| 165 | const none = carriesNone.get(db) ?? new Map<string, number>(); | |
| 166 | carriesNone.set(db, none); | |
| 167 | const until = none.get(run.id); | |
| 168 | if (until !== undefined) { | |
| 169 | if (until > performance.now()) return Promise.resolve([]); | |
| 170 | none.delete(run.id); | |
| 171 | } | |
| 172 | const flights = walking.get(db) ?? new Map<string, Promise<number[]>>(); | |
| 173 | walking.set(db, flights); | |
| 174 | const inFlight = flights.get(run.id); | |
| 175 | if (inFlight !== undefined) return inFlight; | |
| 176 | const walk = walkCarried(db, { runId: run.id, repoId: run.repoId, commitOid: run.commitOid, dir }) | |
| 177 | .then((found) => { | |
| 178 | if (found === undefined || found.length === 0) { | |
| 179 | none.set(run.id, found === undefined ? performance.now() + WALK_FAILED_MS : Infinity); | |
| 180 | if (none.size > EMPTY_MAX) none.delete(none.keys().next().value!); | |
| 181 | } | |
| 182 | return found ?? []; | |
| 183 | }) | |
| 184 | .finally(() => flights.delete(run.id)); | |
| 185 | flights.set(run.id, walk); | |
| 186 | return walk; | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * The pull numbers a deploy run carries, as pages read them: the recorded | |
| 191 | * rows, or — for a finished production deploy with none, as retention | |
| 192 | * leaves an older one (`system/retention_deploys.ts`) — the record rebuilt | |
| 193 | * from git now and kept. A deploy that carries none is walked once per | |
| 194 | * process, and concurrent views of one run share a walk. `dir` is the | |
| 195 | * repository's directory. | |
| 196 | */ | |
| 197 | export async function carriedFor(db: Database, run: CarriedRun, dir: string): Promise<number[]> { | |
| 198 | const recorded = carriedBy(db, run.id); | |
| 199 | if (recorded.length > 0 || !rebuildable(run)) return recorded; | |
| 200 | return rebuild(db, run, dir); | |
| 201 | } | |
| 202 | ||
| 203 | /** | |
| 204 | * `carriedByRuns` for a list of deploys, with each trimmed one rebuilt as | |
| 205 | * `carriedFor` does (one at a time, the same single walk per run). Runs that | |
| 206 | * carry none are absent. | |
| 207 | */ | |
| 208 | export async function carriedForRuns( | |
| 209 | db: Database, | |
| 210 | runs: readonly CarriedRun[], | |
| 211 | dir: string, | |
| 212 | ): Promise<Map<string, number[]>> { | |
| 213 | const found = carriedByRuns(db, runs.map((r) => r.id)); | |
| 214 | for (const run of runs) { | |
| 215 | if (found.has(run.id) || !rebuildable(run)) continue; | |
| 216 | const rebuilt = await rebuild(db, run, dir); | |
| 217 | if (rebuilt.length > 0) found.set(run.id, rebuilt); | |
| 218 | } | |
| 219 | return found; | |
| 220 | } | |
| 221 | ||
| 122 | 222 | /** The pull numbers each of these runs carries, in one read; runs that carry none are absent. */ |
| 123 | 223 | export function carriedByRuns( |
| 124 | 224 | db: Database, |
src/crew/dispatch.ts +6
| @@ -41,6 +41,7 @@ import { | ||
| 41 | 41 | import { closesFromBody, getPull, pullDir, type Pull } from "../core/pulls.ts"; |
| 42 | 42 | import { claimedClones, sweepOrphanClones } from "./orphans.ts"; |
| 43 | 43 | import { pruneRunFiles } from "../runs/prune.ts"; |
| 44 | import { deployRetention } from "../system/retention_deploys.ts"; | |
| 44 | 45 | import { crewRoot, isClaimed } from "./workdir.ts"; |
| 45 | 46 | import { resolveOpen } from "../core/decisions.ts"; |
| 46 | 47 | import { accountActive } from "../core/accounts.ts"; |
| @@ -272,6 +273,8 @@ export async function startCrew( | ||
| 272 | 273 | // Repositories that run on their own take a turn once a minute, on the |
| 273 | 274 | // same clock as the pump, so a pick becomes a run on the next tick. |
| 274 | 275 | let lastAutonomy = 0; |
| 276 | // deploy_pull keeps recent deploys only (#12009): a capped batch a tick while rows remain. | |
| 277 | const trimDeploys = deployRetention(app.db); | |
| 275 | 278 | // The sweeps a tick started and has not seen settle: `stop()` waits for |
| 276 | 279 | // them, so none reads `app.db` after its caller closed it (#9977). |
| 277 | 280 | const sweeps = new Set<Promise<unknown>>(); |
| @@ -297,6 +300,9 @@ export async function startCrew( | ||
| 297 | 300 | )); |
| 298 | 301 | } |
| 299 | 302 | pump(app, ready, inFlight); |
| 303 | const trimmed = trimDeploys(); | |
| 304 | if (trimmed !== undefined && trimmed.deleted > 0) | |
| 305 | log.info("trimmed deploy_pull", { count: trimmed.deleted, more: trimmed.more }); | |
| 300 | 306 | }); |
| 301 | 307 | } catch (error) { |
| 302 | 308 | log.error("crew tick failed", { error: String(error) }); |
src/http/api.ts +10 −8
| @@ -289,7 +289,7 @@ import { readReturns, reportSpend, RETURNS_DAYS } from "../actions/returns.ts"; | ||
| 289 | 289 | import { COLUMNS, readSort, sortRows } from "../fleet/columns.ts"; |
| 290 | 290 | import { viewFleet } from "../fleet/rows.ts"; |
| 291 | 291 | import { fleetSelection, readFleetGraph } from "../actions/fleet-graph.ts"; |
| 292 | import { carriedBy } from "../core/carried.ts"; | |
| 292 | import { carriedFor } from "../core/carried.ts"; | |
| 293 | 293 | import { readGateLog } from "../checks/gate_log.ts"; |
| 294 | 294 | import { recentGates } from "../checks/gate_result.ts"; |
| 295 | 295 | import { readRunLog, subscribeRunLog } from "../runs/log.ts"; |
| @@ -4049,16 +4049,18 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4049 | 4049 | return timePhase(ctx.request, "render", () => json(paged(runs, limit, (r) => r.id), 200, headers)); |
| 4050 | 4050 | }); |
| 4051 | 4051 | |
| 4052 | router.add("GET", "/api/v1/repos/:owner/:name/runs/:id", (ctx) => { | |
| 4052 | router.add("GET", "/api/v1/repos/:owner/:name/runs/:id", async (ctx) => { | |
| 4053 | 4053 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 4054 | 4054 | const repo = loadRepo(ctx); |
| 4055 | 4055 | if (!repo) return notFound(); |
| 4056 | const found = timePhase(ctx.request, "run", () => { | |
| 4057 | const run = getRepoRun(ctx.app, ctx.auth.user, repo, ctx.params.id ?? ""); | |
| 4058 | // The merged pulls a finished production deploy carries (`core/carried.ts`). | |
| 4059 | return run.ok ? { ...run.value, carries: carriedBy(ctx.app.db, run.value.id) } : null; | |
| 4060 | }); | |
| 4061 | if (!found) return notFound(); | |
| 4056 | const run = timePhase(ctx.request, "run", () => | |
| 4057 | getRepoRun(ctx.app, ctx.auth.user, repo, ctx.params.id ?? ""), | |
| 4058 | ); | |
| 4059 | if (!run.ok) return notFound(); | |
| 4060 | // The merged pulls a finished production deploy carries (`core/carried.ts`), | |
| 4061 | // rebuilt from git when retention has trimmed an older deploy's rows. | |
| 4062 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); | |
| 4063 | const found = { ...run.value, carries: await carriedFor(ctx.app.db, run.value, dir) }; | |
| 4062 | 4064 | return timePhase(ctx.request, "render", () => json(found)); |
| 4063 | 4065 | }); |
| 4064 | 4066 |
src/system/retention_deploys.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | /** | |
| 2 | * Retention for `deploy_pull` (#12009, part of #11973): the table is an | |
| 3 | * index over git (`core/carried.ts`), so it keeps only what pages read | |
| 4 | * often — the pulls of deploys from the last `DEPLOY_PULL_KEEP_DAYS` days, | |
| 5 | * and of the deploy each app is running now (live, or sleeping and woken | |
| 6 | * on it: the newest successful production deploy of its repository). An | |
| 7 | * older deploy's list is rebuilt from git when it is asked for | |
| 8 | * (`carriedFor`). | |
| 9 | * | |
| 10 | * The rule runs in capped batches, one per crew tick while rows remain: | |
| 11 | * the candidates are read first (a read holds no write lock under WAL), | |
| 12 | * then deleted by rowid in one short transaction, so no batch holds the | |
| 13 | * write lock for long. Rows of a deleted run are not swept here: finding | |
| 14 | * them is a scan of the whole table. | |
| 15 | */ | |
| 16 | ||
| 17 | import type { Database } from "bun:sqlite"; | |
| 18 | import { transact } from "../kernel/db.ts"; | |
| 19 | ||
| 20 | /** Deploys this recent keep their rows. Above `PILLAR_DAYS` (7), the window ask-to-live reads. */ | |
| 21 | export const DEPLOY_PULL_KEEP_DAYS = 14; | |
| 22 | /** Rows one batch deletes at most. */ | |
| 23 | export const DEPLOY_PULL_BATCH = 5_000; | |
| 24 | ||
| 25 | export interface Pruned { | |
| 26 | readonly deleted: number; | |
| 27 | /** A full batch went: another may follow on the next tick. */ | |
| 28 | readonly more: boolean; | |
| 29 | } | |
| 30 | ||
| 31 | /** Delete one batch of at most `rowCap` rows of deploys past the rule. */ | |
| 32 | export function pruneDeploys( | |
| 33 | db: Database, | |
| 34 | now: Date = new Date(), | |
| 35 | rowCap: number = DEPLOY_PULL_BATCH, | |
| 36 | ): Pruned { | |
| 37 | const cap = Math.max(0, Math.floor(rowCap)); | |
| 38 | if (cap === 0) return { deleted: 0, more: false }; | |
| 39 | const cutoff = new Date(now.getTime() - DEPLOY_PULL_KEEP_DAYS * 86_400_000).toISOString(); | |
| 40 | const rowids = db | |
| 41 | .query<{ rid: number }, [string, number]>( | |
| 42 | `WITH running AS ( | |
| 43 | SELECT (SELECT r.id FROM run r | |
| 44 | WHERE r.repo_id = app.repo_id AND r.kind = 'deploy' | |
| 45 | AND r.env = 'production' AND r.state = 'ok' | |
| 46 | ORDER BY COALESCE(r.serving_at, r.finished_at) DESC, r.id DESC | |
| 47 | LIMIT 1) AS id | |
| 48 | FROM app | |
| 49 | ) | |
| 50 | SELECT dp.rowid AS rid | |
| 51 | FROM run r JOIN deploy_pull dp ON dp.run_id = r.id | |
| 52 | WHERE r.kind = 'deploy' | |
| 53 | AND COALESCE(r.serving_at, r.finished_at, r.created_at) < ?1 | |
| 54 | AND r.id NOT IN (SELECT id FROM running WHERE id IS NOT NULL) | |
| 55 | LIMIT ?2`, | |
| 56 | ) | |
| 57 | .all(cutoff, cap) | |
| 58 | .map((row) => row.rid); | |
| 59 | if (rowids.length === 0) return { deleted: 0, more: false }; | |
| 60 | const remove = db.query("DELETE FROM deploy_pull WHERE rowid = ?1"); | |
| 61 | const deleted = transact(db, () => { | |
| 62 | let n = 0; | |
| 63 | for (const rid of rowids) n += remove.run(rid).changes; | |
| 64 | return n; | |
| 65 | }); | |
| 66 | return { deleted, more: rowids.length === cap }; | |
| 67 | } | |
| 68 | ||
| 69 | /** | |
| 70 | * The crew tick's view of the rule: a batch on each tick while the last one | |
| 71 | * was full, and otherwise one an hour. Returns the batch's result, or | |
| 72 | * undefined when this tick runs none. | |
| 73 | */ | |
| 74 | export function deployRetention( | |
| 75 | db: Database, | |
| 76 | every: number = 60 * 60_000, | |
| 77 | ): (now?: Date) => Pruned | undefined { | |
| 78 | let more = true; | |
| 79 | let last = 0; | |
| 80 | return (now = new Date()) => { | |
| 81 | if (!more && now.getTime() - last < every) return undefined; | |
| 82 | last = now.getTime(); | |
| 83 | const result = pruneDeploys(db, now); | |
| 84 | more = result.more; | |
| 85 | return result; | |
| 86 | }; | |
| 87 | } |
src/tui/screens/deploys.ts +23 −3
| @@ -4,7 +4,8 @@ | ||
| 4 | 4 | * A production deploy is a `kind=deploy` run with no issue, so no run list |
| 5 | 5 | * a pull or an issue shows holds one. This is the list that does, read |
| 6 | 6 | * through `listRepoRuns` (so it is gated like every other run read) and |
| 7 | * joined to `deploy_pull` in one grouped read for `carries #3 #5`. | |
| 7 | * joined to `deploy_pull` in one grouped read for `carries #3 #5`; a deploy | |
| 8 | * retention trimmed is rebuilt from git after the first paint (`carriedForRuns`). | |
| 8 | 9 | * |
| 9 | 10 | * Under production's deploys, anyone who may read the repository sees a |
| 10 | 11 | * "Previews" group: one row per open pull with a preview environment, |
| @@ -104,7 +105,8 @@ import { trendLine, visitorsLine } from "../../apps/vitals.ts"; | ||
| 104 | 105 | import { egressModeSentence, undeclaredHosts } from "../../apps/egress_report.ts"; |
| 105 | 106 | import { egressSetting } from "../../core/capacity.ts"; |
| 106 | 107 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 107 | import { carriedByRuns, carriesText } from "../../core/carried.ts"; | |
| 108 | import { carriedByRuns, carriedForRuns, carriesText } from "../../core/carried.ts"; | |
| 109 | import { repoDir } from "../../git/repos.ts"; | |
| 108 | 110 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 109 | 111 | import { getAppByRepo } from "../../core/apps.ts"; |
| 110 | 112 | import { canAdmin, canWrite, type Repo } from "../../core/repos.ts"; |
| @@ -219,6 +221,8 @@ function egressLines(f: Session, repo: Repo): string[] { | ||
| 219 | 221 | export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 220 | 222 | const list = new RunList(f); |
| 221 | 223 | let carries = new Map<string, number[]>(); |
| 224 | /** The rebuild of trimmed deploys' carries the last read started, if any. */ | |
| 225 | let rebuilding: Promise<void> = Promise.resolve(); | |
| 222 | 226 | let loaded = false; |
| 223 | 227 | let visitors = ""; |
| 224 | 228 | let trend = ""; |
| @@ -248,6 +252,19 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 248 | 252 | f.db, |
| 249 | 253 | runs.map((r) => r.id), |
| 250 | 254 | ); |
| 255 | // A deploy retention trimmed is rebuilt from git as the run page does (#12009); | |
| 256 | // the rows above paint now, the rebuilt ones when the walk is back. | |
| 257 | if (runs.some((r) => !carries.has(r.id))) { | |
| 258 | const asked = runs; | |
| 259 | rebuilding = carriedForRuns(f.db, asked, repoDir(f.app.reposRoot, repo.ownerName, repo.name)).then( | |
| 260 | (found) => { | |
| 261 | if (list.runs !== asked) return; | |
| 262 | carries = found; | |
| 263 | ctx.invalidate(); | |
| 264 | }, | |
| 265 | () => undefined, | |
| 266 | ); | |
| 267 | } | |
| 251 | 268 | const seen = appVisitors(f.app, f.user, repo); |
| 252 | 269 | visitors = seen === undefined ? "" : visitorsLine(seen); |
| 253 | 270 | trend = seen === undefined ? "" : trendLine(seen.loadDays); |
| @@ -405,7 +422,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 405 | 422 | ...(mayBuild ? [binding(["b"], "preview a commit")] : []), |
| 406 | 423 | ]; |
| 407 | 424 | }, |
| 408 | load: async () => read(), | |
| 425 | load: async () => { | |
| 426 | read(); | |
| 427 | await rebuilding; | |
| 428 | }, | |
| 409 | 429 | render(area, s) { |
| 410 | 430 | const c = ctx.theme.c; |
| 411 | 431 | const named = domainsLine(domains); |
src/web/studio/run.ts +2 −2
| @@ -14,7 +14,7 @@ import { parseTestsRow, testsRowWords } from "../../checks/tests-row.ts"; | ||
| 14 | 14 | import { directionNote } from "../../crew/direct.ts"; |
| 15 | 15 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 16 | 16 | import { getAppByRepo } from "../../core/apps.ts"; |
| 17 | import { carriedBy } from "../../core/carried.ts"; | |
| 17 | import { carriedFor } from "../../core/carried.ts"; | |
| 18 | 18 | import { getIssueById } from "../../core/issues.ts"; |
| 19 | 19 | import { getPull, openPullsClosing } from "../../core/pulls.ts"; |
| 20 | 20 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| @@ -126,7 +126,7 @@ export async function runView( | ||
| 126 | 126 | const duration = durationText(run); |
| 127 | 127 | const all = ctx.url.searchParams.get("all") === "1"; |
| 128 | 128 | const held = listHeldSecrets(db, run.id); |
| 129 | const carried = carriedBy(db, run.id); | |
| 129 | const carried = await carriedFor(db, run, wordCtx.dir); | |
The TUI deploys screen does not get the same rebuild (it uses carriedByRuns), so the two doors disagree about a trimmed deploy. Fix it there too; add a test for each door. |
||
| 130 | 130 | const logPath = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log`; |
| 131 | 131 | |
| 132 | 132 | // The pull this run's tell has become, once one exists: the tell itself |
test/retention-deploys.test.ts added +242
| @@ -0,0 +1,242 @@ | ||
| 1 | /** | |
| 2 | * deploy_pull is trimmed to recent deploys (#12009): a deploy whose rows | |
| 3 | * are gone is asked for through the function the run page and the run API | |
| 4 | * use, and the same list comes back from git; one batch deletes rows of old | |
| 5 | * deploys only, keeps the deploy an app is running, and a 5,000-row batch | |
| 6 | * costs a fixed handful of statements, not one per row (#7824: no clock). | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { once } from "./helpers/statements.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import { deploysScreen } from "../src/tui/screens/deploys.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { settleApp } from "./helpers/tui.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { expectWork } from "./helpers/work.ts"; | |
| 21 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 22 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 23 | import { carriedBy, carriedFor, recordCarried } from "../src/core/carried.ts"; | |
| 24 | import { getPull } from "../src/core/pulls.ts"; | |
| 25 | import { repoDir } from "../src/git/repos.ts"; | |
| 26 | import type { Repo } from "../src/core/repos.ts"; | |
| 27 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 28 | import { | |
| 29 | DEPLOY_PULL_KEEP_DAYS, | |
| 30 | deployRetention, | |
| 31 | pruneDeploys, | |
| 32 | } from "../src/system/retention_deploys.ts"; | |
| 33 | ||
| 34 | quietLogs(); | |
| 35 | ||
| 36 | const DAY = 86_400_000; | |
| 37 | ||
| 38 | describe("deploy_pull retention", () => { | |
| 39 | let dataDir: string; | |
| 40 | let app: App; | |
| 41 | let repo: Repo; | |
| 42 | let dir: string; | |
| 43 | let servers: Servers; | |
| 44 | let start = ""; | |
| 45 | const merged: { number: number; oid: string }[] = []; | |
| 46 | const now = new Date(); | |
| 47 | const ago = (days: number): string => new Date(now.getTime() - days * DAY).toISOString(); | |
| 48 | ||
| 49 | const deploy = (id: string, oid: string, at: string, env = "production"): void => { | |
| 50 | app.db | |
| 51 | .query( | |
| 52 | `INSERT INTO run (id, repo_id, kind, env, commit_oid, state, serving_at, finished_at, created_at) | |
| 53 | VALUES (?1, ?2, 'deploy', ?3, ?4, 'ok', ?5, ?5, ?5)`, | |
| 54 | ) | |
| 55 | .run(id, repo.id, env, oid, at); | |
| 56 | }; | |
| 57 | const rows = (runId: string): number => | |
| 58 | app.db | |
| 59 | .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM deploy_pull WHERE run_id = ?1") | |
| 60 | .get(runId)!.n; | |
| 61 | ||
| 62 | beforeAll(async () => { | |
| 63 | dataDir = await mkdtemp(join(tmpdir(), "forge-retention-deploys-")); | |
| 64 | app = createApp({ dataDir, port: 0 }); | |
| 65 | const made = await createUser(app.db, { | |
| 66 | origin: "seed", | |
| 67 | trusted: true, | |
| 68 | username: "alice", | |
| 69 | email: "[email protected]", | |
| 70 | password: "a-good-passphrase", | |
| 71 | adminIfFirst: true, | |
| 72 | }); | |
| 73 | if (!made.ok) throw new Error(made.error); | |
| 74 | const alice = getUserByName(app.db, "alice")!; | |
| 75 | const created = await createRepo(app, alice, { name: "engine", visibility: "public" }); | |
| 76 | if (!created.ok) throw new Error(String(created.error)); | |
| 77 | repo = created.value; | |
| 78 | dir = repoDir(app.reposRoot, "alice", "engine"); | |
| 79 | const seeded = await commitToBranch(app, alice, repo, { | |
| 80 | message: "Start", | |
| 81 | files: [{ path: "README.md", content: "# engine\n" }], | |
| 82 | }); | |
| 83 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 84 | start = seeded.value.commitOid; | |
| 85 | for (const name of ["one", "two"]) { | |
| 86 | const branch = await commitToBranch(app, alice, repo, { | |
| 87 | branch: `feature/${name}`, | |
| 88 | base: "main", | |
| 89 | message: name, | |
| 90 | files: [{ path: `${name}.md`, content: `${name}\n` }], | |
| 91 | }); | |
| 92 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 93 | const opened = await openPull(app, alice, repo, { | |
| 94 | title: name, | |
| 95 | body: "", | |
| 96 | baseBranch: "main", | |
| 97 | headBranch: `feature/${name}`, | |
| 98 | }); | |
| 99 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 100 | const done = await mergePull(app, alice, repo, opened.value, "squash"); | |
| 101 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 102 | const pull = getPull(app.db, repo.id, opened.value.number)!; | |
| 103 | merged.push({ number: pull.number, oid: pull.mergedOid! }); | |
| 104 | } | |
| 105 | }); | |
| 106 | ||
| 107 | afterAll(async () => { | |
| 108 | servers?.stop(); | |
| 109 | app.close?.(); | |
| 110 | await rm(dataDir, { recursive: true, force: true }); | |
| 111 | }); | |
| 112 | ||
| 113 | const record = (runId: string, oid: string) => | |
| 114 | recordCarried(app.db, { runId, repoId: repo.id, commitOid: oid, dir }); | |
| 115 | ||
| 116 | test("a deploy whose rows are gone gets the same list back from git", async () => { | |
| 117 | deploy("rebuilt", merged[1]!.oid, ago(30)); | |
| 118 | const before = await record("rebuilt", merged[1]!.oid); | |
| 119 | expect(before).toEqual([merged[0]!.number, merged[1]!.number]); | |
| 120 | app.db.query("DELETE FROM deploy_pull WHERE run_id = 'rebuilt'").run(); | |
| 121 | expect(carriedBy(app.db, "rebuilt")).toEqual([]); | |
| 122 | const run = { id: "rebuilt", repoId: repo.id, kind: "deploy", env: "production", state: "ok", commitOid: merged[1]!.oid }; | |
| 123 | expect(await carriedFor(app.db, run, dir)).toEqual(before); | |
| 124 | // Kept again, so the next view reads rows. | |
| 125 | expect(carriedBy(app.db, "rebuilt")).toEqual(before); | |
| 126 | }); | |
| 127 | ||
| 128 | const trimmed = async (id: string): Promise<void> => { | |
| 129 | deploy(id, merged[1]!.oid, ago(30)); | |
| 130 | expect(await record(id, merged[1]!.oid)).toHaveLength(2); | |
| 131 | app.db.query("DELETE FROM deploy_pull WHERE run_id = ?1").run(id); | |
| 132 | }; | |
| 133 | const both = () => [merged[0]!.number, merged[1]!.number]; | |
| 134 | ||
| 135 | test("the run API reads a trimmed deploy's carries back from git", async () => { | |
| 136 | servers ??= await serve(app); | |
| 137 | await trimmed("api-door"); | |
| 138 | const res = await fetch(`${servers.url}/api/v1/repos/alice/engine/runs/api-door`); | |
| 139 | expect(res.status).toBe(200); | |
| 140 | expect(((await res.json()) as { carries: number[] }).carries).toEqual(both()); | |
| 141 | }); | |
| 142 | ||
| 143 | test("the run page reads a trimmed deploy's carries back from git", async () => { | |
| 144 | servers ??= await serve(app); | |
| 145 | await trimmed("page-door"); | |
| 146 | const res = await fetch(`${servers.url}/alice/engine/runs/page-door`); | |
| 147 | expect(res.status).toBe(200); | |
| 148 | const page = await res.text(); | |
| 149 | expect(page).toContain('id="run-carries"'); | |
| 150 | for (const n of both()) expect(page).toContain(`#${n}`); | |
| 151 | }); | |
| 152 | ||
| 153 | test("the terminal's deploys screen reads a trimmed deploy's carries back from git", async () => { | |
| 154 | await trimmed("tui-door"); | |
| 155 | // No deploy on the list keeps rows: whatever it shows, it rebuilt. | |
| 156 | app.db.query("DELETE FROM deploy_pull").run(); | |
| 157 | const alice = getUserByName(app.db, "alice")!; | |
| 158 | const { app: tui, session } = createForgeApp( | |
| 159 | app, | |
| 160 | { sshUsername: "alice", remoteAddress: "127.0.0.1", user: alice }, | |
| 161 | { terminal: headlessTerminal(160, 40), exitHooks: false }, | |
| 162 | ); | |
| 163 | tui.start(); | |
| 164 | await settleApp(tui); | |
| 165 | tui.push(deploysScreen(session, tui.ctx, repo)); | |
| 166 | await settleApp(tui); | |
| 167 | const text = tui.screen.toText(); | |
| 168 | tui.stop(); | |
| 169 | expect(text).toContain(`carries ${both().map((n) => `#${n}`).join(" ")}`); | |
| 170 | }); | |
| 171 | ||
| 172 | test("a deploy that carries none is walked once, not on every view", async () => { | |
| 173 | deploy("none", start, ago(30)); | |
| 174 | const run = { id: "none", repoId: repo.id, kind: "deploy", env: "production", state: "ok", commitOid: start }; | |
| 175 | expect(await carriedFor(app.db, run, dir)).toEqual([]); | |
| 176 | // Had it walked again, this commit would carry both pulls. | |
| 177 | expect(await carriedFor(app.db, { ...run, commitOid: merged[1]!.oid }, dir)).toEqual([]); | |
| 178 | expect(rows("none")).toBe(0); | |
| 179 | }); | |
| 180 | ||
| 181 | test("two views of one trimmed deploy share one walk", async () => { | |
| 182 | deploy("shared", merged[0]!.oid, ago(30)); | |
| 183 | const run = { id: "shared", repoId: repo.id, kind: "deploy", env: "production", state: "ok", commitOid: merged[0]!.oid }; | |
| 184 | // The second asks with another commit: a second walk would answer both pulls. | |
| 185 | const [a, b] = await Promise.all([ | |
| 186 | carriedFor(app.db, run, dir), | |
| 187 | carriedFor(app.db, { ...run, commitOid: merged[1]!.oid }, dir), | |
| 188 | ]); | |
| 189 | expect(a).toEqual([merged[0]!.number]); | |
| 190 | expect(b).toEqual(a); | |
| 191 | }); | |
| 192 | ||
| 193 | test("a preview deploy with no rows is not walked", async () => { | |
| 194 | const run = { id: "preview", repoId: repo.id, kind: "deploy", env: "pull-1", state: "ok", commitOid: merged[1]!.oid }; | |
| 195 | expect(await carriedFor(app.db, run, dir)).toEqual([]); | |
| 196 | expect(rows("preview")).toBe(0); | |
| 197 | }); | |
| 198 | ||
| 199 | test("a batch drops old deploys' rows and keeps recent ones and the one an app runs", async () => { | |
| 200 | app.db.query("DELETE FROM deploy_pull").run(); | |
| 201 | app.db.query("DELETE FROM run").run(); | |
| 202 | deploy("old", merged[0]!.oid, ago(DEPLOY_PULL_KEEP_DAYS + 5)); | |
| 203 | deploy("running", merged[1]!.oid, ago(DEPLOY_PULL_KEEP_DAYS + 3)); | |
| 204 | deploy("recent-preview", merged[1]!.oid, ago(1), "pull-2"); | |
| 205 | for (const id of ["old", "running", "recent-preview"]) await record(id, merged[1]!.oid); | |
| 206 | app.db | |
| 207 | .query("INSERT INTO app (id, repo_id, slug, access) VALUES ('app-1', ?1, 'engine', 'public')") | |
| 208 | .run(repo.id); | |
| 209 | expect(pruneDeploys(app.db, now, 100)).toEqual({ deleted: 2, more: false }); | |
| 210 | expect(rows("old")).toBe(0); | |
| 211 | expect(rows("running")).toBe(2); | |
| 212 | expect(rows("recent-preview")).toBe(2); | |
| 213 | // Without the app, its last deploy is just an old one. | |
| 214 | app.db.query("DELETE FROM app").run(); | |
| 215 | expect(pruneDeploys(app.db, now, 1)).toEqual({ deleted: 1, more: true }); | |
| 216 | expect(pruneDeploys(app.db, now, 1)).toEqual({ deleted: 1, more: true }); | |
| 217 | expect(pruneDeploys(app.db, now, 1)).toEqual({ deleted: 0, more: false }); | |
| 218 | expect(rows("running")).toBe(0); | |
| 219 | expect(rows("recent-preview")).toBe(2); | |
| 220 | }); | |
| 221 | ||
| 222 | test("a 5,000-row batch costs a fixed handful of statements, and the tick keeps going while batches are full", async () => { | |
| 223 | deploy("big", merged[0]!.oid, ago(DEPLOY_PULL_KEEP_DAYS + 10)); | |
| 224 | const insert = app.db.query( | |
| 225 | "INSERT INTO deploy_pull (run_id, issue_id, pull_number, merged_oid) VALUES ('big', ?1, ?2, 'x')", | |
| 226 | ); | |
| 227 | app.db.transaction(() => { | |
| 228 | for (let i = 0; i < 7_000; i++) insert.run(`issue-${i}`, i); | |
| 229 | })(); | |
| 230 | const tick = deployRetention(app.db); | |
| 231 | let first: ReturnType<typeof tick>; | |
| 232 | const cost = await once(app.db, () => { | |
| 233 | first = tick(now); | |
| 234 | }); | |
| 235 | expect(first!).toEqual({ deleted: 5_000, more: true }); | |
| 236 | expectWork(cost, { max: 4 }); | |
| 237 | expect(tick(now)).toEqual({ deleted: 2_000, more: false }); | |
| 238 | // Nothing full last time: the next batch waits for the hour. | |
| 239 | expect(tick(now)).toBeUndefined(); | |
| 240 | expect(tick(new Date(now.getTime() + 61 * 60_000))).toEqual({ deleted: 0, more: false }); | |
| 241 | }); | |
| 242 | }); |
new above
offline
HIGH: a deploy that legitimately carries no pulls has no rows, so this walks git on every read and caches nothing. Unauthenticated readers of a public repo can drive repeated git walks. Record an empty result and single-flight concurrent walks per run.