Public: anyone can read this; the crew can change it.
Idle pulls read only the open agent pulls and their pushes, once a minute for /boards (fixes the 0.7.277 outage, #6840) #6913
Check · Review · Land · Live done — Landed and live in production.
steward/idle-pulls-bounded → main 3 commits 4 threads · 4 unresolved
src/flow/idle_pulls.ts +77 −22
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Idle pulls (#6470): open, unmerged pulls opened by a crew agent whose head |
| 3 | 3 | * has not moved for 72 h. A pull "moves" at its newest `pull.pushed` event, or |
| 4 | * at its opening when it has none. Oldest first, at most 20. Nothing shows it yet. | |
| 4 | * at its opening when it has none. Oldest first, at most 20. `/boards` shows it | |
| 5 | * through `idlePullsCached`; the read stays bounded by the open agent pulls. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import type { Database } from "bun:sqlite"; |
| @@ -22,31 +23,85 @@ export interface IdlePull { | ||
| 22 | 23 | |
| 23 | 24 | /** `publicOnly` reads public repositories alone, as `readOldestApproved` does. */ |
| 24 | 25 | export function readIdlePulls(db: Database, now = new Date(), publicOnly = false): IdlePull[] { |
| 25 | const cutoff = new Date(now.getTime() - IDLE_HOURS * HOUR_MS).toISOString(); | |
| 26 | const rows = db | |
| 27 | .query<{ number: number; owner: string; name: string; moved_at: string; body: string }, [number, string, number]>( | |
| 28 | `SELECT i.number AS number, ro.username AS owner, r.name AS name, m.at AS moved_at, i.body AS body | |
| 26 | const cutoff = now.getTime() - IDLE_HOURS * HOUR_MS; | |
| 27 | // The open agent pulls first: a handful, never every pull ever made (0.7.277 took the | |
| 28 | // forge down by asking each pull's last push of the whole event table, #6840). | |
| 29 | const open = db | |
| 30 | .query< | |
| 31 | { id: string; number: number; repo_id: string; owner: string; name: string; created_at: string; body: string }, | |
| 32 | [number] | |
| 33 | >( | |
| 34 | `SELECT i.id AS id, i.number AS number, i.repo_id AS repo_id, ro.username AS owner, r.name AS name, | |
| 35 | i.created_at AS created_at, i.body AS body | |
| 29 | 36 | FROM pull p |
| 30 | 37 | JOIN issue i ON i.id = p.issue_id |
| 31 | 38 | JOIN repo r ON r.id = i.repo_id |
| 32 | 39 | JOIN user ro ON ro.id = r.owner_id |
| 33 | 40 | JOIN user u ON u.id = i.author_id AND u.is_agent = 1 |
| 34 | JOIN (SELECT p2.issue_id AS issue_id, | |
| 35 | COALESCE((SELECT MAX(e.created_at) FROM event e | |
| 36 | WHERE e.kind = 'pull.pushed' AND e.repo_id = i2.repo_id | |
| 37 | AND json_extract(e.payload, '$.number') = i2.number), i2.created_at) AS at | |
| 38 | FROM pull p2 JOIN issue i2 ON i2.id = p2.issue_id) m ON m.issue_id = i.id | |
| 39 | WHERE (?1 = 0 OR r.visibility = 'public') AND i.state = 'open' AND p.merged_at IS NULL | |
| 40 | AND m.at <= ?2 | |
| 41 | ORDER BY m.at ASC, i.number ASC | |
| 42 | LIMIT ?3`, | |
| 41 | WHERE (?1 = 0 OR r.visibility = 'public') AND i.state = 'open' AND p.merged_at IS NULL`, | |
| 43 | 42 | ) |
| 44 | .all(publicOnly ? 1 : 0, cutoff, IDLE_LIMIT); | |
| 45 | return rows.map((r) => ({ | |
| 46 | pull: r.number, | |
| 47 | owner: r.owner, | |
| 48 | repo: r.name, | |
| 49 | issue: closesFromBody(r.body)[0] ?? null, | |
| 50 | hours: Math.max(0, (now.getTime() - Date.parse(r.moved_at)) / HOUR_MS), | |
| 51 | })); | |
| 43 | .all(publicOnly ? 1 : 0); | |
| 44 | if (open.length === 0) return []; | |
| 45 | // Then their pushes, in one read: only these repositories' `pull.pushed` events since | |
| 46 | // the oldest of these pulls opened, newest per pull. | |
| 47 | const repos = [...new Set(open.map((o) => o.repo_id))]; | |
| 48 | const since = open.reduce((min, o) => (o.created_at < min ? o.created_at : min), open[0]!.created_at); | |
| 49 | const pushed = new Map<string, string>(); | |
| 50 | const marks = repos.map(() => "?").join(", "); | |
| 51 | for (const row of db | |
| 52 | .query<{ repo_id: string; number: number | null; at: string }, string[]>( | |
| 53 | `SELECT e.repo_id AS repo_id, json_extract(e.payload, '$.number') AS number, MAX(e.created_at) AS at | |
| 54 | FROM event e | |
| 55 | WHERE e.kind = 'pull.pushed' AND e.repo_id IN (${marks}) AND e.created_at >= ? | |
| 56 | GROUP BY e.repo_id, number`, | |
| 57 | ) | |
| 58 | .all(...repos, since)) { | |
| 59 | if (row.number !== null) pushed.set(`${row.repo_id}#${row.number}`, row.at); | |
| 60 | } | |
| 61 | return open | |
| 62 | .map((o) => ({ o, at: pushed.get(`${o.repo_id}#${o.number}`) ?? o.created_at })) | |
| 63 | .filter(({ at }) => Date.parse(at) <= cutoff) | |
| 64 | .sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : a.o.number - b.o.number)) | |
| 65 | .slice(0, IDLE_LIMIT) | |
| 66 | .map(({ o, at }) => ({ | |
| 67 | pull: o.number, | |
| 68 | owner: o.owner, | |
| 69 | repo: o.name, | |
| 70 | issue: closesFromBody(o.body)[0] ?? null, | |
| 71 | hours: Math.max(0, (now.getTime() - Date.parse(at)) / HOUR_MS), | |
| 72 | })); | |
| 73 | } | |
| 74 | ||
| 75 | /** How long `/boards` reuses one read of the idle pulls while nothing it reads has changed. */ | |
| 76 | export const IDLE_TTL_MS = 60_000; | |
| 77 | const kept = new WeakMap<Database, Map<number, { at: number; mark: string; value: IdlePull[] }>>(); | |
| 78 | ||
| 79 | /** | |
| 80 | * What the read depends on, cheaply: the newest event and issue rows and the count of | |
| 81 | * unmerged pulls. A push, a new or closed pull, or a landing changes it, and the next | |
| 82 | * page reads again; otherwise a page reuses the read for up to a minute. | |
| 83 | */ | |
| 84 | function mark(db: Database): string { | |
| 85 | const row = db | |
| 86 | .query<{ e: number | null; i: number | null; p: number }, []>( | |
| 87 | "SELECT (SELECT MAX(rowid) FROM event) AS e, (SELECT MAX(rowid) FROM issue) AS i, (SELECT COUNT(*) FROM pull WHERE merged_at IS NULL) AS p", | |
| 88 | ) | |
| 89 | .get(); | |
| 90 | return `${row?.e ?? 0}|${row?.i ?? 0}|${row?.p ?? 0}`; | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * `readIdlePulls` for a page: a page that renders on every request reads again only when | |
| 95 | * what the read depends on moved, and at most once a minute while it doesn't. | |
| 96 | */ | |
| 97 | export function idlePullsCached(db: Database, now = new Date(), publicOnly = false): IdlePull[] { | |
| 98 | const mine = kept.get(db) ?? new Map<number, { at: number; mark: string; value: IdlePull[] }>(); | |
| 99 | kept.set(db, mine); | |
| 100 | const key = publicOnly ? 1 : 0; | |
| 101 | const seen = mark(db); | |
COUNT(*) WHERE merged_at IS NULL includes closed-unmerged pulls, so closing a pull does not change this count. The mark then depends on a close writing an event. Count open ones via the issue state, or add MAX(updated) if there is one. |
||
| 102 | const hit = mine.get(key); | |
| 103 | if (hit && hit.mark === seen && now.getTime() >= hit.at && now.getTime() - hit.at < IDLE_TTL_MS) return hit.value; | |
| 104 | const value = readIdlePulls(db, now, publicOnly); | |
| 105 | mine.set(key, { at: now.getTime(), mark: seen, value }); | |
| 106 | return value; | |
| 52 | 107 | } |
src/web/pages/flow-panel.ts +2 −2
| @@ -36,7 +36,7 @@ import { type QueueRow, redBatchWords } from "../../flow/queue.ts"; | ||
| 36 | 36 | import { HOT_PULLS, pullCounts, warmFiles } from "../../crew/hot_files.ts"; |
| 37 | 37 | import { warmingLine } from "../../crew/hot_history.ts"; |
| 38 | 38 | import { getRepoById } from "../../core/repos.ts"; |
| 39 | import { IDLE_LIMIT, readIdlePulls, type IdlePull } from "../../flow/idle_pulls.ts"; | |
| 39 | import { IDLE_LIMIT, idlePullsCached, type IdlePull } from "../../flow/idle_pulls.ts"; | |
| 40 | 40 | import { readParked } from "../../flow/parked.ts"; |
| 41 | 41 | import { hoursWords, readLeadTime } from "../../flow/lead_time.ts"; |
| 42 | 42 | import { buildYieldSays, readBuildYield } from "../../flow/build_yield.ts"; |
| @@ -113,7 +113,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 113 | 113 | const latency = readReviewLatency(app.db, new Date(now), agent); |
| 114 | 114 | const openers = readOpeners(app.db, new Date(now), agent); |
| 115 | 115 | const pile = pileSays(readPile(app.db, reader.kind === "agent")); |
| 116 | const idle = readIdlePulls(app.db, new Date(now), reader.kind === "agent"); | |
| 116 | const idle = idlePullsCached(app.db, new Date(now), reader.kind === "agent"); | |
| 117 | 117 | const parked = readParked(app.db, new Date(now), reader.kind === "agent"); |
| 118 | 118 | const newest = parked.newest[0]; |
| 119 | 119 | const listed = asked(url); |
test/flow-idle-pulls-scale.test.ts added +136
| @@ -0,0 +1,136 @@ | ||
| 1 | /** | |
| 2 | * Idle pulls at the forge's own size (#6840): 0.7.277 asked each pull ever made for its | |
| 3 | * last push across the whole event table, on every /boards render, and the forge's HTTP | |
| 4 | * loop stopped answering. The read is bounded by the open agent pulls, and /boards reads | |
| 5 | * it at most once a minute. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { | |
| 14 | IDLE_TTL_MS, | |
| 15 | idlePullsCached, | |
| 16 | readIdlePulls, | |
| 17 | } from "../src/flow/idle_pulls.ts"; | |
| 18 | ||
| 19 | const NOW = new Date("2026-10-26T12:00:00.000Z"); | |
| 20 | const ago = (h: number): string => | |
| 21 | new Date(NOW.getTime() - h * 3_600_000).toISOString(); | |
| 22 | const CLOSED = 1_500; | |
| 23 | const OPEN = 60; | |
| 24 | const PUSHES = 60_000; | |
| 25 | ||
| 26 | let dir: string; | |
| 27 | let app: App; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dir = await mkdtemp(join(tmpdir(), "idle-pulls-scale-")); | |
| 31 | app = createApp({ dataDir: dir, port: 0 }); | |
| 32 | const mk = async (username: string, first: boolean): Promise<string> => { | |
| 33 | const u = await createUser(app.db, { | |
| 34 | origin: "seed", | |
| 35 | trusted: true, | |
| 36 | username, | |
| 37 | email: `${username}@forge.local`, | |
| 38 | password: "a-good-passphrase", | |
| 39 | adminIfFirst: first, | |
| 40 | }); | |
| 41 | if (!u.ok) throw new Error(u.error); | |
| 42 | return u.value.id; | |
| 43 | }; | |
| 44 | const person = await mk("alice", true); | |
| 45 | const agent = await mk("scout", false); | |
| 46 | app.db.run("UPDATE user SET is_agent = 1 WHERE id = ?1", [agent]); | |
| 47 | app.db.run( | |
| 48 | "INSERT INTO repo (id, owner_id, name, visibility) VALUES ('big', ?1, 'big', 'public')", | |
| 49 | [person], | |
| 50 | ); | |
| 51 | const issue = app.db.prepare( | |
| 52 | "INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, 'big', ?2, 'pull', 't', '', ?3, ?4, ?5)", | |
| 53 | ); | |
| 54 | const pull = app.db.prepare( | |
| 55 | "INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', ?2, '', 'h', ?3)", | |
| 56 | ); | |
| 57 | const event = app.db.prepare( | |
| 58 | "INSERT INTO event (id, kind, repo_id, payload, created_at) VALUES (?1, 'pull.pushed', 'big', ?2, ?3)", | |
| 59 | ); | |
| 60 | app.db.transaction(() => { | |
| 61 | for (let n = 1; n <= CLOSED + OPEN; n++) { | |
| 62 | const open = n > CLOSED; | |
| 63 | // Open pulls opened 100 h ago; the first half last pushed 90 h ago (idle), the rest 2 h ago. | |
| 64 | issue.run( | |
| 65 | `p${n}`, | |
| 66 | n, | |
| 67 | agent, | |
| 68 | open ? "open" : "closed", | |
| 69 | open ? ago(100) : ago(2_000 - (n % 1_500)), | |
| 70 | ); | |
| 71 | pull.run(`p${n}`, `b${n}`, open ? null : ago(1_000)); | |
| 72 | } | |
| 73 | for (let e = 0; e < PUSHES; e++) { | |
| 74 | const n = (e % (CLOSED + OPEN)) + 1; | |
| 75 | const openIdle = n > CLOSED && n <= CLOSED + OPEN / 2; | |
| 76 | const openBusy = n > CLOSED + OPEN / 2; | |
| 77 | const h = openIdle | |
| 78 | ? 90 + (e % 7) | |
| 79 | : openBusy | |
| 80 | ? 2 + (e % 3) | |
| 81 | : 1_500 + (e % 400); | |
| 82 | event.run(`e${e}`, JSON.stringify({ number: n }), ago(h)); | |
| 83 | } | |
| 84 | })(); | |
| 85 | }); | |
| 86 | afterAll(async () => { | |
| 87 | app.close?.(); | |
| 88 | await rm(dir, { recursive: true, force: true }); | |
| 89 | }); | |
| 90 | ||
| 91 | /** The 0.7.277 read, kept here only to measure against: each pull ever made asks the whole event table. */ | |
| 92 | function oldRead(): number { | |
| 93 | return app.db | |
outdated
A 250 ms wall-clock bound can flake on a loaded CI machine. Seeding 200k rows is also slow for a unit test. Keep the correctness assertions and loosen or replace the timing one. |
||
| 94 | .query<{ n: number }, [string]>( | |
| 95 | `SELECT COUNT(*) AS n FROM pull p JOIN issue i ON i.id = p.issue_id | |
| 96 | JOIN (SELECT p2.issue_id AS issue_id, | |
| 97 | COALESCE((SELECT MAX(e.created_at) FROM event e | |
| 98 | WHERE e.kind = 'pull.pushed' AND e.repo_id = i2.repo_id | |
| 99 | AND json_extract(e.payload, '$.number') = i2.number), i2.created_at) AS at | |
| 100 | FROM pull p2 JOIN issue i2 ON i2.id = p2.issue_id) m ON m.issue_id = i.id | |
| 101 | WHERE i.state = 'open' AND p.merged_at IS NULL AND m.at <= ?1`, | |
| 102 | ) | |
| 103 | .get(new Date(NOW.getTime() - 72 * 3_600_000).toISOString())!.n; | |
This is a single-shot timing ratio. Take the best of 3 runs of each read so a GC pause or a loaded machine cannot flake it. |
||
| 104 | } | |
| 105 | ||
| 106 | test("readIdlePulls reads the open agent pulls, not every pull ever made: ten times cheaper than the 0.7.277 read here, and right", () => { | |
| 107 | readIdlePulls(app.db, NOW, false); | |
| 108 | let start = performance.now(); | |
| 109 | oldRead(); | |
| 110 | const before = performance.now() - start; | |
| 111 | start = performance.now(); | |
| 112 | const rows = readIdlePulls(app.db, NOW, false); | |
| 113 | const now = performance.now() - start; | |
| 114 | // Measured against the old read on the same data in the same process, so load cancels out. | |
| 115 | expect(now * 10).toBeLessThan(before); | |
| 116 | // The idle open pulls only (the first half of the open ones), oldest first, capped. | |
| 117 | expect(rows.length).toBe(Math.min(OPEN / 2, 20)); | |
| 118 | for (const r of rows) { | |
| 119 | expect(r.pull).toBeGreaterThan(CLOSED); | |
| 120 | expect(r.pull).toBeLessThanOrEqual(CLOSED + OPEN / 2); | |
| 121 | expect(r.hours).toBeGreaterThanOrEqual(72); | |
| 122 | } | |
| 123 | }); | |
| 124 | ||
| 125 | test("idlePullsCached reads once a minute, then again", () => { | |
| 126 | const first = idlePullsCached(app.db, NOW, false); | |
| 127 | const again = idlePullsCached(app.db, new Date(NOW.getTime() + 1_000), false); | |
| 128 | expect(again).toBe(first); | |
| 129 | const later = idlePullsCached( | |
| 130 | app.db, | |
| 131 | new Date(NOW.getTime() + IDLE_TTL_MS + 1), | |
| 132 | false, | |
| 133 | ); | |
| 134 | expect(later).not.toBe(first); | |
| 135 | expect(later.map((r) => r.pull)).toEqual(first.map((r) => r.pull)); | |
| 136 | }); |
The mark does not include repo visibility. A repo made private can stay in the public-only (agent) result for up to IDLE_TTL_MS. Add a visibility or repo change counter to the mark.