Skip to content
97 linesCodeBlameRaw
1/**
2 * How long the site waits on a service before it gives up. Nothing on
3 * Workers ends a subrequest that stops answering: the page waits as long
4 * as the visitor does, so a service stuck on one call holds the page on
5 * its loading bar for good. With a deadline it fails instead, as an error
6 * the page can draw and the next click can get past.
7 */
8
9/** A read: no page should wait longer than this on one service call. */
10export const READ_DEADLINE_MS = 15_000;
11
12/** A write: some do real work (a merge, a commit), so they get longer. */
13export const WRITE_DEADLINE_MS = 60_000;
14
15/** Reads that do real work inline the first time, or walk history. */
16export const SLOW_READ_DEADLINE_MS = 60_000;
17
18/** Jobs a person starts and waits on: an import, a model's draft, a computer waking. */
19export const LONG_DEADLINE_MS = 600_000;
20
21/**
22 * Reads that can take longer than `READ_DEADLINE_MS` and still be right,
23 * by service binding and method. Cut off, their work is lost and starts
24 * again on the next visit, so they get the time to finish.
25 */
26const SLOW_READS = new Set([
27 // A repository's first look runs its dependency scan inline (services/security/src/deps.rs).
28 "security overview",
29 "security dependency_graph",
30 "security sbom",
31 // Walks up to 400 commits, reading trees and blobs one after another (services/repos/src/blame.rs).
32 "repos blame",
33 // Up to 2,500 store reads per call (services/repos/src/last_commits.rs).
34 "repos last_commits",
35]);
36
37/**
38 * Writes that run for minutes and still finish: copying a repository in,
39 * a scan over many repositories.
40 */
41const LONG_CALLS = new Set([
42 "repos create",
43 "integrations github_import",
44 "security dry_run_pattern",
45 "security rescan",
46 "security check_updates",
47 "artifacts add_repo_space",
48]);
49
50/** How long a call to `service`'s `method` may take; `writes` as lib/perf.ts `mayWrite` says. */
51export function deadlineFor(service: string, method: string, writes: boolean): number {
52 const call = `${service} ${method}`;
53 if (LONG_CALLS.has(call)) return LONG_DEADLINE_MS;
54 if (SLOW_READS.has(call)) return SLOW_READ_DEADLINE_MS;
55 return writes ? WRITE_DEADLINE_MS : READ_DEADLINE_MS;
56}
57
58/** What a call past its deadline throws. */
59export class DeadlineExceeded extends Error {
60 constructor(what: string, ms: number) {
61 super(`${what} did not answer within ${ms}ms`);
62 this.name = "DeadlineExceeded";
63 }
64}
65
66/**
67 * `run`'s answer, or a `DeadlineExceeded` after `ms`. `run` is handed a
68 * signal that aborts at the deadline, so the call it makes is cancelled
69 * too; `outer`, when given, aborts it sooner. Whatever `run` reads must be
70 * read inside it: the deadline ends when it returns.
71 */
72export async function withDeadline<T>(
73 what: string,
74 ms: number,
75 run: (signal: AbortSignal) => Promise<T>,
76 outer?: AbortSignal | null,
77): Promise<T> {
78 const controller = new AbortController();
79 let timer: ReturnType<typeof setTimeout> | undefined;
80 const expired = new Promise<never>((_, reject) => {
81 timer = setTimeout(() => {
82 const error = new DeadlineExceeded(what, ms);
83 controller.abort(error);
84 reject(error);
85 }, ms);
86 });
87 const stop = () => controller.abort(outer?.reason);
88 if (outer?.aborted) stop();
89 else outer?.addEventListener("abort", stop, { once: true });
90 try {
91 // Raced as well as aborted: a binding that ignores the signal still lets go.
92 return await Promise.race([run(controller.signal), expired]);
93 } finally {
94 clearTimeout(timer);
95 outer?.removeEventListener("abort", stop);
96 }
97}