Skip to content

Commit 7512639

Browse files
committed
feat: add ncu-ci workload
Count running and queued PR CI jobs for workload threshold checks. Include PR builds waiting for downstream tests and count requests only once when they move from the queue to an executor between reads. Signed-off-by: Filip Skokan <panva.ip@gmail.com> Assisted-by: Codex
1 parent 03f7881 commit 7512639

5 files changed

Lines changed: 449 additions & 9 deletions

File tree

‎bin/ncu-ci.js‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from '../lib/ci/run_ci.js';
2626
import { ResumePRJob } from '../lib/ci/resume_ci.js';
2727
import { checkAvailability } from '../lib/ci/availability.js';
28+
import { getPRWorkload } from '../lib/ci/workload.js';
2829
import { writeJson, writeFile } from '../lib/file.js';
2930
import { getMergedConfig } from '../lib/config.js';
3031
import { runPromise } from '../lib/run.js';
@@ -60,6 +61,11 @@ const args = yargs(hideBin(process.argv))
6061
desc: 'Check whether Jenkins is available for PR CI requests',
6162
handler
6263
})
64+
.command({
65+
command: 'workload',
66+
desc: 'Print the number of running and queued node-test-pull-request jobs',
67+
handler
68+
})
6369
.command({
6470
command: 'rate <type>',
6571
desc: 'Calculate the green rate of a CI job in the last 100 runs',
@@ -604,7 +610,7 @@ class DailyCommand extends CICommand {
604610
}
605611
}
606612

607-
async function checkJenkins() {
613+
async function checkJenkins(command) {
608614
try {
609615
let jenkins;
610616
try {
@@ -614,16 +620,23 @@ async function checkJenkins() {
614620
throw new Error('Configure username and jenkins_token with ncu-config');
615621
}
616622
const request = new Request({ jenkins });
617-
await checkAvailability(request);
623+
if (command === 'available') {
624+
await checkAvailability(request);
625+
} else {
626+
console.log(await getPRWorkload(request));
627+
}
618628
} catch (err) {
619-
console.error(`Unable to check Jenkins availability: ${err.message}`);
629+
const action = command === 'available'
630+
? 'check Jenkins availability'
631+
: 'read the PR CI workload';
632+
console.error(`Unable to ${action}: ${err.message}`);
620633
process.exitCode = 1;
621634
}
622635
}
623636

624637
async function main(command, argv) {
625-
if (command === 'available') {
626-
return checkJenkins();
638+
if (command === 'available' || command === 'workload') {
639+
return checkJenkins(command);
627640
}
628641
const cli = new CLI();
629642
const credentials = await auth({

‎docs/ncu-ci.md‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ ncu-ci <command>
1414
1515
Commands:
1616
ncu-ci available Check whether Jenkins is available for PR CI requests
17+
ncu-ci workload Print the number of running and queued node-test-pull-request jobs
1718
ncu-ci rate <type> Calculate the green rate of a CI job in the last 100
1819
runs
1920
ncu-ci walk <type> Walk the CI and display the failures
@@ -48,14 +49,30 @@ The command only reads Jenkins state. It does not start or resume a build, check
4849
individual PRs, or require idle executors. Availability can change after the
4950
check.
5051

51-
The command uses the configured `username` and `jenkins_token`; no GitHub token,
52-
repository configuration, or PR argument is required. It has a 20-second deadline
53-
for its Jenkins requests, including response bodies.
52+
### `ncu-ci workload`
5453

55-
For example, skip processing requests unless Jenkins is available:
54+
`ncu-ci workload` prints the number of running and queued `node-test-pull-request`
55+
jobs, followed by a newline. This includes PR builds waiting for downstream tests.
56+
Each PR build counts once; downstream test jobs do not add to the count. If no PR
57+
builds are running or queued, it prints `0`. A failed query exits with status 1,
58+
reports the reason on stderr, and does not print a count.
59+
60+
The command reads Jenkins executors and the waiting queue, so older active builds
61+
are counted without relying on a limited build history. A request that starts
62+
between the two reads is counted once. The count is a snapshot and can change
63+
before a new request starts.
64+
65+
Both commands use the configured `username` and `jenkins_token`; no GitHub token,
66+
repository configuration, or PR argument is required. Each command has a
67+
20-second deadline for its Jenkins requests, including response bodies.
68+
69+
For example, skip processing requests unless Jenkins is available and fewer than
70+
five PR jobs are running or queued:
5671

5772
```sh
5873
ncu-ci available || exit 0
74+
workload=$(ncu-ci workload) || exit 0
75+
[ "$workload" -lt 5 ] || exit 0
5976

6077
# Process requests here.
6178
```

‎lib/ci/workload.js‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { CI_DOMAIN, CI_TYPES, CI_TYPES_KEYS } from './ci_type_parser.js';
2+
import { readJenkinsJSON } from './jenkins.js';
3+
4+
const PR_JOB_URL = `https://${CI_DOMAIN}/job/${CI_TYPES.get(CI_TYPES_KEYS.PR).jobName}`;
5+
6+
export async function getPRWorkload(request) {
7+
const signal = AbortSignal.timeout(20_000);
8+
const data = await readJenkinsJSON(request, '/queue/api/json?tree=items[id,task[url]]', signal);
9+
if (!Array.isArray(data?.items)) {
10+
throw new Error('Jenkins returned an invalid queue');
11+
}
12+
13+
const queued = new Set();
14+
for (const item of data.items) {
15+
if (!item || typeof item !== 'object' || Array.isArray(item) ||
16+
!item.task || typeof item.task !== 'object' || Array.isArray(item.task)) {
17+
throw new Error('Jenkins returned an invalid queue item');
18+
}
19+
const { url } = item.task;
20+
// Some Jenkins task types do not export a URL.
21+
if (url === undefined || url === null) continue;
22+
if (typeof url !== 'string') {
23+
throw new Error('Jenkins returned an invalid queue task URL');
24+
}
25+
if (url === PR_JOB_URL || url === `${PR_JOB_URL}/`) {
26+
if (!Number.isSafeInteger(item.id) || item.id < 0) {
27+
throw new Error('Jenkins returned an invalid queue item ID');
28+
}
29+
queued.add(item.id);
30+
}
31+
}
32+
33+
// PR multijobs stay on an executor while waiting for downstream tests. The
34+
// waiting queue alone misses those builds, and build history can be truncated.
35+
const computers = await readJenkinsJSON(request,
36+
'/computer/api/json?tree=computer[executors[currentExecutable[url,queueId]],' +
37+
'oneOffExecutors[currentExecutable[url,queueId]]]', signal);
38+
if (!Array.isArray(computers?.computer)) {
39+
throw new Error('Jenkins returned invalid executor data');
40+
}
41+
const running = new Set();
42+
for (const computer of computers.computer) {
43+
if (!Array.isArray(computer?.executors) || !Array.isArray(computer?.oneOffExecutors)) {
44+
throw new Error('Jenkins returned invalid executor lists');
45+
}
46+
for (const executor of [...computer.executors, ...computer.oneOffExecutors]) {
47+
if (!executor || typeof executor !== 'object' || Array.isArray(executor)) {
48+
throw new Error('Jenkins returned an invalid executor');
49+
}
50+
const build = executor.currentExecutable;
51+
if (build === undefined || build === null) continue;
52+
if (typeof build !== 'object' || Array.isArray(build)) {
53+
throw new Error('Jenkins returned an invalid executable');
54+
}
55+
const { url, queueId } = build;
56+
if (url === undefined || url === null) continue;
57+
if (typeof url !== 'string') {
58+
throw new Error('Jenkins returned an invalid executable URL');
59+
}
60+
if (!url.startsWith(`${PR_JOB_URL}/`)) continue;
61+
const match = /^([1-9]\d*)\/?$/.exec(url.slice(PR_JOB_URL.length + 1));
62+
if (!match) continue;
63+
if (!Number.isSafeInteger(queueId)) {
64+
throw new Error('Jenkins returned an invalid executable queue ID');
65+
}
66+
running.add(match[1]);
67+
// A queued request can start between the two reads. Count it only once.
68+
queued.delete(queueId);
69+
}
70+
}
71+
return queued.size + running.size;
72+
}

‎test/unit/ci_preflight_cli.test.js‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,28 @@ import { fileURLToPath } from 'node:url';
99
const binaryURL = new URL('../../bin/ncu-ci.js', import.meta.url);
1010
const requestURL = new URL('../../lib/request.js', import.meta.url);
1111
const undiciURL = import.meta.resolve('undici');
12+
const jobURL = 'https://ci.nodejs.org/job/node-test-pull-request/';
1213
const root = (data, extra = {}) => ({
1314
path: '/api/json', tree: 'quietingDown', data, ...extra
1415
});
1516
const job = (data, extra = {}) => ({
1617
path: '/job/node-test-pull-request/api/json', tree: 'disabled,buildable', data, ...extra
1718
});
19+
const queue = (data, extra = {}) => ({
20+
path: '/queue/api/json', tree: 'items[id,task[url]]', data, ...extra
21+
});
22+
const computers = (data, extra = {}) => ({
23+
path: '/computer/api/json',
24+
tree: 'computer[executors[currentExecutable[url,queueId]],' +
25+
'oneOffExecutors[currentExecutable[url,queueId]]]',
26+
data,
27+
...extra
28+
});
29+
const idleComputers = () => computers({ computer: [] });
30+
const running = (number, queueId = number) => ({
31+
currentExecutable: { url: `${jobURL}${number}/`, queueId }
32+
});
33+
1834
function run(t, command, responses, credentials = {
1935
username: 'test', jenkins_token: 'test-jenkins-token'
2036
}) {
@@ -169,3 +185,132 @@ describe('ncu-ci available', () => {
169185
assert.doesNotMatch(result.stdout + result.stderr, /secret-invalid-token/);
170186
});
171187
});
188+
189+
describe('ncu-ci workload', () => {
190+
it('prints only the number of unfinished and queued PR jobs', (t) => {
191+
const result = run(t, 'workload', [queue({
192+
items: [
193+
{ id: 1, task: { url: jobURL } },
194+
{ task: { url: 'https://ci.nodejs.org/job/node-test-commit/' } },
195+
{ task: { url: `${jobURL}123/` } },
196+
{ task: { url: 'https://example.org/job/node-test-pull-request/' } },
197+
{ task: { url: 'https://ci.nodejs.org/job/node-test-pull-request-other/' } },
198+
{ id: 2, task: { url: jobURL.slice(0, -1) } },
199+
{ task: {} },
200+
{ task: { url: null } },
201+
{ id: 3, task: { url: jobURL } }
202+
]
203+
}), idleComputers()]);
204+
assert.equal(result.status, 0, result.stderr);
205+
assert.equal(result.stdout, '3\n');
206+
assert.equal(result.stderr, '');
207+
});
208+
209+
it('includes all 20 working PR builds when the waiting queue is empty', (t) => {
210+
const result = run(t, 'workload', [queue({ items: [] }), computers({
211+
computer: [{
212+
executors: Array.from({ length: 3 }, (_, i) => running(i + 1)),
213+
oneOffExecutors: Array.from({ length: 17 }, (_, i) => running(i + 4))
214+
}]
215+
})]);
216+
assert.equal(result.status, 0, result.stderr);
217+
assert.equal(result.stdout, '20\n');
218+
assert.equal(result.stderr, '');
219+
assert.equal(result.trace.jsonReads, 2);
220+
});
221+
222+
it('combines queued and working PR builds without double counting transitions', (t) => {
223+
const result = run(t, 'workload', [queue({
224+
items: [
225+
{ id: 10, task: { url: jobURL } },
226+
{ id: 11, task: { url: jobURL } },
227+
{ id: 12, task: { url: jobURL } }
228+
]
229+
}), computers({
230+
computer: [{
231+
executors: [running(101, 10), running(102, 20), { currentExecutable: null }, {}],
232+
oneOffExecutors: [
233+
running(102, 20),
234+
{ currentExecutable: { url: `${jobURL}102`, queueId: 20 } },
235+
{ currentExecutable: { url: 'https://ci.nodejs.org/job/node-test-commit/1/' } },
236+
{ currentExecutable: { url: 'https://example.org/job/node-test-pull-request/1/' } },
237+
{ currentExecutable: {} }
238+
]
239+
}]
240+
})]);
241+
assert.equal(result.status, 0, result.stderr);
242+
assert.equal(result.stdout, '4\n');
243+
assert.equal(result.stderr, '');
244+
});
245+
246+
for (const items of [[], [{ task: { url: 'https://ci.nodejs.org/job/node-test-commit/' } }]]) {
247+
it(`prints zero for a valid queue with no PR jobs: ${JSON.stringify(items)}`, (t) => {
248+
const result = run(t, 'workload', [queue({ items }), idleComputers()]);
249+
assert.equal(result.status, 0, result.stderr);
250+
assert.equal(result.stdout, '0\n');
251+
assert.equal(result.stderr, '');
252+
});
253+
}
254+
255+
for (const data of [null, {}, { items: null }, { items: {} }]) {
256+
it(`fails without a misleading count for invalid queue data: ${JSON.stringify(data)}`, (t) => {
257+
assertFailure(run(t, 'workload', [queue(data)]));
258+
});
259+
}
260+
261+
for (const item of [null, {}, { task: null }, { task: false }, { task: { url: 123 } }]) {
262+
it(`fails for a malformed queue item: ${JSON.stringify(item)}`, (t) => {
263+
assertFailure(run(t, 'workload', [queue({ items: [item] })]));
264+
});
265+
}
266+
267+
for (const id of [undefined, null, -1, 1.5, '1', Number.MAX_SAFE_INTEGER + 1]) {
268+
it(`rejects a queued PR item with invalid ID: ${JSON.stringify(id)}`, (t) => {
269+
assertFailure(run(t, 'workload', [queue({ items: [{ id, task: { url: jobURL } }] })]));
270+
});
271+
}
272+
273+
for (const data of [
274+
null,
275+
{},
276+
{ computer: null },
277+
{ computer: {} },
278+
{ computer: [{ executors: {}, oneOffExecutors: [] }] },
279+
{ computer: [{ executors: [], oneOffExecutors: null }] },
280+
{ computer: [{ executors: [{ currentExecutable: false }], oneOffExecutors: [] }] },
281+
{ computer: [{ executors: [], oneOffExecutors: [{ currentExecutable: { url: 1 } }] }] }
282+
]) {
283+
it(`fails without a partial count for invalid executor data: ${JSON.stringify(data)}`, (t) => {
284+
assertFailure(run(t, 'workload', [
285+
queue({ items: [{ id: 1, task: { url: jobURL } }] }), computers(data)
286+
]));
287+
});
288+
}
289+
290+
it('reports executor API failure without printing the already counted queue', (t) => {
291+
const result = run(t, 'workload', [
292+
queue({ items: [{ id: 1, task: { url: jobURL } }] }),
293+
computers(null, { status: 503, statusText: 'Service Unavailable' })
294+
]);
295+
assertFailure(result, /503/);
296+
assert.equal(result.trace.jsonReads, 1);
297+
assert.equal(result.trace.cancellations, 1);
298+
});
299+
300+
it('reports HTTP failures instead of printing zero', (t) => {
301+
const result = run(t, 'workload', [queue(null, { status: 403, statusText: 'Forbidden' })]);
302+
assertFailure(result, /403/);
303+
assert.equal(result.trace.jsonReads, 0);
304+
assert.equal(result.trace.cancellations, 1);
305+
});
306+
307+
it('reports malformed JSON instead of printing zero', (t) => {
308+
assertFailure(run(t, 'workload', [queue(null, { jsonError: 'Invalid queue JSON' })]),
309+
/Jenkins returned invalid JSON/);
310+
});
311+
312+
it('reports a network failure instead of printing zero', (t) => {
313+
assertFailure(run(t, 'workload', [queue(null, { error: 'Connection reset' })]),
314+
/Connection reset/);
315+
});
316+
});

0 commit comments

Comments
 (0)