@shipfox/api-runners 12.5.0 → 12.7.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 (39) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +15 -0
  3. package/dist/db/job-executions.d.ts +3 -0
  4. package/dist/db/job-executions.d.ts.map +1 -1
  5. package/dist/db/job-executions.js +23 -1
  6. package/dist/db/job-executions.js.map +1 -1
  7. package/dist/db/provisioner-tokens.d.ts.map +1 -1
  8. package/dist/db/provisioner-tokens.js +14 -2
  9. package/dist/db/provisioner-tokens.js.map +1 -1
  10. package/dist/db/reservations.d.ts +10 -0
  11. package/dist/db/reservations.d.ts.map +1 -1
  12. package/dist/db/reservations.js +137 -17
  13. package/dist/db/reservations.js.map +1 -1
  14. package/dist/db/runner-assignments.d.ts.map +1 -1
  15. package/dist/db/runner-assignments.js +8 -5
  16. package/dist/db/runner-assignments.js.map +1 -1
  17. package/dist/db/runner-instances.d.ts +0 -1
  18. package/dist/db/runner-instances.d.ts.map +1 -1
  19. package/dist/db/runner-instances.js +85 -100
  20. package/dist/db/runner-instances.js.map +1 -1
  21. package/dist/db/runner-states.d.ts +3 -1
  22. package/dist/db/runner-states.d.ts.map +1 -1
  23. package/dist/db/runner-states.js +6 -4
  24. package/dist/db/runner-states.js.map +1 -1
  25. package/dist/tsconfig.test.tsbuildinfo +1 -1
  26. package/package.json +2 -2
  27. package/src/db/job-executions.test.ts +119 -0
  28. package/src/db/job-executions.ts +24 -2
  29. package/src/db/provisioner-tokens.test.ts +19 -0
  30. package/src/db/provisioner-tokens.ts +13 -2
  31. package/src/db/reservations.test.ts +138 -0
  32. package/src/db/reservations.ts +311 -35
  33. package/src/db/runner-assignments.test.ts +20 -0
  34. package/src/db/runner-assignments.ts +6 -7
  35. package/src/db/runner-instances.test.ts +250 -15
  36. package/src/db/runner-instances.ts +136 -198
  37. package/src/db/runner-states.test.ts +16 -0
  38. package/src/db/runner-states.ts +9 -4
  39. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-runners",
3
3
  "license": "MIT",
4
- "version": "12.5.0",
4
+ "version": "12.7.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -25,7 +25,7 @@
25
25
  "@shipfox/api-auth-context": "12.2.0",
26
26
  "@shipfox/api-auth-dto": "12.0.0",
27
27
  "@shipfox/api-runners-dto": "12.4.0",
28
- "@shipfox/api-workflows-dto": "12.5.0",
28
+ "@shipfox/api-workflows-dto": "12.7.0",
29
29
  "@shipfox/config": "1.2.4",
30
30
  "@shipfox/inter-module": "0.2.3",
31
31
  "@shipfox/node-drizzle": "0.3.5",
@@ -29,6 +29,7 @@ import {
29
29
  } from './job-executions.js';
30
30
  import {runnersOutbox} from './schema/outbox.js';
31
31
  import {pendingJobExecutions} from './schema/pending-job-executions.js';
32
+ import {reservations} from './schema/reservations.js';
32
33
  import {providerRunners} from './schema/runner-instances.js';
33
34
  import {runnerSessions} from './schema/runner-sessions.js';
34
35
  import {runningJobExecutions} from './schema/running-job-executions.js';
@@ -969,6 +970,124 @@ describe('releaseJobExecution', () => {
969
970
  ).resolves.toBeUndefined();
970
971
  });
971
972
 
973
+ it('releases a terminal runner reservation after deleting its final lease', async () => {
974
+ const provisionerId = crypto.randomUUID();
975
+ const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
976
+ const [reservation] = await db()
977
+ .insert(reservations)
978
+ .values({
979
+ workspaceId,
980
+ provisionerId,
981
+ requiredLabels: sessionLabels,
982
+ count: 1,
983
+ expiresAt: new Date(Date.now() + 60_000),
984
+ })
985
+ .returning({id: reservations.id});
986
+ if (!reservation) throw new Error('Expected reservation');
987
+
988
+ await db()
989
+ .update(runnerSessions)
990
+ .set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
991
+ .where(eq(runnerSessions.id, runnerSessionId));
992
+ await db().insert(providerRunners).values({
993
+ workspaceId,
994
+ provisionerId,
995
+ providerRunnerId,
996
+ reservationId: reservation.id,
997
+ runnerSessionId,
998
+ state: 'terminated',
999
+ reportedAt: new Date(),
1000
+ terminatedAt: new Date(),
1001
+ labels: sessionLabels,
1002
+ });
1003
+
1004
+ const created = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
1005
+ const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
1006
+ expect(claimed?.jobExecutionId).toBe(created.jobExecutionId);
1007
+
1008
+ await releaseJobExecution({jobExecutionId: created.jobExecutionId});
1009
+
1010
+ expect(
1011
+ await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
1012
+ ).toHaveLength(0);
1013
+ const [runner] = await db()
1014
+ .select({reservationReleasedAt: providerRunners.reservationReleasedAt})
1015
+ .from(providerRunners)
1016
+ .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1017
+ expect(runner?.reservationReleasedAt).toEqual(expect.any(Date));
1018
+ });
1019
+
1020
+ it('rechecks terminal state after a concurrent terminal projection commits', async () => {
1021
+ const provisionerId = crypto.randomUUID();
1022
+ const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
1023
+ const [reservation] = await db()
1024
+ .insert(reservations)
1025
+ .values({
1026
+ workspaceId,
1027
+ provisionerId,
1028
+ requiredLabels: sessionLabels,
1029
+ count: 1,
1030
+ expiresAt: new Date(Date.now() + 60_000),
1031
+ })
1032
+ .returning({id: reservations.id});
1033
+ if (!reservation) throw new Error('Expected reservation');
1034
+
1035
+ await db()
1036
+ .update(runnerSessions)
1037
+ .set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
1038
+ .where(eq(runnerSessions.id, runnerSessionId));
1039
+ await db().insert(providerRunners).values({
1040
+ workspaceId,
1041
+ provisionerId,
1042
+ providerRunnerId,
1043
+ reservationId: reservation.id,
1044
+ runnerSessionId,
1045
+ state: 'running',
1046
+ reportedAt: new Date(),
1047
+ labels: sessionLabels,
1048
+ });
1049
+
1050
+ const created = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
1051
+ const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
1052
+ expect(claimed?.jobExecutionId).toBe(created.jobExecutionId);
1053
+
1054
+ const terminalProjectionReady = deferred<void>();
1055
+ const releaseTerminalProjection = deferred<void>();
1056
+ const terminalProjection = db().transaction(async (tx) => {
1057
+ await tx
1058
+ .update(providerRunners)
1059
+ .set({state: 'failed', failedAt: new Date(), updatedAt: new Date()})
1060
+ .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1061
+ terminalProjectionReady.resolve();
1062
+ await releaseTerminalProjection.promise;
1063
+ });
1064
+
1065
+ await terminalProjectionReady.promise;
1066
+ const cleanup = releaseJobExecution({jobExecutionId: created.jobExecutionId});
1067
+ try {
1068
+ await waitForLockWait({queryLike: '%runner_instances%'});
1069
+
1070
+ expect(
1071
+ await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
1072
+ ).toHaveLength(1);
1073
+ } finally {
1074
+ releaseTerminalProjection.resolve();
1075
+ await Promise.all([terminalProjection, cleanup]);
1076
+ }
1077
+
1078
+ expect(
1079
+ await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
1080
+ ).toHaveLength(0);
1081
+ const [runner] = await db()
1082
+ .select({
1083
+ reservationReleasedAt: providerRunners.reservationReleasedAt,
1084
+ state: providerRunners.state,
1085
+ })
1086
+ .from(providerRunners)
1087
+ .where(eq(providerRunners.providerRunnerId, providerRunnerId));
1088
+ expect(runner).toMatchObject({state: 'failed', reservationReleasedAt: expect.any(Date)});
1089
+ });
1090
+
972
1091
  it('releases regardless of which session holds the lease', async () => {
973
1092
  await pendingJobFactory.create({workspaceId});
974
1093
  const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
@@ -39,6 +39,7 @@ import {
39
39
  } from '#metrics/instance.js';
40
40
  import type {Tx} from './db.js';
41
41
  import {db} from './db.js';
42
+ import {releaseTerminalRunnerInstanceReservationsByIds} from './reservations.js';
42
43
  import {runnersOutbox} from './schema/outbox.js';
43
44
  import {pendingJobExecutions} from './schema/pending-job-executions.js';
44
45
  import {providerRunners} from './schema/runner-instances.js';
@@ -405,6 +406,9 @@ async function touchRunnerSessionLiveness(params: {
405
406
  /**
406
407
  * Releases a job execution's lease when the orchestration workflow finalizes it: deletes the
407
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.
408
412
  * Idempotent (0-row no-op), no token scope (the workflow is authoritative), and
409
413
  * emits no event: the workflow already owns the outcome. Sweeping the pending row
410
414
  * too closes the at-least-once window where an enqueue retry left an orphan that a
@@ -421,9 +425,27 @@ export async function releaseJobExecution(params: {jobExecutionId: string}): Pro
421
425
  await tx
422
426
  .delete(pendingJobExecutions)
423
427
  .where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
424
- await tx
428
+ const deletedRunningRows = await tx
425
429
  .delete(runningJobExecutions)
426
- .where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId));
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
+ }
427
449
  });
428
450
  }
429
451
 
@@ -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,