@shipfox/api-runners 12.6.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 +8 -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
@@ -2,6 +2,10 @@ import {pgClient} from '@shipfox/node-postgres';
2
2
  import {and, desc, eq, inArray, or, sql} from 'drizzle-orm';
3
3
  import {db} from '#db/db.js';
4
4
  import {createRunnerSessionConsumingEphemeralToken} from '#db/ephemeral-registration-tokens.js';
5
+ import {
6
+ pollDemandAndReserve,
7
+ releaseTerminalRunnerInstanceReservationsByIds,
8
+ } from '#db/reservations.js';
5
9
  import {
6
10
  attachRunnerInstanceProviderId,
7
11
  countStaleEnrolledRunnerInstances,
@@ -22,6 +26,7 @@ import {runnerSessions} from '#db/schema/runner-sessions.js';
22
26
  import {runningJobExecutions} from '#db/schema/running-job-executions.js';
23
27
  import {
24
28
  ephemeralRegistrationTokenFactory,
29
+ pendingJobFactory,
25
30
  providerRunnerFactory,
26
31
  provisionerTokenFactory,
27
32
  reservationFactory,
@@ -221,6 +226,39 @@ describe('reportRunnerInstances', () => {
221
226
  expect(rows[0]?.reason).toBe('late stale failure');
222
227
  });
223
228
 
229
+ it('does not release a reservation for a terminal report older than the projection', async () => {
230
+ const reservationId = await createReservation(1);
231
+ const currentReportedAt = new Date('2025-01-01T00:01:00.000Z');
232
+ await providerRunnerFactory.create({
233
+ workspaceId,
234
+ provisionerId,
235
+ providerRunnerId: 'stale-terminal-runner',
236
+ reservationId,
237
+ state: 'running',
238
+ reportedAt: currentReportedAt,
239
+ });
240
+
241
+ const result = await reportRunnerInstances({
242
+ scope: 'workspace',
243
+ workspaceId,
244
+ provisionerId,
245
+ events: [
246
+ event({
247
+ providerRunnerId: 'stale-terminal-runner',
248
+ reservationId,
249
+ state: 'failed',
250
+ reportedAt: new Date(currentReportedAt.getTime() - 1_000),
251
+ }),
252
+ ],
253
+ });
254
+
255
+ const [providerRunner] = await providerRunnerRowsFor({workspaceId, provisionerId});
256
+ const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
257
+ expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
258
+ expect(providerRunner).toMatchObject({state: 'failed', reservationReleasedAt: null});
259
+ expect(reservation?.count).toBe(1);
260
+ });
261
+
224
262
  it('rejects older out-of-order events in the same lifecycle state', async () => {
225
263
  const newest = new Date();
226
264
  await reportRunnerInstances({
@@ -1036,8 +1074,16 @@ describe('reportRunnerInstances', () => {
1036
1074
  expect(providerRunnerRows[0]?.reservationReleasedAt).toBeInstanceOf(Date);
1037
1075
  });
1038
1076
 
1039
- it('does not release a reservation for a provisioned runner that already has a runner session', async () => {
1077
+ it('releases a reservation for a provisioned runner that already has a runner session', async () => {
1040
1078
  const reservationId = await createReservation(1);
1079
+ const runnerSession = await runnerSessionFactory.create({workspaceId});
1080
+ await providerRunnerFactory.create({
1081
+ workspaceId,
1082
+ provisionerId,
1083
+ providerRunnerId: 'provisioned-runner-1',
1084
+ reservationId,
1085
+ state: 'running',
1086
+ });
1041
1087
 
1042
1088
  const result = await reportRunnerInstances({
1043
1089
  scope: 'workspace',
@@ -1048,16 +1094,55 @@ describe('reportRunnerInstances', () => {
1048
1094
  providerRunnerId: 'provisioned-runner-1',
1049
1095
  reservationId,
1050
1096
  state: 'failed',
1051
- runnerSessionId: crypto.randomUUID(),
1097
+ runnerSessionId: runnerSession.id,
1052
1098
  }),
1053
1099
  ],
1054
1100
  });
1055
1101
 
1056
1102
  const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
1057
1103
  const providerRunnerRows = await providerRunnerRowsFor({workspaceId, provisionerId});
1104
+ expect(result).toEqual({accepted: 1, reservationsReleased: 1, terminateIntentsHonored: []});
1105
+ expect(reservationRows).toHaveLength(0);
1106
+ expect(providerRunnerRows[0]?.runnerSessionId).toBe(runnerSession.id);
1107
+ expect(providerRunnerRows[0]?.reservationReleasedAt).toBeInstanceOf(Date);
1108
+ });
1109
+
1110
+ it('keeps a reservation while a terminal runner still has a running job', async () => {
1111
+ const reservationId = await createReservation(1);
1112
+ const runnerSession = await runnerSessionFactory.create({workspaceId});
1113
+ await providerRunnerFactory.create({
1114
+ workspaceId,
1115
+ provisionerId,
1116
+ providerRunnerId: 'busy-terminal-runner',
1117
+ reservationId,
1118
+ runnerSessionId: runnerSession.id,
1119
+ state: 'running',
1120
+ });
1121
+ await insertRunningJobRow({
1122
+ workspaceId,
1123
+ provisionerId,
1124
+ providerRunnerId: 'busy-terminal-runner',
1125
+ });
1126
+
1127
+ const result = await reportRunnerInstances({
1128
+ scope: 'workspace',
1129
+ workspaceId,
1130
+ provisionerId,
1131
+ events: [
1132
+ event({
1133
+ providerRunnerId: 'busy-terminal-runner',
1134
+ reservationId,
1135
+ state: 'failed',
1136
+ runnerSessionId: runnerSession.id,
1137
+ }),
1138
+ ],
1139
+ });
1140
+
1141
+ const [providerRunner] = await providerRunnerRowsFor({workspaceId, provisionerId});
1142
+ const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
1058
1143
  expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
1059
- expect(reservationRows[0]?.count).toBe(1);
1060
- expect(providerRunnerRows[0]?.reservationReleasedAt).toBeNull();
1144
+ expect(providerRunner).toMatchObject({state: 'failed', reservationReleasedAt: null});
1145
+ expect(reservation?.count).toBe(1);
1061
1146
  });
1062
1147
 
1063
1148
  it('uses the consumed ephemeral token session before releasing a terminal report', async () => {
@@ -1068,6 +1153,13 @@ describe('reportRunnerInstances', () => {
1068
1153
  reservationId,
1069
1154
  providerRunnerId: 'provisioned-runner-1',
1070
1155
  });
1156
+ await providerRunnerFactory.create({
1157
+ workspaceId,
1158
+ provisionerId,
1159
+ providerRunnerId: 'provisioned-runner-1',
1160
+ reservationId,
1161
+ state: 'running',
1162
+ });
1071
1163
  const session = await createRunnerSessionConsumingEphemeralToken({
1072
1164
  ephemeralTokenId: token.id,
1073
1165
  workspaceId,
@@ -1091,16 +1183,24 @@ describe('reportRunnerInstances', () => {
1091
1183
 
1092
1184
  const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
1093
1185
  const providerRunnerRows = await providerRunnerRowsFor({workspaceId, provisionerId});
1094
- expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
1095
- expect(reservationRows[0]?.count).toBe(1);
1186
+ expect(result).toEqual({accepted: 1, reservationsReleased: 1, terminateIntentsHonored: []});
1187
+ expect(reservationRows).toHaveLength(0);
1096
1188
  expect(providerRunnerRows[0]?.runnerSessionId).toBe(session.id);
1097
- expect(providerRunnerRows[0]?.reservationReleasedAt).toBeNull();
1189
+ expect(providerRunnerRows[0]?.reservationReleasedAt).toBeInstanceOf(Date);
1098
1190
  });
1099
1191
 
1100
1192
  it('preserves claimed runner session metadata when a terminal state wins the batch', async () => {
1101
1193
  const reservationId = await createReservation(1);
1102
1194
  const runnerSessionId = crypto.randomUUID();
1103
1195
  const reportedAt = new Date('2025-01-01T00:00:00.000Z');
1196
+ await providerRunnerFactory.create({
1197
+ workspaceId,
1198
+ provisionerId,
1199
+ providerRunnerId: 'provisioned-runner-1',
1200
+ reservationId,
1201
+ state: 'running',
1202
+ reportedAt: new Date(reportedAt.getTime() - 1_000),
1203
+ });
1104
1204
 
1105
1205
  const result = await reportRunnerInstances({
1106
1206
  scope: 'workspace',
@@ -1125,11 +1225,11 @@ describe('reportRunnerInstances', () => {
1125
1225
 
1126
1226
  const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
1127
1227
  const providerRunnerRows = await providerRunnerRowsFor({workspaceId, provisionerId});
1128
- expect(result).toEqual({accepted: 1, reservationsReleased: 0, terminateIntentsHonored: []});
1129
- expect(reservationRows[0]?.count).toBe(1);
1228
+ expect(result).toEqual({accepted: 1, reservationsReleased: 1, terminateIntentsHonored: []});
1229
+ expect(reservationRows).toHaveLength(0);
1130
1230
  expect(providerRunnerRows[0]?.state).toBe('failed');
1131
1231
  expect(providerRunnerRows[0]?.runnerSessionId).toBe(runnerSessionId);
1132
- expect(providerRunnerRows[0]?.reservationReleasedAt).toBeNull();
1232
+ expect(providerRunnerRows[0]?.reservationReleasedAt).toBeInstanceOf(Date);
1133
1233
  });
1134
1234
 
1135
1235
  it('returns honored terminate intents only for the first active-to-terminated transition', async () => {
@@ -1642,7 +1742,6 @@ describe('reapStaleRunnerInstances', () => {
1642
1742
  .values({
1643
1743
  provisionerId: installationProvisioner.id,
1644
1744
  intendedReservationId: reservation.id,
1645
- providerRunnerId: 'stale-installation-runner-with-reservation',
1646
1745
  state: 'starting',
1647
1746
  reportedAt: staleAt(),
1648
1747
  updatedAt: staleAt(),
@@ -1746,6 +1845,30 @@ describe('reapStaleRunnerInstances', () => {
1746
1845
  expect(reservationRows.find((row) => row.id === reservationId)).toBeUndefined();
1747
1846
  });
1748
1847
 
1848
+ it('drains stale terminal rows with unreleased session-linked reservations', async () => {
1849
+ const reservationId = await createReservation(1);
1850
+ const session = await createLinkedSession({
1851
+ providerRunnerId: 'legacy-terminal-runner',
1852
+ updatedAt: staleAt(),
1853
+ });
1854
+ await createRunnerInstance({
1855
+ providerRunnerId: 'legacy-terminal-runner',
1856
+ reservationId,
1857
+ runnerSessionId: session.id,
1858
+ state: 'failed',
1859
+ reportedAt: staleAt(),
1860
+ updatedAt: staleAt(),
1861
+ });
1862
+
1863
+ const result = await reapStaleRunnerInstances({thresholdSeconds: 60, limit: 100});
1864
+
1865
+ const [providerRunner] = await providerRunnerRowsFor({workspaceId, provisionerId});
1866
+ const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
1867
+ expect(result).toEqual({reaped: 0, reservationsReleased: 1});
1868
+ expect(providerRunner?.reservationReleasedAt).toBeInstanceOf(Date);
1869
+ expect(reservationRows).toHaveLength(0);
1870
+ });
1871
+
1749
1872
  it('flags expired releases and drains only the configured batch size', async () => {
1750
1873
  const activeReservationId = await createReservation(2);
1751
1874
  const expiredReservationId = await createReservation(1, {
@@ -1786,6 +1909,52 @@ describe('reapStaleRunnerInstances', () => {
1786
1909
  ).toBe(true);
1787
1910
  });
1788
1911
 
1912
+ it('waits for reservation assignment locks before releasing terminal runners', async () => {
1913
+ const reservationId = await createReservation(1);
1914
+ const runner = await createRunnerInstance({
1915
+ providerRunnerId: 'terminal-assignment-race',
1916
+ reservationId,
1917
+ state: 'failed',
1918
+ reportedAt: staleAt(),
1919
+ updatedAt: staleAt(),
1920
+ });
1921
+ const releaseLock = deferred<void>();
1922
+ const lockHolderReady = deferred<void>();
1923
+ const lockHolder = db().transaction(async (tx) => {
1924
+ await tx.execute(
1925
+ sql`select pg_advisory_xact_lock(hashtext(${`runners_assignment:${provisionerId}:${reservationId}`}))`,
1926
+ );
1927
+ lockHolderReady.resolve();
1928
+ await releaseLock.promise;
1929
+ });
1930
+
1931
+ const cleanup = (async () => {
1932
+ await lockHolderReady.promise;
1933
+ return await db().transaction((tx) =>
1934
+ releaseTerminalRunnerInstanceReservationsByIds(tx, {
1935
+ workspaceId,
1936
+ provisionerId,
1937
+ runnerInstanceIds: [runner.id],
1938
+ requireUnlinkedSession: false,
1939
+ }),
1940
+ );
1941
+ })();
1942
+
1943
+ try {
1944
+ await waitForLockWait({queryLike: '%pg_advisory_xact_lock%'});
1945
+ const [beforeRunner] = await providerRunnerRowsFor({workspaceId, provisionerId});
1946
+ const [beforeReservation] = await reservationRowsFor({workspaceId, provisionerId});
1947
+ expect(beforeRunner?.reservationReleasedAt).toBeNull();
1948
+ expect(beforeReservation?.count).toBe(1);
1949
+ } finally {
1950
+ releaseLock.resolve();
1951
+ await Promise.all([cleanup, lockHolder]);
1952
+ }
1953
+
1954
+ const [providerRunner] = await providerRunnerRowsFor({workspaceId, provisionerId});
1955
+ expect(providerRunner?.reservationReleasedAt).toBeInstanceOf(Date);
1956
+ });
1957
+
1789
1958
  it('does not double-release reservations when terminal report and reaper queue on the workspace lock', async () => {
1790
1959
  const reservationId = await createReservation(2);
1791
1960
  await createRunnerInstance({
@@ -1973,6 +2142,58 @@ describe('reconcileRunnerInstances', () => {
1973
2142
  expect(reservation?.count).toBe(1);
1974
2143
  });
1975
2144
 
2145
+ it('releases reservations when reconciling stale installation runners', async () => {
2146
+ const reservationWorkspaceId = crypto.randomUUID();
2147
+ const [reservation] = await db()
2148
+ .insert(reservations)
2149
+ .values({
2150
+ workspaceId: reservationWorkspaceId,
2151
+ provisionerId,
2152
+ requiredLabels: ['linux'],
2153
+ count: 1,
2154
+ expiresAt: new Date(Date.now() + 60_000),
2155
+ })
2156
+ .returning();
2157
+ if (!reservation) throw new Error('Expected reservation');
2158
+ const [runner] = await db()
2159
+ .insert(providerRunners)
2160
+ .values({
2161
+ workspaceId: null,
2162
+ provisionerId,
2163
+ providerRunnerId: 'stale-installation-runner',
2164
+ reservationId: reservation.id,
2165
+ runnerSessionId: crypto.randomUUID(),
2166
+ state: 'running',
2167
+ reportedAt: staleReportedAt(),
2168
+ updatedAt: staleReportedAt(),
2169
+ })
2170
+ .returning({id: providerRunners.id});
2171
+ if (!runner) throw new Error('Expected runner');
2172
+
2173
+ const result = await reconcileRunnerInstances({
2174
+ workspaceId: null,
2175
+ provisionerId,
2176
+ observedRunnerInstanceIds: ['observed-runner'],
2177
+ terminateGraceSeconds: 60,
2178
+ });
2179
+
2180
+ const [providerRunner] = await db()
2181
+ .select()
2182
+ .from(providerRunners)
2183
+ .where(eq(providerRunners.id, runner.id));
2184
+ const [remainingReservation] = await db()
2185
+ .select()
2186
+ .from(reservations)
2187
+ .where(eq(reservations.id, reservation.id));
2188
+ expect(result).toMatchObject({
2189
+ absentIds: ['stale-installation-runner'],
2190
+ reservationsReleased: 1,
2191
+ });
2192
+ expect(providerRunner).toMatchObject({state: 'terminated'});
2193
+ expect(providerRunner?.reservationReleasedAt).toBeInstanceOf(Date);
2194
+ expect(remainingReservation).toBeUndefined();
2195
+ });
2196
+
1976
2197
  it('treats an empty observed set as read-only', async () => {
1977
2198
  const reservationId = await createReservation(2);
1978
2199
  await createRunnerInstance({
@@ -2219,8 +2440,9 @@ describe('reconcileRunnerInstances', () => {
2219
2440
  ]);
2220
2441
  });
2221
2442
 
2222
- it('terminates session-bound absent runners without releasing their reservation', async () => {
2443
+ it('terminates session-bound absent runners and releases their reservation', async () => {
2223
2444
  const reservationId = await createReservation(1);
2445
+ await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
2224
2446
  await createRunnerInstance({
2225
2447
  providerRunnerId: 'provisioned-runner-1',
2226
2448
  reservationId,
@@ -2237,10 +2459,23 @@ describe('reconcileRunnerInstances', () => {
2237
2459
 
2238
2460
  const [providerRunner] = await providerRunnerRowsFor({workspaceId, provisionerId});
2239
2461
  const [reservation] = await reservationRowsFor({workspaceId, provisionerId});
2240
- expect(result.reservationsReleased).toBe(0);
2462
+ expect(result.reservationsReleased).toBe(1);
2241
2463
  expect(providerRunner?.state).toBe('terminated');
2242
- expect(providerRunner?.reservationReleasedAt).toBeNull();
2243
- expect(reservation?.count).toBe(1);
2464
+ expect(providerRunner?.reservationReleasedAt).toBeInstanceOf(Date);
2465
+ expect(reservation).toBeUndefined();
2466
+
2467
+ const poll = await pollDemandAndReserve({
2468
+ workspaceId,
2469
+ provisionerId,
2470
+ maxReservations: 1,
2471
+ ttlSeconds: 60,
2472
+ templates: [
2473
+ {templateKey: 'linux', labels: ['linux'], availableSlots: 1, starting: 0, running: 0},
2474
+ ],
2475
+ });
2476
+ expect(poll.reservations).toHaveLength(1);
2477
+ expect(poll.reservations[0]?.count).toBe(1);
2478
+ expect(poll.stats[0]).toMatchObject({labels: ['linux'], queued: 1, reserved: 1});
2244
2479
  });
2245
2480
 
2246
2481
  it('returns a deterministic newest running job execution bound to an observed provisioned runner', async () => {