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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12009-deploy-pull-is-trimmed-to-recent-deploys → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/12009-deploy-pull-is-trimmed-to-recent-deploys was merged into main as a96572b.

Review

Sign in to review this change.
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 @@
66 * merge commit is among them is recorded against the run. A pull merged
77 * after the deployed commit is not in the walk, so it is not recorded. The
88 * 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.
1011 */
1112
1213 import type { Database } from "bun:sqlite";
@@ -44,20 +45,30 @@ function mergedAmong(
4445 */
4546 export async function recordCarried(
4647 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,
5449 ): 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> {
5566 if (!FULL_OID.test(input.commitOid)) return [];
5667 const walked = await runLocal(
5768 ["rev-list", `--max-count=${WALK_MAX}`, input.commitOid],
5869 { cwd: input.dir },
5970 );
60 if (!walked.ok) return [];
71 if (!walked.ok) return undefined;
6172 const contained = new Set(walked.value.split("\n").filter((l) => l !== ""));
6273 const carried = mergedAmong(db, input.repoId, contained);
6374 const insert = db.query(
@@ -119,6 +130,95 @@ export function carriedBy(db: Database, runId: string): number[] {
119130 .map((row) => row.pull_number);
120131 }
121132
133 /** A deploy run as `carriedFor` reads it. */
134 export interface CarriedRun {
135 readonly id: string;
136 readonly repoId: string;
137 readonly kind: string;
outdated

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.

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
122222 /** The pull numbers each of these runs carries, in one read; runs that carry none are absent. */
123223 export function carriedByRuns(
124224 db: Database,
src/crew/dispatch.ts +6
@@ -41,6 +41,7 @@ import {
4141 import { closesFromBody, getPull, pullDir, type Pull } from "../core/pulls.ts";
4242 import { claimedClones, sweepOrphanClones } from "./orphans.ts";
4343 import { pruneRunFiles } from "../runs/prune.ts";
44 import { deployRetention } from "../system/retention_deploys.ts";
4445 import { crewRoot, isClaimed } from "./workdir.ts";
4546 import { resolveOpen } from "../core/decisions.ts";
4647 import { accountActive } from "../core/accounts.ts";
@@ -272,6 +273,8 @@ export async function startCrew(
272273 // Repositories that run on their own take a turn once a minute, on the
273274 // same clock as the pump, so a pick becomes a run on the next tick.
274275 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);
275278 // The sweeps a tick started and has not seen settle: `stop()` waits for
276279 // them, so none reads `app.db` after its caller closed it (#9977).
277280 const sweeps = new Set<Promise<unknown>>();
@@ -297,6 +300,9 @@ export async function startCrew(
297300 ));
298301 }
299302 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 });
300306 });
301307 } catch (error) {
302308 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";
289289 import { COLUMNS, readSort, sortRows } from "../fleet/columns.ts";
290290 import { viewFleet } from "../fleet/rows.ts";
291291 import { fleetSelection, readFleetGraph } from "../actions/fleet-graph.ts";
292 import { carriedBy } from "../core/carried.ts";
292 import { carriedFor } from "../core/carried.ts";
293293 import { readGateLog } from "../checks/gate_log.ts";
294294 import { recentGates } from "../checks/gate_result.ts";
295295 import { readRunLog, subscribeRunLog } from "../runs/log.ts";
@@ -4049,16 +4049,18 @@ export function apiRoutes(router: Router<Handler>): void {
40494049 return timePhase(ctx.request, "render", () => json(paged(runs, limit, (r) => r.id), 200, headers));
40504050 });
40514051
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) => {
40534053 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
40544054 const repo = loadRepo(ctx);
40554055 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) };
40624064 return timePhase(ctx.request, "render", () => json(found));
40634065 });
40644066
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 @@
44 * A production deploy is a `kind=deploy` run with no issue, so no run list
55 * a pull or an issue shows holds one. This is the list that does, read
66 * 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`).
89 *
910 * Under production's deploys, anyone who may read the repository sees a
1011 * "Previews" group: one row per open pull with a preview environment,
@@ -104,7 +105,8 @@ import { trendLine, visitorsLine } from "../../apps/vitals.ts";
104105 import { egressModeSentence, undeclaredHosts } from "../../apps/egress_report.ts";
105106 import { egressSetting } from "../../core/capacity.ts";
106107 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";
108110 import { appsRuntime } from "../../apps/runtime.ts";
109111 import { getAppByRepo } from "../../core/apps.ts";
110112 import { canAdmin, canWrite, type Repo } from "../../core/repos.ts";
@@ -219,6 +221,8 @@ function egressLines(f: Session, repo: Repo): string[] {
219221 export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
220222 const list = new RunList(f);
221223 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();
222226 let loaded = false;
223227 let visitors = "";
224228 let trend = "";
@@ -248,6 +252,19 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
248252 f.db,
249253 runs.map((r) => r.id),
250254 );
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 }
251268 const seen = appVisitors(f.app, f.user, repo);
252269 visitors = seen === undefined ? "" : visitorsLine(seen);
253270 trend = seen === undefined ? "" : trendLine(seen.loadDays);
@@ -405,7 +422,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
405422 ...(mayBuild ? [binding(["b"], "preview a commit")] : []),
406423 ];
407424 },
408 load: async () => read(),
425 load: async () => {
426 read();
427 await rebuilding;
428 },
409429 render(area, s) {
410430 const c = ctx.theme.c;
411431 const named = domainsLine(domains);
src/web/studio/run.ts +2 −2
@@ -14,7 +14,7 @@ import { parseTestsRow, testsRowWords } from "../../checks/tests-row.ts";
1414 import { directionNote } from "../../crew/direct.ts";
1515 import { appsRuntime } from "../../apps/runtime.ts";
1616 import { getAppByRepo } from "../../core/apps.ts";
17 import { carriedBy } from "../../core/carried.ts";
17 import { carriedFor } from "../../core/carried.ts";
1818 import { getIssueById } from "../../core/issues.ts";
1919 import { getPull, openPullsClosing } from "../../core/pulls.ts";
2020 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
@@ -126,7 +126,7 @@ export async function runView(
126126 const duration = durationText(run);
127127 const all = ctx.url.searchParams.get("all") === "1";
128128 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.

130130 const logPath = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log`;
131131
132132 // 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs