@shipfox/api-runners 10.2.0 → 12.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 (28) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +43 -0
  3. package/dist/db/job-executions.d.ts +10 -5
  4. package/dist/db/job-executions.d.ts.map +1 -1
  5. package/dist/db/job-executions.js +59 -23
  6. package/dist/db/job-executions.js.map +1 -1
  7. package/dist/db/rate-limits.d.ts +6 -15
  8. package/dist/db/rate-limits.d.ts.map +1 -1
  9. package/dist/db/rate-limits.js +17 -18
  10. package/dist/db/rate-limits.js.map +1 -1
  11. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  12. package/dist/presentation/routes/rate-limit.js +33 -54
  13. package/dist/presentation/routes/rate-limit.js.map +1 -1
  14. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js +3 -3
  15. package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js.map +1 -1
  16. package/dist/tsconfig.test.tsbuildinfo +1 -1
  17. package/package.json +13 -13
  18. package/src/db/job-executions.test.ts +270 -22
  19. package/src/db/job-executions.ts +101 -32
  20. package/src/db/rate-limits.ts +30 -42
  21. package/src/presentation/routes/heartbeat.test.ts +3 -3
  22. package/src/presentation/routes/list-active-runners.test.ts +8 -2
  23. package/src/presentation/routes/manual-registration-tokens.test.ts +8 -2
  24. package/src/presentation/routes/provisioner-tokens.test.ts +8 -2
  25. package/src/presentation/routes/rate-limit.ts +33 -64
  26. package/src/presentation/subscribers/on-workflows-job-execution-timed-out.test.ts +16 -0
  27. package/src/presentation/subscribers/on-workflows-job-execution-timed-out.ts +3 -3
  28. 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": "10.2.0",
4
+ "version": "12.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -21,25 +21,25 @@
21
21
  "@temporalio/workflow": "1.18.1",
22
22
  "drizzle-orm": "^0.45.2",
23
23
  "zod": "^4.4.3",
24
- "@shipfox/api-common-dto": "9.2.0",
25
- "@shipfox/api-runners-dto": "10.2.0",
26
- "@shipfox/api-workflows-dto": "10.0.0",
27
- "@shipfox/inter-module": "0.2.2",
24
+ "@shipfox/api-common-dto": "12.0.0",
25
+ "@shipfox/api-auth-context": "12.0.0",
26
+ "@shipfox/api-auth-dto": "12.0.0",
27
+ "@shipfox/api-runners-dto": "12.0.0",
28
+ "@shipfox/api-workflows-dto": "12.0.0",
28
29
  "@shipfox/config": "1.2.4",
29
- "@shipfox/node-drizzle": "0.3.4",
30
+ "@shipfox/inter-module": "0.2.3",
31
+ "@shipfox/node-drizzle": "0.3.5",
30
32
  "@shipfox/node-error-monitoring": "0.3.0",
31
- "@shipfox/node-module": "1.0.4",
32
33
  "@shipfox/node-auth-root-key": "0.2.3",
33
- "@shipfox/node-fastify": "0.4.0",
34
+ "@shipfox/node-fastify": "0.4.1",
35
+ "@shipfox/node-module": "1.0.5",
34
36
  "@shipfox/node-opentelemetry": "0.6.3",
35
37
  "@shipfox/node-outbox": "0.2.6",
36
- "@shipfox/node-postgres": "0.4.4",
37
- "@shipfox/node-rate-limit": "0.3.2",
38
+ "@shipfox/node-postgres": "0.5.0",
39
+ "@shipfox/node-rate-limit": "0.4.0",
38
40
  "@shipfox/node-temporal": "0.4.4",
39
41
  "@shipfox/node-tokens": "0.3.2",
40
- "@shipfox/runner-labels": "0.1.3",
41
- "@shipfox/api-auth-dto": "10.2.0",
42
- "@shipfox/api-auth-context": "10.2.0"
42
+ "@shipfox/runner-labels": "0.1.3"
43
43
  },
44
44
  "imports": {
45
45
  "#*": "./dist/*"
@@ -3,6 +3,7 @@ import {
3
3
  RUNNER_JOB_LEASE_EXPIRED,
4
4
  RUNNER_JOB_QUEUED,
5
5
  } from '@shipfox/api-runners-dto';
6
+ import {pgClient} from '@shipfox/node-postgres';
6
7
  import {eq, sql} from 'drizzle-orm';
7
8
  import {EmptyRequiredLabelsError, RunnerSessionExhaustedError} from '#core/errors.js';
8
9
  import {claimJobExecution} from '#core/job-executions.js';
@@ -21,9 +22,9 @@ import {
21
22
  expireStuckJobExecutions,
22
23
  getJobExecutionQueueDepth,
23
24
  isJobLeaseActive,
25
+ reconcileTerminalJobExecution,
24
26
  recordHeartbeat,
25
27
  releaseJobExecution,
26
- requestJobExecutionCancellation,
27
28
  } from './job-executions.js';
28
29
  import {runnersOutbox} from './schema/outbox.js';
29
30
  import {pendingJobExecutions} from './schema/pending-job-executions.js';
@@ -486,6 +487,40 @@ describe('claimPendingJobExecution', () => {
486
487
  expect(claimed).toHaveLength(1);
487
488
  });
488
489
 
490
+ it('locks only the FIFO candidate before acquiring its execution advisory lock', async () => {
491
+ const first = await pendingJobFactory.create({workspaceId});
492
+ const second = await pendingJobFactory.create({workspaceId});
493
+ const releaseLock = deferred<void>();
494
+ const lockReady = deferred<void>();
495
+ const lockHolder = db().transaction(async (tx) => {
496
+ await tx.execute(
497
+ sql`select pg_advisory_xact_lock(hashtext(${`runners_job_execution:${first.jobExecutionId}`}))`,
498
+ );
499
+ lockReady.resolve();
500
+ await releaseLock.promise;
501
+ });
502
+
503
+ try {
504
+ await lockReady.promise;
505
+ expect(
506
+ await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null}),
507
+ ).toBeNull();
508
+ expect(
509
+ await db()
510
+ .select()
511
+ .from(pendingJobExecutions)
512
+ .where(eq(pendingJobExecutions.workspaceId, workspaceId)),
513
+ ).toHaveLength(2);
514
+ } finally {
515
+ releaseLock.resolve();
516
+ await lockHolder;
517
+ }
518
+
519
+ const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
520
+ expect(claimed?.jobExecutionId).toBe(first.jobExecutionId);
521
+ expect(second.jobExecutionId).not.toBe(claimed?.jobExecutionId);
522
+ });
523
+
489
524
  it('claims the oldest job first', async () => {
490
525
  const older = await pendingJobFactory.create({workspaceId});
491
526
  await pendingJobFactory.create({workspaceId});
@@ -975,11 +1010,11 @@ describe('recordHeartbeat', () => {
975
1010
  );
976
1011
  });
977
1012
 
978
- it('returns cancel:true after requestJobExecutionCancellation', async () => {
1013
+ it('returns cancel:true after reconcileTerminalJobExecution', async () => {
979
1014
  await pendingJobFactory.create({workspaceId});
980
1015
  const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
981
1016
 
982
- await requestJobExecutionCancellation({jobExecutionId: claimed?.jobExecutionId as string});
1017
+ await reconcileTerminalJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
983
1018
 
984
1019
  const result = await recordHeartbeat({
985
1020
  jobExecutionId: claimed?.jobExecutionId as string,
@@ -1016,7 +1051,7 @@ describe('recordHeartbeat', () => {
1016
1051
  });
1017
1052
  });
1018
1053
 
1019
- describe('requestJobExecutionCancellation', () => {
1054
+ describe('reconcileTerminalJobExecution', () => {
1020
1055
  let workspaceId: string;
1021
1056
  let runnerSessionId: string;
1022
1057
 
@@ -1026,47 +1061,194 @@ describe('requestJobExecutionCancellation', () => {
1026
1061
  runnerSessionId = runnerSession.id;
1027
1062
  });
1028
1063
 
1029
- it('sets cancellation_requested_at on a fresh row', async () => {
1030
- await pendingJobFactory.create({workspaceId});
1064
+ it('deletes a pending execution', async () => {
1065
+ const pending = await pendingJobFactory.create({workspaceId});
1066
+
1067
+ await reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
1068
+
1069
+ expect(
1070
+ await db()
1071
+ .select()
1072
+ .from(pendingJobExecutions)
1073
+ .where(eq(pendingJobExecutions.jobExecutionId, pending.jobExecutionId)),
1074
+ ).toHaveLength(0);
1075
+ });
1076
+
1077
+ it('sets cancellation_requested_at on a running execution', async () => {
1078
+ const pending = await pendingJobFactory.create({workspaceId});
1031
1079
  const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
1080
+ expect(claimed?.jobExecutionId).toBe(pending.jobExecutionId);
1032
1081
 
1033
- await requestJobExecutionCancellation({jobExecutionId: claimed?.jobExecutionId as string});
1082
+ await reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
1034
1083
 
1035
1084
  const rows = await db()
1036
1085
  .select()
1037
1086
  .from(runningJobExecutions)
1038
- .where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
1087
+ .where(eq(runningJobExecutions.jobExecutionId, pending.jobExecutionId));
1039
1088
  expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
1040
1089
  });
1041
1090
 
1042
1091
  it('is idempotent: second call preserves the first timestamp', async () => {
1043
- await pendingJobFactory.create({workspaceId});
1092
+ const pending = await pendingJobFactory.create({workspaceId});
1044
1093
  const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
1094
+ expect(claimed?.jobExecutionId).toBe(pending.jobExecutionId);
1045
1095
 
1046
- await requestJobExecutionCancellation({jobExecutionId: claimed?.jobExecutionId as string});
1096
+ await reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
1047
1097
  const after1 = await db()
1048
1098
  .select()
1049
1099
  .from(runningJobExecutions)
1050
- .where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
1100
+ .where(eq(runningJobExecutions.jobExecutionId, pending.jobExecutionId));
1051
1101
  const firstTs = after1[0]?.cancellationRequestedAt;
1052
1102
 
1053
1103
  await new Promise((r) => setTimeout(r, 10));
1054
- await requestJobExecutionCancellation({jobExecutionId: claimed?.jobExecutionId as string});
1104
+ await reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
1055
1105
 
1056
1106
  const after2 = await db()
1057
1107
  .select()
1058
1108
  .from(runningJobExecutions)
1059
- .where(eq(runningJobExecutions.jobId, claimed?.jobId as string));
1109
+ .where(eq(runningJobExecutions.jobExecutionId, pending.jobExecutionId));
1060
1110
  expect(after2[0]?.cancellationRequestedAt?.getTime()).toBe(firstTs?.getTime());
1061
1111
  });
1062
1112
 
1063
- it('is a no-op when the job execution is missing (does not throw)', async () => {
1113
+ it('is a no-op when the job execution is missing', async () => {
1064
1114
  await expect(
1065
- requestJobExecutionCancellation({jobExecutionId: crypto.randomUUID()}),
1115
+ reconcileTerminalJobExecution({jobExecutionId: crypto.randomUUID()}),
1066
1116
  ).resolves.toBeUndefined();
1067
1117
  });
1118
+
1119
+ it('leaves a pending sibling for the same job untouched', async () => {
1120
+ const target = await pendingJobFactory.create({workspaceId});
1121
+ const sibling = await pendingJobFactory.create({workspaceId, jobId: target.jobId});
1122
+
1123
+ await reconcileTerminalJobExecution({jobExecutionId: target.jobExecutionId});
1124
+
1125
+ expect(
1126
+ await db()
1127
+ .select()
1128
+ .from(pendingJobExecutions)
1129
+ .where(eq(pendingJobExecutions.jobExecutionId, target.jobExecutionId)),
1130
+ ).toHaveLength(0);
1131
+ expect(
1132
+ await db()
1133
+ .select()
1134
+ .from(pendingJobExecutions)
1135
+ .where(eq(pendingJobExecutions.jobExecutionId, sibling.jobExecutionId)),
1136
+ ).toHaveLength(1);
1137
+ });
1138
+
1139
+ it('leaves a running sibling for the same job uncancelled', async () => {
1140
+ const target = await pendingJobFactory.create({workspaceId});
1141
+ const sibling = await pendingJobFactory.create({workspaceId, jobId: target.jobId});
1142
+ const targetClaim = await claimPendingJobExecution({
1143
+ workspaceId,
1144
+ runnerSessionId,
1145
+ maxClaims: null,
1146
+ });
1147
+ const siblingClaim = await claimPendingJobExecution({
1148
+ workspaceId,
1149
+ runnerSessionId,
1150
+ maxClaims: null,
1151
+ });
1152
+ expect(targetClaim?.jobExecutionId).toBe(target.jobExecutionId);
1153
+ expect(siblingClaim?.jobExecutionId).toBe(sibling.jobExecutionId);
1154
+
1155
+ await reconcileTerminalJobExecution({jobExecutionId: target.jobExecutionId});
1156
+
1157
+ const rows = await db()
1158
+ .select({
1159
+ jobExecutionId: runningJobExecutions.jobExecutionId,
1160
+ cancellationRequestedAt: runningJobExecutions.cancellationRequestedAt,
1161
+ })
1162
+ .from(runningJobExecutions)
1163
+ .where(eq(runningJobExecutions.jobId, target.jobId));
1164
+ const byJobExecutionId = new Map(
1165
+ rows.map((row) => [row.jobExecutionId, row.cancellationRequestedAt]),
1166
+ );
1167
+ expect(byJobExecutionId.get(target.jobExecutionId)).not.toBeNull();
1168
+ expect(byJobExecutionId.get(sibling.jobExecutionId)).toBeNull();
1169
+ });
1170
+
1171
+ it('cancels the lease when reconciliation races a claim that has already acquired the row', async () => {
1172
+ const pending = await pendingJobFactory.create({workspaceId});
1173
+ const releaseClaim = deferred<void>();
1174
+ const claimTransactionReady = deferred<void>();
1175
+ const lockHolder = db().transaction(async (tx) => {
1176
+ await tx.execute(sql`LOCK TABLE runners_outbox IN ACCESS EXCLUSIVE MODE`);
1177
+ claimTransactionReady.resolve();
1178
+ await releaseClaim.promise;
1179
+ });
1180
+
1181
+ let claim: ReturnType<typeof claimPendingJobExecution> | undefined;
1182
+ let reconciliation: Promise<void> | undefined;
1183
+ try {
1184
+ await claimTransactionReady.promise;
1185
+ claim = claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
1186
+ await waitForLockWait({queryLike: '%runners_outbox%'});
1187
+
1188
+ reconciliation = reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
1189
+ // Claim has already deleted the pending row and inserted the lease, but cannot commit its
1190
+ // outbox event while the table lock is held. Reconciliation must wait on that same claim's
1191
+ // execution advisory lock.
1192
+ await waitForLockWait({queryLike: '%pg_advisory_xact_lock%'});
1193
+ } finally {
1194
+ releaseClaim.resolve();
1195
+ await Promise.allSettled([
1196
+ lockHolder,
1197
+ claim ?? Promise.resolve(null),
1198
+ reconciliation ?? Promise.resolve(),
1199
+ ]);
1200
+ }
1201
+
1202
+ if (!claim || !reconciliation) throw new Error('Claim and reconciliation must both start');
1203
+ const [claimed] = await Promise.all([claim, reconciliation, lockHolder]);
1204
+ expect(claimed?.jobExecutionId).toBe(pending.jobExecutionId);
1205
+
1206
+ expect(
1207
+ await db()
1208
+ .select()
1209
+ .from(pendingJobExecutions)
1210
+ .where(eq(pendingJobExecutions.jobExecutionId, pending.jobExecutionId)),
1211
+ ).toHaveLength(0);
1212
+ const runningRows = await db()
1213
+ .select({cancellationRequestedAt: runningJobExecutions.cancellationRequestedAt})
1214
+ .from(runningJobExecutions)
1215
+ .where(eq(runningJobExecutions.jobExecutionId, pending.jobExecutionId));
1216
+ expect(runningRows).toHaveLength(1);
1217
+ expect(runningRows[0]?.cancellationRequestedAt).not.toBeNull();
1218
+ });
1068
1219
  });
1069
1220
 
1221
+ function deferred<T>() {
1222
+ let resolve!: (value: T | PromiseLike<T>) => void;
1223
+ let reject!: (reason?: unknown) => void;
1224
+ const promise = new Promise<T>((innerResolve, innerReject) => {
1225
+ resolve = innerResolve;
1226
+ reject = innerReject;
1227
+ });
1228
+ return {promise, resolve, reject};
1229
+ }
1230
+
1231
+ async function waitForLockWait(params: {queryLike: string}) {
1232
+ const deadline = Date.now() + 2_000;
1233
+ while (Date.now() < deadline) {
1234
+ const result = await pgClient().query<{count: number}>(
1235
+ `
1236
+ SELECT count(*)::int AS count
1237
+ FROM pg_stat_activity
1238
+ WHERE datname = current_database()
1239
+ AND pid <> pg_backend_pid()
1240
+ AND state = 'active'
1241
+ AND wait_event_type = 'Lock'
1242
+ AND query ILIKE $1
1243
+ `,
1244
+ [params.queryLike],
1245
+ );
1246
+ if ((result.rows[0]?.count ?? 0) > 0) return;
1247
+ await new Promise((resolve) => setTimeout(resolve, 10));
1248
+ }
1249
+ throw new Error(`Timed out waiting for lock waiter matching ${params.queryLike}`);
1250
+ }
1251
+
1070
1252
  describe('cancelRunnerJobs', () => {
1071
1253
  let workspaceId: string;
1072
1254
  let runnerSessionId: string;
@@ -1226,6 +1408,32 @@ describe('detectAndExpireStuckJobs', () => {
1226
1408
  expect(payload.steps).toBeUndefined();
1227
1409
  });
1228
1410
 
1411
+ it('does not requeue an execution after its lease has expired', async () => {
1412
+ const stale = await makeStaleJob(600);
1413
+
1414
+ await expireStuckJobExecutions({
1415
+ noFirstHeartbeatGraceSeconds: 60,
1416
+ thresholdSeconds: 180,
1417
+ });
1418
+
1419
+ await enqueueJobExecution({
1420
+ workspaceId,
1421
+ workflowRunId: stale.workflowRunId,
1422
+ workflowRunAttemptId: stale.workflowRunAttemptId,
1423
+ jobId: stale.jobId,
1424
+ jobExecutionId: stale.jobExecutionId,
1425
+ projectId: stale.projectId,
1426
+ requiredLabels: ['linux'],
1427
+ });
1428
+
1429
+ expect(
1430
+ await db()
1431
+ .select()
1432
+ .from(pendingJobExecutions)
1433
+ .where(eq(pendingJobExecutions.jobExecutionId, stale.jobExecutionId)),
1434
+ ).toHaveLength(0);
1435
+ });
1436
+
1229
1437
  it('expires a job that never sent a first heartbeat after the startup grace', async () => {
1230
1438
  const {jobId, workflowRunId, workflowRunAttemptId} = await makeNoFirstHeartbeatJob(90);
1231
1439
 
@@ -1338,7 +1546,7 @@ describe('detectAndExpireStuckJobs', () => {
1338
1546
  });
1339
1547
 
1340
1548
  it('skips a row whose heartbeat refreshed before the atomic DELETE re-evaluates the predicate', async () => {
1341
- // Pre-stale, then refresh, then run the cutoff is folded into the DELETE's
1549
+ // Pre-stale, then refresh, then run: the cutoff is folded into the DELETE's
1342
1550
  // WHERE so the live row survives even though the iteration SELECT saw it stale.
1343
1551
  const {jobId} = await makeStaleJob(600);
1344
1552
  await db()
@@ -1453,7 +1661,43 @@ describe('detectAndExpireStuckJobs', () => {
1453
1661
  expect(await outboxForJobs([stuck1.jobId, stuck2.jobId])).toHaveLength(2);
1454
1662
  });
1455
1663
 
1456
- it('a reaper tick and a concurrent claim of the same orphan-pending job leave consistent state', async () => {
1664
+ it('skips an execution whose advisory lock is held and reaps the next stale page', async () => {
1665
+ const first = await makeStaleJob(600);
1666
+ const second = await makeStaleJob(600);
1667
+ const releaseLock = deferred<void>();
1668
+ const lockReady = deferred<void>();
1669
+ const lockHolder = db().transaction(async (tx) => {
1670
+ await tx.execute(
1671
+ sql`select pg_advisory_xact_lock(hashtext(${`runners_job_execution:${first.jobExecutionId}`}))`,
1672
+ );
1673
+ lockReady.resolve();
1674
+ await releaseLock.promise;
1675
+ });
1676
+
1677
+ try {
1678
+ await lockReady.promise;
1679
+ const reaped = await expireStuckJobExecutions({
1680
+ noFirstHeartbeatGraceSeconds: 60,
1681
+ thresholdSeconds: 180,
1682
+ limit: 1,
1683
+ });
1684
+
1685
+ expect(reaped).toHaveLength(1);
1686
+ expect(reaped[0]?.jobExecutionId).toBe(second.jobExecutionId);
1687
+ } finally {
1688
+ releaseLock.resolve();
1689
+ await lockHolder;
1690
+ }
1691
+
1692
+ const remaining = await expireStuckJobExecutions({
1693
+ noFirstHeartbeatGraceSeconds: 60,
1694
+ thresholdSeconds: 180,
1695
+ limit: 1,
1696
+ });
1697
+ expect(remaining.map((row) => row.jobExecutionId)).toContain(first.jobExecutionId);
1698
+ });
1699
+
1700
+ it('a reaper tick and a concurrent claim of the same orphan-pending job do not deadlock', async () => {
1457
1701
  const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, projectId} =
1458
1702
  await makeStaleJob(600);
1459
1703
  // Orphan pending row from a post-claim enqueue retry for an already-running job.
@@ -1469,21 +1713,25 @@ describe('detectAndExpireStuckJobs', () => {
1469
1713
  requiredLabels: ['linux'],
1470
1714
  });
1471
1715
 
1472
- // The reaper locks running-then-pending while the claim locks pending-then-running;
1473
- // a deadlock loser rolls back, so either side may settle as rejected.
1474
- await Promise.allSettled([
1716
+ // The reaper may acquire the execution lock first, or it may skip the row while the claim
1717
+ // transaction holds it. Either interleaving must complete without deadlocking.
1718
+ const [firstReap, claimed] = await Promise.all([
1475
1719
  detectAndExpireStuckJobs({thresholdSeconds: 180}),
1476
1720
  claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null}),
1477
1721
  ]);
1478
1722
 
1479
- // A follow-up tick finishes any reap that lost a deadlock race.
1480
- await detectAndExpireStuckJobs({thresholdSeconds: 180});
1723
+ // Once the contending claim settles, the next tick reaps a row that the non-blocking
1724
+ // advisory-lock scan intentionally skipped.
1725
+ const secondReap = await detectAndExpireStuckJobs({thresholdSeconds: 180});
1481
1726
 
1727
+ expect(claimed).toBeNull();
1728
+ expect(firstReap.expired + secondReap.expired).toBeGreaterThanOrEqual(1);
1482
1729
  // The expired job is gone and not re-claimable; its orphan pending row is swept.
1483
1730
  expect(await runningJobsForTest()).toHaveLength(0);
1484
1731
  expect(
1485
1732
  await db().select().from(pendingJobExecutions).where(eq(pendingJobExecutions.jobId, jobId)),
1486
1733
  ).toHaveLength(0);
1734
+ expect(await outboxForJobs([jobId])).toHaveLength(1);
1487
1735
  expect(
1488
1736
  await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null}),
1489
1737
  ).toBeNull();
@@ -38,6 +38,14 @@ import {providerRunners} from './schema/runner-instances.js';
38
38
  import {runnerSessions} from './schema/runner-sessions.js';
39
39
  import {runningJobExecutions} from './schema/running-job-executions.js';
40
40
 
41
+ const runnerJobExecutionLockPrefix = 'runners_job_execution:';
42
+
43
+ async function lockJobExecution(tx: Tx, jobExecutionId: string): Promise<void> {
44
+ await tx.execute(
45
+ sql`select pg_advisory_xact_lock(hashtext(${`${runnerJobExecutionLockPrefix}${jobExecutionId}`}))`,
46
+ );
47
+ }
48
+
41
49
  export interface EnqueueJobExecutionParams {
42
50
  workspaceId: string;
43
51
  workflowRunId: string;
@@ -77,17 +85,29 @@ export async function getWorkspaceJobCounts(params: {
77
85
  }
78
86
 
79
87
  // Idempotent while the job execution is still pending: a duplicate jobExecutionId already in
80
- // `runners_pending_jobs` is a no-op. Temporal retries the enqueue activity
81
- // at-least-once, so a unique-violation throw on a retry-after-lost-result
82
- // would permanently fail a healthy job execution's workflow. The guard does not extend
83
- // past the claim: once the job execution has moved to `runners_running_jobs`, a retry
84
- // can reinsert an orphan pending row, which a later `claimPendingJobExecution` drops
85
- // via the running-execution unique constraint (onConflictDoNothing) instead of failing.
88
+ // `runners_pending_jobs` is a no-op. Temporal retries the enqueue activity at-least-once, so a
89
+ // unique-violation throw on a retry-after-lost-result would permanently fail a healthy execution.
90
+ // The per-execution advisory lock serializes retries with lease expiry/reconciliation, and the
91
+ // durable lease-expired event prevents a retry from re-queueing an execution already reaped.
86
92
  export async function enqueueJobExecution(params: EnqueueJobExecutionParams): Promise<void> {
87
93
  const requiredLabels = [...canonicalizeLabels(params.requiredLabels)];
88
94
  if (requiredLabels.length === 0) throw new EmptyRequiredLabelsError();
89
95
 
90
96
  const enqueued = await db().transaction(async (tx) => {
97
+ await lockJobExecution(tx, params.jobExecutionId);
98
+
99
+ const [leaseExpired] = await tx
100
+ .select({id: runnersOutbox.id})
101
+ .from(runnersOutbox)
102
+ .where(
103
+ and(
104
+ eq(runnersOutbox.eventType, RUNNER_JOB_LEASE_EXPIRED),
105
+ sql`${runnersOutbox.payload}->>'jobExecutionId' = ${params.jobExecutionId}`,
106
+ ),
107
+ )
108
+ .limit(1);
109
+ if (leaseExpired) return false;
110
+
91
111
  const [inserted] = await tx
92
112
  .insert(pendingJobExecutions)
93
113
  .values({
@@ -209,8 +229,10 @@ export async function claimPendingJobExecution(params: {
209
229
  }
210
230
 
211
231
  // `id` is a uuidv7 (time-ordered), so it is a deterministic FIFO tiebreaker
212
- // for rows sharing a created_at within a batch.
213
- const [row] = await tx
232
+ // for rows sharing a created_at within a batch. Lock only the FIFO candidate before
233
+ // attempting its execution advisory lock; putting pg_try_advisory_xact_lock in this
234
+ // predicate would evaluate it while scanning and temporarily lock many queue entries.
235
+ const candidate = tx
214
236
  .select()
215
237
  .from(pendingJobExecutions)
216
238
  .where(
@@ -221,7 +243,19 @@ export async function claimPendingJobExecution(params: {
221
243
  )
222
244
  .orderBy(asc(pendingJobExecutions.createdAt), asc(pendingJobExecutions.id))
223
245
  .limit(1)
224
- .for('update', {skipLocked: true});
246
+ .for('update', {skipLocked: true})
247
+ .as('pending_candidate');
248
+
249
+ const [row] = await tx
250
+ .select()
251
+ .from(candidate)
252
+ .where(
253
+ sql`
254
+ pg_try_advisory_xact_lock(
255
+ hashtext(${runnerJobExecutionLockPrefix} || ${candidate.jobExecutionId}::text)
256
+ )
257
+ `,
258
+ );
225
259
 
226
260
  if (!row) return null;
227
261
 
@@ -324,12 +358,14 @@ async function touchRunnerSessionLiveness(params: {
324
358
  * Releases a job execution's lease when the orchestration workflow finalizes it: deletes the
325
359
  * running-job-execution row AND any lingering pending row for the same execution, in one tx.
326
360
  * Idempotent (0-row no-op), no token scope (the workflow is authoritative), and
327
- * emits no event the workflow already owns the outcome. Sweeping the pending row
361
+ * emits no event: the workflow already owns the outcome. Sweeping the pending row
328
362
  * too closes the at-least-once window where an enqueue retry left an orphan that a
329
363
  * later claim would otherwise pick up for an already-finished job execution.
330
364
  */
331
365
  export async function releaseJobExecution(params: {jobExecutionId: string}): Promise<void> {
332
366
  await db().transaction(async (tx) => {
367
+ await lockJobExecution(tx, params.jobExecutionId);
368
+
333
369
  // Delete pending before running to match `claimPendingJobExecution`'s lock-acquisition
334
370
  // order (it locks the pending row first, then the running row). A concurrent
335
371
  // claim picking up an orphan pending row for this same job execution would otherwise
@@ -352,9 +388,9 @@ export async function releaseJobExecution(params: {jobExecutionId: string}): Pro
352
388
  * pending row: a failed best-effort `releaseJobExecution` would otherwise leave an orphan
353
389
  * that a later claim re-runs as an already-finished job execution.
354
390
  *
355
- * Locks running-then-pending, the inverse of `claimPendingJobExecution` / `releaseJobExecution`.
356
- * That pre-existing asymmetry opens a narrow deadlock window against a concurrent
357
- * claim of the same orphan-pending job execution; Postgres breaks it and the cron retries.
391
+ * Locks pending-then-running to match `claimPendingJobExecution`, `releaseJobExecution`, and
392
+ * `reconcileTerminalJobExecution`. The stale candidate scan intentionally does not lock running
393
+ * rows first; the running-row DELETE re-checks the stale predicate after the pending-row sweep.
358
394
  */
359
395
  export async function expireStuckJobExecutions(params: {
360
396
  thresholdSeconds: number;
@@ -386,17 +422,42 @@ export async function expireStuckJobExecutions(params: {
386
422
  ),
387
423
  );
388
424
 
389
- const staleIds = tx
390
- .select({id: runningJobExecutions.id})
425
+ const staleRows = await tx
426
+ .select({
427
+ id: runningJobExecutions.id,
428
+ jobExecutionId: runningJobExecutions.jobExecutionId,
429
+ })
391
430
  .from(runningJobExecutions)
392
- .where(stalePredicate)
431
+ .where(
432
+ and(
433
+ stalePredicate,
434
+ sql`
435
+ pg_try_advisory_xact_lock(
436
+ hashtext(${runnerJobExecutionLockPrefix} || ${runningJobExecutions.jobExecutionId}::text)
437
+ )
438
+ `,
439
+ ),
440
+ )
393
441
  .orderBy(
394
442
  asc(
395
443
  sql`CASE WHEN ${runningJobExecutions.firstHeartbeatAt} IS NULL AND ${runningJobExecutions.lastHeartbeatAt} <= ${runningJobExecutions.startedAt} THEN ${runningJobExecutions.startedAt} ELSE ${runningJobExecutions.lastHeartbeatAt} END`,
396
444
  ),
445
+ asc(runningJobExecutions.id),
397
446
  )
398
- .limit(params.limit ?? 100)
399
- .for('update', {skipLocked: true});
447
+ .limit(params.limit ?? 100);
448
+
449
+ if (staleRows.length === 0) return [];
450
+
451
+ const staleIds = staleRows.map((row) => row.id);
452
+ const staleJobExecutionIds = staleRows.map((row) => row.jobExecutionId);
453
+
454
+ // Sweep pending rows first so this transaction cannot hold a running-row lock while waiting
455
+ // for a pending-row lock held by reconciliation or a claim of an orphan pending row. The
456
+ // advisory lock acquired in the candidate scan also makes enqueue retries wait until this
457
+ // transaction has either reaped the execution or released its lock without selecting it.
458
+ await tx
459
+ .delete(pendingJobExecutions)
460
+ .where(inArray(pendingJobExecutions.jobExecutionId, staleJobExecutionIds));
400
461
 
401
462
  const deleted = await tx
402
463
  .delete(runningJobExecutions)
@@ -410,13 +471,6 @@ export async function expireStuckJobExecutions(params: {
410
471
 
411
472
  if (deleted.length === 0) return [];
412
473
 
413
- await tx.delete(pendingJobExecutions).where(
414
- inArray(
415
- pendingJobExecutions.jobExecutionId,
416
- deleted.map((row) => row.jobExecutionId),
417
- ),
418
- );
419
-
420
474
  await writeOutboxEvents<RunnersEventMap>(
421
475
  tx,
422
476
  runnersOutbox,
@@ -677,15 +731,30 @@ export async function recordHeartbeat(params: {
677
731
  };
678
732
  }
679
733
 
680
- export async function requestJobExecutionCancellation(params: {
734
+ /**
735
+ * Reconciles a terminal job execution with runner state in one transaction: removes its pending
736
+ * queue row and requests cancellation on its running lease, if either exists. The operation is
737
+ * idempotent and preserves the first cancellation-request timestamp.
738
+ */
739
+ export async function reconcileTerminalJobExecution(params: {
681
740
  jobExecutionId: string;
682
741
  }): Promise<void> {
683
- await db()
684
- .update(runningJobExecutions)
685
- .set({
686
- cancellationRequestedAt: sql`COALESCE(${runningJobExecutions.cancellationRequestedAt}, now())`,
687
- })
688
- .where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId));
742
+ await db().transaction(async (tx) => {
743
+ await lockJobExecution(tx, params.jobExecutionId);
744
+
745
+ // Delete pending before updating running to match claim/release lock order. Claim locks
746
+ // pending rows with SKIP LOCKED before inserting the running lease, so this ordering makes
747
+ // a concurrent terminal reconciliation either win before claim or cancel the new lease.
748
+ await tx
749
+ .delete(pendingJobExecutions)
750
+ .where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
751
+ await tx
752
+ .update(runningJobExecutions)
753
+ .set({
754
+ cancellationRequestedAt: sql`COALESCE(${runningJobExecutions.cancellationRequestedAt}, now())`,
755
+ })
756
+ .where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId));
757
+ });
689
758
  }
690
759
 
691
760
  export async function cancelRunnerJobs(params: {jobIds: string[]}): Promise<void> {