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

Merged opened this

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

Merged

steward/idle-pulls-bounded → main 3 commits 4 threads · 4 unresolved

  • Merged. steward/idle-pulls-bounded was merged into main as 0806aca.

Review

Sign in to review this change.
3 files changed +215 −24
src/flow/idle_pulls.ts +77 −22
@@ -1,7 +1,8 @@
11 /**
22 * Idle pulls (#6470): open, unmerged pulls opened by a crew agent whose head
33 * 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.
56 */
67
78 import type { Database } from "bun:sqlite";
@@ -22,31 +23,85 @@ export interface IdlePull {
2223
2324 /** `publicOnly` reads public repositories alone, as `readOldestApproved` does. */
2425 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
2936 FROM pull p
3037 JOIN issue i ON i.id = p.issue_id
3138 JOIN repo r ON r.id = i.repo_id
3239 JOIN user ro ON ro.id = r.owner_id
3340 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`,
4342 )
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

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.

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;
52107 }
src/web/pages/flow-panel.ts +2 −2
@@ -36,7 +36,7 @@ import { type QueueRow, redBatchWords } from "../../flow/queue.ts";
3636 import { HOT_PULLS, pullCounts, warmFiles } from "../../crew/hot_files.ts";
3737 import { warmingLine } from "../../crew/hot_history.ts";
3838 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";
4040 import { readParked } from "../../flow/parked.ts";
4141 import { hoursWords, readLeadTime } from "../../flow/lead_time.ts";
4242 import { buildYieldSays, readBuildYield } from "../../flow/build_yield.ts";
@@ -113,7 +113,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
113113 const latency = readReviewLatency(app.db, new Date(now), agent);
114114 const openers = readOpeners(app.db, new Date(now), agent);
115115 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");
117117 const parked = readParked(app.db, new Date(now), reader.kind === "agent");
118118 const newest = parked.newest[0];
119119 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs