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

The forge calls each running adapter's /pull on its schedule and records the run #10060

Merged opened this

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

Merged

torque/forge:torque/10043-the-forge-calls-each-running-adapter-s-p → main 2 commits fork

  • Merged. torque/10043-the-forge-calls-each-running-adapter-s-p was merged into main as 0cef25d.

Review

Sign in to review this change.
6 files changed +380 −1
docs/ADAPTERS.md +8
@@ -114,3 +114,11 @@ For a bad answer the forge:
114114
115115 A good answer is recorded as a run with its counts, its cursor and its
116116 duration.
117
118 The schedule is kept by `src/apps/pull_schedule.ts`: once a minute the forge
119 pulls each app whose production is running, whose `forge.json` on the default
120 branch has an `adapter` block, and whose last pull, good or failed, is at least
121 `schedule.every` seconds old. A sleeping app is never woken to be pulled; it is
122 pulled on the first minute it is running and due. The request goes over
123 loopback, waits at most 10 seconds and reads at most 64 KB. Two pulls of the
124 same app never overlap.
src/apps/pull.ts +1 −1
@@ -4,7 +4,7 @@
44 * raw body, it answers four checked fields or a failed pull with a short
55 * reason naming what was wrong. `recordPull` writes the outcome as a run of
66 * kind `pull` through `core/runs.ts`. Nothing here reaches the network: the
7 * caller that sends the request and times it comes later (#437).
7 * caller that sends the request and times it is `pull_schedule.ts` (#10043).
88 */
99
1010 import type { Database } from "bun:sqlite";
src/apps/pull_schedule.ts added +176
@@ -0,0 +1,176 @@
1 /**
2 * An adapter pulled on its schedule (#10043, docs/ADAPTERS.md): on a tick the
3 * forge POSTs `/pull` to each app whose production is running, whose
4 * `forge.json` on the default branch has an `adapter` block
5 * (`apps/adapter.ts`), and whose last pull is at least `schedule.every`
6 * seconds old. The answer is judged by `parsePullAnswer` and kept by
7 * `recordPull` (`apps/pull.ts`). The request goes over the same loopback reach
8 * the scheduled tools use (`feature_tools.ts`): 64 KB, 10 s. An app that is
9 * not running is skipped, never woken; two pulls of one app never overlap.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import type { App } from "../app.ts";
14 import { listApps, type AppRecord } from "../core/apps.ts";
15 import { getRepoById } from "../core/repos.ts";
16 import { lastPullAt } from "../core/runs.ts";
17 import { resolveOid } from "../git/merge.ts";
18 import { repoDir } from "../git/repos.ts";
19 import { parseAdapter, type AdapterSpec } from "./adapter.ts";
20 import { readFileAt } from "./checkout.ts";
21 import { parsePullAnswer, PULL_BODY_MAX, recordPull, type PullAnswer } from "./pull.ts";
22 import { appsRuntime } from "./runtime.ts";
23
24 /** How long the forge waits for one `/pull` answer, as for a scheduled tool. */
25 export const PULL_WAIT_MS = 10_000;
26
27 /** Whether a pull is due: never pulled, or at least `everySeconds` since the last one (ms clock). */
28 export function isPullDue(lastAt: number | undefined, everySeconds: number, now: number): boolean {
29 return lastAt === undefined || now - lastAt >= everySeconds * 1000;
30 }
31
32 /** What the forge heard from `POST /pull`: a status and the body as text, or why there was none. */
33 export type PullReply = { readonly status: number; readonly body: string } | { readonly error: string };
34
35 /** What `pullApp` needs from the running forge for one app; the seam a test replaces. */
36 export interface PullReach {
37 /** The production port when it is running; undefined otherwise (asleep, stopped, never deployed). */
38 port(): number | undefined;
39 /** The `adapter` block the default branch declares now; undefined when it is not an adapter. */
40 adapter(): Promise<AdapterSpec | undefined>;
41 /** Send `POST /pull` to the app on `port`. */
42 post(port: number): Promise<PullReply>;
43 }
44
45 const inFlight = new Set<string>();
46
47 /** The answer a reply is worth: a reply with no status is a failed pull naming why. */
48 export function answerOf(reply: PullReply): PullAnswer {
49 if ("error" in reply) return { ok: false, reason: reply.error };
50 return parsePullAnswer(reply.status, reply.body);
51 }
52
53 /**
54 * Pull one app if it is a running adapter and due: whether a pull was made.
55 * The checks run cheapest first — running, then in flight, then declared,
56 * then due — so a sleeping app costs no git read and is never reached.
57 */
58 export async function pullApp(db: Database, appId: string, reach: PullReach, now: () => number = Date.now): Promise<boolean> {
59 const port = reach.port();
60 if (port === undefined) return false;
61 if (inFlight.has(appId)) return false;
62 inFlight.add(appId);
63 try {
64 const adapter = await reach.adapter();
65 if (adapter === undefined) return false;
66 if (!isPullDue(lastPullAt(db, appId), adapter.schedule.every, now())) return false;
67 const started = now();
68 const answer = answerOf(await reach.post(port));
69 recordPull(db, appId, { ...answer, durationMs: Math.max(0, now() - started) });
70 return true;
71 } finally {
72 inFlight.delete(appId);
73 }
74 }
75
76 /** POST `/pull` on loopback: at most 64 KB read (one byte over, so the judge says so), 10 s. */
77 export async function postPull(port: number, waitMs: number = PULL_WAIT_MS): Promise<PullReply> {
78 try {
79 const res = await fetch(`http://127.0.0.1:${port}/pull`, {
80 method: "POST",
81 headers: { "content-type": "application/json", accept: "application/json" },
82 body: "{}",
83 redirect: "manual",
84 signal: AbortSignal.timeout(waitMs),
85 });
86 const reader = res.body?.getReader();
87 const chunks: Uint8Array[] = [];
88 let total = 0;
89 while (reader !== undefined) {
90 const { done, value } = await reader.read();
91 if (done) break;
92 total += value.length;
93 if (total > PULL_BODY_MAX) {
94 await reader.cancel();
95 return { error: `body is over ${PULL_BODY_MAX} bytes` };
96 }
97 chunks.push(value);
98 }
99 return { status: res.status, body: Buffer.concat(chunks).toString("utf8") };
100 } catch {
101 return { error: "the app did not answer" };
102 }
103 }
104
105 /** The adapter block by default-branch tip: a tick costs a git read only when a tip is new. */
106 const declaredByTip = new Map<string, AdapterSpec | undefined>();
107 const DECLARED_MAX = 256;
108
109 async function declaredAdapter(app: App, record: AppRecord): Promise<AdapterSpec | undefined> {
110 const repo = getRepoById(app.db, record.repoId);
111 if (repo === undefined) return undefined;
112 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
113 const tip = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
114 if (tip === undefined) return undefined;
115 if (declaredByTip.has(tip)) return declaredByTip.get(tip);
116 const text = await readFileAt(dir, tip, "forge.json");
117 let spec: AdapterSpec | undefined;
118 if (text !== undefined) {
119 try {
120 const parsed: unknown = JSON.parse(text);
121 if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
122 const read = parseAdapter((parsed as Record<string, unknown>)["adapter"]);
123 spec = read.ok ? read.value : undefined; // a bad block is the deploy's to report
124 }
125 } catch {
126 spec = undefined;
127 }
128 }
129 if (declaredByTip.size >= DECLARED_MAX) declaredByTip.clear();
130 declaredByTip.set(tip, spec);
131 return spec;
132 }
133
134 /** What `pullApp` reaches for one app in the real runtime: only a production that is running, never woken. */
135 export function runtimePullReach(app: App, record: AppRecord): PullReach {
136 return {
137 port: () => {
138 const status = appsRuntime(app)?.envStatus(record, "production");
139 return status?.state === "running" ? status.port : undefined;
140 },
141 adapter: () => declaredAdapter(app, record),
142 post: (port) => postPull(port),
143 };
144 }
145
146 /** Every app, one after another; the apps pulled. `reachOf` is the seam a test replaces. */
147 export async function pullDue(
148 app: App,
149 now: () => number = Date.now,
150 reachOf: (record: AppRecord) => PullReach = (record) => runtimePullReach(app, record),
151 ): Promise<string[]> {
152 const pulled: string[] = [];
153 for (const record of listApps(app.db)) {
154 try {
155 if (await pullApp(app.db, record.id, reachOf(record), now)) pulled.push(record.slug);
156 } catch (error) {
157 app.log.warn("could not pull an adapter", { app: record.slug, error: String(error) });
158 }
159 }
160 return pulled;
161 }
162
163 const TICK_MS = 60_000;
164
165 /** The timer. Off under `bun test` (NODE_ENV=test) so no sweep outlives a test; a test calls `pullDue`. */
166 export function startPullSchedule(app: App, tickMs: number = TICK_MS): { stop(): void } | undefined {
167 if (process.env["NODE_ENV"] === "test") return undefined;
168 let running = false;
169 const tick = setInterval(() => {
170 if (running) return;
171 running = true;
172 void pullDue(app).finally(() => (running = false));
173 }, tickMs);
174 tick.unref();
175 return { stop: () => clearInterval(tick) };
176 }
src/core/runs.ts +12
@@ -1587,3 +1587,15 @@ export function listPullRuns(db: Database, appId: string, limit = 50): PullRun[]
15871587 .all(appId, Math.max(1, Math.min(500, Math.trunc(limit))))
15881588 .map(toPullRun);
15891589 }
1590
1591 /** When an app's last pull, good or failed, was recorded, in ms since the epoch; undefined before the first. */
1592 export function lastPullAt(db: Database, appId: string): number | undefined {
1593 const row = db
1594 .query<{ created_at: string }, [string]>(
1595 `SELECT created_at FROM pull_run WHERE app_id = ?1 ORDER BY id DESC LIMIT 1`,
1596 )
1597 .get(appId);
1598 if (row === null) return undefined;
1599 const at = Date.parse(row.created_at);
1600 return Number.isFinite(at) ? at : undefined;
1601 }
src/http/server.ts +3
@@ -471,6 +471,7 @@ import { captureStore, loopWatch, perfRecord, readLedger } from "../perf/live.ts
471471 import { classOf, countryOf, repoOf, signatureOf } from "../perf/traffic.ts";
472472 import { createTrafficWriter } from "../perf/traffic_store.ts";
473473 import { startFeatureSchedule } from "../apps/feature_schedule.ts";
474 import { startPullSchedule } from "../apps/pull_schedule.ts";
474475 import { startGithubPull } from "../perf/github_traffic.ts";
475476 import { appSourcesPage } from "../web/pages/sources.ts";
476477 import { createViewWriter } from "../perf/views_store.ts";
@@ -2312,6 +2313,7 @@ export async function serve(app: App): Promise<Servers> {
23122313 const viewWriter = createViewWriter(app.db, app.log);
23132314 const githubPull = startGithubPull(app.db, app.log);
23142315 const featureSchedule = startFeatureSchedule(app);
2316 const pullSchedule = startPullSchedule(app);
23152317 const readWriter = createReadWriter(app.db, readLedger, app.log);
23162318 const counted = (
23172319 owner: App,
@@ -2635,6 +2637,7 @@ export async function serve(app: App): Promise<Servers> {
26352637 viewWriter.stop();
26362638 githubPull?.stop();
26372639 featureSchedule?.stop();
2640 pullSchedule?.stop();
26382641 readWriter.stop();
26392642 // A child's hooks stop answering before its database closes.
26402643 for (const key of [...new Set([...childHttps.keys(), ...childInternals.keys()])]) letGo(key);
test/pull-schedule.test.ts added +180
@@ -0,0 +1,180 @@
1 /**
2 * The forge pulls a running adapter on its schedule (#10043): a due adapter
3 * is pulled once, one not yet due is not, a sleeping app is never reached, a
4 * failing answer is recorded failed, and two pulls of one app never overlap.
5 * The clock and the reach are fakes; one test posts to a loopback server to
6 * prove the real request's path, size cap and shape.
7 */
8
9 import { Database } from "bun:sqlite";
10 import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
11 import { PULL_BODY_MAX } from "../src/apps/pull.ts";
12 import {
13 isPullDue,
14 postPull,
15 pullApp,
16 pullDue,
17 startPullSchedule,
18 type PullReach,
19 type PullReply,
20 } from "../src/apps/pull_schedule.ts";
21 import { listPullRuns } from "../src/core/runs.ts";
22 import { migrationFiles } from "../src/core/schema.ts";
23 import { migrate } from "../src/kernel/migrate.ts";
24
25 const EVERY = 900; // seconds
26 const good = JSON.stringify({ pulled: 4, skipped: 1, cursor: "c1", errors: 0 });
27
28 let db: Database;
29 let posts: number;
30 let reply: () => Promise<PullReply>;
31
32 const reach = (over: Partial<PullReach> = {}): PullReach => ({
33 port: () => 4321,
34 adapter: async () => ({ schedule: { every: EVERY } }),
35 post: async () => {
36 posts += 1;
37 return reply();
38 },
39 ...over,
40 });
41
42 /** A pull recorded `agoMs` before the real clock, so "last pull" is under the test's control. */
43 const pulledAgo = (appId: string, agoMs: number): void => {
44 db.run(
45 `INSERT INTO pull_run (id, app_id, state, cursor, created_at) VALUES (?1, ?2, 'ok', 'c0', ?3)`,
46 ["00000000000000000000000000", appId, new Date(Date.now() - agoMs).toISOString()],
47 );
48 };
49
50 beforeEach(() => {
51 db = new Database(":memory:");
52 migrate(db, migrationFiles);
53 posts = 0;
54 reply = async () => ({ status: 200, body: good });
55 });
56 afterEach(() => db.close());
57
58 describe("isPullDue", () => {
59 test("never pulled is due; inside the interval is not; at it is", () => {
60 expect(isPullDue(undefined, EVERY, 5)).toBe(true);
61 expect(isPullDue(1000, EVERY, 1000 + EVERY * 1000 - 1)).toBe(false);
62 expect(isPullDue(1000, EVERY, 1000 + EVERY * 1000)).toBe(true);
63 });
64 });
65
66 describe("pullApp", () => {
67 test("a due adapter is pulled once and its answer recorded; the next tick inside the interval does nothing", async () => {
68 let t = Date.now();
69 const clock = () => t;
70 expect(await pullApp(db, "a1", reach(), clock)).toBe(true);
71 expect(posts).toBe(1);
72 const [run] = listPullRuns(db, "a1");
73 expect(run).toMatchObject({ state: "ok", written: 4, skipped: 1, cursor: "c1", failed: 0 });
74 t += 60_000; // one tick later: not due
75 expect(await pullApp(db, "a1", reach(), clock)).toBe(false);
76 expect(posts).toBe(1);
77 t += EVERY * 1000; // a whole interval later: due again
78 expect(await pullApp(db, "a1", reach(), clock)).toBe(true);
79 expect(posts).toBe(2);
80 });
81
82 test("an adapter pulled less than `every` seconds ago is not pulled", async () => {
83 pulledAgo("a1", (EVERY - 1) * 1000);
84 expect(await pullApp(db, "a1", reach())).toBe(false);
85 expect(posts).toBe(0);
86 expect(listPullRuns(db, "a1")).toHaveLength(1);
87 });
88
89 test("a failing answer is recorded as failed with its reason", async () => {
90 reply = async () => ({ status: 500, body: "no" });
91 expect(await pullApp(db, "a1", reach())).toBe(true);
92 const [run] = listPullRuns(db, "a1");
93 expect(run?.state).toBe("failed");
94 expect(run?.reason).toBe("status 500 is not 2xx");
95 });
96
97 test("no answer at all is recorded as failed", async () => {
98 reply = async () => ({ error: "the app did not answer" });
99 await pullApp(db, "a1", reach());
100 expect(listPullRuns(db, "a1")[0]).toMatchObject({ state: "failed", reason: "the app did not answer" });
101 });
102
103 test("a sleeping app is never reached, and its manifest is not read", async () => {
104 let read = false;
105 const asleep = reach({ port: () => undefined, adapter: async () => ((read = true), { schedule: { every: EVERY } }) });
106 expect(await pullApp(db, "a1", asleep)).toBe(false);
107 expect(posts).toBe(0);
108 expect(read).toBe(false);
109 expect(listPullRuns(db, "a1")).toEqual([]);
110 });
111
112 test("an app without an adapter block is not pulled", async () => {
113 expect(await pullApp(db, "a1", reach({ adapter: async () => undefined }))).toBe(false);
114 expect(posts).toBe(0);
115 });
116
117 test("a second pull of the same app while one is in flight does not overlap it", async () => {
118 let release!: () => void;
119 reply = () => new Promise((resolve) => (release = () => resolve({ status: 200, body: good })));
120 const first = pullApp(db, "a1", reach());
121 await Bun.sleep(0);
122 expect(await pullApp(db, "a1", reach())).toBe(false);
123 release();
124 expect(await first).toBe(true);
125 expect(posts).toBe(1);
126 expect(listPullRuns(db, "a1")).toHaveLength(1);
127 });
128 });
129
130 describe("pullDue", () => {
131 test("sweeps every app: the due one pulled once, the one not due left, one that throws logged", async () => {
132 db.run("PRAGMA foreign_keys = OFF");
133 for (const [id, slug] of [["a1", "due"], ["a2", "fresh"], ["a3", "broken"]] as const)
134 db.run("INSERT INTO app (id, repo_id, slug, access) VALUES (?1, ?2, ?3, 'private')", [id, `repo-${id}`, slug]);
135 pulledAgo("a2", 1000);
136 const warned: string[] = [];
137 const forge = { db, log: { warn: (m: string) => warned.push(m) } } as never;
138 const reachOf = (r: { id: string }): PullReach =>
139 r.id === "a3" ? reach({ adapter: () => Promise.reject(new Error("boom")) }) : reach();
140 expect(await pullDue(forge, Date.now, reachOf)).toEqual(["due"]);
141 expect(posts).toBe(1);
142 expect(warned).toEqual(["could not pull an adapter"]);
143 expect(await pullDue(forge, Date.now, reachOf)).toEqual([]);
144 expect(posts).toBe(1);
145 });
146 });
147
148 describe("postPull", () => {
149 const seen: { method: string; path: string }[] = [];
150 let answer: () => Response = () => new Response(good);
151 const server = Bun.serve({
152 port: 0,
153 hostname: "127.0.0.1",
154 fetch: (req) => {
155 seen.push({ method: req.method, path: new URL(req.url).pathname });
156 return answer();
157 },
158 });
159 const port = server.port ?? 0;
160 afterAll(() => server.stop(true));
161
162 test("POSTs /pull on loopback and hands back the status and body", async () => {
163 expect(await postPull(port)).toEqual({ status: 200, body: good });
164 expect(seen).toEqual([{ method: "POST", path: "/pull" }]);
165 });
166
167 test("a body over 64 KB is cut off and named", async () => {
168 answer = () => new Response("x".repeat(PULL_BODY_MAX + 1));
169 expect(await postPull(port)).toEqual({ error: `body is over ${PULL_BODY_MAX} bytes` });
170 });
171
172 test("an app that answers too slowly did not answer", async () => {
173 answer = () => new Response(new ReadableStream({ start() {} }));
174 expect(await postPull(port, 50)).toEqual({ error: "the app did not answer" });
175 });
176 });
177
178 test("the timer does not start under the test harness", () => {
179 expect(startPullSchedule({} as never)).toBeUndefined();
180 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs