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

The roll rehearsal refuses nobody on a loaded machine: zero refused connections is a promise, not a lucky run #2471

Merged opened this in answer to

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

Merged

mason/forge:mason/2453-the-roll-rehearsal-refuses-nobody-on-a-l → main 1 commit fork

  • Merged. mason/2453-the-roll-rehearsal-refuses-nobody-on-a-l was merged into main as a6934b3.

Review

Sign in to review this change.
4 files changed +172 −30
docs/ops/ROLL.md +12 −6
@@ -111,18 +111,24 @@ It starts the old forge on a copy of `--from` (never the live data
111111 directory: restore a snapshot first, `docs/RESTORE.md`), sends requests to
112112 `/`, `/healthz` and `/explore`, starts the new forge beside it on the same
113113 copy, and moves rotation the way the Service does: a forge joins when its
114 `/readyz` says 200 and leaves when it does not, probed every 5 s as the
115 chart probes. Once the new forge is in, the old one is sent SIGTERM. A minute
116 later it prints its verdict and exits:
114 `/readyz` says 200 and leaves when it says 503 or nothing is listening,
115 probed every 5 s as the chart probes. A probe that only times out is a busy
116 machine, not a forge going away: like the chart's (Kubernetes' default
117 failure threshold), it takes three in a row to leave. Once the new forge is
118 in, the old one is sent SIGTERM. A minute later it prints its verdict and
119 exits:
117120
118121 | exit | means |
119122 | ---- | -------------------------------------------------------------- |
120123 | 0 | zero refused: roll |
121 | 1 | a request got a 5xx or no answer, or nothing rolled: do not roll |
124 | 1 | a request got a 5xx or a refused connection, or nothing rolled: do not roll |
122125 | 2 | an argument is wrong, or a forge would not start |
123126
124 Exit 1 is a bug in the release, not a reason to ask for a dark roll: the
125 logs it names say which forge refused and when. Release checklist: step 1
127 Exit 1 is a bug in the release, not a reason to ask for a dark roll: each
128 `miss` line says when, which forge was asked, what came back (the client's
129 error code with it), and whether each process was serving, draining or gone;
130 the logs it names say the rest. A request the client gave up on is counted
131 `slow` and listed, but is not a refusal: the forge held its connection. Release checklist: step 1
126132 exits 0, then this exits 0, then roll. Quote the `zero refused` line in the
127133 release notes.
128134
scripts/rehearsal.ts +76 −11
@@ -98,20 +98,40 @@ export function parseArgs(argv: readonly string[], here: string): RehearsalArgs
9898 };
9999 }
100100
101 /**
102 * What one readiness probe heard: a 200, a not-ready answer (a 503 or a
103 * refused connection — nothing there to take work), or silence past the
104 * probe's timeout, which says only that the machine is busy.
105 */
106 export type ProbeAnswer = "ready" | "not-ready" | "silent";
107
108 /**
109 * Silent probes in a row before a member leaves: the chart's readiness
110 * probe keeps Kubernetes' default failureThreshold, 3. One slow probe on
111 * a loaded machine is not a forge going away.
112 */
113 export const SILENT_PROBES_TO_LEAVE = 3;
114
101115 /**
102116 * Who takes requests, as a Service's endpoints do: a forge joins when a
103 * probe sees it ready and leaves when a probe sees it not, never between
117 * probe sees it ready and leaves when a probe hears it is not, or hears
118 * nothing {@link SILENT_PROBES_TO_LEAVE} times in a row — never between
104119 * probes. Requests go round the members in turn.
105120 */
106121 export class Rotation<T> {
107122 private members: T[] = [];
123 private silent = new Map<T, number>();
108124 private turn = 0;
109125
110 /** What one probe of `member` saw. */
111 saw(member: T, ready: boolean): void {
126 /** What one probe of `member` heard; `true` and `false` are ready and not. */
127 saw(member: T, answer: ProbeAnswer | boolean): void {
128 const heard = answer === true ? "ready" : answer === false ? "not-ready" : answer;
112129 const at = this.members.indexOf(member);
113 if (ready && at < 0) this.members.push(member);
114 if (!ready && at >= 0) this.members.splice(at, 1);
130 const quiet = heard === "silent" ? (this.silent.get(member) ?? 0) + 1 : 0;
131 this.silent.set(member, quiet);
132 const leaves = heard === "not-ready" || quiet >= SILENT_PROBES_TO_LEAVE;
133 if (heard === "ready" && at < 0) this.members.push(member);
134 if (leaves && at >= 0) this.members.splice(at, 1);
115135 }
116136
117137 has(member: T): boolean {
@@ -126,8 +146,26 @@ export class Rotation<T> {
126146 }
127147 }
128148
129 /** One request's outcome: a status, or "refused" when nothing answered. */
130 export type Outcome = number | "refused" | "nobody";
149 /**
150 * One request's outcome: a status; "refused" when the connection was
151 * refused or cut (nothing was there to answer); "slow" when the client gave
152 * up waiting on a forge that held the connection open; "nobody" when no
153 * forge was in rotation to ask.
154 */
155 export type Outcome = number | "refused" | "slow" | "nobody";
156
157 /** A request that did not come back answered, and the moment around it. */
158 export interface Miss {
159 /** Milliseconds from the first request. */
160 readonly atMs: number;
161 /** The forge asked, or null when nobody was in rotation. */
162 readonly forge: string | null;
163 readonly outcome: Outcome;
164 /** The client's error code ("ConnectionRefused", "TimeoutError"…), or null. */
165 readonly code: string | null;
166 /** Each forge's process at that moment: serving, draining or exited. */
167 readonly forges: Readonly<Record<string, string>>;
168 }
131169
132170 export interface Tally {
133171 requests: number;
@@ -135,22 +173,35 @@ export interface Tally {
135173 byOutcome: Record<string, number>;
136174 /** Requests each forge answered, by its name. */
137175 byForge: Record<string, number>;
176 /** Every request that was not answered below 500, in order. */
177 misses: Miss[];
138178 }
139179
140180 export function emptyTally(): Tally {
141 return { requests: 0, byOutcome: {}, byForge: {} };
181 return { requests: 0, byOutcome: {}, byForge: {}, misses: [] };
142182 }
143183
144 export function count(tally: Tally, forge: string | null, outcome: Outcome): void {
184 export function count(
185 tally: Tally,
186 forge: string | null,
187 outcome: Outcome,
188 miss?: Omit<Miss, "forge" | "outcome">,
189 ): void {
145190 tally.requests += 1;
146191 const key = String(outcome);
147192 tally.byOutcome[key] = (tally.byOutcome[key] ?? 0) + 1;
148193 if (forge !== null) tally.byForge[forge] = (tally.byForge[forge] ?? 0) + 1;
194 if (miss !== undefined && (outcome === "slow" || isRefusal(outcome)))
195 tally.misses.push({ ...miss, forge, outcome });
149196 }
150197
151 /** Whether a person would have seen the forge down. */
198 /**
199 * Whether a person would have seen the forge down. A slow answer is not
200 * one: the forge held the connection, and a busy machine is not a roll
201 * that went dark (#2453).
202 */
152203 export function isRefusal(outcome: Outcome): boolean {
153 return typeof outcome === "number" ? outcome >= 500 : true;
204 return typeof outcome === "number" ? outcome >= 500 : outcome !== "slow";
154205 }
155206
156207 export function refusals(tally: Tally): number {
@@ -205,6 +256,16 @@ export function judge(tally: Tally, timeline: Timeline): Verdict {
205256 const secs = (ms: number | null): string =>
206257 ms === null ? "never" : `${(ms / 1000).toFixed(1)}s`;
207258
259 const MISSES_SHOWN = 20;
260
261 /** One miss as a line: when, whom, what came back, and where each process was. */
262 export function missLine(m: Miss): string {
263 const forges = Object.entries(m.forges)
264 .map(([k, state]) => `${k} ${state}`)
265 .join(", ");
266 return `miss ${m.atMs} ms ${m.forge ?? "nobody in rotation"}: ${m.outcome}${m.code === null ? "" : ` (${m.code})`}; ${forges}`;
267 }
268
208269 /** The verdict as the release checklist reads it. */
209270 export function report(v: Verdict): string {
210271 const outcomes = Object.entries(v.tally.byOutcome)
@@ -225,5 +286,9 @@ export function report(v: Verdict): string {
225286 `new ready ${secs(v.timeline.newInRotationMs)} after the first request`,
226287 `old out ${secs(v.timeline.oldOutOfRotationMs)} after its SIGTERM`,
227288 `old exit ${secs(v.timeline.oldExitMs)} after its SIGTERM`,
289 ...v.tally.misses.slice(0, MISSES_SHOWN).map(missLine),
290 ...(v.tally.misses.length > MISSES_SHOWN
291 ? [` and ${v.tally.misses.length - MISSES_SHOWN} more`]
292 : []),
228293 ].join("\n");
229294 }
scripts/rehearse-roll.ts +45 −13
@@ -30,6 +30,7 @@ import {
3030 report,
3131 Rotation,
3232 type Outcome,
33 type ProbeAnswer,
3334 type RehearsalArgs,
3435 type Timeline,
3536 type Verdict,
@@ -83,23 +84,41 @@ function start(
8384 return { name, url: `http://127.0.0.1:${port}`, proc, log };
8485 }
8586
86 async function ready(forge: Forge, timeoutMs: number): Promise<boolean> {
87 /** One readiness probe: ready, not ready (a 503, or nothing listening), or silent past its timeout. */
88 async function probe(forge: Forge, timeoutMs: number): Promise<ProbeAnswer> {
8789 try {
8890 const res = await fetch(`${forge.url}/readyz`, { signal: AbortSignal.timeout(timeoutMs) });
8991 await res.arrayBuffer();
90 return res.status === 200;
91 } catch {
92 return false;
92 return res.status === 200 ? "ready" : "not-ready";
93 } catch (error) {
94 return errorCode(error) === "TimeoutError" ? "silent" : "not-ready";
9395 }
9496 }
9597
96 async function ask(url: string): Promise<Outcome> {
98 async function ready(forge: Forge, timeoutMs: number): Promise<boolean> {
99 return (await probe(forge, timeoutMs)) === "ready";
100 }
101
102 /** The client's name for what went wrong: "ConnectionRefused", "TimeoutError"… */
103 function errorCode(error: unknown): string {
104 const e = error as { code?: unknown; name?: unknown };
105 if (e?.name === "TimeoutError" || e?.name === "AbortError") return "TimeoutError";
106 return typeof e?.code === "string" ? e.code : String(e?.name ?? "Error");
107 }
108
109 /**
110 * One request. A connection refused or cut is "refused"; one the forge
111 * held open past the client's patience is "slow" — the machine, not the
112 * roll (#2453).
113 */
114 async function ask(url: string): Promise<{ outcome: Outcome; code: string | null }> {
97115 try {
98116 const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(10_000) });
99117 await res.arrayBuffer();
100 return res.status;
101 } catch {
102 return "refused";
118 return { outcome: res.status, code: null };
119 } catch (error) {
120 const code = errorCode(error);
121 return { outcome: code === "TimeoutError" ? "slow" : "refused", code };
103122 }
104123 }
105124
@@ -135,17 +154,26 @@ export async function rehearse(
135154 const began = Date.now();
136155 const until = began + args.seconds * 1000;
137156 let termAt: number | null = null;
157 // Each process as a miss saw it: whether it still ran, and was told to go.
158 const states = (): Record<string, string> =>
159 Object.fromEntries(
160 forges.map((f) => [
161 f.name,
162 f.proc.exitCode !== null ? "exited" : f === old && termAt !== null ? "draining" : "serving",
163 ]),
164 );
138165
139166 const load = async (lane: number): Promise<void> => {
140167 for (let i = lane; Date.now() < until; i += args.concurrency) {
141168 const forge = rotation.next();
142169 if (forge === null) {
143 count(tally, null, "nobody");
170 count(tally, null, "nobody", { atMs: Date.now() - began, code: null, forges: states() });
144171 await Bun.sleep(10);
145172 continue;
146173 }
147174 const path = args.paths[i % args.paths.length] ?? "/";
148 count(tally, forge.name, await ask(forge.url + path));
175 const { outcome, code } = await ask(forge.url + path);
176 count(tally, forge.name, outcome, { atMs: Date.now() - began, code, forges: states() });
149177 }
150178 };
151179
@@ -161,10 +189,14 @@ export async function rehearse(
161189 });
162190 while (Date.now() < until) {
163191 for (const forge of forges) {
164 const up = await ready(forge, Math.min(1000, args.probeMs));
192 const answer = await probe(forge, Math.min(1000, args.probeMs));
165193 const was = rotation.has(forge);
166 rotation.saw(forge, up);
167 if (forge === next && up && !was) {
194 rotation.saw(forge, answer);
195 const up = rotation.has(forge);
196 if (up !== was)
197 say(`${forge.name} forge ${up ? "joins" : "leaves"} rotation at ${Date.now() - began} ms: probe ${answer}`);
198 // Signalled once: a new forge that leaves and rejoins is not a second roll.
199 if (forge === next && up && !was && termAt === null) {
168200 timeline.newInRotationMs = Date.now() - began;
169201 say("new forge in rotation; the old one is sent SIGTERM");
170202 termAt = Date.now();
test/rehearsal.test.ts +39
@@ -13,10 +13,12 @@ import {
1313 emptyTally,
1414 isRefusal,
1515 judge,
16 missLine,
1617 parseArgs,
1718 refusals,
1819 report,
1920 Rotation,
21 SILENT_PROBES_TO_LEAVE,
2022 type RehearsalArgs,
2123 } from "../scripts/rehearsal.ts";
2224
@@ -70,6 +72,23 @@ describe("rotation", () => {
7072 r.saw("old", false);
7173 expect([r.next(), r.next(), r.has("old")]).toEqual(["new", "new", false]);
7274 });
75
76 test("a silent probe is a busy machine: a member leaves only after three in a row (#2453)", () => {
77 const r = new Rotation<string>();
78 r.saw("old", "ready");
79 for (let i = 1; i < SILENT_PROBES_TO_LEAVE; i++) r.saw("old", "silent");
80 r.saw("old", "ready");
81 for (let i = 1; i < SILENT_PROBES_TO_LEAVE; i++) r.saw("old", "silent");
82 expect(r.has("old")).toBe(true);
83 r.saw("old", "silent");
84 expect(r.has("old")).toBe(false);
85 // Silence never lets anyone in; a not-ready answer sends a member out at once.
86 r.saw("new", "silent");
87 expect(r.has("new")).toBe(false);
88 r.saw("new", "ready");
89 r.saw("new", "not-ready");
90 expect(r.has("new")).toBe(false);
91 });
7392 });
7493
7594 describe("the verdict", () => {
@@ -80,6 +99,24 @@ describe("the verdict", () => {
8099 expect([200, 302, 404].some((o) => isRefusal(o))).toBe(false);
81100 });
82101
102 test("a slow answer is not a refusal, but every miss is kept with its moment", () => {
103 expect(isRefusal("slow")).toBe(false);
104 const t = emptyTally();
105 const at = { atMs: 1234, code: "TimeoutError", forges: { old: "draining", new: "serving" } };
106 count(t, "old", 200, at);
107 count(t, "new", "slow", at);
108 count(t, null, "nobody", { ...at, code: null });
109 expect(refusals(t)).toBe(1);
110 expect(t.misses.map((m) => [m.forge, m.outcome])).toEqual([
111 ["new", "slow"],
112 [null, "nobody"],
113 ]);
114 expect(missLine(t.misses[0]!)).toBe(
115 "miss 1234 ms new: slow (TimeoutError); old draining, new serving",
116 );
117 expect(report(judge(t, rolled))).toContain("nobody in rotation: nobody; old draining");
118 });
119
83120 test("zero refused over a roll that happened is a pass", () => {
84121 const t = emptyTally();
85122 count(t, "old", 200);
@@ -131,6 +168,8 @@ describe("a real roll of two forge processes on one data directory", () => {
131168 test("an old forge that lingers past a probe period refuses nobody", async () => {
132169 const v = await rehearse(short(5));
133170 expect(v.problem).toBeNull();
171 // Each refusal says when, whom, what came back and where both processes were.
172 expect(v.tally.misses.filter((m) => isRefusal(m.outcome)).map(missLine)).toEqual([]);
134173 expect(v.refused).toBe(0);
135174 expect(v.tally.byForge["old"]).toBeGreaterThan(0);
136175 expect(v.tally.byForge["new"]).toBeGreaterThan(0);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs