@shipfox/api-runners 9.3.0 → 10.1.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 (66) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +46 -0
  3. package/dist/config.js +1 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/core/runner-activation.d.ts +6 -0
  6. package/dist/core/runner-activation.d.ts.map +1 -1
  7. package/dist/core/runner-activation.js +20 -19
  8. package/dist/core/runner-activation.js.map +1 -1
  9. package/dist/core/runner-control-sessions.d.ts +2 -1
  10. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  11. package/dist/core/runner-control-sessions.js +65 -19
  12. package/dist/core/runner-control-sessions.js.map +1 -1
  13. package/dist/db/db.d.ts +34 -0
  14. package/dist/db/db.d.ts.map +1 -1
  15. package/dist/db/index.d.ts +1 -1
  16. package/dist/db/index.d.ts.map +1 -1
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/index.js.map +1 -1
  19. package/dist/db/job-executions.d.ts +7 -0
  20. package/dist/db/job-executions.d.ts.map +1 -1
  21. package/dist/db/job-executions.js +25 -0
  22. package/dist/db/job-executions.js.map +1 -1
  23. package/dist/db/runner-assignments.d.ts +6 -0
  24. package/dist/db/runner-assignments.d.ts.map +1 -1
  25. package/dist/db/runner-assignments.js +57 -42
  26. package/dist/db/runner-assignments.js.map +1 -1
  27. package/dist/db/runner-instances.d.ts.map +1 -1
  28. package/dist/db/runner-instances.js +64 -19
  29. package/dist/db/runner-instances.js.map +1 -1
  30. package/dist/db/schema/runner-instances.d.ts +17 -0
  31. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  32. package/dist/db/schema/runner-instances.js +3 -0
  33. package/dist/db/schema/runner-instances.js.map +1 -1
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/presentation/inter-module.d.ts.map +1 -1
  39. package/dist/presentation/inter-module.js +7 -2
  40. package/dist/presentation/inter-module.js.map +1 -1
  41. package/dist/presentation/routes/runner-enrollment.d.ts +9 -0
  42. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  43. package/dist/presentation/routes/runner-enrollment.js +36 -23
  44. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  45. package/dist/tsconfig.test.tsbuildinfo +1 -1
  46. package/drizzle/0001_intended_reservation.sql +3 -0
  47. package/drizzle/meta/0001_snapshot.json +2093 -0
  48. package/drizzle/meta/_journal.json +7 -0
  49. package/package.json +7 -7
  50. package/src/config.test.ts +11 -0
  51. package/src/config.ts +1 -1
  52. package/src/core/runner-activation.ts +48 -38
  53. package/src/core/runner-control-sessions.ts +115 -33
  54. package/src/db/index.ts +1 -0
  55. package/src/db/job-executions.ts +28 -0
  56. package/src/db/runner-assignments.test.ts +81 -0
  57. package/src/db/runner-assignments.ts +134 -86
  58. package/src/db/runner-instances.test.ts +182 -0
  59. package/src/db/runner-instances.ts +123 -28
  60. package/src/db/schema/runner-instances.ts +9 -0
  61. package/src/index.ts +1 -0
  62. package/src/presentation/inter-module.ts +4 -0
  63. package/src/presentation/routes/poll-demand.test.ts +2 -2
  64. package/src/presentation/routes/runner-enrollment.test.ts +144 -1
  65. package/src/presentation/routes/runner-enrollment.ts +40 -20
  66. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,11 +1,13 @@
1
- import {and, eq, inArray, isNull, sql} from 'drizzle-orm';
1
+ import {and, eq, inArray, isNull, notInArray, or, sql} from 'drizzle-orm';
2
2
  import {
3
3
  ReservationExpiredError,
4
4
  ReservationNotFoundError,
5
5
  RunnerInstanceAlreadyAssignedError,
6
6
  RunnerInstanceNotAssignableError,
7
7
  } from '#core/errors.js';
8
+ import type {Tx} from './db.js';
8
9
  import {db} from './db.js';
10
+ import {terminalStates} from './runner-instances.js';
9
11
  import {reservations} from './schema/reservations.js';
10
12
  import {runnerControlSessions} from './schema/runner-control-sessions.js';
11
13
  import {providerRunners} from './schema/runner-instances.js';
@@ -16,100 +18,146 @@ export async function assignRunnerInstances(params: {
16
18
  reservationId: string;
17
19
  runnerInstanceIds: string[];
18
20
  }): Promise<string[]> {
21
+ return await db().transaction(async (tx) => assignRunnerInstancesTx(tx, params));
22
+ }
23
+
24
+ export async function assignRunnerInstancesTx(
25
+ tx: Tx,
26
+ params: {
27
+ provisionerId: string;
28
+ reservationId: string;
29
+ runnerInstanceIds: string[];
30
+ },
31
+ ): Promise<string[]> {
19
32
  const runnerInstanceIds = [...params.runnerInstanceIds].sort();
20
- return await db().transaction(async (tx) => {
21
- await tx.execute(
22
- sql`select pg_advisory_xact_lock(hashtext(${`runners_assignment:${params.provisionerId}:${params.reservationId}`}))`,
23
- );
24
- const [reservation] = await tx
25
- .select()
26
- .from(reservations)
27
- .where(
28
- and(
29
- eq(reservations.id, params.reservationId),
30
- eq(reservations.provisionerId, params.provisionerId),
31
- ),
32
- )
33
- .limit(1)
34
- .for('update');
35
- if (!reservation) throw new ReservationNotFoundError(params.reservationId);
36
- if (reservation.expiresAt <= new Date())
37
- throw new ReservationExpiredError(params.reservationId);
33
+ await tx.execute(
34
+ sql`select pg_advisory_xact_lock(hashtext(${`runners_assignment:${params.provisionerId}:${params.reservationId}`}))`,
35
+ );
36
+ const runnerRows = await tx
37
+ .select({
38
+ id: providerRunners.id,
39
+ reservationId: providerRunners.reservationId,
40
+ intendedReservationId: providerRunners.intendedReservationId,
41
+ workspaceId: providerRunners.workspaceId,
42
+ providerRunnerId: providerRunners.providerRunnerId,
43
+ labels: providerRunners.labels,
44
+ state: providerRunners.state,
45
+ })
46
+ .from(providerRunners)
47
+ .where(
48
+ and(
49
+ eq(providerRunners.provisionerId, params.provisionerId),
50
+ inArray(providerRunners.id, runnerInstanceIds),
51
+ ),
52
+ )
53
+ .for('update');
38
54
 
39
- const runnerRows = await tx
40
- .select({
41
- id: providerRunners.id,
42
- reservationId: providerRunners.reservationId,
43
- workspaceId: providerRunners.workspaceId,
44
- providerRunnerId: providerRunners.providerRunnerId,
45
- labels: providerRunners.labels,
46
- state: providerRunners.state,
47
- })
48
- .from(providerRunners)
55
+ // The assignment outlives its short reservation row. A retry after maintenance
56
+ // cleanup is complete when every owned runner already carries the requested assignment.
57
+ if (
58
+ runnerInstanceIds.length > 0 &&
59
+ runnerRows.length === runnerInstanceIds.length &&
60
+ runnerRows.every((runner) => runner.reservationId === params.reservationId)
61
+ ) {
62
+ await tx
63
+ .update(providerRunners)
64
+ .set({intendedReservationId: null, updatedAt: sql`now()`})
49
65
  .where(
50
66
  and(
51
67
  eq(providerRunners.provisionerId, params.provisionerId),
52
68
  inArray(providerRunners.id, runnerInstanceIds),
53
69
  ),
54
- )
55
- .for('update');
56
- const activeControlSessions = await tx
57
- .select({runnerInstanceId: runnerControlSessions.runnerInstanceId})
58
- .from(runnerControlSessions)
59
- .where(
60
- and(
61
- inArray(
62
- runnerControlSessions.runnerInstanceId,
63
- runnerRows.map((runner) => runner.id),
64
- ),
65
- isNull(runnerControlSessions.closedAt),
66
- ),
67
70
  );
68
- const runnerInstanceIdsWithControlSession = new Set(
69
- activeControlSessions.map((session) => session.runnerInstanceId),
71
+ return params.runnerInstanceIds;
72
+ }
73
+
74
+ const [reservation] = await tx
75
+ .select()
76
+ .from(reservations)
77
+ .where(
78
+ and(
79
+ eq(reservations.id, params.reservationId),
80
+ eq(reservations.provisionerId, params.provisionerId),
81
+ ),
82
+ )
83
+ .limit(1)
84
+ .for('update');
85
+ if (!reservation) throw new ReservationNotFoundError(params.reservationId);
86
+ if (reservation.expiresAt <= new Date()) throw new ReservationExpiredError(params.reservationId);
87
+ if (runnerRows.length !== runnerInstanceIds.length)
88
+ throw new RunnerInstanceNotAssignableError(runnerInstanceIds[0] ?? '');
89
+
90
+ const activeControlSessions = await tx
91
+ .select({runnerInstanceId: runnerControlSessions.runnerInstanceId})
92
+ .from(runnerControlSessions)
93
+ .where(
94
+ and(
95
+ inArray(
96
+ runnerControlSessions.runnerInstanceId,
97
+ runnerRows.map((runner) => runner.id),
98
+ ),
99
+ isNull(runnerControlSessions.closedAt),
100
+ ),
70
101
  );
71
- const runners = runnerRows.map((runner) => ({
72
- ...runner,
73
- controlSessionId: runnerInstanceIdsWithControlSession.has(runner.id) ? runner.id : null,
74
- }));
75
- if (runners.length !== runnerInstanceIds.length)
76
- throw new RunnerInstanceNotAssignableError(runnerInstanceIds[0] ?? '');
102
+ const runnerInstanceIdsWithControlSession = new Set(
103
+ activeControlSessions.map((session) => session.runnerInstanceId),
104
+ );
105
+ const runners = runnerRows.map((runner) => ({
106
+ ...runner,
107
+ controlSessionId: runnerInstanceIdsWithControlSession.has(runner.id) ? runner.id : null,
108
+ }));
77
109
 
78
- const alreadyAssigned = runners.filter((runner) => runner.reservationId !== null);
79
- if (alreadyAssigned.some((runner) => runner.reservationId !== reservation.id))
80
- throw new RunnerInstanceAlreadyAssignedError(alreadyAssigned[0]?.id ?? '');
81
- const newRunners = runners.filter((runner) => runner.reservationId === null);
82
- const assignedCount = await tx
83
- .select({count: sql<number>`count(*)::int`})
84
- .from(providerRunners)
85
- .where(eq(providerRunners.reservationId, reservation.id));
86
- if ((assignedCount[0]?.count ?? 0) + newRunners.length > reservation.count)
87
- throw new RunnerInstanceNotAssignableError(newRunners[0]?.id ?? '');
88
- for (const runner of newRunners) {
89
- if (
90
- runner.state !== 'running' ||
91
- !runner.providerRunnerId ||
92
- !runner.controlSessionId ||
93
- !reservation.requiredLabels.every((label) => runner.labels.includes(label))
94
- )
95
- throw new RunnerInstanceNotAssignableError(runner.id);
96
- }
97
- if (newRunners.length > 0) {
98
- await tx
99
- .update(providerRunners)
100
- .set({
101
- workspaceId: reservation.workspaceId,
102
- reservationId: reservation.id,
103
- assignedAt: sql`now()`,
104
- updatedAt: sql`now()`,
105
- })
106
- .where(
107
- inArray(
108
- providerRunners.id,
109
- newRunners.map((runner) => runner.id),
110
+ const alreadyAssigned = runners.filter((runner) => runner.reservationId !== null);
111
+ if (alreadyAssigned.some((runner) => runner.reservationId !== reservation.id))
112
+ throw new RunnerInstanceAlreadyAssignedError(alreadyAssigned[0]?.id ?? '');
113
+ const newRunners = runners.filter((runner) => runner.reservationId === null);
114
+ const assignedCount = await tx
115
+ .select({count: sql<number>`count(*)::int`})
116
+ .from(providerRunners)
117
+ .where(
118
+ and(
119
+ eq(providerRunners.provisionerId, params.provisionerId),
120
+ or(
121
+ and(
122
+ eq(providerRunners.reservationId, reservation.id),
123
+ isNull(providerRunners.reservationReleasedAt),
110
124
  ),
111
- );
112
- }
113
- return params.runnerInstanceIds;
114
- });
125
+ and(
126
+ eq(providerRunners.intendedReservationId, reservation.id),
127
+ notInArray(providerRunners.id, runnerInstanceIds),
128
+ isNull(providerRunners.reservationReleasedAt),
129
+ notInArray(providerRunners.state, [...terminalStates]),
130
+ ),
131
+ ),
132
+ ),
133
+ );
134
+ if ((assignedCount[0]?.count ?? 0) + newRunners.length > reservation.count)
135
+ throw new RunnerInstanceNotAssignableError(newRunners[0]?.id ?? '');
136
+ for (const runner of newRunners) {
137
+ if (
138
+ runner.state !== 'running' ||
139
+ !runner.providerRunnerId ||
140
+ !runner.controlSessionId ||
141
+ !reservation.requiredLabels.every((label) => runner.labels.includes(label))
142
+ )
143
+ throw new RunnerInstanceNotAssignableError(runner.id);
144
+ }
145
+ if (newRunners.length > 0) {
146
+ await tx
147
+ .update(providerRunners)
148
+ .set({
149
+ workspaceId: reservation.workspaceId,
150
+ reservationId: reservation.id,
151
+ intendedReservationId: null,
152
+ assignedAt: sql`now()`,
153
+ updatedAt: sql`now()`,
154
+ })
155
+ .where(
156
+ inArray(
157
+ providerRunners.id,
158
+ newRunners.map((runner) => runner.id),
159
+ ),
160
+ );
161
+ }
162
+ return params.runnerInstanceIds;
115
163
  }
@@ -413,6 +413,134 @@ describe('reportRunnerInstances', () => {
413
413
  expect(providerRunnerRows[0]?.reservationReleasedAt).toBeInstanceOf(Date);
414
414
  });
415
415
 
416
+ it('releases an intended reservation for a runner that dies before enrollment', async () => {
417
+ const reservationId = await createReservation(2);
418
+ const [runner] = await db()
419
+ .insert(providerRunners)
420
+ .values({
421
+ provisionerId,
422
+ intendedReservationId: reservationId,
423
+ providerRunnerId: 'pre-enrollment-runner',
424
+ state: 'starting',
425
+ reportedAt: new Date(),
426
+ })
427
+ .returning();
428
+ if (!runner) throw new Error('Runner instance insert returned no row');
429
+ if (!runner.providerRunnerId) throw new Error('Runner instance provider id missing');
430
+
431
+ const result = await reportRunnerInstances({
432
+ workspaceId,
433
+ provisionerId,
434
+ events: [event({providerRunnerId: runner.providerRunnerId, state: 'failed'})],
435
+ });
436
+
437
+ const [storedRunner] = await db()
438
+ .select()
439
+ .from(providerRunners)
440
+ .where(eq(providerRunners.id, runner.id));
441
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
442
+ expect(result).toEqual({accepted: 1, reservationsReleased: 1, terminateIntentsHonored: []});
443
+ expect(reservationRows[0]?.count).toBe(1);
444
+ expect(storedRunner?.intendedReservationId).toBeNull();
445
+ expect(storedRunner?.reservationReleasedAt).toBeInstanceOf(Date);
446
+ });
447
+
448
+ it('releases an intended reservation from an installation-scoped terminal report', async () => {
449
+ const installationProvisioner = await provisionerTokenFactory.create({
450
+ scope: 'installation',
451
+ workspaceId: null,
452
+ });
453
+ const reservationWorkspaceId = crypto.randomUUID();
454
+ await reservationFactory.create({
455
+ workspaceId: reservationWorkspaceId,
456
+ provisionerId: installationProvisioner.id,
457
+ requiredLabels: ['linux'],
458
+ count: 2,
459
+ expiresAt: new Date(Date.now() + 60_000),
460
+ });
461
+ const [reservation] = await db()
462
+ .select()
463
+ .from(reservations)
464
+ .where(
465
+ and(
466
+ eq(reservations.workspaceId, reservationWorkspaceId),
467
+ eq(reservations.provisionerId, installationProvisioner.id),
468
+ ),
469
+ )
470
+ .orderBy(desc(reservations.id))
471
+ .limit(1);
472
+ if (!reservation) throw new Error('Expected reservation');
473
+ await db().insert(providerRunners).values({
474
+ provisionerId: installationProvisioner.id,
475
+ intendedReservationId: reservation.id,
476
+ providerRunnerId: 'installation-pre-enrollment-runner',
477
+ state: 'starting',
478
+ reportedAt: new Date(),
479
+ });
480
+
481
+ const result = await reportRunnerInstances({
482
+ workspaceId: null,
483
+ provisionerId: installationProvisioner.id,
484
+ events: [event({providerRunnerId: 'installation-pre-enrollment-runner', state: 'failed'})],
485
+ });
486
+
487
+ const [storedReservation] = await db()
488
+ .select()
489
+ .from(reservations)
490
+ .where(eq(reservations.id, reservation.id));
491
+ const [storedRunner] = await db()
492
+ .select()
493
+ .from(providerRunners)
494
+ .where(eq(providerRunners.providerRunnerId, 'installation-pre-enrollment-runner'));
495
+ expect(result).toEqual({accepted: 1, reservationsReleased: 1, terminateIntentsHonored: []});
496
+ expect(storedReservation?.count).toBe(1);
497
+ expect(storedRunner?.intendedReservationId).toBeNull();
498
+ expect(storedRunner?.reservationReleasedAt).toBeInstanceOf(Date);
499
+ });
500
+
501
+ it('prefers the validated intended reservation when both reservation ids are set', async () => {
502
+ const installationProvisioner = await provisionerTokenFactory.create({
503
+ scope: 'installation',
504
+ workspaceId: null,
505
+ });
506
+ const staleReservationId = await createReservation(1, {
507
+ workspaceId: crypto.randomUUID(),
508
+ provisionerId: installationProvisioner.id,
509
+ });
510
+ const intendedReservationId = await createReservation(1, {
511
+ workspaceId: crypto.randomUUID(),
512
+ provisionerId: installationProvisioner.id,
513
+ });
514
+ await db().insert(providerRunners).values({
515
+ provisionerId: installationProvisioner.id,
516
+ reservationId: staleReservationId,
517
+ intendedReservationId,
518
+ providerRunnerId: 'installation-runner-with-stale-reservation',
519
+ state: 'starting',
520
+ reportedAt: new Date(),
521
+ });
522
+
523
+ const result = await reportRunnerInstances({
524
+ workspaceId: null,
525
+ provisionerId: installationProvisioner.id,
526
+ events: [
527
+ event({providerRunnerId: 'installation-runner-with-stale-reservation', state: 'failed'}),
528
+ ],
529
+ });
530
+
531
+ const [staleReservation] = await db()
532
+ .select()
533
+ .from(reservations)
534
+ .where(eq(reservations.id, staleReservationId));
535
+ const [intendedReservation] = await db()
536
+ .select()
537
+ .from(reservations)
538
+ .where(eq(reservations.id, intendedReservationId));
539
+ expect(result).toEqual({accepted: 1, reservationsReleased: 1, terminateIntentsHonored: []});
540
+ expect(staleReservation?.count).toBe(1);
541
+ expect(intendedReservation).toBeUndefined();
542
+ });
543
+
416
544
  it('does not release a reservation owned by another workspace or provisioner', async () => {
417
545
  const otherWorkspaceReservationId = await createReservation(1, {
418
546
  workspaceId: crypto.randomUUID(),
@@ -1045,6 +1173,60 @@ describe('reapStaleRunnerInstances', () => {
1045
1173
  expect(row).toMatchObject({workspaceId: null, providerRunnerId: null, state: 'failed'});
1046
1174
  });
1047
1175
 
1176
+ it('releases an intended reservation while reaping a stale installation runner', async () => {
1177
+ const installationProvisioner = await provisionerTokenFactory.create({
1178
+ scope: 'installation',
1179
+ workspaceId: null,
1180
+ });
1181
+ const reservationWorkspaceId = crypto.randomUUID();
1182
+ await reservationFactory.create({
1183
+ workspaceId: reservationWorkspaceId,
1184
+ provisionerId: installationProvisioner.id,
1185
+ requiredLabels: ['linux'],
1186
+ count: 1,
1187
+ expiresAt: new Date(Date.now() + 60_000),
1188
+ });
1189
+ const [reservation] = await db()
1190
+ .select()
1191
+ .from(reservations)
1192
+ .where(
1193
+ and(
1194
+ eq(reservations.workspaceId, reservationWorkspaceId),
1195
+ eq(reservations.provisionerId, installationProvisioner.id),
1196
+ ),
1197
+ )
1198
+ .orderBy(desc(reservations.id))
1199
+ .limit(1);
1200
+ if (!reservation) throw new Error('Expected reservation');
1201
+ const [instance] = await db()
1202
+ .insert(providerRunners)
1203
+ .values({
1204
+ provisionerId: installationProvisioner.id,
1205
+ intendedReservationId: reservation.id,
1206
+ providerRunnerId: 'stale-installation-runner-with-reservation',
1207
+ state: 'starting',
1208
+ reportedAt: staleAt(),
1209
+ updatedAt: staleAt(),
1210
+ })
1211
+ .returning({id: providerRunners.id});
1212
+ if (!instance) throw new Error('Runner instance insert returned no row');
1213
+
1214
+ const result = await reapStaleRunnerInstances({thresholdSeconds: 60, limit: 100});
1215
+ const [row] = await db()
1216
+ .select()
1217
+ .from(providerRunners)
1218
+ .where(eq(providerRunners.id, instance.id));
1219
+ const remainingReservations = await db()
1220
+ .select()
1221
+ .from(reservations)
1222
+ .where(eq(reservations.id, reservation.id));
1223
+
1224
+ expect(result).toEqual({reaped: 1, reservationsReleased: 1});
1225
+ expect(row?.intendedReservationId).toBeNull();
1226
+ expect(row?.reservationReleasedAt).toBeInstanceOf(Date);
1227
+ expect(remainingReservations).toHaveLength(0);
1228
+ });
1229
+
1048
1230
  it('skips fresh rows, live provisioners, terminal rows, running jobs, and fresh sessions', async () => {
1049
1231
  await createRunnerInstance({
1050
1232
  providerRunnerId: 'fresh-row',
@@ -28,6 +28,7 @@ import {
28
28
  import {releaseReservationUnits} from './reservations.js';
29
29
  import {ephemeralRegistrationTokens} from './schema/ephemeral-registration-tokens.js';
30
30
  import {provisionerTokens} from './schema/provisioner-tokens.js';
31
+ import {reservations} from './schema/reservations.js';
31
32
  import {providerRunners, toRunnerInstance} from './schema/runner-instances.js';
32
33
  import {runnerSessions} from './schema/runner-sessions.js';
33
34
  import {runningJobExecutions} from './schema/running-job-executions.js';
@@ -563,7 +564,7 @@ async function releaseTerminalRunnerInstanceReservations(
563
564
  events: RunnerInstanceReportEvent[],
564
565
  ): Promise<number> {
565
566
  const terminalEvents = events.filter((event) => isTerminalState(event.state));
566
- if (terminalEvents.length === 0 || !params.workspaceId) return 0;
567
+ if (terminalEvents.length === 0) return 0;
567
568
 
568
569
  return await releaseTerminalRunnerInstanceReservationsByIds(tx, {
569
570
  workspaceId: params.workspaceId,
@@ -575,7 +576,7 @@ async function releaseTerminalRunnerInstanceReservations(
575
576
  async function releaseTerminalRunnerInstanceReservationsByIds(
576
577
  tx: Tx,
577
578
  params: {
578
- workspaceId: string;
579
+ workspaceId: string | null;
579
580
  provisionerId: string;
580
581
  providerRunnerIds: string[];
581
582
  requireUnlinkedSession?: boolean;
@@ -583,31 +584,115 @@ async function releaseTerminalRunnerInstanceReservationsByIds(
583
584
  ): Promise<number> {
584
585
  if (params.providerRunnerIds.length === 0) return 0;
585
586
 
587
+ const reservationWorkspacePredicate =
588
+ params.workspaceId === null
589
+ ? sql``
590
+ : sql`and ${eq(reservations.workspaceId, params.workspaceId)}`;
591
+
586
592
  const rows = await tx
587
593
  .select({
588
594
  id: providerRunners.id,
589
- reservationId: providerRunners.reservationId,
595
+ releaseReservationId: sql<string | null>`coalesce(
596
+ (select ${reservations.id}
597
+ from ${reservations}
598
+ where ${reservations.id} = ${providerRunners.intendedReservationId}
599
+ and ${reservations.provisionerId} = ${params.provisionerId}
600
+ ${reservationWorkspacePredicate}),
601
+ (select ${reservations.id}
602
+ from ${reservations}
603
+ where ${reservations.id} = ${providerRunners.reservationId}
604
+ and ${reservations.provisionerId} = ${params.provisionerId}
605
+ ${reservationWorkspacePredicate})
606
+ )`,
607
+ releaseReservationWorkspaceId: sql<string | null>`coalesce(
608
+ (select ${reservations.workspaceId}
609
+ from ${reservations}
610
+ where ${reservations.id} = ${providerRunners.intendedReservationId}
611
+ and ${reservations.provisionerId} = ${params.provisionerId}
612
+ ${reservationWorkspacePredicate}),
613
+ (select ${reservations.workspaceId}
614
+ from ${reservations}
615
+ where ${reservations.id} = ${providerRunners.reservationId}
616
+ and ${reservations.provisionerId} = ${params.provisionerId}
617
+ ${reservationWorkspacePredicate})
618
+ )`,
590
619
  })
591
620
  .from(providerRunners)
592
621
  .where(
593
622
  and(
594
- eq(providerRunners.workspaceId, params.workspaceId),
595
623
  eq(providerRunners.provisionerId, params.provisionerId),
596
624
  inArray(providerRunners.providerRunnerId, params.providerRunnerIds),
597
625
  inArray(providerRunners.state, terminalStates),
598
- isNotNull(providerRunners.reservationId),
626
+ or(
627
+ isNotNull(providerRunners.reservationId),
628
+ isNotNull(providerRunners.intendedReservationId),
629
+ ),
630
+ params.workspaceId === null
631
+ ? undefined
632
+ : or(
633
+ and(
634
+ eq(providerRunners.workspaceId, params.workspaceId),
635
+ or(
636
+ exists(
637
+ tx
638
+ .select({id: reservations.id})
639
+ .from(reservations)
640
+ .where(
641
+ and(
642
+ eq(reservations.workspaceId, params.workspaceId),
643
+ eq(reservations.provisionerId, params.provisionerId),
644
+ eq(reservations.id, providerRunners.reservationId),
645
+ ),
646
+ ),
647
+ ),
648
+ exists(
649
+ tx
650
+ .select({id: reservations.id})
651
+ .from(reservations)
652
+ .where(
653
+ and(
654
+ eq(reservations.workspaceId, params.workspaceId),
655
+ eq(reservations.provisionerId, params.provisionerId),
656
+ eq(reservations.id, providerRunners.intendedReservationId),
657
+ ),
658
+ ),
659
+ ),
660
+ ),
661
+ ),
662
+ and(
663
+ isNull(providerRunners.workspaceId),
664
+ isNotNull(providerRunners.intendedReservationId),
665
+ exists(
666
+ tx
667
+ .select({id: reservations.id})
668
+ .from(reservations)
669
+ .where(
670
+ and(
671
+ eq(reservations.workspaceId, params.workspaceId),
672
+ eq(reservations.provisionerId, params.provisionerId),
673
+ eq(reservations.id, providerRunners.intendedReservationId),
674
+ ),
675
+ ),
676
+ ),
677
+ ),
678
+ ),
599
679
  params.requireUnlinkedSession === false
600
680
  ? undefined
601
681
  : isNull(providerRunners.runnerSessionId),
602
682
  isNull(providerRunners.reservationReleasedAt),
603
683
  ),
604
- );
684
+ )
685
+ .for('update');
605
686
 
606
687
  if (rows.length === 0) return 0;
607
688
 
608
689
  const updated = await tx
609
690
  .update(providerRunners)
610
- .set({reservationReleasedAt: sql`now()`, updatedAt: sql`now()`})
691
+ .set({
692
+ intendedReservationId: null,
693
+ reservationReleasedAt: sql`now()`,
694
+ updatedAt: sql`now()`,
695
+ })
611
696
  .where(
612
697
  and(
613
698
  inArray(
@@ -620,27 +705,37 @@ async function releaseTerminalRunnerInstanceReservationsByIds(
620
705
  isNull(providerRunners.reservationReleasedAt),
621
706
  ),
622
707
  )
623
- .returning({reservationId: providerRunners.reservationId});
624
-
625
- const releasesByReservationId = new Map<string, number>();
626
- for (const row of updated) {
627
- if (!row.reservationId) continue;
628
- releasesByReservationId.set(
629
- row.reservationId,
630
- (releasesByReservationId.get(row.reservationId) ?? 0) + 1,
631
- );
708
+ .returning({id: providerRunners.id});
709
+
710
+ const releasesByReservationId = new Map<
711
+ string,
712
+ {workspaceId: string; reservationId: string; count: number}
713
+ >();
714
+ const updatedIds = new Set(updated.map((row) => row.id));
715
+ for (const row of rows) {
716
+ if (!updatedIds.has(row.id)) continue;
717
+ if (!row.releaseReservationId || !row.releaseReservationWorkspaceId) continue;
718
+ const key = `${row.releaseReservationWorkspaceId}:${row.releaseReservationId}`;
719
+ const release = releasesByReservationId.get(key) ?? {
720
+ workspaceId: row.releaseReservationWorkspaceId,
721
+ reservationId: row.releaseReservationId,
722
+ count: 0,
723
+ };
724
+ release.count += 1;
725
+ releasesByReservationId.set(key, release);
632
726
  }
633
727
 
634
728
  if (releasesByReservationId.size === 0) return 0;
635
729
 
636
- return await releaseReservationUnits(tx, {
637
- workspaceId: params.workspaceId,
638
- provisionerId: params.provisionerId,
639
- releases: [...releasesByReservationId].map(([reservationId, count]) => ({
640
- reservationId,
641
- count,
642
- })),
643
- });
730
+ let released = 0;
731
+ for (const release of releasesByReservationId.values()) {
732
+ released += await releaseReservationUnits(tx, {
733
+ workspaceId: release.workspaceId,
734
+ provisionerId: params.provisionerId,
735
+ releases: [{reservationId: release.reservationId, count: release.count}],
736
+ });
737
+ }
738
+ return released;
644
739
  }
645
740
 
646
741
  function staleRunnerInstanceCutoff(thresholdSeconds: number): SQL {
@@ -697,14 +792,14 @@ function groupRunnerInstanceIds(
697
792
  provisionerId: string;
698
793
  providerRunnerId: string | null;
699
794
  }>,
700
- ): Array<{workspaceId: string; provisionerId: string; providerRunnerIds: string[]}> {
795
+ ): Array<{workspaceId: string | null; provisionerId: string; providerRunnerIds: string[]}> {
701
796
  const groups = new Map<
702
797
  string,
703
- {workspaceId: string; provisionerId: string; providerRunnerIds: string[]}
798
+ {workspaceId: string | null; provisionerId: string; providerRunnerIds: string[]}
704
799
  >();
705
800
  for (const row of rows) {
706
- if (!row.workspaceId || !row.providerRunnerId) continue;
707
- const key = `${row.workspaceId}:${row.provisionerId}`;
801
+ if (!row.providerRunnerId) continue;
802
+ const key = `${row.workspaceId ?? ''}:${row.provisionerId}`;
708
803
  const group = groups.get(key) ?? {
709
804
  workspaceId: row.workspaceId,
710
805
  provisionerId: row.provisionerId,
@@ -23,6 +23,7 @@ export const providerRunners = pgTable(
23
23
  workspaceId: uuid('workspace_id'),
24
24
  provisionerId: uuid('provisioner_id').notNull(),
25
25
  providerRunnerId: text('provider_runner_id'),
26
+ intendedReservationId: uuid('intended_reservation_id'),
26
27
  reservationId: uuid('reservation_id'),
27
28
  assignedAt: timestamp('assigned_at', {withTimezone: true}),
28
29
  templateKey: text('template_key'),
@@ -53,6 +54,14 @@ export const providerRunners = pgTable(
53
54
  table.updatedAt,
54
55
  table.reportedAt,
55
56
  ),
57
+ index('runners_runner_instances_provisioner_intended_reservation_idx')
58
+ .on(table.provisionerId, table.intendedReservationId)
59
+ .where(
60
+ sql`${table.intendedReservationId} is not null and ${table.reservationReleasedAt} is null`,
61
+ ),
62
+ index('runners_runner_instances_provisioner_reservation_idx')
63
+ .on(table.provisionerId, table.reservationId)
64
+ .where(sql`${table.reservationId} is not null`),
56
65
  index('runners_runner_instances_active_template_counts_idx')
57
66
  .on(table.provisionerId, table.state, table.templateKey)
58
67
  .where(sql`"state" in ('starting', 'running') and "template_key" is not null`),