@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
@@ -10,6 +10,7 @@ import {
10
10
  isNotNull,
11
11
  isNull,
12
12
  lt,
13
+ lte,
13
14
  notExists,
14
15
  notInArray,
15
16
  or,
@@ -27,6 +28,7 @@ import {reservations} from './schema/reservations.js';
27
28
  import {runnerActivationTokens} from './schema/runner-activation-tokens.js';
28
29
  import {runnerControlSessions} from './schema/runner-control-sessions.js';
29
30
  import {type providerRunnerLaunchKindEnum, providerRunners} from './schema/runner-instances.js';
31
+ import {runningJobExecutions} from './schema/running-job-executions.js';
30
32
 
31
33
  export interface ReservationTemplate {
32
34
  templateKey: string;
@@ -427,7 +429,6 @@ async function listActiveProvisionerReservationRowsTx(
427
429
  id: providerRunners.id,
428
430
  reservationId: providerRunners.reservationId,
429
431
  intendedReservationId: providerRunners.intendedReservationId,
430
- state: providerRunners.state,
431
432
  })
432
433
  .from(providerRunners)
433
434
  .where(
@@ -435,7 +436,10 @@ async function listActiveProvisionerReservationRowsTx(
435
436
  eq(providerRunners.provisionerId, params.provisionerId),
436
437
  isNull(providerRunners.reservationReleasedAt),
437
438
  or(
438
- inArray(providerRunners.reservationId, activeIds),
439
+ and(
440
+ inArray(providerRunners.reservationId, activeIds),
441
+ notInArray(providerRunners.state, [...terminalStates]),
442
+ ),
439
443
  and(
440
444
  inArray(providerRunners.intendedReservationId, activeIds),
441
445
  notInArray(providerRunners.state, [...terminalStates]),
@@ -448,11 +452,7 @@ async function listActiveProvisionerReservationRowsTx(
448
452
  if (runner.reservationId && activeIdSet.has(runner.reservationId)) {
449
453
  addUsedRunner(usedByReservation, runner.reservationId, runner.id);
450
454
  }
451
- if (
452
- runner.intendedReservationId &&
453
- activeIdSet.has(runner.intendedReservationId) &&
454
- !terminalStates.includes(runner.state as (typeof terminalStates)[number])
455
- ) {
455
+ if (runner.intendedReservationId && activeIdSet.has(runner.intendedReservationId)) {
456
456
  addUsedRunner(usedByReservation, runner.intendedReservationId, runner.id);
457
457
  }
458
458
  }
@@ -863,46 +863,322 @@ export async function releaseReservationUnits(
863
863
  releases: Array<{reservationId: string; count: number}>;
864
864
  },
865
865
  ): Promise<number> {
866
- let released = 0;
867
-
866
+ const releaseByReservationId = new Map<string, number>();
868
867
  for (const release of params.releases) {
869
868
  if (release.count <= 0) continue;
869
+ releaseByReservationId.set(
870
+ release.reservationId,
871
+ (releaseByReservationId.get(release.reservationId) ?? 0) + release.count,
872
+ );
873
+ }
874
+ const reservationIds = [...releaseByReservationId.keys()];
875
+ if (reservationIds.length === 0) return 0;
876
+
877
+ const releaseCount = sql<number>`CASE ${reservations.id}
878
+ ${sql.join(
879
+ [...releaseByReservationId].map(
880
+ ([reservationId, count]) => sql`WHEN ${reservationId} THEN ${count}`,
881
+ ),
882
+ sql` `,
883
+ )}
884
+ ELSE 0
885
+ END`;
886
+ const scope = and(
887
+ eq(reservations.workspaceId, params.workspaceId),
888
+ eq(reservations.provisionerId, params.provisionerId),
889
+ inArray(reservations.id, reservationIds),
890
+ gt(reservations.expiresAt, sql`now()`),
891
+ );
892
+
893
+ const decremented = await tx
894
+ .update(reservations)
895
+ .set({count: sql`${reservations.count} - ${releaseCount}`})
896
+ .where(and(scope, gt(reservations.count, releaseCount)))
897
+ .returning({id: reservations.id});
898
+ const releasedFromDecremented = decremented.reduce(
899
+ (total, row) => total + (releaseByReservationId.get(row.id) ?? 0),
900
+ 0,
901
+ );
870
902
 
871
- const decremented = await tx
872
- .update(reservations)
873
- .set({count: sql`${reservations.count} - ${release.count}`})
903
+ const decrementedIds = decremented.map((row) => row.id);
904
+ const deleted = await tx
905
+ .delete(reservations)
906
+ .where(
907
+ and(
908
+ scope,
909
+ sql`${reservations.count} <= ${releaseCount}`,
910
+ decrementedIds.length > 0 ? notInArray(reservations.id, decrementedIds) : undefined,
911
+ ),
912
+ )
913
+ .returning({count: reservations.count});
914
+ const releasedFromDeleted = deleted.reduce((total, row) => total + row.count, 0);
915
+
916
+ return releasedFromDeleted + releasedFromDecremented;
917
+ }
918
+
919
+ export async function releaseTerminalRunnerInstanceReservationsByIds(
920
+ tx: Tx,
921
+ params: {
922
+ workspaceId: string | null;
923
+ provisionerId: string;
924
+ providerRunnerIds?: string[];
925
+ runnerInstanceIds?: string[];
926
+ reportedAtByProviderRunnerId?: ReadonlyMap<string, Date>;
927
+ requireUnlinkedSession?: boolean;
928
+ /** Lock linked runners before rechecking terminal state for lease finalization. */
929
+ requireTerminalState?: boolean;
930
+ },
931
+ ): Promise<number> {
932
+ if (
933
+ (params.providerRunnerIds?.length ?? 0) === 0 &&
934
+ (params.runnerInstanceIds?.length ?? 0) === 0
935
+ )
936
+ return 0;
937
+
938
+ const reservationWorkspacePredicate =
939
+ params.workspaceId === null
940
+ ? sql``
941
+ : sql`and ${eq(reservations.workspaceId, params.workspaceId)}`;
942
+ const noRunningJobPredicate = notExists(
943
+ tx
944
+ .select({id: runningJobExecutions.id})
945
+ .from(runningJobExecutions)
874
946
  .where(
875
947
  and(
876
- eq(reservations.id, release.reservationId),
877
- eq(reservations.workspaceId, params.workspaceId),
878
- eq(reservations.provisionerId, params.provisionerId),
879
- gt(reservations.count, release.count),
880
- gt(reservations.expiresAt, sql`now()`),
948
+ eq(runningJobExecutions.provisionerId, params.provisionerId),
949
+ eq(runningJobExecutions.providerRunnerId, providerRunners.providerRunnerId),
950
+ params.workspaceId === null
951
+ ? undefined
952
+ : eq(runningJobExecutions.workspaceId, params.workspaceId),
881
953
  ),
882
- )
883
- .returning({id: reservations.id});
954
+ ),
955
+ );
956
+ const reportFreshnessPredicate =
957
+ params.reportedAtByProviderRunnerId && params.reportedAtByProviderRunnerId.size > 0
958
+ ? or(
959
+ ...[...params.reportedAtByProviderRunnerId].map(([providerRunnerId, reportedAt]) =>
960
+ and(
961
+ eq(providerRunners.providerRunnerId, providerRunnerId),
962
+ lte(providerRunners.reportedAt, reportedAt),
963
+ ),
964
+ ),
965
+ )
966
+ : undefined;
967
+ const runnerIdentityPredicate = or(
968
+ params.providerRunnerIds && params.providerRunnerIds.length > 0
969
+ ? inArray(providerRunners.providerRunnerId, params.providerRunnerIds)
970
+ : undefined,
971
+ params.runnerInstanceIds && params.runnerInstanceIds.length > 0
972
+ ? inArray(providerRunners.id, params.runnerInstanceIds)
973
+ : undefined,
974
+ );
884
975
 
885
- if (decremented.length > 0) {
886
- released += release.count;
887
- continue;
888
- }
976
+ // Assignment locks these keys before changing runner links. Acquire them before locking or
977
+ // marking terminal runners so an assignment cannot consume a reservation concurrently with
978
+ // this release.
979
+ const reservationRowsToLock = await tx
980
+ .select({
981
+ reservationId: providerRunners.reservationId,
982
+ intendedReservationId: providerRunners.intendedReservationId,
983
+ })
984
+ .from(providerRunners)
985
+ .where(
986
+ and(
987
+ eq(providerRunners.provisionerId, params.provisionerId),
988
+ runnerIdentityPredicate,
989
+ params.requireTerminalState === false
990
+ ? undefined
991
+ : inArray(providerRunners.state, terminalStates),
992
+ or(
993
+ isNotNull(providerRunners.reservationId),
994
+ isNotNull(providerRunners.intendedReservationId),
995
+ ),
996
+ isNull(providerRunners.reservationReleasedAt),
997
+ ),
998
+ );
999
+ const reservationIdsToLock = [
1000
+ ...new Set(
1001
+ reservationRowsToLock.flatMap((row) =>
1002
+ [row.reservationId, row.intendedReservationId].filter(
1003
+ (reservationId): reservationId is string => reservationId !== null,
1004
+ ),
1005
+ ),
1006
+ ),
1007
+ ];
1008
+ await lockRunnerReservationAdvisoryKeysTx(tx, {
1009
+ provisionerId: params.provisionerId,
1010
+ reservationIds: reservationIdsToLock,
1011
+ });
889
1012
 
890
- const deleted = await tx
891
- .delete(reservations)
892
- .where(
893
- and(
894
- eq(reservations.id, release.reservationId),
895
- eq(reservations.workspaceId, params.workspaceId),
896
- eq(reservations.provisionerId, params.provisionerId),
897
- sql`${reservations.count} <= ${release.count}`,
898
- gt(reservations.expiresAt, sql`now()`),
1013
+ const rows = await tx
1014
+ .select({
1015
+ id: providerRunners.id,
1016
+ state: providerRunners.state,
1017
+ releaseReservationId: sql<string | null>`coalesce(
1018
+ (select ${reservations.id}
1019
+ from ${reservations}
1020
+ where ${reservations.id} = ${providerRunners.intendedReservationId}
1021
+ and ${reservations.provisionerId} = ${params.provisionerId}
1022
+ ${reservationWorkspacePredicate}),
1023
+ (select ${reservations.id}
1024
+ from ${reservations}
1025
+ where ${reservations.id} = ${providerRunners.reservationId}
1026
+ and ${reservations.provisionerId} = ${params.provisionerId}
1027
+ ${reservationWorkspacePredicate})
1028
+ )`,
1029
+ releaseReservationWorkspaceId: sql<string | null>`coalesce(
1030
+ (select ${reservations.workspaceId}
1031
+ from ${reservations}
1032
+ where ${reservations.id} = ${providerRunners.intendedReservationId}
1033
+ and ${reservations.provisionerId} = ${params.provisionerId}
1034
+ ${reservationWorkspacePredicate}),
1035
+ (select ${reservations.workspaceId}
1036
+ from ${reservations}
1037
+ where ${reservations.id} = ${providerRunners.reservationId}
1038
+ and ${reservations.provisionerId} = ${params.provisionerId}
1039
+ ${reservationWorkspacePredicate})
1040
+ )`,
1041
+ })
1042
+ .from(providerRunners)
1043
+ .where(
1044
+ and(
1045
+ eq(providerRunners.provisionerId, params.provisionerId),
1046
+ runnerIdentityPredicate,
1047
+ params.requireTerminalState === false
1048
+ ? undefined
1049
+ : inArray(providerRunners.state, terminalStates),
1050
+ or(
1051
+ isNotNull(providerRunners.reservationId),
1052
+ isNotNull(providerRunners.intendedReservationId),
899
1053
  ),
900
- )
901
- .returning({count: reservations.count});
1054
+ params.workspaceId === null
1055
+ ? undefined
1056
+ : or(
1057
+ and(
1058
+ eq(providerRunners.workspaceId, params.workspaceId),
1059
+ or(
1060
+ exists(
1061
+ tx
1062
+ .select({id: reservations.id})
1063
+ .from(reservations)
1064
+ .where(
1065
+ and(
1066
+ eq(reservations.workspaceId, params.workspaceId),
1067
+ eq(reservations.provisionerId, params.provisionerId),
1068
+ eq(reservations.id, providerRunners.reservationId),
1069
+ ),
1070
+ ),
1071
+ ),
1072
+ exists(
1073
+ tx
1074
+ .select({id: reservations.id})
1075
+ .from(reservations)
1076
+ .where(
1077
+ and(
1078
+ eq(reservations.workspaceId, params.workspaceId),
1079
+ eq(reservations.provisionerId, params.provisionerId),
1080
+ eq(reservations.id, providerRunners.intendedReservationId),
1081
+ ),
1082
+ ),
1083
+ ),
1084
+ ),
1085
+ ),
1086
+ and(
1087
+ isNull(providerRunners.workspaceId),
1088
+ isNotNull(providerRunners.intendedReservationId),
1089
+ exists(
1090
+ tx
1091
+ .select({id: reservations.id})
1092
+ .from(reservations)
1093
+ .where(
1094
+ and(
1095
+ eq(reservations.workspaceId, params.workspaceId),
1096
+ eq(reservations.provisionerId, params.provisionerId),
1097
+ eq(reservations.id, providerRunners.intendedReservationId),
1098
+ ),
1099
+ ),
1100
+ ),
1101
+ ),
1102
+ ),
1103
+ params.requireUnlinkedSession === false
1104
+ ? undefined
1105
+ : isNull(providerRunners.runnerSessionId),
1106
+ noRunningJobPredicate,
1107
+ reportFreshnessPredicate,
1108
+ isNull(providerRunners.reservationReleasedAt),
1109
+ ),
1110
+ )
1111
+ .for('update');
1112
+
1113
+ if (rows.length === 0) return 0;
1114
+
1115
+ // The lease-finalization path deliberately locks active rows too. If a terminal report is
1116
+ // concurrently projecting the runner state, PostgreSQL rechecks this row after the lock and
1117
+ // lets cleanup observe the committed terminal state without a cross-module advisory lock.
1118
+ const terminalRows =
1119
+ params.requireTerminalState === false
1120
+ ? rows.filter((row) => terminalStates.some((terminalState) => terminalState === row.state))
1121
+ : rows;
1122
+ if (terminalRows.length === 0) return 0;
902
1123
 
903
- released += deleted.reduce((total, row) => total + row.count, 0);
1124
+ const updated = await tx
1125
+ .update(providerRunners)
1126
+ .set({
1127
+ intendedReservationId: null,
1128
+ reservationReleasedAt: sql`now()`,
1129
+ updatedAt: sql`now()`,
1130
+ })
1131
+ .where(
1132
+ and(
1133
+ inArray(
1134
+ providerRunners.id,
1135
+ terminalRows.map((row) => row.id),
1136
+ ),
1137
+ params.requireUnlinkedSession === false
1138
+ ? undefined
1139
+ : isNull(providerRunners.runnerSessionId),
1140
+ noRunningJobPredicate,
1141
+ reportFreshnessPredicate,
1142
+ isNull(providerRunners.reservationReleasedAt),
1143
+ ),
1144
+ )
1145
+ .returning({id: providerRunners.id});
1146
+
1147
+ const releasesByReservationId = new Map<
1148
+ string,
1149
+ {workspaceId: string; reservationId: string; count: number}
1150
+ >();
1151
+ const updatedIds = new Set(updated.map((row) => row.id));
1152
+ for (const row of terminalRows) {
1153
+ if (!updatedIds.has(row.id)) continue;
1154
+ if (!row.releaseReservationId || !row.releaseReservationWorkspaceId) continue;
1155
+ const key = `${row.releaseReservationWorkspaceId}:${row.releaseReservationId}`;
1156
+ const release = releasesByReservationId.get(key) ?? {
1157
+ workspaceId: row.releaseReservationWorkspaceId,
1158
+ reservationId: row.releaseReservationId,
1159
+ count: 0,
1160
+ };
1161
+ release.count += 1;
1162
+ releasesByReservationId.set(key, release);
904
1163
  }
905
1164
 
1165
+ if (releasesByReservationId.size === 0) return 0;
1166
+
1167
+ const releasesByWorkspaceId = new Map<string, Array<{reservationId: string; count: number}>>();
1168
+ for (const release of releasesByReservationId.values()) {
1169
+ const releases = releasesByWorkspaceId.get(release.workspaceId) ?? [];
1170
+ releases.push({reservationId: release.reservationId, count: release.count});
1171
+ releasesByWorkspaceId.set(release.workspaceId, releases);
1172
+ }
1173
+
1174
+ let released = 0;
1175
+ for (const [workspaceId, releases] of releasesByWorkspaceId) {
1176
+ released += await releaseReservationUnits(tx, {
1177
+ workspaceId,
1178
+ provisionerId: params.provisionerId,
1179
+ releases,
1180
+ });
1181
+ }
906
1182
  return released;
907
1183
  }
908
1184
 
@@ -304,6 +304,26 @@ describe('assignRunnerInstances', () => {
304
304
  ).resolves.toEqual([runner.id]);
305
305
  });
306
306
 
307
+ it('does not charge capacity for a terminal reservation assignment before release bookkeeping', async () => {
308
+ const reservation = await createReservation();
309
+ await db().insert(providerRunners).values({
310
+ provisionerId,
311
+ reservationId: reservation.id,
312
+ providerRunnerId: crypto.randomUUID(),
313
+ state: 'failed',
314
+ reportedAt: new Date(),
315
+ });
316
+ const runner = await createEnrolledRunner();
317
+
318
+ await expect(
319
+ assignRunnerInstances({
320
+ provisionerId,
321
+ reservationId: reservation.id,
322
+ runnerInstanceIds: [runner.id],
323
+ }),
324
+ ).resolves.toEqual([runner.id]);
325
+ });
326
+
307
327
  async function createReservation(
308
328
  overrides: Partial<{expiresAt: Date; requiredLabels: string[]; kind: 'bound' | 'launch'}> = {},
309
329
  ) {
@@ -165,6 +165,7 @@ export async function assignRunnerInstancesTx(
165
165
  and(
166
166
  eq(providerRunners.reservationId, reservation.id),
167
167
  isNull(providerRunners.reservationReleasedAt),
168
+ notInArray(providerRunners.state, [...terminalStates]),
168
169
  ),
169
170
  and(
170
171
  eq(providerRunners.intendedReservationId, reservation.id),
@@ -308,7 +309,6 @@ export async function validateRunnerReservationCapacityTx(
308
309
  id: providerRunners.id,
309
310
  reservationId: providerRunners.reservationId,
310
311
  intendedReservationId: providerRunners.intendedReservationId,
311
- state: providerRunners.state,
312
312
  })
313
313
  .from(providerRunners)
314
314
  .where(
@@ -316,7 +316,10 @@ export async function validateRunnerReservationCapacityTx(
316
316
  eq(providerRunners.provisionerId, params.provisionerId),
317
317
  isNull(providerRunners.reservationReleasedAt),
318
318
  or(
319
- inArray(providerRunners.reservationId, reservationIds),
319
+ and(
320
+ inArray(providerRunners.reservationId, reservationIds),
321
+ notInArray(providerRunners.state, [...terminalStates]),
322
+ ),
320
323
  and(
321
324
  inArray(providerRunners.intendedReservationId, reservationIds),
322
325
  notInArray(providerRunners.state, [...terminalStates]),
@@ -331,11 +334,7 @@ export async function validateRunnerReservationCapacityTx(
331
334
  usedRunnerIds.add(runner.id);
332
335
  usedRunnerIdsByReservation.set(runner.reservationId, usedRunnerIds);
333
336
  }
334
- if (
335
- runner.intendedReservationId &&
336
- requestedReservationIds.has(runner.intendedReservationId) &&
337
- !terminalStates.includes(runner.state as (typeof terminalStates)[number])
338
- ) {
337
+ if (runner.intendedReservationId && requestedReservationIds.has(runner.intendedReservationId)) {
339
338
  const usedRunnerIds =
340
339
  usedRunnerIdsByReservation.get(runner.intendedReservationId) ?? new Set<string>();
341
340
  usedRunnerIds.add(runner.id);