Public: anyone can read this; the crew can change it.
A loop stall that no request ran across names the background job that was running #10845
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10834-a-loop-stall-that-no-request-ran-across → main 3 commits fork 1 thread · 1 unresolved
25 files
changed +351
−92
src/apps/feature_schedule.ts +2 −1
| @@ -16,6 +16,7 @@ import { getRepoById } from "../core/repos.ts"; | ||
| 16 | 16 | import { callTool } from "./feature_tools.ts"; |
| 17 | 17 | import { SCHEDULE_EVERY, type ScheduleEntry } from "./feature.ts"; |
| 18 | 18 | import { appsRuntime } from "./runtime.ts"; |
| 19 | import { during } from "../perf/jobs.ts"; | |
| 19 | 20 | |
| 20 | 21 | /** The entries whose interval has passed since they last fired (never fired: due). `lastFired` is ms by tool. */ |
| 21 | 22 | export function due(schedule: readonly ScheduleEntry[], lastFired: ReadonlyMap<string, number>, now: number): ScheduleEntry[] { |
| @@ -148,7 +149,7 @@ export function startFeatureSchedule(app: App, tickMs: number = TICK_MS): { stop | ||
| 148 | 149 | const tick = setInterval(() => { |
| 149 | 150 | if (running) return; |
| 150 | 151 | running = true; |
| 151 | void fireDue(app).finally(() => (running = false)); | |
| 152 | void during("feature-schedule", () => fireDue(app)).finally(() => (running = false)); | |
| 152 | 153 | }, tickMs); |
| 153 | 154 | tick.unref(); |
| 154 | 155 | return { stop: () => clearInterval(tick) }; |
src/apps/pull_schedule.ts +2 −1
| @@ -28,6 +28,7 @@ import { readFileAt } from "./checkout.ts"; | ||
| 28 | 28 | import { identityHeaders, type GrantWho } from "./proxy.ts"; |
| 29 | 29 | import { parsePullAnswer, PULL_BODY_MAX, recordPull, type PullAnswer } from "./pull.ts"; |
| 30 | 30 | import { appsRuntime } from "./runtime.ts"; |
| 31 | import { during } from "../perf/jobs.ts"; | |
| 31 | 32 | |
| 32 | 33 | /** How long the forge waits for one `/pull` answer, as for a scheduled tool. */ |
| 33 | 34 | export const PULL_WAIT_MS = 10_000; |
| @@ -246,7 +247,7 @@ export function startPullSchedule(app: App, tickMs: number = TICK_MS): { stop(): | ||
| 246 | 247 | const tick = setInterval(() => { |
| 247 | 248 | if (running) return; |
| 248 | 249 | running = true; |
| 249 | void pullDue(app).finally(() => (running = false)); | |
| 250 | void during("pull-schedule", () => pullDue(app)).finally(() => (running = false)); | |
| 250 | 251 | }, tickMs); |
| 251 | 252 | tick.unref(); |
| 252 | 253 | return { stop: () => clearInterval(tick) }; |
src/boards/memory.ts +3 −2
| @@ -9,6 +9,7 @@ import type { Database } from "bun:sqlite"; | ||
| 9 | 9 | import { heapStats } from "bun:jsc"; |
| 10 | 10 | import type { App } from "../app.ts"; |
| 11 | 11 | import { quietly } from "../core/write_clock.ts"; |
| 12 | import { during } from "../perf/jobs.ts"; | |
| 12 | 13 | |
| 13 | 14 | export const MEMORY_EVERY_MS = 60_000; |
| 14 | 15 | export const HOUR_MS = 3_600_000; |
| @@ -68,8 +69,8 @@ export function startMemorySampler(app: App, everyMs = MEMORY_EVERY_MS): MemoryS | ||
| 68 | 69 | app.log.warn("memory sample failed", { error: String(error) }); |
| 69 | 70 | } |
| 70 | 71 | }; |
| 71 | tick(); | |
| 72 | const timer = setInterval(tick, everyMs); | |
| 72 | during("memory-sampler", () => tick()); | |
| 73 | const timer = setInterval(() => during("memory-sampler", () => tick()), everyMs); | |
| 73 | 74 | timer.unref?.(); |
| 74 | 75 | return { tick, stop: () => clearInterval(timer) }; |
| 75 | 76 | } |
src/boards/sampler.ts +3 −2
| @@ -28,6 +28,7 @@ import { cellCounters, type CellCounter } from "../apps/contain.ts"; | ||
| 28 | 28 | import { startLoopLag, type LoopLag } from "../kernel/loop_lag.ts"; |
| 29 | 29 | import { FINE_MS, record, type Point } from "./ring.ts"; |
| 30 | 30 | import { readHwmon, readTopology, temperatures } from "./thermal.ts"; |
| 31 | import { during } from "../perf/jobs.ts"; | |
| 31 | 32 | |
| 32 | 33 | export type Paths = { cgroup: string; proc: string; sys: string }; |
| 33 | 34 | export const SYSTEM: Paths = { cgroup: "/sys/fs/cgroup", proc: "/proc", sys: "/sys" }; |
| @@ -240,8 +241,8 @@ export function startSampler( | ||
| 240 | 241 | app.log.warn("machine sample failed", { error: String(error) }); |
| 241 | 242 | } |
| 242 | 243 | }; |
| 243 | tick(); | |
| 244 | const timer = setInterval(tick, options.everyMs ?? FINE_MS); | |
| 244 | during("sampler", tick); | |
| 245 | const timer = setInterval(() => during("sampler", tick), options.everyMs ?? FINE_MS); | |
| 245 | 246 | timer.unref?.(); |
| 246 | 247 | return { |
| 247 | 248 | tick, |
src/checks/service.ts +7 −4
| @@ -127,6 +127,7 @@ import { checkStage, type GateStage } from "./gate_reason.ts"; | ||
| 127 | 127 | import { extraGates, GATE_DEADLINE_MS, gateQueued, gateRunId, gatesInFlight, watchGates } from "./queue.ts"; |
| 128 | 128 | import type { JobOptions } from "../cluster/jobs.ts"; |
| 129 | 129 | import type { CheckDetail } from "../core/runs.ts"; |
| 130 | import { during } from "../perf/jobs.ts"; | |
| 130 | 131 | |
| 131 | 132 | const log = createLog("checks"); |
| 132 | 133 | |
| @@ -1660,7 +1661,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1660 | 1661 | } |
| 1661 | 1662 | |
| 1662 | 1663 | const timer = setInterval(() => { |
| 1663 | if (!stopped) schedule(); | |
| 1664 | if (!stopped) during("lane", schedule); | |
| 1664 | 1665 | }, TICK_MS); |
| 1665 | 1666 | // Main is verified after landing on a schedule, when the forge says how often. |
| 1666 | 1667 | const sweeper = |
| @@ -1668,12 +1669,14 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1668 | 1669 | ? undefined |
| 1669 | 1670 | : setInterval(() => { |
| 1670 | 1671 | if (!stopped) |
| 1671 | askVerifications().catch((error) => log.error("verification not asked", { error: String(error) })); | |
| 1672 | during("verify-sweep", askVerifications).catch((error) => | |
| 1673 | log.error("verification not asked", { error: String(error) }), | |
| 1674 | ); | |
| 1672 | 1675 | }, Math.min(VERIFY_SWEEP_MS, options.verifyEveryMs)); |
| 1673 | 1676 | // A gate's slot frees: the pull check held for it starts now, not at the tick. |
| 1674 | 1677 | const unwatch = watchGates(schedule); |
| 1675 | schedule(); | |
| 1676 | void catchUp(); | |
| 1678 | during("lane", schedule); | |
| 1679 | void during("lane-catch-up", catchUp); | |
| 1677 | 1680 | |
| 1678 | 1681 | /** One gate's run on the lane: the full tier on `commitOid`, traced, cut off by `controller`. */ |
| 1679 | 1682 | async function gateOnce( |
src/contract/probes.ts +3 −2
| @@ -22,6 +22,7 @@ import { id as newId } from "../kernel/id.ts"; | ||
| 22 | 22 | import { createLog } from "../kernel/log.ts"; |
| 23 | 23 | import { audit } from "./audit.ts"; |
| 24 | 24 | import { probe, type Knock, type Probed } from "./probe.ts"; |
| 25 | import { during } from "../perf/jobs.ts"; | |
| 25 | 26 | |
| 26 | 27 | const log = createLog("probes"); |
| 27 | 28 | |
| @@ -229,7 +230,7 @@ export function startProbes( | ||
| 229 | 230 | if (!record) return; |
| 230 | 231 | const timer = setTimeout(() => { |
| 231 | 232 | timers.delete(timer); |
| 232 | if (!stopped) void one(record); | |
| 233 | if (!stopped) void during("probes", () => one(record)); | |
| 233 | 234 | }, AFTER_DEPLOY_MS); |
| 234 | 235 | timers.add(timer); |
| 235 | 236 | }); |
| @@ -238,7 +239,7 @@ export function startProbes( | ||
| 238 | 239 | const scheduleNightly = (): void => { |
| 239 | 240 | if (options.nightly === false) return; |
| 240 | 241 | nightly = setTimeout(() => { |
| 241 | void track(round()).finally(scheduleNightly); | |
| 242 | void track(during("probes", round)).finally(scheduleNightly); | |
| 242 | 243 | }, untilNightly()); |
| 243 | 244 | }; |
| 244 | 245 | scheduleNightly(); |
src/crew/dispatch.ts +22 −17
| @@ -132,6 +132,7 @@ import { | ||
| 132 | 132 | } from "./runner.ts"; |
| 133 | 133 | import { STALE_AFTER_MS } from "../runs/lease.ts"; |
| 134 | 134 | import { abortAgentRun } from "./running.ts"; |
| 135 | import { during } from "../perf/jobs.ts"; | |
| 135 | 136 | |
| 136 | 137 | const log = createLog("crew"); |
| 137 | 138 | |
| @@ -275,18 +276,20 @@ export async function startCrew( | ||
| 275 | 276 | // A tick that throws is logged and the next one tries again: thrown |
| 276 | 277 | // from a timer it is an uncaught error that takes the process down. |
| 277 | 278 | try { |
| 278 | requeueInterrupted(app); | |
| 279 | recheckForDisk(app); | |
| 280 | if (Date.now() - lastAutonomy >= AUTONOMY_TICK_MS) { | |
| 281 | lastAutonomy = Date.now(); | |
| 282 | track(settleAll(app).catch((error) => | |
| 283 | log.error("converge sweep failed", { error: String(error) }), | |
| 284 | )); | |
| 285 | track(autonomyTick(app).catch((error) => | |
| 286 | log.error("autonomy tick failed", { error: String(error) }), | |
| 287 | )); | |
| 288 | } | |
| 289 | pump(app, ready, inFlight); | |
| 279 | during("crew", () => { | |
| 280 | requeueInterrupted(app); | |
| 281 | recheckForDisk(app); | |
| 282 | if (Date.now() - lastAutonomy >= AUTONOMY_TICK_MS) { | |
| 283 | lastAutonomy = Date.now(); | |
| 284 | track(settleAll(app).catch((error) => | |
| 285 | log.error("converge sweep failed", { error: String(error) }), | |
| 286 | )); | |
| 287 | track(autonomyTick(app).catch((error) => | |
| 288 | log.error("autonomy tick failed", { error: String(error) }), | |
| 289 | )); | |
| 290 | } | |
| 291 | pump(app, ready, inFlight); | |
| 292 | }); | |
| 290 | 293 | } catch (error) { |
| 291 | 294 | log.error("crew tick failed", { error: String(error) }); |
| 292 | 295 | } |
| @@ -301,11 +304,13 @@ export async function startCrew( | ||
| 301 | 304 | const pruned = pruneRunFiles(app.config.dataDir); |
| 302 | 305 | if (pruned > 0) log.info("removed old run logs", { count: pruned }); |
| 303 | 306 | }; |
| 304 | sweepClones(); | |
| 305 | const cloneTimer = setInterval(() => !stopped && sweepClones(), 60 * 60_000); | |
| 306 | requeueInterrupted(app); | |
| 307 | sweepAssigned(app); | |
| 308 | pump(app, ready, inFlight); | |
| 307 | during("clone-sweep", sweepClones); | |
| 308 | const cloneTimer = setInterval(() => !stopped && during("clone-sweep", sweepClones), 60 * 60_000); | |
| 309 | during("crew", () => { | |
| 310 | requeueInterrupted(app); | |
| 311 | sweepAssigned(app); | |
| 312 | pump(app, ready, inFlight); | |
| 313 | }); | |
| 309 | 314 | |
| 310 | 315 | const handle: CrewHandle = { |
| 311 | 316 | ready: detected.ok, |
src/crew/notify.ts +7 −3
| @@ -48,6 +48,7 @@ import { loadClog } from "../flow/clog_tick.ts"; | ||
| 48 | 48 | import { redactSecrets } from "../kernel/log.ts"; |
| 49 | 49 | import { forgeCeiling } from "../flow/ceiling.ts"; |
| 50 | 50 | import { askLinesFor } from "../web/studio/asks.ts"; |
| 51 | import { during } from "../perf/jobs.ts"; | |
| 51 | 52 | |
| 52 | 53 | const KEY = "notify_url"; |
| 53 | 54 | export const MAX_URL = 200; |
| @@ -585,13 +586,13 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier { | ||
| 585 | 586 | |
| 586 | 587 | // Eight days of silence is no event: the clock wakes the notifier too. |
| 587 | 588 | const clock = setInterval(() => { |
| 588 | queue = queue.then(async () => { | |
| 589 | queue = queue.then(() => during("notify", async () => { | |
| 589 | 590 | try { |
| 590 | 591 | for (const id of people()) await tell(id, false); |
| 591 | 592 | } catch (error) { |
| 592 | 593 | app.log.error("notify failed", { error: String(error) }); |
| 593 | 594 | } |
| 594 | }); | |
| 595 | })); | |
| 595 | 596 | }, options.clockMs ?? CLOCK_MS); |
| 596 | 597 | clock.unref(); |
| 597 | 598 | |
| @@ -607,7 +608,10 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier { | ||
| 607 | 608 | }); |
| 608 | 609 | return queue; |
| 609 | 610 | }; |
| 610 | const pager = setInterval(() => void pageAll(), Math.min(options.clockMs ?? CLOCK_MS, PAGE_MS)); | |
| 611 | const pager = setInterval( | |
| 612 | () => void during("notify-page", pageAll), | |
| 613 | Math.min(options.clockMs ?? CLOCK_MS, PAGE_MS), | |
| 614 | ); | |
| 611 | 615 | pager.unref(); |
| 612 | 616 | |
| 613 | 617 | return { |
src/crew/prompts.ts +2 −1
| @@ -35,6 +35,7 @@ import { | ||
| 35 | 35 | type InstagramClient, |
| 36 | 36 | type Reel, |
| 37 | 37 | } from "./instagram.ts"; |
| 38 | import { during } from "../perf/jobs.ts"; | |
| 38 | 39 | |
| 39 | 40 | const log = createLog("prompts"); |
| 40 | 41 | |
| @@ -101,7 +102,7 @@ export function startPromptRoutine( | ||
| 101 | 102 | |
| 102 | 103 | const timer = setInterval(() => { |
| 103 | 104 | if (stopped) return; |
| 104 | runDue().catch((error) => | |
| 105 | during("prompts", runDue).catch((error) => | |
| 105 | 106 | log.error("prompt sweep failed", { error: String(error) }), |
| 106 | 107 | ); |
| 107 | 108 | }, options.everyMs ?? TICK_MS); |
src/flow/clog_tick.ts +2 −1
| @@ -17,6 +17,7 @@ import { clogStep, type ClogEvent, type ClogState } from "./clog_watch.ts"; | ||
| 17 | 17 | import { readOldestApproved } from "./oldest_approved.ts"; |
| 18 | 18 | import { readStranded } from "./stranded.ts"; |
| 19 | 19 | import { readVitals } from "./vitals.ts"; |
| 20 | import { during } from "../perf/jobs.ts"; | |
| 20 | 21 | |
| 21 | 22 | const HOUR_MS = 3_600_000; |
| 22 | 23 | const log = createLog("clog"); |
| @@ -90,7 +91,7 @@ export function startClogTick( | ||
| 90 | 91 | ): () => void { |
| 91 | 92 | const timer = setInterval(() => { |
| 92 | 93 | try { |
| 93 | tickClog(db, bus); | |
| 94 | during("clog", () => tickClog(db, bus)); | |
| 94 | 95 | } catch (error) { |
| 95 | 96 | onError(error); |
| 96 | 97 | } |
src/flow/keeper.ts +2 −1
| @@ -18,6 +18,7 @@ import { ownRepoPath } from "../kernel/own_repo.ts"; | ||
| 18 | 18 | import { KEEPER_CAUSES, pickCause, readKeeperFacts, type KeeperCause, type KeeperMove } from "./keeper_cause.ts"; |
| 19 | 19 | import { isLow, landedBetween, readRate, type Rate } from "./keeper_rate.ts"; |
| 20 | 20 | import { oldestWaiting } from "./pile.ts"; |
| 21 | import { during } from "../perf/jobs.ts"; | |
| 21 | 22 | |
| 22 | 23 | const log = createLog("keeper"); |
| 23 | 24 | const HOUR_MS = 3_600_000; |
| @@ -177,7 +178,7 @@ export function tickKeeper(app: App, now = new Date(), onLift: () => void = () = | ||
| 177 | 178 | export function startKeeper(app: App, onLift: () => void, everyMs = 60_000, now: () => number = Date.now): () => void { |
| 178 | 179 | const timer = setInterval(() => { |
| 179 | 180 | try { |
| 180 | tickKeeper(app, new Date(now()), onLift); | |
| 181 | during("keeper", () => tickKeeper(app, new Date(now()), onLift)); | |
| 181 | 182 | } catch (error) { |
| 182 | 183 | log.warn("keeper tick failed", { error: String(error) }); |
| 183 | 184 | } |
src/http/server.ts +5 −2
| @@ -495,6 +495,7 @@ import { | ||
| 495 | 495 | type RouteTiming, |
| 496 | 496 | } from "../core/route_timing.ts"; |
| 497 | 497 | import { captureStore, loopWatch, perfRecord, readLedger } from "../perf/live.ts"; |
| 498 | import { during } from "../perf/jobs.ts"; | |
| 498 | 499 | import { classOf, countryOf, repoOf, signatureOf } from "../perf/traffic.ts"; |
| 499 | 500 | import { createTrafficWriter } from "../perf/traffic_store.ts"; |
| 500 | 501 | import { startFeatureSchedule } from "../apps/feature_schedule.ts"; |
| @@ -2349,8 +2350,10 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2349 | 2350 | // suspension or sign-out another process wrote reaches it too (#846). |
| 2350 | 2351 | const sweep = setInterval( |
| 2351 | 2352 | () => |
| 2352 | sweepStanding(app.db, (error) => | |
| 2353 | app.log.warn("standing sweep did not finish", { error: String(error) }), | |
| 2353 | during("standing-sweep", () => | |
| 2354 | sweepStanding(app.db, (error) => | |
| 2355 | app.log.warn("standing sweep did not finish", { error: String(error) }), | |
| 2356 | ), | |
| 2354 | 2357 | ), |
| 2355 | 2358 | app.config.standingSweepMs ?? 5_000, |
| 2356 | 2359 | ); |
src/looks/service.ts +3 −2
| @@ -71,6 +71,7 @@ import { addCommitLook } from "../checks/looks.ts"; | ||
| 71 | 71 | import type { LookVitals } from "../checks/summary.ts"; |
| 72 | 72 | import { keepAsMain } from "./main_looks.ts"; |
| 73 | 73 | import { findChromium, isNoBrowser, takeLook, type Capture } from "./capture.ts"; |
| 74 | import { during } from "../perf/jobs.ts"; | |
| 74 | 75 | |
| 75 | 76 | const log = createLog("looks"); |
| 76 | 77 | |
| @@ -225,8 +226,8 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 225 | 226 | // Production missing a picture at its head — one never taken, or a |
| 226 | 227 | // thumb from before thumbs were the page — gets one while it is up. |
| 227 | 228 | // A woken app says nothing on the bus, so this is how it is pictured. |
| 228 | const firstSweep = setTimeout(sweep, FIRST_SWEEP_MS); | |
| 229 | const sweeping = setInterval(sweep, SWEEP_MS); | |
| 229 | const firstSweep = setTimeout(() => during("looks", sweep), FIRST_SWEEP_MS); | |
| 230 | const sweeping = setInterval(() => during("looks", sweep), SWEEP_MS); | |
| 230 | 231 | firstSweep.unref?.(); |
| 231 | 232 | sweeping.unref?.(); |
| 232 | 233 |
src/main.ts +50 −33
| @@ -101,6 +101,7 @@ import { crewRoot } from "./crew/workdir.ts"; | ||
| 101 | 101 | import { createLog } from "./kernel/log.ts"; |
| 102 | 102 | import { watchStalls } from "./kernel/loop_lag.ts"; |
| 103 | 103 | import { startPerfWatcher, TICK_MS } from "./perf/duty.ts"; |
| 104 | import { during } from "./perf/jobs.ts"; | |
| 104 | 105 | import { stallLine } from "./perf/loop.ts"; |
| 105 | 106 | import { bootStall, gitRecord, loopWatch, perfRecord } from "./perf/live.ts"; |
| 106 | 107 | import { timeStatements } from "./kernel/slow_sql.ts"; |
| @@ -350,7 +351,10 @@ const bootSignup = signupFromEnv(process.env.FORGE_SIGNUP); | ||
| 350 | 351 | if (bootSignup.ok && bootSignup.value !== null) |
| 351 | 352 | setSignup(app.db, bootSignup.value, BOOT_ACTOR); |
| 352 | 353 | startPerfWatcher(app, perfRecord, Date.now, TICK_MS, gitRecord); |
| 353 | const flagTimer = setInterval(() => flushFlagUses(app.db), 10_000); | |
| 354 | const flagTimer = setInterval( | |
| 355 | () => during("flag-uses", () => flushFlagUses(app.db)), | |
| 356 | 10_000, | |
| 357 | ); | |
| 354 | 358 | await reconcile(app); |
| 355 | 359 | // Issues linked under a pillar's umbrella before pillars had labels. |
| 356 | 360 | const labelled = syncAllPillars(app); |
| @@ -589,14 +593,17 @@ const notifier = startNotifier(app, { forgeUrl }); | ||
| 589 | 593 | // loses it. runDelivery never throws into the timer. |
| 590 | 594 | // Expired sessions and run tokens ride the same tick: cheap DELETEs, never |
| 591 | 595 | // worth a worker. |
| 592 | const deliveryTimer = setInterval(() => { | |
| 596 | const deliveryTimer = setInterval(() => during("delivery", () => { | |
| 593 | 597 | runDelivery(app.db, app.log, app.bus); |
| 594 | 598 | pruneSessions(app.db); |
| 595 | 599 | pruneTokens(app.db); |
| 596 | }, 5_000); | |
| 597 | runDelivery(app.db, app.log, app.bus); // sweep anything queued while we were down | |
| 598 | pruneSessions(app.db); | |
| 599 | pruneTokens(app.db); | |
| 600 | }), 5_000); | |
| 601 | // Sweep anything queued while we were down. | |
| 602 | during("delivery", () => { | |
| 603 | runDelivery(app.db, app.log, app.bus); | |
| 604 | pruneSessions(app.db); | |
| 605 | pruneTokens(app.db); | |
| 606 | }); | |
| 600 | 607 | |
| 601 | 608 | function positive(value: string | undefined, fallback: number): number { |
| 602 | 609 | const parsed = Number(value); |
| @@ -612,12 +619,13 @@ function positive(value: string | undefined, fallback: number): number { | ||
| 612 | 619 | // the sweep. The operator guard's sweep of stored origins rides it too: one |
| 613 | 620 | // origin a tick, the never-checked first (so on boot), every other daily. |
| 614 | 621 | const syncTimer = setInterval(() => { |
| 615 | runSync(app); | |
| 616 | void runAheadReads(app); | |
| 617 | void runGuardSweep(app); | |
| 622 | during("sync", () => runSync(app)); | |
| 623 | void during("ahead", () => runAheadReads(app)); | |
| 624 | void during("guard-sweep", () => runGuardSweep(app)); | |
| 618 | 625 | }, 60_000); |
| 619 | runSync(app); // one pass on boot: a forge that was down has fetching to do | |
| 620 | void runGuardSweep(app); | |
| 626 | // One pass on boot: a forge that was down has fetching to do. | |
| 627 | during("sync", () => runSync(app)); | |
| 628 | void during("guard-sweep", () => runGuardSweep(app)); | |
| 621 | 629 | |
| 622 | 630 | // Which green pulls no longer merge as they stand, so the asks can leave |
| 623 | 631 | // them out (`crew/stale_asks.ts`): git answers, off the request path, and an |
| @@ -658,9 +666,12 @@ const tickAsks = async (): Promise<void> => { | ||
| 658 | 666 | }; |
| 659 | 667 | // Followed upstreams are read on a slow tick (`actions/upstream_follow.ts`): |
| 660 | 668 | // the tip and how far behind land on the follow row; nothing is opened. |
| 661 | const followTimer = setInterval(() => void checkDue(app), 60_000); | |
| 662 | const staleTimer = setInterval(() => void tickAsks(), 120_000); | |
| 663 | void tickAsks(); | |
| 669 | const followTimer = setInterval( | |
| 670 | () => void during("follow", () => checkDue(app)), | |
| 671 | 60_000, | |
| 672 | ); | |
| 673 | const staleTimer = setInterval(() => void during("asks", tickAsks), 120_000); | |
| 674 | void during("asks", tickAsks); | |
| 664 | 675 | |
| 665 | 676 | // Every start is counted, and one that follows a process that never said |
| 666 | 677 | // goodbye is a crash the log could not record (kernel/boots.ts, #3708). |
| @@ -701,23 +712,23 @@ void resumeAtBoot(app, undefined, { forgeUrl, runner: checks?.lane }).catch((err | ||
| 701 | 712 | app.log.warn("resume at boot failed", { error: String(error) }), |
| 702 | 713 | ); |
| 703 | 714 | // An agent's hold that has stood 24 h is released each minute (#6918). |
| 704 | const holdTimer = setInterval(() => { | |
| 715 | const holdTimer = setInterval(() => during("holds", () => { | |
| 705 | 716 | try { |
| 706 | 717 | expireCrewHolds(app); |
| 707 | 718 | } catch (error) { |
| 708 | 719 | app.log.warn("hold expiry failed", { error: String(error) }); |
| 709 | 720 | } |
| 710 | }, 60_000); | |
| 721 | }), 60_000); | |
| 711 | 722 | // The weekly model review (#7815): settles last week's changes, then proposes this week's; it runs once a week whatever the tick. |
| 712 | const modelReviewTimer = setInterval(() => { | |
| 723 | const modelReviewTimer = setInterval(() => during("model-review", () => { | |
| 713 | 724 | try { |
| 714 | 725 | runModelReview(app); |
| 715 | 726 | } catch (error) { |
| 716 | 727 | app.log.warn("model review failed", { error: String(error) }); |
| 717 | 728 | } |
| 718 | }, 3_600_000); | |
| 729 | }), 3_600_000); | |
| 719 | 730 | // The week is the sprint (#8704): at each weekly reset the forge writes its retrospective, unasked. |
| 720 | const weekTimer = setInterval(() => { | |
| 731 | const weekTimer = setInterval(() => during("week", () => { | |
| 721 | 732 | try { |
| 722 | 733 | const repos = plannedRepoIds(app.db).flatMap((id) => getRepoById(app.db, id) ?? []); |
| 723 | 734 | for (const repo of tickWeeks(app.db, repos, new Date())) |
| @@ -725,18 +736,18 @@ const weekTimer = setInterval(() => { | ||
| 725 | 736 | } catch (error) { |
| 726 | 737 | app.log.warn("week retrospective failed", { error: String(error) }); |
| 727 | 738 | } |
| 728 | }, 3_600_000); | |
| 739 | }), 3_600_000); | |
| 729 | 740 | // Code scanning's nightly pass (#9593): each hour, the repositories whose last pass is a day old get secrets, advisories and static analysis over their default branch. |
| 730 | 741 | const nightlyScanTimer = setInterval(() => { |
| 731 | void (async () => { | |
| 742 | void during("nightly-scan", async () => { | |
| 732 | 743 | for (const id of nightlyDue(app.db, new Date(Date.now() - 24 * 3_600_000).toISOString(), 25)) { |
| 733 | 744 | const repo = getRepoById(app.db, id); |
| 734 | 745 | if (repo !== undefined) await scanNightly(app, repo); |
| 735 | 746 | } |
| 736 | })().catch((error) => app.log.warn("nightly scan failed", { error: String(error) })); | |
| 747 | }).catch((error) => app.log.warn("nightly scan failed", { error: String(error) })); | |
| 737 | 748 | }, 3_600_000); |
| 738 | const processTimer = setInterval(processTick, 60_000); | |
| 739 | processTick(); | |
| 749 | const processTimer = setInterval(() => during("processes", processTick), 60_000); | |
| 750 | during("processes", processTick); | |
| 740 | 751 | // A new version proves itself against the public URL; a failed one again, 5 min after and backing off to hourly (release/prove.ts). |
| 741 | 752 | proveRunning({ |
| 742 | 753 | dataDir: app.config.dataDir, |
| @@ -760,11 +771,14 @@ if (app.config.forgeRepo) { | ||
| 760 | 771 | read: ownTagsReader(app, app.config.forgeRepo), |
| 761 | 772 | }); |
| 762 | 773 | setBehind(watch); |
| 763 | void watch.tick(); | |
| 764 | setInterval(() => void watch.tick(), CHECK_EVERY_MS); | |
| 774 | void during("release-behind", () => watch.tick()); | |
| 775 | setInterval( | |
| 776 | () => void during("release-behind", () => watch.tick()), | |
| 777 | CHECK_EVERY_MS, | |
| 778 | ); | |
| 765 | 779 | // The no-release alarm reads what this keeps of the running commit (#7638). |
| 766 | void readLandings(app); | |
| 767 | setInterval(() => void readLandings(app), 60_000); | |
| 780 | void during("landings", () => readLandings(app)); | |
| 781 | setInterval(() => void during("landings", () => readLandings(app)), 60_000); | |
| 768 | 782 | } |
| 769 | 783 | // The release keeper (#7557): cut on cadence, promote, verify under page load, roll back to the named |
| 770 | 784 | // known-good image and hold. Off unless FORGE_RELEASE_KEEPER=on in a cluster (keeper/wire.ts). |
| @@ -786,9 +800,12 @@ if (keeperOn.on) { | ||
| 786 | 800 | said = line; |
| 787 | 801 | }) |
| 788 | 802 | .catch((error) => app.log.warn("release keeper step failed", { error: String(error) })); |
| 789 | step(); | |
| 790 | setInterval(step, 60_000); | |
| 791 | setInterval(() => void rollProbe(ports).catch(() => {}), 1000); | |
| 803 | during("release-keeper", step); | |
| 804 | setInterval(() => during("release-keeper", step), 60_000); | |
| 805 | setInterval( | |
| 806 | () => void during("roll-probe", () => rollProbe(ports)).catch(() => {}), | |
| 807 | 1000, | |
| 808 | ); | |
| 792 | 809 | } |
| 793 | 810 | } else app.log.info("the release keeper is off", { why: keeperOn.why }); |
| 794 | 811 | // Only an agent is on a crew (the `repo_crew_agents_only` trigger refuses anyone else). A person |
| @@ -825,7 +842,7 @@ if (ssh) | ||
| 825 | 842 | // failed; checks are queued again while their commit heads an open pull; |
| 826 | 843 | // agent runs are the crew's to queue again, unless there is no crew here to |
| 827 | 844 | // do it. |
| 828 | const leaseTimer = setInterval(() => { | |
| 845 | const leaseTimer = setInterval(() => during("lease", () => { | |
| 829 | 846 | failAbandonedRuns( |
| 830 | 847 | app.db, |
| 831 | 848 | STALE_AFTER_MS, |
| @@ -834,7 +851,7 @@ const leaseTimer = setInterval(() => { | ||
| 834 | 851 | requeueAbandonedChecks(app, STALE_AFTER_MS); |
| 835 | 852 | // A grant whose run is no longer running is a crash's leftover: close it. |
| 836 | 853 | releaseOrphans(app.db); |
| 837 | }, 30_000); | |
| 854 | }), 30_000); | |
| 838 | 855 | |
| 839 | 856 | // A release that read healthy beside this process takes over (#1663), |
| 840 | 857 | // and this one lets go of the port before it drains (#2040). |
src/nightly/service.ts +3 −2
| @@ -21,6 +21,7 @@ import { proposeQuarantine } from "./propose.ts"; | ||
| 21 | 21 | import { type Ran, runJob } from "./run.ts"; |
| 22 | 22 | import { lastSlot, owed, untilNext } from "./slots.ts"; |
| 23 | 23 | import { readLast, writeLast } from "./stamps.ts"; |
| 24 | import { during } from "../perf/jobs.ts"; | |
| 24 | 25 | |
| 25 | 26 | const log = createLog("nightly"); |
| 26 | 27 | |
| @@ -132,7 +133,7 @@ export function startNightly( | ||
| 132 | 133 | const waitRadar = (): void => { |
| 133 | 134 | const timer = setTimeout(() => { |
| 134 | 135 | timers.delete(timer); |
| 135 | void runRadarNight().finally(() => !abort.signal.aborted && waitRadar()); | |
| 136 | void during("nightly", runRadarNight).finally(() => !abort.signal.aborted && waitRadar()); | |
| 136 | 137 | }, untilNext(RADAR_SLOT, now()) + 1_000); |
| 137 | 138 | timers.add(timer); |
| 138 | 139 | }; |
| @@ -142,7 +143,7 @@ export function startNightly( | ||
| 142 | 143 | const wait = (job: Job): void => { |
| 143 | 144 | const timer = setTimeout(() => { |
| 144 | 145 | timers.delete(timer); |
| 145 | void run(job.name).finally(() => !abort.signal.aborted && wait(job)); | |
| 146 | void during("nightly", () => run(job.name)).finally(() => !abort.signal.aborted && wait(job)); | |
| 146 | 147 | // A second past the slot: a timer that fires early would read yesterday's. |
| 147 | 148 | }, untilNext(job.slot, now()) + 1_000); |
| 148 | 149 | timers.add(timer); |
src/origins/mirror-pulls.ts +3 −2
| @@ -35,6 +35,7 @@ import { vettedEnv } from "./git-mirror.ts"; | ||
| 35 | 35 | import { hookSecret, hookUrl } from "./hook-secret.ts"; |
| 36 | 36 | import { verifySignature } from "../core/webhooks.ts"; |
| 37 | 37 | import type { Provider, RemoteIssue } from "./types.ts"; |
| 38 | import { during } from "../perf/jobs.ts"; | |
| 38 | 39 | |
| 39 | 40 | const log = createLog("mirror-pulls"); |
| 40 | 41 | |
| @@ -335,7 +336,7 @@ export function startMirrorPulls(app: App): () => void { | ||
| 335 | 336 | busy = false; |
| 336 | 337 | }); |
| 337 | 338 | }; |
| 338 | const timer = setInterval(tick, MIRROR_PULLS_EVERY_MS); | |
| 339 | tick(); | |
| 339 | const timer = setInterval(() => during("mirror-pulls", tick), MIRROR_PULLS_EVERY_MS); | |
| 340 | during("mirror-pulls", tick); | |
| 340 | 341 | return () => clearInterval(timer); |
| 341 | 342 | } |
src/perf/duty.ts +2 −1
| @@ -16,6 +16,7 @@ import { addLabels, kindLabels } from "../hunt/label.ts"; | ||
| 16 | 16 | import { createGitRecord } from "./git.ts"; |
| 17 | 17 | import type { PerfRecord } from "./record.ts"; |
| 18 | 18 | import { evaluate, evaluateGit, fresh } from "./watch.ts"; |
| 19 | import { during } from "./jobs.ts"; | |
| 19 | 20 | |
| 20 | 21 | export const TICK_MS = 60_000; |
| 21 | 22 | |
| @@ -46,7 +47,7 @@ export function startPerfWatcher( | ||
| 46 | 47 | app.log.warn("performance watcher failed", { error: String(error) }); |
| 47 | 48 | } |
| 48 | 49 | }; |
| 49 | const timer = setInterval(tick, everyMs); | |
| 50 | const timer = setInterval(() => during("perf-watcher", tick), everyMs); | |
| 50 | 51 | timer.unref(); |
| 51 | 52 | return () => clearInterval(timer); |
| 52 | 53 | } |
src/perf/jobs.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | /** | |
| 2 | * Background jobs by name (#10834): a stall no request ran across is counted | |
| 3 | * against the timer, scheduler tick or sweep that was running (`loop.ts`). | |
| 4 | * | |
| 5 | * `during(name, fn)` marks `name` as blocking from the call until `fn` | |
| 6 | * returns: that synchronous slice is the only time it is sure to hold the | |
| 7 | * loop. When `fn` returns a promise, the job is then marked awaiting until | |
| 8 | * the promise settles; awaiting holds nothing, so an awaiting job is named | |
| 9 | * for a stall only when no job's synchronous slice ran across it — then one | |
| 10 | * of its continuations is the likeliest blocker, and the name is a lead, not | |
| 11 | * a verdict. Each call costs a map write and a ring slot or two. The clock | |
| 12 | * is `performance.now`, the stall watcher's. | |
| 13 | */ | |
| 14 | ||
| 15 | /** Finished slices kept to match a stall against: a tick a second fills it in about two minutes. */ | |
| 16 | const KEPT = 256; | |
| 17 | ||
| 18 | export interface JobSpan { | |
| 19 | readonly name: string; | |
| 20 | readonly start: number; | |
| 21 | readonly end: number; | |
| 22 | /** True for the time after `fn` returned a promise and before it settled. */ | |
| 23 | readonly awaiting: boolean; | |
| 24 | } | |
| 25 | ||
| 26 | export interface JobWatch { | |
| 27 | /** Run `fn` marked as job `name`; returns what it returns, and throws what it throws. */ | |
| 28 | during<T>(name: string, fn: () => T): T; | |
| 29 | /** | |
| 30 | * The jobs whose synchronous slice ran across `[from, to]`, longest first, | |
| 31 | * each once; when there are none, the jobs awaiting across it. | |
| 32 | */ | |
| 33 | across(from: number, to: number): string[]; | |
| 34 | } | |
| 35 | ||
| 36 | export function createJobWatch(now: () => number = () => performance.now()): JobWatch { | |
| 37 | const awaiting = new Map<number, { name: string; start: number }>(); | |
| 38 | const ring: (JobSpan | undefined)[] = new Array(KEPT); | |
| 39 | let next = 0; | |
| 40 | let id = 0; | |
| 41 | const keep = (span: JobSpan): void => { | |
| 42 | ring[next] = span; | |
| 43 | next = (next + 1) % KEPT; | |
| 44 | }; | |
| 45 | const settle = (key: number): void => { | |
| 46 | const job = awaiting.get(key); | |
| 47 | if (job === undefined) return; | |
| 48 | awaiting.delete(key); | |
| 49 | keep({ name: job.name, start: job.start, end: now(), awaiting: true }); | |
| 50 | }; | |
| 51 | const names = (spans: JobSpan[]): string[] => [ | |
| 52 | ...new Set(spans.sort((a, b) => b.end - b.start - (a.end - a.start)).map((s) => s.name)), | |
| 53 | ]; | |
| 54 | return { | |
| 55 | during(name, fn) { | |
| 56 | const start = now(); | |
| 57 | let result: ReturnType<typeof fn>; | |
| 58 | try { | |
| 59 | result = fn(); | |
| 60 | } finally { | |
| 61 | keep({ name, start, end: now(), awaiting: false }); | |
| 62 | } | |
| 63 | if (result instanceof Promise) { | |
| 64 | const key = ++id; | |
| 65 | awaiting.set(key, { name, start: now() }); | |
| 66 | result.then(() => settle(key), () => settle(key)); | |
| 67 | } | |
| 68 | return result; | |
| 69 | }, | |
| 70 | across(from, to) { | |
| 71 | const overlapping = ring.filter( | |
| 72 | (s): s is JobSpan => s !== undefined && s.end >= from && s.start <= to, | |
| 73 | ); | |
| 74 | const blocking = overlapping.filter((s) => !s.awaiting); | |
| 75 | if (blocking.length > 0) return names(blocking); | |
| 76 | const t = now(); | |
| 77 | for (const r of awaiting.values()) | |
| 78 | if (r.start <= to) overlapping.push({ name: r.name, start: r.start, end: t, awaiting: true }); | |
| 79 | return names(overlapping); | |
| 80 | }, | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | /** The process's one job watch, read by the loop watch in `live.ts`. */ | |
| 85 | export const jobWatch = createJobWatch(); | |
| 86 | /** Run `fn` as background job `name` on the process's job watch. */ | |
| 87 | export const during = <T>(name: string, fn: () => T): T => jobWatch.during(name, fn); |
src/perf/live.ts +3 −1
| @@ -2,13 +2,15 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { createBootStall } from "./boot_stall.ts"; |
| 4 | 4 | import { createGitRecord } from "./git.ts"; |
| 5 | import { jobWatch } from "./jobs.ts"; | |
| 5 | 6 | import { createLoopWatch } from "./loop.ts"; |
| 6 | 7 | import { createCaptureStore } from "./capture.ts"; |
| 7 | 8 | import { createReadLedger } from "./reads.ts"; |
| 8 | 9 | import { createPerfRecord } from "./record.ts"; |
| 9 | 10 | |
| 10 | 11 | export const perfRecord = createPerfRecord(); |
| 11 | export const loopWatch = createLoopWatch(); | |
| 12 | /** A stall no request ran across names the background jobs that did (#10834). */ | |
| 13 | export const loopWatch = createLoopWatch(undefined, jobWatch.across); | |
| 12 | 14 | export const gitRecord = createGitRecord(); |
| 13 | 15 | /** The worst stall in this process's first five minutes, read by the release keeper (#8848). */ |
| 14 | 16 | export const bootStall = createBootStall(); |
src/perf/loop.ts +21 −4
| @@ -10,6 +10,10 @@ | ||
| 10 | 10 | * full against every route that ran across it — the same reading as the log |
| 11 | 11 | * lines, so the sum over routes can exceed the wall time — and against |
| 12 | 12 | * `NO_REQUEST` when none did. |
| 13 | * | |
| 14 | * A stall no request ran across is counted against the background jobs that | |
| 15 | * did (#10834, `jobs.ts`), each as `job:<name>`; `NO_REQUEST` is left for a | |
| 16 | * stall nothing named ran across. | |
| 13 | 17 | */ |
| 14 | 18 | |
| 15 | 19 | export const LIMIT_MS = 250; |
| @@ -20,6 +24,10 @@ export const HOUR_MS = 3_600_000; | ||
| 20 | 24 | const HOUR_KEPT = 14_400; |
| 21 | 25 | /** The route a stall is counted against when no request ran across it. */ |
| 22 | 26 | export const NO_REQUEST = "none"; |
| 27 | /** The prefix a background job's name is tallied under, apart from routes. */ | |
| 28 | export const JOB_PREFIX = "job:"; | |
| 29 | /** The jobs that ran across `[from, to]` (`JobWatch.across`). */ | |
| 30 | export type JobsAcross = (from: number, to: number) => readonly string[]; | |
| 23 | 31 | |
| 24 | 32 | export interface RouteStall { |
| 25 | 33 | readonly route: string; |
| @@ -38,6 +46,8 @@ export interface Stall { | ||
| 38 | 46 | readonly at: number; |
| 39 | 47 | readonly lateMs: number; |
| 40 | 48 | readonly routes: readonly string[]; |
| 49 | /** The background jobs running across it; read only when no request was. */ | |
| 50 | readonly jobs: readonly string[]; | |
| 41 | 51 | } |
| 42 | 52 | |
| 43 | 53 | export interface LoopWatch { |
| @@ -49,10 +59,14 @@ export interface LoopWatch { | ||
| 49 | 59 | tally(now: number): StallTally; |
| 50 | 60 | } |
| 51 | 61 | |
| 62 | /** What a stall is counted against: its routes, else its jobs, else `NO_REQUEST`. */ | |
| 63 | const blamed = (s: Stall): readonly string[] => | |
| 64 | s.routes.length > 0 ? s.routes : s.jobs.length > 0 ? s.jobs.map((j) => JOB_PREFIX + j) : [NO_REQUEST]; | |
| 65 | ||
| 52 | 66 | function byRoute(stalls: Iterable<Stall>): RouteStall[] { |
| 53 | 67 | const sums = new Map<string, { ms: number; count: number }>(); |
| 54 | 68 | for (const s of stalls) |
| 55 | for (const route of s.routes.length === 0 ? [NO_REQUEST] : s.routes) { | |
| 69 | for (const route of blamed(s)) { | |
| 56 | 70 | const t = sums.get(route) ?? { ms: 0, count: 0 }; |
| 57 | 71 | t.ms += s.lateMs; |
| 58 | 72 | t.count += 1; |
| @@ -61,7 +75,7 @@ function byRoute(stalls: Iterable<Stall>): RouteStall[] { | ||
| 61 | 75 | return [...sums].map(([route, t]) => ({ route, ms: t.ms, count: t.count })); |
| 62 | 76 | } |
| 63 | 77 | |
| 64 | export function createLoopWatch(limitMs = LIMIT_MS): LoopWatch { | |
| 78 | export function createLoopWatch(limitMs = LIMIT_MS, jobsAcross: JobsAcross = () => []): LoopWatch { | |
| 65 | 79 | const recent: { pattern: string; start: number; end: number }[] = []; |
| 66 | 80 | const totals = new Map<string, { ms: number; count: number }>(); |
| 67 | 81 | const hour: Stall[] = []; |
| @@ -81,7 +95,8 @@ export function createLoopWatch(limitMs = LIMIT_MS): LoopWatch { | ||
| 81 | 95 | .map((r) => r.pattern), |
| 82 | 96 | ), |
| 83 | 97 | ]; |
| 84 | const stall = { at, lateMs, routes }; | |
| 98 | const jobs = routes.length === 0 ? jobsAcross(from, at) : []; | |
| 99 | const stall = { at, lateMs, routes, jobs }; | |
| 85 | 100 | for (const r of byRoute([stall])) { |
| 86 | 101 | const t = totals.get(r.route) ?? { ms: 0, count: 0 }; |
| 87 | 102 | t.ms += r.ms; |
| @@ -104,4 +119,6 @@ export function createLoopWatch(limitMs = LIMIT_MS): LoopWatch { | ||
| 104 | 119 | |
| 105 | 120 | /** The stall as a finding line. */ |
| 106 | 121 | export const stallLine = (s: Stall): string => |
| 107 | `event loop blocked ${Math.round(s.lateMs)} ms; running then: ${s.routes.length === 0 ? "no request" : s.routes.join(", ")}`; | |
| 122 | `event loop blocked ${Math.round(s.lateMs)} ms; running then: ${ | |
| 123 | s.routes.length > 0 ? s.routes.join(", ") : s.jobs.length > 0 ? `no request; jobs ${s.jobs.join(", ")}` : "no request" | |
| 124 | }`; |
src/snapshot/service.ts +9 −6
| @@ -35,6 +35,7 @@ import { lastMirror, mirrorPass, mirrorTarget, MIRROR_EVERY_MS, recordMirror, ty | ||
| 35 | 35 | import { isOffBox } from "./off_box.ts"; |
| 36 | 36 | import { noteOffBox, recordSnapshotOutcome } from "./outcome.ts"; |
| 37 | 37 | import { s3FromEnv, type Store } from "./store.ts"; |
| 38 | import { during } from "../perf/jobs.ts"; | |
| 38 | 39 | |
| 39 | 40 | const log = createLog("snapshots"); |
| 40 | 41 | |
| @@ -379,7 +380,7 @@ export function startSnapshots( | ||
| 379 | 380 | if (timer !== undefined) clearTimeout(timer); |
| 380 | 381 | timer = undefined; |
| 381 | 382 | if (options.nightly === false || stopped) return; |
| 382 | timer = setTimeout(() => void take().catch(() => {}), ms); | |
| 383 | timer = setTimeout(() => void during("snapshot", take).catch(() => {}), ms); | |
| 383 | 384 | } |
| 384 | 385 | arm(untilNightly()); |
| 385 | 386 | |
| @@ -398,7 +399,7 @@ export function startSnapshots( | ||
| 398 | 399 | const scheduleDrill = (): void => { |
| 399 | 400 | if (options.nightly === false || stopped) return; |
| 400 | 401 | drillTimer = setTimeout(() => { |
| 401 | runIfDue().finally(scheduleDrill); | |
| 402 | during("drill", () => runIfDue()).finally(scheduleDrill); | |
| 402 | 403 | }, untilDrill()); |
| 403 | 404 | }; |
| 404 | 405 | // While the newest drill is red, look again when it is owed again. |
| @@ -409,7 +410,7 @@ export function startSnapshots( | ||
| 409 | 410 | const newest = lastDrill(app.db); |
| 410 | 411 | if (newest?.verdict !== "red") return; |
| 411 | 412 | const wait = Math.max(catchUpMs, Date.parse(newest.at) + redRetryMs - Date.now()); |
| 412 | redRetry = setTimeout(() => void runIfDue().finally(armRedRetry), wait); | |
| 413 | redRetry = setTimeout(() => void during("drill", () => runIfDue()).finally(armRedRetry), wait); | |
| 413 | 414 | }; |
| 414 | 415 | // Owed and not yet kept: run it once. A restart after a green drill finds it |
| 415 | 416 | // kept; one after a red drill runs it again, once it is past the boot floor. |
| @@ -423,7 +424,9 @@ export function startSnapshots( | ||
| 423 | 424 | // shortly after boot; a red one is looked at again until one passes. |
| 424 | 425 | if (options.nightly !== false) { |
| 425 | 426 | catchUp = setTimeout( |
| 426 | () => void runIfDue(options.bootRedFloorMs ?? BOOT_RED_FLOOR_MS).finally(armRedRetry), | |
| 427 | () => | |
| 428 | void during("drill", () => runIfDue(options.bootRedFloorMs ?? BOOT_RED_FLOOR_MS)) | |
| 429 | .finally(armRedRetry), | |
| 427 | 430 | catchUpMs, |
| 428 | 431 | ); |
| 429 | 432 | } |
| @@ -434,7 +437,7 @@ export function startSnapshots( | ||
| 434 | 437 | const scheduleMirror = (first: boolean): void => { |
| 435 | 438 | if (options.nightly === false || stopped) return; |
| 436 | 439 | mirrorTimer = setTimeout(() => { |
| 437 | mirrorNow() | |
| 440 | during("snapshot-mirror", mirrorNow) | |
| 438 | 441 | .catch(() => {}) |
| 439 | 442 | .finally(() => scheduleMirror(false)); |
| 440 | 443 | }, first ? Math.min(30_000, everyMs) : everyMs); |
| @@ -447,7 +450,7 @@ export function startSnapshots( | ||
| 447 | 450 | const scheduleCatchUp = (ms: number): void => { |
| 448 | 451 | if (options.nightly === false || stopped) return; |
| 449 | 452 | catchUpSnapshot = setTimeout(() => { |
| 450 | takeIfDue() | |
| 453 | during("snapshot", takeIfDue) | |
| 451 | 454 | .catch(() => {}) |
| 452 | 455 | .finally(() => { |
| 453 | 456 | bootLookAt = undefined; |
src/spawn/heartbeat.ts +2 −1
| @@ -26,6 +26,7 @@ import { upstreamState } from "./upstream.ts"; | ||
| 26 | 26 | import { progress } from "../core/next-steps.ts"; |
| 27 | 27 | import { running } from "../kernel/version.ts"; |
| 28 | 28 | import { forgeCeiling } from "../flow/ceiling.ts"; |
| 29 | import { during } from "../perf/jobs.ts"; | |
| 29 | 30 | |
| 30 | 31 | export const SIGNATURE_HEADER = "x-forge-signature"; |
| 31 | 32 | /** A child reports this often; one silent for three beats reads as quiet. */ |
| @@ -323,7 +324,7 @@ export async function sendBeat( | ||
| 323 | 324 | /** Report now and then every BEAT_MS; returns the timer to clear. */ |
| 324 | 325 | export function startHeartbeat(app: App): ReturnType<typeof setInterval> { |
| 325 | 326 | void sendBeat(app); |
| 326 | return setInterval(() => void sendBeat(app), BEAT_MS); | |
| 327 | return setInterval(() => void during("heartbeat", () => sendBeat(app)), BEAT_MS); | |
| 327 | 328 | } |
| 328 | 329 | |
| 329 | 330 | export type Standing = "reporting" | "quiet" | "unheard"; |
src/spawn/reach.ts +3 −2
| @@ -25,6 +25,7 @@ import type { App } from "../app.ts"; | ||
| 25 | 25 | import { isSlug, slugFor } from "../core/apps.ts"; |
| 26 | 26 | import type { FiredAlarm } from "../flow/alarms.ts"; |
| 27 | 27 | import { childPublicUrl, parentOrigin } from "./placement.ts"; |
| 28 | import { during } from "../perf/jobs.ts"; | |
| 28 | 29 | |
| 29 | 30 | export const APP_HEADER = "x-forge-app"; |
| 30 | 31 | |
| @@ -283,8 +284,8 @@ export function startReachWatch( | ||
| 283 | 284 | everyMs = REACH_EVERY_MS, |
| 284 | 285 | asleep: (name: string) => boolean = () => false, |
| 285 | 286 | ): ReturnType<typeof setInterval> { |
| 286 | const first = setTimeout(() => void sweepReach(app, asleep), 30_000); | |
| 287 | const timer = setInterval(() => void sweepReach(app, asleep), everyMs); | |
| 287 | const first = setTimeout(() => void during("reach", () => sweepReach(app, asleep)), 30_000); | |
| 288 | const timer = setInterval(() => void during("reach", () => sweepReach(app, asleep)), everyMs); | |
| 288 | 289 | app.closing.signal.addEventListener("abort", () => clearTimeout(first), { once: true }); |
| 289 | 290 | return timer; |
| 290 | 291 | } |
src/spawn/upstream.ts +2 −1
| @@ -23,6 +23,7 @@ import { basicHeader, gitAuthEnv, redact, secretsIn } from "../origins/credentia | ||
| 23 | 23 | import { forgeLinkOf, originOf } from "./chain.ts"; |
| 24 | 24 | import { parentCredential } from "./parent_credential.ts"; |
| 25 | 25 | import { newestCommits, takeFromParent, type Taken } from "./take.ts"; |
| 26 | import { during } from "../perf/jobs.ts"; | |
| 26 | 27 | |
| 27 | 28 | export const CHECK_MS = 60 * 60_000; |
| 28 | 29 | export const FETCH_TIMEOUT_MS = 60_000; |
| @@ -211,7 +212,7 @@ function tellOwner(app: App, repo: Repo, fork: string, behind: number, commits = | ||
| 211 | 212 | /** Check at boot and every hour; returns the timer to clear. */ |
| 212 | 213 | export function startUpstreamWatch(app: App): ReturnType<typeof setInterval> { |
| 213 | 214 | void checkUpstream(app); |
| 214 | return setInterval(() => void checkUpstream(app), CHECK_MS); | |
| 215 | return setInterval(() => void during("upstream", () => checkUpstream(app)), CHECK_MS); | |
| 215 | 216 | } |
| 216 | 217 | |
| 217 | 218 | /** The state in one plain sentence, escaped by whoever renders it. */ |
test/perf-loop-jobs.test.ts added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | /** A stall no request ran across names the background job that was running (#10834). */ | |
| 2 | ||
| 3 | import { expect, test } from "bun:test"; | |
| 4 | import { createJobWatch, during } from "../src/perf/jobs.ts"; | |
| 5 | import { loopWatch } from "../src/perf/live.ts"; | |
| 6 | import { createLoopWatch, NO_REQUEST, stallLine } from "../src/perf/loop.ts"; | |
| 7 | ||
| 8 | const block = (ms: number): void => { | |
| 9 | const until = performance.now() + ms; | |
| 10 | while (performance.now() < until) {} | |
| 11 | }; | |
| 12 | ||
| 13 | test("a job that blocks the loop 300 ms is named in the tally and the stall line", () => { | |
| 14 | const jobs = createJobWatch(); | |
| 15 | const w = createLoopWatch(250, jobs.across); | |
| 16 | jobs.during("sweep", () => block(300)); | |
| 17 | const at = performance.now(); | |
| 18 | const stall = w.stalled(310, at)!; | |
| 19 | expect(stall.routes).toEqual([]); | |
| 20 | expect(stall.jobs).toEqual(["sweep"]); | |
| 21 | expect(stallLine(stall)).toContain("jobs sweep"); | |
| 22 | const tally = w.tally(at); | |
| 23 | expect(tally.total).toEqual([{ route: "job:sweep", ms: 310, count: 1 }]); | |
| 24 | expect(tally.hour.map((r) => r.route)).toEqual(["job:sweep"]); | |
| 25 | }); | |
| 26 | ||
| 27 | test("an async job is named while it awaits only when no job blocked across the stall", async () => { | |
| 28 | let t = 0; | |
| 29 | const jobs = createJobWatch(() => t); | |
| 30 | let done!: () => void; | |
| 31 | const body = jobs.during("lane", () => { | |
| 32 | t = 10; // the synchronous slice, up to the first await | |
| 33 | return new Promise<void>((r) => (done = r)); | |
| 34 | }); | |
| 35 | t = 1_000; | |
| 36 | expect(jobs.across(0, 5)).toEqual(["lane"]); | |
| 37 | // Nothing blocked across [500, 1000]: the awaiting lane is the lead. | |
| 38 | expect(jobs.across(500, 1_000)).toEqual(["lane"]); | |
| 39 | // A job that blocks while the lane awaits is the one named; the lane is not. | |
| 40 | jobs.during("sweep", () => { t = 1_400; }); | |
| 41 | expect(jobs.across(1_100, 1_400)).toEqual(["sweep"]); | |
| 42 | t = 2_000; | |
| 43 | done(); | |
| 44 | await body; | |
| 45 | t = 5_000; | |
| 46 | expect(jobs.across(1_500, 5_000)).toEqual(["lane"]); | |
| 47 | expect(jobs.across(2_001, 6_000)).toEqual([]); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a loop stall while a job awaits and another blocks is tallied to the blocker only", async () => { | |
| 51 | const jobs = createJobWatch(); | |
| 52 | const w = createLoopWatch(250, jobs.across); | |
| 53 | let done!: () => void; | |
| 54 | const lane = jobs.during("lane", () => new Promise<void>((r) => (done = r))); | |
| 55 | const from = performance.now(); | |
| 56 | jobs.during("sweep", () => block(300)); | |
| 57 | const at = performance.now(); | |
| 58 | const stall = w.stalled(at - from, at)!; | |
| 59 | expect(stall.jobs).toEqual(["sweep"]); | |
| 60 | expect(w.tally(at).total.map((r) => r.route)).toEqual(["job:sweep"]); | |
| 61 | done(); | |
| 62 | await lane; | |
| 63 | }); | |
| 64 | ||
| 65 | test("the process's loop watch reads the process's job watch", () => { | |
| 66 | const before = loopWatch.tally(performance.now()).total.find((r) => r.route === "job:wired-test")?.count ?? 0; | |
| 67 | during("wired-test", () => block(300)); | |
| 68 | const at = performance.now(); | |
| 69 | loopWatch.stalled(300, at); | |
| 70 | const after = loopWatch.tally(at).total.find((r) => r.route === "job:wired-test"); | |
| 71 | expect(after?.count).toBe(before + 1); | |
| 72 | }); | |
| 73 | ||
| 74 | test("a job that throws still ends, and the throw reaches the caller", () => { | |
| 75 | let t = 0; | |
| 76 | const jobs = createJobWatch(() => t); | |
| 77 | expect(() => jobs.during("bad", () => { t = 10; throw new Error("no"); })).toThrow("no"); | |
| 78 | expect(jobs.across(20, 30)).toEqual([]); | |
| 79 | expect(jobs.across(5, 30)).toEqual(["bad"]); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a request across the stall is blamed, not the job", () => { | |
| 83 | let t = 0; | |
| 84 | const jobs = createJobWatch(() => t); | |
| 85 | const w = createLoopWatch(250, jobs.across); | |
| 86 | jobs.during("sweep", () => { t = 400; }); | |
| 87 | w.finished("/boards", 0, 400); | |
| 88 | const stall = w.stalled(400, 400)!; | |
| 89 | expect(stall.routes).toEqual(["/boards"]); | |
| 90 | expect(w.tally(400).total.map((r) => r.route)).toEqual(["/boards"]); | |
| 91 | }); | |
| 92 | ||
| 93 | test("an idle stall with no request and no job is still none", () => { | |
| 94 | let t = 0; | |
| 95 | const jobs = createJobWatch(() => t); | |
| 96 | const w = createLoopWatch(250, jobs.across); | |
| 97 | jobs.during("early", () => { t = 10; }); | |
| 98 | t = 5_000; | |
| 99 | const stall = w.stalled(300, 5_000)!; | |
| 100 | expect(stall.jobs).toEqual([]); | |
| 101 | expect(stallLine(stall)).toBe("event loop blocked 300 ms; running then: no request"); | |
| 102 | expect(w.tally(5_000).total).toEqual([{ route: NO_REQUEST, ms: 300, count: 1 }]); | |
| 103 | }); |
A promise counts as running until it settles, so a job that is only awaiting I/O is blamed for stalls it did not cause. Narrow it to the synchronous slices, or add a test that pins and documents the limit.