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

/backups patches from /events while a backup moves, instead of reloading the page every 5 seconds #2902

Merged opened this

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

Merged

scout/forge:scout/2890-backups-patches-from-events-while-a-back → main 5 commits fork 1 thread · 1 unresolved

  • Merged. scout/2890-backups-patches-from-events-while-a-back was merged into main as 558c740.

Review

Sign in to review this change.
8 files changed +238 −17
src/core/event_audience.ts +5 −1
@@ -42,6 +42,8 @@ export function eventAudience(kind: string): EventAudience {
4242 kind.startsWith("mail.") ||
4343 // A restore drill's verdict is the forge owner's (`snapshot/service.ts`).
4444 kind.startsWith("drill.") ||
45 // A snapshot moving is the forge owner's too, and has no repository.
46 kind.startsWith("backup.") ||
4547 // A report from outside waits for a person who decides; its arrival is
4648 // theirs to see, never the readers' (`actions/reports.ts`).
4749 kind.startsWith("report.") ||
@@ -151,7 +153,9 @@ export function audienceFilter(
151153 case "actor":
152154 return viewer !== undefined && event.actorId === viewer.id;
153155 case "admins":
154 return event.repoId !== null && isAdmin(event.repoId);
156 return event.repoId === null
157 ? viewer?.isAdmin === true && isActive(viewer)
158 : isAdmin(event.repoId);
155159 }
156160 };
157161 }
src/http/server.ts +2
@@ -138,6 +138,7 @@ import {
138138 backupsClear,
139139 backupsDestination,
140140 backupsDrill,
141 backupsLive,
141142 backupsMirror,
142143 backupsMirrorClear,
143144 backupsMirrorNow,
@@ -829,6 +830,7 @@ export function buildRouter(): Router<Handler> {
829830 router.add("POST", "/admin/invites", inviteMint);
830831 router.add("POST", "/admin/invites/:id/revoke", inviteRevoke);
831832 router.add("GET", "/settings/backups", backupsPage);
833 router.add("GET", "/settings/backups/live", backupsLive);
832834 router.add("POST", "/settings/backups/now", backupsNow);
833835 router.add("POST", "/settings/backups/drill", backupsDrill);
834836 router.add("POST", "/settings/backups/destination", backupsDestination);
src/http/sse.ts +1 −1
@@ -250,7 +250,7 @@ export function viewerStream(
250250 emit("run.log", { run: runId, ...frameOf(lines) });
251251 }, gone.signal);
252252 const unsubscribe = app.bus.subscribe((event: ForgeEvent) => {
253 if (event.repoId === null || !visible(event.repoId)) return;
253 if (event.repoId === null ? !event.kind.startsWith("backup.") : !visible(event.repoId)) return;
254254 if (!meant(event)) return;
255255 emit("forge", named(event), event.id);
256256 });
src/snapshot/service.ts +12
@@ -34,6 +34,8 @@ const log = createLog("snapshots");
3434 const NIGHTLY_HOUR_UTC = 8;
3535 /** After a failed snapshot, the next try is this soon (or the nightly, if sooner). */
3636 export const RETRY_MS = 3_600_000;
37 /** A moving snapshot tells the page how far it is no oftener than this. */
38 const PROGRESS_EVENT_MS = 2_000;
3739
3840 /**
3941 * A forge given nowhere to put a snapshot. Not a failure: the Backups page
@@ -110,6 +112,8 @@ export function startSnapshots(
110112 /** Test seam: how often the mirror is looked at. */
111113 readonly mirrorEveryMs?: number | undefined;
112114 readonly nightly?: boolean;
115 /** Test seam: how often a moving snapshot tells the outbox its progress. */
116 readonly progressEventMs?: number | undefined;
113117 /** Test seam: how soon a failed snapshot is tried again. */
114118 readonly retryMs?: number | undefined;
115119 readonly passphrase?: string | undefined;
@@ -152,6 +156,9 @@ export function startSnapshots(
152156 const store = current();
153157 if (store === undefined) throw new Nowhere();
154158 running = { objects: 0, bytes: 0, elapsedMs: 0 };
159 // Admin-only, no repository, nothing /backups does not already show.
160 app.bus.append({ kind: "backup.started" });
161 let toldAt = Date.now();
155162 const manifest = await takeSnapshot(
156163 {
157164 db: app.db,
@@ -161,6 +168,9 @@ export function startSnapshots(
161168 passphrase: options.passphrase,
162169 onLand: (p) => {
163170 running = p;
171 if (Date.now() - toldAt < (options.progressEventMs ?? PROGRESS_EVENT_MS)) return;
172 toldAt = Date.now();
173 app.bus.append({ kind: "backup.progress", payload: { objects: p.objects, bytes: p.bytes } });

Not blocking: every progress tick is kept as a row in the event table, about 30 a minute for as long as the backup runs, and no reader replays them (the resume path skips repository-less events). That's fine at this throttle. If the outbox ever gets pruned by kind, backup.progress is the first candidate.

164174 },
165175 },
166176 store,
@@ -177,6 +187,7 @@ export function startSnapshots(
177187 (m) => {
178188 last = { manifest: m, at: m.finishedAt };
179189 keep({ landed: m.name });
190 app.bus.append({ kind: "backup.finished", payload: { ok: true } });
180191 arm(untilNightly());
181192 return m;
182193 },
@@ -188,6 +199,7 @@ export function startSnapshots(
188199 }
189200 log.error("snapshot failed", { error: String(error) });
190201 keep({ error: error instanceof Error ? error.message : String(error) });
202 app.bus.append({ kind: "backup.finished", payload: { ok: false } });
191203 arm(Math.min(options.retryMs ?? RETRY_MS, untilNightly()));
192204 throw error;
193205 },
src/web/pages/backups.ts +31 −13
@@ -26,13 +26,15 @@ import { resolveDestination, sealedSet } from "../../snapshot/destination.ts";
2626 import { keyOf } from "../../snapshot/snapshot.ts";
2727 import { formatMinutes, formatSpan, timeByPart, type DrillResult } from "../../snapshot/drill.ts";
2828 import { lastDrill } from "../../snapshot/drills.ts";
29 import { snapshotsOf } from "../../snapshot/service.ts";
29 import type { Store } from "../../snapshot/store.ts";
30 import { snapshotsOf, type SnapshotsHandle } from "../../snapshot/service.ts";
3031 import { lastSnapshotOutcome } from "../../snapshot/outcome.ts";
3132 import type { Ctx } from "../../http/context.ts";
3233 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
3334 import { field, readForm, type Form } from "../form.ts";
3435 import { html, when, type Html } from "../html.ts";
35 import { flashTo, page, redirect } from "../layout.ts";
36 import { liveRegionsJs } from "../client.ts";
37 import { flashTo, fragment, page, redirect } from "../layout.ts";
3638 import { btn, card, empty, fileSize, icon, row, time } from "../ui.ts";
3739 import { accountSections } from "./origins.ts";
3840
@@ -42,11 +44,11 @@ function requireAdmin(ctx: Ctx): Response | undefined {
4244 return undefined;
4345 }
4446
45 function shell(ctx: Ctx, body: Html, moving = false): Response {
47 function shell(ctx: Ctx, body: Html): Response {
4648 return page({
4749 ctx,
4850 title: "Backups",
49 ...(moving ? { refresh: { seconds: 5 } } : {}),
51 scriptJs: liveRegionsJs,
5052 body: html`<div class="page-head"><h1>Backups</h1></div>
5153 ${settingsLayout(
5254 accountSections("backups", { admin: true }),
@@ -245,20 +247,40 @@ export async function backupsPage(ctx: Ctx): Promise<Response> {
245247 if (denied) return denied;
246248 const svc = snapshotsOf(ctx.app);
247249 const form = html`${destinationCard(ctx)}${mirrorCard(ctx)}${domainsCard(ctx)}`;
248 const now = Date.now();
249250 if (svc === undefined || svc.store === undefined)
250251 return shell(
251252 ctx,
252253 html`${recoveryLine({
253254 reachable: false,
254255 unreachable: "no snapshot store is set",
255 now,
256 now: Date.now(),
256257 })}${empty({
257258 icon: "download",
258259 title: "No snapshots are taken",
259260 note: "This forge was given nowhere to put them. On the platform an app's bucket (S3_*) is the place; on a machine, FORGE_SNAPSHOT_DIR names a directory. Seal a bucket below, or restart with one set, and a snapshot is taken every night at 08:00 UTC.",
260261 })}${form}`,
261262 );
263 return shell(ctx, html`${await movingPart(ctx, svc, svc.store)}${form}`);
264 }
265
266 /** GET /settings/backups/live — the moving part alone, which the page refetches on an event. */
267 export async function backupsLive(ctx: Ctx): Promise<Response> {
268 if (!ctx.auth.user?.isAdmin) return fragment(html``, { status: 404 });
269 const svc = snapshotsOf(ctx.app);
270 if (svc === undefined || svc.store === undefined) return fragment(html``, { status: 404 });
271 return fragment(await movingPart(ctx, svc, svc.store), { request: ctx.request });
272 }
273
274 /**
275 * What changes while a snapshot is taken: the recovery line, the progress,
276 * the outcome and the list. It patches in place from `backup.*` events.
277 */
278 async function movingPart(
279 ctx: Ctx,
280 svc: SnapshotsHandle,
281 store: Store,
282 ): Promise<Html> {
283 const now = Date.now();
262284 // A failure is kept on disk, so it is shown after a restart too; this
263285 // process's own failure is newer when the file could not be written.
264286 const kept = lastSnapshotOutcome(ctx.app.config.dataDir);
@@ -292,13 +314,11 @@ export async function backupsPage(ctx: Ctx): Promise<Response> {
292314 ? html`<pre class="backups-restore">
293315 bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
294316 : null;
295 return shell(
296 ctx,
297 html`${recovery}${card(
317 return html`<div id="backups-live" data-live="backup.*" data-live-src="/settings/backups/live">${recovery}${card(
298318 html`<p>
299319 Every night at 08:00 UTC the forge writes what cannot be rebuilt — its
300320 database, its keys, every repository, every app's production data, the
301 crew's logs — to <code>${svc.store.where}</code>, and keeps the newest
321 crew's logs — to <code>${store.where}</code>, and keeps the newest
302322 fourteen.
303323 </p>
304324 ${keyNote(svc.wraps, snapshots[0])}
@@ -384,9 +404,7 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
384404 },
385405 )
386406 }
387 ${form}`,
388 svc.progress() !== undefined,
389 );
407 </div>`;
390408 }
391409
392410 const SOURCE: Record<string, string> = {
test/backups-live.test.ts added +186
@@ -0,0 +1,186 @@
1 /**
2 * A backup moving tells the outbox (`backup.started`, `.progress`,
3 * `.finished`), and /backups patches from them: no meta refresh while one
4 * moves, a region that names its events and its own address, and events
5 * only an admin's stream carries.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtempSync, rmSync } from "node:fs";
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 { listEvents } from "../src/core/events.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
16 import { dirStore } from "../src/snapshot/store.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 let root = "";
22 let app: App;
23 let svc: SnapshotsHandle;
24 let servers: Servers;
25 let release: () => void = () => {};
26 let gate: Promise<void> | undefined;
27 let reached: () => void = () => {};
28 let atPut = new Promise<void>((r) => (reached = r));
29
30 const login = async (username: string, password: string) => {
31 const r = await fetch(`${servers.url}/signin`, {
32 method: "POST",
33 headers: { origin: servers.url },
34 body: new URLSearchParams({ username, password }),
35 redirect: "manual",
36 });
37 return (r.headers.get("set-cookie") ?? "").split(";")[0]!;
38 };
39
40 beforeAll(async () => {
41 root = mkdtempSync(join(tmpdir(), "forge-backups-live-"));
42 app = createApp({ dataDir: root, port: 0 });
43 for (const [username, password, adminIfFirst] of [
44 ["alice", "a-good-passphrase", true],
45 ["bob", "another-good-passphrase", false],
46 ] as const) {
47 const u = await createUser(app.db, {
48 origin: "seed",
49 trusted: true,
50 username,
51 email: `${username}@forge.local`,
52 password,
53 adminIfFirst,
54 });
55 if (!u.ok) throw new Error(u.error);
56 }
57 const inner = dirStore(join(root, "store"));
58 // Holds the first put until the test lets go, so the snapshot is moving.
59 const store = {
60 ...inner,
61 async put(key: string, body: Uint8Array | string) {
62 reached();
63 await gate;
64 return inner.put(key, body);
65 },
66 };
67 svc = startSnapshots(app, store, { version: "t", nightly: false });
68 servers = await serve(app);
69 });
70 afterAll(async () => {
71 release();
72 servers.stop();
73 await svc.stop();
74 app.close();
75 rmSync(root, { recursive: true, force: true });
76 });
77
78 const kinds = () =>
79 listEvents(app.db, {})
80 .map((e) => e.kind)
81 .filter((k) => k.startsWith("backup."))
82 .reverse();
83
84 test("a backup moving emits its events, and /backups does not reload itself meanwhile", async () => {
85 gate = new Promise<void>((r) => (release = r));
86 atPut = new Promise<void>((r) => (reached = r));
87 const done = svc.now();
88 await atPut;
89 expect(kinds()).toEqual(["backup.started"]);
90
91 const alice = await login("alice", "a-good-passphrase");
92 const moving = await fetch(`${servers.url}/settings/backups`, { headers: { cookie: alice } });
93 const text = await moving.text();
94 expect(text).not.toContain('http-equiv="refresh"');
95 expect(text).toContain('id="backups-live"');
96 expect(text).toContain('data-live="backup.*"');
97 expect(text).toContain('data-live-src="/settings/backups/live"');
98 const part = await fetch(`${servers.url}/settings/backups/live`, { headers: { cookie: alice } });
99 expect(part.status).toBe(200);
100 expect(await part.text()).toContain('id="backups-live"');
101
102 release();
103 await done;
104 const seen = kinds();
105 expect(seen[0]).toBe("backup.started");
106 expect(seen.at(-1)).toBe("backup.finished");
107 const finished = listEvents(app.db, { kind: "backup.finished" })[0]!;
108 expect(finished.payload).toEqual({ ok: true });
109 expect(finished.repoId).toBeNull();
110 });
111
112 test("a moving snapshot tells its progress, no oftener than the throttle", async () => {
113 const inner = dirStore(join(root, "store-slow"));
114 const slow = {
115 ...inner,
116 async put(key: string, body: Uint8Array | string) {
117 await Bun.sleep(200);
118 return inner.put(key, body);
119 },
120 };
121 const mine = startSnapshots(app, slow, { version: "t", nightly: false, progressEventMs: 100 });
122 const from = listEvents(app.db, {})[0]?.id ?? "";
123 await mine.now();
124 await mine.stop();
125 const progress = listEvents(app.db, { kind: "backup.progress", afterId: from, limit: 500 }).reverse();
126 expect(progress.length).toBeGreaterThanOrEqual(1);
127 for (const e of progress) {
128 expect(typeof e.payload["objects"]).toBe("number");
129 expect(typeof e.payload["bytes"]).toBe("number");
130 expect(e.repoId).toBeNull();
131 }
132 for (let i = 1; i < progress.length; i++)
133 expect(Date.parse(progress[i]!.createdAt) - Date.parse(progress[i - 1]!.createdAt)).toBeGreaterThanOrEqual(90);
134 });
135
136 test("a failed snapshot ends in backup.finished {ok:false}, and the live part shows it", async () => {
137 const inner = dirStore(join(root, "store-bad"));
138 const bad = {
139 ...inner,
140 async put(): Promise<void> {
141 throw new Error("the bucket said no");
142 },
143 };
144 const mine = startSnapshots(app, bad, { version: "t", nightly: false });
145 await expect(mine.now()).rejects.toThrow();
146 const seen = kinds();
147 expect(seen.at(-1)).toBe("backup.finished");
148 expect(listEvents(app.db, { kind: "backup.finished" })[0]!.payload).toEqual({ ok: false });
149 const alice = await login("alice", "a-good-passphrase");
150 const part = await fetch(`${servers.url}/settings/backups/live`, { headers: { cookie: alice } });
151 const text = await part.text();
152 expect(text).toContain("The last snapshot failed");
153 expect(text).toContain("the bucket said no");
154 await mine.stop();
155 });
156
157 test("the live part and the stream are an admin's alone", async () => {
158 const bob = await login("bob", "another-good-passphrase");
159 const part = await fetch(`${servers.url}/settings/backups/live`, { headers: { cookie: bob } });
160 expect(part.status).toBe(404);
161 expect((await fetch(`${servers.url}/settings/backups/live`)).status).toBeGreaterThanOrEqual(300);
162
163 const frames = async (cookie: string): Promise<string> => {
164 const ac = new AbortController();
165 const res = await fetch(`${servers.url}/events`, { headers: { cookie }, signal: ac.signal });
166 const reader = res.body!.getReader();
167 // Let the stream subscribe, then move a backup's worth of events.
168 await Bun.sleep(50);
169 app.bus.append({ kind: "backup.started" });
170 await Bun.sleep(100);
171 let got = "";
172 const until = Date.now() + 500;
173 while (Date.now() < until && !got.includes("backup.")) {
174 const { value } = await Promise.race([
175 reader.read(),
176 Bun.sleep(200).then(() => ({ value: undefined })),
177 ]);
178 if (value) got += new TextDecoder().decode(value);
179 }
180 ac.abort();
181 return got;
182 };
183 const alice = await login("alice", "a-good-passphrase");
184 expect(await frames(alice)).toContain("backup.started");
185 expect(await frames(bob)).not.toContain("backup.started");
186 });
test/design-audit.test.ts +1 −1
@@ -11,7 +11,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8");
1111
1212 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
1313 const NOT_PAGES =
14 /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|sitemap\.xml$|manifest\.webmanifest$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/)/;
14 /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|sitemap\.xml$|manifest\.webmanifest$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$)/;
1515
1616 const getRoutes = [
1717 ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g),
test/no-polling.test.ts −1
@@ -13,7 +13,6 @@ const ALLOWED: Record<string, { allow: string[]; why: string }> = {
1313 "src/web/layout.ts": { allow: ['http-equiv="refresh"'], why: "defines the refresh option and its meta tag; callers are listed here" },
1414 "src/web/pages/migrate.ts": { allow: ["refresh: {", 'http-equiv="refresh"'], why: "migrate progress runs in a process that emits no events" },
1515 "src/web/pages/browse.ts": { allow: ["refresh: {"], why: "mirror progress runs in a process that emits no events" },
16 "src/web/pages/backups.ts": { allow: ["refresh: {"], why: "a backup moving emits no event" },
1716 "src/web/client.ts": { allow: ["data-live-every", "setInterval"], why: "the data-live-every mechanism itself, and clocks that only redraw text" },
1817 "src/web/fleet-graph.ts": { allow: ["setInterval"], why: "no fleet event yet; remove once it has one" },
1918 };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs