@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
@@ -1,4 +1,7 @@
1
+ import {pgClient} from '@shipfox/node-postgres';
2
+ import {vi} from '@shipfox/vitest/vi';
1
3
  import {and, eq, inArray, or, sql, sum} from 'drizzle-orm';
4
+ import type {RunnerInstanceState} from '#core/entities/runner-instance.js';
2
5
  import {db} from '#db/db.js';
3
6
  import {
4
7
  deleteExpiredReservations,
@@ -7,10 +10,12 @@ import {
7
10
  pollInstallationDemandAndReserve,
8
11
  releaseReservationUnits,
9
12
  } from '#db/reservations.js';
13
+ import {pendingJobExecutions} from '#db/schema/pending-job-executions.js';
10
14
  import {reservations} from '#db/schema/reservations.js';
11
15
  import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
12
16
  import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
13
17
  import {providerRunners} from '#db/schema/runner-instances.js';
18
+ import {providerRunnerActivationOutcomeCount} from '#metrics/instance.js';
14
19
  import {pendingJobFactory, reservationFactory} from '#test/index.js';
15
20
 
16
21
  describe('pollDemandAndReserve', () => {
@@ -38,7 +43,24 @@ describe('pollDemandAndReserve', () => {
38
43
  expect(result.stats[0]).toMatchObject({labels: ['linux'], queued: 50, reserved: 20});
39
44
  });
40
45
 
41
- it('binds the oldest matching enrolled runners to a new reservation', async () => {
46
+ it('does not create a bound reservation when no idle runner can be rebound', async () => {
47
+ await createPendingJobs(1, ['linux']);
48
+
49
+ const result = await pollDemandAndReserve({
50
+ workspaceId,
51
+ provisionerId,
52
+ maxReservations: 1,
53
+ ttlSeconds: 60,
54
+ templates: [template('linux', ['linux'], 1)],
55
+ });
56
+
57
+ const storedReservations = await reservationsForTest();
58
+ expect(result.reservations).toHaveLength(1);
59
+ expect(storedReservations).toHaveLength(1);
60
+ expect(storedReservations[0]).toMatchObject({kind: 'launch', count: 1});
61
+ });
62
+
63
+ it('binds the oldest matching enrolled runners and returns only the launch remainder', async () => {
42
64
  const olderRunner = await createIdleRunner({
43
65
  labels: ['linux'],
44
66
  createdAt: new Date('2026-01-01T00:00:00.000Z'),
@@ -51,14 +73,14 @@ describe('pollDemandAndReserve', () => {
51
73
  labels: ['macos'],
52
74
  createdAt: new Date('2026-01-03T00:00:00.000Z'),
53
75
  });
54
- await createPendingJobs(2, ['linux']);
76
+ await createPendingJobs(3, ['linux']);
55
77
 
56
78
  const result = await pollDemandAndReserve({
57
79
  workspaceId,
58
80
  provisionerId,
59
- maxReservations: 2,
81
+ maxReservations: 3,
60
82
  ttlSeconds: 60,
61
- templates: [template('linux', ['linux'], 2)],
83
+ templates: [template('linux', ['linux'], 3)],
62
84
  });
63
85
 
64
86
  const rows = await db()
@@ -66,9 +88,21 @@ describe('pollDemandAndReserve', () => {
66
88
  .from(providerRunners)
67
89
  .where(inArray(providerRunners.id, [olderRunner.id, newerRunner.id, unmatchedRunner.id]));
68
90
  const rowsById = new Map(rows.map((row) => [row.id, row]));
69
- const reservationId = result.reservations[0]?.reservationId;
91
+ const [boundReservation] = await db()
92
+ .select()
93
+ .from(reservations)
94
+ .where(and(eq(reservations.workspaceId, workspaceId), eq(reservations.kind, 'bound')));
95
+ const [launchReservation] = await db()
96
+ .select()
97
+ .from(reservations)
98
+ .where(and(eq(reservations.workspaceId, workspaceId), eq(reservations.kind, 'launch')));
99
+ const reservationId = boundReservation?.id;
70
100
 
71
101
  expect(reservationId).toEqual(expect.any(String));
102
+ expect(result.reservations).toEqual([
103
+ expect.objectContaining({reservationId: launchReservation?.id, count: 1}),
104
+ ]);
105
+ expect(boundReservation).toMatchObject({kind: 'bound', count: 2});
72
106
  expect(rowsById.get(olderRunner.id)).toMatchObject({
73
107
  workspaceId,
74
108
  reservationId,
@@ -86,6 +120,421 @@ describe('pollDemandAndReserve', () => {
86
120
  });
87
121
  });
88
122
 
123
+ it('does not deadlock polling with expired-reservation cleanup', async () => {
124
+ const oldestRunnerId = `ffffffff-${crypto.randomUUID().slice(9)}`;
125
+ const lowestIdRunnerId = `00000000-${crypto.randomUUID().slice(9)}`;
126
+ const [expiredReservation] = await db()
127
+ .insert(reservations)
128
+ .values({
129
+ workspaceId,
130
+ provisionerId,
131
+ requiredLabels: ['linux'],
132
+ count: 2,
133
+ expiresAt: new Date('2000-01-01T00:00:00.000Z'),
134
+ })
135
+ .returning({id: reservations.id});
136
+ if (!expiredReservation) throw new Error('Expected expired reservation');
137
+
138
+ const oldestRunner = await createIdleRunner({
139
+ id: oldestRunnerId,
140
+ labels: ['linux'],
141
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
142
+ workspaceId,
143
+ reservationId: expiredReservation.id,
144
+ });
145
+ const lowestIdRunner = await createIdleRunner({
146
+ id: lowestIdRunnerId,
147
+ labels: ['linux'],
148
+ createdAt: new Date('2026-01-02T00:00:00.000Z'),
149
+ workspaceId,
150
+ reservationId: expiredReservation.id,
151
+ });
152
+ await createPendingJobs(2, ['linux']);
153
+
154
+ const lockClient = await pgClient().connect();
155
+ let transactionOpen = false;
156
+ let cleanupPromise: Promise<number> | undefined;
157
+ let pollPromise: ReturnType<typeof pollDemandAndReserve> | undefined;
158
+ try {
159
+ await lockClient.query('BEGIN');
160
+ transactionOpen = true;
161
+ const lockPidResult = await lockClient.query<{pid: number}>('SELECT pg_backend_pid() AS pid');
162
+ const lockPid = lockPidResult.rows[0]?.pid;
163
+ if (lockPid === undefined) throw new Error('Expected lock holder backend pid');
164
+ await lockClient.query('SELECT id FROM runners_runner_instances WHERE id = $1 FOR UPDATE', [
165
+ lowestIdRunner.id,
166
+ ]);
167
+
168
+ cleanupPromise = deleteExpiredReservations({limit: 1});
169
+ // Attach a handler before waiting for the second transaction so a deadlock loser cannot
170
+ // become an unhandled rejection while the test is coordinating the lock holders.
171
+ cleanupPromise.catch(() => undefined);
172
+ await waitForLockWait({blockingPid: lockPid});
173
+
174
+ pollPromise = pollDemandAndReserve({
175
+ workspaceId,
176
+ provisionerId,
177
+ maxReservations: 2,
178
+ ttlSeconds: 60,
179
+ templates: [template('linux', ['linux'], 2)],
180
+ });
181
+ pollPromise.catch(() => undefined);
182
+ await waitForLockWait({blockingPid: lockPid, minWaiters: 2});
183
+
184
+ await lockClient.query('COMMIT');
185
+ transactionOpen = false;
186
+
187
+ if (!cleanupPromise || !pollPromise) throw new Error('Expected concurrent operations');
188
+ const [deleted, result] = await Promise.all([cleanupPromise, pollPromise]);
189
+
190
+ expect(deleted).toBe(1);
191
+ expect(result.reservations).toEqual([]);
192
+ const rows = await db()
193
+ .select({id: providerRunners.id, reservationId: providerRunners.reservationId})
194
+ .from(providerRunners)
195
+ .where(inArray(providerRunners.id, [oldestRunner.id, lowestIdRunner.id]));
196
+ expect(rows).toHaveLength(2);
197
+ expect(rows.every((row) => row.reservationId !== expiredReservation.id)).toBe(true);
198
+ } finally {
199
+ if (transactionOpen) await lockClient.query('ROLLBACK');
200
+ if (cleanupPromise) await cleanupPromise.catch(() => undefined);
201
+ if (pollPromise) await pollPromise.catch(() => undefined);
202
+ lockClient.release();
203
+ }
204
+ }, 10_000);
205
+
206
+ it('refills with the next eligible runner when the oldest becomes ineligible', async () => {
207
+ const runner = await createIdleRunner({
208
+ labels: ['linux'],
209
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
210
+ });
211
+ const replacementRunner = await createIdleRunner({
212
+ labels: ['linux'],
213
+ createdAt: new Date('2026-01-02T00:00:00.000Z'),
214
+ });
215
+ await createPendingJobs(1, ['linux']);
216
+
217
+ const lockClient = await pgClient().connect();
218
+ const eligibilityClient = await pgClient().connect();
219
+ let lockTransactionOpen = false;
220
+ let eligibilityTransactionOpen = false;
221
+ let eligibilityLockPromise: Promise<unknown> | undefined;
222
+ let pollPromise: ReturnType<typeof pollDemandAndReserve> | undefined;
223
+ try {
224
+ await lockClient.query('BEGIN');
225
+ lockTransactionOpen = true;
226
+ const lockPidResult = await lockClient.query<{pid: number}>('SELECT pg_backend_pid() AS pid');
227
+ const lockPid = lockPidResult.rows[0]?.pid;
228
+ if (lockPid === undefined) throw new Error('Expected lock holder backend pid');
229
+ await lockClient.query('SELECT id FROM runners_runner_instances WHERE id = $1 FOR UPDATE', [
230
+ runner.id,
231
+ ]);
232
+
233
+ await eligibilityClient.query('BEGIN');
234
+ eligibilityTransactionOpen = true;
235
+ const eligibilityPidResult = await eligibilityClient.query<{pid: number}>(
236
+ 'SELECT pg_backend_pid() AS pid',
237
+ );
238
+ const eligibilityPid = eligibilityPidResult.rows[0]?.pid;
239
+ if (eligibilityPid === undefined) throw new Error('Expected eligibility backend pid');
240
+ eligibilityLockPromise = eligibilityClient.query(
241
+ 'SELECT id FROM runners_runner_instances WHERE id = $1 FOR UPDATE',
242
+ [runner.id],
243
+ );
244
+ // Attach a handler before starting the poll so cleanup can safely await a failed lock query.
245
+ eligibilityLockPromise.catch(() => undefined);
246
+ await waitForLockWait({blockingPid: lockPid});
247
+
248
+ pollPromise = pollDemandAndReserve({
249
+ workspaceId,
250
+ provisionerId,
251
+ maxReservations: 1,
252
+ ttlSeconds: 60,
253
+ templates: [template('linux', ['linux'], 1)],
254
+ });
255
+ pollPromise.catch(() => undefined);
256
+ await waitForLockWait({blockingPid: eligibilityPid});
257
+
258
+ await lockClient.query('COMMIT');
259
+ lockTransactionOpen = false;
260
+ await eligibilityLockPromise;
261
+ await eligibilityClient.query(
262
+ "UPDATE runners_runner_instances SET state = 'terminated', terminated_at = now(), updated_at = now() WHERE id = $1",
263
+ [runner.id],
264
+ );
265
+ await eligibilityClient.query(
266
+ "UPDATE runners_runner_control_sessions SET closed_at = now(), close_reason = 'test' WHERE runner_instance_id = $1 AND closed_at IS NULL",
267
+ [runner.id],
268
+ );
269
+ await eligibilityClient.query('COMMIT');
270
+ eligibilityTransactionOpen = false;
271
+
272
+ if (!pollPromise) throw new Error('Expected concurrent poll');
273
+ const result = await pollPromise;
274
+
275
+ expect(result.reservations).toEqual([]);
276
+ const rows = await db()
277
+ .select({
278
+ id: providerRunners.id,
279
+ state: providerRunners.state,
280
+ workspaceId: providerRunners.workspaceId,
281
+ reservationId: providerRunners.reservationId,
282
+ })
283
+ .from(providerRunners)
284
+ .where(inArray(providerRunners.id, [runner.id, replacementRunner.id]));
285
+ const rowsById = new Map(rows.map((row) => [row.id, row]));
286
+ expect(rowsById.get(runner.id)).toMatchObject({
287
+ state: 'terminated',
288
+ workspaceId: null,
289
+ reservationId: null,
290
+ });
291
+ expect(rowsById.get(replacementRunner.id)).toMatchObject({
292
+ state: 'running',
293
+ workspaceId,
294
+ reservationId: expect.any(String),
295
+ });
296
+ } finally {
297
+ if (lockTransactionOpen) await lockClient.query('ROLLBACK');
298
+ if (eligibilityTransactionOpen) await eligibilityClient.query('ROLLBACK');
299
+ if (eligibilityLockPromise) await eligibilityLockPromise.catch(() => undefined);
300
+ if (pollPromise) await pollPromise.catch(() => undefined);
301
+ lockClient.release();
302
+ eligibilityClient.release();
303
+ }
304
+ }, 10_000);
305
+
306
+ it('splits rebound and launch capacity with separate reservation lifetimes', async () => {
307
+ const runner = await createIdleRunner({labels: ['linux']});
308
+ await createPendingJobs(2, ['linux']);
309
+
310
+ const before = Date.now();
311
+ const result = await pollDemandAndReserve({
312
+ workspaceId,
313
+ provisionerId,
314
+ maxReservations: 2,
315
+ ttlSeconds: 120,
316
+ activationGraceSeconds: 5,
317
+ templates: [template('linux', ['linux'], 2)],
318
+ });
319
+
320
+ expect(result.reservations).toHaveLength(1);
321
+ expect(result.reservations[0]).toMatchObject({count: 1});
322
+ const rows = await db()
323
+ .select()
324
+ .from(reservations)
325
+ .where(
326
+ and(
327
+ eq(reservations.workspaceId, workspaceId),
328
+ eq(reservations.provisionerId, provisionerId),
329
+ ),
330
+ );
331
+ const boundReservation = rows.find((row) => row.kind === 'bound');
332
+ const launchReservation = rows.find((row) => row.kind === 'launch');
333
+ expect(boundReservation).toMatchObject({count: 1});
334
+ expect(launchReservation).toMatchObject({count: 1});
335
+ expect(boundReservation?.expiresAt.getTime()).toBeGreaterThan(before + 4_000);
336
+ expect(boundReservation?.expiresAt.getTime()).toBeLessThan(
337
+ launchReservation?.expiresAt.getTime() ?? 0,
338
+ );
339
+ expect(launchReservation?.id).toBe(result.reservations[0]?.reservationId);
340
+ const [storedRunner] = await db()
341
+ .select()
342
+ .from(providerRunners)
343
+ .where(eq(providerRunners.id, runner.id));
344
+ expect(storedRunner?.reservationId).toBe(boundReservation?.id);
345
+ });
346
+
347
+ it('records a rebound outcome for a demand-backed idle runner', async () => {
348
+ const outcomeSpy = vi.spyOn(providerRunnerActivationOutcomeCount, 'add');
349
+ await createIdleRunner({labels: ['linux'], launchKind: 'demand'});
350
+ await createPendingJobs(1, ['linux']);
351
+ const callsBefore = outcomeSpy.mock.calls.length;
352
+
353
+ await pollDemandAndReserve({
354
+ workspaceId,
355
+ provisionerId,
356
+ maxReservations: 1,
357
+ ttlSeconds: 60,
358
+ templates: [template('linux', ['linux'], 1)],
359
+ });
360
+
361
+ expect(
362
+ outcomeSpy.mock.calls
363
+ .slice(callsBefore)
364
+ .filter(
365
+ ([value, attributes]) =>
366
+ value === 1 && JSON.stringify(attributes) === JSON.stringify({outcome: 'rebound'}),
367
+ ),
368
+ ).toHaveLength(1);
369
+ });
370
+
371
+ it('does not charge adopted runners against later overlapping demand', async () => {
372
+ const adoptedRunner = await createIdleRunner({labels: ['linux', 'gpu']});
373
+ await createPendingJobs(1, ['linux', 'gpu']);
374
+
375
+ const firstPoll = await pollDemandAndReserve({
376
+ workspaceId,
377
+ provisionerId,
378
+ maxReservations: 1,
379
+ ttlSeconds: 60,
380
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
381
+ });
382
+ expect(firstPoll.reservations).toEqual([]);
383
+
384
+ await createPendingJobs(1, ['linux']);
385
+ const result = await pollDemandAndReserve({
386
+ workspaceId,
387
+ provisionerId,
388
+ maxReservations: 1,
389
+ ttlSeconds: 60,
390
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
391
+ });
392
+
393
+ const storedReservations = await reservationsForTest();
394
+ const boundReservation = storedReservations.find(
395
+ (reservation) => reservation.requiredLabels.includes('gpu') && reservation.kind === 'bound',
396
+ );
397
+ const launchReservation = storedReservations.find(
398
+ (reservation) => !reservation.requiredLabels.includes('gpu') && reservation.kind === 'launch',
399
+ );
400
+ const [storedRunner] = await db()
401
+ .select()
402
+ .from(providerRunners)
403
+ .where(eq(providerRunners.id, adoptedRunner.id));
404
+
405
+ expect(result.reservations).toEqual([
406
+ expect.objectContaining({
407
+ reservationId: launchReservation?.id,
408
+ labels: ['linux'],
409
+ count: 1,
410
+ }),
411
+ ]);
412
+ expect(boundReservation).toMatchObject({kind: 'bound', count: 1});
413
+ expect(storedRunner?.reservationId).toBe(boundReservation?.id);
414
+ });
415
+
416
+ it('deducts only pending launch units from a partially adopted reservation', async () => {
417
+ await createIdleRunner({labels: ['linux', 'gpu']});
418
+ await createIdleRunner({labels: ['linux', 'gpu']});
419
+ await createPendingJobs(3, ['linux', 'gpu']);
420
+
421
+ const firstPoll = await pollDemandAndReserve({
422
+ workspaceId,
423
+ provisionerId,
424
+ maxReservations: 3,
425
+ ttlSeconds: 60,
426
+ templates: [template('linux-gpu', ['linux', 'gpu'], 3)],
427
+ });
428
+ expect(firstPoll.reservations).toEqual([expect.objectContaining({count: 1})]);
429
+
430
+ await createPendingJobs(1, ['linux']);
431
+ const secondPoll = await pollDemandAndReserve({
432
+ workspaceId,
433
+ provisionerId,
434
+ maxReservations: 1,
435
+ ttlSeconds: 60,
436
+ templates: [template('linux-gpu', ['linux', 'gpu'], 2)],
437
+ });
438
+
439
+ expect(secondPoll.reservations).toEqual([
440
+ expect.objectContaining({labels: ['linux'], count: 1}),
441
+ ]);
442
+ });
443
+
444
+ it('deducts a launch reservation with no adopted units in full', async () => {
445
+ await createPendingJobs(1, ['linux', 'gpu']);
446
+
447
+ const firstPoll = await pollDemandAndReserve({
448
+ workspaceId,
449
+ provisionerId,
450
+ maxReservations: 1,
451
+ ttlSeconds: 60,
452
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
453
+ });
454
+ expect(firstPoll.reservations).toEqual([expect.objectContaining({count: 1})]);
455
+
456
+ await createPendingJobs(1, ['linux']);
457
+ const secondPoll = await pollDemandAndReserve({
458
+ workspaceId,
459
+ provisionerId,
460
+ maxReservations: 1,
461
+ ttlSeconds: 60,
462
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
463
+ });
464
+
465
+ expect(secondPoll.reservations).toEqual([]);
466
+ });
467
+
468
+ it('counts an intended runner while its reservation assignment is in flight', async () => {
469
+ const intendedReservation = await createIntendedReservation({
470
+ workspaceId,
471
+ expiresAt: new Date(Date.now() + 60_000),
472
+ });
473
+ await createIdleRunner({
474
+ labels: ['linux', 'gpu'],
475
+ intendedReservationId: intendedReservation.id,
476
+ });
477
+ await createPendingJobs(1, ['linux', 'gpu']);
478
+
479
+ const result = await pollDemandAndReserve({
480
+ workspaceId,
481
+ provisionerId,
482
+ maxReservations: 1,
483
+ ttlSeconds: 60,
484
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
485
+ });
486
+
487
+ expect(result.reservations).toEqual([
488
+ expect.objectContaining({labels: ['gpu', 'linux'], count: 1}),
489
+ ]);
490
+ });
491
+
492
+ it('deducts a released runner reservation unit as pending', async () => {
493
+ const reservation = await createIntendedReservation({
494
+ workspaceId,
495
+ expiresAt: new Date(Date.now() + 60_000),
496
+ });
497
+ await createIdleRunner({
498
+ labels: ['linux'],
499
+ reservationId: reservation.id,
500
+ reservationReleasedAt: new Date(Date.now() - 1_000),
501
+ });
502
+ await createPendingJobs(1, ['linux', 'gpu']);
503
+
504
+ const result = await pollDemandAndReserve({
505
+ workspaceId,
506
+ provisionerId,
507
+ maxReservations: 1,
508
+ ttlSeconds: 60,
509
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
510
+ });
511
+
512
+ expect(result.reservations).toEqual([]);
513
+ });
514
+
515
+ it('deducts a terminal intended runner reservation unit as pending', async () => {
516
+ const reservation = await createIntendedReservation({
517
+ workspaceId,
518
+ expiresAt: new Date(Date.now() + 60_000),
519
+ });
520
+ await createIdleRunner({
521
+ labels: ['linux'],
522
+ intendedReservationId: reservation.id,
523
+ state: 'terminated',
524
+ });
525
+ await createPendingJobs(1, ['linux', 'gpu']);
526
+
527
+ const result = await pollDemandAndReserve({
528
+ workspaceId,
529
+ provisionerId,
530
+ maxReservations: 1,
531
+ ttlSeconds: 60,
532
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
533
+ });
534
+
535
+ expect(result.reservations).toEqual([]);
536
+ });
537
+
89
538
  it('binds a runner whose intended reservation has passed its activation grace period', async () => {
90
539
  const intendedReservation = await createIntendedReservation({
91
540
  workspaceId,
@@ -109,9 +558,10 @@ describe('pollDemandAndReserve', () => {
109
558
  .select()
110
559
  .from(providerRunners)
111
560
  .where(eq(providerRunners.id, runner.id));
561
+ expect(result.reservations).toEqual([]);
112
562
  expect(storedRunner).toMatchObject({
113
563
  workspaceId,
114
- reservationId: result.reservations[0]?.reservationId,
564
+ reservationId: expect.any(String),
115
565
  intendedReservationId: null,
116
566
  assignedAt: expect.any(Date),
117
567
  });
@@ -141,9 +591,10 @@ describe('pollDemandAndReserve', () => {
141
591
  .select()
142
592
  .from(providerRunners)
143
593
  .where(eq(providerRunners.id, runner.id));
594
+ expect(result.reservations).toEqual([]);
144
595
  expect(storedRunner).toMatchObject({
145
596
  workspaceId,
146
- reservationId: result.reservations[0]?.reservationId,
597
+ reservationId: expect.any(String),
147
598
  intendedReservationId: null,
148
599
  assignedAt: expect.any(Date),
149
600
  });
@@ -188,9 +639,10 @@ describe('pollDemandAndReserve', () => {
188
639
  .select()
189
640
  .from(runnerActivationTokens)
190
641
  .where(eq(runnerActivationTokens.id, activationToken.id));
642
+ expect(result.reservations).toEqual([]);
191
643
  expect(storedRunner).toMatchObject({
192
644
  workspaceId,
193
- reservationId: result.reservations[0]?.reservationId,
645
+ reservationId: expect.any(String),
194
646
  intendedReservationId: null,
195
647
  assignedAt: expect.any(Date),
196
648
  });
@@ -223,9 +675,10 @@ describe('pollDemandAndReserve', () => {
223
675
  .select()
224
676
  .from(providerRunners)
225
677
  .where(eq(providerRunners.id, runner.id));
678
+ expect(result.reservations).toEqual([]);
226
679
  expect(storedRunner).toMatchObject({
227
680
  workspaceId,
228
- reservationId: result.reservations[0]?.reservationId,
681
+ reservationId: expect.any(String),
229
682
  intendedReservationId: null,
230
683
  assignedAt: expect.any(Date),
231
684
  });
@@ -349,9 +802,10 @@ describe('pollDemandAndReserve', () => {
349
802
  .select()
350
803
  .from(providerRunners)
351
804
  .where(eq(providerRunners.id, runner.id));
805
+ expect(result.reservations).toEqual([]);
352
806
  expect(storedRunner).toMatchObject({
353
807
  workspaceId,
354
- reservationId: result.reservations[0]?.reservationId,
808
+ reservationId: expect.any(String),
355
809
  intendedReservationId: null,
356
810
  assignedAt: expect.any(Date),
357
811
  });
@@ -385,15 +839,164 @@ describe('pollDemandAndReserve', () => {
385
839
  .select()
386
840
  .from(providerRunners)
387
841
  .where(eq(providerRunners.id, runner.id));
388
- expect(result.reservations[0]).toMatchObject({workspaceId: targetWorkspaceId, count: 1});
842
+ const [storedReservation] = await db()
843
+ .select()
844
+ .from(reservations)
845
+ .where(
846
+ and(
847
+ eq(reservations.workspaceId, targetWorkspaceId),
848
+ eq(reservations.provisionerId, provisionerId),
849
+ eq(reservations.kind, 'bound'),
850
+ ),
851
+ );
852
+ expect(result.reservations).toEqual([]);
853
+ expect(storedReservation).toMatchObject({workspaceId: targetWorkspaceId, count: 1});
389
854
  expect(storedRunner).toMatchObject({
390
855
  workspaceId: targetWorkspaceId,
391
- reservationId: result.reservations[0]?.reservationId,
856
+ reservationId: storedReservation?.id,
392
857
  intendedReservationId: null,
393
858
  assignedAt: expect.any(Date),
394
859
  });
395
860
  });
396
861
 
862
+ it('does not rebind a runner reserved by a concurrent installation poll', async () => {
863
+ const previousWorkspaceId = crypto.randomUUID();
864
+ const targetWorkspaceId = crypto.randomUUID();
865
+ const runner = await createIdleRunner({labels: ['linux']});
866
+ await pendingJobFactory.create({workspaceId: targetWorkspaceId, requiredLabels: ['linux']});
867
+
868
+ const lockClient = await pgClient().connect();
869
+ let transactionOpen = false;
870
+ let pollPromise: ReturnType<typeof pollInstallationDemandAndReserve> | undefined;
871
+ try {
872
+ await lockClient.query('BEGIN');
873
+ transactionOpen = true;
874
+ const lockPidResult = await lockClient.query<{pid: number}>('SELECT pg_backend_pid() AS pid');
875
+ const lockPid = lockPidResult.rows[0]?.pid;
876
+ if (lockPid === undefined) throw new Error('Expected lock holder backend pid');
877
+ await lockClient.query('SELECT id FROM runners_runner_instances WHERE id = $1 FOR UPDATE', [
878
+ runner.id,
879
+ ]);
880
+
881
+ const reservationId = crypto.randomUUID();
882
+ await lockClient.query(
883
+ `INSERT INTO runners_reservations
884
+ (id, workspace_id, provisioner_id, required_labels, count, kind, expires_at)
885
+ VALUES ($1, $2, $3, $4, $5, 'bound', $6)`,
886
+ [
887
+ reservationId,
888
+ previousWorkspaceId,
889
+ provisionerId,
890
+ ['linux'],
891
+ 1,
892
+ new Date(Date.now() + 60_000),
893
+ ],
894
+ );
895
+ await lockClient.query(
896
+ `UPDATE runners_runner_instances
897
+ SET workspace_id = $1, reservation_id = $2, updated_at = now()
898
+ WHERE id = $3`,
899
+ [previousWorkspaceId, reservationId, runner.id],
900
+ );
901
+
902
+ pollPromise = pollInstallationDemandAndReserve({
903
+ provisionerId,
904
+ maxReservations: 1,
905
+ ttlSeconds: 60,
906
+ templates: [template('linux', ['linux'], 1)],
907
+ capabilityWindowSeconds: 60,
908
+ eligibleWorkspaceIds: new Set([targetWorkspaceId]),
909
+ });
910
+ // waitForLockWait yields to the macrotask queue, so an early poll rejection would
911
+ // surface as an unhandled rejection before the finally block attaches its handler.
912
+ pollPromise.catch(() => undefined);
913
+ await waitForLockWait({blockingPid: lockPid});
914
+
915
+ await lockClient.query('COMMIT');
916
+ transactionOpen = false;
917
+ const result = await pollPromise;
918
+ const [storedRunner] = await db()
919
+ .select()
920
+ .from(providerRunners)
921
+ .where(eq(providerRunners.id, runner.id));
922
+ const storedReservations = await db()
923
+ .select()
924
+ .from(reservations)
925
+ .where(eq(reservations.provisionerId, provisionerId));
926
+
927
+ expect(result.reservations).toHaveLength(1);
928
+ expect(result.reservations[0]).toMatchObject({workspaceId: targetWorkspaceId, count: 1});
929
+ expect(storedRunner).toMatchObject({
930
+ workspaceId: previousWorkspaceId,
931
+ reservationId,
932
+ });
933
+ expect(storedReservations).toHaveLength(2);
934
+ expect(
935
+ storedReservations.find((reservation) => reservation.workspaceId === targetWorkspaceId),
936
+ ).toMatchObject({kind: 'launch', count: 1});
937
+ } finally {
938
+ if (transactionOpen) await lockClient.query('ROLLBACK');
939
+ if (pollPromise) await pollPromise.catch(() => undefined);
940
+ lockClient.release();
941
+ }
942
+ });
943
+
944
+ it.each([
945
+ ['template capacity', 5, 2, 2],
946
+ ['global reservation budget', 2, 5, 1],
947
+ ])('does not overcount rebound units for the %s case', async (_case, maxReservations, availableSlots, expectedLaunchCount) => {
948
+ const reboundWorkspaceId = crypto.randomUUID();
949
+ const launchWorkspaceId = crypto.randomUUID();
950
+ await pendingJobFactory.create({
951
+ workspaceId: reboundWorkspaceId,
952
+ requiredLabels: ['linux'],
953
+ });
954
+ await pendingJobFactory.create({
955
+ workspaceId: launchWorkspaceId,
956
+ requiredLabels: ['linux'],
957
+ });
958
+ await pendingJobFactory.create({
959
+ workspaceId: launchWorkspaceId,
960
+ requiredLabels: ['linux'],
961
+ });
962
+ await db()
963
+ .update(pendingJobExecutions)
964
+ .set({createdAt: new Date('2026-01-01T00:00:00.000Z')})
965
+ .where(eq(pendingJobExecutions.workspaceId, reboundWorkspaceId));
966
+ await db()
967
+ .update(pendingJobExecutions)
968
+ .set({createdAt: new Date('2026-01-02T00:00:00.000Z')})
969
+ .where(eq(pendingJobExecutions.workspaceId, launchWorkspaceId));
970
+ await createIdleRunner({labels: ['linux']});
971
+
972
+ const result = await pollInstallationDemandAndReserve({
973
+ provisionerId,
974
+ maxReservations,
975
+ ttlSeconds: 60,
976
+ templates: [template('linux', ['linux'], availableSlots)],
977
+ capabilityWindowSeconds: 60,
978
+ eligibleWorkspaceIds: new Set([reboundWorkspaceId, launchWorkspaceId]),
979
+ });
980
+
981
+ const storedReservations = await db()
982
+ .select()
983
+ .from(reservations)
984
+ .where(eq(reservations.provisionerId, provisionerId));
985
+ expect(result.reservations).toEqual([
986
+ expect.objectContaining({workspaceId: launchWorkspaceId, count: expectedLaunchCount}),
987
+ ]);
988
+ expect(storedReservations).toEqual(
989
+ expect.arrayContaining([
990
+ expect.objectContaining({workspaceId: reboundWorkspaceId, kind: 'bound', count: 1}),
991
+ expect.objectContaining({
992
+ workspaceId: launchWorkspaceId,
993
+ kind: 'launch',
994
+ count: expectedLaunchCount,
995
+ }),
996
+ ]),
997
+ );
998
+ });
999
+
397
1000
  it('does not rebind a released runner after its reservation expires', async () => {
398
1001
  const staleReservation = await createIntendedReservation({
399
1002
  workspaceId,
@@ -402,6 +1005,7 @@ describe('pollDemandAndReserve', () => {
402
1005
  const releasedAt = new Date(Date.now() - 30_000);
403
1006
  const runner = await createIdleRunner({
404
1007
  labels: ['linux'],
1008
+ launchKind: 'demand',
405
1009
  workspaceId,
406
1010
  reservationId: staleReservation.id,
407
1011
  intendedReservationId: staleReservation.id,
@@ -502,9 +1106,10 @@ describe('pollDemandAndReserve', () => {
502
1106
  templates: [template('linux', ['linux'], 1)],
503
1107
  });
504
1108
 
505
- expect(firstResult.reservations).toHaveLength(1);
1109
+ expect(firstResult.reservations).toEqual([]);
506
1110
  expect(secondResult.reservations).toEqual([]);
507
1111
  expect(secondResult.stats[0]).toMatchObject({queued: 1, reserved: 1});
1112
+ expect(await activeReservedCount()).toBe(1);
508
1113
  });
509
1114
 
510
1115
  it('allocates overlapping label sets most-specific-first', async () => {
@@ -569,6 +1174,53 @@ describe('pollDemandAndReserve', () => {
569
1174
  ]);
570
1175
  });
571
1176
 
1177
+ it('does not charge adopted installation runners against later launch capacity', async () => {
1178
+ const olderWorkspaceId = crypto.randomUUID();
1179
+ const newerWorkspaceId = crypto.randomUUID();
1180
+ const runner = await createIdleRunner({labels: ['linux', 'gpu']});
1181
+ await pendingJobFactory.create({
1182
+ workspaceId: olderWorkspaceId,
1183
+ requiredLabels: ['linux', 'gpu'],
1184
+ });
1185
+ await pendingJobFactory.create({workspaceId: newerWorkspaceId, requiredLabels: ['linux']});
1186
+
1187
+ const result = await pollInstallationDemandAndReserve({
1188
+ provisionerId,
1189
+ maxReservations: 2,
1190
+ ttlSeconds: 60,
1191
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
1192
+ capabilityWindowSeconds: 60,
1193
+ eligibleWorkspaceIds: new Set([olderWorkspaceId, newerWorkspaceId]),
1194
+ });
1195
+
1196
+ const storedReservations = await db()
1197
+ .select()
1198
+ .from(reservations)
1199
+ .where(eq(reservations.provisionerId, provisionerId));
1200
+ const boundReservation = storedReservations.find(
1201
+ (reservation) => reservation.workspaceId === olderWorkspaceId,
1202
+ );
1203
+ const launchReservation = storedReservations.find(
1204
+ (reservation) => reservation.workspaceId === newerWorkspaceId,
1205
+ );
1206
+ const [storedRunner] = await db()
1207
+ .select()
1208
+ .from(providerRunners)
1209
+ .where(eq(providerRunners.id, runner.id));
1210
+
1211
+ expect(result.reservations).toEqual([
1212
+ expect.objectContaining({
1213
+ reservationId: launchReservation?.id,
1214
+ workspaceId: newerWorkspaceId,
1215
+ labels: ['linux'],
1216
+ count: 1,
1217
+ }),
1218
+ ]);
1219
+ expect(boundReservation?.count).toBe(1);
1220
+ expect(storedRunner?.workspaceId).toBe(olderWorkspaceId);
1221
+ expect(storedRunner?.reservationId).toBe(boundReservation?.id);
1222
+ });
1223
+
572
1224
  it('reports committed installation grants before an aborted poll stops allocating', async () => {
573
1225
  const firstWorkspaceId = crypto.randomUUID();
574
1226
  const secondWorkspaceId = crypto.randomUUID();
@@ -765,15 +1417,25 @@ describe('pollDemandAndReserve', () => {
765
1417
  const runner = await createIdleRunner({labels: ['linux']});
766
1418
  await createPendingJobs(1, ['linux']);
767
1419
 
768
- const result = await pollDemandAndReserve({
1420
+ await pollDemandAndReserve({
769
1421
  workspaceId,
770
1422
  provisionerId,
771
1423
  maxReservations: 1,
772
1424
  ttlSeconds: 60,
773
1425
  templates: [template('linux', ['linux'], 1)],
774
1426
  });
775
- const reservationId = result.reservations[0]?.reservationId;
776
- if (!reservationId) throw new Error('Expected reservation');
1427
+ const [boundReservation] = await db()
1428
+ .select()
1429
+ .from(reservations)
1430
+ .where(
1431
+ and(
1432
+ eq(reservations.workspaceId, workspaceId),
1433
+ eq(reservations.provisionerId, provisionerId),
1434
+ eq(reservations.kind, 'bound'),
1435
+ ),
1436
+ );
1437
+ const reservationId = boundReservation?.id;
1438
+ if (!reservationId) throw new Error('Expected bound reservation');
777
1439
 
778
1440
  const [activationToken] = await db()
779
1441
  .insert(runnerActivationTokens)
@@ -867,15 +1529,25 @@ describe('pollDemandAndReserve', () => {
867
1529
  const runner = await createIdleRunner({labels: ['linux']});
868
1530
  await createPendingJobs(1, ['linux']);
869
1531
 
870
- const result = await pollDemandAndReserve({
1532
+ await pollDemandAndReserve({
871
1533
  workspaceId,
872
1534
  provisionerId,
873
1535
  maxReservations: 1,
874
1536
  ttlSeconds: 60,
875
1537
  templates: [template('linux', ['linux'], 1)],
876
1538
  });
877
- const reservationId = result.reservations[0]?.reservationId;
878
- if (!reservationId) throw new Error('Expected reservation');
1539
+ const [boundReservation] = await db()
1540
+ .select()
1541
+ .from(reservations)
1542
+ .where(
1543
+ and(
1544
+ eq(reservations.workspaceId, workspaceId),
1545
+ eq(reservations.provisionerId, provisionerId),
1546
+ eq(reservations.kind, 'bound'),
1547
+ ),
1548
+ );
1549
+ const reservationId = boundReservation?.id;
1550
+ if (!reservationId) throw new Error('Expected bound reservation');
879
1551
 
880
1552
  await db()
881
1553
  .update(reservations)
@@ -963,18 +1635,18 @@ describe('pollDemandAndReserve', () => {
963
1635
  expect(storedSurvivingRunner?.intendedReservationId).toBe(survivingReservation.id);
964
1636
 
965
1637
  await createPendingJobs(1, ['linux']);
966
- const result = await pollDemandAndReserve({
1638
+ await pollDemandAndReserve({
967
1639
  workspaceId,
968
1640
  provisionerId,
969
1641
  maxReservations: 1,
970
1642
  ttlSeconds: 60,
971
1643
  templates: [template('linux', ['linux'], 1)],
972
1644
  });
973
- const reboundReservationId = result.reservations[0]?.reservationId;
974
1645
  const [reboundRunner] = await db()
975
1646
  .select()
976
1647
  .from(providerRunners)
977
1648
  .where(eq(providerRunners.id, runner.id));
1649
+ const reboundReservationId = reboundRunner?.reservationId;
978
1650
  expect(reboundReservationId).toEqual(expect.any(String));
979
1651
  expect(reboundRunner).toMatchObject({
980
1652
  intendedReservationId: null,
@@ -1171,26 +1843,31 @@ describe('pollDemandAndReserve', () => {
1171
1843
  }
1172
1844
 
1173
1845
  async function createIdleRunner(params: {
1846
+ id?: string;
1174
1847
  labels: string[];
1175
1848
  createdAt?: Date;
1176
1849
  controlSessionExpiresAt?: Date;
1850
+ launchKind?: 'demand' | 'warm' | 'manual';
1177
1851
  workspaceId?: string | null;
1178
1852
  reservationId?: string | null;
1179
1853
  intendedReservationId?: string | null;
1180
1854
  reservationReleasedAt?: Date | null;
1855
+ state?: RunnerInstanceState;
1181
1856
  }) {
1182
1857
  const createdAt = params.createdAt ?? new Date();
1183
1858
  const [runner] = await db()
1184
1859
  .insert(providerRunners)
1185
1860
  .values({
1861
+ ...(params.id ? {id: params.id} : {}),
1186
1862
  provisionerId,
1187
1863
  workspaceId: params.workspaceId,
1188
1864
  reservationId: params.reservationId,
1189
1865
  providerRunnerId: crypto.randomUUID(),
1866
+ launchKind: params.launchKind ?? 'manual',
1190
1867
  intendedReservationId: params.intendedReservationId,
1191
1868
  reservationReleasedAt: params.reservationReleasedAt,
1192
1869
  labels: params.labels,
1193
- state: 'running',
1870
+ state: params.state ?? 'running',
1194
1871
  reportedAt: createdAt,
1195
1872
  createdAt,
1196
1873
  updatedAt: createdAt,
@@ -1236,3 +1913,37 @@ describe('pollDemandAndReserve', () => {
1236
1913
  return {templateKey, labels, availableSlots, starting: 0, running: 0};
1237
1914
  }
1238
1915
  });
1916
+
1917
+ async function waitForLockWait(params: {blockingPid: number; minWaiters?: number}) {
1918
+ const minWaiters = params.minWaiters ?? 1;
1919
+ const deadline = Date.now() + 5_000;
1920
+ while (Date.now() < deadline) {
1921
+ const result = await pgClient().query<{count: number}>(
1922
+ `
1923
+ WITH RECURSIVE lock_waiters(waiter_pid, blocker_pid, path) AS (
1924
+ SELECT activity.pid, blockers.blocker_pid, ARRAY[activity.pid, blockers.blocker_pid]
1925
+ FROM pg_stat_activity AS activity
1926
+ CROSS JOIN LATERAL unnest(pg_blocking_pids(activity.pid)) AS blockers(blocker_pid)
1927
+ WHERE activity.datname = current_database()
1928
+ AND activity.pid <> pg_backend_pid()
1929
+ AND activity.state = 'active'
1930
+ AND activity.wait_event_type = 'Lock'
1931
+ UNION ALL
1932
+ SELECT waiters.waiter_pid, blockers.blocker_pid, waiters.path || blockers.blocker_pid
1933
+ FROM lock_waiters AS waiters
1934
+ CROSS JOIN LATERAL unnest(pg_blocking_pids(waiters.blocker_pid)) AS blockers(blocker_pid)
1935
+ WHERE NOT (blockers.blocker_pid = ANY(waiters.path))
1936
+ )
1937
+ SELECT count(DISTINCT waiter_pid)::int AS count
1938
+ FROM lock_waiters
1939
+ WHERE blocker_pid = $1::int
1940
+ `,
1941
+ [params.blockingPid],
1942
+ );
1943
+ if ((result.rows[0]?.count ?? 0) >= minWaiters) return;
1944
+ await new Promise((resolve) => setTimeout(resolve, 10));
1945
+ }
1946
+ throw new Error(
1947
+ `Timed out waiting for ${minWaiters} backend(s) blocked by pid ${params.blockingPid}`,
1948
+ );
1949
+ }