@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +43 -0
- package/dist/db/job-executions.d.ts +10 -5
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +59 -23
- package/dist/db/job-executions.js.map +1 -1
- package/dist/db/rate-limits.d.ts +6 -15
- package/dist/db/rate-limits.d.ts.map +1 -1
- package/dist/db/rate-limits.js +17 -18
- package/dist/db/rate-limits.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +33 -54
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js +3 -3
- package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +13 -13
- package/src/db/job-executions.test.ts +270 -22
- package/src/db/job-executions.ts +101 -32
- package/src/db/rate-limits.ts +30 -42
- package/src/presentation/routes/heartbeat.test.ts +3 -3
- package/src/presentation/routes/list-active-runners.test.ts +8 -2
- package/src/presentation/routes/manual-registration-tokens.test.ts +8 -2
- package/src/presentation/routes/provisioner-tokens.test.ts +8 -2
- package/src/presentation/routes/rate-limit.ts +33 -64
- package/src/presentation/subscribers/on-workflows-job-execution-timed-out.test.ts +16 -0
- package/src/presentation/subscribers/on-workflows-job-execution-timed-out.ts +3 -3
- 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": "
|
|
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": "
|
|
25
|
-
"@shipfox/api-
|
|
26
|
-
"@shipfox/api-
|
|
27
|
-
"@shipfox/
|
|
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/
|
|
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.
|
|
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.
|
|
37
|
-
"@shipfox/node-rate-limit": "0.
|
|
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
|
|
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
|
|
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('
|
|
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('
|
|
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
|
|
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.
|
|
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
|
|
1096
|
+
await reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
|
|
1047
1097
|
const after1 = await db()
|
|
1048
1098
|
.select()
|
|
1049
1099
|
.from(runningJobExecutions)
|
|
1050
|
-
.where(eq(runningJobExecutions.
|
|
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
|
|
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.
|
|
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
|
|
1113
|
+
it('is a no-op when the job execution is missing', async () => {
|
|
1064
1114
|
await expect(
|
|
1065
|
-
|
|
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
|
|
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('
|
|
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
|
|
1473
|
-
//
|
|
1474
|
-
await Promise.
|
|
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
|
-
//
|
|
1480
|
-
|
|
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();
|
package/src/db/job-executions.ts
CHANGED
|
@@ -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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
356
|
-
*
|
|
357
|
-
*
|
|
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
|
|
390
|
-
.select({
|
|
425
|
+
const staleRows = await tx
|
|
426
|
+
.select({
|
|
427
|
+
id: runningJobExecutions.id,
|
|
428
|
+
jobExecutionId: runningJobExecutions.jobExecutionId,
|
|
429
|
+
})
|
|
391
430
|
.from(runningJobExecutions)
|
|
392
|
-
.where(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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> {
|