@shipfox/api-runners 12.6.0 → 13.0.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 (78) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +20 -0
  3. package/dist/db/index.d.ts +1 -1
  4. package/dist/db/index.d.ts.map +1 -1
  5. package/dist/db/index.js +1 -1
  6. package/dist/db/index.js.map +1 -1
  7. package/dist/db/job-executions.d.ts +8 -21
  8. package/dist/db/job-executions.d.ts.map +1 -1
  9. package/dist/db/job-executions.js +63 -60
  10. package/dist/db/job-executions.js.map +1 -1
  11. package/dist/db/provisioner-tokens.d.ts.map +1 -1
  12. package/dist/db/provisioner-tokens.js +14 -2
  13. package/dist/db/provisioner-tokens.js.map +1 -1
  14. package/dist/db/reservations.d.ts +10 -0
  15. package/dist/db/reservations.d.ts.map +1 -1
  16. package/dist/db/reservations.js +139 -17
  17. package/dist/db/reservations.js.map +1 -1
  18. package/dist/db/runner-assignments.d.ts.map +1 -1
  19. package/dist/db/runner-assignments.js +8 -5
  20. package/dist/db/runner-assignments.js.map +1 -1
  21. package/dist/db/runner-instances.d.ts +0 -1
  22. package/dist/db/runner-instances.d.ts.map +1 -1
  23. package/dist/db/runner-instances.js +85 -100
  24. package/dist/db/runner-instances.js.map +1 -1
  25. package/dist/db/runner-states.d.ts +3 -1
  26. package/dist/db/runner-states.d.ts.map +1 -1
  27. package/dist/db/runner-states.js +6 -4
  28. package/dist/db/runner-states.js.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +5 -4
  32. package/dist/index.js.map +1 -1
  33. package/dist/presentation/index.d.ts +2 -1
  34. package/dist/presentation/index.d.ts.map +1 -1
  35. package/dist/presentation/index.js +2 -1
  36. package/dist/presentation/index.js.map +1 -1
  37. package/dist/presentation/inter-module.d.ts +0 -1
  38. package/dist/presentation/inter-module.d.ts.map +1 -1
  39. package/dist/presentation/inter-module.js +2 -25
  40. package/dist/presentation/inter-module.js.map +1 -1
  41. package/dist/presentation/subscribers/on-workflows-job-execution-queued.d.ts +3 -0
  42. package/dist/presentation/subscribers/on-workflows-job-execution-queued.d.ts.map +1 -0
  43. package/dist/presentation/subscribers/on-workflows-job-execution-queued.js +21 -0
  44. package/dist/presentation/subscribers/on-workflows-job-execution-queued.js.map +1 -0
  45. package/dist/presentation/subscribers/on-workflows-job-execution-terminated.d.ts +3 -0
  46. package/dist/presentation/subscribers/on-workflows-job-execution-terminated.d.ts.map +1 -0
  47. package/dist/presentation/subscribers/{on-workflows-job-execution-timed-out.js → on-workflows-job-execution-terminated.js} +5 -4
  48. package/dist/presentation/subscribers/on-workflows-job-execution-terminated.js.map +1 -0
  49. package/dist/tsconfig.test.tsbuildinfo +1 -1
  50. package/package.json +3 -3
  51. package/src/db/index.ts +0 -2
  52. package/src/db/job-executions.test.ts +106 -217
  53. package/src/db/job-executions.ts +71 -70
  54. package/src/db/provisioner-tokens.test.ts +19 -0
  55. package/src/db/provisioner-tokens.ts +13 -2
  56. package/src/db/reservations.test.ts +138 -0
  57. package/src/db/reservations.ts +314 -35
  58. package/src/db/runner-assignments.test.ts +20 -0
  59. package/src/db/runner-assignments.ts +6 -7
  60. package/src/db/runner-instances.test.ts +350 -15
  61. package/src/db/runner-instances.ts +136 -198
  62. package/src/db/runner-states.test.ts +16 -0
  63. package/src/db/runner-states.ts +9 -4
  64. package/src/index.test.ts +1 -0
  65. package/src/index.ts +8 -7
  66. package/src/presentation/index.ts +2 -1
  67. package/src/presentation/inter-module.ts +2 -40
  68. package/src/presentation/subscribers/on-workflows-job-execution-queued.test.ts +57 -0
  69. package/src/presentation/subscribers/on-workflows-job-execution-queued.ts +26 -0
  70. package/src/presentation/subscribers/on-workflows-job-execution-terminated.test.ts +142 -0
  71. package/src/presentation/subscribers/{on-workflows-job-execution-timed-out.ts → on-workflows-job-execution-terminated.ts} +5 -4
  72. package/test/factories/pending-job.ts +3 -0
  73. package/tsconfig.build.tsbuildinfo +1 -1
  74. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts +0 -3
  75. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts.map +0 -1
  76. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js.map +0 -1
  77. package/src/presentation/inter-module.test.ts +0 -15
  78. package/src/presentation/subscribers/on-workflows-job-execution-timed-out.test.ts +0 -163
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  RUNNER_JOB_CLAIMED,
3
3
  RUNNER_JOB_LEASE_EXPIRED,
4
- RUNNER_JOB_QUEUED,
5
4
  type RunnersEventMap,
6
5
  type RunnerToolCapabilitiesDto,
7
6
  } from '@shipfox/api-runners-dto';
@@ -39,6 +38,7 @@ import {
39
38
  } from '#metrics/instance.js';
40
39
  import type {Tx} from './db.js';
41
40
  import {db} from './db.js';
41
+ import {releaseTerminalRunnerInstanceReservationsByIds} from './reservations.js';
42
42
  import {runnersOutbox} from './schema/outbox.js';
43
43
  import {pendingJobExecutions} from './schema/pending-job-executions.js';
44
44
  import {providerRunners} from './schema/runner-instances.js';
@@ -53,6 +53,39 @@ async function lockJobExecution(tx: Tx, jobExecutionId: string): Promise<void> {
53
53
  );
54
54
  }
55
55
 
56
+ async function releaseReservationsForTerminalRunningRows(
57
+ tx: Tx,
58
+ rows: ReadonlyArray<{
59
+ provisionerId: string | null;
60
+ providerRunnerId: string | null;
61
+ }>,
62
+ ): Promise<void> {
63
+ // Converge after the lease fact changes. The reservation helper locks each provider runner
64
+ // and releases only when the runner is terminal and no uncancelled lease remains.
65
+ const providerRunnerIdsByProvisionerId = new Map<string, Set<string>>();
66
+ for (const row of rows) {
67
+ if (row.provisionerId === null || row.providerRunnerId === null) continue;
68
+ const providerRunnerIds =
69
+ providerRunnerIdsByProvisionerId.get(row.provisionerId) ?? new Set<string>();
70
+ providerRunnerIds.add(row.providerRunnerId);
71
+ providerRunnerIdsByProvisionerId.set(row.provisionerId, providerRunnerIds);
72
+ }
73
+
74
+ for (const provisionerId of [...providerRunnerIdsByProvisionerId.keys()].sort()) {
75
+ const providerRunnerIds = providerRunnerIdsByProvisionerId.get(provisionerId);
76
+ if (!providerRunnerIds) continue;
77
+ await releaseTerminalRunnerInstanceReservationsByIds(tx, {
78
+ workspaceId: null,
79
+ provisionerId,
80
+ providerRunnerIds: [...providerRunnerIds].sort(),
81
+ requireUnlinkedSession: false,
82
+ // Lock and re-check the runner row locally so lease finalization remains retryable
83
+ // without a workflow/runner scope lock.
84
+ requireTerminalState: false,
85
+ });
86
+ }
87
+ }
88
+
56
89
  export interface EnqueueJobExecutionParams {
57
90
  workspaceId: string;
58
91
  workflowRunId: string;
@@ -61,6 +94,7 @@ export interface EnqueueJobExecutionParams {
61
94
  jobExecutionId: string;
62
95
  projectId: string;
63
96
  requiredLabels: string[];
97
+ queuedAt: Date;
64
98
  }
65
99
 
66
100
  export async function getWorkspaceJobCounts(params: {
@@ -91,11 +125,9 @@ export async function getWorkspaceJobCounts(params: {
91
125
  }));
92
126
  }
93
127
 
94
- // Idempotent while the job execution is still pending: a duplicate jobExecutionId already in
95
- // `runners_pending_jobs` is a no-op. Temporal retries the enqueue activity at-least-once, so a
96
- // unique-violation throw on a retry-after-lost-result would permanently fail a healthy execution.
97
- // The per-execution advisory lock serializes retries with lease expiry/reconciliation, and the
98
- // durable lease-expired event prevents a retry from re-queueing an execution already reaped.
128
+ // The workflows outbox delivers queue facts at least once. The per-execution advisory lock and
129
+ // pending/running checks make replay a no-op across the claim transition. A durable lease-expired
130
+ // event also prevents a delayed replay from resurrecting an execution already reaped locally.
99
131
  export async function enqueueJobExecution(params: EnqueueJobExecutionParams): Promise<void> {
100
132
  const requiredLabels = [...canonicalizeLabels(params.requiredLabels)];
101
133
  if (requiredLabels.length === 0) throw new EmptyRequiredLabelsError();
@@ -103,6 +135,13 @@ export async function enqueueJobExecution(params: EnqueueJobExecutionParams): Pr
103
135
  const enqueued = await db().transaction(async (tx) => {
104
136
  await lockJobExecution(tx, params.jobExecutionId);
105
137
 
138
+ const [running] = await tx
139
+ .select({jobExecutionId: runningJobExecutions.jobExecutionId})
140
+ .from(runningJobExecutions)
141
+ .where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId))
142
+ .limit(1);
143
+ if (running) return false;
144
+
106
145
  const [leaseExpired] = await tx
107
146
  .select({id: runnersOutbox.id})
108
147
  .from(runnersOutbox)
@@ -125,26 +164,12 @@ export async function enqueueJobExecution(params: EnqueueJobExecutionParams): Pr
125
164
  jobExecutionId: params.jobExecutionId,
126
165
  projectId: params.projectId,
127
166
  requiredLabels,
167
+ createdAt: params.queuedAt,
128
168
  })
129
169
  .onConflictDoNothing({target: pendingJobExecutions.jobExecutionId})
130
170
  .returning({createdAt: pendingJobExecutions.createdAt});
131
171
 
132
- // A retry that hits the conflict inserts nothing: the first enqueue already
133
- // emitted the queued event (durably, in the outbox), so re-emitting would
134
- // only add a redundant row the subscriber coalesces away. Skip it.
135
- if (!inserted) return false;
136
-
137
- await writeOutboxEvent<RunnersEventMap>(tx, runnersOutbox, {
138
- type: RUNNER_JOB_QUEUED,
139
- payload: {
140
- workflowRunId: params.workflowRunId,
141
- workflowRunAttemptId: params.workflowRunAttemptId,
142
- jobId: params.jobId,
143
- jobExecutionId: params.jobExecutionId,
144
- queuedAt: inserted.createdAt.toISOString(),
145
- },
146
- });
147
- return true;
172
+ return inserted !== undefined;
148
173
  });
149
174
 
150
175
  if (enqueued) jobExecutionEnqueuedCount.add(1);
@@ -402,43 +427,18 @@ async function touchRunnerSessionLiveness(params: {
402
427
  );
403
428
  }
404
429
 
405
- /**
406
- * Releases a job execution's lease when the orchestration workflow finalizes it: deletes the
407
- * running-job-execution row AND any lingering pending row for the same execution, in one tx.
408
- * Idempotent (0-row no-op), no token scope (the workflow is authoritative), and
409
- * emits no event: the workflow already owns the outcome. Sweeping the pending row
410
- * too closes the at-least-once window where an enqueue retry left an orphan that a
411
- * later claim would otherwise pick up for an already-finished job execution.
412
- */
413
- export async function releaseJobExecution(params: {jobExecutionId: string}): Promise<void> {
414
- await db().transaction(async (tx) => {
415
- await lockJobExecution(tx, params.jobExecutionId);
416
-
417
- // Delete pending before running to match `claimPendingJobExecution`'s lock-acquisition
418
- // order (it locks the pending row first, then the running row). A concurrent
419
- // claim picking up an orphan pending row for this same job execution would otherwise
420
- // deadlock against the reverse order here.
421
- await tx
422
- .delete(pendingJobExecutions)
423
- .where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
424
- await tx
425
- .delete(runningJobExecutions)
426
- .where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId));
427
- });
428
- }
429
-
430
430
  /**
431
431
  * Reaps stale leases (bounded by `limit`), emitting one
432
432
  * `runners.job.lease_expired` event per reaped job execution.
433
433
  *
434
434
  * The cutoff is re-checked in the DELETE, not just the locking subquery, so a
435
435
  * heartbeat landing mid-call spares the live row. Each reaped execution also sweeps its
436
- * pending row: a failed best-effort `releaseJobExecution` would otherwise leave an orphan
437
- * that a later claim re-runs as an already-finished job execution.
436
+ * pending row so an at-least-once queue replay cannot leave an orphan.
438
437
  *
439
- * Locks pending-then-running to match `claimPendingJobExecution`, `releaseJobExecution`, and
440
- * `reconcileTerminalJobExecution`. The stale candidate scan intentionally does not lock running
441
- * rows first; the running-row DELETE re-checks the stale predicate after the pending-row sweep.
438
+ * Locks pending-then-running to match `claimPendingJobExecution` and
439
+ * `reconcileTerminalJobExecution`. The stale candidate scan intentionally does not lock
440
+ * running rows first; the running-row DELETE re-checks the stale predicate after the
441
+ * pending-row sweep.
442
442
  */
443
443
  export async function expireStuckJobExecutions(params: {
444
444
  thresholdSeconds: number;
@@ -515,10 +515,14 @@ export async function expireStuckJobExecutions(params: {
515
515
  workflowRunAttemptId: runningJobExecutions.workflowRunAttemptId,
516
516
  jobId: runningJobExecutions.jobId,
517
517
  jobExecutionId: runningJobExecutions.jobExecutionId,
518
+ provisionerId: runningJobExecutions.provisionerId,
519
+ providerRunnerId: runningJobExecutions.providerRunnerId,
518
520
  });
519
521
 
520
522
  if (deleted.length === 0) return [];
521
523
 
524
+ await releaseReservationsForTerminalRunningRows(tx, deleted);
525
+
522
526
  await writeOutboxEvents<RunnersEventMap>(
523
527
  tx,
524
528
  runnersOutbox,
@@ -533,7 +537,12 @@ export async function expireStuckJobExecutions(params: {
533
537
  })),
534
538
  );
535
539
 
536
- return deleted;
540
+ return deleted.map(({workflowRunId, workflowRunAttemptId, jobId, jobExecutionId}) => ({
541
+ workflowRunId,
542
+ workflowRunAttemptId,
543
+ jobId,
544
+ jobExecutionId,
545
+ }));
537
546
  });
538
547
 
539
548
  if (reaped.length > 0) jobExecutionLeaseExpiredCount.add(reaped.length);
@@ -781,8 +790,8 @@ export async function recordHeartbeat(params: {
781
790
 
782
791
  /**
783
792
  * Reconciles a terminal job execution with runner state in one transaction: removes its pending
784
- * queue row and requests cancellation on its running lease, if either exists. The operation is
785
- * idempotent and preserves the first cancellation-request timestamp.
793
+ * queue row, requests cancellation on its running lease, and converges any linked reservation.
794
+ * The operation is idempotent and preserves the first cancellation-request timestamp.
786
795
  */
787
796
  export async function reconcileTerminalJobExecution(params: {
788
797
  jobExecutionId: string;
@@ -796,25 +805,17 @@ export async function reconcileTerminalJobExecution(params: {
796
805
  await tx
797
806
  .delete(pendingJobExecutions)
798
807
  .where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
799
- await tx
808
+ const cancelledRunningRows = await tx
800
809
  .update(runningJobExecutions)
801
810
  .set({
802
811
  cancellationRequestedAt: sql`COALESCE(${runningJobExecutions.cancellationRequestedAt}, now())`,
803
812
  })
804
- .where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId));
805
- });
806
- }
807
-
808
- export async function cancelRunnerJobs(params: {jobIds: string[]}): Promise<void> {
809
- if (params.jobIds.length === 0) return;
813
+ .where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId))
814
+ .returning({
815
+ provisionerId: runningJobExecutions.provisionerId,
816
+ providerRunnerId: runningJobExecutions.providerRunnerId,
817
+ });
810
818
 
811
- await db().transaction(async (tx) => {
812
- await tx.delete(pendingJobExecutions).where(inArray(pendingJobExecutions.jobId, params.jobIds));
813
- await tx
814
- .update(runningJobExecutions)
815
- .set({
816
- cancellationRequestedAt: sql`COALESCE(${runningJobExecutions.cancellationRequestedAt}, now())`,
817
- })
818
- .where(inArray(runningJobExecutions.jobId, params.jobIds));
819
+ await releaseReservationsForTerminalRunningRows(tx, cancelledRunningRows);
819
820
  });
820
821
  }
@@ -10,6 +10,7 @@ import {
10
10
  touchProvisionerLastSeen,
11
11
  } from '#db/provisioner-tokens.js';
12
12
  import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
13
+ import {reservations} from '#db/schema/reservations.js';
13
14
  import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
14
15
  import {runnerBootstrapTokens, runnerControlSessions} from '#db/schema/runner-control-sessions.js';
15
16
  import {providerRunners} from '#db/schema/runner-instances.js';
@@ -105,10 +106,22 @@ describe('provisioner token db', () => {
105
106
  it("cascades revocation to the provisioner's unclaimed runner credentials and sessions", async () => {
106
107
  const workspaceId = crypto.randomUUID();
107
108
  const provisioner = await provisionerTokenFactory.create({workspaceId});
109
+ const [reservation] = await db()
110
+ .insert(reservations)
111
+ .values({
112
+ workspaceId,
113
+ provisionerId: provisioner.id,
114
+ requiredLabels: ['linux'],
115
+ count: 1,
116
+ expiresAt: new Date(Date.now() + 60_000),
117
+ })
118
+ .returning();
119
+ if (!reservation) throw new Error('Expected reservation');
108
120
  const runner = await providerRunnerFactory.create({
109
121
  workspaceId,
110
122
  provisionerId: provisioner.id,
111
123
  runnerSessionId: null,
124
+ reservationId: reservation.id,
112
125
  });
113
126
  const future = new Date(Date.now() + 60_000);
114
127
  const tokenValue = (name: string) => hashOpaqueToken(`${name}-${crypto.randomUUID()}`);
@@ -192,6 +205,10 @@ describe('provisioner token db', () => {
192
205
  .select()
193
206
  .from(providerRunners)
194
207
  .where(eq(providerRunners.id, runner.id));
208
+ const [releasedReservation] = await db()
209
+ .select()
210
+ .from(reservations)
211
+ .where(eq(reservations.id, reservation.id));
195
212
  const [revokedSession] = await db()
196
213
  .select()
197
214
  .from(runnerSessions)
@@ -207,6 +224,8 @@ describe('provisioner token db', () => {
207
224
  expect(activation?.revokedAt).toBeInstanceOf(Date);
208
225
  expect(terminatedRunner).toMatchObject({state: 'terminated'});
209
226
  expect(terminatedRunner?.terminatedAt).toBeInstanceOf(Date);
227
+ expect(terminatedRunner?.reservationReleasedAt).toBeInstanceOf(Date);
228
+ expect(releasedReservation).toBeUndefined();
210
229
  expect(revokedSession?.revokedAt).toBeInstanceOf(Date);
211
230
  expect(preservedSession?.revokedAt).toBeNull();
212
231
  });
@@ -12,6 +12,7 @@ import type {
12
12
  } from '#core/entities/provisioner-token.js';
13
13
  import {ProvisionerAdminIdempotencyKeyReuseError} from '#core/errors.js';
14
14
  import {db} from './db.js';
15
+ import {releaseTerminalRunnerInstanceReservationsByIds} from './reservations.js';
15
16
  import {runnersAdminCommandResults} from './schema/admin-command-results.js';
16
17
  import {runnersOutbox} from './schema/outbox.js';
17
18
  import {provisionerTokens, toProvisionerToken} from './schema/provisioner-tokens.js';
@@ -62,7 +63,7 @@ async function cascadeProvisionerRevocation(tx: Tx, provisionerId: string) {
62
63
  ),
63
64
  ),
64
65
  );
65
- await tx
66
+ const terminatedRows = await tx
66
67
  .update(providerRunners)
67
68
  .set({state: 'terminated', terminatedAt: sql`now()`, updatedAt: sql`now()`})
68
69
  .where(
@@ -70,7 +71,17 @@ async function cascadeProvisionerRevocation(tx: Tx, provisionerId: string) {
70
71
  eq(providerRunners.provisionerId, provisionerId),
71
72
  isNull(providerRunners.runnerSessionId),
72
73
  ),
73
- );
74
+ )
75
+ .returning({id: providerRunners.id});
76
+ const runnerInstanceIds = terminatedRows.map((row) => row.id);
77
+ if (runnerInstanceIds.length > 0) {
78
+ await releaseTerminalRunnerInstanceReservationsByIds(tx, {
79
+ workspaceId: null,
80
+ provisionerId,
81
+ runnerInstanceIds,
82
+ requireUnlinkedSession: false,
83
+ });
84
+ }
74
85
  await tx
75
86
  .update(runnerSessions)
76
87
  .set({revokedAt: sql`now()`})
@@ -535,6 +535,66 @@ describe('pollDemandAndReserve', () => {
535
535
  expect(result.reservations).toEqual([]);
536
536
  });
537
537
 
538
+ it('deducts a terminal assigned runner reservation unit as pending', async () => {
539
+ const reservation = await createIntendedReservation({
540
+ workspaceId,
541
+ expiresAt: new Date(Date.now() + 60_000),
542
+ });
543
+ await createIdleRunner({
544
+ labels: ['linux'],
545
+ reservationId: reservation.id,
546
+ state: 'terminated',
547
+ });
548
+ await createPendingJobs(1, ['linux', 'gpu']);
549
+
550
+ const result = await pollDemandAndReserve({
551
+ workspaceId,
552
+ provisionerId,
553
+ maxReservations: 1,
554
+ ttlSeconds: 60,
555
+ templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
556
+ });
557
+
558
+ expect(result.reservations).toEqual([]);
559
+ });
560
+
561
+ it('counts only active assigned runners when deducting provisioner reservations', async () => {
562
+ const [reservation] = await db()
563
+ .insert(reservations)
564
+ .values({
565
+ workspaceId,
566
+ provisionerId,
567
+ requiredLabels: ['linux'],
568
+ count: 2,
569
+ expiresAt: new Date(Date.now() + 60_000),
570
+ })
571
+ .returning({id: reservations.id});
572
+ if (!reservation) throw new Error('Expected reservation');
573
+ await createIdleRunner({
574
+ labels: ['linux'],
575
+ reservationId: reservation.id,
576
+ state: 'running',
577
+ });
578
+ await createIdleRunner({
579
+ labels: ['linux'],
580
+ reservationId: reservation.id,
581
+ state: 'failed',
582
+ });
583
+ await createPendingJobs(2, ['linux', 'gpu']);
584
+
585
+ const result = await pollDemandAndReserve({
586
+ workspaceId,
587
+ provisionerId,
588
+ maxReservations: 2,
589
+ ttlSeconds: 60,
590
+ templates: [template('linux-gpu', ['linux', 'gpu'], 2)],
591
+ });
592
+
593
+ expect(result.reservations).toHaveLength(1);
594
+ expect(result.reservations[0]?.count).toBe(1);
595
+ expect(result.stats[0]).toMatchObject({queued: 2, reserved: 1});
596
+ });
597
+
538
598
  it('binds a runner whose intended reservation has passed its activation grace period', async () => {
539
599
  const intendedReservation = await createIntendedReservation({
540
600
  workspaceId,
@@ -1716,6 +1776,84 @@ describe('pollDemandAndReserve', () => {
1716
1776
  expect(rows[0]?.count).toBe(1);
1717
1777
  });
1718
1778
 
1779
+ it('releases units from multiple reservations in one transaction', async () => {
1780
+ const [first] = await db()
1781
+ .insert(reservations)
1782
+ .values({
1783
+ workspaceId,
1784
+ provisionerId,
1785
+ requiredLabels: ['linux'],
1786
+ count: 3,
1787
+ expiresAt: new Date(Date.now() + 60_000),
1788
+ })
1789
+ .returning({id: reservations.id});
1790
+ const [second] = await db()
1791
+ .insert(reservations)
1792
+ .values({
1793
+ workspaceId,
1794
+ provisionerId,
1795
+ requiredLabels: ['linux', 'gpu'],
1796
+ count: 2,
1797
+ expiresAt: new Date(Date.now() + 60_000),
1798
+ })
1799
+ .returning({id: reservations.id});
1800
+ if (!first || !second) throw new Error('Expected reservations');
1801
+
1802
+ const released = await db().transaction((tx) =>
1803
+ releaseReservationUnits(tx, {
1804
+ workspaceId,
1805
+ provisionerId,
1806
+ releases: [
1807
+ {reservationId: first.id, count: 2},
1808
+ {reservationId: second.id, count: 1},
1809
+ ],
1810
+ }),
1811
+ );
1812
+
1813
+ const rows = await reservationsForTest();
1814
+ expect(released).toBe(3);
1815
+ expect(rows).toEqual(
1816
+ expect.arrayContaining([
1817
+ expect.objectContaining({id: first.id, count: 1}),
1818
+ expect.objectContaining({id: second.id, count: 1}),
1819
+ ]),
1820
+ );
1821
+ });
1822
+
1823
+ it('does not strand units when concurrent releases drain one reservation', async () => {
1824
+ const [reservation] = await db()
1825
+ .insert(reservations)
1826
+ .values({
1827
+ workspaceId,
1828
+ provisionerId,
1829
+ requiredLabels: ['linux'],
1830
+ count: 2,
1831
+ expiresAt: new Date(Date.now() + 60_000),
1832
+ })
1833
+ .returning({id: reservations.id});
1834
+ if (!reservation) throw new Error('Expected reservation');
1835
+
1836
+ const [firstReleased, secondReleased] = await Promise.all([
1837
+ db().transaction((tx) =>
1838
+ releaseReservationUnits(tx, {
1839
+ workspaceId,
1840
+ provisionerId,
1841
+ releases: [{reservationId: reservation.id, count: 1}],
1842
+ }),
1843
+ ),
1844
+ db().transaction((tx) =>
1845
+ releaseReservationUnits(tx, {
1846
+ workspaceId,
1847
+ provisionerId,
1848
+ releases: [{reservationId: reservation.id, count: 1}],
1849
+ }),
1850
+ ),
1851
+ ]);
1852
+
1853
+ expect(firstReleased + secondReleased).toBe(2);
1854
+ expect(await reservationsForTest()).toHaveLength(0);
1855
+ });
1856
+
1719
1857
  it('deletes reservations when releasing all remaining units', async () => {
1720
1858
  await reservationFactory.create({
1721
1859
  workspaceId,