Repository navigation
perf(ci): cut GITHUB_TOKEN calls in pr-verify and pace timer lanes on burn rate #11046
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
55ca354
perf(ci): run pr-verify's resolver before its trigger gate
kyle-sexton 408b51e
ci: log GITHUB_TOKEN api-calls from intake-triage apply-verdict and p…
kyle-sexton 0b7c6e0
feat(ci): pace timer-driven lanes on the GITHUB_TOKEN burn rate
kyle-sexton 0adc993
Merge remote-tracking branch 'origin/main' into perf/github-token-cut…
kyle-sexton 6c445fd
fix(ci): hand the config's pace to the pacer, and log 0 calls when no…
kyle-sexton 0db806c
Merge remote-tracking branch 'origin/main' into perf/github-token-cut…
kyle-sexton 402cee6
docs(pr-pipeline): give pace its purpose before the off switch
kyle-sexton 10475b1
Merge remote-tracking branch 'origin/main' into perf/github-token-cut…
kyle-sexton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| --- | ||
| bump: minor | ||
| --- | ||
|
|
||
| ### Added | ||
|
|
||
| - `limits.api` github-token `pace` (default 0.2, in rate-limits.json; set it in pr-pipeline.yaml, | ||
| 0 turns pacing off): the lost-run reconciler | ||
| and pr-unblock-dispatch skip every other scheduled tick (the even run numbers) while the | ||
| GITHUB_TOKEN pool's remaining at its reset, projected from the window's burn rate so far, is | ||
| under that share of the header's limit, so the timer lanes slow to half cadence before the | ||
| breaker stops every lane. Any other event, or a failed read, runs as before. | ||
|
|
||
| ### Changed | ||
|
|
||
| - pr-verify runs its read-only resolver before its trigger gate, so a run the resolver skips (a | ||
| head already judged or moved past, a draft, a run red for anything an approval does not lift, | ||
| an author it does not judge) no longer spends the gate's reads. A judged run makes the same | ||
| reads as before. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| // The burn-rate pacer for timer-driven lanes. A budget trip (the breaker | ||
| // share of the header's limit left, rate-limits.json) turns the kill switch | ||
| // on and stops every lane; this slows the timer-driven ones first. On a | ||
| // schedule tick it reads the GITHUB_TOKEN pool once, projects the remaining | ||
| // at the window's reset from the burn rate the window has shown so far, and | ||
| // writes paced=true when that falls under `pace` of the header's limit | ||
| // (limits.api github-token pace: pr-pipeline.yaml, which each paced workflow | ||
| // hands over as RESOLVED_CONFIG, over rate-limits.json; 0 is off). Only an | ||
| // even-numbered tick is paced, so a paced lane runs at half its cadence | ||
| // rather than stopping: the lost-run reconciler, which re-runs the runs a | ||
| // breaker trip stops, keeps working while the pool recovers. A paced tick | ||
| // skips its work and ends green. Any other event, a reading that cannot | ||
| // project, a run number that is no number, or a failed read is | ||
| // paced=false: the breaker stays the backstop. | ||
| // | ||
| // node pace.mjs EVENT_NAME, RUN_NUMBER, GITHUB_TOKEN, GITHUB_REPOSITORY | ||
| // and GITHUB_OUTPUT; RESOLVED_CONFIG optional | ||
|
|
||
| import { appendFileSync } from "node:fs"; | ||
| import process from "node:process"; | ||
| import { pathToFileURL } from "node:url"; | ||
|
|
||
| import { apiLimits, countRequests } from "./rate-guard.mjs"; | ||
|
|
||
| // GitHub's primary rate-limit window. | ||
| const WINDOW_SECONDS = 3600; | ||
| // Before this much of the window has passed, the burn rate is too short to project. | ||
| const MIN_ELAPSED_SECONDS = 60; | ||
|
|
||
| const round = (value) => Math.round(value * 100) / 100; | ||
|
|
||
| // { paced, projected } for one reading at `now` (epoch seconds): projected is | ||
| // the share of the header's limit left at the reset, at the window's burn | ||
| // rate so far, clamped to 0..1. | ||
| export function paceDecision({ limit, remaining, reset } = {}, share, now) { | ||
| const left = reset - now; | ||
| if (![limit, remaining, reset].every(Number.isFinite) || limit <= 0 || remaining < 0 || remaining > limit || left < 0 || !(share > 0)) { | ||
| return { paced: false, projected: null }; | ||
| } | ||
| const elapsed = WINDOW_SECONDS - Math.min(left, WINDOW_SECONDS); | ||
| const used = limit - remaining; | ||
| const atReset = elapsed < MIN_ELAPSED_SECONDS ? remaining : remaining - (used * left) / elapsed; | ||
| const projected = Math.max(0, atReset) / limit; | ||
| return { paced: projected < share, projected: round(projected) }; | ||
| } | ||
|
|
||
| export async function main(env = process.env, { fetchImpl = fetch, now = () => Date.now() / 1000 } = {}) { | ||
| let paced = false; | ||
| if (env.EVENT_NAME === "schedule") { | ||
| try { | ||
| const share = apiLimits({ resolvedPath: env.RESOLVED_CONFIG })["github-token"].pace; | ||
|
kyle-sexton marked this conversation as resolved.
Resolved
|
||
| // Counted for the job-end api-calls notice like any guarded call. | ||
| const response = await countRequests(fetchImpl)(`${env.GITHUB_API_URL ?? "https://api.github.com"}/repos/${env.GITHUB_REPOSITORY}`, { | ||
| headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${env.GITHUB_TOKEN}`, "X-GitHub-Api-Version": "2022-11-28" }, | ||
| }); | ||
| const header = (name) => (response.headers.has(name) ? Number(response.headers.get(name)) : Number.NaN); | ||
| const reading = { limit: header("x-ratelimit-limit"), remaining: header("x-ratelimit-remaining"), reset: header("x-ratelimit-reset") }; | ||
| const decision = response.ok ? paceDecision(reading, share, now()) : { paced: false, projected: null }; | ||
| const even = /^[0-9]+$/.test(env.RUN_NUMBER ?? "") && Number(env.RUN_NUMBER) % 2 === 0; | ||
| paced = decision.paced && even; | ||
| const why = paced ? "; this tick skips its work" : decision.paced ? "; an odd tick runs at half cadence" : ""; | ||
| if (decision.projected !== null) { | ||
| console.log(`::notice title=api-pace::github-token projected=${decision.projected} pace=${share} paced=${paced}${why}`); | ||
| } | ||
| } catch (error) { | ||
| console.log(`::warning::the pacer could not read the GITHUB_TOKEN pool (${error.message}); the tick runs`); | ||
| paced = false; | ||
| } | ||
| } | ||
| appendFileSync(env.GITHUB_OUTPUT, `paced=${paced}\n`); | ||
| } | ||
|
|
||
| if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { | ||
| main().catch((error) => { | ||
| console.log(`::warning::the pacer failed (${error.message}); the tick runs`); | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| // The burn-rate pacer's rules (pace.mjs): a timer-driven run is paced when | ||
| // the GITHUB_TOKEN pool's remaining at its reset, projected at the burn rate | ||
| // the window has shown so far, falls under `pace` of the header's limit, the | ||
| // share the breaker is also stated in (pr-pipeline.schema.json api-pool). | ||
| // Expected values are worked by hand from limit, remaining and reset. | ||
| import assert from "node:assert/strict"; | ||
| import { spawnSync } from "node:child_process"; | ||
| import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
| import { test } from "node:test"; | ||
|
|
||
| import { main, paceDecision } from "./pace.mjs"; | ||
| import { apiLimits } from "./rate-guard.mjs"; | ||
| import { tempDir } from "../../../lib/test-tmp.mjs"; | ||
|
|
||
| const NOW = 1_800_000_000; | ||
|
|
||
| test("the pace share is a config value: rate-limits.json's github-token default, under limits.api", () => { | ||
| assert.equal(apiLimits({ resolvedPath: "" })["github-token"].pace, 0.2); | ||
| const dir = tempDir("pace-config-"); | ||
| const resolved = join(dir, "resolved.json"); | ||
| writeFileSync(resolved, JSON.stringify({ limits: { api: { "github-token": { pace: 0.35 } } } })); | ||
| assert.equal(apiLimits({ resolvedPath: resolved })["github-token"].pace, 0.35); | ||
| // The schema lets limits.api set it, in the same range as the breaker. | ||
| const schema = JSON.parse(readFileSync(new URL("../../../docs/conventions/pr-pipeline/pr-pipeline.schema.json", import.meta.url), "utf8")); | ||
| assert.deepEqual( | ||
| { ...schema.$defs["api-pool"].properties.pace, description: undefined }, | ||
| { ...schema.$defs["api-pool"].properties.breaker, description: undefined }, | ||
| ); | ||
| }); | ||
|
|
||
| test("a run is paced when the projected remaining at reset falls under the share", () => { | ||
| // 30 minutes in: 2,000 of 5,000 used, so 2,000 more by the reset leaves 1,000, 20%. | ||
| const half = { limit: 5000, remaining: 3000, reset: NOW + 1800 }; | ||
| assert.deepEqual(paceDecision(half, 0.2, NOW), { paced: false, projected: 0.2 }); | ||
| // 2,100 used: 2,100 more leaves 800, 16%. | ||
| assert.deepEqual(paceDecision({ ...half, remaining: 2900 }, 0.2, NOW), { paced: true, projected: 0.16 }); | ||
| // The evidence window's reading: used 4,289, remaining 711, about 4 minutes to reset. | ||
| assert.equal(paceDecision({ limit: 5000, remaining: 711, reset: NOW + 252 }, 0.2, NOW).paced, true); | ||
| // A slow burn early in the window: 100 used in 10 minutes projects 500 more, 4,400 left. | ||
| assert.deepEqual(paceDecision({ limit: 5000, remaining: 4900, reset: NOW + 3000 }, 0.2, NOW), { paced: false, projected: 0.88 }); | ||
| // A projection under zero is clamped to it. | ||
| assert.deepEqual(paceDecision({ limit: 1000, remaining: 100, reset: NOW + 1800 }, 0.2, NOW), { paced: true, projected: 0 }); | ||
| }); | ||
|
|
||
| test("the first minute projects nothing, so only the remaining itself can pace it", () => { | ||
| assert.deepEqual(paceDecision({ limit: 5000, remaining: 4000, reset: NOW + 3590 }, 0.2, NOW), { paced: false, projected: 0.8 }); | ||
| assert.deepEqual(paceDecision({ limit: 5000, remaining: 900, reset: NOW + 3590 }, 0.2, NOW), { paced: true, projected: 0.18 }); | ||
| }); | ||
|
|
||
| test("a reading that cannot project, or a share of 0, never paces", () => { | ||
| for (const reading of [{}, { limit: 0, remaining: 0, reset: NOW + 60 }, { limit: 5000, remaining: "na", reset: NOW + 60 }, { limit: 5000, remaining: 100, reset: NOW - 10 }, { limit: 5000, remaining: 6000, reset: NOW + 60 }]) { | ||
| assert.equal(paceDecision(reading, 0.2, NOW).paced, false, JSON.stringify(reading)); | ||
| } | ||
| assert.equal(paceDecision({ limit: 5000, remaining: 10, reset: NOW + 1800 }, 0, NOW).paced, false); | ||
| }); | ||
|
|
||
| // main's one read: a 200 answer's x-ratelimit headers. | ||
| const answer = (headers, status = 200) => async () => new Response("{}", { status, headers }); | ||
| const run = async (env, fetchImpl) => { | ||
| const dir = tempDir("pace-"); | ||
| const output = join(dir, "output"); | ||
| writeFileSync(output, ""); | ||
| const lines = []; | ||
| const log = console.log; | ||
| console.log = (line) => lines.push(line); | ||
| try { | ||
| await main({ GITHUB_TOKEN: "t", GITHUB_REPOSITORY: "o/r", GITHUB_OUTPUT: output, RUNNER_TEMP: dir, EVENT_NAME: "schedule", RUN_NUMBER: "42", ...env }, { fetchImpl, now: () => NOW }); | ||
| } finally { | ||
| console.log = log; | ||
| } | ||
| return { output: readFileSync(output, "utf8"), lines }; | ||
| }; | ||
|
|
||
| test("on a schedule tick main reads the pool once and writes paced, with a notice", async () => { | ||
| let calls = 0; | ||
| const fetchImpl = async (...args) => { | ||
| calls += 1; | ||
| return answer({ "x-ratelimit-limit": "5000", "x-ratelimit-remaining": "2900", "x-ratelimit-used": "2100", "x-ratelimit-reset": String(NOW + 1800), "x-ratelimit-resource": "core" })(...args); | ||
| }; | ||
| const got = await run({}, fetchImpl); | ||
| assert.equal(calls, 1); | ||
| assert.equal(got.output, "paced=true\n"); | ||
| assert.deepEqual(got.lines, ["::notice title=api-pace::github-token projected=0.16 pace=0.2 paced=true; this tick skips its work"]); | ||
| }); | ||
|
|
||
| // Pacing halves a lane's cadence rather than stopping it: a paced | ||
| // projection still runs the odd-numbered ticks, so the lost-run reconciler | ||
| // keeps re-running lost runs while the pool recovers. | ||
| test("a paced projection skips only the even-numbered ticks", async () => { | ||
| const low = answer({ "x-ratelimit-limit": "5000", "x-ratelimit-remaining": "2900", "x-ratelimit-reset": String(NOW + 1800) }); | ||
| assert.equal((await run({ RUN_NUMBER: "42" }, low)).output, "paced=true\n"); | ||
| const odd = await run({ RUN_NUMBER: "43" }, low); | ||
| assert.equal(odd.output, "paced=false\n"); | ||
| assert.deepEqual(odd.lines, ["::notice title=api-pace::github-token projected=0.16 pace=0.2 paced=false; an odd tick runs at half cadence"]); | ||
| // A run number that is no number runs the tick. | ||
| for (const number of ["", "4x", "-2"]) { | ||
| assert.equal((await run({ RUN_NUMBER: number }, low)).output, "paced=false\n", JSON.stringify(number)); | ||
| } | ||
| }); | ||
|
|
||
| test("any other event, an unreadable answer or a failed read is never paced", async () => { | ||
| let calls = 0; | ||
| const counting = async () => { | ||
| calls += 1; | ||
| return new Response("{}", { status: 200 }); | ||
| }; | ||
| assert.equal((await run({ EVENT_NAME: "workflow_dispatch" }, counting)).output, "paced=false\n"); | ||
| assert.equal((await run({ EVENT_NAME: "push" }, counting)).output, "paced=false\n"); | ||
| assert.equal(calls, 0, "no read off a schedule tick"); | ||
| assert.equal((await run({}, counting)).output, "paced=false\n", "no headers"); | ||
| assert.equal((await run({}, answer({}, 502))).output, "paced=false\n"); | ||
| const throws = async () => { | ||
| throw new Error("down"); | ||
| }; | ||
| const failed = await run({}, throws); | ||
| assert.equal(failed.output, "paced=false\n"); | ||
| assert.match(failed.lines.join("\n"), /::warning::/); | ||
| }); | ||
|
|
||
| // The step that hands the base config's limits.api to the pacer, in | ||
| // resolve-config's output shape, which apiLimits reads. | ||
| const READ_STEP = "Read the pace override from the base config"; | ||
| const READ_RUN = `yq -o=json '{"limits": {"api": (.limits.api // {})}}' .base/docs/conventions/pr-pipeline.yaml >"$RUNNER_TEMP/pace-config.json"`; | ||
|
|
||
| // The documented off switch, limits.api github-token pace: 0 in | ||
| // pr-pipeline.yaml, through that step to main: a pool burning toward the | ||
| // breaker on an even tick runs, with no pace notice. | ||
| test("pace: 0 in the base config turns the pacer off", async () => { | ||
| const dir = tempDir("pace-off-"); | ||
| mkdirSync(join(dir, ".base/docs/conventions"), { recursive: true }); | ||
| const read = (yaml) => { | ||
| writeFileSync(join(dir, ".base/docs/conventions/pr-pipeline.yaml"), yaml); | ||
| const result = spawnSync("bash", ["--noprofile", "--norc", "-eo", "pipefail", "-c", READ_RUN], { cwd: dir, env: { PATH: process.env.PATH, RUNNER_TEMP: dir }, encoding: "utf8" }); | ||
| assert.equal(result.status, 0, result.stderr); | ||
| return join(dir, "pace-config.json"); | ||
| }; | ||
| const low = answer({ "x-ratelimit-limit": "5000", "x-ratelimit-remaining": "2900", "x-ratelimit-reset": String(NOW + 1800) }); | ||
| const off = await run({ RESOLVED_CONFIG: read("limits:\n api:\n github-token:\n pace: 0\n") }, low); | ||
| assert.equal(off.output, "paced=false\n"); | ||
| assert.deepEqual(off.lines, []); | ||
| // With no api entry the rate-limits.json default paces the same reading. | ||
| assert.equal((await run({ RESOLVED_CONFIG: read("limits:\n wip-cap: 12\n") }, low)).output, "paced=true\n"); | ||
| assert.equal(apiLimits({ resolvedPath: read("lanes: {}\n") })["github-token"].pace, 0.2, "no limits section"); | ||
| }); | ||
|
|
||
| // The timer-driven lanes that read the pacer: each reads it once on the | ||
| // event, never fails on it, and gates every step that spends the pool. | ||
| test("each paced timer lane reads the pacer before its work and gates every spending step on it", () => { | ||
| const lanes = { | ||
| "maintenance-reconcile-lost-runs.yml": { job: "reconcile", gated: ["Find lost runs", "Re-run lost runs"] }, | ||
| "pr-unblock-dispatch.yml": { job: "dispatch", gated: ["Install resolve-config's dependencies", "Restore the dispatch record", "Dispatch pr-unblock for conflicting, review-tier Dependabot, failing lanes App and Markdown-changing PRs"] }, | ||
| }; | ||
| for (const [file, { job, gated }] of Object.entries(lanes)) { | ||
| const text = readFileSync(new URL(`../../workflows/${file}`, import.meta.url), "utf8"); | ||
| const block = new RegExp(`\\n ${job}:\\n([\\s\\S]*?)(?=\\n [a-z0-9-]+:\\n|$)`).exec(text)?.[1] ?? ""; | ||
| // Less the blank and comment lines that lead into the next step. | ||
| const steps = block.split(/\n(?= {6}- name: )/).slice(1).map((s) => ({ name: /^ {6}- name: (.*)$/m.exec(s)[1], s: s.replace(/(\n *(#[^\n]*)?)+$/, "") })); | ||
| const at = (name) => steps.findIndex((step) => step.name === name); | ||
| const pace = steps[at("Pace the tick on the GITHUB_TOKEN burn rate")]?.s ?? ""; | ||
| // The base config's limits.api reaches the pacer, so its pace (0 for off) | ||
| // overrides rate-limits.json. | ||
| assert.ok(at(READ_STEP) >= 0 && at(READ_STEP) < at("Pace the tick on the GITHUB_TOKEN burn rate"), `${file}: the override is read before the pacer`); | ||
| assert.equal(steps[at(READ_STEP)].s.replace(/^[\s\S]*?\n {8}run: \|\n {10}/, ""), READ_RUN, file); | ||
| assert.match(steps[at(READ_STEP)].s, /\n {8}continue-on-error: true\n/, file); | ||
| assert.equal(/\n {8}if: (.*)\n/.exec(steps[at(READ_STEP)].s)?.[1], /\n {8}if: (.*)\n/.exec(pace)?.[1], `${file}: the read runs when the pacer does`); | ||
| assert.match(pace, /\n {10}RESOLVED_CONFIG: \$\{\{ runner\.temp \}\}\/pace-config\.json\n/, file); | ||
| assert.match(pace, /\n {8}id: pace\n/, file); | ||
| assert.match(pace, /\n {8}continue-on-error: true\n/, file); | ||
| assert.match(pace, /\n {10}EVENT_NAME: \$\{\{ github\.event_name \}\}\n/, file); | ||
| assert.match(pace, /\n {10}RUN_NUMBER: \$\{\{ github\.run_number \}\}\n/, file); | ||
| assert.match(pace, /\n {8}run: node \.base\/\.github\/actions\/log-api-budget\/pace\.mjs\n?$/, file); | ||
| for (const name of gated) { | ||
| assert.ok(at(name) > at("Pace the tick on the GITHUB_TOKEN burn rate"), `${file}: ${name} follows the pacer`); | ||
| assert.match(steps[at(name)].s, /steps\.pace\.outputs\.paced != 'true'/, `${file}: ${name}`); | ||
| } | ||
| } | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.