@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
@@ -12,13 +12,17 @@ import {
12
12
  } from '#core/errors.js';
13
13
  import {sanitizeRunnerLabels} from '#core/runner-labels.js';
14
14
  import {db, type schema, type Tx} from '#db/db.js';
15
- import {assignRunnerInstancesTx} from '#db/runner-assignments.js';
16
- import {terminalStates} from '#db/runner-instances.js';
15
+ import {
16
+ assignRunnerInstancesTx,
17
+ validateRunnerReservationCapacityTx,
18
+ } from '#db/runner-assignments.js';
19
+ import {terminalStates} from '#db/runner-states.js';
17
20
  import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
18
21
  import {runnerBootstrapTokens, runnerControlSessions} from '#db/schema/runner-control-sessions.js';
19
22
  import {providerRunners} from '#db/schema/runner-instances.js';
20
23
  import {
21
24
  type RunnerReservationPromotionFailureReason,
25
+ recordRunnerReservationCapacityFailure,
22
26
  recordRunnerReservationPromotionFailure,
23
27
  } from '#metrics/index.js';
24
28
  import {issueRunnerActivationTokenTx} from './runner-activation.js';
@@ -45,27 +49,72 @@ export async function createRunnerInstancesWithBootstrapTokens(params: {
45
49
  reservationId?: string | null;
46
50
  }>;
47
51
  ttlSeconds: number;
48
- }): Promise<Array<{runnerInstanceId: string; bootstrapToken: string}>> {
52
+ }): Promise<{
53
+ runnerInstances: Array<{
54
+ runnerInstanceId: string;
55
+ bootstrapToken: string;
56
+ requestIndex: number;
57
+ }>;
58
+ reservationUnavailable: boolean;
59
+ }> {
49
60
  const expiresAt = new Date(Date.now() + params.ttlSeconds * 1000);
50
61
  return await db().transaction(async (tx) => {
62
+ const reservationValidation = await validateRunnerReservationCapacityTx(tx, {
63
+ provisionerId: params.provisionerId,
64
+ requests: params.runnerInstances.reduce(
65
+ (requests, runner) => {
66
+ if (!runner.reservationId) return requests;
67
+ const request = requests.find(
68
+ (candidate) => candidate.reservationId === runner.reservationId,
69
+ );
70
+ if (request) request.count += 1;
71
+ else requests.push({reservationId: runner.reservationId, count: 1});
72
+ return requests;
73
+ },
74
+ [] as Array<{reservationId: string; count: number}>,
75
+ ),
76
+ });
77
+ for (const {reason, count} of reservationValidation.unavailableByReservation.values())
78
+ recordRunnerReservationCapacityFailure(reason, count);
79
+ const remainingAcceptedByReservation = new Map(reservationValidation.acceptedByReservation);
80
+ const runnerInstances = params.runnerInstances.flatMap((runner, requestIndex) => {
81
+ if (!runner.reservationId) return [{...runner, requestIndex}];
82
+ const remaining = remainingAcceptedByReservation.get(runner.reservationId) ?? 0;
83
+ if (remaining === 0) return [];
84
+ remainingAcceptedByReservation.set(runner.reservationId, remaining - 1);
85
+ return [{...runner, requestIndex}];
86
+ });
87
+
88
+ if (runnerInstances.length === 0)
89
+ return {
90
+ runnerInstances: [],
91
+ reservationUnavailable: reservationValidation.unavailableByReservation.size > 0,
92
+ };
93
+
51
94
  const instances = await tx
52
95
  .insert(providerRunners)
53
96
  .values(
54
- params.runnerInstances.map((runner) => ({
97
+ runnerInstances.map((runner) => ({
55
98
  provisionerId: params.provisionerId,
56
99
  providerKind: params.providerKind ?? null,
57
100
  templateKey: runner.templateKey ?? null,
58
101
  intendedReservationId: runner.reservationId ?? null,
102
+ launchKind: runner.reservationId ? ('demand' as const) : ('warm' as const),
59
103
  state: 'starting' as const,
60
104
  labels: [],
61
105
  reportedAt: new Date(),
62
106
  })),
63
107
  )
64
108
  .returning({id: providerRunners.id});
65
- const results = instances.map((instance) => ({
66
- runnerInstanceId: instance.id,
67
- bootstrapToken: generateOpaqueToken('runnerBootstrapToken'),
68
- }));
109
+ const results = instances.map((instance, index) => {
110
+ const runner = runnerInstances[index];
111
+ if (!runner) throw new Error('Runner instance insert returned an unexpected row count');
112
+ return {
113
+ runnerInstanceId: instance.id,
114
+ bootstrapToken: generateOpaqueToken('runnerBootstrapToken'),
115
+ requestIndex: runner.requestIndex,
116
+ };
117
+ });
69
118
  await tx.insert(runnerBootstrapTokens).values(
70
119
  results.map((result) => ({
71
120
  runnerInstanceId: result.runnerInstanceId,
@@ -75,7 +124,10 @@ export async function createRunnerInstancesWithBootstrapTokens(params: {
75
124
  expiresAt,
76
125
  })),
77
126
  );
78
- return results;
127
+ return {
128
+ runnerInstances: results,
129
+ reservationUnavailable: reservationValidation.unavailableByReservation.size > 0,
130
+ };
79
131
  });
80
132
  }
81
133
 
@@ -227,6 +279,7 @@ export async function enrollRunnerControlSession(params: {
227
279
  runnerInstanceId: params.runnerInstanceId,
228
280
  provisionerId: params.provisionerId,
229
281
  ttlSeconds: config.RUNNER_ACTIVATION_TOKEN_TTL_SECONDS,
282
+ surface: 'enrollment',
230
283
  });
231
284
  });
232
285
  } catch (error) {
@@ -54,18 +54,37 @@ describe('reconcileRunnerInstancesFromDbResult', () => {
54
54
 
55
55
  expect(result[0]?.desiredIntent).toBe('terminate');
56
56
  });
57
+
58
+ it('returns the intended reservation when a runner is not assigned yet', () => {
59
+ const intendedReservationId = crypto.randomUUID();
60
+ const result = reconcileRunnerInstancesFromDbResult({
61
+ observedRunnerInstanceIds: ['provisioned-runner-1'],
62
+ observedRows: [
63
+ providerRunner({providerRunnerId: 'provisioned-runner-1', intendedReservationId}),
64
+ ],
65
+ boundJobExecutionsByRunnerInstanceId: new Map(),
66
+ });
67
+
68
+ expect(result[0]).toMatchObject({
69
+ reservationId: null,
70
+ intendedReservationId,
71
+ });
72
+ });
57
73
  });
58
74
 
59
75
  function providerRunner(params: {
60
76
  providerRunnerId: string;
61
77
  state?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'terminated';
78
+ intendedReservationId?: string | null;
62
79
  }) {
63
80
  return {
64
81
  id: crypto.randomUUID(),
65
82
  workspaceId: crypto.randomUUID(),
66
83
  provisionerId: crypto.randomUUID(),
67
84
  providerRunnerId: params.providerRunnerId,
85
+ intendedReservationId: params.intendedReservationId ?? null,
68
86
  reservationId: null,
87
+ launchKind: 'manual' as const,
69
88
  templateKey: 'linux',
70
89
  labels: ['linux'],
71
90
  state: params.state ?? 'running',
@@ -62,6 +62,7 @@ export interface ReconciledBoundJobExecution {
62
62
  export interface ReconciledRunnerInstance {
63
63
  providerRunnerId: string;
64
64
  state: RunnerInstanceState | null;
65
+ intendedReservationId: string | null;
65
66
  reservationId: string | null;
66
67
  runnerSessionId: string | null;
67
68
  boundJobExecution: ReconciledBoundJobExecution | null;
@@ -157,6 +158,7 @@ export function reconcileRunnerInstancesFromDbResult(params: {
157
158
  return {
158
159
  providerRunnerId,
159
160
  state: row?.state ?? null,
161
+ intendedReservationId: row?.intendedReservationId ?? null,
160
162
  reservationId: row?.reservationId ?? null,
161
163
  runnerSessionId: row?.runnerSessionId ?? null,
162
164
  boundJobExecution: boundJobExecution
@@ -1,9 +1,15 @@
1
+ import {vi} from '@shipfox/vitest/vi';
1
2
  import {and, eq} from 'drizzle-orm';
2
3
  import {db} from '#db/db.js';
3
4
  import {ephemeralRegistrationTokens} from '#db/schema/ephemeral-registration-tokens.js';
4
5
  import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
6
+ import type {RunnerInstanceInsertDb} from '#db/schema/runner-instances.js';
5
7
  import {providerRunners} from '#db/schema/runner-instances.js';
6
8
  import {runnerSessions} from '#db/schema/runner-sessions.js';
9
+ import {
10
+ type RunnerActivationTokenNotIssuedReason,
11
+ runnerActivationTokenNotIssuedCount,
12
+ } from '#metrics/instance.js';
7
13
  import {
8
14
  ephemeralRegistrationTokenFactory,
9
15
  getRunnerSessionTokenClaims,
@@ -18,9 +24,34 @@ import {
18
24
  RegistrationTokenWorkspaceMismatchError,
19
25
  RunnerLabelsReservedError,
20
26
  } from './errors.js';
21
- import {issueRunnerActivationToken} from './runner-activation.js';
27
+ import {getRunnerAssignment, issueRunnerActivationToken} from './runner-activation.js';
22
28
  import {registerRunnerSession} from './runner-sessions.js';
23
29
 
30
+ const activationTokenNotIssuedCases: Array<{
31
+ reason: RunnerActivationTokenNotIssuedReason;
32
+ update: Partial<RunnerInstanceInsertDb>;
33
+ provisionerId?: string;
34
+ }> = [
35
+ {reason: 'runner-not-found', update: {}, provisionerId: crypto.randomUUID()},
36
+ {reason: 'missing-workspace', update: {workspaceId: null}},
37
+ {reason: 'existing-session', update: {runnerSessionId: crypto.randomUUID()}},
38
+ {reason: 'not-running', update: {state: 'starting'}},
39
+ ];
40
+
41
+ function activationTokenMetricCalls(spy: {mock: {calls: unknown[][]}}): unknown[][] {
42
+ // The test setup uses a shared NoopMeter counter, so filter calls to this metric before counting.
43
+ return spy.mock.calls.filter(([, attributes]) => {
44
+ if (typeof attributes !== 'object' || attributes === null) return false;
45
+ const reason = (attributes as {reason?: unknown}).reason;
46
+ const surface = (attributes as {surface?: unknown}).surface;
47
+ return (
48
+ ['runner-not-found', 'missing-workspace', 'existing-session', 'not-running'].includes(
49
+ String(reason),
50
+ ) && ['enrollment', 'poll'].includes(String(surface))
51
+ );
52
+ });
53
+ }
54
+
24
55
  describe('registerRunnerSession', () => {
25
56
  let workspaceId: string;
26
57
  let registrationTokenId: string;
@@ -245,11 +276,13 @@ describe('activation runner sessions', () => {
245
276
  runnerInstanceId,
246
277
  provisionerId,
247
278
  ttlSeconds: 60,
279
+ surface: 'poll',
248
280
  });
249
281
  const replacementToken = await issueRunnerActivationToken({
250
282
  runnerInstanceId,
251
283
  provisionerId,
252
284
  ttlSeconds: 60,
285
+ surface: 'poll',
253
286
  });
254
287
 
255
288
  const tokens = await db()
@@ -265,11 +298,97 @@ describe('activation runner sessions', () => {
265
298
  expect(tokens.filter((token) => token.revokedAt !== null)).toHaveLength(1);
266
299
  });
267
300
 
301
+ it.each(
302
+ activationTokenNotIssuedCases,
303
+ )('records $reason when direct activation-token issuance is skipped', async ({
304
+ reason,
305
+ update,
306
+ provisionerId: caseProvisionerId,
307
+ }) => {
308
+ const addSpy = vi.spyOn(runnerActivationTokenNotIssuedCount, 'add');
309
+
310
+ try {
311
+ if (Object.keys(update).length > 0) {
312
+ await db()
313
+ .update(providerRunners)
314
+ .set(update)
315
+ .where(eq(providerRunners.id, runnerInstanceId));
316
+ }
317
+
318
+ const activationToken = await issueRunnerActivationToken({
319
+ runnerInstanceId,
320
+ provisionerId: caseProvisionerId ?? provisionerId,
321
+ ttlSeconds: 60,
322
+ surface: 'poll',
323
+ });
324
+
325
+ expect(activationToken).toBeNull();
326
+ expect(activationTokenMetricCalls(addSpy)).toEqual([[1, {reason, surface: 'poll'}]]);
327
+ } finally {
328
+ addSpy.mockRestore();
329
+ }
330
+ });
331
+
332
+ it('does not record a metric when assignment polling finds no assignment', async () => {
333
+ const addSpy = vi.spyOn(runnerActivationTokenNotIssuedCount, 'add');
334
+
335
+ try {
336
+ await db()
337
+ .update(providerRunners)
338
+ .set({workspaceId: null})
339
+ .where(eq(providerRunners.id, runnerInstanceId));
340
+
341
+ const assignment = await getRunnerAssignment({runnerInstanceId, provisionerId});
342
+
343
+ expect(assignment).toBeNull();
344
+ expect(activationTokenMetricCalls(addSpy)).toHaveLength(0);
345
+ } finally {
346
+ addSpy.mockRestore();
347
+ }
348
+ });
349
+
350
+ it('does not poll for an assignment while the runner is not running', async () => {
351
+ const addSpy = vi.spyOn(runnerActivationTokenNotIssuedCount, 'add');
352
+
353
+ try {
354
+ await db()
355
+ .update(providerRunners)
356
+ .set({state: 'starting'})
357
+ .where(eq(providerRunners.id, runnerInstanceId));
358
+
359
+ const assignment = await getRunnerAssignment({runnerInstanceId, provisionerId});
360
+
361
+ expect(assignment).toBeNull();
362
+ expect(activationTokenMetricCalls(addSpy)).toHaveLength(0);
363
+ } finally {
364
+ addSpy.mockRestore();
365
+ }
366
+ });
367
+
368
+ it('does not record a metric when activation-token issuance succeeds', async () => {
369
+ const addSpy = vi.spyOn(runnerActivationTokenNotIssuedCount, 'add');
370
+
371
+ try {
372
+ const activationToken = await issueRunnerActivationToken({
373
+ runnerInstanceId,
374
+ provisionerId,
375
+ ttlSeconds: 60,
376
+ surface: 'poll',
377
+ });
378
+
379
+ expect(activationToken).toEqual(expect.any(String));
380
+ expect(activationTokenMetricCalls(addSpy)).toHaveLength(0);
381
+ } finally {
382
+ addSpy.mockRestore();
383
+ }
384
+ });
385
+
268
386
  it('allows only one concurrent registration to consume an activation token', async () => {
269
387
  const rawToken = await issueRunnerActivationToken({
270
388
  runnerInstanceId,
271
389
  provisionerId,
272
390
  ttlSeconds: 60,
391
+ surface: 'poll',
273
392
  });
274
393
  const [activationToken] = await db()
275
394
  .select()
@@ -314,6 +433,7 @@ describe('activation runner sessions', () => {
314
433
  runnerInstanceId,
315
434
  provisionerId,
316
435
  ttlSeconds: 60,
436
+ surface: 'poll',
317
437
  });
318
438
  const [activationToken] = await db()
319
439
  .select()
@@ -3,6 +3,7 @@ import {and, eq, inArray} from 'drizzle-orm';
3
3
  import {
4
4
  ActiveEphemeralRegistrationTokensExistError,
5
5
  type RegistrationTokenBatchExceedsReservationError,
6
+ ReservationNotFoundError,
6
7
  } from '#core/errors.js';
7
8
  import {db} from '#db/db.js';
8
9
  import {
@@ -56,6 +57,20 @@ describe('createEphemeralRegistrationTokensBatch', () => {
56
57
  ).toEqual(expect.objectContaining({providerRunnerId: 'provisioned-runner-a'}));
57
58
  });
58
59
 
60
+ it('rejects bound reservations', async () => {
61
+ const boundReservationId = await createReservation({count: 1, kind: 'bound'});
62
+
63
+ await expect(
64
+ createEphemeralRegistrationTokensBatch({
65
+ workspaceId,
66
+ provisionerId,
67
+ reservationId: boundReservationId,
68
+ expiresAt: new Date(Date.now() + 300_000),
69
+ rows: [row('bound-runner', generateOpaqueToken('ephemeralRegistrationToken'))],
70
+ }),
71
+ ).rejects.toBeInstanceOf(ReservationNotFoundError);
72
+ });
73
+
59
74
  it('rejects the batch and inserts no rows when any provisioned runner already has an active token', async () => {
60
75
  await ephemeralRegistrationTokenFactory.create({
61
76
  workspaceId,
@@ -186,7 +201,10 @@ describe('createEphemeralRegistrationTokensBatch', () => {
186
201
  } satisfies Partial<RegistrationTokenBatchExceedsReservationError>);
187
202
  });
188
203
 
189
- async function createReservation(params: {count: number}): Promise<string> {
204
+ async function createReservation(params: {
205
+ count: number;
206
+ kind?: 'bound' | 'launch';
207
+ }): Promise<string> {
190
208
  const [reservation] = await db()
191
209
  .insert(reservations)
192
210
  .values({
@@ -194,6 +212,7 @@ describe('createEphemeralRegistrationTokensBatch', () => {
194
212
  provisionerId,
195
213
  requiredLabels: ['linux'],
196
214
  count: params.count,
215
+ kind: params.kind ?? 'launch',
197
216
  expiresAt: new Date(Date.now() + 60_000),
198
217
  })
199
218
  .returning({id: reservations.id});
@@ -133,6 +133,7 @@ export async function createEphemeralRegistrationTokensBatch(
133
133
  eq(reservations.id, params.reservationId),
134
134
  eq(reservations.workspaceId, params.workspaceId),
135
135
  eq(reservations.provisionerId, params.provisionerId),
136
+ eq(reservations.kind, 'launch'),
136
137
  ),
137
138
  )
138
139
  .limit(1);
@@ -0,0 +1,22 @@
1
+ import {sql} from 'drizzle-orm';
2
+ import type {Tx} from './db.js';
3
+
4
+ export async function lockRunnerReservationAdvisoryKeysTx(
5
+ tx: Tx,
6
+ params: {provisionerId: string; reservationIds: readonly string[]},
7
+ ): Promise<void> {
8
+ const reservationIds = [...new Set(params.reservationIds)].sort();
9
+ if (reservationIds.length === 0) return;
10
+
11
+ const lockKeys = sql.join(
12
+ reservationIds.map(
13
+ (reservationId) => sql`(${`runners_assignment:${params.provisionerId}:${reservationId}`})`,
14
+ ),
15
+ sql`, `,
16
+ );
17
+ await tx.execute(sql`
18
+ select pg_advisory_xact_lock(hashtext(lock_key))
19
+ from (values ${lockKeys}) as reservation_locks(lock_key)
20
+ order by lock_key
21
+ `);
22
+ }