@shipfox/api-runners 12.2.0 → 12.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +34 -0
  3. package/dist/config.d.ts +1 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +10 -3
  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 +69 -11
  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/index.d.ts +1 -1
  35. package/dist/db/index.d.ts.map +1 -1
  36. package/dist/db/index.js +1 -1
  37. package/dist/db/index.js.map +1 -1
  38. package/dist/db/reservation-locks.d.ts +6 -0
  39. package/dist/db/reservation-locks.d.ts.map +1 -0
  40. package/dist/db/reservation-locks.js +15 -0
  41. package/dist/db/reservation-locks.js.map +1 -0
  42. package/dist/db/reservations.d.ts +17 -3
  43. package/dist/db/reservations.d.ts.map +1 -1
  44. package/dist/db/reservations.js +256 -45
  45. package/dist/db/reservations.js.map +1 -1
  46. package/dist/db/runner-assignments.d.ts +17 -0
  47. package/dist/db/runner-assignments.d.ts.map +1 -1
  48. package/dist/db/runner-assignments.js +103 -8
  49. package/dist/db/runner-assignments.js.map +1 -1
  50. package/dist/db/runner-instances.d.ts +5 -2
  51. package/dist/db/runner-instances.d.ts.map +1 -1
  52. package/dist/db/runner-instances.js +134 -25
  53. package/dist/db/runner-instances.js.map +1 -1
  54. package/dist/db/runner-sessions.d.ts.map +1 -1
  55. package/dist/db/runner-sessions.js +22 -15
  56. package/dist/db/runner-sessions.js.map +1 -1
  57. package/dist/db/runner-states.d.ts +2 -0
  58. package/dist/db/runner-states.d.ts.map +1 -0
  59. package/dist/db/runner-states.js +7 -0
  60. package/dist/db/runner-states.js.map +1 -0
  61. package/dist/db/schema/reservations.d.ts +18 -0
  62. package/dist/db/schema/reservations.d.ts.map +1 -1
  63. package/dist/db/schema/reservations.js +7 -1
  64. package/dist/db/schema/reservations.js.map +1 -1
  65. package/dist/db/schema/runner-instances.d.ts +18 -0
  66. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  67. package/dist/db/schema/runner-instances.js +8 -0
  68. package/dist/db/schema/runner-instances.js.map +1 -1
  69. package/dist/metrics/index.d.ts +2 -2
  70. package/dist/metrics/index.d.ts.map +1 -1
  71. package/dist/metrics/index.js +1 -1
  72. package/dist/metrics/index.js.map +1 -1
  73. package/dist/metrics/instance.d.ts +24 -2
  74. package/dist/metrics/instance.d.ts.map +1 -1
  75. package/dist/metrics/instance.js +23 -0
  76. package/dist/metrics/instance.js.map +1 -1
  77. package/dist/metrics/service.d.ts.map +1 -1
  78. package/dist/metrics/service.js +20 -4
  79. package/dist/metrics/service.js.map +1 -1
  80. package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
  81. package/dist/presentation/dto/poll-demand.js +3 -0
  82. package/dist/presentation/dto/poll-demand.js.map +1 -1
  83. package/dist/presentation/dto/runner-instances.d.ts +4 -1
  84. package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
  85. package/dist/presentation/dto/runner-instances.js +4 -1
  86. package/dist/presentation/dto/runner-instances.js.map +1 -1
  87. package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
  88. package/dist/presentation/routes/assign-runner-instances.js +3 -3
  89. package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
  90. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  91. package/dist/presentation/routes/poll-demand.js +1 -0
  92. package/dist/presentation/routes/poll-demand.js.map +1 -1
  93. package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
  94. package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
  95. package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
  96. package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
  97. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  98. package/dist/presentation/routes/runner-enrollment.js +11 -6
  99. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  100. package/dist/tsconfig.test.tsbuildinfo +1 -1
  101. package/drizzle/0005_previous_lenny_balinger.sql +5 -0
  102. package/drizzle/meta/0005_snapshot.json +2220 -0
  103. package/drizzle/meta/_journal.json +7 -0
  104. package/package.json +4 -4
  105. package/src/config.test.ts +24 -2
  106. package/src/config.ts +16 -3
  107. package/src/core/demand.test.ts +192 -3
  108. package/src/core/demand.ts +44 -4
  109. package/src/core/entities/reservation.ts +1 -0
  110. package/src/core/entities/runner-instance.ts +4 -0
  111. package/src/core/runner-activation.ts +48 -2
  112. package/src/core/runner-control-sessions.test.ts +230 -4
  113. package/src/core/runner-control-sessions.ts +84 -10
  114. package/src/core/runner-instances.test.ts +19 -0
  115. package/src/core/runner-instances.ts +2 -0
  116. package/src/core/runner-sessions.test.ts +121 -1
  117. package/src/db/ephemeral-registration-tokens.test.ts +20 -1
  118. package/src/db/ephemeral-registration-tokens.ts +1 -0
  119. package/src/db/index.ts +1 -0
  120. package/src/db/reservation-locks.ts +22 -0
  121. package/src/db/reservations.test.ts +1110 -31
  122. package/src/db/reservations.ts +434 -105
  123. package/src/db/runner-assignments.test.ts +105 -1
  124. package/src/db/runner-assignments.ts +178 -9
  125. package/src/db/runner-instances.test.ts +496 -2
  126. package/src/db/runner-instances.ts +247 -56
  127. package/src/db/runner-sessions.ts +26 -15
  128. package/src/db/runner-states.ts +7 -0
  129. package/src/db/schema/reservations.ts +5 -1
  130. package/src/db/schema/runner-instances.ts +9 -0
  131. package/src/metrics/index.ts +10 -1
  132. package/src/metrics/instance.ts +59 -2
  133. package/src/metrics/service.test.ts +106 -0
  134. package/src/metrics/service.ts +24 -4
  135. package/src/presentation/dto/poll-demand.ts +3 -0
  136. package/src/presentation/dto/runner-instances.ts +8 -1
  137. package/src/presentation/routes/assign-runner-instances.ts +6 -2
  138. package/src/presentation/routes/late-runner-enrollment.test.ts +447 -0
  139. package/src/presentation/routes/poll-demand.test.ts +79 -6
  140. package/src/presentation/routes/poll-demand.ts +1 -0
  141. package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
  142. package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
  143. package/src/presentation/routes/runner-enrollment.test.ts +316 -3
  144. package/src/presentation/routes/runner-enrollment.ts +8 -5
  145. package/test/factories/reservation.ts +2 -0
  146. package/test/factories/runner-instance.ts +6 -0
  147. package/test/fixtures/late-runner-enrollment.ts +103 -0
  148. package/test/index.ts +5 -0
  149. package/tsconfig.build.tsbuildinfo +1 -1
@@ -17,27 +17,28 @@ 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';
37
+ import {runnerControlSessions} from './schema/runner-control-sessions.js';
32
38
  import {providerRunners, toRunnerInstance} from './schema/runner-instances.js';
33
39
  import {runnerSessions} from './schema/runner-sessions.js';
34
40
  import {runningJobExecutions} from './schema/running-job-executions.js';
35
41
 
36
- export const terminalStates = [
37
- 'stopped',
38
- 'failed',
39
- 'terminated',
40
- ] as const satisfies readonly RunnerInstanceState[];
41
42
  export const activeStates = [
42
43
  'starting',
43
44
  'running',
@@ -48,11 +49,12 @@ export const divergenceCountStates = ['starting', 'running'] as const satisfies
48
49
  'starting' | 'running'
49
50
  >[];
50
51
 
51
- export type RunnerInstanceTerminateIntentReason = 'job-cancelled';
52
+ export type RunnerInstanceTerminateIntentReason = 'activation-timeout' | 'job-cancelled';
52
53
 
53
54
  export interface RunnerInstanceTerminateIntent {
54
55
  providerRunnerId: string;
55
56
  reason: RunnerInstanceTerminateIntentReason;
57
+ activationTimeoutRetry?: boolean;
56
58
  }
57
59
 
58
60
  export interface ActiveRunnerInstanceTemplateCount {
@@ -154,13 +156,14 @@ export async function reportRunnerInstances(params: ReportRunnerInstancesParams)
154
156
  }
155
157
 
156
158
  const events = await hydrateRunnerSessionIdsFromConsumedTokens(tx, params, aggregatedEvents);
159
+ const reservationSafeEvents = await guardReportedReservationIdsTx(tx, params, events);
157
160
  const terminateIntentsHonored = await listTerminateIntentsHonoredByTerminatedReportsTx(
158
161
  tx,
159
162
  params,
160
- events,
163
+ reservationSafeEvents,
161
164
  );
162
165
 
163
- const values = events.map((event) => ({
166
+ const values = reservationSafeEvents.map((event) => ({
164
167
  workspaceId: params.workspaceId,
165
168
  provisionerId: params.provisionerId,
166
169
  providerRunnerId: event.providerRunnerId,
@@ -190,7 +193,7 @@ export async function reportRunnerInstances(params: ReportRunnerInstancesParams)
190
193
  target: [providerRunners.provisionerId, providerRunners.providerRunnerId],
191
194
  targetWhere: isNotNull(providerRunners.providerRunnerId),
192
195
  set: {
193
- reservationId: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN coalesce(excluded.reservation_id, ${providerRunners.reservationId}) ELSE ${providerRunners.reservationId} END`,
196
+ reservationId: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN coalesce(${providerRunners.reservationId}, excluded.reservation_id) ELSE ${providerRunners.reservationId} END`,
194
197
  templateKey: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN coalesce(excluded.template_key, ${providerRunners.templateKey}) ELSE ${providerRunners.templateKey} END`,
195
198
  labels: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN excluded.labels ELSE ${providerRunners.labels} END`,
196
199
  state: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN excluded.state ELSE ${providerRunners.state} END`,
@@ -212,10 +215,105 @@ export async function reportRunnerInstances(params: ReportRunnerInstancesParams)
212
215
  });
213
216
 
214
217
  const reservationsReleased = hasTerminalEvent
215
- ? await releaseTerminalRunnerInstanceReservations(tx, params, events)
218
+ ? await releaseTerminalRunnerInstanceReservations(tx, params, reservationSafeEvents)
216
219
  : 0;
217
220
 
218
- 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;
219
317
  });
220
318
  }
221
319
 
@@ -247,6 +345,36 @@ export async function listActiveRunnerInstanceCountsByTemplateTx(
247
345
  );
248
346
  }
249
347
 
348
+ export async function countStaleEnrolledRunnerInstances(params: {
349
+ graceSeconds: number;
350
+ }): Promise<number> {
351
+ const [row] = await db()
352
+ .select({count: sql<number>`count(*)::int`})
353
+ .from(providerRunners)
354
+ .where(
355
+ and(
356
+ eq(providerRunners.state, 'running'),
357
+ isNull(providerRunners.workspaceId),
358
+ isNull(providerRunners.runnerSessionId),
359
+ lt(providerRunners.reportedAt, staleRunnerInstanceCutoff(params.graceSeconds)),
360
+ exists(
361
+ db()
362
+ .select({id: runnerControlSessions.id})
363
+ .from(runnerControlSessions)
364
+ .where(
365
+ and(
366
+ eq(runnerControlSessions.runnerInstanceId, providerRunners.id),
367
+ isNull(runnerControlSessions.closedAt),
368
+ gt(runnerControlSessions.expiresAt, sql`now()`),
369
+ ),
370
+ ),
371
+ ),
372
+ ),
373
+ );
374
+
375
+ return row?.count ?? 0;
376
+ }
377
+
250
378
  export async function listActiveRunnerInstances(params: {
251
379
  workspaceId: string;
252
380
  windowSeconds: number;
@@ -305,9 +433,42 @@ export async function listProvisionerTerminateIntentRowsTx(
305
433
  );
306
434
  }
307
435
 
308
- return returnedRows.flatMap((row) =>
309
- row.providerRunnerId ? [{...row, providerRunnerId: row.providerRunnerId}] : [],
436
+ const activationTimeoutRunnerIds = returnedRows.flatMap((row) =>
437
+ row.reason === 'activation-timeout' && row.providerRunnerId ? [row.providerRunnerId] : [],
310
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
+ ];
311
472
  }
312
473
 
313
474
  function provisionerTerminateIntentsQuery(
@@ -315,59 +476,91 @@ function provisionerTerminateIntentsQuery(
315
476
  params: {workspaceId: string; provisionerId: string; providerRunnerIds?: string[]},
316
477
  ) {
317
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
+ );
318
541
 
319
542
  return tx
320
543
  .select({
321
544
  providerRunnerId: providerRunners.providerRunnerId,
322
- 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`,
323
550
  })
324
- .from(runningJobExecutions)
325
- .innerJoin(
326
- providerRunners,
327
- and(
328
- eq(providerRunners.workspaceId, runningJobExecutions.workspaceId),
329
- eq(providerRunners.provisionerId, runningJobExecutions.provisionerId),
330
- eq(providerRunners.providerRunnerId, runningJobExecutions.providerRunnerId),
331
- ),
332
- )
551
+ .from(providerRunners)
333
552
  .where(
334
553
  and(
335
- eq(runningJobExecutions.workspaceId, params.workspaceId),
336
- eq(runningJobExecutions.provisionerId, params.provisionerId),
554
+ eq(providerRunners.workspaceId, params.workspaceId),
555
+ eq(providerRunners.provisionerId, params.provisionerId),
337
556
  isNotNull(providerRunners.providerRunnerId),
338
- isNotNull(runningJobExecutions.cancellationRequestedAt),
339
557
  inArray(providerRunners.state, activeStates),
340
558
  params.providerRunnerIds && params.providerRunnerIds.length > 0
341
559
  ? inArray(providerRunners.providerRunnerId, params.providerRunnerIds)
342
560
  : undefined,
343
- notExists(
344
- tx
345
- .select({id: newerRunningJobExecutions.id})
346
- .from(newerRunningJobExecutions)
347
- .where(
348
- and(
349
- eq(newerRunningJobExecutions.workspaceId, runningJobExecutions.workspaceId),
350
- eq(newerRunningJobExecutions.provisionerId, runningJobExecutions.provisionerId),
351
- eq(
352
- newerRunningJobExecutions.providerRunnerId,
353
- runningJobExecutions.providerRunnerId,
354
- ),
355
- or(
356
- gt(newerRunningJobExecutions.startedAt, runningJobExecutions.startedAt),
357
- and(
358
- eq(newerRunningJobExecutions.startedAt, runningJobExecutions.startedAt),
359
- gt(
360
- newerRunningJobExecutions.jobExecutionId,
361
- runningJobExecutions.jobExecutionId,
362
- ),
363
- ),
364
- ),
365
- ),
366
- ),
367
- ),
561
+ or(latestCancelledJob, activationTimeout),
368
562
  ),
369
- )
370
- .groupBy(providerRunners.providerRunnerId);
563
+ );
371
564
  }
372
565
 
373
566
  async function listTerminateIntentsHonoredByTerminatedReportsTx(
@@ -388,9 +581,7 @@ async function listTerminateIntentsHonoredByTerminatedReportsTx(
388
581
  provisionerId: params.provisionerId,
389
582
  providerRunnerIds: terminatedRunnerInstanceIds,
390
583
  }).orderBy(asc(providerRunners.providerRunnerId));
391
- return rows.flatMap((row) =>
392
- row.providerRunnerId ? [{...row, providerRunnerId: row.providerRunnerId}] : [],
393
- );
584
+ return rows.flatMap((row) => toRunnerInstanceTerminateIntent(row));
394
585
  }
395
586
 
396
587
  export async function reconcileRunnerInstances(
@@ -59,33 +59,44 @@ export async function createRunnerSessionConsumingActivationToken(params: {
59
59
  toolCapabilities?: RunnerToolCapabilitiesDto | null;
60
60
  }) {
61
61
  return await db().transaction(async (tx) => {
62
- const [token] = await tx
62
+ const [runner] = await tx
63
63
  .select({
64
- id: runnerActivationTokens.id,
65
- runnerInstanceId: runnerActivationTokens.runnerInstanceId,
64
+ activationTokenId: runnerActivationTokens.id,
65
+ runnerInstanceId: providerRunners.id,
66
66
  workspaceId: providerRunners.workspaceId,
67
67
  provisionerId: providerRunners.provisionerId,
68
68
  providerRunnerId: providerRunners.providerRunnerId,
69
+ runnerSessionId: providerRunners.runnerSessionId,
69
70
  })
70
71
  .from(runnerActivationTokens)
71
72
  .innerJoin(providerRunners, eq(providerRunners.id, runnerActivationTokens.runnerInstanceId))
73
+ .where(eq(runnerActivationTokens.id, params.activationTokenId))
74
+ .limit(1)
75
+ .for('update', {of: providerRunners});
76
+ if (!runner?.workspaceId || !runner.providerRunnerId || runner.runnerSessionId)
77
+ throw new Error('Runner activation token is invalid, expired, or has already been used');
78
+
79
+ const [activationToken] = await tx
80
+ .select({id: runnerActivationTokens.id})
81
+ .from(runnerActivationTokens)
72
82
  .where(
73
83
  and(
74
- eq(runnerActivationTokens.id, params.activationTokenId),
84
+ eq(runnerActivationTokens.id, runner.activationTokenId),
85
+ eq(runnerActivationTokens.runnerInstanceId, runner.runnerInstanceId),
75
86
  isNull(runnerActivationTokens.consumedAt),
76
87
  isNull(runnerActivationTokens.revokedAt),
77
88
  gt(runnerActivationTokens.expiresAt, sql`now()`),
78
- isNull(providerRunners.runnerSessionId),
79
89
  ),
80
90
  )
81
91
  .limit(1)
82
92
  .for('update');
83
- if (!token?.workspaceId || !token.providerRunnerId)
93
+ if (!activationToken)
84
94
  throw new Error('Runner activation token is invalid, expired, or has already been used');
95
+
85
96
  const [provisioner] = await tx
86
97
  .select({scope: provisionerTokens.scope})
87
98
  .from(provisionerTokens)
88
- .where(eq(provisionerTokens.id, token.provisionerId))
99
+ .where(eq(provisionerTokens.id, runner.provisionerId))
89
100
  .limit(1);
90
101
  const labels = sanitizeRunnerLabelsOrThrow(params.labels, {
91
102
  scope: provisioner?.scope ?? 'workspace',
@@ -95,13 +106,13 @@ export async function createRunnerSessionConsumingActivationToken(params: {
95
106
  const [session] = await tx
96
107
  .insert(runnerSessions)
97
108
  .values({
98
- workspaceId: token.workspaceId,
109
+ workspaceId: runner.workspaceId,
99
110
  scope: 'workspace',
100
- registrationTokenId: token.id,
111
+ registrationTokenId: activationToken.id,
101
112
  registrationTokenKind: 'activation',
102
- runnerInstanceId: token.runnerInstanceId,
103
- provisionerId: token.provisionerId,
104
- providerRunnerId: token.providerRunnerId,
113
+ runnerInstanceId: runner.runnerInstanceId,
114
+ provisionerId: runner.provisionerId,
115
+ providerRunnerId: runner.providerRunnerId,
105
116
  labels,
106
117
  toolCapabilities: params.toolCapabilities ?? null,
107
118
  toolCapabilitiesReportedAt: params.toolCapabilities ? sql`now()` : null,
@@ -113,13 +124,13 @@ export async function createRunnerSessionConsumingActivationToken(params: {
113
124
  await tx
114
125
  .update(runnerActivationTokens)
115
126
  .set({consumedAt: sql`now()`, consumedSessionId: session.id})
116
- .where(eq(runnerActivationTokens.id, token.id));
127
+ .where(eq(runnerActivationTokens.id, activationToken.id));
117
128
  await tx
118
129
  .update(providerRunners)
119
130
  .set({runnerSessionId: session.id, updatedAt: sql`now()`})
120
131
  .where(
121
132
  and(
122
- eq(providerRunners.id, token.runnerInstanceId),
133
+ eq(providerRunners.id, runner.runnerInstanceId),
123
134
  isNull(providerRunners.runnerSessionId),
124
135
  ),
125
136
  );
@@ -128,7 +139,7 @@ export async function createRunnerSessionConsumingActivationToken(params: {
128
139
  .set({closedAt: sql`now()`, closeReason: 'activated'})
129
140
  .where(
130
141
  and(
131
- eq(runnerControlSessions.runnerInstanceId, token.runnerInstanceId),
142
+ eq(runnerControlSessions.runnerInstanceId, runner.runnerInstanceId),
132
143
  isNull(runnerControlSessions.closedAt),
133
144
  ),
134
145
  );
@@ -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,