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

A pure read says the gate minutes: check-slot minutes spent on merge-queue gates and on pull checks over the last hour and 6 h #6435

Merged opened this

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

Merged

torque/forge:torque/6426-a-pure-read-says-the-gate-minutes-check → main 1 commit fork

  • Merged. torque/6426-a-pure-read-says-the-gate-minutes-check was merged into main as 80ec9d2.

Review

Sign in to review this change.
6 files changed +257 −0
src/checks/gate_record.ts added +40
@@ -0,0 +1,40 @@
1 /**
2 * The trace a merge-queue gate leaves (#6426): one `check_gate` row from
3 * the moment it takes a check slot to the moment it lets go, whatever its
4 * verdict. A gate writes no `run`, so without this the slot minutes it spends
5 * are nowhere; `flow/gate_minutes.ts` reads them. A row a crash left open is
6 * clipped there at the queue's deadline. Rows older than `KEEP_MS` are pruned
7 * as each new one is written.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { id } from "../kernel/id.ts";
12
13 /** How long a gate's row is kept: longer than any window the flow reads. */
14 export const KEEP_MS = 7 * 24 * 3_600_000;
15
16 /** A gate took its slot: the row's id, for `endGate`. */
17 export function beginGate(
18 db: Database,
19 repoId: string,
20 issueId: string | undefined,
21 commitOid: string,
22 now = new Date(),
23 ): string {
24 const gateId = id();
25 db.query("DELETE FROM check_gate WHERE started_at < ?1").run(
26 new Date(now.getTime() - KEEP_MS).toISOString(),
27 );
28 db.query(
29 "INSERT INTO check_gate (id, repo_id, issue_id, commit_oid, started_at) VALUES (?1, ?2, ?3, ?4, ?5)",
30 ).run(gateId, repoId, issueId ?? null, commitOid, now.toISOString());
31 return gateId;
32 }
33
34 /** The gate let go of its slot, answered or cut off. */
35 export function endGate(db: Database, gateId: string, now = new Date()): void {
36 db.query("UPDATE check_gate SET finished_at = ?2 WHERE id = ?1 AND finished_at IS NULL").run(
37 gateId,
38 now.toISOString(),
39 );
40 }
src/checks/service.ts +4
@@ -49,6 +49,7 @@ import { resolveOid } from "../git/merge.ts";
4949 import { mergeForCheck } from "./merged.ts";
5050 import { checkOnApproval } from "./approval.ts";
5151 import { checkKey, provenBy, recordProven } from "./proven.ts";
52 import { beginGate, endGate } from "./gate_record.ts";
5253 import { giveHistory } from "./history.ts";
5354 import { readLearned } from "./learned.ts";
5455 import { keepCache, restoreCache } from "./cache.ts";
@@ -1213,6 +1214,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
12131214 gates.add(controller);
12141215 const abandon = (): void => controller.abort();
12151216 signal?.addEventListener("abort", abandon, { once: true });
1217 // The slot minutes it spends are the flow's gate minutes (#6426).
1218 const traced = beginGate(app.db, repo.id, pull.id, commitOid);
12161219 try {
12171220 const bare = await pullDir(app.db, app.reposRoot, repo, pull);
12181221 const done = await runTier({
@@ -1238,6 +1241,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
12381241 return { ok: false, error: done.error, lines, interrupted: true };
12391242 return { ok: false, error: done.error, lines };
12401243 } finally {
1244 endGate(app.db, traced);
12411245 signal?.removeEventListener("abort", abandon);
12421246 gates.delete(controller);
12431247 }
src/core/migrations/20261026174649-check-gate.ts added +15
@@ -0,0 +1,15 @@
1 // One row per merge-queue gate (#6426, checks/gate_record.ts): when it took a
2 // check slot and when it let go of it, so the flow's gate minutes are one
3 // read (`flow/gate_minutes.ts`). A gate writes no `run`; this is its whole
4 // trace. Rows past a week are pruned as new ones are written. Additive.
5 export default `
6 CREATE TABLE check_gate (
7 id TEXT PRIMARY KEY,
8 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
9 issue_id TEXT REFERENCES issue(id) ON DELETE SET NULL,
10 commit_oid TEXT NOT NULL DEFAULT '',
11 started_at TEXT NOT NULL,
12 finished_at TEXT
13 );
14 CREATE INDEX check_gate_started ON check_gate(started_at);
15 `;
src/flow/gate_minutes.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * The gate minutes (#6426, a flow vital of #5930): check-slot minutes spent
3 * over the last hour and the last 6 h, on the merge queue's gates
4 * (`checks/queue.ts`, traced in `check_gate` by `checks/gate_record.ts`) and
5 * on every other check (`run` rows of kind `check`: a pull's, and a branch's
6 * after a push or a land).
7 *
8 * Each check counts only its minutes inside the window:
9 *
10 * minutes = Σ max(0, min(end, now) − max(start, now − window)) / 60 000 ms
11 *
12 * where `end` is when it finished, or `now` while it runs. A gate row a
13 * crashed forge left open ends at `start + GATE_DEADLINE_MS`, the most a gate
14 * may hold its slot. A check not yet started (`queued`) holds no slot and
15 * counts nothing. One read; forge-wide for the owner, public repositories
16 * alone for an agent — the rule `readVitals` keeps.
17 */
18
19 import type { Database } from "bun:sqlite";
20 import { GATE_DEADLINE_MS } from "../checks/queue.ts";
21
22 const HOUR_MS = 3_600_000;
23 const DAY_MS = 86_400_000;
24
25 export interface GateMinutes {
26 readonly gate1h: number;
27 readonly gate6h: number;
28 readonly pull1h: number;
29 readonly pull6h: number;
30 }
31
32 /** Milliseconds of [start, end] inside [?from, ?3], per row; `end` already says what an open row ends at. */
33 const clipped = (start: string, end: string, from: string): string =>
34 `MAX(0, MIN(julianday(${end}), julianday(?3)) - MAX(julianday(${start}), julianday(${from}))) * ${DAY_MS}`;
35
36 const GATE_END = "COALESCE(g.finished_at, MIN(julianday(?3), julianday(g.started_at) + ?5))";
37 const RUN_END = "COALESCE(c.finished_at, ?3)";
38
39 const gateSum = (from: string): string =>
40 `(SELECT COALESCE(SUM(${clipped("g.started_at", GATE_END, from)}), 0)
41 FROM check_gate g JOIN repo r ON r.id = g.repo_id
42 WHERE (?4 = 0 OR r.visibility = 'public')
43 AND g.started_at < ?3 AND (g.finished_at IS NULL OR g.finished_at > ${from}))`;
44
45 const runSum = (from: string): string =>
46 `(SELECT COALESCE(SUM(${clipped("c.started_at", RUN_END, from)}), 0)
47 FROM run c JOIN repo r ON r.id = c.repo_id
48 WHERE (?4 = 0 OR r.visibility = 'public') AND c.kind = 'check'
49 AND c.started_at IS NOT NULL AND c.started_at < ?3
50 AND (c.finished_at IS NULL OR c.finished_at > ${from}))`;
51
52 /** Minutes to one decimal: the reading is a vital, not an invoice. */
53 const minutes = (ms: number | undefined): number => Math.round((ms ?? 0) / 6_000) / 10;
54
55 /** `publicOnly` counts public repositories alone, for a reader who must not learn what private ones hold. */
56 export function readGateMinutes(db: Database, now = new Date(), publicOnly = false): GateMinutes {
57 const to = now.toISOString();
58 const h1 = new Date(now.getTime() - HOUR_MS).toISOString();
59 const h6 = new Date(now.getTime() - 6 * HOUR_MS).toISOString();
60 const row = db
61 .query<Record<keyof GateMinutes, number>, [string, string, string, number, number]>(
62 `SELECT ${gateSum("?1")} AS gate1h, ${gateSum("?2")} AS gate6h,
63 ${runSum("?1")} AS pull1h, ${runSum("?2")} AS pull6h`,
64 )
65 .get(h1, h6, to, publicOnly ? 1 : 0, GATE_DEADLINE_MS / DAY_MS);
66 return {
67 gate1h: minutes(row?.gate1h),
68 gate6h: minutes(row?.gate6h),
69 pull1h: minutes(row?.pull1h),
70 pull6h: minutes(row?.pull6h),
71 };
72 }
test/check-tiers.test.ts +8
@@ -510,6 +510,14 @@ describe("the tiers in the forge's own checks service", () => {
510510 expect(again.ok).toBe(true);
511511 expect(again.lines.join("\n")).toContain("already proven green by gate-");
512512 expect(again.lines.join("\n")).not.toContain("FULL-GREEN");
513
514 // Each gate leaves its slot minutes, opened and closed, for the flow (#6426).
515 const traced = app.db
516 .query<{ n: number }, [string, string]>(
517 "SELECT COUNT(*) AS n FROM check_gate WHERE issue_id = ?1 AND commit_oid = ?2 AND finished_at >= started_at",
518 )
519 .get(pull.id, branched.value.commitOid);
520 expect(traced?.n).toBe(2);
513521 });
514522
515523 test("a check's environment carries its cell's CPU limit (#2257)", async () => {
test/flow-gate-minutes.test.ts added +118
@@ -0,0 +1,118 @@
1 /** The gate minutes (#6426): check-slot minutes on merge-queue gates and on the other checks, clipped to the last hour and 6 h. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { type App, createApp } from "../src/app.ts";
7 import { beginGate, endGate, KEEP_MS } from "../src/checks/gate_record.ts";
8 import { GATE_DEADLINE_MS } from "../src/checks/queue.ts";
9 import { createRepo } from "../src/core/repos.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { readGateMinutes } from "../src/flow/gate_minutes.ts";
12 import { id } from "../src/kernel/id.ts";
13
14 const NOW = new Date("2026-10-26T12:00:00.000Z");
15 /** The instant `m` minutes before NOW. */
16 const ago = (m: number): string => new Date(NOW.getTime() - m * 60_000).toISOString();
17
18 let root = "";
19 let app: App;
20 const repos: Record<"public" | "private", string> = { public: "", private: "" };
21
22 beforeAll(async () => {
23 root = mkdtempSync(join(tmpdir(), "forge-gate-minutes-"));
24 app = createApp({ dataDir: root, port: 0 });
25 const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true });
26 if (!made.ok) throw new Error(made.error);
27 for (const visibility of ["public", "private"] as const) {
28 const repo = await createRepo(app.db, app.reposRoot, { ownerId: made.value.id, name: visibility, visibility, description: visibility });
29 if (!repo.ok) throw new Error(repo.error);
30 repos[visibility] = repo.value.id;
31 }
32 });
33
34 afterAll(() => {
35 app.close();
36 rmSync(root, { recursive: true, force: true });
37 });
38
39 const clear = (): void => {
40 app.db.query("DELETE FROM check_gate").run();
41 app.db.query("DELETE FROM run").run();
42 };
43
44 const check = (repoId: string, state: string, started: string | null, finished: string | null): void => {
45 app.db
46 .query(`INSERT INTO run (id, repo_id, kind, state, started_at, finished_at) VALUES (?1, ?2, 'check', ?3, ?4, ?5)`)
47 .run(id(), repoId, state, started, finished);
48 };
49
50 const gate = (repoId: string, started: string, finished: string | null): void => {
51 const at = beginGate(app.db, repoId, undefined, "a".repeat(40), new Date(started));
52 if (finished !== null) endGate(app.db, at, new Date(finished));
53 };
54
55 describe("gate minutes", () => {
56 test("nothing checked is zero minutes", () => {
57 clear();
58 expect(readGateMinutes(app.db, NOW)).toEqual({ gate1h: 0, gate6h: 0, pull1h: 0, pull6h: 0 });
59 });
60
61 test("a check straddling the window's edge counts only its minutes inside it", () => {
62 clear();
63 // 70 → 50 min ago: 10 min inside the hour, all 20 inside 6 h.
64 check(repos.public, "ok", ago(70), ago(50));
65 // 400 → 340 min ago: nothing in the hour, 20 min inside 6 h (360 → 340).
66 check(repos.public, "failed", ago(400), ago(340));
67 // Over before either window opened.
68 check(repos.public, "ok", ago(500), ago(450));
69 expect(readGateMinutes(app.db, NOW)).toEqual({ gate1h: 0, gate6h: 0, pull1h: 10, pull6h: 40 });
70 });
71
72 test("a running check counts up to now; a queued one holds no slot", () => {
73 clear();
74 check(repos.public, "running", ago(12), null);
75 check(repos.public, "running", ago(90), null); // 60 in the hour, 90 in 6 h
76 check(repos.public, "queued", null, null);
77 expect(readGateMinutes(app.db, NOW)).toEqual({ gate1h: 0, gate6h: 0, pull1h: 72, pull6h: 102 });
78 });
79
80 test("gates and pull checks are kept apart", () => {
81 clear();
82 gate(repos.public, ago(30), ago(15)); // 15
83 gate(repos.public, ago(65), ago(55)); // 5 in the hour, 10 in 6 h
84 gate(repos.public, ago(8), null); // running: 8
85 check(repos.public, "ok", ago(20), ago(17)); // 3
86 expect(readGateMinutes(app.db, NOW)).toEqual({ gate1h: 28, gate6h: 33, pull1h: 3, pull6h: 3 });
87 });
88
89 test("a gate a crash left open ends at the queue's deadline", () => {
90 clear();
91 const deadline = GATE_DEADLINE_MS / 60_000;
92 gate(repos.public, ago(deadline + 100), null); // ended at 100 min ago: nothing in the hour
93 gate(repos.public, ago(deadline + 30), null); // ended at 30 min ago: all of it in the hour
94 expect(readGateMinutes(app.db, NOW)).toEqual({ gate1h: deadline, gate6h: 2 * deadline, pull1h: 0, pull6h: 0 });
95 });
96
97 test("publicOnly hides private repositories", () => {
98 clear();
99 gate(repos.public, ago(10), ago(5));
100 gate(repos.private, ago(40), ago(20));
101 check(repos.public, "ok", ago(4), ago(2));
102 check(repos.private, "running", ago(30), null);
103 expect(readGateMinutes(app.db, NOW, true)).toEqual({ gate1h: 5, gate6h: 5, pull1h: 2, pull6h: 2 });
104 expect(readGateMinutes(app.db, NOW)).toEqual({ gate1h: 25, gate6h: 25, pull1h: 32, pull6h: 32 });
105 });
106
107 test("a gate's row is written open and closed once; a week-old row is pruned by the next", () => {
108 clear();
109 const at = beginGate(app.db, repos.public, undefined, "b".repeat(40), new Date(ago(5)));
110 expect(readGateMinutes(app.db, NOW).gate1h).toBe(5);
111 endGate(app.db, at, new Date(ago(2)));
112 endGate(app.db, at, new Date(ago(0))); // a second end moves nothing
113 expect(readGateMinutes(app.db, NOW).gate1h).toBe(3);
114 beginGate(app.db, repos.public, undefined, "c".repeat(40), new Date(NOW.getTime() + KEEP_MS + 60_000));
115 const left = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM check_gate").get();
116 expect(left?.n).toBe(1);
117 });
118 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs