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

The forge's database stays under 2 GiB: each table the db-size alarm names over 256 MiB has a retention rule #12061

Merged opened this

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

Merged

mason/forge:mason/12052-the-forge-s-database-stays-under-2-gib-e → main 3 commits fork

  • Merged. mason/12052-the-forge-s-database-stays-under-2-gib-e was merged into main as 820e450.

Review

Sign in to review this change.
10 files changed +124 −24
docs/MCP.md +1 −1
@@ -137,7 +137,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
137137 | `land_past_gate` | — | Land one pull past a broken gate, with a recorded reason; an owner's only | 321 |
138138 | `lift` | — | Lift an issue out from behind the Later gate | 214 |
139139 | `limb` | `defer`, `resume`, `close`, `raise`, `lower`, `replan`, `takeback`, `undo` | Act on a limb by `action` | 532 |
140 | `limits` | — | Every limit the owner can hit | 1025 |
140 | `limits` | — | Every limit the owner can hit | 1077 |
141141 | `maintenance` | `status`, `prepare`, `resume`, `pause-duty`, `resume-duty` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 308 |
142142 | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 |
143143 | `map` | — | An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first | 375 |
docs/features/deploy-pull-retention.md +2 −2
@@ -6,13 +6,13 @@
66
77 `src/system/retention_deploys.ts` keeps the rows of:
88
9 - deploys from the last `DEPLOY_PULL_KEEP_DAYS` (14) days, and
9 - deploys from the last `retention.deploy_pull_days` days (14 by default, `DEPLOY_PULL_KEEP_DAYS`; an admin moves it on `/admin/limits`, never below 7, the week ask-to-live reads), and
1010 - the deploy each app is running now (its newest successful production deploy), however old.
1111
1212 Every other deploy's rows are deleted by the crew tick in batches of at most `DEPLOY_PULL_BATCH` (5,000) rows. The tick reads the candidate rows first, then deletes them by rowid in one short transaction. It runs one batch per tick while each batch comes back full, then one batch an hour after that. When it deletes something, the log says `trimmed deploy_pull`.
1313
1414 ## Asking for an older deploy
1515
16 You don't have to do anything. Open `/<owner>/<repo>/runs/<id>` for a production deploy older than 14 days, or call the run API, or open the app's deploys screen in the terminal. The forge walks that deploy's commit in git (`carriedFor` / `carriedForRuns` in `src/core/carried.ts`), finds the merged pulls it contains and saves them again. You get the same list as before the trim, and later views read the saved rows.
16 You don't have to do anything. Open `/<owner>/<repo>/runs/<id>` for a production deploy older than the window, or call the run API, or open the app's deploys screen in the terminal. The forge walks that deploy's commit in git (`carriedFor` / `carriedForRuns` in `src/core/carried.ts`), finds the merged pulls it contains and saves them again. You get the same list as before the trim, and later views read the saved rows.
1717
1818 Each running forge walks a deploy at most once at a time; views of the same deploy that arrive together share one walk. If a deploy carries no pulls, it is walked once and the empty answer is remembered for as long as the forge runs, for up to 10,000 deploys. If git can't walk the commit, the forge waits a minute before trying again. Preview deploys and deploys that failed or are still running are never walked.
docs/features/event-retention.md added +15
@@ -0,0 +1,15 @@
1 # How long the event log keeps what it holds
2
3 `event` is the forge's log of what happened. `src/system/retention_events.ts` says which kinds are state read back at any age and are never trimmed: `issue.*`, `pull.*`, `repo.*`, `user.*`, `push`, `app.egress.*`, `keeper_*` and a few marks. Every other kind is trimmed once it is older than `retention.event_days` (#12008, #12052).
4
5 ## The window
6
7 The default is 30 days (`KEEP_DAYS`). That is the longest fixed window any page or tool asks of a kind that is not kept: a board's `30d` range. The home feed, the digest and the affinity weights read a week or less. The events page, the events API, the MCP `events` tool, the Atom feed and SSE replay page back through the log, so for unkept kinds they answer the window and nothing older.
8
9 It was 180 days. On a forge younger than that, which production is (it started 2026-08-03), the rule trimmed nothing while `event` held 578 MB of a 4.6 GiB file.
10
11 An admin moves the window on `/admin/limits` (**Days the event log keeps what no page reads at any age**), from 7 to 3,650 days. The sampler's next 15-second tick reads the new value.
12
13 ## How it trims
14
15 The sampler's tick deletes at most 1,000 rows, a chunk of 1,000 per transaction, oldest first. When a pass reaches rows inside the window, it waits a day before starting again. A webhook delivery that is still pending keeps its event.
src/boards/sampler.ts +2 −1
@@ -30,6 +30,7 @@ import { FINE_MS, record, type Point } from "./ring.ts";
3030 import { readHwmon, readTopology, temperatures } from "./thermal.ts";
3131 import { during } from "../perf/jobs.ts";
3232 import { withoutWaiting } from "../kernel/db.ts";
33 import { limitValue } from "../settings/limits.ts";
3334 import { pruneEvents, TICK_ROWS } from "../system/retention_events.ts";
3435 import { drainSamples } from "../system/retention_samples.ts";
3536
@@ -176,7 +177,7 @@ export function startSampler(
176177 // The same tick prunes one batch of old events (`system/retention_events.ts`); a busy
177178 // database skips it, and the next tick tries again.
178179 try {
179 withoutWaiting(app.db, () => pruneEvents(app.db, now, TICK_ROWS));
180 withoutWaiting(app.db, () => pruneEvents(app.db, now, TICK_ROWS, limitValue(app.db, "retention.event_days") * 86_400_000));
180181 } catch (error) {
181182 app.log.warn("event prune failed", { error: String(error) });
182183 }
src/crew/dispatch.ts +2 −1
@@ -41,6 +41,7 @@ import {
4141 import { closesFromBody, getPull, pullDir, type Pull } from "../core/pulls.ts";
4242 import { claimedClones, sweepOrphanClones } from "./orphans.ts";
4343 import { pruneRunFiles } from "../runs/prune.ts";
44 import { limitValue } from "../settings/limits.ts";
4445 import { deployRetention } from "../system/retention_deploys.ts";
4546 import { crewRoot, isClaimed } from "./workdir.ts";
4647 import { resolveOpen } from "../core/decisions.ts";
@@ -274,7 +275,7 @@ export async function startCrew(
274275 // same clock as the pump, so a pick becomes a run on the next tick.
275276 let lastAutonomy = 0;
276277 // deploy_pull keeps recent deploys only (#12009): a capped batch a tick while rows remain.
277 const trimDeploys = deployRetention(app.db);
278 const trimDeploys = deployRetention(app.db, undefined, () => limitValue(app.db, "retention.deploy_pull_days"));
278279 // The sweeps a tick started and has not seen settle: `stop()` waits for
279280 // them, so none reads `app.db` after its caller closed it (#9977).
280281 const sweeps = new Set<Promise<unknown>>();
src/settings/limits.ts +21 −1
@@ -21,6 +21,8 @@ import {
2121 type CapacityKey,
2222 } from "../core/capacity.ts";
2323 import { err, ok, type Result } from "../kernel/result.ts";
24 import { DEPLOY_PULL_KEEP_DAYS } from "../system/retention_deploys.ts";
25 import { KEEP_DAYS as EVENT_KEEP_DAYS } from "../system/retention_events.ts";
2426
2527 export type LimitKey =
2628 | "crew.concurrency"
@@ -55,7 +57,9 @@ export type LimitKey =
5557 | "model_review.slack_pct"
5658 | "shell_idle_minutes"
5759 | "billing.grace_days"
58 | "billing.retention_days";
60 | "billing.retention_days"
61 | "retention.event_days"
62 | "retention.deploy_pull_days";
5963
6064 export interface LimitDef {
6165 readonly label: string;
@@ -339,6 +343,22 @@ export const LIMITS: Record<LimitKey, LimitDef> = {
339343 max: 365,
340344 reason: "A forge that lapsed may come back, so its data waits this long before anything is allowed to remove it; paying again makes it active at once.",
341345 },
346 "retention.event_days": {
347 label: "Days the event log keeps what no page reads at any age",
348 unit: "days",
349 default: EVENT_KEEP_DAYS,
350 min: 7,
351 max: 3_650,
352 reason: "Issue, pull, push and account events are kept at any age; the rest feeds windows of 30 days at most, and kept longer they filled the database past the 2 GiB Bun maps.",
353 },
354 "retention.deploy_pull_days": {
355 label: "Days a deploy keeps the list of pulls it carried",
356 unit: "days",
357 default: DEPLOY_PULL_KEEP_DAYS,
358 min: 7,
359 max: 3_650,
360 reason: "The list is an index over git, rebuilt when an older deploy is opened, and it was the largest table in the database; a week is what ask-to-live reads.",
361 },
342362 };
343363
344364 export const LIMIT_KEYS = Object.keys(LIMITS) as LimitKey[];
src/system/retention_deploys.ts +11 −4
@@ -17,7 +17,11 @@
1717 import type { Database } from "bun:sqlite";
1818 import { transact } from "../kernel/db.ts";
1919
20 /** Deploys this recent keep their rows. Above `PILLAR_DAYS` (7), the window ask-to-live reads. */
20 /**
21 * Deploys this recent keep their rows, by default; an admin moves it with the
22 * `retention.deploy_pull_days` limit (`settings/limits.ts`), never below
23 * `PILLAR_DAYS` (7), the window ask-to-live reads.
24 */
2125 export const DEPLOY_PULL_KEEP_DAYS = 14;
2226 /** The least time between two slices of one pass. */
2327 const DEPLOY_PULL_PACE_MS = 10_000;
@@ -47,6 +51,7 @@ export function pruneDeploys(
4751 now: Date = new Date(),
4852 rowCap: number = DEPLOY_PULL_SCAN,
4953 after = 0,
54 keepDays: number = DEPLOY_PULL_KEEP_DAYS,
5055 ): Pruned & { readonly next: number } {
5156 const cap = Math.max(0, Math.floor(rowCap));
5257 if (cap === 0) return { deleted: 0, more: false, next: after };
@@ -56,7 +61,7 @@ export function pruneDeploys(
5661 )
5762 .all(after, cap);
5863 if (slice.length === 0) return { deleted: 0, more: false, next: 0 };
59 const cutoff = new Date(now.getTime() - DEPLOY_PULL_KEEP_DAYS * 86_400_000).toISOString();
64 const cutoff = new Date(now.getTime() - keepDays * 86_400_000).toISOString();
6065 const runIds = [...new Set(slice.map((row) => row.run_id))];
6166 const old = new Set<string>();
6267 const isOld = db.query<{ id: string; repo_id: string }, [string, string]>(
@@ -94,11 +99,13 @@ export function pruneDeploys(
9499 /**
95100 * The crew tick's view of the rule: a batch on each tick while the last one
96101 * was full, and otherwise one an hour. Returns the batch's result, or
97 * undefined when this tick runs none.
102 * undefined when this tick runs none. `keepDays` is read as each slice runs,
103 * so a changed setting governs the next slice.
98104 */
99105 export function deployRetention(
100106 db: Database,
101107 every: number = 60 * 60_000,
108 keepDays: () => number = () => DEPLOY_PULL_KEEP_DAYS,
102109 ): (now?: Date) => Pruned | undefined {
103110 let more = true;
104111 let last = 0;
@@ -107,7 +114,7 @@ export function deployRetention(
107114 // Mid-pass, one slice at most every `DEPLOY_PULL_PACE_MS`; a finished pass waits `every`.
108115 if (now.getTime() - last < (more ? DEPLOY_PULL_PACE_MS : every)) return undefined;
109116 last = now.getTime();
110 const result = pruneDeploys(db, now, DEPLOY_PULL_SCAN, after);
117 const result = pruneDeploys(db, now, DEPLOY_PULL_SCAN, after, keepDays());
111118 more = result.more;
112119 after = result.next;
113120 return result;
src/system/retention_events.ts +25 −12
@@ -3,19 +3,20 @@
33 *
44 * Every reader of `event`, and the oldest row it can reach:
55 *
6 * Windowed by age — the longest decides KEEP_MS:
7 * - `flow/estimate_base.ts`: reopened issues, 90 days (the base rate's widest level).
8 * - `core/team-metrics.ts`: reopens for the team ledger, 7 days.
9 * - `web/pages/security.ts`: deleted repositories, 30 days.
6 * Windowed by age — the longest over a kind not KEPT decides KEEP_DAYS:
7 * - `boards/sources.ts`: spawn timings by day over a board's range; the longest fixed one is 30d
8 * (`boards/store.ts` RANGES), and "all" reaches as far as the rule keeps.
9 * - `flow/estimate_base.ts`: reopened issues, 90 days, and `core/team-metrics.ts`: reopens, 7 days —
10 * `issue.*`, KEPT at any age, so no window binds them.
11 * - `web/pages/security.ts`: deleted repositories, 30 days (`repo.*`, KEPT).
1012 * - `web/studio/since.ts`: "since you were here", the digest window (days).
1113 * - `feed/items.ts`, `feed/quiet.ts`: the home feed, its newest 300 and a 24 h quiet window.
1214 * - `feed/affinity.ts`: a viewer's newest 200, weighed by a 7-day half-life.
1315 * - `actions/mail.ts`: mail ceiling notices, 24 h. `crew/stale_pulls.ts`: closes, 72 h.
1416 * - `crew/landed.ts`: comments since a pull merged (read when it merges).
15 * - `boards/sources.ts`: spawn timings by day, a board's range.
1617 *
1718 * Feeds, audit views and replay cursors — they page back through every row, so the rule sets
18 * their reach: they answer the last KEEP_MS, and nothing they list newer than that changes:
19 * their reach: they answer the window the rule keeps, and nothing they list newer than that changes:
1920 * - `core/events.ts` `listEvents`/`listEventsVisibleTo`, under the events page and
2021 * `/api/v1/repos/…/events` (`core/events_page.ts`), the MCP `events` tool
2122 * (`http/mcp/tools/events.ts`), the TUI events screen, the Atom feed (`web/feeds.ts`, newest
@@ -41,13 +42,20 @@
4142 * - `pinned.check`, `clog.cleared`, `webhook.failing`: the newest of each, or a streak's mark.
4243 * Newest-row readers (`web/map_cache.ts`, `spawn/clients.ts`) read MAX and never reach the old end.
4344 *
44 * A new reader that reaches past KEEP_MS names its kind in KEPT, or it is windowed.
45 * A new reader that reaches past the window names its kind in KEPT, or it is windowed.
4546 */
4647 import type { Database } from "bun:sqlite";
4748
4849 const DAY_MS = 86_400_000;
49 /** Twice the longest windowed reader (estimate_base's 90 days). */
50 export const KEEP_MS = 180 * DAY_MS;
50 /**
51 * The default window, in days; an admin moves it with the `retention.event_days` limit
52 * (`settings/limits.ts`). 30: the longest fixed window a reader asks of an unkept kind (a
53 * board's 30d range). It was 180 (#12008), which on a forge younger than 180 days trims
54 * nothing, while `event` was 578 MB of a 4.6 GiB file (#12052).
55 */
56 export const KEEP_DAYS = 30;
57 /** The default window, in milliseconds. */
58 export const KEEP_MS = KEEP_DAYS * DAY_MS;
5159 /** One batch: small enough to hold the write lock for milliseconds. */
5260 export const BATCH_ROWS = 5_000;
5361 /** Rows deleted per transaction: about 7 µs a row across the table's indexes. */
@@ -70,17 +78,22 @@ const passes = new WeakMap<Database, Pass>();
7078
7179 /**
7280 * One batch: walks at most `rowCap` rows from where the last batch stopped, in `seq` order (the
73 * order they were written, so oldest first), and deletes those older than KEEP_MS that no reader
81 * order they were written, so oldest first), and deletes those older than `keepMs` (KEEP_MS unless the setting says otherwise) that no reader
7482 * reaches at any age. `more` is whether rows past the rule are still to walk; when a pass meets
7583 * a row inside the rule it ends and rests REST_MS before starting from the oldest again. `lockMs`
7684 * is the longest the delete held the write lock (one CHUNK_ROWS transaction).
7785 */
78 export function pruneEvents(db: Database, now: number, rowCap: number = BATCH_ROWS): { deleted: number; more: boolean; lockMs: number } {
86 export function pruneEvents(
87 db: Database,
88 now: number,
89 rowCap: number = BATCH_ROWS,
90 keepMs: number = KEEP_MS,
91 ): { deleted: number; more: boolean; lockMs: number } {
7992 const cap = Math.max(1, Math.min(BATCH_ROWS, Math.floor(rowCap)));
8093 const pass = passes.get(db) ?? { after: 0, restUntil: 0 };
8194 passes.set(db, pass);
8295 if (now < pass.restUntil) return { deleted: 0, more: false, lockMs: 0 };
83 const cutoff = new Date(now - KEEP_MS).toISOString();
96 const cutoff = new Date(now - keepMs).toISOString();
8497 const rows = db
8598 .query<{ seq: number; rowid: number; kind: string; created_at: string }, [number, number]>(
8699 "SELECT seq, rowid, kind, created_at FROM event WHERE seq > ?1 ORDER BY seq LIMIT ?2",
test/retention-deploys.test.ts +25
@@ -30,6 +30,7 @@ import {
3030 deployRetention,
3131 pruneDeploys,
3232 } from "../src/system/retention_deploys.ts";
33 import { LIMITS, limitValue, setLimit } from "../src/settings/limits.ts";
3334
3435 quietLogs();
3536
@@ -262,4 +263,28 @@ describe("deploy_pull retention", () => {
262263 expect(tick(at(60))).toBeUndefined();
263264 expect(tick(new Date(now.getTime() + 62 * 60_000))).toMatchObject({ deleted: 0, more: false });
264265 });
266
267 test("the keep window is the setting's: deploys past it go, deploys inside it stay, each slice bounded", async () => {
268 app.db.query("DELETE FROM deploy_pull").run();
269 app.db.query("DELETE FROM run").run();
270 app.db.query("DELETE FROM app").run();
271 // Inside the default window, past a 7-day setting.
272 deploy("past", merged[0]!.oid, ago(9));
273 deploy("inside", merged[1]!.oid, ago(3));
274 for (const id of ["past", "inside"]) await record(id, merged[1]!.oid);
275 expect(pruneDeploys(app.db, now, 100)).toMatchObject({ deleted: 0 });
276 const alice = getUserByName(app.db, "alice")!;
277 expect(setLimit(app.db, "retention.deploy_pull_days", 7, alice.id).ok).toBe(true);
278 const tick = deployRetention(app.db, undefined, () => limitValue(app.db, "retention.deploy_pull_days"));
279 const r = tick(now)!;
280 expect(r).toMatchObject({ deleted: 2, more: false });
281 expect(rows("past")).toBe(0);
282 expect(rows("inside")).toBe(2);
283 // One row a slice: a slice never reads more than its cap.
284 deploy("past2", merged[0]!.oid, ago(10));
285 await record("past2", merged[1]!.oid);
286 expect(pruneDeploys(app.db, now, 1, 0, 7)).toMatchObject({ more: true });
287 expect(LIMITS["retention.deploy_pull_days"].default).toBe(DEPLOY_PULL_KEEP_DAYS);
288 setLimit(app.db, "retention.deploy_pull_days", null, alice.id);
289 });
265290 });
test/retention-events.test.ts +20 −2
@@ -4,6 +4,7 @@ import { migrations } from "../src/core/schema.ts";
44 import { listEvents } from "../src/core/events.ts";
55 import { countNotes } from "../src/maintain/keeper/record.ts";
66 import { openDb } from "../src/kernel/db.ts";
7 import { LIMITS } from "../src/settings/limits.ts";
78 import { BATCH_ROWS, KEEP_MS, keptKind, pruneEvents, REST_MS } from "../src/system/retention_events.ts";
89
910 const DAY = 86_400_000;
@@ -43,7 +44,9 @@ describe("pruneEvents", () => {
4344 put(db, "keeper_said", KEEP_MS + 10 * DAY, null, { key: "k1" });
4445 put(db, "push", KEEP_MS + 5 * DAY);
4546 const recent: string[] = [];
46 for (let d = 170; d >= 0; d -= 10) recent.push(put(db, d % 20 === 0 ? "secret.read" : "issue.reopened", d * DAY, "r1", { number: d }));
47 // Eighteen rows spread over the window, oldest first, alternating a windowed and a kept kind.
48 for (let i = 17; i >= 0; i--)
49 recent.push(put(db, i % 2 === 0 ? "secret.read" : "issue.reopened", Math.floor((i * (KEEP_MS - DAY)) / 17), "r1", { number: i }));
4750
4851 const cutoff = iso(NOW - KEEP_MS);
4952 const readers = () => ({
@@ -95,7 +98,22 @@ describe("pruneEvents", () => {
9598 for (const k of ["issue.closed", "pull.pushed", "repo.detached", "user.created", "push", "keeper_lull", "forge_down", "app.egress.granted"])
9699 expect(keptKind(k)).toBe(true);
97100 for (const k of ["secret.read", "forge.spawn.live", "mail.ceiling", "measurement"]) expect(keptKind(k)).toBe(false);
98 expect(KEEP_MS).toBeGreaterThanOrEqual(90 * DAY);
101 // The longest fixed window over an unkept kind is a board's 30d range (`boards/store.ts`).
102 expect(KEEP_MS).toBeGreaterThanOrEqual(30 * DAY);
103 expect(LIMITS["retention.event_days"].default * DAY).toBe(KEEP_MS);
104 });
105
106 test("the keep window is the caller's: rows past it go, rows inside it stay, a batch is bounded", () => {
107 const db = seeded();
108 const keep = 60 * DAY;
109 const past = [put(db, "secret.read", keep + 2 * DAY), put(db, "secret.read", keep + DAY)];
110 // Past the default window, inside this one.
111 const inside = [put(db, "secret.read", KEEP_MS + DAY), put(db, "secret.read", DAY)];
112 expect(pruneEvents(db, NOW, 1, keep)).toMatchObject({ deleted: 1, more: true });
113 expect(pruneEvents(db, NOW, 1, keep)).toMatchObject({ deleted: 1, more: true });
114 expect(pruneEvents(db, NOW, 1, keep)).toMatchObject({ deleted: 0, more: false });
115 for (const id of past) expect(db.query("SELECT 1 FROM event WHERE id = ?1").get(id)).toBeNull();
116 for (const id of inside) expect(db.query("SELECT 1 FROM event WHERE id = ?1").get(id)).not.toBeNull();
99117 });
100118
101119 test("one 5,000-row batch holds the write lock under 50 ms", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs