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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9935-boot-repairs-an-open-pull-whose-recorded → main 1 commit fork
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 | ||
| 14 | 14 | 1. **The repository holds truth for code.** Git is the durable log. The database |
| 15 | 15 | indexes git, and it is the only record of what git does not hold: issues, |
| 16 | 16 | 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. | |
| 18 | 23 | 2. **One process, one file of state.** A single Bun process serves git, web, |
| 19 | 24 | and API. SQLite is the only database. Everything a forge instance _is_ lives |
| 20 | 25 | 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. | ||
| 195 | 200 | |
| 196 | 201 | State transitions use compare-and-swap (`UPDATE … WHERE state = ?`), and every |
| 197 | 202 | 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). | |
| 200 | 206 | |
| 201 | 207 | **The one exception to "the database is derived."** A connected origin's |
| 202 | 208 | access token is third-party state: it cannot be rebuilt from the bare repos, |
src/core/reconcile.ts +134 −26
| @@ -1,24 +1,54 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 7 | 13 | * |
| 8 | 14 | * It runs before the listeners come up, which makes it the one piece of |
| 9 | 15 | * startup a growing forge waits on: two thousand repos done one at a time is |
| 10 | 16 | * four filesystem round trips each, in series, while nothing is being served. |
| 11 | 17 | * 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. | |
| 13 | 20 | */ |
| 14 | 21 | |
| 15 | 22 | import type { App } from "../app.ts"; |
| 16 | 23 | import { installHooks } from "../git/hooks.ts"; |
| 24 | import { listRefs } from "../git/read.ts"; | |
| 17 | 25 | import { repoDir } from "../git/repos.ts"; |
| 18 | 26 | import { repoExists } from "../git/repos.ts"; |
| 27 | import { getPull, trackPullHead } from "./pulls.ts"; | |
| 19 | 28 | |
| 20 | 29 | /** Repos reconciled at once. Filesystem work, so wider than the git limit. */ |
| 21 | 30 | 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 | } | |
| 22 | 52 | |
| 23 | 53 | export async function reconcile(app: App): Promise<void> { |
| 24 | 54 | const rows = app.db |
| @@ -29,31 +59,109 @@ export async function reconcile(app: App): Promise<void> { | ||
| 29 | 59 | .all(); |
| 30 | 60 | |
| 31 | 61 | 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 | }); | |
| 49 | 73 | } |
| 50 | }; | |
| 51 | await Promise.all( | |
| 52 | Array.from({ length: Math.min(CONCURRENCY, rows.length) }, worker), | |
| 53 | ); | |
| 74 | }); | |
| 75 | ||
| 76 | const heads = await reconcileHeads(app); | |
| 54 | 77 | |
| 55 | 78 | app.log.info("reconciled", { |
| 56 | 79 | repos: rows.length, |
| 57 | 80 | hooks_refreshed: refreshed, |
| 81 | heads_repaired: heads, | |
| 58 | 82 | }); |
| 59 | 83 | } |
| 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 | }); |