@shipfox/api-runners 12.3.0 → 12.4.1

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 (153) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +36 -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/errors.d.ts +5 -1
  18. package/dist/core/errors.d.ts.map +1 -1
  19. package/dist/core/errors.js +9 -2
  20. package/dist/core/errors.js.map +1 -1
  21. package/dist/core/runner-activation.d.ts +4 -1
  22. package/dist/core/runner-activation.d.ts.map +1 -1
  23. package/dist/core/runner-activation.js +32 -3
  24. package/dist/core/runner-activation.js.map +1 -1
  25. package/dist/core/runner-control-sessions.d.ts +8 -4
  26. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  27. package/dist/core/runner-control-sessions.js +116 -20
  28. package/dist/core/runner-control-sessions.js.map +1 -1
  29. package/dist/core/runner-instances.d.ts +1 -0
  30. package/dist/core/runner-instances.d.ts.map +1 -1
  31. package/dist/core/runner-instances.js +1 -0
  32. package/dist/core/runner-instances.js.map +1 -1
  33. package/dist/db/db.d.ts +68 -0
  34. package/dist/db/db.d.ts.map +1 -1
  35. package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
  36. package/dist/db/ephemeral-registration-tokens.js +1 -1
  37. package/dist/db/ephemeral-registration-tokens.js.map +1 -1
  38. package/dist/db/job-executions.d.ts.map +1 -1
  39. package/dist/db/job-executions.js +35 -4
  40. package/dist/db/job-executions.js.map +1 -1
  41. package/dist/db/reservation-locks.d.ts +6 -0
  42. package/dist/db/reservation-locks.d.ts.map +1 -0
  43. package/dist/db/reservation-locks.js +15 -0
  44. package/dist/db/reservation-locks.js.map +1 -0
  45. package/dist/db/reservations.d.ts +17 -3
  46. package/dist/db/reservations.d.ts.map +1 -1
  47. package/dist/db/reservations.js +246 -51
  48. package/dist/db/reservations.js.map +1 -1
  49. package/dist/db/runner-assignments.d.ts +25 -1
  50. package/dist/db/runner-assignments.d.ts.map +1 -1
  51. package/dist/db/runner-assignments.js +160 -18
  52. package/dist/db/runner-assignments.js.map +1 -1
  53. package/dist/db/runner-instances.d.ts +11 -2
  54. package/dist/db/runner-instances.d.ts.map +1 -1
  55. package/dist/db/runner-instances.js +167 -24
  56. package/dist/db/runner-instances.js.map +1 -1
  57. package/dist/db/runner-sessions.d.ts.map +1 -1
  58. package/dist/db/runner-sessions.js +14 -1
  59. package/dist/db/runner-sessions.js.map +1 -1
  60. package/dist/db/runner-states.d.ts +2 -0
  61. package/dist/db/runner-states.d.ts.map +1 -0
  62. package/dist/db/runner-states.js +7 -0
  63. package/dist/db/runner-states.js.map +1 -0
  64. package/dist/db/schema/reservations.d.ts +18 -0
  65. package/dist/db/schema/reservations.d.ts.map +1 -1
  66. package/dist/db/schema/reservations.js +7 -1
  67. package/dist/db/schema/reservations.js.map +1 -1
  68. package/dist/db/schema/runner-instances.d.ts +18 -0
  69. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  70. package/dist/db/schema/runner-instances.js +8 -0
  71. package/dist/db/schema/runner-instances.js.map +1 -1
  72. package/dist/metrics/index.d.ts +2 -2
  73. package/dist/metrics/index.d.ts.map +1 -1
  74. package/dist/metrics/index.js +1 -1
  75. package/dist/metrics/index.js.map +1 -1
  76. package/dist/metrics/instance.d.ts +72 -2
  77. package/dist/metrics/instance.d.ts.map +1 -1
  78. package/dist/metrics/instance.js +161 -1
  79. package/dist/metrics/instance.js.map +1 -1
  80. package/dist/metrics/service.d.ts.map +1 -1
  81. package/dist/metrics/service.js +25 -4
  82. package/dist/metrics/service.js.map +1 -1
  83. package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
  84. package/dist/presentation/dto/poll-demand.js +3 -0
  85. package/dist/presentation/dto/poll-demand.js.map +1 -1
  86. package/dist/presentation/dto/runner-instances.d.ts +4 -1
  87. package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
  88. package/dist/presentation/dto/runner-instances.js +4 -1
  89. package/dist/presentation/dto/runner-instances.js.map +1 -1
  90. package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
  91. package/dist/presentation/routes/assign-runner-instances.js +10 -4
  92. package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
  93. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  94. package/dist/presentation/routes/poll-demand.js +1 -0
  95. package/dist/presentation/routes/poll-demand.js.map +1 -1
  96. package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
  97. package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
  98. package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
  99. package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
  100. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  101. package/dist/presentation/routes/runner-enrollment.js +11 -6
  102. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  103. package/dist/tsconfig.test.tsbuildinfo +1 -1
  104. package/drizzle/0005_previous_lenny_balinger.sql +5 -0
  105. package/drizzle/meta/0005_snapshot.json +2220 -0
  106. package/drizzle/meta/_journal.json +7 -0
  107. package/package.json +6 -6
  108. package/src/config.test.ts +22 -0
  109. package/src/config.ts +15 -2
  110. package/src/core/demand.test.ts +192 -3
  111. package/src/core/demand.ts +44 -4
  112. package/src/core/entities/reservation.ts +1 -0
  113. package/src/core/entities/runner-instance.ts +4 -0
  114. package/src/core/errors.ts +30 -1
  115. package/src/core/runner-activation.ts +48 -2
  116. package/src/core/runner-control-sessions.test.ts +135 -3
  117. package/src/core/runner-control-sessions.ts +141 -19
  118. package/src/core/runner-instances.test.ts +19 -0
  119. package/src/core/runner-instances.ts +2 -0
  120. package/src/core/runner-sessions.test.ts +121 -1
  121. package/src/db/ephemeral-registration-tokens.test.ts +20 -1
  122. package/src/db/ephemeral-registration-tokens.ts +1 -0
  123. package/src/db/job-executions.test.ts +98 -12
  124. package/src/db/job-executions.ts +52 -4
  125. package/src/db/reservation-locks.ts +22 -0
  126. package/src/db/reservations.test.ts +733 -22
  127. package/src/db/reservations.ts +408 -144
  128. package/src/db/runner-assignments.test.ts +105 -1
  129. package/src/db/runner-assignments.ts +245 -25
  130. package/src/db/runner-instances.test.ts +357 -2
  131. package/src/db/runner-instances.ts +289 -55
  132. package/src/db/runner-sessions.ts +19 -1
  133. package/src/db/runner-states.ts +7 -0
  134. package/src/db/schema/reservations.ts +5 -1
  135. package/src/db/schema/runner-instances.ts +9 -0
  136. package/src/metrics/index.ts +24 -1
  137. package/src/metrics/instance.test.ts +118 -0
  138. package/src/metrics/instance.ts +247 -3
  139. package/src/metrics/service.test.ts +46 -1
  140. package/src/metrics/service.ts +55 -8
  141. package/src/presentation/dto/poll-demand.ts +3 -0
  142. package/src/presentation/dto/runner-instances.ts +8 -1
  143. package/src/presentation/routes/assign-runner-instances.ts +14 -2
  144. package/src/presentation/routes/late-runner-enrollment.test.ts +219 -2
  145. package/src/presentation/routes/poll-demand.test.ts +56 -0
  146. package/src/presentation/routes/poll-demand.ts +1 -0
  147. package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
  148. package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
  149. package/src/presentation/routes/runner-enrollment.test.ts +316 -3
  150. package/src/presentation/routes/runner-enrollment.ts +8 -5
  151. package/test/factories/reservation.ts +2 -0
  152. package/test/factories/runner-instance.ts +6 -0
  153. 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
 
@@ -278,6 +375,80 @@ export async function countStaleEnrolledRunnerInstances(params: {
278
375
  return row?.count ?? 0;
279
376
  }
280
377
 
378
+ export type ProviderRunnerPhase =
379
+ | 'control_session'
380
+ | 'enrollment'
381
+ | 'assignment'
382
+ | 'activation'
383
+ | 'idle';
384
+
385
+ export interface ProviderRunnerPhaseMetric {
386
+ phase: ProviderRunnerPhase;
387
+ provider: string;
388
+ launchKind: 'demand' | 'warm' | 'manual';
389
+ count: number;
390
+ oldestAgeMilliseconds: number;
391
+ }
392
+
393
+ export async function listProviderRunnerByPhaseMetrics(): Promise<ProviderRunnerPhaseMetric[]> {
394
+ const phase = sql<ProviderRunnerPhase>`case
395
+ when ${runnerControlSessions.id} is null then 'control_session'
396
+ when ${providerRunners.state} <> 'running' then 'enrollment'
397
+ when ${providerRunners.intendedReservationId} is not null
398
+ and ${providerRunners.workspaceId} is null then 'assignment'
399
+ when ${providerRunners.workspaceId} is not null then 'activation'
400
+ else 'idle'
401
+ end`;
402
+ const startedAt = sql<Date | null>`case
403
+ when ${runnerControlSessions.id} is null then ${providerRunners.createdAt}
404
+ when ${providerRunners.state} <> 'running' then ${runnerControlSessions.createdAt}
405
+ when ${providerRunners.intendedReservationId} is not null
406
+ and ${providerRunners.workspaceId} is null then ${runnerControlSessions.createdAt}
407
+ when ${providerRunners.workspaceId} is not null
408
+ then coalesce(${providerRunners.assignedAt}, ${runnerControlSessions.createdAt})
409
+ else coalesce(
410
+ ${runnerControlSessions.createdAt},
411
+ ${providerRunners.assignedAt},
412
+ ${providerRunners.createdAt}
413
+ )
414
+ end`;
415
+ const rows = await db()
416
+ .select({
417
+ phase,
418
+ provider: sql<string>`coalesce(${providerRunners.providerKind}, 'unknown')`,
419
+ launchKind: providerRunners.launchKind,
420
+ count: sql<number>`count(*)::int`,
421
+ oldestAgeMilliseconds: sql<number>`coalesce(
422
+ max(extract(epoch from (now() - (${startedAt}))) * 1000),
423
+ 0
424
+ )::double precision`,
425
+ })
426
+ .from(providerRunners)
427
+ .leftJoin(
428
+ runnerControlSessions,
429
+ and(
430
+ eq(runnerControlSessions.runnerInstanceId, providerRunners.id),
431
+ isNull(runnerControlSessions.closedAt),
432
+ gt(runnerControlSessions.expiresAt, sql`now()`),
433
+ ),
434
+ )
435
+ .where(
436
+ and(
437
+ inArray(providerRunners.state, ['starting', 'running']),
438
+ isNull(providerRunners.runnerSessionId),
439
+ ),
440
+ )
441
+ .groupBy(phase, providerRunners.providerKind, providerRunners.launchKind);
442
+
443
+ return rows.map((row) => ({
444
+ phase: row.phase,
445
+ provider: row.provider,
446
+ launchKind: row.launchKind,
447
+ count: row.count,
448
+ oldestAgeMilliseconds: Math.max(0, row.oldestAgeMilliseconds),
449
+ }));
450
+ }
451
+
281
452
  export async function listActiveRunnerInstances(params: {
282
453
  workspaceId: string;
283
454
  windowSeconds: number;
@@ -336,9 +507,42 @@ export async function listProvisionerTerminateIntentRowsTx(
336
507
  );
337
508
  }
338
509
 
339
- return returnedRows.flatMap((row) =>
340
- row.providerRunnerId ? [{...row, providerRunnerId: row.providerRunnerId}] : [],
510
+ const activationTimeoutRunnerIds = returnedRows.flatMap((row) =>
511
+ row.reason === 'activation-timeout' && row.providerRunnerId ? [row.providerRunnerId] : [],
341
512
  );
513
+ if (activationTimeoutRunnerIds.length > 0) {
514
+ await tx
515
+ .update(providerRunners)
516
+ .set({reservationReleasedAt: sql`now()`, updatedAt: sql`now()`})
517
+ .where(
518
+ and(
519
+ eq(providerRunners.workspaceId, params.workspaceId),
520
+ eq(providerRunners.provisionerId, params.provisionerId),
521
+ inArray(providerRunners.providerRunnerId, activationTimeoutRunnerIds),
522
+ isNull(providerRunners.reservationReleasedAt),
523
+ ),
524
+ );
525
+ }
526
+
527
+ return returnedRows.flatMap((row) => toRunnerInstanceTerminateIntent(row));
528
+ }
529
+
530
+ function toRunnerInstanceTerminateIntent(row: {
531
+ providerRunnerId: string | null;
532
+ reason: RunnerInstanceTerminateIntentReason;
533
+ activationTimeoutRetry: boolean;
534
+ }): RunnerInstanceTerminateIntent[] {
535
+ if (!row.providerRunnerId) return [];
536
+
537
+ return [
538
+ {
539
+ providerRunnerId: row.providerRunnerId,
540
+ reason: row.reason,
541
+ ...(row.reason === 'activation-timeout' && row.activationTimeoutRetry
542
+ ? {activationTimeoutRetry: true}
543
+ : {}),
544
+ },
545
+ ];
342
546
  }
343
547
 
344
548
  function provisionerTerminateIntentsQuery(
@@ -346,59 +550,91 @@ function provisionerTerminateIntentsQuery(
346
550
  params: {workspaceId: string; provisionerId: string; providerRunnerIds?: string[]},
347
551
  ) {
348
552
  const newerRunningJobExecutions = alias(runningJobExecutions, 'newer_running_jobs');
553
+ const latestCancelledJob = exists(
554
+ tx
555
+ .select({id: runningJobExecutions.id})
556
+ .from(runningJobExecutions)
557
+ .where(
558
+ and(
559
+ eq(runningJobExecutions.workspaceId, params.workspaceId),
560
+ eq(runningJobExecutions.provisionerId, params.provisionerId),
561
+ eq(runningJobExecutions.providerRunnerId, providerRunners.providerRunnerId),
562
+ isNotNull(runningJobExecutions.cancellationRequestedAt),
563
+ notExists(
564
+ tx
565
+ .select({id: newerRunningJobExecutions.id})
566
+ .from(newerRunningJobExecutions)
567
+ .where(
568
+ and(
569
+ eq(newerRunningJobExecutions.workspaceId, runningJobExecutions.workspaceId),
570
+ eq(newerRunningJobExecutions.provisionerId, runningJobExecutions.provisionerId),
571
+ eq(
572
+ newerRunningJobExecutions.providerRunnerId,
573
+ runningJobExecutions.providerRunnerId,
574
+ ),
575
+ or(
576
+ gt(newerRunningJobExecutions.startedAt, runningJobExecutions.startedAt),
577
+ and(
578
+ eq(newerRunningJobExecutions.startedAt, runningJobExecutions.startedAt),
579
+ gt(
580
+ newerRunningJobExecutions.jobExecutionId,
581
+ runningJobExecutions.jobExecutionId,
582
+ ),
583
+ ),
584
+ ),
585
+ ),
586
+ ),
587
+ ),
588
+ ),
589
+ ),
590
+ );
591
+ const activationTimeout = and(
592
+ eq(providerRunners.launchKind, 'demand'),
593
+ isNull(providerRunners.runnerSessionId),
594
+ lt(
595
+ providerRunners.createdAt,
596
+ sql`now() - (${config.RUNNER_DEMAND_ACTIVATION_TIMEOUT_SECONDS} || ' seconds')::interval`,
597
+ ),
598
+ notExists(
599
+ tx
600
+ .select({id: reservations.id})
601
+ .from(reservations)
602
+ .where(
603
+ and(
604
+ or(
605
+ eq(reservations.id, providerRunners.reservationId),
606
+ eq(reservations.id, providerRunners.intendedReservationId),
607
+ ),
608
+ eq(reservations.workspaceId, params.workspaceId),
609
+ eq(reservations.provisionerId, params.provisionerId),
610
+ gt(reservations.expiresAt, sql`now()`),
611
+ ),
612
+ ),
613
+ ),
614
+ );
349
615
 
350
616
  return tx
351
617
  .select({
352
618
  providerRunnerId: providerRunners.providerRunnerId,
353
- reason: sql<RunnerInstanceTerminateIntentReason>`'job-cancelled'`,
619
+ activationTimeoutRetry: sql<boolean>`${providerRunners.reservationReleasedAt} is not null`,
620
+ reason: sql<RunnerInstanceTerminateIntentReason>`case
621
+ when ${activationTimeout} then 'activation-timeout'
622
+ else 'job-cancelled'
623
+ end`,
354
624
  })
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
- )
625
+ .from(providerRunners)
364
626
  .where(
365
627
  and(
366
- eq(runningJobExecutions.workspaceId, params.workspaceId),
367
- eq(runningJobExecutions.provisionerId, params.provisionerId),
628
+ eq(providerRunners.workspaceId, params.workspaceId),
629
+ eq(providerRunners.provisionerId, params.provisionerId),
368
630
  isNotNull(providerRunners.providerRunnerId),
369
- isNotNull(runningJobExecutions.cancellationRequestedAt),
370
631
  inArray(providerRunners.state, activeStates),
371
632
  params.providerRunnerIds && params.providerRunnerIds.length > 0
372
633
  ? inArray(providerRunners.providerRunnerId, params.providerRunnerIds)
373
634
  : 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
- ),
635
+ or(latestCancelledJob, activationTimeout),
399
636
  ),
400
- )
401
- .groupBy(providerRunners.providerRunnerId);
637
+ );
402
638
  }
403
639
 
404
640
  async function listTerminateIntentsHonoredByTerminatedReportsTx(
@@ -419,9 +655,7 @@ async function listTerminateIntentsHonoredByTerminatedReportsTx(
419
655
  provisionerId: params.provisionerId,
420
656
  providerRunnerIds: terminatedRunnerInstanceIds,
421
657
  }).orderBy(asc(providerRunners.providerRunnerId));
422
- return rows.flatMap((row) =>
423
- row.providerRunnerId ? [{...row, providerRunnerId: row.providerRunnerId}] : [],
424
- );
658
+ return rows.flatMap((row) => toRunnerInstanceTerminateIntent(row));
425
659
  }
426
660
 
427
661
  export async function reconcileRunnerInstances(
@@ -3,6 +3,10 @@ import {and, asc, eq, gt, inArray, isNull, lt, notExists, or, sql} from 'drizzle
3
3
  import type {RunnerSession} from '#core/entities/runner-session.js';
4
4
  import {EmptyRunnerLabelsError} from '#core/errors.js';
5
5
  import {sanitizeRunnerLabelsOrThrow} from '#core/runner-labels.js';
6
+ import {
7
+ type ProviderRunnerLifecycleObservation,
8
+ recordProviderRunnerAssignmentToActivation,
9
+ } from '#metrics/instance.js';
6
10
  import {db} from './db.js';
7
11
  import {provisionerTokens} from './schema/provisioner-tokens.js';
8
12
  import {runnerActivationTokens} from './schema/runner-activation-tokens.js';
@@ -58,7 +62,8 @@ export async function createRunnerSessionConsumingActivationToken(params: {
58
62
  labels: string[];
59
63
  toolCapabilities?: RunnerToolCapabilitiesDto | null;
60
64
  }) {
61
- return await db().transaction(async (tx) => {
65
+ let assignmentToActivationObservation: ProviderRunnerLifecycleObservation | null = null;
66
+ const session = await db().transaction(async (tx) => {
62
67
  const [runner] = await tx
63
68
  .select({
64
69
  activationTokenId: runnerActivationTokens.id,
@@ -66,6 +71,9 @@ export async function createRunnerSessionConsumingActivationToken(params: {
66
71
  workspaceId: providerRunners.workspaceId,
67
72
  provisionerId: providerRunners.provisionerId,
68
73
  providerRunnerId: providerRunners.providerRunnerId,
74
+ assignedAt: providerRunners.assignedAt,
75
+ provider: providerRunners.providerKind,
76
+ launchKind: providerRunners.launchKind,
69
77
  runnerSessionId: providerRunners.runnerSessionId,
70
78
  })
71
79
  .from(runnerActivationTokens)
@@ -143,8 +151,18 @@ export async function createRunnerSessionConsumingActivationToken(params: {
143
151
  isNull(runnerControlSessions.closedAt),
144
152
  ),
145
153
  );
154
+ if (runner.assignedAt)
155
+ assignmentToActivationObservation = {
156
+ durationMilliseconds: session.createdAt.getTime() - runner.assignedAt.getTime(),
157
+ provider: runner.provider,
158
+ launchKind: runner.launchKind,
159
+ runnerInstanceId: runner.runnerInstanceId,
160
+ };
146
161
  return toRunnerSession(session);
147
162
  });
163
+ if (assignmentToActivationObservation)
164
+ recordProviderRunnerAssignmentToActivation(assignmentToActivationObservation);
165
+ return session;
148
166
  }
149
167
 
150
168
  export interface DeleteExpiredRunnerSessionsParams {
@@ -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,36 @@
1
- export type {RunnerReservationPromotionFailureReason} from './instance.js';
1
+ export type {
2
+ RunnerActivationTokenNotIssuedReason,
3
+ RunnerActivationTokenNotIssuedSurface,
4
+ RunnerAssignmentSurface,
5
+ RunnerLaunchKind,
6
+ RunnerReservationCapacityFailureReason,
7
+ RunnerReservationPromotionFailureReason,
8
+ } from './instance.js';
2
9
  export {
3
10
  jobExecutionClaimedCount,
4
11
  jobExecutionEnqueuedCount,
5
12
  jobExecutionLeaseExpiredCount,
13
+ jobExecutionQueueTimeDuration,
6
14
  providerRunnerAbsentTerminatedCount,
15
+ providerRunnerActivationOutcomeCount,
16
+ providerRunnerActivationToFirstClaimDuration,
17
+ providerRunnerAssignmentRejectedCount,
18
+ providerRunnerAssignmentToActivationDuration,
19
+ providerRunnerControlSessionToAssignmentDuration,
7
20
  providerRunnerCountDivergenceCount,
21
+ providerRunnerCreatedToControlSessionDuration,
8
22
  providerRunnerReconcileCallCount,
9
23
  providerRunnerTerminateIntentHonoredCount,
10
24
  providerRunnerTerminateIntentIssuedCount,
25
+ recordJobExecutionQueueTime,
26
+ recordProviderRunnerActivationOutcome,
27
+ recordProviderRunnerActivationToFirstClaim,
28
+ recordProviderRunnerAssignmentRejected,
29
+ recordProviderRunnerAssignmentToActivation,
30
+ recordProviderRunnerControlSessionToAssignment,
31
+ recordProviderRunnerCreatedToControlSession,
32
+ recordRunnerActivationTokenNotIssued,
33
+ recordRunnerReservationCapacityFailure,
11
34
  recordRunnerReservationPromotionFailure,
12
35
  recordRunnersRateLimitCheck,
13
36
  recordRunnersRateLimitPruneFailure,