@andrian.yablonskyy/thub-coordinator 1.0.16 → 1.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/services/jobs.js +13 -2
- package/src/services/registry.js +10 -8
- package/src/services/scheduler.js +15 -8
- package/src/web/routes.js +1 -0
- package/test/scheduler.test.js +71 -1
- package/views/jobs/show.pug +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-coordinator",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.17",
|
|
4
4
|
"description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"engines": {
|
package/src/services/jobs.js
CHANGED
|
@@ -91,11 +91,22 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
|
91
91
|
}
|
|
92
92
|
spec.source = source;
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
// `target.client` (thub run --client) accepts a resource id or name;
|
|
95
|
+
// store the id so the job stays pinned to the same Client across renames.
|
|
96
|
+
if (spec.target.client){
|
|
97
|
+
const resource = registry.get(spec.target.client) || registry.getByName(spec.target.client);
|
|
98
|
+
if (!resource){
|
|
99
|
+
throw Object.assign(new Error(`Unknown client "${spec.target.client}"`), { status: 422 });
|
|
100
|
+
}
|
|
101
|
+
spec.target.client = resource.id;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!registry.everSatisfiable(spec.target.type, spec.target.labels, spec.target.group, spec.target.client)){
|
|
95
105
|
throw Object.assign(
|
|
96
106
|
new Error(
|
|
97
107
|
`No registered resource can ever satisfy type=${spec.target.type} labels=${spec.target.labels.join(',')}` +
|
|
98
|
-
(spec.target.group ? ` group=${spec.target.group}` : '')
|
|
108
|
+
(spec.target.group ? ` group=${spec.target.group}` : '') +
|
|
109
|
+
(spec.target.client ? ` client=${spec.target.client}` : '')
|
|
99
110
|
),
|
|
100
111
|
{ status: 422 }
|
|
101
112
|
);
|
package/src/services/registry.js
CHANGED
|
@@ -468,21 +468,23 @@ function createRegistryService(db, { bus, events }){
|
|
|
468
468
|
);
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
-
function
|
|
471
|
+
function matchesTarget(r, labels, groupId, resourceId){
|
|
472
|
+
return labels.every((l) => r.labels.includes(l)) &&
|
|
473
|
+
(!groupId || r.group_ids.includes(groupId)) &&
|
|
474
|
+
(!resourceId || r.id === resourceId);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function findIdleCandidates(type, labels, groupId, resourceId){
|
|
472
478
|
const rows = db
|
|
473
479
|
.prepare('SELECT * FROM resources WHERE status = ? AND type = ?')
|
|
474
480
|
.all(RESOURCE_STATES.IDLE, type)
|
|
475
481
|
.map(rowToResource);
|
|
476
|
-
return rows.filter(
|
|
477
|
-
(r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
|
|
478
|
-
);
|
|
482
|
+
return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
479
483
|
}
|
|
480
484
|
|
|
481
|
-
function everSatisfiable(type, labels, groupId){
|
|
485
|
+
function everSatisfiable(type, labels, groupId, resourceId){
|
|
482
486
|
const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
|
|
483
|
-
return rows.some(
|
|
484
|
-
(r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
|
|
485
|
-
);
|
|
487
|
+
return rows.some((r) => matchesTarget(r, labels, groupId, resourceId));
|
|
486
488
|
}
|
|
487
489
|
|
|
488
490
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/src/services/scheduler.js
|
|
3
|
-
* @description Scheduler: matches queued jobs to idle resources by type/labels/group (README §5.4)
|
|
3
|
+
* @description Scheduler: matches queued jobs to idle resources by type/labels/group/client (README §5.4)
|
|
4
4
|
*
|
|
5
5
|
* @author Andrian Yablonskyy
|
|
6
6
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -34,21 +34,28 @@ function createScheduler(db, { bus, events, registry, config }){
|
|
|
34
34
|
.map(rowToJob),
|
|
35
35
|
|
|
36
36
|
assignments = [],
|
|
37
|
-
claimedResourceIds = new Set()
|
|
37
|
+
claimedResourceIds = new Set(),
|
|
38
|
+
// Resources that some queued job is pinned to (`target.client`) —
|
|
39
|
+
// an unpinned job only takes one of these when nothing else fits,
|
|
40
|
+
// so it doesn't jump the pinned job's per-client queue.
|
|
41
|
+
reservedResourceIds = new Set(queued.map((j) => j.spec.target.client).filter(Boolean));
|
|
38
42
|
|
|
39
43
|
for (const job of queued){
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
const { type, labels = [], group, client } = job.spec.target,
|
|
45
|
+
candidates = registry
|
|
46
|
+
.findIdleCandidates(type, labels, group, client)
|
|
47
|
+
.filter((r) => !claimedResourceIds.has(r.id));
|
|
43
48
|
if (candidates.length === 0){
|
|
44
49
|
continue;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
//
|
|
52
|
+
// Unreserved first, then least-recently-used: spread wear across benches (§5.4).
|
|
48
53
|
candidates.sort((a, b) => {
|
|
49
|
-
const
|
|
54
|
+
const ra = reservedResourceIds.has(a.id) ? 1 : 0,
|
|
55
|
+
rb = reservedResourceIds.has(b.id) ? 1 : 0,
|
|
56
|
+
ta = a.last_job_finished_at ? new Date(a.last_job_finished_at).getTime() : 0,
|
|
50
57
|
tb = b.last_job_finished_at ? new Date(b.last_job_finished_at).getTime() : 0;
|
|
51
|
-
return ta - tb;
|
|
58
|
+
return ra - rb || ta - tb;
|
|
52
59
|
});
|
|
53
60
|
const resource = candidates[0];
|
|
54
61
|
claimedResourceIds.add(resource.id);
|
package/src/web/routes.js
CHANGED
|
@@ -433,6 +433,7 @@ function createWebRouter({ services, config }){
|
|
|
433
433
|
title: job.id,
|
|
434
434
|
active: 'jobs',
|
|
435
435
|
job,
|
|
436
|
+
pinnedClient: job.spec.target.client ? services.registry.get(job.spec.target.client) : null,
|
|
436
437
|
artifacts,
|
|
437
438
|
jobActive,
|
|
438
439
|
canCancel: req.session.user.role === 'admin' && jobActive
|
package/test/scheduler.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file packages/coordinator/test/scheduler.test.js
|
|
3
|
-
* @description Tests: scheduler, registry, groups, job id allocation, and admin queue operations
|
|
3
|
+
* @description Tests: scheduler, registry, groups, client pinning, job id allocation, and admin queue operations
|
|
4
4
|
*
|
|
5
5
|
* @author Andrian Yablonskyy
|
|
6
6
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -349,6 +349,76 @@ test('a job with target.group is rejected (422) when no resource is ever a membe
|
|
|
349
349
|
);
|
|
350
350
|
});
|
|
351
351
|
|
|
352
|
+
test('a job with target.client (by name) waits for that client even when another matching one is idle', () => {
|
|
353
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
354
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
355
|
+
pinned = registerResource(registry, { name: 'lab-pinned', type: 'sw' }),
|
|
356
|
+
other = registerResource(registry, { name: 'lab-other', type: 'sw' });
|
|
357
|
+
registry.heartbeat(other.id, { state: 'idle' }); // pinned is still REGISTERED, not IDLE
|
|
358
|
+
|
|
359
|
+
const job = jobs.create({
|
|
360
|
+
agentId: agent.id,
|
|
361
|
+
source: 'cli',
|
|
362
|
+
spec: makeSpec({ target: { type: 'sw', labels: [], client: 'lab-pinned' } })
|
|
363
|
+
});
|
|
364
|
+
assert.equal(job.spec.target.client, pinned.id); // name resolved to the stable resource id
|
|
365
|
+
|
|
366
|
+
scheduler.runPass();
|
|
367
|
+
assert.equal(jobs.get(job.id).state, JOB_STATES.QUEUED); // not grabbed by lab-other
|
|
368
|
+
|
|
369
|
+
registry.heartbeat(pinned.id, { state: 'idle' });
|
|
370
|
+
scheduler.runPass();
|
|
371
|
+
const assigned = jobs.get(job.id);
|
|
372
|
+
assert.equal(assigned.state, JOB_STATES.ASSIGNED);
|
|
373
|
+
assert.equal(assigned.resource_id, pinned.id);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('an unpinned job prefers a resource no queued job is pinned to', () => {
|
|
377
|
+
const { registry, agents, jobs, scheduler } = buildTestServices(),
|
|
378
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' }),
|
|
379
|
+
pinned = registerResource(registry, { name: 'lab-pinned', type: 'sw' }),
|
|
380
|
+
other = registerResource(registry, { name: 'lab-other', type: 'sw' });
|
|
381
|
+
registry.heartbeat(pinned.id, { state: 'idle' });
|
|
382
|
+
registry.heartbeat(other.id, { state: 'idle' });
|
|
383
|
+
|
|
384
|
+
// Unpinned job has higher priority, so it's considered first; it must not
|
|
385
|
+
// take lab-pinned (which is also least-recently-used) away from the pinned job.
|
|
386
|
+
const unpinned = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ priority: 90 }) }),
|
|
387
|
+
pinnedJob = jobs.create({
|
|
388
|
+
agentId: agent.id,
|
|
389
|
+
source: 'cli',
|
|
390
|
+
spec: makeSpec({ target: { type: 'sw', labels: [], client: pinned.id }, priority: 10 })
|
|
391
|
+
});
|
|
392
|
+
scheduler.runPass();
|
|
393
|
+
|
|
394
|
+
assert.equal(jobs.get(unpinned.id).resource_id, other.id);
|
|
395
|
+
assert.equal(jobs.get(pinnedJob.id).resource_id, pinned.id);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('a job with target.client is rejected (422) when the client is unknown or cannot satisfy the target', () => {
|
|
399
|
+
const { registry, agents, jobs } = buildTestServices(),
|
|
400
|
+
{ agent } = agents.create({ name: 'ci', kind: 'ci' });
|
|
401
|
+
registerResource(registry, { name: 'lab-sw', type: 'sw', labels: ['board:a'] });
|
|
402
|
+
|
|
403
|
+
assert.throws(
|
|
404
|
+
() => jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', client: 'nope' } }) }),
|
|
405
|
+
/Unknown client "nope"/
|
|
406
|
+
);
|
|
407
|
+
assert.throws(
|
|
408
|
+
() => jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'hw', client: 'lab-sw' } }) }),
|
|
409
|
+
/No registered resource/
|
|
410
|
+
);
|
|
411
|
+
assert.throws(
|
|
412
|
+
() =>
|
|
413
|
+
jobs.create({
|
|
414
|
+
agentId: agent.id,
|
|
415
|
+
source: 'cli',
|
|
416
|
+
spec: makeSpec({ target: { type: 'sw', labels: ['board:b'], client: 'lab-sw' } })
|
|
417
|
+
}),
|
|
418
|
+
/No registered resource/
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
352
422
|
test('a resource can belong to several groups at once', () => {
|
|
353
423
|
const { registry, groups } = buildTestServices(),
|
|
354
424
|
g1 = groups.create({ name: 'g1' }),
|
package/views/jobs/show.pug
CHANGED
|
@@ -22,6 +22,9 @@ block content
|
|
|
22
22
|
dd.col-sm-10= job.resource ? job.resource.name : '—'
|
|
23
23
|
dt.col-sm-2 Target
|
|
24
24
|
dd.col-sm-10= `${job.spec.target.type} — ${(job.spec.target.labels || []).join(', ')}`
|
|
25
|
+
if job.spec.target.client
|
|
26
|
+
dt.col-sm-2 Client
|
|
27
|
+
dd.col-sm-10= pinnedClient ? pinnedClient.name : job.spec.target.client
|
|
25
28
|
dt.col-sm-2 Created
|
|
26
29
|
dd.col-sm-10= fmtDate(job.created_at)
|
|
27
30
|
if job.message
|