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

An adapter pull that turns failed shows one line in its writers' feed #10139

Merged opened this

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

Merged

mason/forge:mason/10135-an-adapter-pull-that-turns-failed-shows → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/10135-an-adapter-pull-that-turns-failed-shows was merged into main as ca3a533.

Review

Sign in to review this change.
8 files changed +193 −17
docs/features/sync-failed-feed.md added +18
@@ -0,0 +1,18 @@
1 # A failed adapter pull speaks once in the writers' feed (#10135)
2
3 When an app's adapter pull turns from ok (or from nothing) to failed, the
4 forge appends one `adapter.sync_failed` event: "<owner>/<repo> sync failed:
5 <reason>", linking to the app page. Repeated failures and recoveries append
6 nothing, so a broken source says it once, not on every pull.
7
8 - **Who sees it:** the `writers` audience (`src/core/event_audience.ts`): those
9 who may write the repository (`canWrite`). Readers and signed-out visitors
10 see nothing.
11 - **Where:** the home feed on the web, and the terminal's feed screen
12 (`src/tui/screens/feed.ts`). Both call `readFeed`, which reads
13 `candidatesFor` and its audience filter, so the two doors agree
14 (`test/feed-sync-failed.test.ts` checks both).
15 - **Reason:** stored cut to 200 characters (`PULL_REASON_MAX`), shown cut to
16 120 in the line.
17 - **Timing:** the event is appended after the run's transaction commits, so a
18 live viewer never hears of a pull that was rolled back.
src/actions/app_pull.ts +1 −1
@@ -38,6 +38,6 @@ export async function pullNow(
3838 if (!writer.ok) return writer;
3939 const record = getAppByRepo(app.db, repo.id);
4040 if (!record) return err("not_found");
41 const pulled = await pullAppNow(app.db, record.id, reachOf(record));
41 const pulled = await pullAppNow(app.db, record.id, reachOf(record), Date.now, app.bus);
4242 return pulled.ok ? ok(pulled.run) : err(pulled.why);
4343 }
src/apps/pull.ts +34 −8
@@ -9,7 +9,9 @@
99 */
1010
1111 import type { Database } from "bun:sqlite";
12 import { createPullRun, lastPullCursor, listPullRuns, type PullRun } from "../core/runs.ts";
12 import { getAppById } from "../core/apps.ts";
13 import { EventBus } from "../core/events.ts";
14 import { createPullRun, lastPullCursor, lastPullState, listPullRuns, type PullRun } from "../core/runs.ts";
1315
1416 /** A body over this many bytes is a failed pull, never parsed. */
1517 export const PULL_BODY_MAX = 64 * 1024;
@@ -18,7 +20,7 @@ export const PULL_COUNT_MAX = 1_000_000;
1820 /** The cursor is at most this many characters (code points). */
1921 export const PULL_CURSOR_MAX = 200;
2022 /** A failed pull's reason is cut to this many characters. */
21 const REASON_MAX = 200;
23 export const PULL_REASON_MAX = 200;
2224
2325 const FIELDS = ["pulled", "skipped", "cursor", "errors"] as const;
2426 const COUNTS = ["pulled", "skipped", "errors"] as const;
@@ -39,7 +41,7 @@ export type PullAnswer =
3941 /** An answer and how long the request took, as `recordPull` takes it. */
4042 export type PullOutcome = PullAnswer & { readonly durationMs: number };
4143
42 const fail = (reason: string): PullAnswer => ({ ok: false, reason: reason.slice(0, REASON_MAX) });
44 const fail = (reason: string): PullAnswer => ({ ok: false, reason: reason.slice(0, PULL_REASON_MAX) });
4345
4446 /**
4547 * Any control, format, private-use, unassigned or surrogate character, or a
@@ -110,13 +112,26 @@ const durationOf = (ms: number): number => (Number.isFinite(ms) && ms > 0 ? Math
110112 * the last good cursor stays where the next pull starts. A good answer whose
111113 * cursor equals the last good pull's is a repeat: it is recorded, with its
112114 * `pulled` counted as skipped and nothing written, so the same records never
113 * count as written twice. The read and the write are one transaction, so two
114 * recordings of the same answer cannot both see a fresh cursor.
115 * count as written twice. A failed pull after an ok one (or the first) also
116 * appends one `adapter.sync_failed` event, after the transaction commits, so
117 * a live viewer never hears of a run that rolled back; repeats and recoveries
118 * append none. The read and the write are one transaction, so two recordings
119 * of the same answer cannot both see a fresh cursor.
115120 */
outdated

Re-wrap this comment; the line is far over the width the others keep.

116 export function recordPull(db: Database, appId: string, outcome: PullOutcome): PullRun {
121 export function recordPull(
122 db: Database,
123 appId: string,
124 outcome: PullOutcome,
125 bus: Pick<EventBus, "append"> = new EventBus(db),
126 ): PullRun {
117127 const durationMs = durationOf(outcome.durationMs);
118 return db.transaction((): PullRun => {
128 let speak: { repoId: string; reason: string } | undefined;
129 const run = db.transaction((): PullRun => {
119130 if (!outcome.ok) {
131 // Only the turn from ok (or from nothing) to failed speaks.
132 const record = lastPullState(db, appId) !== "failed" ? getAppById(db, appId) : undefined;
133 const reason = outcome.reason.slice(0, PULL_REASON_MAX);
134 if (record !== undefined) speak = { repoId: record.repoId, reason };
120135 return createPullRun(db, {
121136 appId,
122137 state: "failed",
@@ -125,7 +140,7 @@ export function recordPull(db: Database, appId: string, outcome: PullOutcome): P
125140 failed: 0,
126141 cursor: "",
127142 durationMs,
128 reason: outcome.reason.slice(0, REASON_MAX),
143 reason,
129144 repeat: false,
130145 });
131146 }
@@ -142,6 +157,17 @@ export function recordPull(db: Database, appId: string, outcome: PullOutcome): P
142157 repeat,
143158 });
144159 }).immediate();
160 if (speak !== undefined) {
161 bus.append({
162 // The literal stays a literal: the audience-coverage scan
163 // (`core/event_audience.ts`) reads event kinds from `append` calls, and
164 // a constant would hide this one from it.
165 kind: "adapter.sync_failed",
166 repoId: speak.repoId,
167 payload: { appId, reason: speak.reason },
168 });
169 }
170 return run;
145171 }
146172
147173 /** How many runs `pullRunLines` answers when asked for none in particular, and at most. */
src/apps/pull_schedule.ts +20 −6
@@ -16,6 +16,7 @@
1616 import type { Database } from "bun:sqlite";
1717 import type { App } from "../app.ts";
1818 import { listApps, type AppRecord } from "../core/apps.ts";
19 import type { EventBus } from "../core/events.ts";
1920 import { getRepoById } from "../core/repos.ts";
2021 import { lastPullAt, type PullRun } from "../core/runs.ts";
2122 import { resolveOid } from "../git/merge.ts";
@@ -76,6 +77,7 @@ async function pullOnce<Held extends string = never>(
7677 reach: PullReach,
7778 now: () => number,
7879 hold: (adapter: AdapterSpec) => Held | undefined = () => undefined,
80 bus?: Pick<EventBus, "append">,
7981 ): Promise<PullOnce<Held>> {
8082 const port = reach.port();
8183 if (port === undefined) return { ok: false, why: "asleep" };
@@ -88,22 +90,34 @@ async function pullOnce<Held extends string = never>(
8890 if (held !== undefined) return { ok: false, why: held };
8991 const started = now();
9092 const answer = answerOf(await reach.post(port));
91 return { ok: true, run: recordPull(db, appId, { ...answer, durationMs: Math.max(0, now() - started) }) };
93 return { ok: true, run: recordPull(db, appId, { ...answer, durationMs: Math.max(0, now() - started) }, bus) };
9294 } finally {
9395 inFlight.delete(appId);
9496 }
9597 }
9698
9799 /** Pull one app if it is a running adapter and due: whether a pull was made. */
98 export async function pullApp(db: Database, appId: string, reach: PullReach, now: () => number = Date.now): Promise<boolean> {
100 export async function pullApp(
101 db: Database,
102 appId: string,
103 reach: PullReach,
104 now: () => number = Date.now,
105 bus?: Pick<EventBus, "append">,
106 ): Promise<boolean> {
99107 const notDue = (adapter: AdapterSpec) =>
100108 isPullDue(lastPullAt(db, appId), adapter.schedule.every, now()) ? undefined : ("not_due" as const);
101 return (await pullOnce(db, appId, reach, now, notDue)).ok;
109 return (await pullOnce(db, appId, reach, now, notDue, bus)).ok;
102110 }
103111
104112 /** Pull one app now, whatever its schedule (#10084): the recorded run, or why not. Never wakes it. */
105 export function pullAppNow(db: Database, appId: string, reach: PullReach, now: () => number = Date.now): Promise<PullOnce> {
106 return pullOnce(db, appId, reach, now);
113 export function pullAppNow(
114 db: Database,
115 appId: string,
116 reach: PullReach,
117 now: () => number = Date.now,
118 bus?: Pick<EventBus, "append">,
119 ): Promise<PullOnce> {
120 return pullOnce(db, appId, reach, now, undefined, bus);
107121 }
108122
109123 /** Who the forge says it is when it pulls: an admin of the app, named for itself. */
@@ -203,7 +217,7 @@ export async function pullDue(
203217 const pulled: string[] = [];
204218 for (const record of listApps(app.db)) {
205219 try {
206 if (await pullApp(app.db, record.id, reachOf(record), now)) pulled.push(record.slug);
220 if (await pullApp(app.db, record.id, reachOf(record), now, app.bus)) pulled.push(record.slug);
207221 } catch (error) {
208222 app.log.warn("could not pull an adapter", { app: record.slug, error: String(error) });
209223 }
src/core/event_audience.ts +15 −2
@@ -27,10 +27,10 @@ import type { Database } from "bun:sqlite";
2727 import type { ForgeEvent } from "./events.ts";
2828 import { getAppByRepo } from "./apps.ts";
2929 import { personOwners } from "./orgs.ts";
30 import { canAdmin, canEnterApp, canSeeOwnerFacts, getRepoById } from "./repos.ts";
30 import { canAdmin, canEnterApp, canWrite, canSeeOwnerFacts, getRepoById } from "./repos.ts";
3131 import type { User } from "./users.ts";
3232
33 export type EventAudience = "readers" | "admins" | "actor" | "app";
33 export type EventAudience = "readers" | "admins" | "actor" | "app" | "writers";
3434
3535 /**
3636 * Whether this viewer answers for a deleted repository: the forge's admin, who
@@ -243,6 +243,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
243243 kind === "repo.file_released"
244244 )
245245 return "admins";
246 // A broken adapter sync is for those who may write the repository (`apps/pull.ts`).
247 if (kind === "adapter.sync_failed") return "writers";
246248 // A fleet operation spans repositories; its receipt is its actor's. So is
247249 // a thought: it belongs to no repository and to nobody but its author.
248250 if (
@@ -291,6 +293,15 @@ export function audienceFilter(
291293 admins.set(repoId, yes);
292294 return yes;
293295 };
296 const writers = new Map<string, boolean>();
297 const mayWrite = (repoId: string): boolean => {
298 const known = writers.get(repoId);
299 if (known !== undefined) return known;
300 const repo = getRepoById(db, repoId);
301 const yes = repo !== undefined && canWrite(db, repo, viewer);
302 writers.set(repoId, yes);
303 return yes;
304 };
294305 const entrants = new Map<string, boolean>();
295306 const mayEnter = (repoId: string): boolean => {
296307 const known = entrants.get(repoId);
@@ -348,6 +359,8 @@ export function audienceFilter(
348359 return true;
349360 case "app":
350361 return event.repoId === null || mayEnter(event.repoId);
362 case "writers":
363 return event.repoId !== null && mayWrite(event.repoId);
351364 case "actor":
352365 return viewer !== undefined && event.actorId === viewer.id;
353366 case "admins":
src/core/runs.ts +11
@@ -1588,6 +1588,17 @@ export function listPullRuns(db: Database, appId: string, limit = 50): PullRun[]
15881588 .map(toPullRun);
15891589 }
15901590
1591 /** The state of an app's newest pull run; undefined before the first. */
1592 export function lastPullState(db: Database, appId: string): "ok" | "failed" | undefined {
1593 return (
1594 db
1595 .query<{ state: "ok" | "failed" }, [string]>(
1596 `SELECT state FROM pull_run WHERE app_id = ?1 ORDER BY id DESC LIMIT 1`,
1597 )
1598 .get(appId)?.state ?? undefined
1599 );
1600 }
1601
15911602 /** When an app's last pull, good or failed, was recorded, in ms since the epoch; undefined before the first. */
15921603 export function lastPullAt(db: Database, appId: string): number | undefined {
15931604 const row = db
src/feed/items.ts +13
@@ -161,6 +161,8 @@ export function quietRunsFor(
161161 .filter((e) => quiet.has(e.id) && repos.get(e.repoId!)?.archivedAt == null)
162162 .map((e) => ({ repoId: e.repoId!, actorId: e.actorId, id: e.id }));
163163 }
164 /** How much of a sync failure's stored reason (`PULL_REASON_MAX`, `apps/pull.ts`) a feed line shows. */
165 const SYNC_REASON_SHOWN = 120;
164166 const clip = (s: string, n = 120): string =>
165167 s.length > n ? `${s.slice(0, n - 1)}…` : s;
166168
@@ -630,6 +632,17 @@ function fromEvent(
630632 what: `${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind} run${subject} ${state === "failed" ? "failed" : "finished"}${state === "failed" ? (failure ? `: ${failure}` : "") : summary ? `: ${summary}` : ""}`,
631633 };
632634 }
635 case "adapter.sync_failed": {
636 const reason = clip(typeof p["reason"] === "string" ? p["reason"] : "", SYNC_REASON_SHOWN);
637 return {
638 ...common,
639 kind: "run",
640 link: `${base(repo)}/app`,
641 urgency: 4,
642 said: `${slug(repo)} sync failed: ${reason}`,
643 what: `sync failed: ${reason}`,
644 };
645 }
633646 case "pull.opened":
634647 return { ...common, kind: "pull", link: change, what: `opened a pull request${title ? `: ${clip(title)}` : ""}${toldWords}` };
635648 case "pull.merged":
test/feed-sync-failed.test.ts added +81
@@ -0,0 +1,81 @@
1 /** A pull turning failed speaks once in its writers' feed, and only to them (#10135). */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { newApp } from "../src/actions/apps.ts";
8 import { recordPull } from "../src/apps/pull.ts";
9 import { classifiedAudience } from "../src/core/event_audience.ts";
10 import { getRepo } from "../src/core/repos.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { readFeed } from "../src/feed/feed.ts";
13 import { candidatesFor } from "../src/feed/items.ts";
14 import { seedAdmin } from "./helpers/admin.ts";
15
16 describe("sync failed feed line", () => {
17 let dataDir: string;
18 let app: App;
19 let appId: string;
20
21 const ok = () => recordPull(app.db, appId, { ok: true, pulled: 1, skipped: 0, errors: 0, cursor: `c${Math.random()}`, durationMs: 1 }, app.bus);
22 const fail = (reason: string) => recordPull(app.db, appId, { ok: false, reason, durationMs: 1 }, app.bus);
23 const lines = (name: string) => {
24 const viewer = getUserByName(app.db, name)!;
25 return candidatesFor(app.db, viewer, Date.now(), undefined, false).filter((c) => c.said?.includes("sync failed"));
26 };
27
28 beforeAll(async () => {
29 dataDir = await mkdtemp(join(tmpdir(), "forge-sync-failed-"));
30 app = createApp({ dataDir, port: 0 });
31 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
32 const b = await createUser(app.db, {
33 origin: "seed", trusted: true, username: "bob",
34 email: "[email protected]", password: "a-good-passphrase",
35 });
36 if (!b.ok) throw new Error(b.error);
37 const made = await newApp(app, getUserByName(app.db, "alice")!, { name: "hello", visibility: "public" } as never);
38 if (!made.ok) throw new Error(String(made.error));
39 const repo = getRepo(app.db, "alice", "hello")!;
40 appId = (app.db.query("SELECT id FROM app WHERE repo_id = ?1").get(repo.id) as { id: string }).id;
41 });
42 afterAll(async () => {
43 app.db.close();
44 await rm(dataDir, { recursive: true, force: true });
45 });
46
47 test("ok, failed, failed, ok leaves exactly one line, for a writer and not a reader", () => {
48 ok();
49 fail("<b>status 500</b> " + "x".repeat(200));
50 fail("status 502 is not 2xx");
51 ok();
52 const mine = lines("alice");
53 expect(mine).toHaveLength(1);
54 expect(mine[0]!.said!.startsWith("alice/hello sync failed: <b>status 500</b> ")).toBe(true);
55 expect(mine[0]!.said!.length).toBeLessThanOrEqual("alice/hello sync failed: ".length + 120);
56 expect(mine[0]!.link).toBe("/alice/hello/app");
57 expect(lines("bob")).toHaveLength(0);
58 });
59
60 test("a failure after a recovery speaks again; the first ever pull failing speaks", () => {
61 fail("status 503 is not 2xx");
62 expect(lines("alice")).toHaveLength(2);
63 expect(classifiedAudience("adapter.sync_failed")).toBe("writers");
64 });
65
66 // The terminal feed (`tui/screens/feed.ts`) calls `readFeed`, which reads `candidatesFor`.
67 test("the terminal's feed read shows the line to a writer and not to a reader", () => {
68 const seen = (name: string) =>
69 readFeed(app.db, getUserByName(app.db, name)!, { limit: 50 }).items.filter((i) => i.what.includes("sync failed"));
70 expect(seen("alice").length).toBeGreaterThan(0);
71 expect(seen("bob")).toHaveLength(0);
72 });
73
74 test("the event is appended only once the run is committed", () => {
75 let inTransaction: boolean | undefined;
76 const bus = { append: (input: Parameters<App["bus"]["append"]>[0]) => ((inTransaction = app.db.inTransaction), app.bus.append(input)) };
77 ok();
78 recordPull(app.db, appId, { ok: false, reason: "late", durationMs: 1 }, bus);
79 expect(inTransaction).toBe(false);
80 });
81 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs