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

A loop stall names the request that was still running when the loop woke, not only the ones that finished #11484

Merged opened this

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

Merged

torque/forge:torque/11478-a-loop-stall-names-the-request-that-was → main 3 commits fork

  • Merged. torque/11478-a-loop-stall-names-the-request-that-was was merged into main as da127e7.

Review

Sign in to review this change.
10 files changed +291 −18
docs/features/stalls-by-route.md +9 −2
@@ -6,6 +6,13 @@ it (`none` when no request did) — the same reading as the `event loop blocked`
66 log lines (`src/perf/loop.ts`), so a fix's before and after reads from the
77 forge itself, not the pod log.
88
9 A request still running when the wake comes (#11478) is charged too, when it
10 began at or before the stall did: its row is `<route> (in flight)`, apart from
11 the same route's finished requests, in every place below (MCP `system`
12 `stalls` as `{ cause, ms, count }` with `cause` that name). The open set is
13 `src/perf/in_flight.ts`: one map entry per request being answered, added and
14 removed in O(1), read only at a stall.
15
916 Where to look (a forge admin who is a person; anyone else gets 404, or no
1017 palette entry):
1118
@@ -47,5 +54,5 @@ figure for the last hour; for the same 6-minute window,
4754 in Prometheus, compared with 69,600.
4855
4956 Caveats: a stall over two requests counts against both, so routes sum past
50 the wall time; a request still running when the wake comes is not yet named;
51 a restart starts the figures at zero.
57 the wall time; a request held open across many stalls (a slow upload, say)
58 is charged with each; a restart starts the figures at zero.
src/http/mcp/tools/system.ts +1 −1
@@ -10,7 +10,7 @@ import { good, bad, type Tool } from "../shared.ts";
1010
1111 export const tool: Tool = {
1212 name: "system",
13 doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route or `job:<name>` as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only",
13 doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route (`<route> (in flight)` when still running at the wake), or `job:<name>`, as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only",
1414 description:
1515 "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read; `kind: csp`: 24 h of CSP violations (admin).",
1616 inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp"] } } },
src/http/server.ts +3 −2
@@ -519,7 +519,7 @@ import {
519519 takeDbTime,
520520 type RouteTiming,
521521 } from "../core/route_timing.ts";
522 import { captureStore, loopWatch, perfRecord, readLedger, stallBlame } from "../perf/live.ts";
522 import { captureStore, inFlight, loopWatch, perfRecord, readLedger, stallBlame } from "../perf/live.ts";
523523 import { during } from "../perf/jobs.ts";
524524 import { classOf, countryOf, repoOf, signatureOf } from "../perf/traffic.ts";
525525 import { createTrafficWriter } from "../perf/traffic_store.ts";
@@ -2759,7 +2759,8 @@ export async function serve(app: App): Promise<Servers> {
27592759 // The only sockets the forge holds are an app's, carried through the
27602760 // proxy; its own live surfaces are SSE (`sse.ts`) and stay so.
27612761 websocket: withShells(appWebSocketWith(app.config.wsMaxMessage)),
2762 fetch: handle,
2762 // Open until it is answered, so a stall while it runs names it (#11478).
2763 fetch: (request, server) => inFlight.during(request, performance.now(), () => handle(request, server)),
27632764 // A last resort: `handle` answers a throw itself, so it is counted.
27642765 error: (error) => failure(app, error),
27652766 });
src/http/usage_tag.ts +5
@@ -43,6 +43,11 @@ export function noteUsage(request: Request, tag: UsageTag): void {
4343 noted.set(request, tag);
4444 }
4545
46 /** The pattern noted for this request so far, left in place; `(unmatched)` before the router names it. */
47 export function patternOf(request: Request): string {
48 return noted.get(request)?.pattern ?? UNMATCHED;
49 }
50
4651 /** What was noted for this request, or the unmatched, anonymous default. */
4752 export function takeUsage(request: Request): UsageTag {
4853 const tag = noted.get(request);
src/metrics/stalls.ts +1 −1
@@ -30,7 +30,7 @@ export function stallFamilies(tally: StallTally): Family[] {
3030 {
3131 type: "counter",
3232 name: "forge_loop_stall_milliseconds_total",
33 help: "Milliseconds the main thread stalled past 200 ms, by the route running across the stall (route=\"none\" when none), since the process started.",
33 help: "Milliseconds the main thread stalled past 200 ms, by the route running across the stall (\"<route> (in flight)\" when still running at the wake, route=\"none\" when none), since the process started.",
3434 samples: tally.total.map((r) => ({ labels: { route: r.route }, value: Math.round(r.ms) })),
3535 },
3636 {
src/perf/in_flight.ts added +51
@@ -0,0 +1,51 @@
1 /**
2 * The requests the forge is answering right now (#11478): each one's start,
3 * so a loop stall can name the request still running when the wake comes,
4 * not only the ones that finished across it (`loop.ts`).
5 *
6 * One `Map` entry per open request, keyed by the request itself: `begin` and
7 * `end` are a `set` and a `delete`, O(1), and the map holds only what is
8 * open — it never grows with the requests already answered. The pattern is
9 * read only at a wake (`nameOf`), since the router names a request after it
10 * starts; a stall is rare, so the walk over the open set costs nothing on
11 * the common request.
12 */
13
14 export interface InFlight<K> {
15 /** `key` started at `at` (`performance.now`). */
16 begin(key: K, at: number): void;
17 /** `key` is answered; a key never begun is ignored. */
18 end(key: K): void;
19 /** Run `work` with `key` open from `at` until it settles, however it settles. */
20 during<T>(key: K, at: number, work: () => Promise<T>): Promise<T>;
21 /** The patterns of the requests begun at or before `from` and still open, oldest first, each once. */
22 since(from: number): string[];
23 /** How many are open now. */
24 size(): number;
25 }
26
27 export function createInFlight<K>(nameOf: (key: K) => string): InFlight<K> {
28 const open = new Map<K, number>();
29 return {
30 begin(key, at) {
31 open.set(key, at);
32 },
33 end(key) {
34 open.delete(key);
35 },
36 async during(key, at, work) {
37 open.set(key, at);
38 try {
39 return await work();
40 } finally {
41 open.delete(key);
42 }
43 },
44 since(from) {
45 const old: [number, string][] = [];
46 for (const [key, at] of open) if (at <= from) old.push([at, nameOf(key)]);
47 return [...new Set(old.sort((a, b) => a[0] - b[0]).map(([, name]) => name))];
48 },
49 size: () => open.size,
50 };
51 }
src/perf/live.ts +9 −2
@@ -4,6 +4,8 @@ import { createBootStall } from "./boot_stall.ts";
44 import { createGitRecord } from "./git.ts";
55 import { jobWatch } from "./jobs.ts";
66 import { createLoopWatch } from "./loop.ts";
7 import { createInFlight } from "./in_flight.ts";
8 import { patternOf } from "../http/usage_tag.ts";
79 import { createCaptureStore } from "./capture.ts";
810 import { createReadLedger } from "./reads.ts";
911 import { createStallBlame } from "./stall_blame.ts";
@@ -11,8 +13,13 @@ import { createPerfRecord } from "./record.ts";
1113 import { createLagMinutes } from "./lag_minutes.ts";
1214
1315 export const perfRecord = createPerfRecord();
14 /** A stall no request ran across names the background jobs that did (#10834). */
15 export const loopWatch = createLoopWatch(undefined, jobWatch.across);
16 /** The requests the server is answering now (#11478): begun and ended around each one in `http/server.ts`. */
17 export const inFlight = createInFlight<Request>(patternOf);
18 /**
19 * A stall names the requests that ran across it, finished or still in flight
20 * (#11478); when none did, the background jobs that did (#10834).
21 */
22 export const loopWatch = createLoopWatch(undefined, jobWatch.across, inFlight.since);
1623 export const gitRecord = createGitRecord();
1724 /** The worst stall in this process's first five minutes, read by the release keeper (#8848). */
1825 export const bootStall = createBootStall();
src/perf/loop.ts +27 −7
@@ -14,6 +14,10 @@
1414 * A stall no request ran across is counted against the background jobs that
1515 * did (#10834, `jobs.ts`), each as `job:<name>`; `NO_REQUEST` is left for a
1616 * stall nothing named ran across.
17 *
18 * A request still running when the wake comes (#11478), begun at or before
19 * the stall began, is charged with it too, as `<pattern> (in flight)`: a
20 * stall that never ends in a finished request is not blamed on nothing.
1721 */
1822
1923 /** A wake later than this is a stall, ms (#11069: 200, so a stall the probes would feel is named). */
@@ -29,6 +33,12 @@ export const NO_REQUEST = "none";
2933 export const JOB_PREFIX = "job:";
3034 /** The jobs that ran across `[from, to]` (`JobWatch.across`). */
3135 export type JobsAcross = (from: number, to: number) => readonly string[];
36 /** The patterns of the requests begun at or before `from` and still open (`InFlight.since`). */
37 export type OpenSince = (from: number) => readonly string[];
38 /** What marks a route charged while still running, apart from one that finished. */
39 export const IN_FLIGHT = " (in flight)";
40 /** The name an open request's pattern is charged under. */
41 export const inFlightName = (pattern: string): string => pattern + IN_FLIGHT;
3242
3343 export interface RouteStall {
3444 readonly route: string;
@@ -47,6 +57,8 @@ export interface Stall {
4757 readonly at: number;
4858 readonly lateMs: number;
4959 readonly routes: readonly string[];
60 /** The patterns of the requests begun before it and still open at the wake, oldest first (#11478). */
61 readonly inFlight?: readonly string[];
5062 /** The background jobs running across it; read only when no request was. */
5163 readonly jobs: readonly string[];
5264 }
@@ -68,9 +80,12 @@ export interface LoopWatch {
6880 clear(): void;
6981 }
7082
71 /** What a stall is counted against: its routes, else its jobs, else `NO_REQUEST`. */
72 export const blamed = (s: Stall): readonly string[] =>
73 s.routes.length > 0 ? s.routes : s.jobs.length > 0 ? s.jobs.map((j) => JOB_PREFIX + j) : [NO_REQUEST];
83 /** What a stall is counted against: its routes and its requests in flight, else its jobs, else `NO_REQUEST`. */
84 export function blamed(s: Stall): readonly string[] {
85 const open = s.inFlight ?? [];
86 if (open.length > 0) return [...s.routes, ...open.map(inFlightName)];
87 return s.routes.length > 0 ? s.routes : s.jobs.length > 0 ? s.jobs.map((j) => JOB_PREFIX + j) : [NO_REQUEST];
88 }
7489
7590 function byRoute(stalls: Iterable<Stall>): RouteStall[] {
7691 const sums = new Map<string, { ms: number; count: number }>();
@@ -84,7 +99,7 @@ function byRoute(stalls: Iterable<Stall>): RouteStall[] {
8499 return [...sums].map(([route, t]) => ({ route, ms: t.ms, count: t.count }));
85100 }
86101
87 export function createLoopWatch(limitMs = LIMIT_MS, jobsAcross: JobsAcross = () => []): LoopWatch {
102 export function createLoopWatch(limitMs = LIMIT_MS, jobsAcross: JobsAcross = () => [], openSince: OpenSince = () => []): LoopWatch {
88103 const recent: { pattern: string; start: number; end: number }[] = [];
89104 const totals = new Map<string, { ms: number; count: number }>();
90105 const hour: Stall[] = [];
@@ -104,8 +119,9 @@ export function createLoopWatch(limitMs = LIMIT_MS, jobsAcross: JobsAcross = ()
104119 .map((r) => r.pattern),
105120 ),
106121 ];
107 const jobs = routes.length === 0 ? jobsAcross(from, at) : [];
108 const stall = { at, lateMs, routes, jobs };
122 const inFlight = openSince(from);
123 const jobs = routes.length === 0 && inFlight.length === 0 ? jobsAcross(from, at) : [];
124 const stall: Stall = inFlight.length > 0 ? { at, lateMs, routes, inFlight, jobs } : { at, lateMs, routes, jobs };
109125 for (const r of byRoute([stall])) {
110126 const t = totals.get(r.route) ?? { ms: 0, count: 0 };
111127 t.ms += r.ms;
@@ -143,5 +159,9 @@ export function createLoopWatch(limitMs = LIMIT_MS, jobsAcross: JobsAcross = ()
143159 /** The stall as a finding line. */
144160 export const stallLine = (s: Stall): string =>
145161 `event loop blocked ${Math.round(s.lateMs)} ms; running then: ${
146 s.routes.length > 0 ? s.routes.join(", ") : s.jobs.length > 0 ? `no request; jobs ${s.jobs.join(", ")}` : "no request"
162 s.routes.length > 0 || (s.inFlight ?? []).length > 0
163 ? [...s.routes, ...(s.inFlight ?? []).map(inFlightName)].join(", ")
164 : s.jobs.length > 0
165 ? `no request; jobs ${s.jobs.join(", ")}`
166 : "no request"
147167 }`;
src/perf/stall_blame.ts +4 −3
@@ -4,10 +4,11 @@
44 * can say what held the loop behind its `loopLagMs`.
55 *
66 * Who ran is the loop watch's finding (`loop.ts`): the route patterns whose
7 * request ran across the blocked window, else the background jobs marked
7 * request ran across the blocked window, then those still running at the
8 * wake as `<pattern> (in flight)` (#11478), else the background jobs marked
89 * with `jobWatch.during` (`jobs.ts`), else nothing. `what` is the first of
9 * those — the longest-running route, or `job:<name>` — and `unknown` when
10 * nothing marked ran across the stall.
10 * those — the longest-running finished route, the oldest open one, or
11 * `job:<name>` — and `unknown` when nothing marked ran across the stall.
1112 *
1213 * The ring holds `KEPT` stalls and never grows: a note is three array
1314 * writes and an index step. At one stall per 200 ms an hour would be 18,000,
test/stall-in-flight.test.ts added +181
@@ -0,0 +1,181 @@
1 /** A loop stall names the request still running when the loop woke, not only the ones that finished (#11478). */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { stallFamilies } from "../src/metrics/stalls.ts";
12 import { createInFlight } from "../src/perf/in_flight.ts";
13 import { inFlight, loopWatch } from "../src/perf/live.ts";
14 import { blamed, createLoopWatch, LIMIT_MS, NO_REQUEST, stallLine } from "../src/perf/loop.ts";
15 import { whatOf } from "../src/perf/stall_blame.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { freshStallState } from "./helpers/stall-state.ts";
18
19 quietLogs();
20 freshStallState();
21
22 /** A watch over a hand-held open set, on a clock the test names. */
23 function watched() {
24 const open = createInFlight<string>((key) => key.split("#")[0]!);
25 return { open, watch: createLoopWatch(250, () => ["sampler"], open.since) };
26 }
27
28 test("a request held open across a stall is charged with it, labelled in flight", () => {
29 const { open, watch } = watched();
30 open.begin("/explore#1", 1_000);
31 // The wake at 2_000 is 400 ms late: the loop was held from 1_600.
32 const stall = watch.stalled(400, 2_000)!;
33 expect(stall.inFlight).toEqual(["/explore"]);
34 expect(blamed(stall)).toEqual(["/explore (in flight)"]);
35 expect(whatOf(stall)).toBe("/explore (in flight)");
36 expect(stallLine(stall)).toBe("event loop blocked 400 ms; running then: /explore (in flight)");
37 // Charged in place of the job: a request ran, so the job is not read.
38 expect(stall.jobs).toEqual([]);
39 expect(watch.tally(2_000).hour).toEqual([{ route: "/explore (in flight)", ms: 400, count: 1 }]);
40 });
41
42 test("a request that started after the stall began is not charged", () => {
43 const { open, watch } = watched();
44 open.begin("/late#1", 1_700);
45 const stall = watch.stalled(400, 2_000)!;
46 expect(stall.inFlight).toBeUndefined();
47 expect(blamed(stall)).toEqual(["job:sampler"]);
48 });
49
50 test("one started exactly when the stall began is older than it, and charged", () => {
51 const { open, watch } = watched();
52 open.begin("/edge#1", 1_600);
53 expect(blamed(watch.stalled(400, 2_000)!)).toEqual(["/edge (in flight)"]);
54 });
55
56 test("an ended request is no longer in flight; finished and open both carry the stall", () => {
57 const { open, watch } = watched();
58 open.begin("/gone#1", 1_000);
59 open.end("/gone#1");
60 expect(blamed(watch.stalled(400, 2_000)!)).toEqual(["job:sampler"]);
61 open.begin("/held#1", 2_100);
62 watch.finished("/done", 2_500, 2_900);
63 expect(blamed(watch.stalled(400, 3_000)!)).toEqual(["/done", "/held (in flight)"]);
64 });
65
66 test("open requests are named oldest first, each pattern once", () => {
67 const open = createInFlight<string>((key) => key.split("#")[0]!);
68 open.begin("/b#1", 30);
69 open.begin("/a#1", 10);
70 open.begin("/b#2", 20);
71 open.begin("/c#1", 90);
72 expect(open.since(50)).toEqual(["/a", "/b"]);
73 });
74
75 test("the open set holds only what is open: a million requests begun and ended leave it empty", () => {
76 const open = createInFlight<object>(() => "/x");
77 for (let i = 0; i < 1_000_000; i++) {
78 const key = {};
79 open.begin(key, i);
80 open.end(key);
81 }
82 expect(open.size()).toBe(0);
83 // Ending what was never begun is harmless.
84 open.end({});
85 expect(open.size()).toBe(0);
86 });
87
88 test("`during` holds a request open while it runs and lets it go however it settles", async () => {
89 const open = createInFlight<object>(() => "/x");
90 const key = {};
91 const seen = await open.during(key, 5, async () => open.since(5));
92 expect(seen).toEqual(["/x"]);
93 expect(open.size()).toBe(0);
94 await expect(open.during({}, 5, async () => { throw new Error("boom"); })).rejects.toThrow("boom");
95 expect(open.size()).toBe(0);
96 });
97
98 test("a stall nothing ran across is still `none`", () => {
99 const watch = createLoopWatch(250);
100 expect(blamed(watch.stalled(400, 2_000)!)).toEqual([NO_REQUEST]);
101 });
102
103 test("the in-flight row is a metrics series like any route", () => {
104 const { open, watch } = watched();
105 open.begin("/explore#1", 1_000);
106 watch.stalled(400, 2_000);
107 const total = stallFamilies(watch.tally(2_000)).find((f) => f.name === "forge_loop_stall_milliseconds_total")!;
108 expect(total.samples).toContainEqual({ labels: { route: "/explore (in flight)" }, value: 400 });
109 });
110
111 describe_server();
112
113 function describe_server() {
114 let dataDir = "";
115 let app: App;
116 let servers: Servers;
117 let token = "";
118
119 beforeAll(async () => {
120 dataDir = await mkdtemp(join(tmpdir(), "forge-stall-inflight-"));
121 app = createApp({ dataDir, port: 0 });
122 servers = await serve(app);
123 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "correct horse battery 9", isAdmin: true });
124 if (!u.ok) throw new Error("alice");
125 token = createToken(app.db, u.value.id, "t", "admin").token;
126 }, 30_000);
127
128 afterAll(async () => {
129 servers.stop();
130 app.close();
131 await rm(dataDir, { recursive: true, force: true });
132 });
133
134 const system = async (): Promise<any> => {
135 const res = await fetch(`${servers.url}/mcp`, {
136 method: "POST",
137 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
138 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "system", arguments: {} } }),
139 });
140 const body = (await res.json()) as any;
141 return JSON.parse(body.result.content[0].text);
142 };
143
144 test("a real request held open across a stall is in MCP `system` `stalls`; one begun after is not", async () => {
145 loopWatch.clear();
146 // A request whose body has not finished arriving: the server is answering it, and it is open.
147 let push: ((chunk: Uint8Array) => void) | undefined;
148 let close: (() => void) | undefined;
149 const body = new ReadableStream<Uint8Array>({
150 start(c) {
151 push = (chunk) => c.enqueue(chunk);
152 close = () => c.close();
153 },
154 });
155 const held = fetch(`${servers.url}/mcp`, {
156 method: "POST",
157 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
158 body,
159 duplex: "half",
160 } as RequestInit);
161 push!(new TextEncoder().encode('{"jsonrpc":"2.0",'));
162 const deadline = Date.now() + 5_000;
163 while (inFlight.size() === 0 && Date.now() < deadline) await Bun.sleep(5);
164 expect(inFlight.size()).toBe(1);
165 await Bun.sleep(LIMIT_MS + 60);
166 // The wake: the loop held for longer than the stall limit, beginning after the request did.
167 const now = performance.now();
168 const stall = loopWatch.stalled(LIMIT_MS + 10, now)!;
169 const named = stall.inFlight ?? [];
170 expect(named).toEqual(["/mcp"]);
171 // A stall shorter than the request's age is charged; one reaching back before it began is not.
172 expect(loopWatch.stalled(60_000, now)!.inFlight).toBeUndefined();
173 push!(new TextEncoder().encode('"id":9,"method":"tools/list"}'));
174 close!();
175 await (await held).text();
176 expect(inFlight.size()).toBe(0);
177
178 const out = await system();
179 expect(out.stalls).toContainEqual({ cause: `${named[0]} (in flight)`, ms: LIMIT_MS + 10, count: 1 });
180 });
181 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs