Public: anyone can read this; the crew can change it.

A check starts on the newest push first, and a queued check says how long it has waited #39

Merged opened this

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

Merged

scout/forge:scout/33-a-check-starts-on-the-newest-push-first → main 1 commit fork

  • Merged. scout/33-a-check-starts-on-the-newest-push-first was merged into main as 3b8cd5a.

Review

Sign in to review this change.
6 files changed +217 −22
docs/APPS.md +7
@@ -205,6 +205,13 @@ A `check` that is present and wrong fails with the reason, so a commit is
205205 never silently unchecked. A check under way when the forge restarts is
206206 queued again, and the forge that carries on runs it from the start.
207207
208 Checks share `FORGE_CHECKS_CONCURRENCY` slots (default `1`), and when one
209 frees, it goes to the most recently requested check waiting, not the one
210 queued longest — a fresh push is never stuck behind pulls that queued hours
211 earlier. The one exception is starvation: a check that has waited ten
212 minutes to start outranks a newer one regardless, so nothing waits forever.
213 A pull's status names how long its queued check has been waiting.
214
208215 ### Environment
209216
210217 What an app needs that the repository must not carry — an admin password,
src/checks/service.ts +65 −13
@@ -62,6 +62,44 @@ export interface ChecksHandle {
6262 /** How often queued checks another forge left are looked for. */
6363 const TICK_MS = 5_000;
6464
65 /**
66 * How long a queued check may wait before it outranks a newer one however
67 * fresh: the bound that keeps `pickNext` from starving a pull nobody has
68 * pushed to in a while.
69 */
70 export const STARVATION_MS = 10 * 60 * 1000;
71
72 /**
73 * Which queued check takes a slot when one frees: the one requested most
74 * recently, so a push's own check is never stuck behind pulls that queued
75 * hours earlier — unless something has already waited `STARVATION_MS` or
76 * more, in which case the longest-waiting check goes first regardless of
77 * how fresh the rest are. Ties (down to the same instant) fall back to id
78 * order, which is itself creation order (`kernel/id.ts`).
79 */
80 export function pickNext(
81 queued: readonly Run[],
82 now: number = Date.now(),
83 ): Run | undefined {
84 if (queued.length === 0) return undefined;
85 let oldest = queued[0]!;
86 let newest = queued[0]!;
87 for (const run of queued) {
88 if (
89 run.createdAt < oldest.createdAt ||
90 (run.createdAt === oldest.createdAt && run.id < oldest.id)
91 )
92 oldest = run;
93 if (
94 run.createdAt > newest.createdAt ||
95 (run.createdAt === newest.createdAt && run.id > newest.id)
96 )
97 newest = run;
98 }
99 if (now - Date.parse(oldest.createdAt) >= STARVATION_MS) return oldest;
100 return newest;
101 }
102
65103 /** Where a check's checkout lives while it runs. */
66104 const checksRoot = (dataDir: string): string => join(dataDir, "checks");
67105
@@ -72,7 +110,8 @@ export const checksFor = (app: App): ChecksHandle | undefined =>
72110 handles.get(app);
73111
74112 export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
75 const gate = new Gate(options.concurrency ?? 1);
113 const capacity = options.concurrency ?? 1;
114 const gate = new Gate(capacity);
76115 const inFlight = new Map<
77116 string,
78117 { promise: Promise<void>; abort: AbortController }
@@ -116,7 +155,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
116155 ...(actorId === undefined ? {} : { actorId }),
117156 });
118157 announceRun(app, run, actorId);
119 start(run, repo);
158 schedule();
120159 return run;
121160 }
122161
@@ -131,20 +170,33 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
131170 error: String(error),
132171 }),
133172 )
134 .finally(() => inFlight.delete(run.id));
173 .finally(() => {
174 inFlight.delete(run.id);
175 // The slot this run held is free again: give it to whichever queued
176 // check now deserves it most, not whoever asked first.
177 schedule();
178 });
135179 inFlight.set(run.id, { promise, abort });
136180 }
137181
138182 /**
139 * Queued checks are whichever forge's picks them up: one the forge before
140 * this one never reached, or one a forge stopping beside this one handed
141 * back. Claiming is what decides, so two forges never run the same one.
183 * Fill every free slot with the queued check `pickNext` says deserves it —
184 * one the forge before this one never reached, one a forge stopping
185 * beside this one handed back, or simply the newest push once a slot
186 * opens up. Claiming is what decides between two forges, so calling this
187 * from both never runs the same check twice.
142188 */
143 function pickUp(): void {
144 for (const run of listActive(app.db, "check")) {
145 if (run.state !== "queued" || inFlight.has(run.id)) continue;
146 const repo = getRepoById(app.db, run.repoId);
147 if (repo) start(run, repo);
189 function schedule(): void {
190 if (stopped) return;
191 while (inFlight.size < capacity) {
192 const queued = listActive(app.db, "check").filter(
193 (run) => run.state === "queued" && !inFlight.has(run.id),
194 );
195 const next = pickNext(queued);
196 if (!next) return;
197 const repo = getRepoById(app.db, next.repoId);
198 if (!repo) return;
199 start(next, repo);
148200 }
149201 }
150202
@@ -406,9 +458,9 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
406458 }
407459
408460 const timer = setInterval(() => {
409 if (!stopped) pickUp();
461 if (!stopped) schedule();
410462 }, TICK_MS);
411 pickUp();
463 schedule();
412464 void catchUp();
413465
414466 const handle: ChecksHandle = {
src/tui/screens/pull.ts +3 −1
@@ -1039,7 +1039,9 @@ export function checksText(
10391039 case "running":
10401040 return "checks: deploying this head";
10411041 case "queued":
1042 return "checks: a deploy of this head is queued";
1042 return `checks: a deploy of this head is queued${
1043 run === null ? "" : ` (queued ${relTime(run.createdAt)})`
1044 }`;
10431045 case "cancelled":
10441046 return `checks: the deploy was cancelled${summary}`;
10451047 }
src/web/pages/pull.ts +12 −6
@@ -72,6 +72,7 @@ import {
7272 labelCss,
7373 listCard,
7474 originTag,
75 queuedPhrase,
7576 sha,
7677 stateBadge,
7778 time,
@@ -226,12 +227,17 @@ function checksLine(
226227 ? html`<span class="ok">${icon("check")}Checks passed</span>${link}`
227228 : check.state === "failed"
228229 ? html`<span class="bad">${icon("x")}Checks failed</span>${link}`
229 : check.state === "running" || check.state === "queued"
230 ? html`<span class="now">${icon("loader")}Checks ${check.state}</span
231 >${link}`
232 : check.state === "cancelled"
233 ? html`<span>${icon("ban")}Checks cancelled</span>${link}`
234 : html`<span>${icon("clock")}No checks on this head yet</span>`;
230 : check.state === "queued"
231 ? html`<span class="now"
232 >${icon("loader")}Checks
233 ${run ? queuedPhrase(run.createdAt) : "queued"}</span
234 >${link}`
235 : check.state === "running"
236 ? html`<span class="now">${icon("loader")}Checks running</span
237 >${link}`
238 : check.state === "cancelled"
239 ? html`<span>${icon("ban")}Checks cancelled</span>${link}`
240 : html`<span>${icon("clock")}No checks on this head yet</span>`;
235241 return html`<p class="checks-line">
236242 ${body}${when(
237243 required,
src/web/ui.ts +16
@@ -65,6 +65,22 @@ export function servedPhrase(env: EnvStatus): string | undefined {
6565 return `${(ms / 1000).toFixed(1)}s after the push`;
6666 }
6767
68 /**
69 * How long a queued run has waited, worded like the run list's own line
70 * (`queued 12m ago`) but compact enough to sit beside a state word: `queued
71 * 12m`. `just now` under a minute, so a check that only just queued never
72 * reads as `queued 0m`.
73 */
74 export function queuedPhrase(createdAt: string, now = Date.now()): string {
75 const seconds = Math.max(0, (now - Date.parse(createdAt)) / 1000);
76 if (seconds < 60) return "queued just now";
77 const minutes = Math.floor(seconds / 60);
78 if (minutes < 60) return `queued ${minutes}m`;
79 const hours = Math.floor(minutes / 60);
80 if (hours < 24) return `queued ${hours}h`;
81 return `queued ${Math.floor(hours / 24)}d`;
82 }
83
6884 /** Bytes, three significant figures, never more precision than is true. */
6985 export function fileSize(bytes: number): string {
7086 if (bytes < 1024) return `${bytes} B`;
test/checks.test.ts +114 −2
@@ -11,8 +11,20 @@ import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { openPull } from "../src/actions/pulls.ts";
1313 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
14 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
15 import { checksForHead, listRuns, type Run } from "../src/core/runs.ts";
14 import {
15 pickNext,
16 startChecks,
17 STARVATION_MS,
18 type ChecksHandle,
19 } from "../src/checks/service.ts";
20 import {
21 cancelRun,
22 checksForHead,
23 createRun,
24 getRun,
25 listRuns,
26 type Run,
27 } from "../src/core/runs.ts";
1628 import { createUser, type User } from "../src/core/users.ts";
1729 import type { Repo } from "../src/core/repos.ts";
1830 import { serve, type Servers } from "../src/http/server.ts";
@@ -225,6 +237,106 @@ describe("checks", () => {
225237 expect(middle?.summary).toContain("superseded by");
226238 }, 60_000);
227239
240 test("pickNext takes the newest queued check, unless one has starved", () => {
241 const a = createRun(app.db, { repoId: repo.id, kind: "check", env: "a" });
242 const b = createRun(app.db, { repoId: repo.id, kind: "check", env: "b" });
243 const c = createRun(app.db, { repoId: repo.id, kind: "check", env: "c" });
244 // ids are creation-ordered, so this is oldest-to-newest already.
245 expect(pickNext([a, b, c])?.id).toBe(c.id);
246 expect(pickNext([c, a, b])?.id).toBe(c.id);
247 expect(pickNext([a])?.id).toBe(a.id);
248 expect(pickNext([])).toBeUndefined();
249
250 // `a` has waited past the starvation bound: it outranks `c`, however new.
251 app.db
252 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
253 .run(a.id, new Date(Date.now() - STARVATION_MS - 1_000).toISOString());
254 const starved = getRun(app.db, a.id)!;
255 expect(pickNext([starved, b, c])?.id).toBe(starved.id);
256 // Just short of the bound, the newest still wins.
257 app.db
258 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
259 .run(a.id, new Date(Date.now() - STARVATION_MS + 1_000).toISOString());
260 expect(pickNext([getRun(app.db, a.id)!, b, c])?.id).toBe(c.id);
261
262 // These never named a real commit: cancel them rather than leave them
263 // for the live checks service running beside this test to trip over.
264 for (const run of [a, b, c]) cancelRun(app.db, run.id);
265 });
266
267 test("when a slot frees, the newest of several queued checks runs first", async () => {
268 // A slow check on its own environment holds the only slot.
269 const held = await commitToBranch(app, alice, repo, {
270 branch: "order-hold",
271 message: "Hold the slot",
272 files: [
273 { path: "forge.json", content: checkJson("sleep 2 && test -f OK.md") },
274 ],
275 });
276 if (!held.ok) throw new Error(JSON.stringify(held.error));
277 const holder = await checks.request(
278 repo,
279 "order-hold",
280 held.value.commitOid,
281 "push",
282 );
283 expect(holder).toBeDefined();
284
285 // Three more, on three environments so none supersedes another, queue
286 // behind it — oldest requested first.
287 const oldest = await commitToBranch(app, alice, repo, {
288 branch: "order-a",
289 message: "A",
290 files: [{ path: "forge.json", content: checkJson("test -f OK.md") }],
291 });
292 if (!oldest.ok) throw new Error(JSON.stringify(oldest.error));
293 const middle = await commitToBranch(app, alice, repo, {
294 branch: "order-b",
295 message: "B",
296 files: [{ path: "forge.json", content: checkJson("test -f OK.md") }],
297 });
298 if (!middle.ok) throw new Error(JSON.stringify(middle.error));
299 const newest = await commitToBranch(app, alice, repo, {
300 branch: "order-c",
301 message: "C",
302 files: [{ path: "forge.json", content: checkJson("test -f OK.md") }],
303 });
304 if (!newest.ok) throw new Error(JSON.stringify(newest.error));
305
306 const oldestRun = await checks.request(
307 repo,
308 "order-a",
309 oldest.value.commitOid,
310 "push",
311 );
312 const middleRun = await checks.request(
313 repo,
314 "order-b",
315 middle.value.commitOid,
316 "push",
317 );
318 const newestRun = await checks.request(
319 repo,
320 "order-c",
321 newest.value.commitOid,
322 "push",
323 );
324 expect(oldestRun && middleRun && newestRun).toBeTruthy();
325 expect(oldestRun?.state).toBe("queued");
326 expect(middleRun?.state).toBe("queued");
327 expect(newestRun?.state).toBe("queued");
328
329 await settled((r) => r.id === newestRun!.id);
330 await settled((r) => r.id === middleRun!.id);
331 await settled((r) => r.id === oldestRun!.id);
332
333 const runs = listRuns(app.db, repo.id, { kind: "check", limit: 50 });
334 const startedAt = (id: string): number =>
335 Date.parse(runs.find((r) => r.id === id)!.startedAt!);
336 expect(startedAt(newestRun!.id)).toBeLessThan(startedAt(middleRun!.id));
337 expect(startedAt(middleRun!.id)).toBeLessThan(startedAt(oldestRun!.id));
338 }, 30_000);
339
228340 test("a check in flight when the forge stops is queued again, and the next forge runs it", async () => {
229341 const slow = await commitToBranch(app, alice, repo, {
230342 message: "A slower check",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs