@shipfox/api-runners 12.7.0 → 13.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 (55) 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 -24
  8. package/dist/db/job-executions.d.ts.map +1 -1
  9. package/dist/db/job-executions.js +106 -83
  10. package/dist/db/job-executions.js.map +1 -1
  11. package/dist/db/reservations.d.ts.map +1 -1
  12. package/dist/db/reservations.js +6 -4
  13. package/dist/db/reservations.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +5 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/presentation/index.d.ts +2 -1
  19. package/dist/presentation/index.d.ts.map +1 -1
  20. package/dist/presentation/index.js +2 -1
  21. package/dist/presentation/index.js.map +1 -1
  22. package/dist/presentation/inter-module.d.ts +0 -1
  23. package/dist/presentation/inter-module.d.ts.map +1 -1
  24. package/dist/presentation/inter-module.js +2 -25
  25. package/dist/presentation/inter-module.js.map +1 -1
  26. package/dist/presentation/subscribers/on-workflows-job-execution-queued.d.ts +3 -0
  27. package/dist/presentation/subscribers/on-workflows-job-execution-queued.d.ts.map +1 -0
  28. package/dist/presentation/subscribers/on-workflows-job-execution-queued.js +21 -0
  29. package/dist/presentation/subscribers/on-workflows-job-execution-queued.js.map +1 -0
  30. package/dist/presentation/subscribers/on-workflows-job-execution-terminated.d.ts +3 -0
  31. package/dist/presentation/subscribers/on-workflows-job-execution-terminated.d.ts.map +1 -0
  32. package/dist/presentation/subscribers/{on-workflows-job-execution-timed-out.js → on-workflows-job-execution-terminated.js} +5 -4
  33. package/dist/presentation/subscribers/on-workflows-job-execution-terminated.js.map +1 -0
  34. package/dist/tsconfig.test.tsbuildinfo +1 -1
  35. package/package.json +3 -3
  36. package/src/db/index.ts +0 -2
  37. package/src/db/job-executions.test.ts +197 -335
  38. package/src/db/job-executions.ts +145 -100
  39. package/src/db/reservations.ts +6 -3
  40. package/src/db/runner-instances.test.ts +100 -0
  41. package/src/index.test.ts +1 -0
  42. package/src/index.ts +8 -7
  43. package/src/presentation/index.ts +2 -1
  44. package/src/presentation/inter-module.ts +2 -40
  45. package/src/presentation/subscribers/on-workflows-job-execution-queued.test.ts +57 -0
  46. package/src/presentation/subscribers/on-workflows-job-execution-queued.ts +26 -0
  47. package/src/presentation/subscribers/on-workflows-job-execution-terminated.test.ts +142 -0
  48. package/src/presentation/subscribers/{on-workflows-job-execution-timed-out.ts → on-workflows-job-execution-terminated.ts} +5 -4
  49. package/test/factories/pending-job.ts +3 -0
  50. package/tsconfig.build.tsbuildinfo +1 -1
  51. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts +0 -3
  52. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts.map +0 -1
  53. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js.map +0 -1
  54. package/src/presentation/inter-module.test.ts +0 -15
  55. 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,9 +38,14 @@ import {
39
38
  } from '#metrics/instance.js';
40
39
  import type {Tx} from './db.js';
41
40
  import {db} from './db.js';
42
- import {releaseTerminalRunnerInstanceReservationsByIds} from './reservations.js';
41
+ import {lockRunnerReservationAdvisoryKeysTx} from './reservation-locks.js';
42
+ import {
43
+ releaseReservationUnits,
44
+ releaseTerminalRunnerInstanceReservationsByIds,
45
+ } from './reservations.js';
43
46
  import {runnersOutbox} from './schema/outbox.js';
44
47
  import {pendingJobExecutions} from './schema/pending-job-executions.js';
48
+ import {reservations} from './schema/reservations.js';
45
49
  import {providerRunners} from './schema/runner-instances.js';
46
50
  import {runnerSessions} from './schema/runner-sessions.js';
47
51
  import {runningJobExecutions} from './schema/running-job-executions.js';
@@ -54,6 +58,39 @@ async function lockJobExecution(tx: Tx, jobExecutionId: string): Promise<void> {
54
58
  );
55
59
  }
56
60
 
61
+ async function releaseReservationsForTerminalRunningRows(
62
+ tx: Tx,
63
+ rows: ReadonlyArray<{
64
+ provisionerId: string | null;
65
+ providerRunnerId: string | null;
66
+ }>,
67
+ ): Promise<void> {
68
+ // Converge after the lease fact changes. The reservation helper locks each provider runner
69
+ // and releases only when the runner is terminal and no uncancelled lease remains.
70
+ const providerRunnerIdsByProvisionerId = new Map<string, Set<string>>();
71
+ for (const row of rows) {
72
+ if (row.provisionerId === null || row.providerRunnerId === null) continue;
73
+ const providerRunnerIds =
74
+ providerRunnerIdsByProvisionerId.get(row.provisionerId) ?? new Set<string>();
75
+ providerRunnerIds.add(row.providerRunnerId);
76
+ providerRunnerIdsByProvisionerId.set(row.provisionerId, providerRunnerIds);
77
+ }
78
+
79
+ for (const provisionerId of [...providerRunnerIdsByProvisionerId.keys()].sort()) {
80
+ const providerRunnerIds = providerRunnerIdsByProvisionerId.get(provisionerId);
81
+ if (!providerRunnerIds) continue;
82
+ await releaseTerminalRunnerInstanceReservationsByIds(tx, {
83
+ workspaceId: null,
84
+ provisionerId,
85
+ providerRunnerIds: [...providerRunnerIds].sort(),
86
+ requireUnlinkedSession: false,
87
+ // Lock and re-check the runner row locally so lease finalization remains retryable
88
+ // without a workflow/runner scope lock.
89
+ requireTerminalState: false,
90
+ });
91
+ }
92
+ }
93
+
57
94
  export interface EnqueueJobExecutionParams {
58
95
  workspaceId: string;
59
96
  workflowRunId: string;
@@ -62,6 +99,7 @@ export interface EnqueueJobExecutionParams {
62
99
  jobExecutionId: string;
63
100
  projectId: string;
64
101
  requiredLabels: string[];
102
+ queuedAt: Date;
65
103
  }
66
104
 
67
105
  export async function getWorkspaceJobCounts(params: {
@@ -92,11 +130,9 @@ export async function getWorkspaceJobCounts(params: {
92
130
  }));
93
131
  }
94
132
 
95
- // Idempotent while the job execution is still pending: a duplicate jobExecutionId already in
96
- // `runners_pending_jobs` is a no-op. Temporal retries the enqueue activity at-least-once, so a
97
- // unique-violation throw on a retry-after-lost-result would permanently fail a healthy execution.
98
- // The per-execution advisory lock serializes retries with lease expiry/reconciliation, and the
99
- // durable lease-expired event prevents a retry from re-queueing an execution already reaped.
133
+ // The workflows outbox delivers queue facts at least once. The per-execution advisory lock and
134
+ // pending/running checks make replay a no-op across the claim transition. A durable lease-expired
135
+ // event also prevents a delayed replay from resurrecting an execution already reaped locally.
100
136
  export async function enqueueJobExecution(params: EnqueueJobExecutionParams): Promise<void> {
101
137
  const requiredLabels = [...canonicalizeLabels(params.requiredLabels)];
102
138
  if (requiredLabels.length === 0) throw new EmptyRequiredLabelsError();
@@ -104,6 +140,13 @@ export async function enqueueJobExecution(params: EnqueueJobExecutionParams): Pr
104
140
  const enqueued = await db().transaction(async (tx) => {
105
141
  await lockJobExecution(tx, params.jobExecutionId);
106
142
 
143
+ const [running] = await tx
144
+ .select({jobExecutionId: runningJobExecutions.jobExecutionId})
145
+ .from(runningJobExecutions)
146
+ .where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId))
147
+ .limit(1);
148
+ if (running) return false;
149
+
107
150
  const [leaseExpired] = await tx
108
151
  .select({id: runnersOutbox.id})
109
152
  .from(runnersOutbox)
@@ -126,26 +169,12 @@ export async function enqueueJobExecution(params: EnqueueJobExecutionParams): Pr
126
169
  jobExecutionId: params.jobExecutionId,
127
170
  projectId: params.projectId,
128
171
  requiredLabels,
172
+ createdAt: params.queuedAt,
129
173
  })
130
174
  .onConflictDoNothing({target: pendingJobExecutions.jobExecutionId})
131
175
  .returning({createdAt: pendingJobExecutions.createdAt});
132
176
 
133
- // A retry that hits the conflict inserts nothing: the first enqueue already
134
- // emitted the queued event (durably, in the outbox), so re-emitting would
135
- // only add a redundant row the subscriber coalesces away. Skip it.
136
- if (!inserted) return false;
137
-
138
- await writeOutboxEvent<RunnersEventMap>(tx, runnersOutbox, {
139
- type: RUNNER_JOB_QUEUED,
140
- payload: {
141
- workflowRunId: params.workflowRunId,
142
- workflowRunAttemptId: params.workflowRunAttemptId,
143
- jobId: params.jobId,
144
- jobExecutionId: params.jobExecutionId,
145
- queuedAt: inserted.createdAt.toISOString(),
146
- },
147
- });
148
- return true;
177
+ return inserted !== undefined;
149
178
  });
150
179
 
151
180
  if (enqueued) jobExecutionEnqueuedCount.add(1);
@@ -238,6 +267,32 @@ export async function claimPendingJobExecution(params: {
238
267
  providerRunnerId = session.providerRunnerId;
239
268
  }
240
269
 
270
+ const runnerInstanceCondition = runnerInstanceId
271
+ ? eq(providerRunners.id, runnerInstanceId)
272
+ : provisionerId && providerRunnerId
273
+ ? and(
274
+ eq(providerRunners.provisionerId, provisionerId),
275
+ eq(providerRunners.providerRunnerId, providerRunnerId),
276
+ )
277
+ : null;
278
+ if (runnerInstanceCondition && provisionerId) {
279
+ const [runner] = await tx
280
+ .select({
281
+ reservationId: providerRunners.reservationId,
282
+ intendedReservationId: providerRunners.intendedReservationId,
283
+ })
284
+ .from(providerRunners)
285
+ .where(runnerInstanceCondition)
286
+ .limit(1);
287
+ await lockRunnerReservationAdvisoryKeysTx(tx, {
288
+ provisionerId,
289
+ reservationIds: [runner?.reservationId, runner?.intendedReservationId].filter(
290
+ (reservationId): reservationId is string =>
291
+ reservationId !== null && reservationId !== undefined,
292
+ ),
293
+ });
294
+ }
295
+
241
296
  // `id` is a uuidv7 (time-ordered), so it is a deterministic FIFO tiebreaker
242
297
  // for rows sharing a created_at within a batch. Lock only the FIFO candidate before
243
298
  // attempting its execution advisory lock; putting pg_try_advisory_xact_lock in this
@@ -304,14 +359,6 @@ export async function claimPendingJobExecution(params: {
304
359
  launchKind: params.maxClaims === null ? 'manual' : 'unknown',
305
360
  };
306
361
 
307
- const runnerInstanceCondition = runnerInstanceId
308
- ? eq(providerRunners.id, runnerInstanceId)
309
- : provisionerId && providerRunnerId
310
- ? and(
311
- eq(providerRunners.provisionerId, provisionerId),
312
- eq(providerRunners.providerRunnerId, providerRunnerId),
313
- )
314
- : null;
315
362
  if (runnerInstanceCondition) {
316
363
  const [claimedRunner] = await tx
317
364
  .update(providerRunners)
@@ -326,6 +373,8 @@ export async function claimPendingJobExecution(params: {
326
373
  provider: providerRunners.providerKind,
327
374
  launchKind: providerRunners.launchKind,
328
375
  runnerInstanceId: providerRunners.id,
376
+ reservationId: providerRunners.reservationId,
377
+ intendedReservationId: providerRunners.intendedReservationId,
329
378
  sessionCreatedAtEpochMs: sql<number | null>`(
330
379
  select extract(epoch from ${runnerSessions.createdAt})::double precision * 1000
331
380
  from ${runnerSessions}
@@ -336,6 +385,47 @@ export async function claimPendingJobExecution(params: {
336
385
  queueTimeObservation.provider = claimedRunner.provider;
337
386
  queueTimeObservation.launchKind = claimedRunner.launchKind;
338
387
  }
388
+ if (claimedRunner?.isFirstClaim) {
389
+ const [releasedRunner] = await tx
390
+ .update(providerRunners)
391
+ .set({
392
+ intendedReservationId: null,
393
+ reservationReleasedAt: sql`now()`,
394
+ updatedAt: sql`now()`,
395
+ })
396
+ .where(
397
+ and(
398
+ eq(providerRunners.id, claimedRunner.runnerInstanceId),
399
+ or(
400
+ isNotNull(providerRunners.reservationId),
401
+ isNotNull(providerRunners.intendedReservationId),
402
+ ),
403
+ isNull(providerRunners.reservationReleasedAt),
404
+ ),
405
+ )
406
+ .returning({
407
+ provisionerId: providerRunners.provisionerId,
408
+ });
409
+ const reservationId = claimedRunner.intendedReservationId ?? claimedRunner.reservationId;
410
+ if (releasedRunner?.provisionerId && reservationId) {
411
+ const [reservation] = await tx
412
+ .select({workspaceId: reservations.workspaceId})
413
+ .from(reservations)
414
+ .where(
415
+ and(
416
+ eq(reservations.id, reservationId),
417
+ eq(reservations.provisionerId, releasedRunner.provisionerId),
418
+ ),
419
+ )
420
+ .limit(1);
421
+ if (reservation)
422
+ await releaseReservationUnits(tx, {
423
+ workspaceId: reservation.workspaceId,
424
+ provisionerId: releasedRunner.provisionerId,
425
+ releases: [{reservationId, count: 1}],
426
+ });
427
+ }
428
+ }
339
429
  if (
340
430
  claimedRunner?.isFirstClaim &&
341
431
  claimedRunner.firstClaimedAt !== null &&
@@ -403,64 +493,18 @@ async function touchRunnerSessionLiveness(params: {
403
493
  );
404
494
  }
405
495
 
406
- /**
407
- * Releases a job execution's lease when the orchestration workflow finalizes it: deletes the
408
- * running-job-execution row AND any lingering pending row for the same execution, in one tx.
409
- * When the deleted lease was the last one for a terminal provider runner, it also releases the
410
- * runner's reservation in the same runner-owned transaction. Terminal reports and workflow lease
411
- * finalization remain independently retryable; they do not share a cross-module scope lock.
412
- * Idempotent (0-row no-op), no token scope (the workflow is authoritative), and
413
- * emits no event: the workflow already owns the outcome. Sweeping the pending row
414
- * too closes the at-least-once window where an enqueue retry left an orphan that a
415
- * later claim would otherwise pick up for an already-finished job execution.
416
- */
417
- export async function releaseJobExecution(params: {jobExecutionId: string}): Promise<void> {
418
- await db().transaction(async (tx) => {
419
- await lockJobExecution(tx, params.jobExecutionId);
420
-
421
- // Delete pending before running to match `claimPendingJobExecution`'s lock-acquisition
422
- // order (it locks the pending row first, then the running row). A concurrent
423
- // claim picking up an orphan pending row for this same job execution would otherwise
424
- // deadlock against the reverse order here.
425
- await tx
426
- .delete(pendingJobExecutions)
427
- .where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
428
- const deletedRunningRows = await tx
429
- .delete(runningJobExecutions)
430
- .where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId))
431
- .returning({
432
- workspaceId: runningJobExecutions.workspaceId,
433
- provisionerId: runningJobExecutions.provisionerId,
434
- providerRunnerId: runningJobExecutions.providerRunnerId,
435
- });
436
-
437
- const deletedRunningRow = deletedRunningRows[0];
438
- if (deletedRunningRow?.provisionerId && deletedRunningRow.providerRunnerId) {
439
- await releaseTerminalRunnerInstanceReservationsByIds(tx, {
440
- workspaceId: null,
441
- provisionerId: deletedRunningRow.provisionerId,
442
- providerRunnerIds: [deletedRunningRow.providerRunnerId],
443
- requireUnlinkedSession: false,
444
- // Lock and re-check the runner row locally so terminal reporting remains retryable
445
- // without a workflow/runner scope lock.
446
- requireTerminalState: false,
447
- });
448
- }
449
- });
450
- }
451
-
452
496
  /**
453
497
  * Reaps stale leases (bounded by `limit`), emitting one
454
498
  * `runners.job.lease_expired` event per reaped job execution.
455
499
  *
456
500
  * The cutoff is re-checked in the DELETE, not just the locking subquery, so a
457
501
  * heartbeat landing mid-call spares the live row. Each reaped execution also sweeps its
458
- * pending row: a failed best-effort `releaseJobExecution` would otherwise leave an orphan
459
- * that a later claim re-runs as an already-finished job execution.
502
+ * pending row so an at-least-once queue replay cannot leave an orphan.
460
503
  *
461
- * Locks pending-then-running to match `claimPendingJobExecution`, `releaseJobExecution`, and
462
- * `reconcileTerminalJobExecution`. The stale candidate scan intentionally does not lock running
463
- * rows first; the running-row DELETE re-checks the stale predicate after the pending-row sweep.
504
+ * Locks pending-then-running to match `claimPendingJobExecution` and
505
+ * `reconcileTerminalJobExecution`. The stale candidate scan intentionally does not lock
506
+ * running rows first; the running-row DELETE re-checks the stale predicate after the
507
+ * pending-row sweep.
464
508
  */
465
509
  export async function expireStuckJobExecutions(params: {
466
510
  thresholdSeconds: number;
@@ -537,10 +581,14 @@ export async function expireStuckJobExecutions(params: {
537
581
  workflowRunAttemptId: runningJobExecutions.workflowRunAttemptId,
538
582
  jobId: runningJobExecutions.jobId,
539
583
  jobExecutionId: runningJobExecutions.jobExecutionId,
584
+ provisionerId: runningJobExecutions.provisionerId,
585
+ providerRunnerId: runningJobExecutions.providerRunnerId,
540
586
  });
541
587
 
542
588
  if (deleted.length === 0) return [];
543
589
 
590
+ await releaseReservationsForTerminalRunningRows(tx, deleted);
591
+
544
592
  await writeOutboxEvents<RunnersEventMap>(
545
593
  tx,
546
594
  runnersOutbox,
@@ -555,7 +603,12 @@ export async function expireStuckJobExecutions(params: {
555
603
  })),
556
604
  );
557
605
 
558
- return deleted;
606
+ return deleted.map(({workflowRunId, workflowRunAttemptId, jobId, jobExecutionId}) => ({
607
+ workflowRunId,
608
+ workflowRunAttemptId,
609
+ jobId,
610
+ jobExecutionId,
611
+ }));
559
612
  });
560
613
 
561
614
  if (reaped.length > 0) jobExecutionLeaseExpiredCount.add(reaped.length);
@@ -803,8 +856,8 @@ export async function recordHeartbeat(params: {
803
856
 
804
857
  /**
805
858
  * Reconciles a terminal job execution with runner state in one transaction: removes its pending
806
- * queue row and requests cancellation on its running lease, if either exists. The operation is
807
- * idempotent and preserves the first cancellation-request timestamp.
859
+ * queue row, requests cancellation on its running lease, and converges any linked reservation.
860
+ * The operation is idempotent and preserves the first cancellation-request timestamp.
808
861
  */
809
862
  export async function reconcileTerminalJobExecution(params: {
810
863
  jobExecutionId: string;
@@ -818,25 +871,17 @@ export async function reconcileTerminalJobExecution(params: {
818
871
  await tx
819
872
  .delete(pendingJobExecutions)
820
873
  .where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
821
- await tx
874
+ const cancelledRunningRows = await tx
822
875
  .update(runningJobExecutions)
823
876
  .set({
824
877
  cancellationRequestedAt: sql`COALESCE(${runningJobExecutions.cancellationRequestedAt}, now())`,
825
878
  })
826
- .where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId));
827
- });
828
- }
829
-
830
- export async function cancelRunnerJobs(params: {jobIds: string[]}): Promise<void> {
831
- if (params.jobIds.length === 0) return;
879
+ .where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId))
880
+ .returning({
881
+ provisionerId: runningJobExecutions.provisionerId,
882
+ providerRunnerId: runningJobExecutions.providerRunnerId,
883
+ });
832
884
 
833
- await db().transaction(async (tx) => {
834
- await tx.delete(pendingJobExecutions).where(inArray(pendingJobExecutions.jobId, params.jobIds));
835
- await tx
836
- .update(runningJobExecutions)
837
- .set({
838
- cancellationRequestedAt: sql`COALESCE(${runningJobExecutions.cancellationRequestedAt}, now())`,
839
- })
840
- .where(inArray(runningJobExecutions.jobId, params.jobIds));
885
+ await releaseReservationsForTerminalRunningRows(tx, cancelledRunningRows);
841
886
  });
842
887
  }
@@ -939,7 +939,9 @@ export async function releaseTerminalRunnerInstanceReservationsByIds(
939
939
  params.workspaceId === null
940
940
  ? sql``
941
941
  : sql`and ${eq(reservations.workspaceId, params.workspaceId)}`;
942
- const noRunningJobPredicate = notExists(
942
+ // Cancellation keeps the lease row so the provisioner can observe its terminate intent.
943
+ // Once the runner is terminal, only an uncancelled lease should block reservation release.
944
+ const noUncancelledRunningJobPredicate = notExists(
943
945
  tx
944
946
  .select({id: runningJobExecutions.id})
945
947
  .from(runningJobExecutions)
@@ -947,6 +949,7 @@ export async function releaseTerminalRunnerInstanceReservationsByIds(
947
949
  and(
948
950
  eq(runningJobExecutions.provisionerId, params.provisionerId),
949
951
  eq(runningJobExecutions.providerRunnerId, providerRunners.providerRunnerId),
952
+ isNull(runningJobExecutions.cancellationRequestedAt),
950
953
  params.workspaceId === null
951
954
  ? undefined
952
955
  : eq(runningJobExecutions.workspaceId, params.workspaceId),
@@ -1103,7 +1106,7 @@ export async function releaseTerminalRunnerInstanceReservationsByIds(
1103
1106
  params.requireUnlinkedSession === false
1104
1107
  ? undefined
1105
1108
  : isNull(providerRunners.runnerSessionId),
1106
- noRunningJobPredicate,
1109
+ noUncancelledRunningJobPredicate,
1107
1110
  reportFreshnessPredicate,
1108
1111
  isNull(providerRunners.reservationReleasedAt),
1109
1112
  ),
@@ -1137,7 +1140,7 @@ export async function releaseTerminalRunnerInstanceReservationsByIds(
1137
1140
  params.requireUnlinkedSession === false
1138
1141
  ? undefined
1139
1142
  : isNull(providerRunners.runnerSessionId),
1140
- noRunningJobPredicate,
1143
+ noUncancelledRunningJobPredicate,
1141
1144
  reportFreshnessPredicate,
1142
1145
  isNull(providerRunners.reservationReleasedAt),
1143
1146
  ),
@@ -1145,6 +1145,106 @@ describe('reportRunnerInstances', () => {
1145
1145
  expect(reservation?.count).toBe(1);
1146
1146
  });
1147
1147
 
1148
+ it('keeps a reservation when a terminal runner has cancelled and uncancelled jobs', async () => {
1149
+ const reservationId = await createReservation(1);
1150
+ const runnerSession = await runnerSessionFactory.create({workspaceId});
1151
+ await providerRunnerFactory.create({
1152
+ workspaceId,
1153
+ provisionerId,
1154
+ providerRunnerId: 'mixed-terminal-runner',
1155
+ reservationId,
1156
+ runnerSessionId: runnerSession.id,
1157
+ state: 'running',
1158
+ });
1159
+ await insertRunningJobRow({
1160
+ workspaceId,
1161
+ provisionerId,
1162
+ providerRunnerId: 'mixed-terminal-runner',
1163
+ startedAt: new Date('2025-01-01T00:00:00.000Z'),
1164
+ cancellationRequestedAt: new Date('2025-01-01T00:01:00.000Z'),
1165
+ });
1166
+ await insertRunningJobRow({
1167
+ workspaceId,
1168
+ provisionerId,
1169
+ providerRunnerId: 'mixed-terminal-runner',
1170
+ startedAt: new Date('2025-01-01T00:02:00.000Z'),
1171
+ });
1172
+
1173
+ const result = await reportRunnerInstances({
1174
+ scope: 'workspace',
1175
+ workspaceId,
1176
+ provisionerId,
1177
+ events: [
1178
+ event({
1179
+ providerRunnerId: 'mixed-terminal-runner',
1180
+ reservationId,
1181
+ state: 'terminated',
1182
+ runnerSessionId: runnerSession.id,
1183
+ }),
1184
+ ],
1185
+ });
1186
+
1187
+ const [providerRunner] = await providerRunnerRowsFor({workspaceId, provisionerId});
1188
+ const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
1189
+ expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
1190
+ expect(providerRunner).toMatchObject({state: 'terminated', reservationReleasedAt: null});
1191
+ expect(reservation?.count).toBe(1);
1192
+ });
1193
+
1194
+ it('releases a reservation when a terminal runner only has a cancelled job', async () => {
1195
+ const reservationId = await createReservation(1);
1196
+ const runnerSession = await runnerSessionFactory.create({workspaceId});
1197
+ await providerRunnerFactory.create({
1198
+ workspaceId,
1199
+ provisionerId,
1200
+ providerRunnerId: 'cancelled-terminal-runner',
1201
+ reservationId,
1202
+ runnerSessionId: runnerSession.id,
1203
+ state: 'running',
1204
+ });
1205
+ await insertRunningJobRow({
1206
+ workspaceId,
1207
+ provisionerId,
1208
+ providerRunnerId: 'cancelled-terminal-runner',
1209
+ cancellationRequestedAt: new Date('2025-01-01T00:01:00.000Z'),
1210
+ });
1211
+
1212
+ const result = await reportRunnerInstances({
1213
+ scope: 'workspace',
1214
+ workspaceId,
1215
+ provisionerId,
1216
+ events: [
1217
+ event({
1218
+ providerRunnerId: 'cancelled-terminal-runner',
1219
+ reservationId,
1220
+ state: 'terminated',
1221
+ runnerSessionId: runnerSession.id,
1222
+ }),
1223
+ ],
1224
+ });
1225
+
1226
+ const [providerRunner] = await providerRunnerRowsFor({workspaceId, provisionerId});
1227
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
1228
+ const runningJobRows = await db()
1229
+ .select()
1230
+ .from(runningJobExecutions)
1231
+ .where(eq(runningJobExecutions.providerRunnerId, 'cancelled-terminal-runner'));
1232
+ expect(result).toEqual({
1233
+ accepted: 1,
1234
+ reservationsReleased: 1,
1235
+ terminateIntentsHonored: [
1236
+ {providerRunnerId: 'cancelled-terminal-runner', reason: 'job-cancelled'},
1237
+ ],
1238
+ });
1239
+ expect(providerRunner).toMatchObject({
1240
+ state: 'terminated',
1241
+ reservationReleasedAt: expect.any(Date),
1242
+ });
1243
+ expect(reservationRows).toHaveLength(0);
1244
+ expect(runningJobRows).toHaveLength(1);
1245
+ expect(runningJobRows[0]?.cancellationRequestedAt).toBeInstanceOf(Date);
1246
+ });
1247
+
1148
1248
  it('uses the consumed ephemeral token session before releasing a terminal report', async () => {
1149
1249
  const reservationId = await createReservation(1);
1150
1250
  const token = await ephemeralRegistrationTokenFactory.create({
package/src/index.test.ts CHANGED
@@ -10,6 +10,7 @@ describe('createRunnersModule', () => {
10
10
  expect(module.name).toBe('runners');
11
11
  expect(module.auth).toHaveLength(3);
12
12
  expect(module.routes).toHaveLength(13);
13
+ expect(module.subscribers).toHaveLength(2);
13
14
  expect(module.workers).toHaveLength(1);
14
15
  });
15
16
 
package/src/index.ts CHANGED
@@ -4,7 +4,8 @@ import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
4
4
  import {administrationActionEventSchemas} from '@shipfox/api-common-dto';
5
5
  import {runnersEventSchemas} from '@shipfox/api-runners-dto';
6
6
  import {
7
- WORKFLOWS_JOB_EXECUTION_TIMED_OUT,
7
+ WORKFLOWS_JOB_EXECUTION_QUEUED,
8
+ WORKFLOWS_JOB_EXECUTION_TERMINATED,
8
9
  type WorkflowsEventMapDto,
9
10
  } from '@shipfox/api-workflows-dto';
10
11
  import {type ShipfoxModule, subscriberFactory} from '@shipfox/node-module';
@@ -16,7 +17,8 @@ import {
16
17
  createRunnerControlSessionAuthMethod,
17
18
  createRunnerRegistrationTokenAuthMethod,
18
19
  createRunnerRoutes,
19
- onWorkflowsJobExecutionTimedOut,
20
+ onWorkflowsJobExecutionQueued,
21
+ onWorkflowsJobExecutionTerminated,
20
22
  } from '#presentation/index.js';
21
23
  import {createRunnersInterModulePresentation} from '#presentation/inter-module.js';
22
24
  import {createRunnersMaintenanceActivities} from '#temporal/activities/index.js';
@@ -30,12 +32,8 @@ export {
30
32
  unadvertisedRunnerTools,
31
33
  } from '#core/runner-tool-capabilities.js';
32
34
  export {
33
- cancelRunnerJobs,
34
- type EnqueueJobExecutionParams,
35
- enqueueJobExecution,
36
35
  getWorkspaceJobCounts,
37
36
  isJobLeaseActive,
38
- releaseJobExecution,
39
37
  } from '#db/index.js';
40
38
  export type {
41
39
  CreateRunnersModuleOptions,
@@ -64,7 +62,10 @@ export function createRunnersModule({
64
62
  publishers: [
65
63
  {name: 'runners', table: runnersOutbox, db, eventSchemas: runnersPublisherEventSchemas},
66
64
  ],
67
- subscribers: [subscriber(WORKFLOWS_JOB_EXECUTION_TIMED_OUT, onWorkflowsJobExecutionTimedOut)],
65
+ subscribers: [
66
+ subscriber(WORKFLOWS_JOB_EXECUTION_QUEUED, onWorkflowsJobExecutionQueued),
67
+ subscriber(WORKFLOWS_JOB_EXECUTION_TERMINATED, onWorkflowsJobExecutionTerminated),
68
+ ],
68
69
  workers: [
69
70
  {
70
71
  taskQueue: RUNNERS_MAINTENANCE_TASK_QUEUE,
@@ -4,4 +4,5 @@ export {
4
4
  createRunnerRegistrationTokenAuthMethod,
5
5
  } from './auth/index.js';
6
6
  export {createRunnerRoutes, createRunnerRoutes as routes} from './routes/index.js';
7
- export {onWorkflowsJobExecutionTimedOut} from './subscribers/on-workflows-job-execution-timed-out.js';
7
+ export {onWorkflowsJobExecutionQueued} from './subscribers/on-workflows-job-execution-queued.js';
8
+ export {onWorkflowsJobExecutionTerminated} from './subscribers/on-workflows-job-execution-terminated.js';
@@ -1,39 +1,12 @@
1
1
  import {runnersInterModuleContract} from '@shipfox/api-runners-dto/inter-module';
2
- import {
3
- createInterModuleKnownError,
4
- defineInterModulePresentation,
5
- type InterModulePresentation,
6
- } from '@shipfox/inter-module';
7
- import {EmptyRequiredLabelsError} from '#core/errors.js';
2
+ import {defineInterModulePresentation, type InterModulePresentation} from '@shipfox/inter-module';
8
3
  import {getEffectiveRunnerToolCapabilities} from '#core/runner-tool-capabilities.js';
9
- import {
10
- cancelRunnerJobs,
11
- enqueueJobExecution,
12
- getWorkspaceJobCounts,
13
- isJobLeaseActive,
14
- releaseJobExecution,
15
- } from '#db/job-executions.js';
4
+ import {getWorkspaceJobCounts, isJobLeaseActive} from '#db/job-executions.js';
16
5
 
17
6
  export function createRunnersInterModulePresentation(): InterModulePresentation<
18
7
  typeof runnersInterModuleContract
19
8
  > {
20
9
  return defineInterModulePresentation(runnersInterModuleContract, {
21
- enqueueJobExecution: async (input) => {
22
- try {
23
- await enqueueJobExecution(input);
24
- return {};
25
- } catch (error) {
26
- throw toEnqueueJobExecutionKnownError(error);
27
- }
28
- },
29
- releaseJobExecution: async (input) => {
30
- await releaseJobExecution(input);
31
- return {};
32
- },
33
- cancelJobs: async (input) => {
34
- await cancelRunnerJobs(input);
35
- return {};
36
- },
37
10
  getLeaseState: async (input) => ({active: await isJobLeaseActive(input)}),
38
11
  getEffectiveRunnerToolCapabilities: async (input) => {
39
12
  const result = await getEffectiveRunnerToolCapabilities(input);
@@ -44,14 +17,3 @@ export function createRunnersInterModulePresentation(): InterModulePresentation<
44
17
  }),
45
18
  });
46
19
  }
47
-
48
- export function toEnqueueJobExecutionKnownError(error: unknown): unknown {
49
- if (error instanceof EmptyRequiredLabelsError) {
50
- return createInterModuleKnownError(
51
- runnersInterModuleContract.methods.enqueueJobExecution,
52
- 'empty-required-labels',
53
- {},
54
- );
55
- }
56
- return error;
57
- }