Public: anyone can read this; the crew can change it.
Checks at once and shards per check default to what the pod's CPUs hold, so a big machine needs no override #2551
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/2549-checks-at-once-and-shards-per-check-defa → main 3 commits fork 2 threads · 2 unresolved
7 files
changed +271
−21
src/checks/service.ts +10 −5
| @@ -61,6 +61,7 @@ import { appendRunLog } from "../runs/log.ts"; | ||
| 61 | 61 | import { runningLimitSentence, runningRuns } from "../settings/quotas.ts"; |
| 62 | 62 | import { exportTree, readFileAt } from "../apps/checkout.ts"; |
| 63 | 63 | import { parseCheck } from "../apps/manifest.ts"; |
| 64 | import { workerCpus } from "../../scripts/shards.ts"; | |
| 64 | 65 | import { accountActive } from "../core/accounts.ts"; |
| 65 | 66 | import { childEnv, runStep } from "../apps/process.ts"; |
| 66 | 67 | import { noteReached } from "../apps/egress_report.ts"; |
| @@ -68,6 +69,7 @@ import { | ||
| 68 | 69 | capacityCount, |
| 69 | 70 | noteBooted, |
| 70 | 71 | capacityEnv, |
| 72 | cpuLimit, | |
| 71 | 73 | egressSetting, |
| 72 | 74 | } from "../core/capacity.ts"; |
| 73 | 75 | import { |
| @@ -241,9 +243,10 @@ export function provideChecksForTest(app: App, handle: ChecksHandle): void { | ||
| 241 | 243 | export function startChecks(app: App, options: ChecksOptions): ChecksHandle { |
| 242 | 244 | // Read on every pick (`core/capacity.ts`): an admin's change is honoured |
| 243 | 245 | // by the next check, not the next boot. `concurrency` is the boot default. |
| 244 | noteBooted(app.db, "checks.concurrency", String(options.concurrency ?? 1)); | |
| 246 | const booted = options.concurrency ?? 1; | |
| 247 | noteBooted(app.db, "checks.concurrency", String(booted)); | |
| 245 | 248 | const capacity = (): number => |
| 246 | capacityCount(app.db, "checks.concurrency", options.concurrency ?? 1); | |
| 249 | capacityCount(app.db, "checks.concurrency", booted); | |
| 247 | 250 | const gate = options.gate ?? new Gate(capacity()); |
| 248 | 251 | const placement = options.placement ?? placementFromEnv(); |
| 249 | 252 | /** Full gates on land in flight: not slots in the queue, but stopped with it. */ |
| @@ -788,9 +791,11 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 788 | 791 | } |
| 789 | 792 | : undefined; |
| 790 | 793 | try { |
| 794 | const cellCpus = resolveLimits("step", manifest.limits).cpus; | |
| 791 | 795 | const env = childEnv({ |
| 792 | // The shard count an admin set (`core/capacity.ts`), never the pod's. | |
| 793 | ...capacityEnv(app.db), | |
| 796 | // The shard count an admin set (`core/capacity.ts`), else the | |
| 797 | // default sized to what this check's cell holds, never the pod's. | |
| 798 | ...capacityEnv(app.db, workerCpus(cpuLimit(), cellCpus)), | |
| 794 | 799 | CI: "1", |
| 795 | 800 | FORGE_URL: options.forgeUrl, |
| 796 | 801 | FORGE_CHECK: run.id, |
| @@ -798,7 +803,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 798 | 803 | FORGE_CHECK_IMPACT: IMPACT_FILE, |
| 799 | 804 | // The cell's CPU limit, which `availableParallelism()` may not |
| 800 | 805 | // report inside it; the shard count follows this (#2257). |
| 801 | FORGE_CELL_CPUS: String(resolveLimits("step", manifest.limits).cpus), | |
| 806 | FORGE_CELL_CPUS: String(cellCpus), | |
| 802 | 807 | ...(base === undefined |
| 803 | 808 | ? {} |
| 804 | 809 | : { |
src/core/capacity.ts +81 −11
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | |
| 14 | 14 | import type { Database } from "bun:sqlite"; |
| 15 | 15 | import { readFileSync } from "node:fs"; |
| 16 | import { totalmem } from "node:os"; | |
| 16 | import { availableParallelism, totalmem } from "node:os"; | |
| 17 | 17 | |
| 18 | 18 | export type CapacityKey = |
| 19 | 19 | | "crew.concurrency" |
| @@ -34,6 +34,8 @@ interface Definition { | ||
| 34 | 34 | | { readonly type: "choice"; readonly values: readonly string[] }; |
| 35 | 35 | /** What it is when neither the store nor the environment says. */ |
| 36 | 36 | readonly fallback: string; |
| 37 | /** The default from the pod's CPUs, in place of `fallback` (#2549). */ | |
| 38 | readonly sized?: (cpus: number) => number; | |
| 37 | 39 | readonly help: string; |
| 38 | 40 | } |
| 39 | 41 | |
| @@ -50,14 +52,16 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 50 | 52 | env: "FORGE_CHECKS_CONCURRENCY", |
| 51 | 53 | kind: { type: "count", min: 1, max: 16 }, |
| 52 | 54 | fallback: "1", |
| 53 | help: "How many repository checks run at once. Lowering it lets running ones finish; the next one waits.", | |
| 55 | sized: (cpus) => checksForCpus(cpus), | |
| 56 | help: "How many repository checks run at once. Lowering it lets running ones finish; the next one waits. Empty: the pod's CPUs over three per shard (shards × 3), from 1 to 16 — 80 CPUs give 6.", | |
| 54 | 57 | }, |
| 55 | 58 | "checks.shards": { |
| 56 | 59 | label: "Test shards per check", |
| 57 | 60 | env: "FORGE_CHECK_SHARDS", |
| 58 | 61 | kind: { type: "count", min: 1, max: 32 }, |
| 59 | fallback: "", | |
| 60 | help: "How many parallel shards a check's test run uses (handed to checks and crew shells as FORGE_CHECK_SHARDS). Empty: as many as the machine has cores.", | |
| 62 | fallback: "1", | |
| 63 | sized: (cpus) => shardsForCpus(cpus), | |
| 64 | help: "How many parallel shards a check's test run uses (handed to checks and crew shells as FORGE_CHECK_SHARDS). Empty: the pod's CPUs, at most 4 — and, in a check, no more than its cell holds.", | |
| 61 | 65 | }, |
| 62 | 66 | "children.max": { |
| 63 | 67 | label: "Forges this one may send", |
| @@ -106,14 +110,72 @@ export function validCapacity(key: CapacityKey, value: string): boolean { | ||
| 106 | 110 | } |
| 107 | 111 | |
| 108 | 112 | /** The boot default: the environment, when it holds a valid value. */ |
| 109 | export function capacityDefault(key: CapacityKey): string { | |
| 110 | const env = process.env[CAPACITY[key].env]?.trim() ?? ""; | |
| 111 | if (env !== "" && validCapacity(key, env)) return env; | |
| 113 | export function capacityDefault(key: CapacityKey, cpus?: number): string { | |
| 114 | const env = explicitDefault(key); | |
| 115 | if (env !== undefined) return env; | |
| 116 | const sized = CAPACITY[key].sized; | |
| 117 | if (sized !== undefined) return String(sized(cpus ?? cpuLimit())); | |
| 112 | 118 | if (key === "children.max") return String(childrenForMemory(memoryLimitBytes())); |
| 113 | 119 | if (key === "crew.concurrency") return String(crewForMemory(memoryLimitBytes())); |
| 114 | 120 | return CAPACITY[key].fallback; |
| 115 | 121 | } |
| 116 | 122 | |
| 123 | /** The environment's value for `key`, when it holds a valid one. */ | |
| 124 | function explicitDefault(key: CapacityKey): string | undefined { | |
| 125 | const env = process.env[CAPACITY[key].env]?.trim() ?? ""; | |
| 126 | return env !== "" && validCapacity(key, env) ? env : undefined; | |
| 127 | } | |
| 128 | ||
| 129 | /** Most shards a check takes by default: past four, a test run waits on git, not CPU. */ | |
| 130 | const SHARDS_DEFAULT_MAX = 4; | |
| 131 | /** CPUs a shard is given by default: its bun test, the git children it starts, and the forge it boots. */ | |
| 132 | const CPUS_PER_SHARD = 3; | |
| 133 | ||
| 134 | /** Shards per check by default: `min(4, cpus)`, at least one. 80 CPUs give 4; 2 give 2. */ | |
| 135 | export function shardsForCpus(cpus: number): number { | |
| 136 | const n = Math.floor(cpus); | |
| 137 | return Number.isFinite(n) ? Math.max(1, Math.min(SHARDS_DEFAULT_MAX, n)) : 1; | |
| 138 | } | |
| 139 | ||
| 140 | /** | |
| 141 | * Checks at once by default: `floor(cpus / (shards × 3))`, held to 1–16, so | |
| 142 | * every check's shards have three CPUs each. 80 CPUs: 4 shards, 80 / 12 = 6 | |
| 143 | * checks; 16 CPUs: 1; 4 CPUs: 4 shards, 4 / 12 → 1 (#2549). | |
| 144 | */ | |
| 145 | export function checksForCpus(cpus: number, shards = shardsForCpus(cpus)): number { | |
| 146 | const n = Math.floor(cpus / (shards * CPUS_PER_SHARD)); | |
| 147 | return Number.isFinite(n) ? Math.max(1, Math.min(16, n)) : 1; | |
| 148 | } | |
| 149 | ||
| 150 | let cpusForTest: number | undefined; | |
| 151 | ||
| 152 | /** | |
| 153 | * A test's CPU count in place of the pod's, so a default sized from it is | |
| 154 | * the same on a laptop and an 80-core runner; `undefined` reads the pod again. | |
| 155 | */ | |
| 156 | export function provideCpusForTest(cpus: number | undefined): void { | |
| 157 | cpusForTest = cpus; | |
| 158 | } | |
| 159 | ||
| 160 | /** | |
| 161 | * The pod's CPU limit: cgroup v2 `cpu.max` ("quota period", in µs), rounded | |
| 162 | * down and never below one, else `os.availableParallelism()` — "max" (no | |
| 163 | * quota), an unreadable file or anything not two numbers falls back. Never | |
| 164 | * more than the machine has. | |
| 165 | */ | |
| 166 | export function cpuLimit(file = "/sys/fs/cgroup/cpu.max"): number { | |
| 167 | if (cpusForTest !== undefined) return cpusForTest; | |
| 168 | const machine = availableParallelism(); | |
| 169 | try { | |
| 170 | const [quota, period] = readFileSync(file, "utf8").trim().split(/\s+/).map(Number); | |
| 171 | if (quota !== undefined && period !== undefined && quota > 0 && period > 0) | |
| 172 | return Math.max(1, Math.min(machine, Math.floor(quota / period))); | |
| 173 | } catch { | |
| 174 | // No cgroup v2 CPU controller here: the machine's count. | |
| 175 | } | |
| 176 | return machine; | |
| 177 | } | |
| 178 | ||
| 117 | 179 | const GIB = 1024 ** 3; |
| 118 | 180 | const MIB = 1024 ** 2; |
| 119 | 181 | |
| @@ -215,10 +277,18 @@ export function egressSetting(db: Database): "enforce" | "report" { | ||
| 215 | 277 | return capacityValue(db, "cells.egress") === "report" ? "report" : "enforce"; |
| 216 | 278 | } |
| 217 | 279 | |
| 218 | /** What a check's commands and a crew shell are told: the shard count, when one is set. */ | |
| 219 | export function capacityEnv(db: Database): Record<string, string> { | |
| 220 | const shards = capacityValue(db, "checks.shards"); | |
| 221 | return shards === "" ? {} : { FORGE_CHECK_SHARDS: shards }; | |
| 280 | /** | |
| 281 | * What a check's commands and a crew shell are told: the shard count in | |
| 282 | * force. An admin's value or the environment's is passed as it is; the | |
| 283 | * default is sized from `cpus` — a check passes what its cell holds, so a | |
| 284 | * small cell is never told the pod's count. | |
| 285 | */ | |
| 286 | export function capacityEnv(db: Database, cpus?: number): Record<string, string> { | |
| 287 | const shards = | |
| 288 | storedCapacity(db, "checks.shards") ?? | |
| 289 | explicitDefault("checks.shards") ?? | |
| 290 | String(shardsForCpus(cpus ?? cpuLimit())); | |
| 291 | return { FORGE_CHECK_SHARDS: shards }; | |
| 222 | 292 | } |
| 223 | 293 | |
| 224 | 294 | export interface CapacityChange { |
src/main.ts +2 −1
| @@ -333,7 +333,8 @@ const checks = | ||
| 333 | 333 | ? undefined |
| 334 | 334 | : startChecks(app, { |
| 335 | 335 | forgeUrl, |
| 336 | concurrency: positive(process.env.FORGE_CHECKS_CONCURRENCY, 1), | |
| 336 | // Sized to the pod's CPUs unless the environment says (#2549). | |
| 337 | concurrency: positive(process.env.FORGE_CHECKS_CONCURRENCY, Number(capacityDefault("checks.concurrency"))), | |
This line is well past the width of its neighbours. Compute |
||
| 337 | 338 | redHoldMs: RED_HOLD_MS, |
| 338 | 339 | // A roll lets running checks finish this long before cutting them off. |
| 339 | 340 | drainMs: positive(process.env.FORGE_CHECKS_DRAIN_S, 300) * 1000, |
test/capacity-cpus.test.ts added +143
| @@ -0,0 +1,143 @@ | ||
| 1 | /** | |
| 2 | * Checks at once and shards per check default to what the pod's CPUs hold | |
| 3 | * (#2549): shards `min(4, cpus)`, checks `floor(cpus / (shards × 3))` held | |
| 4 | * to 1–16, so the vxrail's 80 cores need no override and the sentinel no | |
| 5 | * longer calls 4 shards and 6 checks away from their defaults. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterEach, describe, expect, test } from "bun:test"; | |
| 9 | import { writeFileSync } from "node:fs"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { availableParallelism, tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp } from "../src/app.ts"; | |
| 14 | import { | |
| 15 | CAPACITY, | |
| 16 | capacityDefault, | |
| 17 | capacityEnv, | |
| 18 | capacityValue, | |
| 19 | checksForCpus, | |
| 20 | cpuLimit, | |
| 21 | provideCpusForTest, | |
| 22 | setCapacity, | |
| 23 | shardsForCpus, | |
| 24 | } from "../src/core/capacity.ts"; | |
| 25 | import { settingsAway } from "../src/crew/sentinel.ts"; | |
| 26 | import { createUser } from "../src/core/users.ts"; | |
| 27 | ||
| 28 | const ENV = ["FORGE_CHECK_SHARDS", "FORGE_CHECKS_CONCURRENCY"] as const; | |
| 29 | const saved = ENV.map((k) => process.env[k]); | |
| 30 | afterEach(() => { | |
| 31 | ENV.forEach((k, i) => { | |
| 32 | if (saved[i] === undefined) delete process.env[k]; | |
| 33 | else process.env[k] = saved[i]; | |
| 34 | }); | |
| 35 | provideCpusForTest(4); | |
| 36 | }); | |
| 37 | ||
| 38 | describe("the defaults follow the CPUs", () => { | |
| 39 | // By hand: shards = min(4, cpus); checks = floor(cpus / (shards * 3)), 1..16. | |
| 40 | test.each([ | |
| 41 | // cpus, shards, checks | |
| 42 | [4, 4, 1], // 4 / 12 = 0.33 → held to 1 | |
| 43 | [16, 4, 1], // 16 / 12 = 1.33 → 1 | |
| 44 | [80, 4, 6], // 80 / 12 = 6.67 → 6 | |
| 45 | [2, 2, 1], // 2 / 6 = 0.33 → 1 | |
| 46 | [1, 1, 1], | |
| 47 | [1000, 4, 16], // 1000 / 12 = 83 → held to 16 | |
| 48 | ])("%d CPUs: %d shards, %d checks at once", (cpus, shards, checks) => { | |
| 49 | expect(shardsForCpus(cpus)).toBe(shards); | |
| 50 | expect(checksForCpus(cpus)).toBe(checks); | |
| 51 | delete process.env.FORGE_CHECK_SHARDS; | |
| 52 | delete process.env.FORGE_CHECKS_CONCURRENCY; | |
| 53 | expect(capacityDefault("checks.shards", cpus)).toBe(String(shards)); | |
| 54 | expect(capacityDefault("checks.concurrency", cpus)).toBe(String(checks)); | |
| 55 | provideCpusForTest(cpus); | |
| 56 | expect(capacityDefault("checks.shards")).toBe(String(shards)); | |
| 57 | expect(capacityDefault("checks.concurrency")).toBe(String(checks)); | |
| 58 | }); | |
| 59 | ||
| 60 | test("the environment still wins over the CPUs", () => { | |
| 61 | process.env.FORGE_CHECK_SHARDS = "8"; | |
| 62 | process.env.FORGE_CHECKS_CONCURRENCY = "3"; | |
| 63 | expect(capacityDefault("checks.shards", 80)).toBe("8"); | |
| 64 | expect(capacityDefault("checks.concurrency", 80)).toBe("3"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("both help lines say how the default is reached", () => { | |
| 68 | expect(CAPACITY["checks.shards"].help).toContain("at most 4"); | |
| 69 | expect(CAPACITY["checks.concurrency"].help).toContain("shards × 3"); | |
| 70 | }); | |
| 71 | }); | |
| 72 | ||
| 73 | describe("a stored value overrides the default; the sentinel compares with it", () => { | |
| 74 | test("80 CPUs: 4 shards and 6 checks are the defaults, 8 shards is away", async () => { | |
| 75 | delete process.env.FORGE_CHECK_SHARDS; | |
| 76 | delete process.env.FORGE_CHECKS_CONCURRENCY; | |
| 77 | provideCpusForTest(80); | |
| 78 | const dataDir = await mkdtemp(join(tmpdir(), "forge-capacity-cpus-")); | |
| 79 | const app = createApp({ dataDir, port: 0 }); | |
| 80 | try { | |
| 81 | const a = await createUser(app.db, { | |
| 82 | origin: "seed", | |
| 83 | trusted: true, | |
| 84 | username: "alice", | |
| 85 | email: "[email protected]", | |
| 86 | password: "a-good-passphrase", | |
| 87 | adminIfFirst: true, | |
| 88 | }); | |
| 89 | if (!a.ok) throw new Error(a.error); | |
| 90 | expect(capacityValue(app.db, "checks.shards")).toBe("4"); | |
| 91 | expect(capacityEnv(app.db)).toEqual({ FORGE_CHECK_SHARDS: "4" }); | |
| 92 | // A check's cell of 2 CPUs is given 2, not the pod's 4. | |
| 93 | expect(capacityEnv(app.db, 2)).toEqual({ FORGE_CHECK_SHARDS: "2" }); | |
| 94 | ||
| 95 | // The owner's choice for the vxrail, set more than a day ago. | |
| 96 | setCapacity(app.db, "checks.shards", "4", a.value.id); | |
| 97 | setCapacity(app.db, "checks.concurrency", "6", a.value.id); | |
| 98 | setCapacity(app.db, "crew.concurrency", "5", a.value.id); | |
| 99 | const old = new Date(Date.now() - 3 * 24 * 3600_000).toISOString(); | |
| 100 | app.db.query("UPDATE forge_setting SET updated_at = ?1").run(old); | |
| 101 | expect(settingsAway(app.db, Date.now()).map((f) => f.key)).toEqual(["setting:crew.concurrency"]); | |
| 102 | ||
| 103 | // A stored value overrides the default, even a cell's smaller one. | |
| 104 | setCapacity(app.db, "checks.shards", "8", a.value.id); | |
| 105 | app.db.query("UPDATE forge_setting SET updated_at = ?1").run(old); | |
| 106 | expect(capacityValue(app.db, "checks.shards")).toBe("8"); | |
| 107 | expect(capacityEnv(app.db, 2)).toEqual({ FORGE_CHECK_SHARDS: "8" }); | |
| 108 | const away = settingsAway(app.db, Date.now()).find((f) => f.key === "setting:checks.shards"); | |
| 109 | expect(away?.evidence[0]).toBe('checks.shards = "8", default "4"'); | |
| 110 | } finally { | |
| 111 | app.close(); | |
| 112 | await rm(dataDir, { recursive: true, force: true }); | |
| 113 | } | |
| 114 | }); | |
| 115 | }); | |
| 116 | ||
| 117 | describe("cpuLimit reads the pod's cgroup", () => { | |
| 118 | const withFile = async (text: string | undefined, run: (file: string) => void) => { | |
| 119 | provideCpusForTest(undefined); | |
| 120 | const dir = await mkdtemp(join(tmpdir(), "forge-cpu-max-")); | |
| 121 | const file = join(dir, "cpu.max"); | |
| 122 | if (text !== undefined) writeFileSync(file, text); | |
| 123 | try { | |
| 124 | run(file); | |
| 125 | } finally { | |
| 126 | await rm(dir, { recursive: true, force: true }); | |
| 127 | } | |
| 128 | }; | |
| 129 | const machine = availableParallelism(); | |
| 130 | ||
| 131 | test("quota over period, rounded down, never more than the machine", async () => { | |
| 132 | await withFile("150000 100000\n", (f) => expect(cpuLimit(f)).toBe(Math.min(machine, 1))); | |
| 133 | await withFile("50000 100000\n", (f) => expect(cpuLimit(f)).toBe(1)); | |
| 134 | await withFile(`${machine * 100000} 100000\n`, (f) => expect(cpuLimit(f)).toBe(machine)); | |
| 135 | await withFile(`${machine * 200000} 100000\n`, (f) => expect(cpuLimit(f)).toBe(machine)); | |
| 136 | }); | |
| 137 | ||
| 138 | test("no quota, garbage or an unreadable file falls back to the machine", async () => { | |
| 139 | await withFile("max 100000\n", (f) => expect(cpuLimit(f)).toBe(machine)); | |
| 140 | await withFile("nonsense", (f) => expect(cpuLimit(f)).toBe(machine)); | |
| 141 | await withFile(undefined, (f) => expect(cpuLimit(f)).toBe(machine)); | |
| 142 | }); | |
| 143 | }); |
test/capacity.test.ts +13 −4
| @@ -22,8 +22,8 @@ import { AppsRuntime } from "../src/apps/runtime.ts"; | ||
| 22 | 22 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; |
| 23 | 23 | import { |
| 24 | 24 | capacityChanges, |
| 25 | capacityDefault, | |
| 26 | 25 | capacityCount, |
| 26 | capacityDefault, | |
| 27 | 27 | noteBooted, |
| 28 | 28 | capacityEnv, |
| 29 | 29 | capacityValue, |
| @@ -292,14 +292,23 @@ describe("capacity: only a forge admin reads or changes it", () => { | ||
| 292 | 292 | body: new URLSearchParams({ key: "checks.shards", reset: "1" }), |
| 293 | 293 | }); |
| 294 | 294 | expect(reset.status).toBe(303); |
| 295 | expect(capacityEnv(app.db)).toEqual({}); | |
| 295 | // Back at the default, which is sized from the CPUs it is given (#2549). | |
| 296 | const fromEnv = process.env.FORGE_CHECK_SHARDS; | |
| 297 | delete process.env.FORGE_CHECK_SHARDS; | |
| 298 | try { | |
| 299 | expect(capacityEnv(app.db, 80)).toEqual({ FORGE_CHECK_SHARDS: "4" }); | |
| 300 | expect(capacityEnv(app.db, 2)).toEqual({ FORGE_CHECK_SHARDS: "2" }); | |
| 301 | } finally { | |
| 302 | if (fromEnv !== undefined) process.env.FORGE_CHECK_SHARDS = fromEnv; | |
| 303 | } | |
| 304 | const shardsDefault = capacityDefault("checks.shards"); | |
| 296 | 305 | |
| 297 | 306 | const changes = capacityChanges(app.db); |
| 298 | 307 | expect(changes.map((c) => [c.key, c.from, c.to, c.actorName])).toEqual([ |
| 299 | ["checks.shards", "3", "", "alice"], | |
| 308 | ["checks.shards", "3", shardsDefault, "alice"], | |
| 300 | 309 | ["cells.egress", "enforce", "report", "alice"], |
| 301 | 310 | ["crew.concurrency", capacityDefault("crew.concurrency"), "3", "alice"], |
| 302 | ["checks.shards", "", "3", "alice"], | |
| 311 | ["checks.shards", shardsDefault, "3", "alice"], | |
| 303 | 312 | ]); |
| 304 | 313 | const after = await ( |
| 305 | 314 | await fetch(`${servers.url}/settings/capacity`, { headers: { cookie } }) |
test/helpers/preload.ts +4
| @@ -3,6 +3,10 @@ | ||
| 3 | 3 | * the forge. Only what the whole suite must agree on belongs here. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import { provideCpusForTest } from "../../src/core/capacity.ts"; | |
| 6 | 7 | import { installCheapHash } from "./cheap-hash.ts"; |
| 7 | 8 | |
| 8 | 9 | installCheapHash(); |
| 10 | // Defaults sized from CPUs (#2549) read as on a 4-CPU pod — 4 shards, one | |
| 11 | // check at once — whatever machine runs the suite. | |
| 12 | provideCpusForTest(4); |
test/snapshot-restore-parallel.test.ts +18
| @@ -11,6 +11,7 @@ import { mkdtempSync, readFileSync } from "node:fs"; | ||
| 11 | 11 | import { rm } from "node:fs/promises"; |
| 12 | 12 | import { tmpdir } from "node:os"; |
| 13 | 13 | import { join } from "node:path"; |
| 14 | import type { runGit } from "../src/git/env.ts"; | |
| 14 | 15 | import { restoreSnapshot } from "../src/snapshot/restore.ts"; |
| 15 | 16 | import { IN_FLIGHT } from "../src/snapshot/snapshot.ts"; |
| 16 | 17 | import { dirStore, type Store } from "../src/snapshot/store.ts"; |
| @@ -140,4 +141,21 @@ describe("a restore reads in parallel", () => { | ||
| 140 | 141 | "could not restore alice/first", |
| 141 | 142 | ); |
| 142 | 143 | }); |
| 144 | ||
| 145 | test("when clones fail at once, the one named is the manifest's earliest, not the race's winner", async () => { | |
| 146 | const order = ["alice/first", "alice/second", "alice/third"]; | |
| 147 | const files = order.map((r) => ({ path: `repos/${r}.bundle`, bytes: 13 })); | |
| 148 | const store = dirStore(join(root, "store-5")); | |
| 149 | for (const f of files) await store.put(prefix + f.path, "not a bundle\n"); | |
| 150 | await store.put(`${prefix}manifest.json`, manifestOf(files)); | |
| 151 | // Every clone fails; the first entry's fails last. | |
| 152 | const git: typeof runGit = async (args) => { | |
| 153 | const first = args.some((a) => a.endsWith("first.bundle")); | |
| 154 | await Bun.sleep(first ? 60 : 5); | |
| 155 | return { ok: false, error: { code: 128, stderr: "not a bundle", args } }; | |
| 156 | }; | |
| 157 | await expect(restoreSnapshot(store, NAME, join(root, "data-5"), { git })).rejects.toThrow( | |
| 158 | "could not restore alice/first", | |
| 159 | ); | |
| 160 | }); | |
| 143 | 161 | }); |
new above
offline
capacityEnvnow always sets FORGE_CHECK_SHARDS, and the test covers this. A crew shell with nocpusargument gets the pod's count, not its cell's. That is fine today, but say so in the doc comment.