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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/6426-a-pure-read-says-the-gate-minutes-check → main 1 commit fork
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"; | ||
| 49 | 49 | import { mergeForCheck } from "./merged.ts"; |
| 50 | 50 | import { checkOnApproval } from "./approval.ts"; |
| 51 | 51 | import { checkKey, provenBy, recordProven } from "./proven.ts"; |
| 52 | import { beginGate, endGate } from "./gate_record.ts"; | |
| 52 | 53 | import { giveHistory } from "./history.ts"; |
| 53 | 54 | import { readLearned } from "./learned.ts"; |
| 54 | 55 | import { keepCache, restoreCache } from "./cache.ts"; |
| @@ -1213,6 +1214,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1213 | 1214 | gates.add(controller); |
| 1214 | 1215 | const abandon = (): void => controller.abort(); |
| 1215 | 1216 | 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); | |
| 1216 | 1219 | try { |
| 1217 | 1220 | const bare = await pullDir(app.db, app.reposRoot, repo, pull); |
| 1218 | 1221 | const done = await runTier({ |
| @@ -1238,6 +1241,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1238 | 1241 | return { ok: false, error: done.error, lines, interrupted: true }; |
| 1239 | 1242 | return { ok: false, error: done.error, lines }; |
| 1240 | 1243 | } finally { |
| 1244 | endGate(app.db, traced); | |
| 1241 | 1245 | signal?.removeEventListener("abort", abandon); |
| 1242 | 1246 | gates.delete(controller); |
| 1243 | 1247 | } |
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", () => { | ||
| 510 | 510 | expect(again.ok).toBe(true); |
| 511 | 511 | expect(again.lines.join("\n")).toContain("already proven green by gate-"); |
| 512 | 512 | 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); | |
| 513 | 521 | }); |
| 514 | 522 | |
| 515 | 523 | 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 | }); |
new above
offline