|
|
@@ -0,0 +1,303 @@ |
| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 17 |
import { readdirSync, readFileSync, rmSync } from "node:fs"; |
| 18 |
import { join } from "node:path"; |
| 19 |
import { heapStats } from "bun:jsc"; |
| 20 |
import type { App } from "../src/app.ts"; |
| 21 |
import { bigForge } from "./helpers/big-forge.ts"; |
| 22 |
import { quietLogs } from "./helpers/globals.ts"; |
| 23 |
import { startSampler } from "../src/boards/sampler.ts"; |
| 24 |
import { startMemorySampler } from "../src/boards/memory.ts"; |
| 25 |
import { tickKeeper } from "../src/flow/keeper.ts"; |
| 26 |
import { tickClog } from "../src/flow/clog_tick.ts"; |
| 27 |
import { pump, recheckForDisk, requeueInterrupted, sweepAssigned } from "../src/crew/dispatch.ts"; |
| 28 |
import type { CrewOptions } from "../src/crew/runner.ts"; |
| 29 |
import { crewRoot } from "../src/crew/workdir.ts"; |
| 30 |
import { claimedClones, sweepOrphanClones } from "../src/crew/orphans.ts"; |
| 31 |
import { pruneRunFiles } from "../src/runs/prune.ts"; |
| 32 |
import { radarNight } from "../src/nightly/radar.ts"; |
| 33 |
import { runAheadReads } from "../src/origins/ahead.ts"; |
| 34 |
import { runGuardSweep } from "../src/origins/credential.ts"; |
| 35 |
import { sweepMirrorPulls } from "../src/origins/mirror-pulls.ts"; |
| 36 |
import { sweepReach } from "../src/spawn/reach.ts"; |
| 37 |
import { checkUpstream as checkParent } from "../src/spawn/upstream.ts"; |
| 38 |
import { census, censusWords, processRows } from "../src/kernel/census.ts"; |
| 39 |
import { runDelivery } from "../src/core/webhooks.ts"; |
| 40 |
import { pruneSessions } from "../src/core/sessions.ts"; |
| 41 |
import { pruneTokens } from "../src/core/tokens.ts"; |
| 42 |
import { runSync } from "../src/origins/sync.ts"; |
| 43 |
import { checkDue } from "../src/actions/upstream_follow.ts"; |
| 44 |
import { sweepStaleAsks } from "../src/crew/stale_asks.ts"; |
| 45 |
import { sweepStanding } from "../src/crew/standing.ts"; |
| 46 |
import { recordAskLedger } from "../src/web/studio/asks.ts"; |
| 47 |
import { sweepStarts } from "../src/actions/start_sweep.ts"; |
| 48 |
import { expireCrewHolds } from "../src/actions/holds.ts"; |
| 49 |
import { plannedRepoIds, tickWeeks } from "../src/flow/retro.ts"; |
| 50 |
import { getRepoById } from "../src/core/repos.ts"; |
| 51 |
import { failAbandonedRuns, heartbeatRuns } from "../src/core/runs.ts"; |
| 52 |
import { sleepBelowFloor, sleepIdleChildren } from "../src/spawn/host.ts"; |
| 53 |
import { createReadLedger, createReadWriter } from "../src/perf/reads.ts"; |
| 54 |
import { flushViews, noteView } from "../src/perf/views_store.ts"; |
| 55 |
import { requeueAbandonedChecks } from "../src/checks/service.ts"; |
| 56 |
import { releaseOrphans } from "../src/core/run_secret.ts"; |
| 57 |
import { STALE_AFTER_MS } from "../src/runs/lease.ts"; |
| 58 |
import { flushFlagUses } from "../src/core/flags.ts"; |
| 59 |
import { sendBeat } from "../src/spawn/heartbeat.ts"; |
| 60 |
import { runModelReview } from "../src/actions/model_review.ts"; |
| 61 |
import { readLandings } from "../src/release/landings.ts"; |
| 62 |
import { sweepStanding as sweepCallers } from "../src/http/caller.ts"; |
| 63 |
import { nightlyDue } from "../src/checks/scan_store.ts"; |
| 64 |
import { startNotifier } from "../src/crew/notify.ts"; |
| 65 |
import { fireDue } from "../src/apps/feature_schedule.ts"; |
| 66 |
import { pullDue } from "../src/apps/pull_schedule.ts"; |
| 67 |
|
| 68 |
|
| 69 |
const BUSY = { issues: 2000, pulls: 2000, requested: 100, runs: 5000, comments: 5000, events: 10000, reviewComments: 500 }; |
| 70 |
|
| 71 |
|
| 72 |
quietLogs(); |
| 73 |
|
| 74 |
let app: App; |
| 75 |
let dir = ""; |
| 76 |
beforeAll(() => { |
| 77 |
({ app, dir } = bigForge(BUSY)); |
| 78 |
|
| 79 |
const at = new Date(Date.now() - 3_600_000).toISOString(); |
| 80 |
const run = app.db.prepare( |
| 81 |
"INSERT INTO run (id, repo_id, issue_id, kind, state, reason, summary, created_at, started_at, actor_id) VALUES (?1, 'r', ?2, 'agent', ?3, 'assigned', '', ?4, ?5, 'builder')", |
| 82 |
); |
| 83 |
for (let n = 1; n <= 80; n++) run.run(`q${n}`, `i${n}`, "queued", at, null); |
| 84 |
for (let n = 81; n <= 90; n++) run.run(`s${n}`, `i${n}`, "running", at, at); |
| 85 |
reads = createReadWriter(app.db, readLedger, app.log, Date.now, 1e9); |
| 86 |
}, 60_000); |
| 87 |
afterAll(() => { |
| 88 |
reads.stop(); |
| 89 |
app.db.close(); |
| 90 |
rmSync(dir, { recursive: true, force: true }); |
| 91 |
}); |
| 92 |
|
| 93 |
|
| 94 |
async function slice(fn: () => unknown): Promise<number> { |
| 95 |
let last = performance.now(); |
| 96 |
let worst = 0; |
| 97 |
let turning = true; |
| 98 |
const turn = (): void => { |
| 99 |
const at = performance.now(); |
| 100 |
worst = Math.max(worst, at - last); |
| 101 |
last = at; |
| 102 |
if (turning) setImmediate(turn); |
| 103 |
}; |
| 104 |
setImmediate(turn); |
| 105 |
const start = performance.now(); |
| 106 |
const result = fn(); |
| 107 |
const sync = performance.now() - start; |
| 108 |
await Promise.resolve(result).catch(() => undefined); |
| 109 |
turning = false; |
| 110 |
await new Promise((resolve) => setImmediate(resolve)); |
| 111 |
return Math.max(sync, worst); |
| 112 |
} |
| 113 |
|
| 114 |
|
| 115 |
const RUNNING = Array.from({ length: 10 }, (_, i) => `s${81 + i}`); |
| 116 |
const NO_ORIGIN = { refHost: "", source: "", medium: "", campaign: "", out: "" }; |
| 117 |
const readLedger = createReadLedger(); |
| 118 |
let reads: { flush(): void; stop(): void }; |
| 119 |
|
| 120 |
|
| 121 |
const NO_SLOT: CrewOptions = { |
| 122 |
forgeUrl: "https://forge.test/", localUrl: "http://127.0.0.1:0/", binary: "/nonexistent/claude", |
| 123 |
concurrency: 0, budgetUsd: 0, runTimeoutMs: 1, |
| 124 |
}; |
| 125 |
|
| 126 |
|
| 127 |
function jobs(): Record<string, () => unknown> { |
| 128 |
const notifier = startNotifier(app, { |
| 129 |
forgeUrl: "https://forge.test/", send: async () => true, clockMs: 1e9, |
| 130 |
drainingSince: () => null, causes: () => [], alarms: () => [], |
| 131 |
}); |
| 132 |
notifier.stop(); |
| 133 |
const sampler = startSampler(app, { everyMs: 1e9 }); |
| 134 |
const memory = startMemorySampler(app, 1e9); |
| 135 |
sampler.stop(); |
| 136 |
memory.stop(); |
| 137 |
return { |
| 138 |
sampler: () => sampler.tick(), |
| 139 |
"memory-sampler": () => memory.tick(), |
| 140 |
keeper: () => tickKeeper(app, new Date(), () => {}), |
| 141 |
clog: () => tickClog(app.db, app.bus), |
| 142 |
|
| 143 |
|
| 144 |
crew: () => { |
| 145 |
requeueInterrupted(app); |
| 146 |
recheckForDisk(app); |
| 147 |
sweepAssigned(app); |
| 148 |
pump(app, NO_SLOT, new Map()); |
| 149 |
}, |
| 150 |
|
| 151 |
"clone-sweep": async () => { |
| 152 |
await sweepOrphanClones(crewRoot(app.config.dataDir), claimedClones(app.db)); |
| 153 |
pruneRunFiles(app.config.dataDir); |
| 154 |
}, |
| 155 |
|
| 156 |
nightly: () => radarNight(app, async () => "not looked up"), |
| 157 |
|
| 158 |
processes: () => censusWords(census(processRows())), |
| 159 |
|
| 160 |
ahead: () => runAheadReads(app), |
| 161 |
"guard-sweep": () => runGuardSweep(app), |
| 162 |
"mirror-pulls": () => sweepMirrorPulls(app), |
| 163 |
reach: () => sweepReach(app), |
| 164 |
upstream: () => checkParent(app), |
| 165 |
|
| 166 |
notify: async () => { |
| 167 |
const told = startNotifier(app, { |
| 168 |
forgeUrl: "https://forge.test/", send: async () => true, clockMs: 1e9, |
| 169 |
drainingSince: () => null, causes: () => [], alarms: () => [], |
| 170 |
}); |
| 171 |
await told.settled(); |
| 172 |
await told.close(); |
| 173 |
}, |
| 174 |
|
| 175 |
"spawn-floor": () => sleepBelowFloor(app), |
| 176 |
"spawn-idle": () => sleepIdleChildren(app), |
| 177 |
|
| 178 |
"lease-renew": () => heartbeatRuns(app.db, RUNNING), |
| 179 |
|
| 180 |
reads: () => { |
| 181 |
for (let n = 0; n < 1000; n++) readLedger.note(Date.now(), { repo: "r", ref: "main", path: `f${n % 400}` }, n % 2 === 0); |
| 182 |
reads.flush(); |
| 183 |
}, |
| 184 |
views: () => { |
| 185 |
for (let n = 0; n < 1000; n++) |
| 186 |
noteView(app.db, { scope: "r", path: `/p${n % 200}`, origin: NO_ORIGIN, client: `c${n % 300}`, agent: "Mozilla/5.0" }); |
| 187 |
flushViews(app.db); |
| 188 |
}, |
| 189 |
delivery: () => { |
| 190 |
runDelivery(app.db, app.log, app.bus); |
| 191 |
pruneSessions(app.db); |
| 192 |
pruneTokens(app.db); |
| 193 |
}, |
| 194 |
sync: () => runSync(app), |
| 195 |
follow: () => checkDue(app), |
| 196 |
asks: async () => { |
| 197 |
await sweepStaleAsks(app); |
| 198 |
await sweepStanding(app); |
| 199 |
recordAskLedger(app.db); |
| 200 |
}, |
| 201 |
starts: () => sweepStarts(app), |
| 202 |
holds: () => expireCrewHolds(app), |
| 203 |
week: () => { |
| 204 |
const repos = plannedRepoIds(app.db).flatMap((id) => getRepoById(app.db, id) ?? []); |
| 205 |
tickWeeks(app.db, repos, new Date()); |
| 206 |
}, |
| 207 |
lease: () => { |
| 208 |
failAbandonedRuns(app.db, STALE_AFTER_MS, ["deploy"]); |
| 209 |
requeueAbandonedChecks(app, STALE_AFTER_MS); |
| 210 |
releaseOrphans(app.db); |
| 211 |
}, |
| 212 |
"flag-uses": () => flushFlagUses(app.db), |
| 213 |
heartbeat: () => sendBeat(app, async () => new Response("")), |
| 214 |
"model-review": () => runModelReview(app), |
| 215 |
landings: () => readLandings(app), |
| 216 |
"notify-page": async () => { |
| 217 |
await notifier.settled(); |
| 218 |
await notifier.tick(); |
| 219 |
}, |
| 220 |
"standing-sweep": () => sweepCallers(app.db, () => {}), |
| 221 |
"nightly-scan": () => nightlyDue(app.db, new Date(Date.now() - 24 * 3_600_000).toISOString(), 25), |
| 222 |
"feature-schedule": () => fireDue(app), |
| 223 |
"pull-schedule": () => pullDue(app), |
| 224 |
}; |
| 225 |
} |
| 226 |
|
| 227 |
|
| 228 |
|
| 229 |
|
| 230 |
const UNTIMED: Record<string, string> = { |
| 231 |
lane: "a closure in startChecks; starting the checks service starts cell checks — follow-up", |
| 232 |
"lane-catch-up": "a closure in startChecks, once on boot; same as lane", |
| 233 |
"verify-sweep": "a closure in startChecks, off unless verifyEveryMs is set; same as lane", |
| 234 |
looks: "a closure in startLooks; needs the apps runtime and chromium, and the fixture has no apps", |
| 235 |
"perf-watcher": "a closure in startPerfWatcher over its own in-memory record; the fixture has no FORGE_REPO to file in", |
| 236 |
probes: "probes each app over HTTP after a deploy and nightly; the fixture has no apps", |
| 237 |
prompts: "a closure in startPromptRoutine; the fixture has no prompt routines", |
| 238 |
snapshot: "copies the database file to disk (VACUUM INTO): I/O off the loop, nightly", |
| 239 |
"snapshot-mirror": "uploads a snapshot to the mirror: network, nightly", |
| 240 |
drill: "restores a snapshot into a scratch forge: nightly, I/O", |
| 241 |
"release-behind": "reads the forge's own release tags with git; needs FORGE_REPO and its repository", |
| 242 |
"release-keeper": "cluster only (FORGE_RELEASE_KEEPER=on): talks to the cluster's API", |
| 243 |
"roll-probe": "cluster only (FORGE_RELEASE_KEEPER=on): one HTTP probe", |
| 244 |
"apps-sweep": "AppsRuntime's sweep over running app processes; the fixture has no apps", |
| 245 |
"cell-watch": "watches running app cells' cgroups; needs cells, skipped in this sandbox mode", |
| 246 |
"egress-idle": "a closure in the egress proxy over its live connections; needs the proxy's unix socket", |
| 247 |
"git-readers": "closes idle git cat-file readers: a map walk over child processes the fixture has none of", |
| 248 |
"github-traffic": "pulls GitHub's traffic API: network, and off unless a GitHub token is configured", |
| 249 |
presence: "writes this process's heartbeat file under the data directory: one small file write", |
| 250 |
"snapshot-journal": "inside a running snapshot upload: writes its journal; nightly, I/O", |
| 251 |
"snapshot-report": "inside a running snapshot upload: logs its progress; nightly", |
| 252 |
"system-live": "reads /proc and the cgroup roots for the system page while someone watches it", |
| 253 |
traffic: "a closure in createTrafficWriter over its in-memory minute ledger; follow-up", |
| 254 |
}; |
| 255 |
|
| 256 |
|
| 257 |
const CEILING_MS = 250; |
| 258 |
|
| 259 |
|
| 260 |
function duringNames(dir = new URL("../src/", import.meta.url).pathname): Set<string> { |
| 261 |
const names = new Set<string>(); |
| 262 |
for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) { |
| 263 |
if (!entry.isFile() || !entry.name.endsWith(".ts")) continue; |
| 264 |
const text = readFileSync(join(entry.parentPath, entry.name), "utf8"); |
| 265 |
for (const m of text.matchAll(/\bduring\(\s*"([^"]+)"/g)) names.add(m[1]!); |
| 266 |
} |
| 267 |
return names; |
| 268 |
} |
| 269 |
|
| 270 |
test("every job src/ runs under `during` is timed here or named in UNTIMED with a reason", () => { |
| 271 |
const timed = Object.keys(jobs()); |
| 272 |
const named = duringNames(); |
| 273 |
expect(named.size).toBeGreaterThan(40); |
| 274 |
const neither = [...named].filter((n) => !timed.includes(n) && !(n in UNTIMED)).sort(); |
| 275 |
const both = timed.filter((n) => n in UNTIMED); |
| 276 |
const gone = [...timed, ...Object.keys(UNTIMED)].filter((n) => !named.has(n)).sort(); |
| 277 |
expect({ neither, both, gone }).toEqual({ neither: [], both: [], gone: [] }); |
| 278 |
}); |
| 279 |
|
| 280 |
test("every timed job's tick holds the loop under the ceiling on a busy fixture, worst first", async () => { |
| 281 |
const measured: { name: string; ms: number }[] = []; |
| 282 |
for (const [name, tick] of Object.entries(jobs())) { |
| 283 |
await slice(tick); |
| 284 |
Bun.gc(true); |
| 285 |
measured.push({ name, ms: await slice(tick) }); |
| 286 |
} |
| 287 |
measured.sort((a, b) => b.ms - a.ms); |
| 288 |
console.log(`job slices (ms), worst first:\n${measured.map((j) => ` ${j.name}: ${j.ms.toFixed(1)}`).join("\n")}`); |
| 289 |
expect(measured.filter((j) => j.ms >= CEILING_MS)).toEqual([]); |
| 290 |
}, 60_000); |
| 291 |
|
| 292 |
test("the memory sampler reads the heap's size without walking the heap", async () => { |
| 293 |
|
| 294 |
const held = Array.from({ length: 300_000 }, (_, i) => ({ i })); |
| 295 |
const memory = startMemorySampler(app, 1e9); |
| 296 |
memory.stop(); |
| 297 |
memory.tick(); |
| 298 |
|
| 299 |
const walk = await slice(() => heapStats()); |
| 300 |
const tick = await slice(() => memory.tick()); |
| 301 |
console.log(`memory-sampler on ${held.length} objects: ${tick.toFixed(1)} ms; heapStats() alone: ${walk.toFixed(1)} ms`); |
| 302 |
expect(tick).toBeLessThan(walk / 4); |
| 303 |
}, 60_000); |