@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.
Files changed (134) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +22 -0
  3. package/dist/config.d.ts +1 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +9 -2
  6. package/dist/config.js.map +1 -1
  7. package/dist/core/demand.d.ts +4 -2
  8. package/dist/core/demand.d.ts.map +1 -1
  9. package/dist/core/demand.js +22 -6
  10. package/dist/core/demand.js.map +1 -1
  11. package/dist/core/entities/reservation.d.ts +1 -0
  12. package/dist/core/entities/reservation.d.ts.map +1 -1
  13. package/dist/core/entities/reservation.js.map +1 -1
  14. package/dist/core/entities/runner-instance.d.ts +3 -0
  15. package/dist/core/entities/runner-instance.d.ts.map +1 -1
  16. package/dist/core/entities/runner-instance.js.map +1 -1
  17. package/dist/core/runner-activation.d.ts +4 -1
  18. package/dist/core/runner-activation.d.ts.map +1 -1
  19. package/dist/core/runner-activation.js +32 -3
  20. package/dist/core/runner-activation.js.map +1 -1
  21. package/dist/core/runner-control-sessions.d.ts +8 -4
  22. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  23. package/dist/core/runner-control-sessions.js +55 -9
  24. package/dist/core/runner-control-sessions.js.map +1 -1
  25. package/dist/core/runner-instances.d.ts +1 -0
  26. package/dist/core/runner-instances.d.ts.map +1 -1
  27. package/dist/core/runner-instances.js +1 -0
  28. package/dist/core/runner-instances.js.map +1 -1
  29. package/dist/db/db.d.ts +68 -0
  30. package/dist/db/db.d.ts.map +1 -1
  31. package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
  32. package/dist/db/ephemeral-registration-tokens.js +1 -1
  33. package/dist/db/ephemeral-registration-tokens.js.map +1 -1
  34. package/dist/db/reservation-locks.d.ts +6 -0
  35. package/dist/db/reservation-locks.d.ts.map +1 -0
  36. package/dist/db/reservation-locks.js +15 -0
  37. package/dist/db/reservation-locks.js.map +1 -0
  38. package/dist/db/reservations.d.ts +17 -3
  39. package/dist/db/reservations.d.ts.map +1 -1
  40. package/dist/db/reservations.js +246 -51
  41. package/dist/db/reservations.js.map +1 -1
  42. package/dist/db/runner-assignments.d.ts +17 -0
  43. package/dist/db/runner-assignments.d.ts.map +1 -1
  44. package/dist/db/runner-assignments.js +103 -8
  45. package/dist/db/runner-assignments.js.map +1 -1
  46. package/dist/db/runner-instances.d.ts +2 -2
  47. package/dist/db/runner-instances.d.ts.map +1 -1
  48. package/dist/db/runner-instances.js +124 -24
  49. package/dist/db/runner-instances.js.map +1 -1
  50. package/dist/db/runner-states.d.ts +2 -0
  51. package/dist/db/runner-states.d.ts.map +1 -0
  52. package/dist/db/runner-states.js +7 -0
  53. package/dist/db/runner-states.js.map +1 -0
  54. package/dist/db/schema/reservations.d.ts +18 -0
  55. package/dist/db/schema/reservations.d.ts.map +1 -1
  56. package/dist/db/schema/reservations.js +7 -1
  57. package/dist/db/schema/reservations.js.map +1 -1
  58. package/dist/db/schema/runner-instances.d.ts +18 -0
  59. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  60. package/dist/db/schema/runner-instances.js +8 -0
  61. package/dist/db/schema/runner-instances.js.map +1 -1
  62. package/dist/metrics/index.d.ts +2 -2
  63. package/dist/metrics/index.d.ts.map +1 -1
  64. package/dist/metrics/index.js +1 -1
  65. package/dist/metrics/index.js.map +1 -1
  66. package/dist/metrics/instance.d.ts +24 -2
  67. package/dist/metrics/instance.d.ts.map +1 -1
  68. package/dist/metrics/instance.js +23 -0
  69. package/dist/metrics/instance.js.map +1 -1
  70. package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
  71. package/dist/presentation/dto/poll-demand.js +3 -0
  72. package/dist/presentation/dto/poll-demand.js.map +1 -1
  73. package/dist/presentation/dto/runner-instances.d.ts +4 -1
  74. package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
  75. package/dist/presentation/dto/runner-instances.js +4 -1
  76. package/dist/presentation/dto/runner-instances.js.map +1 -1
  77. package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
  78. package/dist/presentation/routes/assign-runner-instances.js +3 -3
  79. package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
  80. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  81. package/dist/presentation/routes/poll-demand.js +1 -0
  82. package/dist/presentation/routes/poll-demand.js.map +1 -1
  83. package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
  84. package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
  85. package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
  86. package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
  87. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  88. package/dist/presentation/routes/runner-enrollment.js +11 -6
  89. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  90. package/dist/tsconfig.test.tsbuildinfo +1 -1
  91. package/drizzle/0005_previous_lenny_balinger.sql +5 -0
  92. package/drizzle/meta/0005_snapshot.json +2220 -0
  93. package/drizzle/meta/_journal.json +7 -0
  94. package/package.json +2 -2
  95. package/src/config.test.ts +22 -0
  96. package/src/config.ts +15 -2
  97. package/src/core/demand.test.ts +192 -3
  98. package/src/core/demand.ts +44 -4
  99. package/src/core/entities/reservation.ts +1 -0
  100. package/src/core/entities/runner-instance.ts +4 -0
  101. package/src/core/runner-activation.ts +48 -2
  102. package/src/core/runner-control-sessions.test.ts +64 -3
  103. package/src/core/runner-control-sessions.ts +62 -9
  104. package/src/core/runner-instances.test.ts +19 -0
  105. package/src/core/runner-instances.ts +2 -0
  106. package/src/core/runner-sessions.test.ts +121 -1
  107. package/src/db/ephemeral-registration-tokens.test.ts +20 -1
  108. package/src/db/ephemeral-registration-tokens.ts +1 -0
  109. package/src/db/reservation-locks.ts +22 -0
  110. package/src/db/reservations.test.ts +733 -22
  111. package/src/db/reservations.ts +408 -144
  112. package/src/db/runner-assignments.test.ts +105 -1
  113. package/src/db/runner-assignments.ts +178 -9
  114. package/src/db/runner-instances.test.ts +265 -2
  115. package/src/db/runner-instances.ts +215 -55
  116. package/src/db/runner-states.ts +7 -0
  117. package/src/db/schema/reservations.ts +5 -1
  118. package/src/db/schema/runner-instances.ts +9 -0
  119. package/src/metrics/index.ts +10 -1
  120. package/src/metrics/instance.ts +59 -2
  121. package/src/metrics/service.test.ts +8 -1
  122. package/src/presentation/dto/poll-demand.ts +3 -0
  123. package/src/presentation/dto/runner-instances.ts +8 -1
  124. package/src/presentation/routes/assign-runner-instances.ts +6 -2
  125. package/src/presentation/routes/late-runner-enrollment.test.ts +219 -2
  126. package/src/presentation/routes/poll-demand.test.ts +56 -0
  127. package/src/presentation/routes/poll-demand.ts +1 -0
  128. package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
  129. package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
  130. package/src/presentation/routes/runner-enrollment.test.ts +316 -3
  131. package/src/presentation/routes/runner-enrollment.ts +8 -5
  132. package/test/factories/reservation.ts +2 -0
  133. package/test/factories/runner-instance.ts +6 -0
  134. 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
- events,
163
+ reservationSafeEvents,
162
164
  );
163
165
 
164
- const values = events.map((event) => ({
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, events)
218
+ ? await releaseTerminalRunnerInstanceReservations(tx, params, reservationSafeEvents)
217
219
  : 0;
218
220
 
219
- return {accepted: events.length, reservationsReleased, terminateIntentsHonored};
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
- return returnedRows.flatMap((row) =>
340
- row.providerRunnerId ? [{...row, providerRunnerId: 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
- reason: sql<RunnerInstanceTerminateIntentReason>`'job-cancelled'`,
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(runningJobExecutions)
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(runningJobExecutions.workspaceId, params.workspaceId),
367
- eq(runningJobExecutions.provisionerId, params.provisionerId),
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
- notExists(
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(
@@ -0,0 +1,7 @@
1
+ import type {RunnerInstanceState} from '#core/entities/runner-instance.js';
2
+
3
+ export const terminalStates = [
4
+ 'stopped',
5
+ 'failed',
6
+ 'terminated',
7
+ ] as const satisfies readonly RunnerInstanceState[];
@@ -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,
@@ -1,13 +1,22 @@
1
- export type {RunnerReservationPromotionFailureReason} from './instance.js';
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,
@@ -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
- import {registerRunnersServiceMetrics} from './service.js';
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(result: ReconcileRunnerInstancesResult): {
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', 'reservation-expired', {status: 409});
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
- 'runner-instance-not-assignable',
38
+ RUNNER_INSTANCE_NOT_ASSIGNABLE_ERROR_CODE,
35
39
  {status: 409},
36
40
  );
37
41
  throw error;