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

Boot repairs an open pull whose recorded head differs from its branch in git, and says so once #9938

Merged opened this

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

Merged

scout/forge:scout/9935-boot-repairs-an-open-pull-whose-recorded → main 1 commit fork

  • Merged. scout/9935-boot-repairs-an-open-pull-whose-recorded was merged into main as d47bb25.

Review

Sign in to review this change.
3 files changed +231 −29
docs/ARCHITECTURE.md +9 −3
@@ -14,7 +14,12 @@ keeps its own SQLite file. Forgejo is the reference for how a forge should behav
1414 1. **The repository holds truth for code.** Git is the durable log. The database
1515 indexes git, and it is the only record of what git does not hold: issues,
1616 comments, accounts. Boot reconciliation (`src/core/reconcile.ts`) refreshes
17 hook stubs today. It does not yet repair index rows from git.
17 hook stubs, then moves every open same-repository pull whose recorded head
18 differs from its branch's ref to that ref, through the call a push makes
19 (`trackPullHead`) and with a `push` event, so a forge that stopped between
20 a push and its index write comes back right. A missing branch leaves the
21 row and logs a line. Pulls from forks, and merged branches whose pull says
22 open, are not repaired yet.
1823 2. **One process, one file of state.** A single Bun process serves git, web,
1924 and API. SQLite is the only database. Everything a forge instance _is_ lives
2025 in one data directory you can tar up and move.
@@ -195,8 +200,9 @@ each name, #783) — they only add, so it serves as it did.
195200
196201 State transitions use compare-and-swap (`UPDATE … WHERE state = ?`), and every
197202 transition writes its event row in the same transaction. On boot, a
198 reconciliation sweep repairs the index from git (a merged branch whose pull row
199 says "open" gets healed from the ref state, not trusted).
203 reconciliation sweep repairs the index from git: an open pull's recorded head
204 is healed from its branch's ref, not trusted (principle 1 says what is not
205 healed yet).
200206
201207 **The one exception to "the database is derived."** A connected origin's
202208 access token is third-party state: it cannot be rebuilt from the bare repos,
src/core/reconcile.ts +134 −26
@@ -1,24 +1,54 @@
11 /**
22 * Boot reconciliation: make on-disk reality match the index before serving.
3 * Today it refreshes every repo's hook stubs — the hook contract can change
4 * between releases, and a stale pre-receive fails closed (silently rejecting
5 * every push to an older repo), so an upgrade must re-lay them. This is also
6 * the seat for future git-is-truth repair (healing index rows from ref state).
3 * Two passes. First it refreshes every repo's hook stubs — the hook contract
4 * can change between releases, and a stale pre-receive fails closed
5 * (silently rejecting every push to an older repo), so an upgrade must re-lay
6 * them. Then it heals index rows from ref state, git being the truth for
7 * code: an open same-repository pull whose recorded head is not its branch's
8 * tip (a forge that stopped between a push and its index write) is moved to
9 * the tip through `trackPullHead`, the call every push makes, and a `push`
10 * event is recorded so whatever hears pushes hears this one. The checks'
11 * boot catch-up then asks for the new head. Pulls from forks live in other
12 * repositories and are left alone here.
713 *
814 * It runs before the listeners come up, which makes it the one piece of
915 * startup a growing forge waits on: two thousand repos done one at a time is
1016 * four filesystem round trips each, in series, while nothing is being served.
1117 * So it fans out — bounded, because the point is not to replace a slow boot
12 * with a thundering herd of file writes.
18 * with a thundering herd of file writes. The head pass reads refs only for
19 * repositories with an open same-repository pull, one `for-each-ref` each.
1320 */
1421
1522 import type { App } from "../app.ts";
1623 import { installHooks } from "../git/hooks.ts";
24 import { listRefs } from "../git/read.ts";
1725 import { repoDir } from "../git/repos.ts";
1826 import { repoExists } from "../git/repos.ts";
27 import { getPull, trackPullHead } from "./pulls.ts";
1928
2029 /** Repos reconciled at once. Filesystem work, so wider than the git limit. */
2130 const CONCURRENCY = 32;
31 /** Repos whose refs are read at once in the head pass: a git process each. */
32 const GIT_CONCURRENCY = 8;
33
34 /** Run `work` over `items`, at most `limit` at a time. */
35 async function fanOut<T>(
36 items: readonly T[],
37 limit: number,
38 work: (item: T) => Promise<void>,
39 ): Promise<void> {
40 let next = 0;
41 const worker = async (): Promise<void> => {
42 for (;;) {
43 const item = items[next++];
44 if (item === undefined) return;
45 await work(item);
46 }
47 };
48 await Promise.all(
49 Array.from({ length: Math.min(limit, items.length) }, worker),
50 );
51 }
2252
2353 export async function reconcile(app: App): Promise<void> {
2454 const rows = app.db
@@ -29,31 +59,109 @@ export async function reconcile(app: App): Promise<void> {
2959 .all();
3060
3161 let refreshed = 0;
32 let next = 0;
33 const worker = async (): Promise<void> => {
34 for (;;) {
35 const index = next++;
36 const row = rows[index];
37 if (row === undefined) return;
38 const dir = repoDir(app.reposRoot, row.owner_name, row.name);
39 // HEAD, not the directory: hooks can only be installed into a usable
40 // repository, and a half-removed one (no HEAD) is not that.
41 if (await repoExists(dir)) {
42 await installHooks(dir);
43 refreshed++;
44 } else {
45 app.log.warn("repo row without a git directory", {
46 repo: `${row.owner_name}/${row.name}`,
47 });
48 }
62 await fanOut(rows, CONCURRENCY, async (row) => {
63 const dir = repoDir(app.reposRoot, row.owner_name, row.name);
64 // HEAD, not the directory: hooks can only be installed into a usable
65 // repository, and a half-removed one (no HEAD) is not that.
66 if (await repoExists(dir)) {
67 await installHooks(dir);
68 refreshed++;
69 } else {
70 app.log.warn("repo row without a git directory", {
71 repo: `${row.owner_name}/${row.name}`,
72 });
4973 }
50 };
51 await Promise.all(
52 Array.from({ length: Math.min(CONCURRENCY, rows.length) }, worker),
53 );
74 });
75
76 const heads = await reconcileHeads(app);
5477
5578 app.log.info("reconciled", {
5679 repos: rows.length,
5780 hooks_refreshed: refreshed,
81 heads_repaired: heads,
5882 });
5983 }
84
85 interface OpenHead {
86 readonly repo_id: string;
87 readonly owner_name: string;
88 readonly repo_name: string;
89 readonly number: number;
90 readonly head_branch: string;
91 readonly head_oid: string;
92 }
93
94 /**
95 * Move every open same-repository pull whose recorded head differs from its
96 * branch's ref to that ref. Returns how many moved. A second run moves none.
97 */
98 export async function reconcileHeads(app: App): Promise<number> {
99 const open = app.db
100 .query<OpenHead, []>(
101 `SELECT issue.repo_id, user.username AS owner_name, repo.name AS repo_name,
102 issue.number, pull.head_branch, pull.head_oid
103 FROM pull
104 JOIN issue ON issue.id = pull.issue_id
105 JOIN repo ON repo.id = issue.repo_id
106 JOIN user ON user.id = repo.owner_id
107 WHERE issue.state = 'open' AND pull.merged_at IS NULL
108 AND (pull.head_repo_id IS NULL OR pull.head_repo_id = issue.repo_id)`,
109 )
110 .all();
111 const byRepo = new Map<string, OpenHead[]>();
112 for (const row of open) {
113 const list = byRepo.get(row.repo_id);
114 if (list === undefined) byRepo.set(row.repo_id, [row]);
115 else list.push(row);
116 }
117
118 let repaired = 0;
119 await fanOut([...byRepo.values()], GIT_CONCURRENCY, async (pulls) => {
120 const first = pulls[0]!;
121 const repo = `${first.owner_name}/${first.repo_name}`;
122 const dir = repoDir(app.reposRoot, first.owner_name, first.repo_name);
123 if (!(await repoExists(dir))) return; // the hook pass has said so
124 const refs = await listRefs(dir, "refs/heads/");
125 if (!refs.ok) {
126 app.log.warn("could not read branches to reconcile pull heads", {
127 repo,
128 error: refs.error.stderr,
129 });
130 return;
131 }
132 const tips = new Map(refs.value.map((r) => [r.name, r.oid]));
133 const updates: { ref: string; oldOid: string; newOid: string }[] = [];
134 for (const pull of pulls) {
135 const ref = `refs/heads/${pull.head_branch}`;
136 const tip = tips.get(ref);
137 if (tip === undefined) {
138 app.log.warn("open pull's branch is missing; row left as it is", {
139 repo,
140 pull: pull.number,
141 branch: pull.head_branch,
142 });
143 continue;
144 }
145 if (tip === pull.head_oid) continue;
146 trackPullHead(app.db, pull.repo_id, pull.head_branch, tip);
147 // trackPullHead moves the branch's newest pull; an older open one
148 // sharing the branch is not its to move, and is not claimed here.
149 if (getPull(app.db, pull.repo_id, pull.number)?.headOid !== tip) continue;
150 updates.push({ ref, oldOid: pull.head_oid, newOid: tip });
151 app.log.info("pull head repaired from its branch", {
152 repo,
153 pull: pull.number,
154 from: pull.head_oid,
155 to: tip,
156 });
157 repaired++;
158 }
159 if (updates.length > 0)
160 app.bus.append({
161 kind: "push",
162 repoId: first.repo_id,
163 payload: { updates, reconciled: true },
164 });
165 });
166 return repaired;
167 }
test/reconcile-heads.test.ts added +88
@@ -0,0 +1,88 @@
1 /**
2 * Boot heals pull heads from refs (#9935): a branch moved behind the index's
3 * back — git written to directly, no hook, no event — is what `reconcile`
4 * finds and repairs, through `trackPullHead`, with one `push` event. A second
5 * run finds nothing. A pull whose branch is gone is left as it is.
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 { openPull } from "../src/actions/pulls.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { getPull } from "../src/core/pulls.ts";
15 import { reconcile } from "../src/core/reconcile.ts";
16 import type { Repo } from "../src/core/repos.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { runGit } from "../src/git/env.ts";
19 import { repoDir } from "../src/git/repos.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 let dataDir = "";
25 let app: App;
26 let alice: User;
27 let repo: Repo;
28 let moved = 0;
29 let gone = 0;
30
31 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
32 if (!r.ok) throw new Error(JSON.stringify(r.error));
33 return r.value;
34 };
35 const git = async (...args: string[]): Promise<string> => {
36 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
37 const r = await runGit(["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd: dir });
38 if (!r.ok) throw new Error(r.error.stderr);
39 return r.value.trim();
40 };
41 const pushEvents = (): { payload: string }[] =>
42 app.db
43 .query<{ payload: string }, [string]>(
44 "SELECT payload FROM event WHERE repo_id = ?1 AND kind = 'push' ORDER BY rowid",
45 )
46 .all(repo.id);
47
48 beforeAll(async () => {
49 dataDir = await mkdtemp(join(tmpdir(), "forge-reconcile-heads-"));
50 app = createApp({ dataDir, port: 0 });
51 alice = ok(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }));
52 repo = ok(await createRepo(app, alice, { name: "heads", visibility: "public" }));
53 ok(await commitToBranch(app, alice, repo, { message: "init", files: [{ path: "README.md", content: "x" }] }));
54 for (const branch of ["topic", "doomed"]) {
55 ok(await commitToBranch(app, alice, repo, { branch, message: branch, files: [{ path: `${branch}.txt`, content: "x" }] }));
56 const pull = ok(await openPull(app, alice, repo, { title: branch, body: "", baseBranch: "main", headBranch: branch }));
57 if (branch === "topic") moved = pull.number;
58 else gone = pull.number;
59 }
60 }, 60_000);
61
62 afterAll(async () => {
63 app.close();
64 await rm(dataDir, { recursive: true, force: true });
65 });
66
67 test("a branch moved behind the index's back moves its pull's head, once", async () => {
68 const before = getPull(app.db, repo.id, moved)!.headOid;
69 const doomedBefore = getPull(app.db, repo.id, gone)!.headOid;
70 const made = await git("commit-tree", `${before}^{tree}`, "-p", before, "-m", "behind the index");
71 await git("update-ref", "refs/heads/topic", made, before);
72 await git("update-ref", "-d", "refs/heads/doomed");
73 const events = pushEvents().length;
74
75 await reconcile(app);
76
77 expect(getPull(app.db, repo.id, moved)!.headOid).toBe(made);
78 const after = pushEvents();
79 expect(after.length).toBe(events + 1);
80 const payload = JSON.parse(after.at(-1)!.payload) as { updates: { ref: string; oldOid: string; newOid: string }[] };
81 expect(payload.updates).toEqual([{ ref: "refs/heads/topic", oldOid: before, newOid: made }]);
82 // The missing branch leaves its row as it was.
83 expect(getPull(app.db, repo.id, gone)!.headOid).toBe(doomedBefore);
84
85 await reconcile(app);
86 expect(getPull(app.db, repo.id, moved)!.headOid).toBe(made);
87 expect(pushEvents().length).toBe(events + 1);
88 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs