@shipfox/api-runners 12.3.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 +22 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -2
- 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 +55 -9
- 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/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 +246 -51
- 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 +2 -2
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +124 -24
- package/dist/db/runner-instances.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/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 +2 -2
- package/src/config.test.ts +22 -0
- package/src/config.ts +15 -2
- 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 +64 -3
- package/src/core/runner-control-sessions.ts +62 -9
- 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/reservation-locks.ts +22 -0
- package/src/db/reservations.test.ts +733 -22
- package/src/db/reservations.ts +408 -144
- package/src/db/runner-assignments.test.ts +105 -1
- package/src/db/runner-assignments.ts +178 -9
- package/src/db/runner-instances.test.ts +265 -2
- package/src/db/runner-instances.ts +215 -55
- 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 +8 -1
- 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 +219 -2
- package/src/presentation/routes/poll-demand.test.ts +56 -0
- 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/tsconfig.build.tsbuildinfo +1 -1
|
@@ -17,15 +17,20 @@ import {
|
|
|
17
17
|
sql,
|
|
18
18
|
} from 'drizzle-orm';
|
|
19
19
|
import {alias} from 'drizzle-orm/pg-core';
|
|
20
|
+
import {config} from '#config.js';
|
|
20
21
|
import type {RunnerInstance, RunnerInstanceState} from '#core/entities/runner-instance.js';
|
|
21
22
|
import {sanitizeRunnerLabels} from '#core/runner-labels.js';
|
|
23
|
+
import {recordRunnerReservationCapacityFailure} from '#metrics/index.js';
|
|
22
24
|
import type {Tx} from './db.js';
|
|
23
25
|
import {db} from './db.js';
|
|
24
26
|
import {
|
|
25
27
|
listRunningJobExecutionsByRunnerInstanceTx,
|
|
26
28
|
type RunnerInstanceBoundJobExecution,
|
|
27
29
|
} from './job-executions.js';
|
|
30
|
+
import {lockRunnerReservationAdvisoryKeysTx} from './reservation-locks.js';
|
|
28
31
|
import {releaseReservationUnits} from './reservations.js';
|
|
32
|
+
import {validateRunnerReservationCapacityTx} from './runner-assignments.js';
|
|
33
|
+
import {terminalStates} from './runner-states.js';
|
|
29
34
|
import {ephemeralRegistrationTokens} from './schema/ephemeral-registration-tokens.js';
|
|
30
35
|
import {provisionerTokens} from './schema/provisioner-tokens.js';
|
|
31
36
|
import {reservations} from './schema/reservations.js';
|
|
@@ -34,11 +39,6 @@ import {providerRunners, toRunnerInstance} from './schema/runner-instances.js';
|
|
|
34
39
|
import {runnerSessions} from './schema/runner-sessions.js';
|
|
35
40
|
import {runningJobExecutions} from './schema/running-job-executions.js';
|
|
36
41
|
|
|
37
|
-
export const terminalStates = [
|
|
38
|
-
'stopped',
|
|
39
|
-
'failed',
|
|
40
|
-
'terminated',
|
|
41
|
-
] as const satisfies readonly RunnerInstanceState[];
|
|
42
42
|
export const activeStates = [
|
|
43
43
|
'starting',
|
|
44
44
|
'running',
|
|
@@ -49,11 +49,12 @@ export const divergenceCountStates = ['starting', 'running'] as const satisfies
|
|
|
49
49
|
'starting' | 'running'
|
|
50
50
|
>[];
|
|
51
51
|
|
|
52
|
-
export type RunnerInstanceTerminateIntentReason = 'job-cancelled';
|
|
52
|
+
export type RunnerInstanceTerminateIntentReason = 'activation-timeout' | 'job-cancelled';
|
|
53
53
|
|
|
54
54
|
export interface RunnerInstanceTerminateIntent {
|
|
55
55
|
providerRunnerId: string;
|
|
56
56
|
reason: RunnerInstanceTerminateIntentReason;
|
|
57
|
+
activationTimeoutRetry?: boolean;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export interface ActiveRunnerInstanceTemplateCount {
|
|
@@ -155,13 +156,14 @@ export async function reportRunnerInstances(params: ReportRunnerInstancesParams)
|
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
const events = await hydrateRunnerSessionIdsFromConsumedTokens(tx, params, aggregatedEvents);
|
|
159
|
+
const reservationSafeEvents = await guardReportedReservationIdsTx(tx, params, events);
|
|
158
160
|
const terminateIntentsHonored = await listTerminateIntentsHonoredByTerminatedReportsTx(
|
|
159
161
|
tx,
|
|
160
162
|
params,
|
|
161
|
-
|
|
163
|
+
reservationSafeEvents,
|
|
162
164
|
);
|
|
163
165
|
|
|
164
|
-
const values =
|
|
166
|
+
const values = reservationSafeEvents.map((event) => ({
|
|
165
167
|
workspaceId: params.workspaceId,
|
|
166
168
|
provisionerId: params.provisionerId,
|
|
167
169
|
providerRunnerId: event.providerRunnerId,
|
|
@@ -213,10 +215,105 @@ export async function reportRunnerInstances(params: ReportRunnerInstancesParams)
|
|
|
213
215
|
});
|
|
214
216
|
|
|
215
217
|
const reservationsReleased = hasTerminalEvent
|
|
216
|
-
? await releaseTerminalRunnerInstanceReservations(tx, params,
|
|
218
|
+
? await releaseTerminalRunnerInstanceReservations(tx, params, reservationSafeEvents)
|
|
217
219
|
: 0;
|
|
218
220
|
|
|
219
|
-
return {
|
|
221
|
+
return {
|
|
222
|
+
accepted: reservationSafeEvents.length,
|
|
223
|
+
reservationsReleased,
|
|
224
|
+
terminateIntentsHonored,
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function guardReportedReservationIdsTx(
|
|
230
|
+
tx: Tx,
|
|
231
|
+
params: ReportRunnerInstancesParams,
|
|
232
|
+
events: RunnerInstanceReportRow[],
|
|
233
|
+
): Promise<RunnerInstanceReportRow[]> {
|
|
234
|
+
const reservationIds = [
|
|
235
|
+
...new Set(events.flatMap((event) => (event.reservationId ? [event.reservationId] : []))),
|
|
236
|
+
].sort();
|
|
237
|
+
if (reservationIds.length === 0) return events;
|
|
238
|
+
|
|
239
|
+
await lockRunnerReservationAdvisoryKeysTx(tx, {
|
|
240
|
+
provisionerId: params.provisionerId,
|
|
241
|
+
reservationIds,
|
|
242
|
+
});
|
|
243
|
+
const providerRunnerIds = events.map((event) => event.providerRunnerId);
|
|
244
|
+
const existingRows = await tx
|
|
245
|
+
.select({
|
|
246
|
+
providerRunnerId: providerRunners.providerRunnerId,
|
|
247
|
+
reservationId: providerRunners.reservationId,
|
|
248
|
+
intendedReservationId: providerRunners.intendedReservationId,
|
|
249
|
+
reservationReleasedAt: providerRunners.reservationReleasedAt,
|
|
250
|
+
})
|
|
251
|
+
.from(providerRunners)
|
|
252
|
+
.where(
|
|
253
|
+
and(
|
|
254
|
+
eq(providerRunners.provisionerId, params.provisionerId),
|
|
255
|
+
inArray(providerRunners.providerRunnerId, providerRunnerIds),
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
.for('update');
|
|
259
|
+
const existingByProviderRunnerId = new Map(
|
|
260
|
+
existingRows.flatMap((row) =>
|
|
261
|
+
row.providerRunnerId ? [[row.providerRunnerId, row] as const] : [],
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
const candidateReservationByProviderRunnerId = new Map<string, string>();
|
|
265
|
+
for (const event of events) {
|
|
266
|
+
if (!event.reservationId) continue;
|
|
267
|
+
if (isTerminalState(event.state)) continue;
|
|
268
|
+
const existing = existingByProviderRunnerId.get(event.providerRunnerId);
|
|
269
|
+
if (existing?.reservationId || existing?.intendedReservationId === event.reservationId)
|
|
270
|
+
continue;
|
|
271
|
+
if (existing?.intendedReservationId || existing?.reservationReleasedAt) continue;
|
|
272
|
+
candidateReservationByProviderRunnerId.set(event.providerRunnerId, event.reservationId);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const validation = await validateRunnerReservationCapacityTx(
|
|
276
|
+
tx,
|
|
277
|
+
{
|
|
278
|
+
provisionerId: params.provisionerId,
|
|
279
|
+
requests: [...candidateReservationByProviderRunnerId.values()].map((reservationId) => ({
|
|
280
|
+
reservationId,
|
|
281
|
+
count: 1,
|
|
282
|
+
})),
|
|
283
|
+
},
|
|
284
|
+
{advisoryLocksHeld: true},
|
|
285
|
+
);
|
|
286
|
+
for (const {reason, count} of validation.unavailableByReservation.values())
|
|
287
|
+
recordRunnerReservationCapacityFailure(reason, count);
|
|
288
|
+
|
|
289
|
+
const remainingAcceptedByReservation = new Map(validation.acceptedByReservation);
|
|
290
|
+
return events.map((event) => {
|
|
291
|
+
const existing = existingByProviderRunnerId.get(event.providerRunnerId);
|
|
292
|
+
const reservationId = candidateReservationByProviderRunnerId.get(event.providerRunnerId);
|
|
293
|
+
if (!reservationId) {
|
|
294
|
+
if (event.reservationId && isTerminalState(event.state)) {
|
|
295
|
+
// Terminal cleanup releases the IDs stored on the projection row. An arbitrary
|
|
296
|
+
// terminal report must not consume reservation capacity during that cleanup.
|
|
297
|
+
const matchesExistingReservation =
|
|
298
|
+
existing !== undefined &&
|
|
299
|
+
(existing.reservationId === event.reservationId ||
|
|
300
|
+
existing.intendedReservationId === event.reservationId);
|
|
301
|
+
if (!matchesExistingReservation) return {...event, reservationId: null};
|
|
302
|
+
}
|
|
303
|
+
if (
|
|
304
|
+
event.reservationId &&
|
|
305
|
+
existing?.intendedReservationId &&
|
|
306
|
+
existing.intendedReservationId !== event.reservationId
|
|
307
|
+
)
|
|
308
|
+
return {...event, reservationId: null};
|
|
309
|
+
if (event.reservationId && existing?.reservationReleasedAt)
|
|
310
|
+
return {...event, reservationId: null};
|
|
311
|
+
return event;
|
|
312
|
+
}
|
|
313
|
+
const remaining = remainingAcceptedByReservation.get(reservationId) ?? 0;
|
|
314
|
+
if (remaining === 0) return {...event, reservationId: null};
|
|
315
|
+
remainingAcceptedByReservation.set(reservationId, remaining - 1);
|
|
316
|
+
return event;
|
|
220
317
|
});
|
|
221
318
|
}
|
|
222
319
|
|
|
@@ -336,9 +433,42 @@ export async function listProvisionerTerminateIntentRowsTx(
|
|
|
336
433
|
);
|
|
337
434
|
}
|
|
338
435
|
|
|
339
|
-
|
|
340
|
-
row.providerRunnerId ? [
|
|
436
|
+
const activationTimeoutRunnerIds = returnedRows.flatMap((row) =>
|
|
437
|
+
row.reason === 'activation-timeout' && row.providerRunnerId ? [row.providerRunnerId] : [],
|
|
341
438
|
);
|
|
439
|
+
if (activationTimeoutRunnerIds.length > 0) {
|
|
440
|
+
await tx
|
|
441
|
+
.update(providerRunners)
|
|
442
|
+
.set({reservationReleasedAt: sql`now()`, updatedAt: sql`now()`})
|
|
443
|
+
.where(
|
|
444
|
+
and(
|
|
445
|
+
eq(providerRunners.workspaceId, params.workspaceId),
|
|
446
|
+
eq(providerRunners.provisionerId, params.provisionerId),
|
|
447
|
+
inArray(providerRunners.providerRunnerId, activationTimeoutRunnerIds),
|
|
448
|
+
isNull(providerRunners.reservationReleasedAt),
|
|
449
|
+
),
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return returnedRows.flatMap((row) => toRunnerInstanceTerminateIntent(row));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function toRunnerInstanceTerminateIntent(row: {
|
|
457
|
+
providerRunnerId: string | null;
|
|
458
|
+
reason: RunnerInstanceTerminateIntentReason;
|
|
459
|
+
activationTimeoutRetry: boolean;
|
|
460
|
+
}): RunnerInstanceTerminateIntent[] {
|
|
461
|
+
if (!row.providerRunnerId) return [];
|
|
462
|
+
|
|
463
|
+
return [
|
|
464
|
+
{
|
|
465
|
+
providerRunnerId: row.providerRunnerId,
|
|
466
|
+
reason: row.reason,
|
|
467
|
+
...(row.reason === 'activation-timeout' && row.activationTimeoutRetry
|
|
468
|
+
? {activationTimeoutRetry: true}
|
|
469
|
+
: {}),
|
|
470
|
+
},
|
|
471
|
+
];
|
|
342
472
|
}
|
|
343
473
|
|
|
344
474
|
function provisionerTerminateIntentsQuery(
|
|
@@ -346,59 +476,91 @@ function provisionerTerminateIntentsQuery(
|
|
|
346
476
|
params: {workspaceId: string; provisionerId: string; providerRunnerIds?: string[]},
|
|
347
477
|
) {
|
|
348
478
|
const newerRunningJobExecutions = alias(runningJobExecutions, 'newer_running_jobs');
|
|
479
|
+
const latestCancelledJob = exists(
|
|
480
|
+
tx
|
|
481
|
+
.select({id: runningJobExecutions.id})
|
|
482
|
+
.from(runningJobExecutions)
|
|
483
|
+
.where(
|
|
484
|
+
and(
|
|
485
|
+
eq(runningJobExecutions.workspaceId, params.workspaceId),
|
|
486
|
+
eq(runningJobExecutions.provisionerId, params.provisionerId),
|
|
487
|
+
eq(runningJobExecutions.providerRunnerId, providerRunners.providerRunnerId),
|
|
488
|
+
isNotNull(runningJobExecutions.cancellationRequestedAt),
|
|
489
|
+
notExists(
|
|
490
|
+
tx
|
|
491
|
+
.select({id: newerRunningJobExecutions.id})
|
|
492
|
+
.from(newerRunningJobExecutions)
|
|
493
|
+
.where(
|
|
494
|
+
and(
|
|
495
|
+
eq(newerRunningJobExecutions.workspaceId, runningJobExecutions.workspaceId),
|
|
496
|
+
eq(newerRunningJobExecutions.provisionerId, runningJobExecutions.provisionerId),
|
|
497
|
+
eq(
|
|
498
|
+
newerRunningJobExecutions.providerRunnerId,
|
|
499
|
+
runningJobExecutions.providerRunnerId,
|
|
500
|
+
),
|
|
501
|
+
or(
|
|
502
|
+
gt(newerRunningJobExecutions.startedAt, runningJobExecutions.startedAt),
|
|
503
|
+
and(
|
|
504
|
+
eq(newerRunningJobExecutions.startedAt, runningJobExecutions.startedAt),
|
|
505
|
+
gt(
|
|
506
|
+
newerRunningJobExecutions.jobExecutionId,
|
|
507
|
+
runningJobExecutions.jobExecutionId,
|
|
508
|
+
),
|
|
509
|
+
),
|
|
510
|
+
),
|
|
511
|
+
),
|
|
512
|
+
),
|
|
513
|
+
),
|
|
514
|
+
),
|
|
515
|
+
),
|
|
516
|
+
);
|
|
517
|
+
const activationTimeout = and(
|
|
518
|
+
eq(providerRunners.launchKind, 'demand'),
|
|
519
|
+
isNull(providerRunners.runnerSessionId),
|
|
520
|
+
lt(
|
|
521
|
+
providerRunners.createdAt,
|
|
522
|
+
sql`now() - (${config.RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS} || ' seconds')::interval`,
|
|
523
|
+
),
|
|
524
|
+
notExists(
|
|
525
|
+
tx
|
|
526
|
+
.select({id: reservations.id})
|
|
527
|
+
.from(reservations)
|
|
528
|
+
.where(
|
|
529
|
+
and(
|
|
530
|
+
or(
|
|
531
|
+
eq(reservations.id, providerRunners.reservationId),
|
|
532
|
+
eq(reservations.id, providerRunners.intendedReservationId),
|
|
533
|
+
),
|
|
534
|
+
eq(reservations.workspaceId, params.workspaceId),
|
|
535
|
+
eq(reservations.provisionerId, params.provisionerId),
|
|
536
|
+
gt(reservations.expiresAt, sql`now()`),
|
|
537
|
+
),
|
|
538
|
+
),
|
|
539
|
+
),
|
|
540
|
+
);
|
|
349
541
|
|
|
350
542
|
return tx
|
|
351
543
|
.select({
|
|
352
544
|
providerRunnerId: providerRunners.providerRunnerId,
|
|
353
|
-
|
|
545
|
+
activationTimeoutRetry: sql<boolean>`${providerRunners.reservationReleasedAt} is not null`,
|
|
546
|
+
reason: sql<RunnerInstanceTerminateIntentReason>`case
|
|
547
|
+
when ${activationTimeout} then 'activation-timeout'
|
|
548
|
+
else 'job-cancelled'
|
|
549
|
+
end`,
|
|
354
550
|
})
|
|
355
|
-
.from(
|
|
356
|
-
.innerJoin(
|
|
357
|
-
providerRunners,
|
|
358
|
-
and(
|
|
359
|
-
eq(providerRunners.workspaceId, runningJobExecutions.workspaceId),
|
|
360
|
-
eq(providerRunners.provisionerId, runningJobExecutions.provisionerId),
|
|
361
|
-
eq(providerRunners.providerRunnerId, runningJobExecutions.providerRunnerId),
|
|
362
|
-
),
|
|
363
|
-
)
|
|
551
|
+
.from(providerRunners)
|
|
364
552
|
.where(
|
|
365
553
|
and(
|
|
366
|
-
eq(
|
|
367
|
-
eq(
|
|
554
|
+
eq(providerRunners.workspaceId, params.workspaceId),
|
|
555
|
+
eq(providerRunners.provisionerId, params.provisionerId),
|
|
368
556
|
isNotNull(providerRunners.providerRunnerId),
|
|
369
|
-
isNotNull(runningJobExecutions.cancellationRequestedAt),
|
|
370
557
|
inArray(providerRunners.state, activeStates),
|
|
371
558
|
params.providerRunnerIds && params.providerRunnerIds.length > 0
|
|
372
559
|
? inArray(providerRunners.providerRunnerId, params.providerRunnerIds)
|
|
373
560
|
: undefined,
|
|
374
|
-
|
|
375
|
-
tx
|
|
376
|
-
.select({id: newerRunningJobExecutions.id})
|
|
377
|
-
.from(newerRunningJobExecutions)
|
|
378
|
-
.where(
|
|
379
|
-
and(
|
|
380
|
-
eq(newerRunningJobExecutions.workspaceId, runningJobExecutions.workspaceId),
|
|
381
|
-
eq(newerRunningJobExecutions.provisionerId, runningJobExecutions.provisionerId),
|
|
382
|
-
eq(
|
|
383
|
-
newerRunningJobExecutions.providerRunnerId,
|
|
384
|
-
runningJobExecutions.providerRunnerId,
|
|
385
|
-
),
|
|
386
|
-
or(
|
|
387
|
-
gt(newerRunningJobExecutions.startedAt, runningJobExecutions.startedAt),
|
|
388
|
-
and(
|
|
389
|
-
eq(newerRunningJobExecutions.startedAt, runningJobExecutions.startedAt),
|
|
390
|
-
gt(
|
|
391
|
-
newerRunningJobExecutions.jobExecutionId,
|
|
392
|
-
runningJobExecutions.jobExecutionId,
|
|
393
|
-
),
|
|
394
|
-
),
|
|
395
|
-
),
|
|
396
|
-
),
|
|
397
|
-
),
|
|
398
|
-
),
|
|
561
|
+
or(latestCancelledJob, activationTimeout),
|
|
399
562
|
),
|
|
400
|
-
)
|
|
401
|
-
.groupBy(providerRunners.providerRunnerId);
|
|
563
|
+
);
|
|
402
564
|
}
|
|
403
565
|
|
|
404
566
|
async function listTerminateIntentsHonoredByTerminatedReportsTx(
|
|
@@ -419,9 +581,7 @@ async function listTerminateIntentsHonoredByTerminatedReportsTx(
|
|
|
419
581
|
provisionerId: params.provisionerId,
|
|
420
582
|
providerRunnerIds: terminatedRunnerInstanceIds,
|
|
421
583
|
}).orderBy(asc(providerRunners.providerRunnerId));
|
|
422
|
-
return rows.flatMap((row) =>
|
|
423
|
-
row.providerRunnerId ? [{...row, providerRunnerId: row.providerRunnerId}] : [],
|
|
424
|
-
);
|
|
584
|
+
return rows.flatMap((row) => toRunnerInstanceTerminateIntent(row));
|
|
425
585
|
}
|
|
426
586
|
|
|
427
587
|
export async function reconcileRunnerInstances(
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
2
|
import {sql} from 'drizzle-orm';
|
|
3
|
-
import {check, index, integer, text, timestamp, uuid} from 'drizzle-orm/pg-core';
|
|
3
|
+
import {check, index, integer, pgEnum, text, timestamp, uuid} from 'drizzle-orm/pg-core';
|
|
4
4
|
import type {Reservation} from '#core/entities/reservation.js';
|
|
5
5
|
import {pgTable} from './common.js';
|
|
6
6
|
|
|
7
|
+
export const reservationKindEnum = pgEnum('runners_reservation_kind', ['bound', 'launch']);
|
|
8
|
+
|
|
7
9
|
export const reservations = pgTable(
|
|
8
10
|
'reservations',
|
|
9
11
|
{
|
|
@@ -12,6 +14,7 @@ export const reservations = pgTable(
|
|
|
12
14
|
provisionerId: uuid('provisioner_id').notNull(),
|
|
13
15
|
requiredLabels: text('required_labels').array().notNull(),
|
|
14
16
|
count: integer('count').notNull(),
|
|
17
|
+
kind: reservationKindEnum('kind').notNull().default('launch'),
|
|
15
18
|
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
16
19
|
expiresAt: timestamp('expires_at', {withTimezone: true}).notNull(),
|
|
17
20
|
},
|
|
@@ -32,6 +35,7 @@ export function toReservation(row: ReservationDb): Reservation {
|
|
|
32
35
|
provisionerId: row.provisionerId,
|
|
33
36
|
requiredLabels: row.requiredLabels,
|
|
34
37
|
count: row.count,
|
|
38
|
+
kind: row.kind,
|
|
35
39
|
createdAt: row.createdAt,
|
|
36
40
|
expiresAt: row.expiresAt,
|
|
37
41
|
};
|
|
@@ -14,6 +14,12 @@ export const providerRunnerStateEnum = pgEnum('runners_provider_runner_state', [
|
|
|
14
14
|
'terminated',
|
|
15
15
|
]);
|
|
16
16
|
|
|
17
|
+
export const providerRunnerLaunchKindEnum = pgEnum('runners_provider_runner_launch_kind', [
|
|
18
|
+
'demand',
|
|
19
|
+
'warm',
|
|
20
|
+
'manual',
|
|
21
|
+
]);
|
|
22
|
+
|
|
17
23
|
export const providerRunners = pgTable(
|
|
18
24
|
'runner_instances',
|
|
19
25
|
{
|
|
@@ -25,6 +31,7 @@ export const providerRunners = pgTable(
|
|
|
25
31
|
providerRunnerId: text('provider_runner_id'),
|
|
26
32
|
intendedReservationId: uuid('intended_reservation_id'),
|
|
27
33
|
reservationId: uuid('reservation_id'),
|
|
34
|
+
launchKind: providerRunnerLaunchKindEnum('launch_kind').notNull().default('manual'),
|
|
28
35
|
assignedAt: timestamp('assigned_at', {withTimezone: true}),
|
|
29
36
|
templateKey: text('template_key'),
|
|
30
37
|
labels: text('labels').array().notNull().default([]),
|
|
@@ -82,7 +89,9 @@ export function toRunnerInstance(row: RunnerInstanceDb): RunnerInstance {
|
|
|
82
89
|
workspaceId: row.workspaceId,
|
|
83
90
|
provisionerId: row.provisionerId,
|
|
84
91
|
providerRunnerId: row.providerRunnerId,
|
|
92
|
+
intendedReservationId: row.intendedReservationId,
|
|
85
93
|
reservationId: row.reservationId,
|
|
94
|
+
launchKind: row.launchKind,
|
|
86
95
|
assignedAt: row.assignedAt,
|
|
87
96
|
templateKey: row.templateKey,
|
|
88
97
|
labels: row.labels,
|
package/src/metrics/index.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
|
-
export type {
|
|
1
|
+
export type {
|
|
2
|
+
RunnerActivationTokenNotIssuedReason,
|
|
3
|
+
RunnerActivationTokenNotIssuedSurface,
|
|
4
|
+
RunnerReservationCapacityFailureReason,
|
|
5
|
+
RunnerReservationPromotionFailureReason,
|
|
6
|
+
} from './instance.js';
|
|
2
7
|
export {
|
|
3
8
|
jobExecutionClaimedCount,
|
|
4
9
|
jobExecutionEnqueuedCount,
|
|
5
10
|
jobExecutionLeaseExpiredCount,
|
|
6
11
|
providerRunnerAbsentTerminatedCount,
|
|
12
|
+
providerRunnerActivationOutcomeCount,
|
|
7
13
|
providerRunnerCountDivergenceCount,
|
|
8
14
|
providerRunnerReconcileCallCount,
|
|
9
15
|
providerRunnerTerminateIntentHonoredCount,
|
|
10
16
|
providerRunnerTerminateIntentIssuedCount,
|
|
17
|
+
recordProviderRunnerActivationOutcome,
|
|
18
|
+
recordRunnerActivationTokenNotIssued,
|
|
19
|
+
recordRunnerReservationCapacityFailure,
|
|
11
20
|
recordRunnerReservationPromotionFailure,
|
|
12
21
|
recordRunnersRateLimitCheck,
|
|
13
22
|
recordRunnersRateLimitPruneFailure,
|
package/src/metrics/instance.ts
CHANGED
|
@@ -31,6 +31,21 @@ export const runnerBootstrapExchangeCount = meter.createCounter<{
|
|
|
31
31
|
description: 'Runner bootstrap-token exchanges by outcome',
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
export type RunnerActivationTokenNotIssuedReason =
|
|
35
|
+
| 'runner-not-found'
|
|
36
|
+
| 'missing-workspace'
|
|
37
|
+
| 'existing-session'
|
|
38
|
+
| 'not-running';
|
|
39
|
+
|
|
40
|
+
export type RunnerActivationTokenNotIssuedSurface = 'enrollment' | 'poll';
|
|
41
|
+
|
|
42
|
+
export const runnerActivationTokenNotIssuedCount = meter.createCounter<{
|
|
43
|
+
reason: RunnerActivationTokenNotIssuedReason;
|
|
44
|
+
surface: RunnerActivationTokenNotIssuedSurface;
|
|
45
|
+
}>('runners_runner_activation_token_not_issued', {
|
|
46
|
+
description: 'Runner activation token issuance skips by reason and surface',
|
|
47
|
+
});
|
|
48
|
+
|
|
34
49
|
export const runnerControlHeartbeatCount = meter.createCounter<Record<string, never>>(
|
|
35
50
|
'runners_runner_control_heartbeat',
|
|
36
51
|
{description: 'Pre-workspace runner-control heartbeats accepted'},
|
|
@@ -67,17 +82,23 @@ export const providerRunnerAbsentTerminatedCount = meter.createCounter<Record<st
|
|
|
67
82
|
|
|
68
83
|
export const providerRunnerTerminateIntentIssuedCount = meter.createCounter<{
|
|
69
84
|
surface: 'poll-demand' | 'reconcile';
|
|
70
|
-
reason: 'job-cancelled' | 'terminal-state';
|
|
85
|
+
reason: 'activation-timeout' | 'job-cancelled' | 'terminal-state';
|
|
71
86
|
}>('runners_provider_runner_terminate_intent_issued', {
|
|
72
87
|
description: 'Provisioned runner terminate intents returned to provisioners',
|
|
73
88
|
});
|
|
74
89
|
|
|
75
90
|
export const providerRunnerTerminateIntentHonoredCount = meter.createCounter<{
|
|
76
|
-
reason: 'job-cancelled';
|
|
91
|
+
reason: 'activation-timeout' | 'job-cancelled';
|
|
77
92
|
}>('runners_provider_runner_terminate_intent_honored', {
|
|
78
93
|
description: 'Provisioned runner terminate intents honored by first transition to terminated',
|
|
79
94
|
});
|
|
80
95
|
|
|
96
|
+
export const providerRunnerActivationOutcomeCount = meter.createCounter<{
|
|
97
|
+
outcome: 'reaped' | 'rebound';
|
|
98
|
+
}>('runners_provider_runner_activation_outcome', {
|
|
99
|
+
description: 'Demand-backed runner activation outcomes by recovery action',
|
|
100
|
+
});
|
|
101
|
+
|
|
81
102
|
export const reservationReleasedCount = meter.createCounter<Record<string, never>>(
|
|
82
103
|
'runners_reservation_released',
|
|
83
104
|
{description: 'Reservation units released from terminal provisioned runner reports'},
|
|
@@ -95,6 +116,18 @@ export const runnerReservationPromotionFailureCount = meter.createCounter<{
|
|
|
95
116
|
description: 'Runner reservation promotion failures during enrollment by reason',
|
|
96
117
|
});
|
|
97
118
|
|
|
119
|
+
export type RunnerReservationCapacityFailureReason =
|
|
120
|
+
| 'reservation-not-found'
|
|
121
|
+
| 'reservation-kind-mismatch'
|
|
122
|
+
| 'reservation-expired'
|
|
123
|
+
| 'capacity-exhausted';
|
|
124
|
+
|
|
125
|
+
export const runnerReservationCapacityFailureCount = meter.createCounter<{
|
|
126
|
+
reason: RunnerReservationCapacityFailureReason;
|
|
127
|
+
}>('runners_reservation_capacity_failures', {
|
|
128
|
+
description: 'Runner reservation admission shortfalls by reason',
|
|
129
|
+
});
|
|
130
|
+
|
|
98
131
|
export type RunnersRateLimitAction = 'provisioner-mint' | 'ephemeral-register';
|
|
99
132
|
export type RunnersRateLimitScope = 'provisioner' | 'ephemeral-token';
|
|
100
133
|
export type RunnersRateLimitOutcome = 'allowed' | 'blocked' | 'unavailable';
|
|
@@ -125,6 +158,21 @@ export function recordRunnerReservationPromotionFailure(
|
|
|
125
158
|
recordMetric(() => runnerReservationPromotionFailureCount.add(1, {reason}));
|
|
126
159
|
}
|
|
127
160
|
|
|
161
|
+
export function recordRunnerActivationTokenNotIssued(params: {
|
|
162
|
+
reason: RunnerActivationTokenNotIssuedReason;
|
|
163
|
+
surface: RunnerActivationTokenNotIssuedSurface;
|
|
164
|
+
}): void {
|
|
165
|
+
recordMetric(() => runnerActivationTokenNotIssuedCount.add(1, params));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function recordRunnerReservationCapacityFailure(
|
|
169
|
+
reason: RunnerReservationCapacityFailureReason,
|
|
170
|
+
count: number,
|
|
171
|
+
): void {
|
|
172
|
+
if (count <= 0) return;
|
|
173
|
+
recordMetric(() => runnerReservationCapacityFailureCount.add(count, {reason}));
|
|
174
|
+
}
|
|
175
|
+
|
|
128
176
|
export function recordRunnersRateLimitCheck(params: {
|
|
129
177
|
action: RunnersRateLimitAction;
|
|
130
178
|
scope: RunnersRateLimitScope;
|
|
@@ -142,3 +190,12 @@ export function recordRunnersRateLimitCheck(params: {
|
|
|
142
190
|
export function recordRunnersRateLimitPruneFailure(): void {
|
|
143
191
|
recordMetric(() => rateLimitPruneFailureCount.add(1));
|
|
144
192
|
}
|
|
193
|
+
|
|
194
|
+
export function recordProviderRunnerActivationOutcome(params: {
|
|
195
|
+
outcome: 'reaped' | 'rebound';
|
|
196
|
+
count?: number;
|
|
197
|
+
}): void {
|
|
198
|
+
recordMetric(() =>
|
|
199
|
+
providerRunnerActivationOutcomeCount.add(params.count ?? 1, {outcome: params.outcome}),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -33,7 +33,14 @@ vi.mock('#db/runner-instances.js', () => ({
|
|
|
33
33
|
countStaleEnrolledRunnerInstances: mocks.countStaleEnrolledRunnerInstances,
|
|
34
34
|
}));
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
let registerRunnersServiceMetrics: typeof import('./service.js').registerRunnersServiceMetrics;
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
// This package intentionally runs with isolate: false. Reset the shared module cache before
|
|
40
|
+
// importing the service so a previous file's real OpenTelemetry module cannot bypass this mock.
|
|
41
|
+
vi.resetModules();
|
|
42
|
+
({registerRunnersServiceMetrics} = await import('./service.js'));
|
|
43
|
+
});
|
|
37
44
|
|
|
38
45
|
describe('registerRunnersServiceMetrics', () => {
|
|
39
46
|
beforeEach(() => {
|
|
@@ -17,6 +17,9 @@ export function toPollDemandResponseDto(result: PollDemandResult): PollDemandRes
|
|
|
17
17
|
count: reservation.count,
|
|
18
18
|
expires_at: reservation.expiresAt.toISOString(),
|
|
19
19
|
})),
|
|
20
|
+
...(result.newlyReservedCount !== undefined
|
|
21
|
+
? {newly_reserved_count: result.newlyReservedCount}
|
|
22
|
+
: {}),
|
|
20
23
|
terminate_provider_runner_ids: result.terminateRunnerInstanceIds,
|
|
21
24
|
};
|
|
22
25
|
}
|
|
@@ -14,10 +14,14 @@ export function toReportRunnerInstancesResponseDto(result: ReportRunnerInstances
|
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function toReconcileRunnerInstancesResponseDto(
|
|
17
|
+
export function toReconcileRunnerInstancesResponseDto(
|
|
18
|
+
result: ReconcileRunnerInstancesResult,
|
|
19
|
+
options: {includeIntendedReservationId: boolean},
|
|
20
|
+
): {
|
|
18
21
|
runners: Array<{
|
|
19
22
|
provider_runner_id: string;
|
|
20
23
|
state: ReconcileRunnerInstancesResult['runners'][number]['state'];
|
|
24
|
+
intended_reservation_id?: string | null;
|
|
21
25
|
reservation_id: string | null;
|
|
22
26
|
runner_session_id: string | null;
|
|
23
27
|
bound_job: {
|
|
@@ -34,6 +38,9 @@ export function toReconcileRunnerInstancesResponseDto(result: ReconcileRunnerIns
|
|
|
34
38
|
runners: result.runners.map((runner) => ({
|
|
35
39
|
provider_runner_id: runner.providerRunnerId,
|
|
36
40
|
state: runner.state,
|
|
41
|
+
...(options.includeIntendedReservationId
|
|
42
|
+
? {intended_reservation_id: runner.intendedReservationId}
|
|
43
|
+
: {}),
|
|
37
44
|
reservation_id: runner.reservationId,
|
|
38
45
|
runner_session_id: runner.runnerSessionId,
|
|
39
46
|
bound_job: runner.boundJobExecution
|
|
@@ -2,6 +2,8 @@ import {requireProvisionerContext} from '@shipfox/api-auth-context';
|
|
|
2
2
|
import {
|
|
3
3
|
assignRunnerInstancesBodySchema,
|
|
4
4
|
assignRunnerInstancesResponseSchema,
|
|
5
|
+
RESERVATION_EXPIRED_ERROR_CODE,
|
|
6
|
+
RUNNER_INSTANCE_NOT_ASSIGNABLE_ERROR_CODE,
|
|
5
7
|
} from '@shipfox/api-runners-dto';
|
|
6
8
|
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
7
9
|
import {
|
|
@@ -24,14 +26,16 @@ export const assignRunnerInstancesRoute = defineRoute({
|
|
|
24
26
|
if (error instanceof ReservationNotFoundError)
|
|
25
27
|
throw new ClientError('Reservation not found', 'reservation-not-found', {status: 404});
|
|
26
28
|
if (error instanceof ReservationExpiredError)
|
|
27
|
-
throw new ClientError('Reservation has expired',
|
|
29
|
+
throw new ClientError('Reservation has expired', RESERVATION_EXPIRED_ERROR_CODE, {
|
|
30
|
+
status: 409,
|
|
31
|
+
});
|
|
28
32
|
if (
|
|
29
33
|
error instanceof RunnerInstanceAlreadyAssignedError ||
|
|
30
34
|
error instanceof RunnerInstanceNotAssignableError
|
|
31
35
|
)
|
|
32
36
|
throw new ClientError(
|
|
33
37
|
'Runner instance cannot be assigned',
|
|
34
|
-
|
|
38
|
+
RUNNER_INSTANCE_NOT_ASSIGNABLE_ERROR_CODE,
|
|
35
39
|
{status: 409},
|
|
36
40
|
);
|
|
37
41
|
throw error;
|