@shipfox/api-runners 12.2.0 → 12.4.0
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/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +34 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -3
- package/dist/config.js.map +1 -1
- package/dist/core/demand.d.ts +4 -2
- package/dist/core/demand.d.ts.map +1 -1
- package/dist/core/demand.js +22 -6
- package/dist/core/demand.js.map +1 -1
- package/dist/core/entities/reservation.d.ts +1 -0
- package/dist/core/entities/reservation.d.ts.map +1 -1
- package/dist/core/entities/reservation.js.map +1 -1
- package/dist/core/entities/runner-instance.d.ts +3 -0
- package/dist/core/entities/runner-instance.d.ts.map +1 -1
- package/dist/core/entities/runner-instance.js.map +1 -1
- package/dist/core/runner-activation.d.ts +4 -1
- package/dist/core/runner-activation.d.ts.map +1 -1
- package/dist/core/runner-activation.js +32 -3
- package/dist/core/runner-activation.js.map +1 -1
- package/dist/core/runner-control-sessions.d.ts +8 -4
- package/dist/core/runner-control-sessions.d.ts.map +1 -1
- package/dist/core/runner-control-sessions.js +69 -11
- package/dist/core/runner-control-sessions.js.map +1 -1
- package/dist/core/runner-instances.d.ts +1 -0
- package/dist/core/runner-instances.d.ts.map +1 -1
- package/dist/core/runner-instances.js +1 -0
- package/dist/core/runner-instances.js.map +1 -1
- package/dist/db/db.d.ts +68 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
- package/dist/db/ephemeral-registration-tokens.js +1 -1
- package/dist/db/ephemeral-registration-tokens.js.map +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/reservation-locks.d.ts +6 -0
- package/dist/db/reservation-locks.d.ts.map +1 -0
- package/dist/db/reservation-locks.js +15 -0
- package/dist/db/reservation-locks.js.map +1 -0
- package/dist/db/reservations.d.ts +17 -3
- package/dist/db/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +256 -45
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/runner-assignments.d.ts +17 -0
- package/dist/db/runner-assignments.d.ts.map +1 -1
- package/dist/db/runner-assignments.js +103 -8
- package/dist/db/runner-assignments.js.map +1 -1
- package/dist/db/runner-instances.d.ts +5 -2
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +134 -25
- package/dist/db/runner-instances.js.map +1 -1
- package/dist/db/runner-sessions.d.ts.map +1 -1
- package/dist/db/runner-sessions.js +22 -15
- package/dist/db/runner-sessions.js.map +1 -1
- package/dist/db/runner-states.d.ts +2 -0
- package/dist/db/runner-states.d.ts.map +1 -0
- package/dist/db/runner-states.js +7 -0
- package/dist/db/runner-states.js.map +1 -0
- package/dist/db/schema/reservations.d.ts +18 -0
- package/dist/db/schema/reservations.d.ts.map +1 -1
- package/dist/db/schema/reservations.js +7 -1
- package/dist/db/schema/reservations.js.map +1 -1
- package/dist/db/schema/runner-instances.d.ts +18 -0
- package/dist/db/schema/runner-instances.d.ts.map +1 -1
- package/dist/db/schema/runner-instances.js +8 -0
- package/dist/db/schema/runner-instances.js.map +1 -1
- package/dist/metrics/index.d.ts +2 -2
- package/dist/metrics/index.d.ts.map +1 -1
- package/dist/metrics/index.js +1 -1
- package/dist/metrics/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +24 -2
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +23 -0
- package/dist/metrics/instance.js.map +1 -1
- package/dist/metrics/service.d.ts.map +1 -1
- package/dist/metrics/service.js +20 -4
- package/dist/metrics/service.js.map +1 -1
- package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
- package/dist/presentation/dto/poll-demand.js +3 -0
- package/dist/presentation/dto/poll-demand.js.map +1 -1
- package/dist/presentation/dto/runner-instances.d.ts +4 -1
- package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
- package/dist/presentation/dto/runner-instances.js +4 -1
- package/dist/presentation/dto/runner-instances.js.map +1 -1
- package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
- package/dist/presentation/routes/assign-runner-instances.js +3 -3
- package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
- package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
- package/dist/presentation/routes/poll-demand.js +1 -0
- package/dist/presentation/routes/poll-demand.js.map +1 -1
- package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
- package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
- package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
- package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
- package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
- package/dist/presentation/routes/runner-enrollment.js +11 -6
- package/dist/presentation/routes/runner-enrollment.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0005_previous_lenny_balinger.sql +5 -0
- package/drizzle/meta/0005_snapshot.json +2220 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +4 -4
- package/src/config.test.ts +24 -2
- package/src/config.ts +16 -3
- package/src/core/demand.test.ts +192 -3
- package/src/core/demand.ts +44 -4
- package/src/core/entities/reservation.ts +1 -0
- package/src/core/entities/runner-instance.ts +4 -0
- package/src/core/runner-activation.ts +48 -2
- package/src/core/runner-control-sessions.test.ts +230 -4
- package/src/core/runner-control-sessions.ts +84 -10
- package/src/core/runner-instances.test.ts +19 -0
- package/src/core/runner-instances.ts +2 -0
- package/src/core/runner-sessions.test.ts +121 -1
- package/src/db/ephemeral-registration-tokens.test.ts +20 -1
- package/src/db/ephemeral-registration-tokens.ts +1 -0
- package/src/db/index.ts +1 -0
- package/src/db/reservation-locks.ts +22 -0
- package/src/db/reservations.test.ts +1110 -31
- package/src/db/reservations.ts +434 -105
- package/src/db/runner-assignments.test.ts +105 -1
- package/src/db/runner-assignments.ts +178 -9
- package/src/db/runner-instances.test.ts +496 -2
- package/src/db/runner-instances.ts +247 -56
- package/src/db/runner-sessions.ts +26 -15
- package/src/db/runner-states.ts +7 -0
- package/src/db/schema/reservations.ts +5 -1
- package/src/db/schema/runner-instances.ts +9 -0
- package/src/metrics/index.ts +10 -1
- package/src/metrics/instance.ts +59 -2
- package/src/metrics/service.test.ts +106 -0
- package/src/metrics/service.ts +24 -4
- package/src/presentation/dto/poll-demand.ts +3 -0
- package/src/presentation/dto/runner-instances.ts +8 -1
- package/src/presentation/routes/assign-runner-instances.ts +6 -2
- package/src/presentation/routes/late-runner-enrollment.test.ts +447 -0
- package/src/presentation/routes/poll-demand.test.ts +79 -6
- package/src/presentation/routes/poll-demand.ts +1 -0
- package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
- package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
- package/src/presentation/routes/runner-enrollment.test.ts +316 -3
- package/src/presentation/routes/runner-enrollment.ts +8 -5
- package/test/factories/reservation.ts +2 -0
- package/test/factories/runner-instance.ts +6 -0
- package/test/fixtures/late-runner-enrollment.ts +103 -0
- package/test/index.ts +5 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-runners",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "12.
|
|
4
|
+
"version": "12.4.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -24,20 +24,20 @@
|
|
|
24
24
|
"@shipfox/api-common-dto": "12.0.0",
|
|
25
25
|
"@shipfox/api-auth-context": "12.2.0",
|
|
26
26
|
"@shipfox/api-auth-dto": "12.0.0",
|
|
27
|
-
"@shipfox/api-runners-dto": "12.
|
|
28
|
-
"@shipfox/api-workflows-dto": "12.
|
|
27
|
+
"@shipfox/api-runners-dto": "12.4.0",
|
|
28
|
+
"@shipfox/api-workflows-dto": "12.3.0",
|
|
29
29
|
"@shipfox/config": "1.2.4",
|
|
30
30
|
"@shipfox/inter-module": "0.2.3",
|
|
31
31
|
"@shipfox/node-drizzle": "0.3.5",
|
|
32
32
|
"@shipfox/node-error-monitoring": "0.3.0",
|
|
33
33
|
"@shipfox/node-auth-root-key": "0.2.3",
|
|
34
34
|
"@shipfox/node-fastify": "0.4.2",
|
|
35
|
+
"@shipfox/node-module": "1.0.6",
|
|
35
36
|
"@shipfox/node-opentelemetry": "0.6.4",
|
|
36
37
|
"@shipfox/node-outbox": "0.2.6",
|
|
37
38
|
"@shipfox/node-postgres": "0.5.0",
|
|
38
39
|
"@shipfox/node-rate-limit": "0.4.0",
|
|
39
40
|
"@shipfox/node-temporal": "0.4.5",
|
|
40
|
-
"@shipfox/node-module": "1.0.6",
|
|
41
41
|
"@shipfox/node-tokens": "0.3.2",
|
|
42
42
|
"@shipfox/runner-labels": "0.2.0"
|
|
43
43
|
},
|
package/src/config.test.ts
CHANGED
|
@@ -86,6 +86,28 @@ describe('runner assignment polling defaults', () => {
|
|
|
86
86
|
});
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
describe('RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS validation', () => {
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
vi.unstubAllEnvs();
|
|
92
|
+
vi.resetModules();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('defaults to a five-minute recovery window', async () => {
|
|
96
|
+
vi.resetModules();
|
|
97
|
+
|
|
98
|
+
const {config} = await import('#config.js');
|
|
99
|
+
|
|
100
|
+
expect(config.RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS).toBe(300);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it.each(['0', '-5', '1.5'])('fails startup when the value is %s', async (value) => {
|
|
104
|
+
vi.stubEnv('RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS', value);
|
|
105
|
+
vi.resetModules();
|
|
106
|
+
|
|
107
|
+
await expect(import('#config.js')).rejects.toThrow('RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
89
111
|
describe('reservation TTL ceiling validation', () => {
|
|
90
112
|
afterEach(() => {
|
|
91
113
|
vi.unstubAllEnvs();
|
|
@@ -104,12 +126,12 @@ describe('reservation TTL ceiling validation', () => {
|
|
|
104
126
|
await expect(import('#config.js')).rejects.toThrow('RESERVATION_TTL_MAX_SECONDS');
|
|
105
127
|
});
|
|
106
128
|
|
|
107
|
-
it('defaults high enough for
|
|
129
|
+
it('defaults high enough for provider registration deadlines and launch headroom', async () => {
|
|
108
130
|
vi.resetModules();
|
|
109
131
|
|
|
110
132
|
const {config} = await import('#config.js');
|
|
111
133
|
|
|
112
|
-
expect(config.RESERVATION_TTL_MAX_SECONDS).toBe(
|
|
134
|
+
expect(config.RESERVATION_TTL_MAX_SECONDS).toBe(600);
|
|
113
135
|
});
|
|
114
136
|
|
|
115
137
|
it('accepts a whole-second ceiling inside the hard maximum', async () => {
|
package/src/config.ts
CHANGED
|
@@ -62,12 +62,12 @@ export const config = createConfig({
|
|
|
62
62
|
default: 250,
|
|
63
63
|
}),
|
|
64
64
|
RESERVATION_TTL_SECONDS: num({
|
|
65
|
-
desc: '
|
|
65
|
+
desc: 'Activation grace period for a rebound runner reservation, in seconds. A runner without a session can be rebound again only after this deadline. It is also the default lifetime for launch reservations when a provisioner does not request a provider-specific value.',
|
|
66
66
|
default: 60,
|
|
67
67
|
}),
|
|
68
68
|
RESERVATION_TTL_MAX_SECONDS: num({
|
|
69
|
-
desc: `Server-side ceiling for a
|
|
70
|
-
default:
|
|
69
|
+
desc: `Server-side ceiling for a launch reservation lifetime, in seconds. Set it at least as high as every provider registration deadline plus its launch headroom so provider-specific values can cover the launch gap, boot, and enrollment. Set this between 1 and ${RESERVATION_TTL_HARD_MAX_SECONDS}.`,
|
|
70
|
+
default: 600,
|
|
71
71
|
}),
|
|
72
72
|
RESERVATION_LONG_POLL_MAX_WAIT_SECONDS: num({
|
|
73
73
|
desc: 'Maximum time the provisioner demand poll endpoint waits for reservable demand before returning, in seconds.',
|
|
@@ -85,6 +85,10 @@ export const config = createConfig({
|
|
|
85
85
|
desc: 'Time window, in seconds, used to list active runners from recent heartbeats and provisioned runner reports.',
|
|
86
86
|
default: 60,
|
|
87
87
|
}),
|
|
88
|
+
RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS: num({
|
|
89
|
+
desc: 'Minimum time, in seconds, before a demand-backed runner without a runner session can be reclaimed when neither its current nor intended reservation is live. Set this above the provider registration deadline when startup reservations remain live during boot.',
|
|
90
|
+
default: 300,
|
|
91
|
+
}),
|
|
88
92
|
RUNNER_TOOL_CAPABILITIES_STALE_AFTER_SECONDS: num({
|
|
89
93
|
desc: 'Time window, in seconds, after which a runner tool capability report is treated as stale. Set this higher than the runner heartbeat interval so active runners keep their advertised tools fresh.',
|
|
90
94
|
default: 60,
|
|
@@ -282,6 +286,15 @@ if (
|
|
|
282
286
|
);
|
|
283
287
|
}
|
|
284
288
|
|
|
289
|
+
if (
|
|
290
|
+
!Number.isInteger(config.RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS) ||
|
|
291
|
+
config.RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS < 1
|
|
292
|
+
) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS (${config.RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS}) must be a whole number of seconds >= 1.`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
285
298
|
if (
|
|
286
299
|
!Number.isInteger(config.RUNNER_TOOL_CAPABILITIES_STALE_AFTER_SECONDS) ||
|
|
287
300
|
config.RUNNER_TOOL_CAPABILITIES_STALE_AFTER_SECONDS < 1
|
package/src/core/demand.test.ts
CHANGED
|
@@ -37,6 +37,18 @@ describe('shouldReturn', () => {
|
|
|
37
37
|
expect(result).toBe(true);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
it('returns true when only a rebound reservation was created', () => {
|
|
41
|
+
const result = shouldReturn({...emptyResult, newlyReservedCount: 1}, 1, 1, false);
|
|
42
|
+
|
|
43
|
+
expect(result).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns true when demand was fully adopted by idle runners', () => {
|
|
47
|
+
const result = shouldReturn({...emptyResult, newlyReservedCount: 3}, 1, 3, false);
|
|
48
|
+
|
|
49
|
+
expect(result).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
40
52
|
it('returns true when terminate intents exist', () => {
|
|
41
53
|
const result = shouldReturn(
|
|
42
54
|
{...emptyResult, terminateRunnerInstanceIds: ['provisioned-runner-1']},
|
|
@@ -48,6 +60,55 @@ describe('shouldReturn', () => {
|
|
|
48
60
|
expect(result).toBe(true);
|
|
49
61
|
});
|
|
50
62
|
|
|
63
|
+
it('continues waiting for a retry-only activation-timeout intent', () => {
|
|
64
|
+
const result = shouldReturn(
|
|
65
|
+
{...emptyResult, terminateRunnerInstanceIds: ['stale-demand-runner']},
|
|
66
|
+
1,
|
|
67
|
+
1,
|
|
68
|
+
false,
|
|
69
|
+
[
|
|
70
|
+
{
|
|
71
|
+
providerRunnerId: 'stale-demand-runner',
|
|
72
|
+
reason: 'activation-timeout',
|
|
73
|
+
activationTimeoutRetry: true,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(result).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns true for a first-delivery activation-timeout intent', () => {
|
|
82
|
+
const result = shouldReturn(
|
|
83
|
+
{...emptyResult, terminateRunnerInstanceIds: ['stale-demand-runner']},
|
|
84
|
+
1,
|
|
85
|
+
1,
|
|
86
|
+
false,
|
|
87
|
+
[{providerRunnerId: 'stale-demand-runner', reason: 'activation-timeout'}],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
expect(result).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns true for a job-cancelled intent alongside a retried activation timeout', () => {
|
|
94
|
+
const result = shouldReturn(
|
|
95
|
+
{...emptyResult, terminateRunnerInstanceIds: ['stale-demand-runner', 'cancelled-runner']},
|
|
96
|
+
1,
|
|
97
|
+
1,
|
|
98
|
+
false,
|
|
99
|
+
[
|
|
100
|
+
{
|
|
101
|
+
providerRunnerId: 'stale-demand-runner',
|
|
102
|
+
reason: 'activation-timeout',
|
|
103
|
+
activationTimeoutRetry: true,
|
|
104
|
+
},
|
|
105
|
+
{providerRunnerId: 'cancelled-runner', reason: 'job-cancelled'},
|
|
106
|
+
],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(result).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
51
112
|
it('returns true when the deadline passed', () => {
|
|
52
113
|
const result = shouldReturn(emptyResult, 1, 1, true);
|
|
53
114
|
|
|
@@ -81,7 +142,9 @@ describe('pollDemand', () => {
|
|
|
81
142
|
|
|
82
143
|
it('returns immediately when terminate intents exist without reservation demand', async () => {
|
|
83
144
|
vi.resetModules();
|
|
84
|
-
const pollDemandAndReserveTx = vi
|
|
145
|
+
const pollDemandAndReserveTx = vi
|
|
146
|
+
.fn()
|
|
147
|
+
.mockResolvedValue({stats: [], reservations: [], newlyReservedUnits: []});
|
|
85
148
|
const listProvisionerTerminateIntentRowsTx = vi
|
|
86
149
|
.fn()
|
|
87
150
|
.mockResolvedValue([{providerRunnerId: 'provisioned-runner-1', reason: 'job-cancelled'}]);
|
|
@@ -129,10 +192,134 @@ describe('pollDemand', () => {
|
|
|
129
192
|
}
|
|
130
193
|
});
|
|
131
194
|
|
|
195
|
+
it('does not return immediately for retry-only activation-timeout intents', async () => {
|
|
196
|
+
vi.resetModules();
|
|
197
|
+
const abortController = new AbortController();
|
|
198
|
+
let listCalls = 0;
|
|
199
|
+
const pollDemandAndReserveTx = vi
|
|
200
|
+
.fn()
|
|
201
|
+
.mockResolvedValue({stats: [], reservations: [], newlyReservedUnits: []});
|
|
202
|
+
const listProvisionerTerminateIntentRowsTx = vi.fn().mockImplementation(() => {
|
|
203
|
+
listCalls += 1;
|
|
204
|
+
if (listCalls === 2) abortController.abort();
|
|
205
|
+
return [
|
|
206
|
+
{
|
|
207
|
+
providerRunnerId: 'stale-demand-runner',
|
|
208
|
+
reason: 'activation-timeout',
|
|
209
|
+
activationTimeoutRetry: true,
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
});
|
|
213
|
+
const listActiveRunnerInstanceCountsByTemplateTx = vi.fn().mockResolvedValue([]);
|
|
214
|
+
vi.doMock('#db/db.js', () => ({
|
|
215
|
+
db: () => ({transaction: (callback: (tx: unknown) => Promise<unknown>) => callback({})}),
|
|
216
|
+
}));
|
|
217
|
+
vi.doMock('#db/reservations.js', () => ({
|
|
218
|
+
deleteReservationsByIds: vi.fn(),
|
|
219
|
+
pollDemandAndReserveTx,
|
|
220
|
+
}));
|
|
221
|
+
vi.doMock('#db/runner-instances.js', () => ({
|
|
222
|
+
listActiveRunnerInstanceCountsByTemplateTx,
|
|
223
|
+
listProvisionerTerminateIntentRowsTx,
|
|
224
|
+
}));
|
|
225
|
+
vi.doMock('#metrics/instance.js', () => ({
|
|
226
|
+
providerRunnerActivationOutcomeCount: {add: vi.fn()},
|
|
227
|
+
providerRunnerCountDivergenceCount: {add: vi.fn()},
|
|
228
|
+
providerRunnerTerminateIntentIssuedCount: {add: vi.fn()},
|
|
229
|
+
}));
|
|
230
|
+
const random = vi.spyOn(Math, 'random').mockReturnValue(0);
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const {pollDemand: mockedPollDemand} = await import('#core/demand.js');
|
|
234
|
+
|
|
235
|
+
const result = await mockedPollDemand({
|
|
236
|
+
workspaceId: crypto.randomUUID(),
|
|
237
|
+
provisionerId: crypto.randomUUID(),
|
|
238
|
+
maxReservations: 1,
|
|
239
|
+
waitSeconds: 60,
|
|
240
|
+
ttlSeconds: 60,
|
|
241
|
+
terminateIntentLimit: 1000,
|
|
242
|
+
templates: [
|
|
243
|
+
{templateKey: 'linux', labels: ['linux'], availableSlots: 1, starting: 0, running: 0},
|
|
244
|
+
],
|
|
245
|
+
signal: abortController.signal,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(result).toEqual({
|
|
249
|
+
stats: [],
|
|
250
|
+
reservations: [],
|
|
251
|
+
terminateRunnerInstanceIds: ['stale-demand-runner'],
|
|
252
|
+
});
|
|
253
|
+
expect(listProvisionerTerminateIntentRowsTx).toHaveBeenCalledTimes(2);
|
|
254
|
+
} finally {
|
|
255
|
+
random.mockRestore();
|
|
256
|
+
vi.doUnmock('#db/db.js');
|
|
257
|
+
vi.doUnmock('#db/reservations.js');
|
|
258
|
+
vi.doUnmock('#db/runner-instances.js');
|
|
259
|
+
vi.doUnmock('#metrics/instance.js');
|
|
260
|
+
vi.resetModules();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('returns immediately when demand is fully adopted by idle runners', async () => {
|
|
265
|
+
vi.resetModules();
|
|
266
|
+
const pollDemandAndReserveTx = vi.fn().mockResolvedValue({
|
|
267
|
+
stats: [],
|
|
268
|
+
reservations: [],
|
|
269
|
+
newlyReservedUnits: [{labels: ['linux'], count: 3}],
|
|
270
|
+
});
|
|
271
|
+
const listProvisionerTerminateIntentRowsTx = vi.fn().mockResolvedValue([]);
|
|
272
|
+
const listActiveRunnerInstanceCountsByTemplateTx = vi.fn().mockResolvedValue([]);
|
|
273
|
+
vi.doMock('#db/db.js', () => ({
|
|
274
|
+
db: () => ({transaction: (callback: (tx: unknown) => Promise<unknown>) => callback({})}),
|
|
275
|
+
}));
|
|
276
|
+
vi.doMock('#db/reservations.js', () => ({
|
|
277
|
+
deleteReservationsByIds: vi.fn(),
|
|
278
|
+
pollDemandAndReserveTx,
|
|
279
|
+
}));
|
|
280
|
+
vi.doMock('#db/runner-instances.js', () => ({
|
|
281
|
+
listActiveRunnerInstanceCountsByTemplateTx,
|
|
282
|
+
listProvisionerTerminateIntentRowsTx,
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const {pollDemand: mockedPollDemand} = await import('#core/demand.js');
|
|
287
|
+
|
|
288
|
+
const result = await mockedPollDemand({
|
|
289
|
+
workspaceId: crypto.randomUUID(),
|
|
290
|
+
provisionerId: crypto.randomUUID(),
|
|
291
|
+
maxReservations: 3,
|
|
292
|
+
waitSeconds: 60,
|
|
293
|
+
ttlSeconds: 60,
|
|
294
|
+
terminateIntentLimit: 1000,
|
|
295
|
+
templates: [
|
|
296
|
+
{templateKey: 'linux', labels: ['linux'], availableSlots: 3, starting: 0, running: 3},
|
|
297
|
+
],
|
|
298
|
+
signal: new AbortController().signal,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
expect(result).toEqual({
|
|
302
|
+
stats: [],
|
|
303
|
+
reservations: [],
|
|
304
|
+
newlyReservedCount: 3,
|
|
305
|
+
terminateRunnerInstanceIds: [],
|
|
306
|
+
});
|
|
307
|
+
expect(pollDemandAndReserveTx).toHaveBeenCalledOnce();
|
|
308
|
+
expect(listActiveRunnerInstanceCountsByTemplateTx).toHaveBeenCalledOnce();
|
|
309
|
+
} finally {
|
|
310
|
+
vi.doUnmock('#db/db.js');
|
|
311
|
+
vi.doUnmock('#db/reservations.js');
|
|
312
|
+
vi.doUnmock('#db/runner-instances.js');
|
|
313
|
+
vi.resetModules();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
132
317
|
it('does not calculate divergence for a non-returned long-poll retry', async () => {
|
|
133
318
|
vi.resetModules();
|
|
134
319
|
const abortController = new AbortController();
|
|
135
|
-
const pollDemandAndReserveTx = vi
|
|
320
|
+
const pollDemandAndReserveTx = vi
|
|
321
|
+
.fn()
|
|
322
|
+
.mockResolvedValue({stats: [], reservations: [], newlyReservedUnits: []});
|
|
136
323
|
const listProvisionerTerminateIntentRowsTx = vi.fn().mockImplementation(() => {
|
|
137
324
|
abortController.abort();
|
|
138
325
|
return [];
|
|
@@ -180,7 +367,9 @@ describe('pollDemand', () => {
|
|
|
180
367
|
vi.resetModules();
|
|
181
368
|
vi.stubEnv('PROVISIONED_RUNNER_COUNT_DIVERGENCE_TEMPLATE_KEY_LABEL_ENABLED', 'true');
|
|
182
369
|
const divergenceAdd = vi.fn();
|
|
183
|
-
const pollDemandAndReserveTx = vi
|
|
370
|
+
const pollDemandAndReserveTx = vi
|
|
371
|
+
.fn()
|
|
372
|
+
.mockResolvedValue({stats: [], reservations: [], newlyReservedUnits: []});
|
|
184
373
|
const listProvisionerTerminateIntentRowsTx = vi.fn().mockResolvedValue([]);
|
|
185
374
|
const listActiveRunnerInstanceCountsByTemplateTx = vi
|
|
186
375
|
.fn()
|
package/src/core/demand.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import {
|
|
19
19
|
providerRunnerCountDivergenceCount,
|
|
20
20
|
providerRunnerTerminateIntentIssuedCount,
|
|
21
|
+
recordProviderRunnerActivationOutcome,
|
|
21
22
|
} from '#metrics/instance.js';
|
|
22
23
|
|
|
23
24
|
export interface PollDemandParams {
|
|
@@ -35,6 +36,8 @@ export interface PollDemandResult {
|
|
|
35
36
|
stats: DemandStat[];
|
|
36
37
|
reservations: ReservationGrant[];
|
|
37
38
|
terminateRunnerInstanceIds: string[];
|
|
39
|
+
/** Units reserved this poll. A bound-only allocation carries no launch grant, so this is what ends the long poll. */
|
|
40
|
+
newlyReservedCount?: number;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export interface RunnerInstanceCountDivergence {
|
|
@@ -78,6 +81,7 @@ export async function pollDemand(params: PollDemandParams): Promise<PollDemandRe
|
|
|
78
81
|
provisionerId: params.provisionerId,
|
|
79
82
|
maxReservations: params.maxReservations,
|
|
80
83
|
ttlSeconds: params.ttlSeconds,
|
|
84
|
+
activationGraceSeconds: config.RESERVATION_TTL_SECONDS,
|
|
81
85
|
templates: params.templates,
|
|
82
86
|
});
|
|
83
87
|
const terminateIntents = await listProvisionerTerminateIntentRowsTx(tx, {
|
|
@@ -85,12 +89,26 @@ export async function pollDemand(params: PollDemandParams): Promise<PollDemandRe
|
|
|
85
89
|
provisionerId: params.provisionerId,
|
|
86
90
|
limit: params.terminateIntentLimit,
|
|
87
91
|
});
|
|
92
|
+
const newlyReservedCount = demand.newlyReservedUnits.reduce(
|
|
93
|
+
(total, reservation) => total + reservation.count,
|
|
94
|
+
0,
|
|
95
|
+
);
|
|
88
96
|
const result: PollDemandResult = {
|
|
89
|
-
|
|
97
|
+
stats: demand.stats,
|
|
98
|
+
reservations: demand.reservations,
|
|
99
|
+
...(newlyReservedCount > 0 ? {newlyReservedCount} : {}),
|
|
90
100
|
terminateRunnerInstanceIds: terminateIntents.map((intent) => intent.providerRunnerId),
|
|
91
101
|
};
|
|
92
102
|
|
|
93
|
-
if (
|
|
103
|
+
if (
|
|
104
|
+
!shouldReturn(
|
|
105
|
+
result,
|
|
106
|
+
params.maxReservations,
|
|
107
|
+
totalCapacity,
|
|
108
|
+
deadlinePassed,
|
|
109
|
+
terminateIntents,
|
|
110
|
+
)
|
|
111
|
+
) {
|
|
94
112
|
return {result, terminateIntents, divergences: []};
|
|
95
113
|
}
|
|
96
114
|
|
|
@@ -113,7 +131,15 @@ export async function pollDemand(params: PollDemandParams): Promise<PollDemandRe
|
|
|
113
131
|
|
|
114
132
|
lastSnapshot = snapshot;
|
|
115
133
|
|
|
116
|
-
if (
|
|
134
|
+
if (
|
|
135
|
+
shouldReturn(
|
|
136
|
+
lastSnapshot.result,
|
|
137
|
+
params.maxReservations,
|
|
138
|
+
totalCapacity,
|
|
139
|
+
deadlinePassed,
|
|
140
|
+
lastSnapshot.terminateIntents,
|
|
141
|
+
)
|
|
142
|
+
) {
|
|
117
143
|
recordPollDemandMetrics(params, lastSnapshot);
|
|
118
144
|
return lastSnapshot.result;
|
|
119
145
|
}
|
|
@@ -132,6 +158,9 @@ export async function pollDemand(params: PollDemandParams): Promise<PollDemandRe
|
|
|
132
158
|
}
|
|
133
159
|
|
|
134
160
|
export async function releaseReservationGrants(reservations: ReservationGrant[]): Promise<void> {
|
|
161
|
+
// Fully adopted reservations are intentionally absent from this list: deleting
|
|
162
|
+
// one on disconnect would orphan the runner bound to that reservation. They
|
|
163
|
+
// remain protected until terminal cleanup or expiry.
|
|
135
164
|
await deleteReservationsByIds(reservations.map((reservation) => reservation.reservationId));
|
|
136
165
|
}
|
|
137
166
|
|
|
@@ -140,12 +169,19 @@ export function shouldReturn(
|
|
|
140
169
|
maxReservations: number,
|
|
141
170
|
totalCapacity: number,
|
|
142
171
|
deadlinePassed: boolean,
|
|
172
|
+
terminateIntents?: readonly RunnerInstanceTerminateIntent[],
|
|
143
173
|
): boolean {
|
|
174
|
+
const hasImmediateTerminateIntent =
|
|
175
|
+
terminateIntents?.some(
|
|
176
|
+
(intent) => intent.reason !== 'activation-timeout' || !intent.activationTimeoutRetry,
|
|
177
|
+
) ?? result.terminateRunnerInstanceIds.length > 0;
|
|
178
|
+
|
|
144
179
|
return (
|
|
145
180
|
maxReservations === 0 ||
|
|
146
181
|
totalCapacity === 0 ||
|
|
147
182
|
result.reservations.length > 0 ||
|
|
148
|
-
result.
|
|
183
|
+
(result.newlyReservedCount ?? 0) > 0 ||
|
|
184
|
+
hasImmediateTerminateIntent ||
|
|
149
185
|
deadlinePassed
|
|
150
186
|
);
|
|
151
187
|
}
|
|
@@ -219,6 +255,10 @@ function recordPollDemandMetrics(params: PollDemandParams, snapshot: PollDemandS
|
|
|
219
255
|
surface: 'poll-demand',
|
|
220
256
|
reason: intent.reason,
|
|
221
257
|
});
|
|
258
|
+
// Retries redeliver an intent already counted on its first emission.
|
|
259
|
+
if (intent.reason === 'activation-timeout' && !intent.activationTimeoutRetry) {
|
|
260
|
+
recordProviderRunnerActivationOutcome({outcome: 'reaped'});
|
|
261
|
+
}
|
|
222
262
|
}
|
|
223
263
|
}
|
|
224
264
|
|
|
@@ -6,12 +6,16 @@ export type RunnerInstanceState =
|
|
|
6
6
|
| 'failed'
|
|
7
7
|
| 'terminated';
|
|
8
8
|
|
|
9
|
+
export type RunnerInstanceLaunchKind = 'demand' | 'warm' | 'manual';
|
|
10
|
+
|
|
9
11
|
export interface RunnerInstance {
|
|
10
12
|
id: string;
|
|
11
13
|
workspaceId: string | null;
|
|
12
14
|
provisionerId: string;
|
|
13
15
|
providerRunnerId: string;
|
|
16
|
+
intendedReservationId: string | null;
|
|
14
17
|
reservationId: string | null;
|
|
18
|
+
launchKind: RunnerInstanceLaunchKind;
|
|
15
19
|
assignedAt?: Date | null;
|
|
16
20
|
templateKey: string | null;
|
|
17
21
|
labels: string[];
|
|
@@ -1,14 +1,22 @@
|
|
|
1
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
1
2
|
import {extractDisplayPrefix, generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
|
|
2
3
|
import {and, eq, isNull, sql} from 'drizzle-orm';
|
|
3
4
|
import type {Tx} from '#db/db.js';
|
|
4
5
|
import {db} from '#db/db.js';
|
|
5
6
|
import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
|
|
7
|
+
import type {RunnerInstanceDb} from '#db/schema/runner-instances.js';
|
|
6
8
|
import {providerRunners} from '#db/schema/runner-instances.js';
|
|
9
|
+
import {
|
|
10
|
+
type RunnerActivationTokenNotIssuedReason,
|
|
11
|
+
type RunnerActivationTokenNotIssuedSurface,
|
|
12
|
+
recordRunnerActivationTokenNotIssued,
|
|
13
|
+
} from '#metrics/instance.js';
|
|
7
14
|
|
|
8
15
|
export async function issueRunnerActivationToken(params: {
|
|
9
16
|
runnerInstanceId: string;
|
|
10
17
|
provisionerId: string;
|
|
11
18
|
ttlSeconds: number;
|
|
19
|
+
surface: RunnerActivationTokenNotIssuedSurface;
|
|
12
20
|
}): Promise<string | null> {
|
|
13
21
|
return await db().transaction(async (tx) => issueRunnerActivationTokenTx(tx, params));
|
|
14
22
|
}
|
|
@@ -19,6 +27,7 @@ export async function issueRunnerActivationTokenTx(
|
|
|
19
27
|
runnerInstanceId: string;
|
|
20
28
|
provisionerId: string;
|
|
21
29
|
ttlSeconds: number;
|
|
30
|
+
surface: RunnerActivationTokenNotIssuedSurface;
|
|
22
31
|
},
|
|
23
32
|
): Promise<string | null> {
|
|
24
33
|
await tx.execute(
|
|
@@ -39,7 +48,11 @@ export async function issueRunnerActivationTokenTx(
|
|
|
39
48
|
)
|
|
40
49
|
.limit(1)
|
|
41
50
|
.for('update');
|
|
42
|
-
|
|
51
|
+
const notIssuedReason = getRunnerActivationTokenNotIssuedReason(runner);
|
|
52
|
+
if (notIssuedReason) {
|
|
53
|
+
recordRunnerActivationTokenNotIssuedWithLog(notIssuedReason, params);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
43
56
|
|
|
44
57
|
await tx
|
|
45
58
|
.update(runnerActivationTokens)
|
|
@@ -69,6 +82,7 @@ export async function getRunnerAssignment(params: {
|
|
|
69
82
|
.select({
|
|
70
83
|
workspaceId: providerRunners.workspaceId,
|
|
71
84
|
runnerSessionId: providerRunners.runnerSessionId,
|
|
85
|
+
state: providerRunners.state,
|
|
72
86
|
})
|
|
73
87
|
.from(providerRunners)
|
|
74
88
|
.where(
|
|
@@ -78,5 +92,37 @@ export async function getRunnerAssignment(params: {
|
|
|
78
92
|
),
|
|
79
93
|
)
|
|
80
94
|
.limit(1);
|
|
81
|
-
return runner?.workspaceId && !runner.runnerSessionId
|
|
95
|
+
return runner?.workspaceId && !runner.runnerSessionId && runner.state === 'running'
|
|
96
|
+
? {workspaceId: runner.workspaceId, runnerSessionId: runner.runnerSessionId}
|
|
97
|
+
: null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getRunnerActivationTokenNotIssuedReason(
|
|
101
|
+
runner: Pick<RunnerInstanceDb, 'workspaceId' | 'runnerSessionId' | 'state'> | undefined,
|
|
102
|
+
): RunnerActivationTokenNotIssuedReason | null {
|
|
103
|
+
if (!runner) return 'runner-not-found';
|
|
104
|
+
if (!runner.workspaceId) return 'missing-workspace';
|
|
105
|
+
if (runner.runnerSessionId) return 'existing-session';
|
|
106
|
+
if (runner.state !== 'running') return 'not-running';
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function recordRunnerActivationTokenNotIssuedWithLog(
|
|
111
|
+
reason: RunnerActivationTokenNotIssuedReason,
|
|
112
|
+
params: {
|
|
113
|
+
runnerInstanceId: string;
|
|
114
|
+
provisionerId: string;
|
|
115
|
+
surface: RunnerActivationTokenNotIssuedSurface;
|
|
116
|
+
},
|
|
117
|
+
): void {
|
|
118
|
+
recordRunnerActivationTokenNotIssued({reason, surface: params.surface});
|
|
119
|
+
logger().debug(
|
|
120
|
+
{
|
|
121
|
+
runnerInstanceId: params.runnerInstanceId,
|
|
122
|
+
provisionerId: params.provisionerId,
|
|
123
|
+
reason,
|
|
124
|
+
surface: params.surface,
|
|
125
|
+
},
|
|
126
|
+
'Runner activation token was not issued',
|
|
127
|
+
);
|
|
82
128
|
}
|