@shipfox/api-runners 12.2.0 → 12.3.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 (43) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +12 -0
  3. package/dist/config.js +3 -3
  4. package/dist/config.js.map +1 -1
  5. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  6. package/dist/core/runner-control-sessions.js +14 -2
  7. package/dist/core/runner-control-sessions.js.map +1 -1
  8. package/dist/db/index.d.ts +1 -1
  9. package/dist/db/index.d.ts.map +1 -1
  10. package/dist/db/index.js +1 -1
  11. package/dist/db/index.js.map +1 -1
  12. package/dist/db/reservations.d.ts.map +1 -1
  13. package/dist/db/reservations.js +22 -6
  14. package/dist/db/reservations.js.map +1 -1
  15. package/dist/db/runner-instances.d.ts +3 -0
  16. package/dist/db/runner-instances.d.ts.map +1 -1
  17. package/dist/db/runner-instances.js +10 -1
  18. package/dist/db/runner-instances.js.map +1 -1
  19. package/dist/db/runner-sessions.d.ts.map +1 -1
  20. package/dist/db/runner-sessions.js +22 -15
  21. package/dist/db/runner-sessions.js.map +1 -1
  22. package/dist/metrics/service.d.ts.map +1 -1
  23. package/dist/metrics/service.js +20 -4
  24. package/dist/metrics/service.js.map +1 -1
  25. package/dist/tsconfig.test.tsbuildinfo +1 -1
  26. package/package.json +3 -3
  27. package/src/config.test.ts +2 -2
  28. package/src/config.ts +3 -3
  29. package/src/core/runner-control-sessions.test.ts +169 -4
  30. package/src/core/runner-control-sessions.ts +22 -1
  31. package/src/db/index.ts +1 -0
  32. package/src/db/reservations.test.ts +369 -1
  33. package/src/db/reservations.ts +74 -9
  34. package/src/db/runner-instances.test.ts +231 -0
  35. package/src/db/runner-instances.ts +32 -1
  36. package/src/db/runner-sessions.ts +26 -15
  37. package/src/metrics/service.test.ts +99 -0
  38. package/src/metrics/service.ts +24 -4
  39. package/src/presentation/routes/late-runner-enrollment.test.ts +230 -0
  40. package/src/presentation/routes/poll-demand.test.ts +23 -6
  41. package/test/fixtures/late-runner-enrollment.ts +103 -0
  42. package/test/index.ts +5 -0
  43. package/tsconfig.build.tsbuildinfo +1 -1
@@ -86,6 +86,350 @@ describe('pollDemandAndReserve', () => {
86
86
  });
87
87
  });
88
88
 
89
+ it('binds a runner whose intended reservation has passed its activation grace period', async () => {
90
+ const intendedReservation = await createIntendedReservation({
91
+ workspaceId,
92
+ expiresAt: new Date(Date.now() - 60_000),
93
+ });
94
+ const runner = await createIdleRunner({
95
+ labels: ['linux'],
96
+ intendedReservationId: intendedReservation.id,
97
+ });
98
+ await createPendingJobs(1, ['linux']);
99
+
100
+ const result = await pollDemandAndReserve({
101
+ workspaceId,
102
+ provisionerId,
103
+ maxReservations: 1,
104
+ ttlSeconds: 60,
105
+ templates: [template('linux', ['linux'], 1)],
106
+ });
107
+
108
+ const [storedRunner] = await db()
109
+ .select()
110
+ .from(providerRunners)
111
+ .where(eq(providerRunners.id, runner.id));
112
+ expect(storedRunner).toMatchObject({
113
+ workspaceId,
114
+ reservationId: result.reservations[0]?.reservationId,
115
+ intendedReservationId: null,
116
+ assignedAt: expect.any(Date),
117
+ });
118
+ });
119
+
120
+ it('binds a runner whose intended reservation was deleted after its grace period', async () => {
121
+ const intendedReservation = await createIntendedReservation({
122
+ workspaceId,
123
+ expiresAt: new Date(Date.now() + 60_000),
124
+ });
125
+ await db().delete(reservations).where(eq(reservations.id, intendedReservation.id));
126
+ const runner = await createIdleRunner({
127
+ labels: ['linux'],
128
+ intendedReservationId: intendedReservation.id,
129
+ });
130
+ await createPendingJobs(1, ['linux']);
131
+
132
+ const result = await pollDemandAndReserve({
133
+ workspaceId,
134
+ provisionerId,
135
+ maxReservations: 1,
136
+ ttlSeconds: 60,
137
+ templates: [template('linux', ['linux'], 1)],
138
+ });
139
+
140
+ const [storedRunner] = await db()
141
+ .select()
142
+ .from(providerRunners)
143
+ .where(eq(providerRunners.id, runner.id));
144
+ expect(storedRunner).toMatchObject({
145
+ workspaceId,
146
+ reservationId: result.reservations[0]?.reservationId,
147
+ intendedReservationId: null,
148
+ assignedAt: expect.any(Date),
149
+ });
150
+ });
151
+
152
+ it('binds an assigned runner after its reservation grace period expires', async () => {
153
+ const staleReservation = await createIntendedReservation({
154
+ workspaceId,
155
+ expiresAt: new Date(Date.now() - 60_000),
156
+ });
157
+ const runner = await createIdleRunner({
158
+ labels: ['linux'],
159
+ workspaceId,
160
+ reservationId: staleReservation.id,
161
+ intendedReservationId: staleReservation.id,
162
+ });
163
+ const [activationToken] = await db()
164
+ .insert(runnerActivationTokens)
165
+ .values({
166
+ runnerInstanceId: runner.id,
167
+ hashedToken: crypto.randomUUID(),
168
+ prefix: 'test',
169
+ expiresAt: new Date(Date.now() + 60_000),
170
+ })
171
+ .returning();
172
+ if (!activationToken) throw new Error('Expected activation token');
173
+ await createPendingJobs(1, ['linux']);
174
+
175
+ const result = await pollDemandAndReserve({
176
+ workspaceId,
177
+ provisionerId,
178
+ maxReservations: 1,
179
+ ttlSeconds: 60,
180
+ templates: [template('linux', ['linux'], 1)],
181
+ });
182
+
183
+ const [storedRunner] = await db()
184
+ .select()
185
+ .from(providerRunners)
186
+ .where(eq(providerRunners.id, runner.id));
187
+ const [storedToken] = await db()
188
+ .select()
189
+ .from(runnerActivationTokens)
190
+ .where(eq(runnerActivationTokens.id, activationToken.id));
191
+ expect(storedRunner).toMatchObject({
192
+ workspaceId,
193
+ reservationId: result.reservations[0]?.reservationId,
194
+ intendedReservationId: null,
195
+ assignedAt: expect.any(Date),
196
+ });
197
+ expect(storedToken?.revokedAt).toBeInstanceOf(Date);
198
+ });
199
+
200
+ it('binds an assigned runner whose reservation was deleted', async () => {
201
+ const staleReservation = await createIntendedReservation({
202
+ workspaceId,
203
+ expiresAt: new Date(Date.now() + 60_000),
204
+ });
205
+ await db().delete(reservations).where(eq(reservations.id, staleReservation.id));
206
+ const runner = await createIdleRunner({
207
+ labels: ['linux'],
208
+ workspaceId,
209
+ reservationId: staleReservation.id,
210
+ intendedReservationId: staleReservation.id,
211
+ });
212
+ await createPendingJobs(1, ['linux']);
213
+
214
+ const result = await pollDemandAndReserve({
215
+ workspaceId,
216
+ provisionerId,
217
+ maxReservations: 1,
218
+ ttlSeconds: 60,
219
+ templates: [template('linux', ['linux'], 1)],
220
+ });
221
+
222
+ const [storedRunner] = await db()
223
+ .select()
224
+ .from(providerRunners)
225
+ .where(eq(providerRunners.id, runner.id));
226
+ expect(storedRunner).toMatchObject({
227
+ workspaceId,
228
+ reservationId: result.reservations[0]?.reservationId,
229
+ intendedReservationId: null,
230
+ assignedAt: expect.any(Date),
231
+ });
232
+ });
233
+
234
+ it('does not bind an assigned runner within its activation grace period', async () => {
235
+ const liveReservation = await createIntendedReservation({
236
+ workspaceId,
237
+ expiresAt: new Date(Date.now() + 60_000),
238
+ });
239
+ const runner = await createIdleRunner({
240
+ labels: ['linux'],
241
+ workspaceId,
242
+ reservationId: liveReservation.id,
243
+ });
244
+ await createPendingJobs(2, ['linux']);
245
+
246
+ const result = await pollDemandAndReserve({
247
+ workspaceId,
248
+ provisionerId,
249
+ maxReservations: 2,
250
+ ttlSeconds: 60,
251
+ templates: [template('linux', ['linux'], 2)],
252
+ });
253
+
254
+ const [storedRunner] = await db()
255
+ .select()
256
+ .from(providerRunners)
257
+ .where(eq(providerRunners.id, runner.id));
258
+ expect(result.reservations[0]?.count).toBe(1);
259
+ expect(storedRunner).toMatchObject({
260
+ workspaceId,
261
+ reservationId: liveReservation.id,
262
+ intendedReservationId: null,
263
+ assignedAt: null,
264
+ });
265
+ });
266
+
267
+ it('does not bind a runner whose intended reservation is within its activation grace period', async () => {
268
+ const intendedReservation = await createIntendedReservation({
269
+ workspaceId: crypto.randomUUID(),
270
+ expiresAt: new Date(Date.now() + 60_000),
271
+ });
272
+ const runner = await createIdleRunner({
273
+ labels: ['linux'],
274
+ intendedReservationId: intendedReservation.id,
275
+ });
276
+ await createPendingJobs(1, ['linux']);
277
+
278
+ const result = await pollDemandAndReserve({
279
+ workspaceId,
280
+ provisionerId,
281
+ maxReservations: 1,
282
+ ttlSeconds: 60,
283
+ templates: [template('linux', ['linux'], 1)],
284
+ });
285
+
286
+ const [storedRunner] = await db()
287
+ .select()
288
+ .from(providerRunners)
289
+ .where(eq(providerRunners.id, runner.id));
290
+ expect(result.reservations[0]?.count).toBe(1);
291
+ expect(storedRunner).toMatchObject({
292
+ workspaceId: null,
293
+ reservationId: null,
294
+ intendedReservationId: intendedReservation.id,
295
+ assignedAt: null,
296
+ });
297
+ });
298
+
299
+ it('does not rebind a stale runner from another workspace in a workspace-scoped poll', async () => {
300
+ const previousWorkspaceId = crypto.randomUUID();
301
+ const staleReservation = await createIntendedReservation({
302
+ workspaceId: previousWorkspaceId,
303
+ expiresAt: new Date(Date.now() - 60_000),
304
+ });
305
+ const runner = await createIdleRunner({
306
+ labels: ['linux'],
307
+ workspaceId: previousWorkspaceId,
308
+ reservationId: staleReservation.id,
309
+ intendedReservationId: staleReservation.id,
310
+ });
311
+ await createPendingJobs(1, ['linux']);
312
+
313
+ const result = await pollDemandAndReserve({
314
+ workspaceId,
315
+ provisionerId,
316
+ maxReservations: 1,
317
+ ttlSeconds: 60,
318
+ templates: [template('linux', ['linux'], 1)],
319
+ });
320
+
321
+ const [storedRunner] = await db()
322
+ .select()
323
+ .from(providerRunners)
324
+ .where(eq(providerRunners.id, runner.id));
325
+ expect(result.reservations[0]?.count).toBe(1);
326
+ expect(storedRunner).toMatchObject({
327
+ workspaceId: previousWorkspaceId,
328
+ reservationId: staleReservation.id,
329
+ intendedReservationId: staleReservation.id,
330
+ });
331
+ });
332
+
333
+ it('rebinds an unowned runner whose reservation was deleted', async () => {
334
+ const runner = await createIdleRunner({
335
+ labels: ['linux'],
336
+ reservationId: crypto.randomUUID(),
337
+ });
338
+ await createPendingJobs(1, ['linux']);
339
+
340
+ const result = await pollDemandAndReserve({
341
+ workspaceId,
342
+ provisionerId,
343
+ maxReservations: 1,
344
+ ttlSeconds: 60,
345
+ templates: [template('linux', ['linux'], 1)],
346
+ });
347
+
348
+ const [storedRunner] = await db()
349
+ .select()
350
+ .from(providerRunners)
351
+ .where(eq(providerRunners.id, runner.id));
352
+ expect(storedRunner).toMatchObject({
353
+ workspaceId,
354
+ reservationId: result.reservations[0]?.reservationId,
355
+ intendedReservationId: null,
356
+ assignedAt: expect.any(Date),
357
+ });
358
+ });
359
+
360
+ it('rebinds a stale runner across workspaces for an installation-scoped poll', async () => {
361
+ const previousWorkspaceId = crypto.randomUUID();
362
+ const targetWorkspaceId = crypto.randomUUID();
363
+ const staleReservation = await createIntendedReservation({
364
+ workspaceId: previousWorkspaceId,
365
+ expiresAt: new Date(Date.now() - 60_000),
366
+ });
367
+ const runner = await createIdleRunner({
368
+ labels: ['linux'],
369
+ workspaceId: previousWorkspaceId,
370
+ reservationId: staleReservation.id,
371
+ intendedReservationId: staleReservation.id,
372
+ });
373
+ await pendingJobFactory.create({workspaceId: targetWorkspaceId, requiredLabels: ['linux']});
374
+
375
+ const result = await pollInstallationDemandAndReserve({
376
+ provisionerId,
377
+ maxReservations: 1,
378
+ ttlSeconds: 60,
379
+ templates: [template('linux', ['linux'], 1)],
380
+ capabilityWindowSeconds: 60,
381
+ eligibleWorkspaceIds: new Set([targetWorkspaceId]),
382
+ });
383
+
384
+ const [storedRunner] = await db()
385
+ .select()
386
+ .from(providerRunners)
387
+ .where(eq(providerRunners.id, runner.id));
388
+ expect(result.reservations[0]).toMatchObject({workspaceId: targetWorkspaceId, count: 1});
389
+ expect(storedRunner).toMatchObject({
390
+ workspaceId: targetWorkspaceId,
391
+ reservationId: result.reservations[0]?.reservationId,
392
+ intendedReservationId: null,
393
+ assignedAt: expect.any(Date),
394
+ });
395
+ });
396
+
397
+ it('does not rebind a released runner after its reservation expires', async () => {
398
+ const staleReservation = await createIntendedReservation({
399
+ workspaceId,
400
+ expiresAt: new Date(Date.now() - 60_000),
401
+ });
402
+ const releasedAt = new Date(Date.now() - 30_000);
403
+ const runner = await createIdleRunner({
404
+ labels: ['linux'],
405
+ workspaceId,
406
+ reservationId: staleReservation.id,
407
+ intendedReservationId: staleReservation.id,
408
+ reservationReleasedAt: releasedAt,
409
+ });
410
+ await createPendingJobs(1, ['linux']);
411
+
412
+ const result = await pollDemandAndReserve({
413
+ workspaceId,
414
+ provisionerId,
415
+ maxReservations: 1,
416
+ ttlSeconds: 60,
417
+ templates: [template('linux', ['linux'], 1)],
418
+ });
419
+
420
+ const [storedRunner] = await db()
421
+ .select()
422
+ .from(providerRunners)
423
+ .where(eq(providerRunners.id, runner.id));
424
+ expect(result.reservations[0]?.count).toBe(1);
425
+ expect(storedRunner).toMatchObject({
426
+ workspaceId,
427
+ reservationId: staleReservation.id,
428
+ intendedReservationId: staleReservation.id,
429
+ reservationReleasedAt: releasedAt,
430
+ });
431
+ });
432
+
89
433
  it('does not bind a running runner without an active control session', async () => {
90
434
  const [runner] = await db()
91
435
  .insert(providerRunners)
@@ -808,19 +1152,43 @@ describe('pollDemandAndReserve', () => {
808
1152
  }
809
1153
  }
810
1154
 
1155
+ async function createIntendedReservation(params: {
1156
+ workspaceId: string;
1157
+ expiresAt: Date;
1158
+ }): Promise<{id: string}> {
1159
+ const [reservation] = await db()
1160
+ .insert(reservations)
1161
+ .values({
1162
+ workspaceId: params.workspaceId,
1163
+ provisionerId,
1164
+ requiredLabels: ['linux'],
1165
+ count: 1,
1166
+ expiresAt: params.expiresAt,
1167
+ })
1168
+ .returning({id: reservations.id});
1169
+ if (!reservation) throw new Error('Expected reservation');
1170
+ return reservation;
1171
+ }
1172
+
811
1173
  async function createIdleRunner(params: {
812
1174
  labels: string[];
813
1175
  createdAt?: Date;
814
1176
  controlSessionExpiresAt?: Date;
815
- intendedReservationId?: string;
1177
+ workspaceId?: string | null;
1178
+ reservationId?: string | null;
1179
+ intendedReservationId?: string | null;
1180
+ reservationReleasedAt?: Date | null;
816
1181
  }) {
817
1182
  const createdAt = params.createdAt ?? new Date();
818
1183
  const [runner] = await db()
819
1184
  .insert(providerRunners)
820
1185
  .values({
821
1186
  provisionerId,
1187
+ workspaceId: params.workspaceId,
1188
+ reservationId: params.reservationId,
822
1189
  providerRunnerId: crypto.randomUUID(),
823
1190
  intendedReservationId: params.intendedReservationId,
1191
+ reservationReleasedAt: params.reservationReleasedAt,
824
1192
  labels: params.labels,
825
1193
  state: 'running',
826
1194
  reportedAt: createdAt,
@@ -10,6 +10,7 @@ import {
10
10
  isNotNull,
11
11
  isNull,
12
12
  lt,
13
+ notExists,
13
14
  or,
14
15
  sql,
15
16
  } from 'drizzle-orm';
@@ -67,6 +68,12 @@ export interface InstallationPollDemandAndReserveParams {
67
68
  onReservations?: (reservations: ReservationGrant[]) => void;
68
69
  }
69
70
 
71
+ type DemandScope = 'installation' | 'workspace';
72
+
73
+ type PollDemandAndReserveLockedParams = PollDemandAndReserveParams & {
74
+ scope: DemandScope;
75
+ };
76
+
70
77
  interface NormalizedTemplate {
71
78
  templateKey: string;
72
79
  labels: string[];
@@ -92,7 +99,7 @@ export async function pollDemandAndReserveTx(
92
99
  params: PollDemandAndReserveParams,
93
100
  ): Promise<{stats: DemandStat[]; reservations: ReservationGrant[]}> {
94
101
  await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${params.workspaceId}))`);
95
- return await pollDemandAndReserveLockedTx(tx, params);
102
+ return await pollDemandAndReserveLockedTx(tx, {...params, scope: 'workspace'});
96
103
  }
97
104
 
98
105
  export async function pollInstallationDemandAndReserve(
@@ -126,6 +133,7 @@ export async function pollInstallationDemandAndReserve(
126
133
  availableSlots: template.remainingSlots,
127
134
  })),
128
135
  capabilityWindowSeconds: params.capabilityWindowSeconds,
136
+ scope: 'installation',
129
137
  });
130
138
  });
131
139
  results.push(result);
@@ -158,7 +166,7 @@ function consumeInstallationTemplateSlots(
158
166
 
159
167
  async function pollDemandAndReserveLockedTx(
160
168
  tx: Tx,
161
- params: PollDemandAndReserveParams,
169
+ params: PollDemandAndReserveLockedParams,
162
170
  ): Promise<{stats: DemandStat[]; reservations: ReservationGrant[]}> {
163
171
  let demandRows = (
164
172
  await tx
@@ -265,6 +273,7 @@ async function pollDemandAndReserveLockedTx(
265
273
  requiredLabels: demand.requiredLabels,
266
274
  count: grant,
267
275
  workspaceId: params.workspaceId,
276
+ scope: params.scope,
268
277
  });
269
278
  reservedAfterGrant += grant;
270
279
  grants.push({
@@ -296,18 +305,61 @@ async function bindIdleRunnerInstancesTx(
296
305
  workspaceId: string;
297
306
  requiredLabels: string[];
298
307
  count: number;
308
+ scope: DemandScope;
299
309
  },
300
310
  ): Promise<void> {
311
+ // Reservation expiry is the activation grace deadline. A live reservation
312
+ // protects a runner that is still booting; an expired or missing row is stale.
313
+ const canBindRunner = () =>
314
+ and(
315
+ or(
316
+ and(isNull(providerRunners.workspaceId), isNull(providerRunners.reservationId)),
317
+ and(
318
+ isNotNull(providerRunners.reservationId),
319
+ params.scope === 'installation'
320
+ ? undefined
321
+ : or(
322
+ isNull(providerRunners.workspaceId),
323
+ eq(providerRunners.workspaceId, params.workspaceId),
324
+ ),
325
+ notExists(
326
+ tx
327
+ .select({id: reservations.id})
328
+ .from(reservations)
329
+ .where(
330
+ and(
331
+ eq(reservations.id, providerRunners.reservationId),
332
+ gt(reservations.expiresAt, sql`now()`),
333
+ ),
334
+ ),
335
+ ),
336
+ ),
337
+ ),
338
+ or(
339
+ isNull(providerRunners.intendedReservationId),
340
+ notExists(
341
+ tx
342
+ .select({id: reservations.id})
343
+ .from(reservations)
344
+ .where(
345
+ and(
346
+ eq(reservations.id, providerRunners.intendedReservationId),
347
+ gt(reservations.expiresAt, sql`now()`),
348
+ ),
349
+ ),
350
+ ),
351
+ ),
352
+ isNull(providerRunners.reservationReleasedAt),
353
+ isNull(providerRunners.runnerSessionId),
354
+ );
355
+
301
356
  const idleRunners = await tx
302
357
  .select({id: providerRunners.id})
303
358
  .from(providerRunners)
304
359
  .where(
305
360
  and(
306
361
  eq(providerRunners.provisionerId, params.provisionerId),
307
- isNull(providerRunners.workspaceId),
308
- isNull(providerRunners.reservationId),
309
- isNull(providerRunners.intendedReservationId),
310
- isNull(providerRunners.runnerSessionId),
362
+ canBindRunner(),
311
363
  isNotNull(providerRunners.providerRunnerId),
312
364
  eq(providerRunners.state, 'running'),
313
365
  arrayContains(providerRunners.labels, params.requiredLabels),
@@ -332,11 +384,26 @@ async function bindIdleRunnerInstancesTx(
332
384
 
333
385
  if (idleRunners.length === 0) return;
334
386
 
387
+ await tx
388
+ .update(runnerActivationTokens)
389
+ .set({revokedAt: sql`now()`})
390
+ .where(
391
+ and(
392
+ inArray(
393
+ runnerActivationTokens.runnerInstanceId,
394
+ idleRunners.map((runner) => runner.id),
395
+ ),
396
+ isNull(runnerActivationTokens.consumedAt),
397
+ isNull(runnerActivationTokens.revokedAt),
398
+ ),
399
+ );
400
+
335
401
  await tx
336
402
  .update(providerRunners)
337
403
  .set({
338
404
  workspaceId: params.workspaceId,
339
405
  reservationId: params.reservationId,
406
+ intendedReservationId: null,
340
407
  assignedAt: sql`now()`,
341
408
  updatedAt: sql`now()`,
342
409
  })
@@ -346,9 +413,7 @@ async function bindIdleRunnerInstancesTx(
346
413
  providerRunners.id,
347
414
  idleRunners.map((runner) => runner.id),
348
415
  ),
349
- isNull(providerRunners.workspaceId),
350
- isNull(providerRunners.reservationId),
351
- isNull(providerRunners.runnerSessionId),
416
+ canBindRunner(),
352
417
  ),
353
418
  );
354
419
  }