Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Save this as the org variable `ROUTACT_CONFIG` (Settings → Secrets and variabl
}
```

Each pool has `primary` labels, `fallback` labels, and optionally `primaries-required` (see [Inputs](#inputs)). Labels can be a comma-separated string or an array.
Each pool has `primary` labels, `fallback` labels, and optionally `primaries-required` (see [Inputs](#inputs)) or `heartbeat` (see [Runners that register on demand](#runners-that-register-on-demand)). Labels can be a comma-separated string or an array.

### 3. Add the route job to a workflow

Expand Down Expand Up @@ -164,6 +164,25 @@ The check reads the organization's [billing usage summary](https://docs.github.c
- GitHub updates billing usage with a delay, so a few jobs can still start on paid minutes right after the included minutes run out.
- The check counts the whole organization. Public repositories use GitHub-hosted runners for free, so do not route them through a pool that ends on a self-hosted runner.

### Runners that register on demand

Some autoscalers register a runner only when a matching job is queued, or keep a single warm runner that is busy while it works and gone for a few seconds after each job. Routact cannot see those runners when it routes, so `primaries-required` falls back even though the host would take the job. Use `heartbeat` instead: labels of a runner that only shows the host is up. While any online runner has those labels, busy or not, the pool uses its primary, and the autoscaler starts a runner for the queued job.

```json
"xcode": {
"primary": "self-hosted,macOS,ARM64,xcode",
"heartbeat": "self-hosted,kinhin-container",
"fallback": [
{ "runs-on": "blacksmith-6vcpu-macos-26", "when": { "var": "ROUTACT_BLACKSMITH_ENABLED" } },
{ "runs-on": "self-hosted,macOS,ARM64,xcode" }
]
}
```

- A heartbeat pool cannot set `primaries-required` or use `claim`, because it does not count idle runners.
- While no heartbeat runner is online, for example in the seconds between two ephemeral runners, the pool falls back. Pick a heartbeat runner that the autoscaler keeps registered.
- The heartbeat runner proves the host is up, not that it can start the primary. A host that is up but cannot start a matching runner leaves the job queued.

### Where the route job runs

The reusable workflow runs the route job on GitHub's `ubuntu-slim`. When a private repository's GitHub-hosted minutes are used up and the spending limit is reached, GitHub does not start that job, so nothing can be routed. To route from a runner that does not use GitHub's minutes, set the org variable `ROUTACT_ROUTER_RUNS_ON` (or the `runs-on` input) to a JSON label or label array:
Expand Down
10 changes: 10 additions & 0 deletions __tests__/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ describe('parseConfig', () => {
]);
});

it('parses a heartbeat', () => {
const pool = parseConfig('{"mac":{"primary":"self-hosted,macOS","heartbeat":"self-hosted,kinhin-container","fallback":"macos-26"}}').pools[0];
expect(pool.heartbeat).toEqual(['self-hosted', 'kinhin-container']);
});

it('rejects a heartbeat with primaries-required', () => {
expect(() => parseConfig('{"mac":{"primary":"a","heartbeat":"b","primaries-required":1,"fallback":"c"}}'))
.toThrow(/both heartbeat and primaries-required/);
});

it('accepts a bare object of pools', () => {
expect(parseConfig('{"x":{"primary":"a","fallback":"b"}}').pools[0].name).toBe('x');
});
Expand Down
33 changes: 33 additions & 0 deletions __tests__/route.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,39 @@ describe('decide', () => {
});
});

describe('decide with a heartbeat', () => {
const xcode = (overrides = {}) => linux({
name: 'xcode',
primary: ['self-hosted', 'macOS', 'ARM64', 'xcode'],
fallback: ['blacksmith-6vcpu-macos-26'],
heartbeat: ['self-hosted', 'kinhin-container'],
...overrides,
});
const beat = (id, options) => runner(id, ['self-hosted', 'linux', 'ARM64', 'kinhin-container'], options);

it('uses the primary while a heartbeat runner is online, though no primary is registered', () => {
const decision = decide(xcode(), [beat(1)]);
expect(decision.usePrimary).toBe(true);
expect(decision.reason).toBe('1 heartbeat runner (self-hosted,kinhin-container) online');
});

it('counts a busy heartbeat runner', () => {
expect(decide(xcode(), [beat(1, { busy: true })]).usePrimary).toBe(true);
});

it('falls back when no heartbeat runner is online', () => {
const decision = decide(xcode(), [beat(1, { status: 'offline' }), selfHosted(2)]);
expect(decision.usePrimary).toBe(false);
expect(decision.reason).toBe('no heartbeat runner (self-hosted,kinhin-container) is online');
});

it('refuses to claim a runner for a heartbeat pool', async () => {
const client = new FakeClient([beat(1)]);
await expect(routePools({ client, claim: true, pools: [xcode({ fallback: ['self-hosted', 'macOS', 'ARM64'] })] }))
.rejects.toThrow(/heartbeat/);
});
});

describe('checkPlatform', () => {
it('throws when the fallback is a different platform', () => {
const pool = linux({ primary: ['self-hosted', 'macOS', 'ARM64'], fallback: ['ubuntu-latest'] });
Expand Down
29 changes: 29 additions & 0 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,14 @@ function parsePool(name, value) {
primary: parseLabels(value.primary),
fallbacks: parseFallbacks(value.fallback, `Pool "${name}"`),
primariesRequired: parseCount(value['primaries-required'], `Pool "${name}" primaries-required`),
heartbeat: parseLabels(value.heartbeat),
};
if (pool.primary.length === 0) throw new ConfigError(`Pool "${name}" has no primary labels`);
if (pool.heartbeat.length === 0) {
delete pool.heartbeat;
} else if (pool.primariesRequired !== undefined) {
throw new ConfigError(`Pool "${name}" sets both heartbeat and primaries-required; a heartbeat pool does not count idle runners`);
}
return pool;
}

Expand Down
23 changes: 23 additions & 0 deletions src/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,26 @@ async function fallbackResult(pool, reason, options) {
};
}

// A heartbeat pool's primary runners register only when a job is queued, so
// any online runner with the heartbeat labels, busy or not, shows the host is up
function decideHeartbeat(pool, runners) {
const alive = runners.filter((runner) => runner.status === 'online' && hasLabels(runner, pool.heartbeat));
const labels = pool.heartbeat.join(',');
if (alive.length === 0) {
return { usePrimary: false, matching: [], idle: [], reason: `no heartbeat runner (${labels}) is online` };
}
const matching = runners.filter((runner) => runner.status === 'online' && hasLabels(runner, pool.primary));
return {
usePrimary: true,
matching,
idle: [],
reason: `${alive.length} heartbeat runner${alive.length === 1 ? '' : 's'} (${labels}) online`,
};
}

// Pure decision for one pool given the current runner list
function decide(pool, runners, { claim = false, now = Date.now(), claimTtlMs } = {}) {
if (pool.heartbeat) return decideHeartbeat(pool, runners);
const matching = runners.filter((runner) => runner.status === 'online' && hasLabels(runner, pool.primary));
const idle = matching.filter((runner) => runner.busy !== true
&& (!claim || liveClaims(runner, now, claimTtlMs).length === 0));
Expand Down Expand Up @@ -272,6 +290,11 @@ async function routePools({
checkPlatform(pool, runners, platformCheck).forEach((message) => warn(message));
}

const heartbeatPool = pools.find((pool) => pool.heartbeat);
if (claim && heartbeatPool) {
throw new ConfigError(`Pool "${heartbeatPool.name}" uses a heartbeat, which cannot claim a runner; turn claim off`);
}

if (claim) {
await removeStaleClaims({ client, runners, now, claimTtlMs, log });
}
Expand Down
Loading