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

Merged opened this

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

Merged

scout/forge:scout/10834-a-loop-stall-that-no-request-ran-across → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/10834-a-loop-stall-that-no-request-ran-across was merged into main as fcd9a02.

Review

Sign in to review this change.
25 files changed +351 −92
src/apps/feature_schedule.ts +2 −1
@@ -16,6 +16,7 @@ import { getRepoById } from "../core/repos.ts";
1616 import { callTool } from "./feature_tools.ts";
1717 import { SCHEDULE_EVERY, type ScheduleEntry } from "./feature.ts";
1818 import { appsRuntime } from "./runtime.ts";
19 import { during } from "../perf/jobs.ts";
1920
2021 /** The entries whose interval has passed since they last fired (never fired: due). `lastFired` is ms by tool. */
2122 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
148149 const tick = setInterval(() => {
149150 if (running) return;
150151 running = true;
151 void fireDue(app).finally(() => (running = false));
152 void during("feature-schedule", () => fireDue(app)).finally(() => (running = false));
152153 }, tickMs);
153154 tick.unref();
154155 return { stop: () => clearInterval(tick) };
src/apps/pull_schedule.ts +2 −1
@@ -28,6 +28,7 @@ import { readFileAt } from "./checkout.ts";
2828 import { identityHeaders, type GrantWho } from "./proxy.ts";
2929 import { parsePullAnswer, PULL_BODY_MAX, recordPull, type PullAnswer } from "./pull.ts";
3030 import { appsRuntime } from "./runtime.ts";
31 import { during } from "../perf/jobs.ts";
3132
3233 /** How long the forge waits for one `/pull` answer, as for a scheduled tool. */
3334 export const PULL_WAIT_MS = 10_000;
@@ -246,7 +247,7 @@ export function startPullSchedule(app: App, tickMs: number = TICK_MS): { stop():
246247 const tick = setInterval(() => {
247248 if (running) return;
248249 running = true;
249 void pullDue(app).finally(() => (running = false));
250 void during("pull-schedule", () => pullDue(app)).finally(() => (running = false));
250251 }, tickMs);
251252 tick.unref();
252253 return { stop: () => clearInterval(tick) };
src/boards/memory.ts +3 −2
@@ -9,6 +9,7 @@ import type { Database } from "bun:sqlite";
99 import { heapStats } from "bun:jsc";
1010 import type { App } from "../app.ts";
1111 import { quietly } from "../core/write_clock.ts";
12 import { during } from "../perf/jobs.ts";
1213
1314 export const MEMORY_EVERY_MS = 60_000;
1415 export const HOUR_MS = 3_600_000;
@@ -68,8 +69,8 @@ export function startMemorySampler(app: App, everyMs = MEMORY_EVERY_MS): MemoryS
6869 app.log.warn("memory sample failed", { error: String(error) });
6970 }
7071 };
71 tick();
72 const timer = setInterval(tick, everyMs);
72 during("memory-sampler", () => tick());
73 const timer = setInterval(() => during("memory-sampler", () => tick()), everyMs);
7374 timer.unref?.();
7475 return { tick, stop: () => clearInterval(timer) };
7576 }
src/boards/sampler.ts +3 −2
@@ -28,6 +28,7 @@ import { cellCounters, type CellCounter } from "../apps/contain.ts";
2828 import { startLoopLag, type LoopLag } from "../kernel/loop_lag.ts";
2929 import { FINE_MS, record, type Point } from "./ring.ts";
3030 import { readHwmon, readTopology, temperatures } from "./thermal.ts";
31 import { during } from "../perf/jobs.ts";
3132
3233 export type Paths = { cgroup: string; proc: string; sys: string };
3334 export const SYSTEM: Paths = { cgroup: "/sys/fs/cgroup", proc: "/proc", sys: "/sys" };
@@ -240,8 +241,8 @@ export function startSampler(
240241 app.log.warn("machine sample failed", { error: String(error) });
241242 }
242243 };
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);
245246 timer.unref?.();
246247 return {
247248 tick,
src/checks/service.ts +7 −4
@@ -127,6 +127,7 @@ import { checkStage, type GateStage } from "./gate_reason.ts";
127127 import { extraGates, GATE_DEADLINE_MS, gateQueued, gateRunId, gatesInFlight, watchGates } from "./queue.ts";
128128 import type { JobOptions } from "../cluster/jobs.ts";
129129 import type { CheckDetail } from "../core/runs.ts";
130 import { during } from "../perf/jobs.ts";
130131
131132 const log = createLog("checks");
132133
@@ -1660,7 +1661,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
16601661 }
16611662
16621663 const timer = setInterval(() => {
1663 if (!stopped) schedule();
1664 if (!stopped) during("lane", schedule);
16641665 }, TICK_MS);
16651666 // Main is verified after landing on a schedule, when the forge says how often.
16661667 const sweeper =
@@ -1668,12 +1669,14 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
16681669 ? undefined
16691670 : setInterval(() => {
16701671 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 );
16721675 }, Math.min(VERIFY_SWEEP_MS, options.verifyEveryMs));
16731676 // A gate's slot frees: the pull check held for it starts now, not at the tick.
16741677 const unwatch = watchGates(schedule);
1675 schedule();
1676 void catchUp();
1678 during("lane", schedule);
1679 void during("lane-catch-up", catchUp);
16771680
16781681 /** One gate's run on the lane: the full tier on `commitOid`, traced, cut off by `controller`. */
16791682 async function gateOnce(
src/contract/probes.ts +3 −2
@@ -22,6 +22,7 @@ import { id as newId } from "../kernel/id.ts";
2222 import { createLog } from "../kernel/log.ts";
2323 import { audit } from "./audit.ts";
2424 import { probe, type Knock, type Probed } from "./probe.ts";
25 import { during } from "../perf/jobs.ts";
2526
2627 const log = createLog("probes");
2728
@@ -229,7 +230,7 @@ export function startProbes(
229230 if (!record) return;
230231 const timer = setTimeout(() => {
231232 timers.delete(timer);
232 if (!stopped) void one(record);
233 if (!stopped) void during("probes", () => one(record));
233234 }, AFTER_DEPLOY_MS);
234235 timers.add(timer);
235236 });
@@ -238,7 +239,7 @@ export function startProbes(
238239 const scheduleNightly = (): void => {
239240 if (options.nightly === false) return;
240241 nightly = setTimeout(() => {
241 void track(round()).finally(scheduleNightly);
242 void track(during("probes", round)).finally(scheduleNightly);
242243 }, untilNightly());
243244 };
244245 scheduleNightly();
src/crew/dispatch.ts +22 −17
@@ -132,6 +132,7 @@ import {
132132 } from "./runner.ts";
133133 import { STALE_AFTER_MS } from "../runs/lease.ts";
134134 import { abortAgentRun } from "./running.ts";
135 import { during } from "../perf/jobs.ts";
135136
136137 const log = createLog("crew");
137138
@@ -275,18 +276,20 @@ export async function startCrew(
275276 // A tick that throws is logged and the next one tries again: thrown
276277 // from a timer it is an uncaught error that takes the process down.
277278 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 });
290293 } catch (error) {
291294 log.error("crew tick failed", { error: String(error) });
292295 }
@@ -301,11 +304,13 @@ export async function startCrew(
301304 const pruned = pruneRunFiles(app.config.dataDir);
302305 if (pruned > 0) log.info("removed old run logs", { count: pruned });
303306 };
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 });
309314
310315 const handle: CrewHandle = {
311316 ready: detected.ok,
src/crew/notify.ts +7 −3
@@ -48,6 +48,7 @@ import { loadClog } from "../flow/clog_tick.ts";
4848 import { redactSecrets } from "../kernel/log.ts";
4949 import { forgeCeiling } from "../flow/ceiling.ts";
5050 import { askLinesFor } from "../web/studio/asks.ts";
51 import { during } from "../perf/jobs.ts";
5152
5253 const KEY = "notify_url";
5354 export const MAX_URL = 200;
@@ -585,13 +586,13 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier {
585586
586587 // Eight days of silence is no event: the clock wakes the notifier too.
587588 const clock = setInterval(() => {
588 queue = queue.then(async () => {
589 queue = queue.then(() => during("notify", async () => {
589590 try {
590591 for (const id of people()) await tell(id, false);
591592 } catch (error) {
592593 app.log.error("notify failed", { error: String(error) });
593594 }
594 });
595 }));
595596 }, options.clockMs ?? CLOCK_MS);
596597 clock.unref();
597598
@@ -607,7 +608,10 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier {
607608 });
608609 return queue;
609610 };
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 );
611615 pager.unref();
612616
613617 return {
src/crew/prompts.ts +2 −1
@@ -35,6 +35,7 @@ import {
3535 type InstagramClient,
3636 type Reel,
3737 } from "./instagram.ts";
38 import { during } from "../perf/jobs.ts";
3839
3940 const log = createLog("prompts");
4041
@@ -101,7 +102,7 @@ export function startPromptRoutine(
101102
102103 const timer = setInterval(() => {
103104 if (stopped) return;
104 runDue().catch((error) =>
105 during("prompts", runDue).catch((error) =>
105106 log.error("prompt sweep failed", { error: String(error) }),
106107 );
107108 }, 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";
1717 import { readOldestApproved } from "./oldest_approved.ts";
1818 import { readStranded } from "./stranded.ts";
1919 import { readVitals } from "./vitals.ts";
20 import { during } from "../perf/jobs.ts";
2021
2122 const HOUR_MS = 3_600_000;
2223 const log = createLog("clog");
@@ -90,7 +91,7 @@ export function startClogTick(
9091 ): () => void {
9192 const timer = setInterval(() => {
9293 try {
93 tickClog(db, bus);
94 during("clog", () => tickClog(db, bus));
9495 } catch (error) {
9596 onError(error);
9697 }
src/flow/keeper.ts +2 −1
@@ -18,6 +18,7 @@ import { ownRepoPath } from "../kernel/own_repo.ts";
1818 import { KEEPER_CAUSES, pickCause, readKeeperFacts, type KeeperCause, type KeeperMove } from "./keeper_cause.ts";
1919 import { isLow, landedBetween, readRate, type Rate } from "./keeper_rate.ts";
2020 import { oldestWaiting } from "./pile.ts";
21 import { during } from "../perf/jobs.ts";
2122
2223 const log = createLog("keeper");
2324 const HOUR_MS = 3_600_000;
@@ -177,7 +178,7 @@ export function tickKeeper(app: App, now = new Date(), onLift: () => void = () =
177178 export function startKeeper(app: App, onLift: () => void, everyMs = 60_000, now: () => number = Date.now): () => void {
178179 const timer = setInterval(() => {
179180 try {
180 tickKeeper(app, new Date(now()), onLift);
181 during("keeper", () => tickKeeper(app, new Date(now()), onLift));
181182 } catch (error) {
182183 log.warn("keeper tick failed", { error: String(error) });
183184 }
src/http/server.ts +5 −2
@@ -495,6 +495,7 @@ import {
495495 type RouteTiming,
496496 } from "../core/route_timing.ts";
497497 import { captureStore, loopWatch, perfRecord, readLedger } from "../perf/live.ts";
498 import { during } from "../perf/jobs.ts";
498499 import { classOf, countryOf, repoOf, signatureOf } from "../perf/traffic.ts";
499500 import { createTrafficWriter } from "../perf/traffic_store.ts";
500501 import { startFeatureSchedule } from "../apps/feature_schedule.ts";
@@ -2349,8 +2350,10 @@ export async function serve(app: App): Promise<Servers> {
23492350 // suspension or sign-out another process wrote reaches it too (#846).
23502351 const sweep = setInterval(
23512352 () =>
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 ),
23542357 ),
23552358 app.config.standingSweepMs ?? 5_000,
23562359 );
src/looks/service.ts +3 −2
@@ -71,6 +71,7 @@ import { addCommitLook } from "../checks/looks.ts";
7171 import type { LookVitals } from "../checks/summary.ts";
7272 import { keepAsMain } from "./main_looks.ts";
7373 import { findChromium, isNoBrowser, takeLook, type Capture } from "./capture.ts";
74 import { during } from "../perf/jobs.ts";
7475
7576 const log = createLog("looks");
7677
@@ -225,8 +226,8 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
225226 // Production missing a picture at its head — one never taken, or a
226227 // thumb from before thumbs were the page — gets one while it is up.
227228 // 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);
230231 firstSweep.unref?.();
231232 sweeping.unref?.();
232233
src/main.ts +50 −33
@@ -101,6 +101,7 @@ import { crewRoot } from "./crew/workdir.ts";
101101 import { createLog } from "./kernel/log.ts";
102102 import { watchStalls } from "./kernel/loop_lag.ts";
103103 import { startPerfWatcher, TICK_MS } from "./perf/duty.ts";
104 import { during } from "./perf/jobs.ts";
104105 import { stallLine } from "./perf/loop.ts";
105106 import { bootStall, gitRecord, loopWatch, perfRecord } from "./perf/live.ts";
106107 import { timeStatements } from "./kernel/slow_sql.ts";
@@ -350,7 +351,10 @@ const bootSignup = signupFromEnv(process.env.FORGE_SIGNUP);
350351 if (bootSignup.ok && bootSignup.value !== null)
351352 setSignup(app.db, bootSignup.value, BOOT_ACTOR);
352353 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 );
354358 await reconcile(app);
355359 // Issues linked under a pillar's umbrella before pillars had labels.
356360 const labelled = syncAllPillars(app);
@@ -589,14 +593,17 @@ const notifier = startNotifier(app, { forgeUrl });
589593 // loses it. runDelivery never throws into the timer.
590594 // Expired sessions and run tokens ride the same tick: cheap DELETEs, never
591595 // worth a worker.
592 const deliveryTimer = setInterval(() => {
596 const deliveryTimer = setInterval(() => during("delivery", () => {
593597 runDelivery(app.db, app.log, app.bus);
594598 pruneSessions(app.db);
595599 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 });
600607
601608 function positive(value: string | undefined, fallback: number): number {
602609 const parsed = Number(value);
@@ -612,12 +619,13 @@ function positive(value: string | undefined, fallback: number): number {
612619 // the sweep. The operator guard's sweep of stored origins rides it too: one
613620 // origin a tick, the never-checked first (so on boot), every other daily.
614621 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));
618625 }, 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));
621629
622630 // Which green pulls no longer merge as they stand, so the asks can leave
623631 // them out (`crew/stale_asks.ts`): git answers, off the request path, and an
@@ -658,9 +666,12 @@ const tickAsks = async (): Promise<void> => {
658666 };
659667 // Followed upstreams are read on a slow tick (`actions/upstream_follow.ts`):
660668 // 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);
664675
665676 // Every start is counted, and one that follows a process that never said
666677 // 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
701712 app.log.warn("resume at boot failed", { error: String(error) }),
702713 );
703714 // An agent's hold that has stood 24 h is released each minute (#6918).
704 const holdTimer = setInterval(() => {
715 const holdTimer = setInterval(() => during("holds", () => {
705716 try {
706717 expireCrewHolds(app);
707718 } catch (error) {
708719 app.log.warn("hold expiry failed", { error: String(error) });
709720 }
710 }, 60_000);
721 }), 60_000);
711722 // 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", () => {
713724 try {
714725 runModelReview(app);
715726 } catch (error) {
716727 app.log.warn("model review failed", { error: String(error) });
717728 }
718 }, 3_600_000);
729 }), 3_600_000);
719730 // 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", () => {
721732 try {
722733 const repos = plannedRepoIds(app.db).flatMap((id) => getRepoById(app.db, id) ?? []);
723734 for (const repo of tickWeeks(app.db, repos, new Date()))
@@ -725,18 +736,18 @@ const weekTimer = setInterval(() => {
725736 } catch (error) {
726737 app.log.warn("week retrospective failed", { error: String(error) });
727738 }
728 }, 3_600_000);
739 }), 3_600_000);
729740 // 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.
730741 const nightlyScanTimer = setInterval(() => {
731 void (async () => {
742 void during("nightly-scan", async () => {
732743 for (const id of nightlyDue(app.db, new Date(Date.now() - 24 * 3_600_000).toISOString(), 25)) {
733744 const repo = getRepoById(app.db, id);
734745 if (repo !== undefined) await scanNightly(app, repo);
735746 }
736 })().catch((error) => app.log.warn("nightly scan failed", { error: String(error) }));
747 }).catch((error) => app.log.warn("nightly scan failed", { error: String(error) }));
737748 }, 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);
740751 // A new version proves itself against the public URL; a failed one again, 5 min after and backing off to hourly (release/prove.ts).
741752 proveRunning({
742753 dataDir: app.config.dataDir,
@@ -760,11 +771,14 @@ if (app.config.forgeRepo) {
760771 read: ownTagsReader(app, app.config.forgeRepo),
761772 });
762773 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 );
765779 // 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);
768782 }
769783 // The release keeper (#7557): cut on cadence, promote, verify under page load, roll back to the named
770784 // known-good image and hold. Off unless FORGE_RELEASE_KEEPER=on in a cluster (keeper/wire.ts).
@@ -786,9 +800,12 @@ if (keeperOn.on) {
786800 said = line;
787801 })
788802 .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 );
792809 }
793810 } else app.log.info("the release keeper is off", { why: keeperOn.why });
794811 // Only an agent is on a crew (the `repo_crew_agents_only` trigger refuses anyone else). A person
@@ -825,7 +842,7 @@ if (ssh)
825842 // failed; checks are queued again while their commit heads an open pull;
826843 // agent runs are the crew's to queue again, unless there is no crew here to
827844 // do it.
828 const leaseTimer = setInterval(() => {
845 const leaseTimer = setInterval(() => during("lease", () => {
829846 failAbandonedRuns(
830847 app.db,
831848 STALE_AFTER_MS,
@@ -834,7 +851,7 @@ const leaseTimer = setInterval(() => {
834851 requeueAbandonedChecks(app, STALE_AFTER_MS);
835852 // A grant whose run is no longer running is a crash's leftover: close it.
836853 releaseOrphans(app.db);
837 }, 30_000);
854 }), 30_000);
838855
839856 // A release that read healthy beside this process takes over (#1663),
840857 // 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";
2121 import { type Ran, runJob } from "./run.ts";
2222 import { lastSlot, owed, untilNext } from "./slots.ts";
2323 import { readLast, writeLast } from "./stamps.ts";
24 import { during } from "../perf/jobs.ts";
2425
2526 const log = createLog("nightly");
2627
@@ -132,7 +133,7 @@ export function startNightly(
132133 const waitRadar = (): void => {
133134 const timer = setTimeout(() => {
134135 timers.delete(timer);
135 void runRadarNight().finally(() => !abort.signal.aborted && waitRadar());
136 void during("nightly", runRadarNight).finally(() => !abort.signal.aborted && waitRadar());
136137 }, untilNext(RADAR_SLOT, now()) + 1_000);
137138 timers.add(timer);
138139 };
@@ -142,7 +143,7 @@ export function startNightly(
142143 const wait = (job: Job): void => {
143144 const timer = setTimeout(() => {
144145 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));
146147 // A second past the slot: a timer that fires early would read yesterday's.
147148 }, untilNext(job.slot, now()) + 1_000);
148149 timers.add(timer);
src/origins/mirror-pulls.ts +3 −2
@@ -35,6 +35,7 @@ import { vettedEnv } from "./git-mirror.ts";
3535 import { hookSecret, hookUrl } from "./hook-secret.ts";
3636 import { verifySignature } from "../core/webhooks.ts";
3737 import type { Provider, RemoteIssue } from "./types.ts";
38 import { during } from "../perf/jobs.ts";
3839
3940 const log = createLog("mirror-pulls");
4041
@@ -335,7 +336,7 @@ export function startMirrorPulls(app: App): () => void {
335336 busy = false;
336337 });
337338 };
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);
340341 return () => clearInterval(timer);
341342 }
src/perf/duty.ts +2 −1
@@ -16,6 +16,7 @@ import { addLabels, kindLabels } from "../hunt/label.ts";
1616 import { createGitRecord } from "./git.ts";
1717 import type { PerfRecord } from "./record.ts";
1818 import { evaluate, evaluateGit, fresh } from "./watch.ts";
19 import { during } from "./jobs.ts";
1920
2021 export const TICK_MS = 60_000;
2122
@@ -46,7 +47,7 @@ export function startPerfWatcher(
4647 app.log.warn("performance watcher failed", { error: String(error) });
4748 }
4849 };
49 const timer = setInterval(tick, everyMs);
50 const timer = setInterval(() => during("perf-watcher", tick), everyMs);
5051 timer.unref();
5152 return () => clearInterval(timer);
5253 }
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);
outdated

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.

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 @@
22
33 import { createBootStall } from "./boot_stall.ts";
44 import { createGitRecord } from "./git.ts";
5 import { jobWatch } from "./jobs.ts";
56 import { createLoopWatch } from "./loop.ts";
67 import { createCaptureStore } from "./capture.ts";
78 import { createReadLedger } from "./reads.ts";
89 import { createPerfRecord } from "./record.ts";
910
1011 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);
1214 export const gitRecord = createGitRecord();
1315 /** The worst stall in this process's first five minutes, read by the release keeper (#8848). */
1416 export const bootStall = createBootStall();
src/perf/loop.ts +21 −4
@@ -10,6 +10,10 @@
1010 * full against every route that ran across it — the same reading as the log
1111 * lines, so the sum over routes can exceed the wall time — and against
1212 * `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.
1317 */
1418
1519 export const LIMIT_MS = 250;
@@ -20,6 +24,10 @@ export const HOUR_MS = 3_600_000;
2024 const HOUR_KEPT = 14_400;
2125 /** The route a stall is counted against when no request ran across it. */
2226 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[];
2331
2432 export interface RouteStall {
2533 readonly route: string;
@@ -38,6 +46,8 @@ export interface Stall {
3846 readonly at: number;
3947 readonly lateMs: number;
4048 readonly routes: readonly string[];
49 /** The background jobs running across it; read only when no request was. */
50 readonly jobs: readonly string[];
4151 }
4252
4353 export interface LoopWatch {
@@ -49,10 +59,14 @@ export interface LoopWatch {
4959 tally(now: number): StallTally;
5060 }
5161
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
5266 function byRoute(stalls: Iterable<Stall>): RouteStall[] {
5367 const sums = new Map<string, { ms: number; count: number }>();
5468 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)) {
5670 const t = sums.get(route) ?? { ms: 0, count: 0 };
5771 t.ms += s.lateMs;
5872 t.count += 1;
@@ -61,7 +75,7 @@ function byRoute(stalls: Iterable<Stall>): RouteStall[] {
6175 return [...sums].map(([route, t]) => ({ route, ms: t.ms, count: t.count }));
6276 }
6377
64 export function createLoopWatch(limitMs = LIMIT_MS): LoopWatch {
78 export function createLoopWatch(limitMs = LIMIT_MS, jobsAcross: JobsAcross = () => []): LoopWatch {
6579 const recent: { pattern: string; start: number; end: number }[] = [];
6680 const totals = new Map<string, { ms: number; count: number }>();
6781 const hour: Stall[] = [];
@@ -81,7 +95,8 @@ export function createLoopWatch(limitMs = LIMIT_MS): LoopWatch {
8195 .map((r) => r.pattern),
8296 ),
8397 ];
84 const stall = { at, lateMs, routes };
98 const jobs = routes.length === 0 ? jobsAcross(from, at) : [];
99 const stall = { at, lateMs, routes, jobs };
85100 for (const r of byRoute([stall])) {
86101 const t = totals.get(r.route) ?? { ms: 0, count: 0 };
87102 t.ms += r.ms;
@@ -104,4 +119,6 @@ export function createLoopWatch(limitMs = LIMIT_MS): LoopWatch {
104119
105120 /** The stall as a finding line. */
106121 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
3535 import { isOffBox } from "./off_box.ts";
3636 import { noteOffBox, recordSnapshotOutcome } from "./outcome.ts";
3737 import { s3FromEnv, type Store } from "./store.ts";
38 import { during } from "../perf/jobs.ts";
3839
3940 const log = createLog("snapshots");
4041
@@ -379,7 +380,7 @@ export function startSnapshots(
379380 if (timer !== undefined) clearTimeout(timer);
380381 timer = undefined;
381382 if (options.nightly === false || stopped) return;
382 timer = setTimeout(() => void take().catch(() => {}), ms);
383 timer = setTimeout(() => void during("snapshot", take).catch(() => {}), ms);
383384 }
384385 arm(untilNightly());
385386
@@ -398,7 +399,7 @@ export function startSnapshots(
398399 const scheduleDrill = (): void => {
399400 if (options.nightly === false || stopped) return;
400401 drillTimer = setTimeout(() => {
401 runIfDue().finally(scheduleDrill);
402 during("drill", () => runIfDue()).finally(scheduleDrill);
402403 }, untilDrill());
403404 };
404405 // While the newest drill is red, look again when it is owed again.
@@ -409,7 +410,7 @@ export function startSnapshots(
409410 const newest = lastDrill(app.db);
410411 if (newest?.verdict !== "red") return;
411412 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);
413414 };
414415 // Owed and not yet kept: run it once. A restart after a green drill finds it
415416 // kept; one after a red drill runs it again, once it is past the boot floor.
@@ -423,7 +424,9 @@ export function startSnapshots(
423424 // shortly after boot; a red one is looked at again until one passes.
424425 if (options.nightly !== false) {
425426 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),
427430 catchUpMs,
428431 );
429432 }
@@ -434,7 +437,7 @@ export function startSnapshots(
434437 const scheduleMirror = (first: boolean): void => {
435438 if (options.nightly === false || stopped) return;
436439 mirrorTimer = setTimeout(() => {
437 mirrorNow()
440 during("snapshot-mirror", mirrorNow)
438441 .catch(() => {})
439442 .finally(() => scheduleMirror(false));
440443 }, first ? Math.min(30_000, everyMs) : everyMs);
@@ -447,7 +450,7 @@ export function startSnapshots(
447450 const scheduleCatchUp = (ms: number): void => {
448451 if (options.nightly === false || stopped) return;
449452 catchUpSnapshot = setTimeout(() => {
450 takeIfDue()
453 during("snapshot", takeIfDue)
451454 .catch(() => {})
452455 .finally(() => {
453456 bootLookAt = undefined;
src/spawn/heartbeat.ts +2 −1
@@ -26,6 +26,7 @@ import { upstreamState } from "./upstream.ts";
2626 import { progress } from "../core/next-steps.ts";
2727 import { running } from "../kernel/version.ts";
2828 import { forgeCeiling } from "../flow/ceiling.ts";
29 import { during } from "../perf/jobs.ts";
2930
3031 export const SIGNATURE_HEADER = "x-forge-signature";
3132 /** A child reports this often; one silent for three beats reads as quiet. */
@@ -323,7 +324,7 @@ export async function sendBeat(
323324 /** Report now and then every BEAT_MS; returns the timer to clear. */
324325 export function startHeartbeat(app: App): ReturnType<typeof setInterval> {
325326 void sendBeat(app);
326 return setInterval(() => void sendBeat(app), BEAT_MS);
327 return setInterval(() => void during("heartbeat", () => sendBeat(app)), BEAT_MS);
327328 }
328329
329330 export type Standing = "reporting" | "quiet" | "unheard";
src/spawn/reach.ts +3 −2
@@ -25,6 +25,7 @@ import type { App } from "../app.ts";
2525 import { isSlug, slugFor } from "../core/apps.ts";
2626 import type { FiredAlarm } from "../flow/alarms.ts";
2727 import { childPublicUrl, parentOrigin } from "./placement.ts";
28 import { during } from "../perf/jobs.ts";
2829
2930 export const APP_HEADER = "x-forge-app";
3031
@@ -283,8 +284,8 @@ export function startReachWatch(
283284 everyMs = REACH_EVERY_MS,
284285 asleep: (name: string) => boolean = () => false,
285286 ): 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);
288289 app.closing.signal.addEventListener("abort", () => clearTimeout(first), { once: true });
289290 return timer;
290291 }
src/spawn/upstream.ts +2 −1
@@ -23,6 +23,7 @@ import { basicHeader, gitAuthEnv, redact, secretsIn } from "../origins/credentia
2323 import { forgeLinkOf, originOf } from "./chain.ts";
2424 import { parentCredential } from "./parent_credential.ts";
2525 import { newestCommits, takeFromParent, type Taken } from "./take.ts";
26 import { during } from "../perf/jobs.ts";
2627
2728 export const CHECK_MS = 60 * 60_000;
2829 export const FETCH_TIMEOUT_MS = 60_000;
@@ -211,7 +212,7 @@ function tellOwner(app: App, repo: Repo, fork: string, behind: number, commits =
211212 /** Check at boot and every hour; returns the timer to clear. */
212213 export function startUpstreamWatch(app: App): ReturnType<typeof setInterval> {
213214 void checkUpstream(app);
214 return setInterval(() => void checkUpstream(app), CHECK_MS);
215 return setInterval(() => void during("upstream", () => checkUpstream(app)), CHECK_MS);
215216 }
216217
217218 /** 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs