Skip to content

Commit fc27c38

Browse files
committed
feat(triage-contracts): make Triage's waits overridable and publish them in the release
Every wait the hub, listener and manager share is one named timing in triage-contracts with its production value as default and a TRIAGE_*_MS override. The hub reads them once as it starts and writes the listener's and manager's share into their release.json, since a workflow's process gets none of the hub's environment; a tree without them runs on the defaults. The hub's deploy hold stays derived from the same ready window the listener waits.
1 parent 1b7eaf8 commit fc27c38

15 files changed

Lines changed: 126 additions & 61 deletions

File tree

‎.env.example‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ HUB_DATA_DIR=./tmp/hub-data
5050

5151
# Milliseconds between passes that re-queue open pull requests whose head is not triaged. Default 300000.
5252
# TRIAGE_RECONCILE_INTERVAL_MS=
53+
# Milliseconds before a pass that could not reach a listener tries again, doubling to two minutes. Default 15000.
54+
# TRIAGE_RECONCILE_RETRY_MS=
55+
# Milliseconds a listener waits for a just-deployed manager to reach a sidecar before it gives up on it. Default 120000.
56+
# TRIAGE_MANAGER_READY_MS=
57+
# Milliseconds a just-deployed manager may have no sidecar and still hold its pull request. Default 120000.
58+
# TRIAGE_MANAGER_START_GRACE_MS=
59+
# Milliseconds a manager waiting on CI or a reviewer waits between checks. Default 30000.
60+
# TRIAGE_MANAGER_POLL_MS=
61+
# Milliseconds between a listener's checks on whether its manager reached a sidecar. Default 2000.
62+
# TRIAGE_WAKE_POLL_MS=
5363

5464
# Pull request heads the reconciler lets a tenant have queued or running at once. Default 5.
5565
# TRIAGE_MAX_IN_FLIGHT=

‎apps/hub/src/env.ts‎

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,9 @@ const HubEnvSchema = type({
7373
"RAILWAY_GIT_REPO_OWNER?": "string",
7474
"RAILWAY_GIT_REPO_NAME?": "string",
7575
"RAILWAY_ENVIRONMENT_ID?": "string",
76-
"TRIAGE_RECONCILE_INTERVAL_MS?": positiveInteger,
7776
"TRIAGE_MAX_IN_FLIGHT?": positiveInteger,
7877
"TRIAGE_CAPPED_RETRY_MS?": positiveInteger,
79-
"TRIAGE_COALESCE_QUIET_MS?": positiveInteger,
8078
"TRIAGE_MAX_LOCAL_SIDECARS?": positiveInteger,
81-
"TRIAGE_STALLED_AFTER_MS?": positiveInteger,
82-
"TRIAGE_LIVENESS_INTERVAL_MS?": positiveInteger,
8379
"HUB_AGENT_GC_PACK_THRESHOLD?": positiveInteger,
8480
"HUB_AGENT_GC_LOOSE_THRESHOLD?": positiveInteger,
8581
"HUB_AGENT_GC_WARN_BYTES?": positiveInteger,
@@ -268,20 +264,6 @@ export function migrationEnv(db: DatabaseConfig): Record<string, string> {
268264
};
269265
}
270266

271-
export function triageReconcileIntervalMs(env: HubEnv): number {
272-
return env.TRIAGE_RECONCILE_INTERVAL_MS === undefined ? 5 * 60_000 : Number(env.TRIAGE_RECONCILE_INTERVAL_MS);
273-
}
274-
275-
/** How long a workspace's pull request may wait for a manager before the hub raises its alarm. */
276-
export function triageStalledAfterMs(env: Pick<HubEnv, "TRIAGE_STALLED_AFTER_MS">): number {
277-
return numberOr(env.TRIAGE_STALLED_AFTER_MS, 5 * 60_000);
278-
}
279-
280-
/** How often the hub checks whether each workspace's pull requests still reach a manager. */
281-
export function triageLivenessIntervalMs(env: Pick<HubEnv, "TRIAGE_LIVENESS_INTERVAL_MS">): number {
282-
return numberOr(env.TRIAGE_LIVENESS_INTERVAL_MS, 60_000);
283-
}
284-
285267
/** Heads the reconciler lets a tenant have queued or running at once. */
286268
export function triageReconcilePolicy(env: Pick<HubEnv, "TRIAGE_MAX_IN_FLIGHT" | "TRIAGE_CAPPED_RETRY_MS">): ReconcilePolicy {
287269
return {
@@ -302,8 +284,7 @@ const COALESCE_BURST_MS = 3_000;
302284
* A pull request's webhook events go out as one mail once none arrived for the quiet period, or at most four quiet periods after the first; the window must close before the reconciler gives up on its queued head.
303285
* A new commit or a newly opened pull request waits only for the burst, long enough to take in the review requests, labels and assignees GitHub delivers with it within a second or so.
304286
*/
305-
export function triageCoalesceWindow(env: HubEnv, policy: ReconcilePolicy): CoalesceWindow {
306-
const quietMs = numberOr(env.TRIAGE_COALESCE_QUIET_MS, 30_000);
287+
export function triageCoalesceWindow(quietMs: number, policy: ReconcilePolicy): CoalesceWindow {
307288
if (4 * quietMs >= policy.unstartedAfterMs) {
308289
throw new Error(`TRIAGE_COALESCE_QUIET_MS must be below ${policy.unstartedAfterMs / 4}: four times it must stay under the ${policy.unstartedAfterMs} ms a queued head may wait to start`);
309290
}

‎apps/hub/src/manager-state.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { jsonb, primaryKey, text } from "drizzle-orm/pg-core";
77
import { type } from "arktype";
88
import { canExecuteWorkflowRun, schema, type DB } from "@intx/db";
99
import { asRecord } from "@corbits/common";
10-
import { APPROVE_DO_KIND, AWAITED_STAGES, MANAGER_READY_MS, REPO_SOURCES, TRIAGE_JUDGES, TRIAGE_LISTENERS, TRIAGE_MANAGERS, type AwaitedStage, type RepoSource } from "@corbits/triage-contracts";
10+
import { APPROVE_DO_KIND, AWAITED_STAGES, REPO_SOURCES, TRIAGE_JUDGES, TRIAGE_LISTENERS, TRIAGE_MANAGERS, type AwaitedStage, type RepoSource } from "@corbits/triage-contracts";
1111
import type { Tx } from "./github/admin-github-grant.js";
1212
import { resolveLiveDeployments, workflowName, type LiveDeployment } from "./github/deployment.js";
13+
import { TIMINGS } from "./timings.js";
1314
import { triageSchema } from "./triage-db.js";
1415
import { START_DEADLINE_MS } from "./workflow-release/rollout.js";
1516

@@ -194,7 +195,7 @@ export async function recordResult(db: DB["db"], tenantId: string, pr: PullReque
194195
* How long a just-deployed manager may have no sidecar and still hold the pull request, and a just-deployed listener
195196
* still count as live. The listener waits this long for a manager to start.
196197
*/
197-
const MANAGER_START_GRACE_MS = 2 * 60_000;
198+
const MANAGER_START_GRACE_MS = TIMINGS.managerStartGraceMs;
198199

199200
/**
200201
* How long a manager or judge may sit on its sidecar without its first mail starting it. Both are deployed with that
@@ -596,9 +597,9 @@ async function endedWaiting(tx: Tx, tenantId: string, pr: PullRequestRef, histor
596597

597598
/**
598599
* How long an ask holds off the next: the listener's wake of the manager it deployed and of the one it deploys in its
599-
* place when that wake fails, each waiting `MANAGER_READY_MS`, and stock's 30-second cancel grace between them.
600+
* place when that wake fails, each waiting `managerReadyMs`, and stock's 30-second cancel grace between them.
600601
*/
601-
const DEPLOY_ASK_HOLD_MS = 2 * MANAGER_READY_MS + 30_000;
602+
const DEPLOY_ASK_HOLD_MS = 2 * TIMINGS.managerReadyMs + 30_000;
602603

603604
/** Whether a manager the hub asked the listener for has not taken the claim yet and may still be starting. */
604605
function deploying(claim: Claim, now: Date): boolean {

‎apps/hub/src/server.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { createManagerIntake, createManagerWake, createOverdueWake, createSupers
4040
import { MANAGER_RUN_TOKEN_ROUTES } from "./manager-routes.js";
4141
import { deploymentUp, endNeverRan, liveListener, notePlaced, passRequest } from "./manager-state.js";
4242
import { createTakeLedger, createTriageLiveness } from "./triage-liveness.js";
43+
import { TIMINGS } from "./timings.js";
4344
import { mountTenantRoutes } from "./tenant-routes.js";
4445
import { archiveStoredPolicies } from "./stored-policies.js";
4546
import { connectDockerSidecars, dockerPlaces, sweepDockerSidecarsOf } from "./sidecar/docker.js";
@@ -73,7 +74,7 @@ import { createListenerWatch, releaseWriteOf } from "./workflow-release/triggers
7374
import { setUpPublisher, TRIAGE_PUBLISHER } from "./workflow-release/publisher.js";
7475
import { catalogModels, createWorkspaceLock, deployedAllocation, deploymentState, liveDeployments } from "./workflow-release/reads.js";
7576
import { createRolloutQueue, createWorkflowRollout, stopGivenUp, type DeployRequest } from "./workflow-release/rollout.js";
76-
import { bitbucketEgressAllowlist, childProcessEnv, clientIpHeaders, databaseConfig, dockerSettings, e2bSettings, emailSettings, interchangeSettings, githubApiOrigin, loadHubEnv, inviteLinkOrigin, maxLocalSidecars, railwaySandboxSettings, signInSettings, triageCoalesceWindow, triageReconcileIntervalMs, triageReconcilePolicy, triageLivenessIntervalMs, triageStalledAfterMs } from "./env.js";
77+
import { bitbucketEgressAllowlist, childProcessEnv, clientIpHeaders, databaseConfig, dockerSettings, e2bSettings, emailSettings, interchangeSettings, githubApiOrigin, loadHubEnv, inviteLinkOrigin, maxLocalSidecars, railwaySandboxSettings, signInSettings, triageCoalesceWindow, triageReconcilePolicy } from "./env.js";
7778
import { HOOK_MOUNT_PATH } from "./hooks.js";
7879
import { assertMigrated } from "./migrations.js";
7980
import { createBridgeHandler, logJson, withAppLogin } from "./github/bridge.js";
@@ -132,7 +133,6 @@ const EXPECTED_DEPLOYMENT_SET = {
132133
"bitbucket-datacenter": { workflows: hostWorkflows("bitbucket-datacenter"), tools: [bitbucketRead.id] },
133134
};
134135

135-
const TRIAGE_RETRY_MS = 15_000;
136136
const TRIAGE_RETRY_MAX_MS = 120_000;
137137
/** A repository is listed again once its listing is this old, and every pass once its webhook (the App's, on GitHub) heard nothing this long. */
138138
const TRIAGE_RELIST_QUIET_MS = 30 * 60_000;
@@ -652,7 +652,7 @@ const triageLiveness = createTriageLiveness({
652652
db: composition.db,
653653
store: triageStateStore,
654654
takes,
655-
stalledAfterMs: triageStalledAfterMs(env),
655+
stalledAfterMs: TIMINGS.stalledAfterMs,
656656
mail: smtp && async function mailStall(notice) {
657657
await smtp.triageStall({ ...notice, link: triageSettingsLink });
658658
},
@@ -699,7 +699,7 @@ async function reconcileAll(): Promise<ReconcileOutcome> {
699699
const outcome: ReconcileOutcome = { retry, ...(unrouted && { unrouted: true }) };
700700
return limits.length === 0 ? outcome : { ...outcome, retryAt: Math.min(...limits) };
701701
}
702-
triageLoop = createReconcileLoop(reconcileAll, { intervalMs: triageReconcileIntervalMs(env), retryMs: TRIAGE_RETRY_MS, retryMaxMs: TRIAGE_RETRY_MAX_MS }, logJson);
702+
triageLoop = createReconcileLoop(reconcileAll, { intervalMs: TIMINGS.reconcileIntervalMs, retryMs: TIMINGS.reconcileRetryMs, retryMaxMs: TRIAGE_RETRY_MAX_MS }, logJson);
703703
const kickTriage = triageLoop.kick;
704704
const githubPrTriage = createGithubPrTriage({
705705
...credentialDeps,
@@ -778,7 +778,7 @@ coalescer = createCoalescer({
778778
sendMail: sendTriageMail,
779779
retry: kickTriage,
780780
openHeadFor: tenantOpenHead,
781-
window: triageCoalesceWindow(env, reconcilePolicy),
781+
window: triageCoalesceWindow(TIMINGS.coalesceQuietMs, reconcilePolicy),
782782
setTimer,
783783
now,
784784
log: logJson,
@@ -1087,7 +1087,7 @@ await takes.seed(composition.db);
10871087
function checkLiveness(): void {
10881088
void triageLiveness.check(reconcileTenants);
10891089
}
1090-
livenessTimer = setInterval(checkLiveness, triageLivenessIntervalMs(env));
1090+
livenessTimer = setInterval(checkLiveness, TIMINGS.livenessIntervalMs);
10911091

10921092
/** Rolls every workspace onto a new build's release, as many at once as the queue allows. */
10931093
async function rollWorkspaces(): Promise<void> {

‎apps/hub/src/timings.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { timingsFrom } from "@corbits/triage-contracts";
2+
3+
/** The hub's waits, read from its environment once as it starts; the release carries the workflows' share. */
4+
export const TIMINGS = timingsFrom(process.env);

‎apps/hub/src/workflow-release/publish.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { and, eq } from "drizzle-orm";
66
import git from "isomorphic-git";
77
import { schema, type DB } from "@intx/db";
88
import { AssetServiceError, DEFAULT_ASSET_REF, type AssetService, type RepoStore } from "@intx/hub-sessions";
9-
import { deployedByChain, publishable, withRelease, type ModelInfo, type PublishedSource, type SourceIndexEntry, type SourceTree } from "@corbits/triage-contracts";
9+
import { deployedByChain, publishable, withRelease, workflowTimings, type ModelInfo, type PublishedSource, type SourceIndexEntry, type SourceTree } from "@corbits/triage-contracts";
1010
import { allowCreator } from "../github/admin-github-grant.js";
11+
import { TIMINGS } from "../timings.js";
1112

1213
/** The workflow sources the portal build writes under `packages/sources/`, in the order they publish. */
1314
export type Bundle = {
@@ -132,7 +133,7 @@ async function publishSource(deps: PublishDeps, publisher: Publisher, workflow:
132133
export async function publishRelease(deps: PublishDeps, publisher: Publisher, bundle: Bundle, models: readonly ModelInfo[]): Promise<PublishedSource[]> {
133134
const published: PublishedSource[] = [];
134135
for (const entry of (await bundle.index()).filter((row) => publishable(models, row.workflow))) {
135-
const { hash, tree } = await withRelease(models, entry, await bundle.tree(entry), published);
136+
const { hash, tree } = await withRelease(models, entry, await bundle.tree(entry), published, workflowTimings(TIMINGS));
136137
published.push(await publishSource(deps, publisher, entry.workflow, assetName(entry.workflow, publisher, hash), tree));
137138
}
138139
return published;

‎docs/SELF_HOST.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,11 @@ All optional; the defaults suit most installs.
291291
| Variable | Default | Effect |
292292
| --- | --- | --- |
293293
| `TRIAGE_RECONCILE_INTERVAL_MS` | 300000 | How often the hub re-queues open pull requests whose latest commit has no verdict. |
294+
| `TRIAGE_RECONCILE_RETRY_MS` | 15000 | How soon a pass that could not reach a host's listener tries again, doubling up to two minutes. |
295+
| `TRIAGE_MANAGER_READY_MS` | 120000 | How long Triage waits for a pull request's just-started manager to come up before it starts another in its place. |
296+
| `TRIAGE_MANAGER_START_GRACE_MS` | 120000 | How long a just-started manager may take to come up and still keep its pull request. |
297+
| `TRIAGE_MANAGER_POLL_MS` | 30000 | How often a pull request's manager checks again while it waits for CI, the decision model or code review. |
298+
| `TRIAGE_WAKE_POLL_MS` | 2000 | How often Triage checks whether a just-started manager has come up. |
294299
| `TRIAGE_MAX_IN_FLIGHT` | 5 | Pull request heads a workspace may have queued or running at once. |
295300
| `TRIAGE_CAPPED_RETRY_MS` | 21600000 | How long a head that ran out of attempts waits before a fresh set. |
296301
| `TRIAGE_STALLED_AFTER_MS` | 300000 | How long a pull request may wait for Triage to start before the hub raises the alarm for its host in that workspace: the `triage_stalled` log line, a notice in the portal, and, with [email](#email) set up, one email to the workspace's owners and admins; `triage_stall_cleared` and a second email follow once Triage starts a pull request on that host again. The alarm fires within this time plus one `TRIAGE_LIVENESS_INTERVAL_MS`. |

‎packages/triage-contracts/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@ export * from "./policy-patch.js";
3131
export * from "./code-patterns.js";
3232
export * from "./visible-text.js";
3333
export * from "./workflow-release.js";
34+
export * from "./timings.js";
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { type } from "arktype";
2+
3+
/**
4+
* Every wait the hub, the listener and the manager share, each its production value unless its variable overrides it.
5+
* The hub reads them once as it starts and publishes the workflows' share in each release, since a workflow's process
6+
* gets none of the hub's environment.
7+
*/
8+
export const TIMING_VARIABLES = {
9+
/** How long a listener's wake mail waits for a just-deployed manager to reach a sidecar before it fails. */
10+
managerReadyMs: "TRIAGE_MANAGER_READY_MS",
11+
/** How long a just-deployed manager may have no sidecar and still hold its pull request. */
12+
managerStartGraceMs: "TRIAGE_MANAGER_START_GRACE_MS",
13+
/** How long a manager whose head awaits CI or a reviewer waits between passes. */
14+
managerPollMs: "TRIAGE_MANAGER_POLL_MS",
15+
/** How often a wake mail checks whether the manager it wakes has reached its sidecar. */
16+
wakePollMs: "TRIAGE_WAKE_POLL_MS",
17+
/** How often the hub re-queues open pull requests whose head has no verdict. */
18+
reconcileIntervalMs: "TRIAGE_RECONCILE_INTERVAL_MS",
19+
/** The first retry of a reconcile pass that could not mail a listener, doubling up to its ceiling. */
20+
reconcileRetryMs: "TRIAGE_RECONCILE_RETRY_MS",
21+
/** Quiet time before a pull request's webhook events go out as one mail. */
22+
coalesceQuietMs: "TRIAGE_COALESCE_QUIET_MS",
23+
/** How often the hub checks whether each workspace's pull requests still reach a manager. */
24+
livenessIntervalMs: "TRIAGE_LIVENESS_INTERVAL_MS",
25+
/** How long a pull request may wait for a manager before the hub raises its alarm. */
26+
stalledAfterMs: "TRIAGE_STALLED_AFTER_MS",
27+
} as const;
28+
29+
export type Timings = Record<keyof typeof TIMING_VARIABLES, number>;
30+
31+
export const DEFAULT_TIMINGS: Timings = {
32+
managerReadyMs: 2 * 60_000,
33+
managerStartGraceMs: 2 * 60_000,
34+
managerPollMs: 30_000,
35+
wakePollMs: 2_000,
36+
reconcileIntervalMs: 5 * 60_000,
37+
reconcileRetryMs: 15_000,
38+
coalesceQuietMs: 30_000,
39+
livenessIntervalMs: 60_000,
40+
stalledAfterMs: 5 * 60_000,
41+
};
42+
43+
/** The waits the listener and manager run on, which the hub writes into their release. */
44+
export const WorkflowTimings = type({ managerReadyMs: "number.integer > 0", managerPollMs: "number.integer > 0", wakePollMs: "number.integer > 0" });
45+
export type WorkflowTimings = typeof WorkflowTimings.infer;
46+
47+
export function workflowTimings({ managerReadyMs, managerPollMs, wakePollMs }: Timings): WorkflowTimings {
48+
return { managerReadyMs, managerPollMs, wakePollMs };
49+
}
50+
51+
/** The timings `env` sets, each unset one its default; a value that is not a positive integer throws. */
52+
export function timingsFrom(env: Readonly<Record<string, string | undefined>>): Timings {
53+
const timings = { ...DEFAULT_TIMINGS };
54+
for (const [key, variable] of Object.entries(TIMING_VARIABLES) as [keyof Timings, string][]) {
55+
const value = env[variable];
56+
if (value === undefined) continue;
57+
if (!/^[1-9]\d*$/.test(value)) throw new Error(`${variable} must be a positive integer of milliseconds, not ${JSON.stringify(value)}`);
58+
timings[key] = Number(value);
59+
}
60+
return timings;
61+
}

‎packages/triage-contracts/src/triage-state.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@ export const CI_WAIT_MS = 60 * 60_000;
1212
/** How long a head with no checks yet waits for its CI to start before it is taken as having none. */
1313
export const CI_START_MS = 2 * 60_000;
1414

15-
/** How long a listener's wake mail waits for a just-deployed manager to reach a sidecar before it fails. */
16-
export const MANAGER_READY_MS = 2 * 60_000;
17-
1815
/** `stale`: the base branch moved after the head's verdict, so it is due again. */
1916
export const PR_TRIAGE_STATUSES = ["new", "queued", "running", "triaged", "failed", "stale"] as const;
2017
export type PrTriageStatus = (typeof PR_TRIAGE_STATUSES)[number];

0 commit comments

Comments
 (0)