@shipfox/api-runners 12.6.0 → 13.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 +20 -0
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/job-executions.d.ts +8 -21
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +63 -60
- package/dist/db/job-executions.js.map +1 -1
- package/dist/db/provisioner-tokens.d.ts.map +1 -1
- package/dist/db/provisioner-tokens.js +14 -2
- package/dist/db/provisioner-tokens.js.map +1 -1
- package/dist/db/reservations.d.ts +10 -0
- package/dist/db/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +139 -17
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/runner-assignments.d.ts.map +1 -1
- package/dist/db/runner-assignments.js +8 -5
- package/dist/db/runner-assignments.js.map +1 -1
- package/dist/db/runner-instances.d.ts +0 -1
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +85 -100
- package/dist/db/runner-instances.js.map +1 -1
- package/dist/db/runner-states.d.ts +3 -1
- package/dist/db/runner-states.d.ts.map +1 -1
- package/dist/db/runner-states.js +6 -4
- package/dist/db/runner-states.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/presentation/index.d.ts +2 -1
- package/dist/presentation/index.d.ts.map +1 -1
- package/dist/presentation/index.js +2 -1
- package/dist/presentation/index.js.map +1 -1
- package/dist/presentation/inter-module.d.ts +0 -1
- package/dist/presentation/inter-module.d.ts.map +1 -1
- package/dist/presentation/inter-module.js +2 -25
- package/dist/presentation/inter-module.js.map +1 -1
- package/dist/presentation/subscribers/on-workflows-job-execution-queued.d.ts +3 -0
- package/dist/presentation/subscribers/on-workflows-job-execution-queued.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-workflows-job-execution-queued.js +21 -0
- package/dist/presentation/subscribers/on-workflows-job-execution-queued.js.map +1 -0
- package/dist/presentation/subscribers/on-workflows-job-execution-terminated.d.ts +3 -0
- package/dist/presentation/subscribers/on-workflows-job-execution-terminated.d.ts.map +1 -0
- package/dist/presentation/subscribers/{on-workflows-job-execution-timed-out.js → on-workflows-job-execution-terminated.js} +5 -4
- package/dist/presentation/subscribers/on-workflows-job-execution-terminated.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/db/index.ts +0 -2
- package/src/db/job-executions.test.ts +106 -217
- package/src/db/job-executions.ts +71 -70
- package/src/db/provisioner-tokens.test.ts +19 -0
- package/src/db/provisioner-tokens.ts +13 -2
- package/src/db/reservations.test.ts +138 -0
- package/src/db/reservations.ts +314 -35
- package/src/db/runner-assignments.test.ts +20 -0
- package/src/db/runner-assignments.ts +6 -7
- package/src/db/runner-instances.test.ts +350 -15
- package/src/db/runner-instances.ts +136 -198
- package/src/db/runner-states.test.ts +16 -0
- package/src/db/runner-states.ts +9 -4
- package/src/index.test.ts +1 -0
- package/src/index.ts +8 -7
- package/src/presentation/index.ts +2 -1
- package/src/presentation/inter-module.ts +2 -40
- package/src/presentation/subscribers/on-workflows-job-execution-queued.test.ts +57 -0
- package/src/presentation/subscribers/on-workflows-job-execution-queued.ts +26 -0
- package/src/presentation/subscribers/on-workflows-job-execution-terminated.test.ts +142 -0
- package/src/presentation/subscribers/{on-workflows-job-execution-timed-out.ts → on-workflows-job-execution-terminated.ts} +5 -4
- package/test/factories/pending-job.ts +3 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts +0 -3
- package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.d.ts.map +0 -1
- package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js.map +0 -1
- package/src/presentation/inter-module.test.ts +0 -15
- package/src/presentation/subscribers/on-workflows-job-execution-timed-out.test.ts +0 -163
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-runners",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "13.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"@shipfox/api-common-dto": "12.0.0",
|
|
25
25
|
"@shipfox/api-auth-context": "12.2.0",
|
|
26
26
|
"@shipfox/api-auth-dto": "12.0.0",
|
|
27
|
-
"@shipfox/api-runners-dto": "
|
|
28
|
-
"@shipfox/api-workflows-dto": "
|
|
27
|
+
"@shipfox/api-runners-dto": "13.0.0",
|
|
28
|
+
"@shipfox/api-workflows-dto": "13.0.0",
|
|
29
29
|
"@shipfox/config": "1.2.4",
|
|
30
30
|
"@shipfox/inter-module": "0.2.3",
|
|
31
31
|
"@shipfox/node-drizzle": "0.3.5",
|
package/src/db/index.ts
CHANGED
|
@@ -25,7 +25,6 @@ export type {
|
|
|
25
25
|
RunnerInstanceBoundJobExecution,
|
|
26
26
|
} from './job-executions.js';
|
|
27
27
|
export {
|
|
28
|
-
cancelRunnerJobs,
|
|
29
28
|
claimPendingJobExecution,
|
|
30
29
|
enqueueJobExecution,
|
|
31
30
|
expireStuckJobExecutions,
|
|
@@ -34,7 +33,6 @@ export {
|
|
|
34
33
|
listActiveRunningJobExecutions,
|
|
35
34
|
listRunningJobExecutionsByRunnerInstanceTx,
|
|
36
35
|
recordHeartbeat,
|
|
37
|
-
releaseJobExecution,
|
|
38
36
|
} from './job-executions.js';
|
|
39
37
|
export type {CreateManualRegistrationTokenParams} from './manual-registration-tokens.js';
|
|
40
38
|
export {
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
RUNNER_JOB_CLAIMED,
|
|
3
|
-
RUNNER_JOB_LEASE_EXPIRED,
|
|
4
|
-
RUNNER_JOB_QUEUED,
|
|
5
|
-
} from '@shipfox/api-runners-dto';
|
|
1
|
+
import {RUNNER_JOB_CLAIMED, RUNNER_JOB_LEASE_EXPIRED} from '@shipfox/api-runners-dto';
|
|
6
2
|
import {pgClient} from '@shipfox/node-postgres';
|
|
7
3
|
import {eq, sql} from 'drizzle-orm';
|
|
8
4
|
import {EmptyRequiredLabelsError, RunnerSessionExhaustedError} from '#core/errors.js';
|
|
@@ -12,12 +8,12 @@ import * as runnerMetrics from '#metrics/instance.js';
|
|
|
12
8
|
import {
|
|
13
9
|
getLeaseTokenClaims,
|
|
14
10
|
pendingJobFactory,
|
|
11
|
+
providerRunnerFactory,
|
|
15
12
|
runnerSessionFactory,
|
|
16
13
|
runnersTestAuthClient,
|
|
17
14
|
} from '#test/index.js';
|
|
18
15
|
import {db} from './db.js';
|
|
19
16
|
import {
|
|
20
|
-
cancelRunnerJobs,
|
|
21
17
|
claimPendingJobExecution as claimPendingJobExecutionDb,
|
|
22
18
|
enqueueJobExecution,
|
|
23
19
|
expireStuckJobExecutions,
|
|
@@ -25,10 +21,10 @@ import {
|
|
|
25
21
|
isJobLeaseActive,
|
|
26
22
|
reconcileTerminalJobExecution,
|
|
27
23
|
recordHeartbeat,
|
|
28
|
-
releaseJobExecution,
|
|
29
24
|
} from './job-executions.js';
|
|
30
25
|
import {runnersOutbox} from './schema/outbox.js';
|
|
31
26
|
import {pendingJobExecutions} from './schema/pending-job-executions.js';
|
|
27
|
+
import {reservations} from './schema/reservations.js';
|
|
32
28
|
import {providerRunners} from './schema/runner-instances.js';
|
|
33
29
|
import {runnerSessions} from './schema/runner-sessions.js';
|
|
34
30
|
import {runningJobExecutions} from './schema/running-job-executions.js';
|
|
@@ -69,6 +65,7 @@ describe('enqueueJobExecution', () => {
|
|
|
69
65
|
const workflowRunAttemptId = crypto.randomUUID();
|
|
70
66
|
const workspaceId = crypto.randomUUID();
|
|
71
67
|
const projectId = crypto.randomUUID();
|
|
68
|
+
const queuedAt = new Date('2026-08-11T08:00:00.000Z');
|
|
72
69
|
|
|
73
70
|
await enqueueJobExecution({
|
|
74
71
|
workspaceId,
|
|
@@ -78,6 +75,7 @@ describe('enqueueJobExecution', () => {
|
|
|
78
75
|
workflowRunAttemptId,
|
|
79
76
|
projectId,
|
|
80
77
|
requiredLabels: ['linux'],
|
|
78
|
+
queuedAt,
|
|
81
79
|
});
|
|
82
80
|
|
|
83
81
|
const rows = await db()
|
|
@@ -92,6 +90,7 @@ describe('enqueueJobExecution', () => {
|
|
|
92
90
|
expect(rows[0]?.projectId).toBe(projectId);
|
|
93
91
|
expect(rows[0]?.workspaceId).toBe(workspaceId);
|
|
94
92
|
expect(rows[0]?.requiredLabels).toEqual(['linux']);
|
|
93
|
+
expect(rows[0]?.createdAt).toEqual(queuedAt);
|
|
95
94
|
expect(rows[0]).not.toHaveProperty('payload');
|
|
96
95
|
});
|
|
97
96
|
|
|
@@ -107,6 +106,7 @@ describe('enqueueJobExecution', () => {
|
|
|
107
106
|
workflowRunAttemptId: crypto.randomUUID(),
|
|
108
107
|
projectId: crypto.randomUUID(),
|
|
109
108
|
requiredLabels: ['Ubuntu22', ' ubuntu22 ', 'LINUX'],
|
|
109
|
+
queuedAt: new Date(),
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
const rows = await db()
|
|
@@ -126,6 +126,7 @@ describe('enqueueJobExecution', () => {
|
|
|
126
126
|
workflowRunAttemptId: crypto.randomUUID(),
|
|
127
127
|
projectId: crypto.randomUUID(),
|
|
128
128
|
requiredLabels: [],
|
|
129
|
+
queuedAt: new Date(),
|
|
129
130
|
}),
|
|
130
131
|
).rejects.toBeInstanceOf(EmptyRequiredLabelsError);
|
|
131
132
|
});
|
|
@@ -140,6 +141,7 @@ describe('enqueueJobExecution', () => {
|
|
|
140
141
|
jobId,
|
|
141
142
|
jobExecutionId: crypto.randomUUID(),
|
|
142
143
|
requiredLabels: ['linux'],
|
|
144
|
+
queuedAt: new Date(),
|
|
143
145
|
};
|
|
144
146
|
|
|
145
147
|
await enqueueJobExecution(params);
|
|
@@ -151,63 +153,6 @@ describe('enqueueJobExecution', () => {
|
|
|
151
153
|
.where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
|
|
152
154
|
expect(rows).toHaveLength(1);
|
|
153
155
|
});
|
|
154
|
-
|
|
155
|
-
it('emits runners.job.queued carrying the pending row created_at', async () => {
|
|
156
|
-
const jobId = crypto.randomUUID();
|
|
157
|
-
const workflowRunId = crypto.randomUUID();
|
|
158
|
-
const workflowRunAttemptId = crypto.randomUUID();
|
|
159
|
-
|
|
160
|
-
await enqueueJobExecution({
|
|
161
|
-
workspaceId: crypto.randomUUID(),
|
|
162
|
-
workflowRunId,
|
|
163
|
-
jobId,
|
|
164
|
-
jobExecutionId: crypto.randomUUID(),
|
|
165
|
-
workflowRunAttemptId,
|
|
166
|
-
projectId: crypto.randomUUID(),
|
|
167
|
-
requiredLabels: ['linux'],
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
const [pending] = await db()
|
|
171
|
-
.select()
|
|
172
|
-
.from(pendingJobExecutions)
|
|
173
|
-
.where(eq(pendingJobExecutions.jobId, jobId));
|
|
174
|
-
const outbox = await outboxEventsForJob(RUNNER_JOB_QUEUED, jobId);
|
|
175
|
-
expect(outbox).toHaveLength(1);
|
|
176
|
-
expect(outbox[0]?.eventType).toBe(RUNNER_JOB_QUEUED);
|
|
177
|
-
const payload = outbox[0]?.payload as {
|
|
178
|
-
jobId: string;
|
|
179
|
-
workflowRunId: string;
|
|
180
|
-
workflowRunAttemptId: string;
|
|
181
|
-
queuedAt: string;
|
|
182
|
-
};
|
|
183
|
-
expect(payload.jobId).toBe(jobId);
|
|
184
|
-
expect(payload.workflowRunId).toBe(workflowRunId);
|
|
185
|
-
expect(payload.workflowRunAttemptId).toBe(workflowRunAttemptId);
|
|
186
|
-
expect(new Date(payload.queuedAt).getTime()).toBe(pending?.createdAt.getTime());
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('does not double-emit queued when the same jobId is re-enqueued (idempotency regression)', async () => {
|
|
190
|
-
const params = {
|
|
191
|
-
workspaceId: crypto.randomUUID(),
|
|
192
|
-
workflowRunId: crypto.randomUUID(),
|
|
193
|
-
workflowRunAttemptId: crypto.randomUUID(),
|
|
194
|
-
projectId: crypto.randomUUID(),
|
|
195
|
-
jobId: crypto.randomUUID(),
|
|
196
|
-
jobExecutionId: crypto.randomUUID(),
|
|
197
|
-
requiredLabels: ['linux'],
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
await enqueueJobExecution(params);
|
|
201
|
-
await enqueueJobExecution(params);
|
|
202
|
-
|
|
203
|
-
expect(
|
|
204
|
-
await db()
|
|
205
|
-
.select()
|
|
206
|
-
.from(pendingJobExecutions)
|
|
207
|
-
.where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId)),
|
|
208
|
-
).toHaveLength(1);
|
|
209
|
-
expect(await outboxEventsForJob(RUNNER_JOB_QUEUED, params.jobId)).toHaveLength(1);
|
|
210
|
-
});
|
|
211
156
|
});
|
|
212
157
|
|
|
213
158
|
describe('claimPendingJobExecution', () => {
|
|
@@ -826,7 +771,7 @@ describe('claimPendingJobExecution', () => {
|
|
|
826
771
|
expect(running[0]?.jobId).toBe(created.jobId);
|
|
827
772
|
});
|
|
828
773
|
|
|
829
|
-
it('leaves a non-matching orphan unclaimed until
|
|
774
|
+
it('leaves a non-matching orphan unclaimed until terminal reconciliation sweeps it', async () => {
|
|
830
775
|
const created = await pendingJobFactory.create({workspaceId});
|
|
831
776
|
const first = await claimPendingJobExecution({workspaceId, runnerSessionId});
|
|
832
777
|
if (!first) throw new Error('Expected pending job to be claimed');
|
|
@@ -846,15 +791,15 @@ describe('claimPendingJobExecution', () => {
|
|
|
846
791
|
runnerSessionId,
|
|
847
792
|
sessionLabels: ['macos'],
|
|
848
793
|
});
|
|
849
|
-
await
|
|
794
|
+
await reconcileTerminalJobExecution({jobExecutionId: created.jobExecutionId});
|
|
850
795
|
|
|
851
796
|
expect(second).toBeNull();
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
).
|
|
797
|
+
const [running] = await db()
|
|
798
|
+
.select()
|
|
799
|
+
.from(runningJobExecutions)
|
|
800
|
+
.where(eq(runningJobExecutions.workspaceId, workspaceId));
|
|
801
|
+
expect(running?.jobExecutionId).toBe(created.jobExecutionId);
|
|
802
|
+
expect(running?.cancellationRequestedAt).not.toBeNull();
|
|
858
803
|
expect(
|
|
859
804
|
await db()
|
|
860
805
|
.select()
|
|
@@ -935,88 +880,6 @@ describe('claimJobExecution', () => {
|
|
|
935
880
|
});
|
|
936
881
|
});
|
|
937
882
|
|
|
938
|
-
describe('releaseJobExecution', () => {
|
|
939
|
-
let workspaceId: string;
|
|
940
|
-
let runnerSessionId: string;
|
|
941
|
-
|
|
942
|
-
beforeEach(async () => {
|
|
943
|
-
workspaceId = crypto.randomUUID();
|
|
944
|
-
const runnerSession = await runnerSessionFactory.create({workspaceId});
|
|
945
|
-
runnerSessionId = runnerSession.id;
|
|
946
|
-
});
|
|
947
|
-
|
|
948
|
-
it('deletes the running row and writes no outbox event', async () => {
|
|
949
|
-
await pendingJobFactory.create({workspaceId});
|
|
950
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
951
|
-
const before = await outboxEventsForJob(RUNNER_JOB_CLAIMED, claimed?.jobId as string);
|
|
952
|
-
|
|
953
|
-
await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
|
|
954
|
-
|
|
955
|
-
expect(
|
|
956
|
-
await db()
|
|
957
|
-
.select()
|
|
958
|
-
.from(runningJobExecutions)
|
|
959
|
-
.where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
|
|
960
|
-
).toHaveLength(0);
|
|
961
|
-
expect(await outboxEventsForJob(RUNNER_JOB_CLAIMED, claimed?.jobId as string)).toHaveLength(
|
|
962
|
-
before.length,
|
|
963
|
-
);
|
|
964
|
-
});
|
|
965
|
-
|
|
966
|
-
it('is a no-op when the job is absent (idempotent)', async () => {
|
|
967
|
-
await expect(
|
|
968
|
-
releaseJobExecution({jobExecutionId: crypto.randomUUID()}),
|
|
969
|
-
).resolves.toBeUndefined();
|
|
970
|
-
});
|
|
971
|
-
|
|
972
|
-
it('releases regardless of which session holds the lease', async () => {
|
|
973
|
-
await pendingJobFactory.create({workspaceId});
|
|
974
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
975
|
-
|
|
976
|
-
// No token is passed: the workflow is authoritative over the lease.
|
|
977
|
-
await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
|
|
978
|
-
|
|
979
|
-
expect(
|
|
980
|
-
await db()
|
|
981
|
-
.select()
|
|
982
|
-
.from(runningJobExecutions)
|
|
983
|
-
.where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
|
|
984
|
-
).toHaveLength(0);
|
|
985
|
-
});
|
|
986
|
-
|
|
987
|
-
it('also sweeps a lingering pending row for the same job', async () => {
|
|
988
|
-
await pendingJobFactory.create({workspaceId});
|
|
989
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
990
|
-
// An orphan pending row left by a post-claim enqueue retry.
|
|
991
|
-
await db()
|
|
992
|
-
.insert(pendingJobExecutions)
|
|
993
|
-
.values({
|
|
994
|
-
workspaceId,
|
|
995
|
-
workflowRunId: claimed?.workflowRunId as string,
|
|
996
|
-
jobId: claimed?.jobId as string,
|
|
997
|
-
jobExecutionId: claimed?.jobExecutionId as string,
|
|
998
|
-
workflowRunAttemptId: claimed?.workflowRunAttemptId as string,
|
|
999
|
-
projectId: claimed?.projectId as string,
|
|
1000
|
-
requiredLabels: ['linux'],
|
|
1001
|
-
});
|
|
1002
|
-
|
|
1003
|
-
await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
|
|
1004
|
-
|
|
1005
|
-
expect(
|
|
1006
|
-
await db()
|
|
1007
|
-
.select()
|
|
1008
|
-
.from(runningJobExecutions)
|
|
1009
|
-
.where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
|
|
1010
|
-
).toHaveLength(0);
|
|
1011
|
-
expect(
|
|
1012
|
-
await db()
|
|
1013
|
-
.select()
|
|
1014
|
-
.from(pendingJobExecutions)
|
|
1015
|
-
.where(eq(pendingJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
|
|
1016
|
-
).toHaveLength(0);
|
|
1017
|
-
});
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
883
|
describe('recordHeartbeat', () => {
|
|
1021
884
|
let workspaceId: string;
|
|
1022
885
|
let runnerSessionId: string;
|
|
@@ -1174,6 +1037,49 @@ describe('reconcileTerminalJobExecution', () => {
|
|
|
1174
1037
|
expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
|
|
1175
1038
|
});
|
|
1176
1039
|
|
|
1040
|
+
it('releases an already-terminal runner reservation after cancelling its last lease', async () => {
|
|
1041
|
+
const provisionerId = crypto.randomUUID();
|
|
1042
|
+
const providerRunnerId = crypto.randomUUID();
|
|
1043
|
+
const [reservation] = await db()
|
|
1044
|
+
.insert(reservations)
|
|
1045
|
+
.values({
|
|
1046
|
+
workspaceId,
|
|
1047
|
+
provisionerId,
|
|
1048
|
+
requiredLabels: sessionLabels,
|
|
1049
|
+
count: 1,
|
|
1050
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
1051
|
+
})
|
|
1052
|
+
.returning({id: reservations.id});
|
|
1053
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
1054
|
+
await providerRunnerFactory.create({
|
|
1055
|
+
workspaceId,
|
|
1056
|
+
provisionerId,
|
|
1057
|
+
providerRunnerId,
|
|
1058
|
+
reservationId: reservation.id,
|
|
1059
|
+
runnerSessionId,
|
|
1060
|
+
state: 'terminated',
|
|
1061
|
+
terminatedAt: new Date(),
|
|
1062
|
+
});
|
|
1063
|
+
const pending = await pendingJobFactory.create({workspaceId});
|
|
1064
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1065
|
+
expect(claimed?.jobExecutionId).toBe(pending.jobExecutionId);
|
|
1066
|
+
await db()
|
|
1067
|
+
.update(runningJobExecutions)
|
|
1068
|
+
.set({provisionerId, providerRunnerId})
|
|
1069
|
+
.where(eq(runningJobExecutions.jobExecutionId, pending.jobExecutionId));
|
|
1070
|
+
|
|
1071
|
+
await reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
|
|
1072
|
+
|
|
1073
|
+
expect(
|
|
1074
|
+
await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
|
|
1075
|
+
).toHaveLength(0);
|
|
1076
|
+
const [runner] = await db()
|
|
1077
|
+
.select({reservationReleasedAt: providerRunners.reservationReleasedAt})
|
|
1078
|
+
.from(providerRunners)
|
|
1079
|
+
.where(eq(providerRunners.providerRunnerId, providerRunnerId));
|
|
1080
|
+
expect(runner?.reservationReleasedAt).toBeInstanceOf(Date);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1177
1083
|
it('is idempotent: second call preserves the first timestamp', async () => {
|
|
1178
1084
|
const pending = await pendingJobFactory.create({workspaceId});
|
|
1179
1085
|
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
@@ -1335,68 +1241,6 @@ async function waitForLockWait(params: {queryLike: string}) {
|
|
|
1335
1241
|
throw new Error(`Timed out waiting for lock waiter matching ${params.queryLike}`);
|
|
1336
1242
|
}
|
|
1337
1243
|
|
|
1338
|
-
describe('cancelRunnerJobs', () => {
|
|
1339
|
-
let workspaceId: string;
|
|
1340
|
-
let runnerSessionId: string;
|
|
1341
|
-
|
|
1342
|
-
beforeEach(async () => {
|
|
1343
|
-
workspaceId = crypto.randomUUID();
|
|
1344
|
-
const runnerSession = await runnerSessionFactory.create({workspaceId});
|
|
1345
|
-
runnerSessionId = runnerSession.id;
|
|
1346
|
-
});
|
|
1347
|
-
|
|
1348
|
-
it('deletes queued jobs and requests cancellation for running jobs', async () => {
|
|
1349
|
-
const running = await pendingJobFactory.create({workspaceId});
|
|
1350
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1351
|
-
const queued = await pendingJobFactory.create({workspaceId});
|
|
1352
|
-
|
|
1353
|
-
await cancelRunnerJobs({jobIds: [queued.jobId, claimed?.jobId as string]});
|
|
1354
|
-
|
|
1355
|
-
expect(
|
|
1356
|
-
await db()
|
|
1357
|
-
.select()
|
|
1358
|
-
.from(pendingJobExecutions)
|
|
1359
|
-
.where(eq(pendingJobExecutions.workspaceId, workspaceId)),
|
|
1360
|
-
).toHaveLength(0);
|
|
1361
|
-
const rows = await db()
|
|
1362
|
-
.select()
|
|
1363
|
-
.from(runningJobExecutions)
|
|
1364
|
-
.where(eq(runningJobExecutions.workspaceId, workspaceId));
|
|
1365
|
-
expect(rows).toHaveLength(1);
|
|
1366
|
-
expect(rows[0]?.jobId).toBe(running.jobId);
|
|
1367
|
-
expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
|
|
1368
|
-
});
|
|
1369
|
-
|
|
1370
|
-
it('is idempotent and no-ops for absent jobs', async () => {
|
|
1371
|
-
const queued = await pendingJobFactory.create({workspaceId});
|
|
1372
|
-
|
|
1373
|
-
await cancelRunnerJobs({jobIds: [queued.jobId, crypto.randomUUID()]});
|
|
1374
|
-
await cancelRunnerJobs({jobIds: [queued.jobId, crypto.randomUUID()]});
|
|
1375
|
-
|
|
1376
|
-
expect(
|
|
1377
|
-
await db()
|
|
1378
|
-
.select()
|
|
1379
|
-
.from(pendingJobExecutions)
|
|
1380
|
-
.where(eq(pendingJobExecutions.workspaceId, workspaceId)),
|
|
1381
|
-
).toHaveLength(0);
|
|
1382
|
-
expect(
|
|
1383
|
-
await db()
|
|
1384
|
-
.select()
|
|
1385
|
-
.from(runningJobExecutions)
|
|
1386
|
-
.where(eq(runningJobExecutions.workspaceId, workspaceId)),
|
|
1387
|
-
).toHaveLength(0);
|
|
1388
|
-
});
|
|
1389
|
-
|
|
1390
|
-
it('prevents a cancelled queued job from being claimed', async () => {
|
|
1391
|
-
const queued = await pendingJobFactory.create({workspaceId});
|
|
1392
|
-
|
|
1393
|
-
await cancelRunnerJobs({jobIds: [queued.jobId]});
|
|
1394
|
-
|
|
1395
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1396
|
-
expect(claimed).toBeNull();
|
|
1397
|
-
});
|
|
1398
|
-
});
|
|
1399
|
-
|
|
1400
1244
|
describe('detectAndExpireStuckJobs', () => {
|
|
1401
1245
|
let workspaceId: string;
|
|
1402
1246
|
let runnerSessionId: string;
|
|
@@ -1494,6 +1338,50 @@ describe('detectAndExpireStuckJobs', () => {
|
|
|
1494
1338
|
expect(payload.steps).toBeUndefined();
|
|
1495
1339
|
});
|
|
1496
1340
|
|
|
1341
|
+
it('releases a terminal runner reservation when its stuck lease is reaped', async () => {
|
|
1342
|
+
const stale = await makeStaleJob(600);
|
|
1343
|
+
const provisionerId = crypto.randomUUID();
|
|
1344
|
+
const providerRunnerId = crypto.randomUUID();
|
|
1345
|
+
const [reservation] = await db()
|
|
1346
|
+
.insert(reservations)
|
|
1347
|
+
.values({
|
|
1348
|
+
workspaceId,
|
|
1349
|
+
provisionerId,
|
|
1350
|
+
requiredLabels: ['linux'],
|
|
1351
|
+
count: 1,
|
|
1352
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
1353
|
+
})
|
|
1354
|
+
.returning();
|
|
1355
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
1356
|
+
await providerRunnerFactory.create({
|
|
1357
|
+
workspaceId,
|
|
1358
|
+
provisionerId,
|
|
1359
|
+
providerRunnerId,
|
|
1360
|
+
reservationId: reservation.id,
|
|
1361
|
+
runnerSessionId,
|
|
1362
|
+
state: 'terminated',
|
|
1363
|
+
terminatedAt: new Date(),
|
|
1364
|
+
});
|
|
1365
|
+
await db()
|
|
1366
|
+
.update(runningJobExecutions)
|
|
1367
|
+
.set({provisionerId, providerRunnerId})
|
|
1368
|
+
.where(eq(runningJobExecutions.jobExecutionId, stale.jobExecutionId));
|
|
1369
|
+
|
|
1370
|
+
await expireStuckJobExecutions({
|
|
1371
|
+
noFirstHeartbeatGraceSeconds: 60,
|
|
1372
|
+
thresholdSeconds: 180,
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
expect(
|
|
1376
|
+
await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
|
|
1377
|
+
).toHaveLength(0);
|
|
1378
|
+
const [runner] = await db()
|
|
1379
|
+
.select()
|
|
1380
|
+
.from(providerRunners)
|
|
1381
|
+
.where(eq(providerRunners.providerRunnerId, providerRunnerId));
|
|
1382
|
+
expect(runner?.reservationReleasedAt).toBeInstanceOf(Date);
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1497
1385
|
it('does not requeue an execution after its lease has expired', async () => {
|
|
1498
1386
|
const stale = await makeStaleJob(600);
|
|
1499
1387
|
|
|
@@ -1510,6 +1398,7 @@ describe('detectAndExpireStuckJobs', () => {
|
|
|
1510
1398
|
jobExecutionId: stale.jobExecutionId,
|
|
1511
1399
|
projectId: stale.projectId,
|
|
1512
1400
|
requiredLabels: ['linux'],
|
|
1401
|
+
queuedAt: new Date(),
|
|
1513
1402
|
});
|
|
1514
1403
|
|
|
1515
1404
|
expect(
|
|
@@ -1661,7 +1550,7 @@ describe('detectAndExpireStuckJobs', () => {
|
|
|
1661
1550
|
expect(await outboxForJobs([jobId])).toHaveLength(1);
|
|
1662
1551
|
});
|
|
1663
1552
|
|
|
1664
|
-
it('sweeps an orphan pending row for the job it reaps
|
|
1553
|
+
it('sweeps an orphan pending row for the job it reaps', async () => {
|
|
1665
1554
|
const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, projectId} =
|
|
1666
1555
|
await makeStaleJob(600);
|
|
1667
1556
|
// A post-claim enqueue retry left a pending row whose job is already running;
|