@shipfox/api-runners 12.7.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 +12 -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 -24
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +62 -81
- package/dist/db/job-executions.js.map +1 -1
- package/dist/db/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +6 -4
- package/dist/db/reservations.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 +105 -335
- package/src/db/job-executions.ts +70 -91
- package/src/db/reservations.ts +6 -3
- package/src/db/runner-instances.test.ts +100 -0
- 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,7 +21,6 @@ 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';
|
|
@@ -70,6 +65,7 @@ describe('enqueueJobExecution', () => {
|
|
|
70
65
|
const workflowRunAttemptId = crypto.randomUUID();
|
|
71
66
|
const workspaceId = crypto.randomUUID();
|
|
72
67
|
const projectId = crypto.randomUUID();
|
|
68
|
+
const queuedAt = new Date('2026-08-11T08:00:00.000Z');
|
|
73
69
|
|
|
74
70
|
await enqueueJobExecution({
|
|
75
71
|
workspaceId,
|
|
@@ -79,6 +75,7 @@ describe('enqueueJobExecution', () => {
|
|
|
79
75
|
workflowRunAttemptId,
|
|
80
76
|
projectId,
|
|
81
77
|
requiredLabels: ['linux'],
|
|
78
|
+
queuedAt,
|
|
82
79
|
});
|
|
83
80
|
|
|
84
81
|
const rows = await db()
|
|
@@ -93,6 +90,7 @@ describe('enqueueJobExecution', () => {
|
|
|
93
90
|
expect(rows[0]?.projectId).toBe(projectId);
|
|
94
91
|
expect(rows[0]?.workspaceId).toBe(workspaceId);
|
|
95
92
|
expect(rows[0]?.requiredLabels).toEqual(['linux']);
|
|
93
|
+
expect(rows[0]?.createdAt).toEqual(queuedAt);
|
|
96
94
|
expect(rows[0]).not.toHaveProperty('payload');
|
|
97
95
|
});
|
|
98
96
|
|
|
@@ -108,6 +106,7 @@ describe('enqueueJobExecution', () => {
|
|
|
108
106
|
workflowRunAttemptId: crypto.randomUUID(),
|
|
109
107
|
projectId: crypto.randomUUID(),
|
|
110
108
|
requiredLabels: ['Ubuntu22', ' ubuntu22 ', 'LINUX'],
|
|
109
|
+
queuedAt: new Date(),
|
|
111
110
|
});
|
|
112
111
|
|
|
113
112
|
const rows = await db()
|
|
@@ -127,6 +126,7 @@ describe('enqueueJobExecution', () => {
|
|
|
127
126
|
workflowRunAttemptId: crypto.randomUUID(),
|
|
128
127
|
projectId: crypto.randomUUID(),
|
|
129
128
|
requiredLabels: [],
|
|
129
|
+
queuedAt: new Date(),
|
|
130
130
|
}),
|
|
131
131
|
).rejects.toBeInstanceOf(EmptyRequiredLabelsError);
|
|
132
132
|
});
|
|
@@ -141,6 +141,7 @@ describe('enqueueJobExecution', () => {
|
|
|
141
141
|
jobId,
|
|
142
142
|
jobExecutionId: crypto.randomUUID(),
|
|
143
143
|
requiredLabels: ['linux'],
|
|
144
|
+
queuedAt: new Date(),
|
|
144
145
|
};
|
|
145
146
|
|
|
146
147
|
await enqueueJobExecution(params);
|
|
@@ -152,63 +153,6 @@ describe('enqueueJobExecution', () => {
|
|
|
152
153
|
.where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
|
|
153
154
|
expect(rows).toHaveLength(1);
|
|
154
155
|
});
|
|
155
|
-
|
|
156
|
-
it('emits runners.job.queued carrying the pending row created_at', async () => {
|
|
157
|
-
const jobId = crypto.randomUUID();
|
|
158
|
-
const workflowRunId = crypto.randomUUID();
|
|
159
|
-
const workflowRunAttemptId = crypto.randomUUID();
|
|
160
|
-
|
|
161
|
-
await enqueueJobExecution({
|
|
162
|
-
workspaceId: crypto.randomUUID(),
|
|
163
|
-
workflowRunId,
|
|
164
|
-
jobId,
|
|
165
|
-
jobExecutionId: crypto.randomUUID(),
|
|
166
|
-
workflowRunAttemptId,
|
|
167
|
-
projectId: crypto.randomUUID(),
|
|
168
|
-
requiredLabels: ['linux'],
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
const [pending] = await db()
|
|
172
|
-
.select()
|
|
173
|
-
.from(pendingJobExecutions)
|
|
174
|
-
.where(eq(pendingJobExecutions.jobId, jobId));
|
|
175
|
-
const outbox = await outboxEventsForJob(RUNNER_JOB_QUEUED, jobId);
|
|
176
|
-
expect(outbox).toHaveLength(1);
|
|
177
|
-
expect(outbox[0]?.eventType).toBe(RUNNER_JOB_QUEUED);
|
|
178
|
-
const payload = outbox[0]?.payload as {
|
|
179
|
-
jobId: string;
|
|
180
|
-
workflowRunId: string;
|
|
181
|
-
workflowRunAttemptId: string;
|
|
182
|
-
queuedAt: string;
|
|
183
|
-
};
|
|
184
|
-
expect(payload.jobId).toBe(jobId);
|
|
185
|
-
expect(payload.workflowRunId).toBe(workflowRunId);
|
|
186
|
-
expect(payload.workflowRunAttemptId).toBe(workflowRunAttemptId);
|
|
187
|
-
expect(new Date(payload.queuedAt).getTime()).toBe(pending?.createdAt.getTime());
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('does not double-emit queued when the same jobId is re-enqueued (idempotency regression)', async () => {
|
|
191
|
-
const params = {
|
|
192
|
-
workspaceId: crypto.randomUUID(),
|
|
193
|
-
workflowRunId: crypto.randomUUID(),
|
|
194
|
-
workflowRunAttemptId: crypto.randomUUID(),
|
|
195
|
-
projectId: crypto.randomUUID(),
|
|
196
|
-
jobId: crypto.randomUUID(),
|
|
197
|
-
jobExecutionId: crypto.randomUUID(),
|
|
198
|
-
requiredLabels: ['linux'],
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
await enqueueJobExecution(params);
|
|
202
|
-
await enqueueJobExecution(params);
|
|
203
|
-
|
|
204
|
-
expect(
|
|
205
|
-
await db()
|
|
206
|
-
.select()
|
|
207
|
-
.from(pendingJobExecutions)
|
|
208
|
-
.where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId)),
|
|
209
|
-
).toHaveLength(1);
|
|
210
|
-
expect(await outboxEventsForJob(RUNNER_JOB_QUEUED, params.jobId)).toHaveLength(1);
|
|
211
|
-
});
|
|
212
156
|
});
|
|
213
157
|
|
|
214
158
|
describe('claimPendingJobExecution', () => {
|
|
@@ -827,7 +771,7 @@ describe('claimPendingJobExecution', () => {
|
|
|
827
771
|
expect(running[0]?.jobId).toBe(created.jobId);
|
|
828
772
|
});
|
|
829
773
|
|
|
830
|
-
it('leaves a non-matching orphan unclaimed until
|
|
774
|
+
it('leaves a non-matching orphan unclaimed until terminal reconciliation sweeps it', async () => {
|
|
831
775
|
const created = await pendingJobFactory.create({workspaceId});
|
|
832
776
|
const first = await claimPendingJobExecution({workspaceId, runnerSessionId});
|
|
833
777
|
if (!first) throw new Error('Expected pending job to be claimed');
|
|
@@ -847,15 +791,15 @@ describe('claimPendingJobExecution', () => {
|
|
|
847
791
|
runnerSessionId,
|
|
848
792
|
sessionLabels: ['macos'],
|
|
849
793
|
});
|
|
850
|
-
await
|
|
794
|
+
await reconcileTerminalJobExecution({jobExecutionId: created.jobExecutionId});
|
|
851
795
|
|
|
852
796
|
expect(second).toBeNull();
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
).
|
|
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();
|
|
859
803
|
expect(
|
|
860
804
|
await db()
|
|
861
805
|
.select()
|
|
@@ -936,206 +880,6 @@ describe('claimJobExecution', () => {
|
|
|
936
880
|
});
|
|
937
881
|
});
|
|
938
882
|
|
|
939
|
-
describe('releaseJobExecution', () => {
|
|
940
|
-
let workspaceId: string;
|
|
941
|
-
let runnerSessionId: string;
|
|
942
|
-
|
|
943
|
-
beforeEach(async () => {
|
|
944
|
-
workspaceId = crypto.randomUUID();
|
|
945
|
-
const runnerSession = await runnerSessionFactory.create({workspaceId});
|
|
946
|
-
runnerSessionId = runnerSession.id;
|
|
947
|
-
});
|
|
948
|
-
|
|
949
|
-
it('deletes the running row and writes no outbox event', async () => {
|
|
950
|
-
await pendingJobFactory.create({workspaceId});
|
|
951
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
952
|
-
const before = await outboxEventsForJob(RUNNER_JOB_CLAIMED, claimed?.jobId as string);
|
|
953
|
-
|
|
954
|
-
await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
|
|
955
|
-
|
|
956
|
-
expect(
|
|
957
|
-
await db()
|
|
958
|
-
.select()
|
|
959
|
-
.from(runningJobExecutions)
|
|
960
|
-
.where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
|
|
961
|
-
).toHaveLength(0);
|
|
962
|
-
expect(await outboxEventsForJob(RUNNER_JOB_CLAIMED, claimed?.jobId as string)).toHaveLength(
|
|
963
|
-
before.length,
|
|
964
|
-
);
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
it('is a no-op when the job is absent (idempotent)', async () => {
|
|
968
|
-
await expect(
|
|
969
|
-
releaseJobExecution({jobExecutionId: crypto.randomUUID()}),
|
|
970
|
-
).resolves.toBeUndefined();
|
|
971
|
-
});
|
|
972
|
-
|
|
973
|
-
it('releases a terminal runner reservation after deleting its final lease', async () => {
|
|
974
|
-
const provisionerId = crypto.randomUUID();
|
|
975
|
-
const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
|
|
976
|
-
const [reservation] = await db()
|
|
977
|
-
.insert(reservations)
|
|
978
|
-
.values({
|
|
979
|
-
workspaceId,
|
|
980
|
-
provisionerId,
|
|
981
|
-
requiredLabels: sessionLabels,
|
|
982
|
-
count: 1,
|
|
983
|
-
expiresAt: new Date(Date.now() + 60_000),
|
|
984
|
-
})
|
|
985
|
-
.returning({id: reservations.id});
|
|
986
|
-
if (!reservation) throw new Error('Expected reservation');
|
|
987
|
-
|
|
988
|
-
await db()
|
|
989
|
-
.update(runnerSessions)
|
|
990
|
-
.set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
|
|
991
|
-
.where(eq(runnerSessions.id, runnerSessionId));
|
|
992
|
-
await db().insert(providerRunners).values({
|
|
993
|
-
workspaceId,
|
|
994
|
-
provisionerId,
|
|
995
|
-
providerRunnerId,
|
|
996
|
-
reservationId: reservation.id,
|
|
997
|
-
runnerSessionId,
|
|
998
|
-
state: 'terminated',
|
|
999
|
-
reportedAt: new Date(),
|
|
1000
|
-
terminatedAt: new Date(),
|
|
1001
|
-
labels: sessionLabels,
|
|
1002
|
-
});
|
|
1003
|
-
|
|
1004
|
-
const created = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
1005
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
|
|
1006
|
-
expect(claimed?.jobExecutionId).toBe(created.jobExecutionId);
|
|
1007
|
-
|
|
1008
|
-
await releaseJobExecution({jobExecutionId: created.jobExecutionId});
|
|
1009
|
-
|
|
1010
|
-
expect(
|
|
1011
|
-
await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
|
|
1012
|
-
).toHaveLength(0);
|
|
1013
|
-
const [runner] = await db()
|
|
1014
|
-
.select({reservationReleasedAt: providerRunners.reservationReleasedAt})
|
|
1015
|
-
.from(providerRunners)
|
|
1016
|
-
.where(eq(providerRunners.providerRunnerId, providerRunnerId));
|
|
1017
|
-
expect(runner?.reservationReleasedAt).toEqual(expect.any(Date));
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
it('rechecks terminal state after a concurrent terminal projection commits', async () => {
|
|
1021
|
-
const provisionerId = crypto.randomUUID();
|
|
1022
|
-
const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
|
|
1023
|
-
const [reservation] = await db()
|
|
1024
|
-
.insert(reservations)
|
|
1025
|
-
.values({
|
|
1026
|
-
workspaceId,
|
|
1027
|
-
provisionerId,
|
|
1028
|
-
requiredLabels: sessionLabels,
|
|
1029
|
-
count: 1,
|
|
1030
|
-
expiresAt: new Date(Date.now() + 60_000),
|
|
1031
|
-
})
|
|
1032
|
-
.returning({id: reservations.id});
|
|
1033
|
-
if (!reservation) throw new Error('Expected reservation');
|
|
1034
|
-
|
|
1035
|
-
await db()
|
|
1036
|
-
.update(runnerSessions)
|
|
1037
|
-
.set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
|
|
1038
|
-
.where(eq(runnerSessions.id, runnerSessionId));
|
|
1039
|
-
await db().insert(providerRunners).values({
|
|
1040
|
-
workspaceId,
|
|
1041
|
-
provisionerId,
|
|
1042
|
-
providerRunnerId,
|
|
1043
|
-
reservationId: reservation.id,
|
|
1044
|
-
runnerSessionId,
|
|
1045
|
-
state: 'running',
|
|
1046
|
-
reportedAt: new Date(),
|
|
1047
|
-
labels: sessionLabels,
|
|
1048
|
-
});
|
|
1049
|
-
|
|
1050
|
-
const created = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
1051
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
|
|
1052
|
-
expect(claimed?.jobExecutionId).toBe(created.jobExecutionId);
|
|
1053
|
-
|
|
1054
|
-
const terminalProjectionReady = deferred<void>();
|
|
1055
|
-
const releaseTerminalProjection = deferred<void>();
|
|
1056
|
-
const terminalProjection = db().transaction(async (tx) => {
|
|
1057
|
-
await tx
|
|
1058
|
-
.update(providerRunners)
|
|
1059
|
-
.set({state: 'failed', failedAt: new Date(), updatedAt: new Date()})
|
|
1060
|
-
.where(eq(providerRunners.providerRunnerId, providerRunnerId));
|
|
1061
|
-
terminalProjectionReady.resolve();
|
|
1062
|
-
await releaseTerminalProjection.promise;
|
|
1063
|
-
});
|
|
1064
|
-
|
|
1065
|
-
await terminalProjectionReady.promise;
|
|
1066
|
-
const cleanup = releaseJobExecution({jobExecutionId: created.jobExecutionId});
|
|
1067
|
-
try {
|
|
1068
|
-
await waitForLockWait({queryLike: '%runner_instances%'});
|
|
1069
|
-
|
|
1070
|
-
expect(
|
|
1071
|
-
await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
|
|
1072
|
-
).toHaveLength(1);
|
|
1073
|
-
} finally {
|
|
1074
|
-
releaseTerminalProjection.resolve();
|
|
1075
|
-
await Promise.all([terminalProjection, cleanup]);
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
expect(
|
|
1079
|
-
await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
|
|
1080
|
-
).toHaveLength(0);
|
|
1081
|
-
const [runner] = await db()
|
|
1082
|
-
.select({
|
|
1083
|
-
reservationReleasedAt: providerRunners.reservationReleasedAt,
|
|
1084
|
-
state: providerRunners.state,
|
|
1085
|
-
})
|
|
1086
|
-
.from(providerRunners)
|
|
1087
|
-
.where(eq(providerRunners.providerRunnerId, providerRunnerId));
|
|
1088
|
-
expect(runner).toMatchObject({state: 'failed', reservationReleasedAt: expect.any(Date)});
|
|
1089
|
-
});
|
|
1090
|
-
|
|
1091
|
-
it('releases regardless of which session holds the lease', async () => {
|
|
1092
|
-
await pendingJobFactory.create({workspaceId});
|
|
1093
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1094
|
-
|
|
1095
|
-
// No token is passed: the workflow is authoritative over the lease.
|
|
1096
|
-
await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
|
|
1097
|
-
|
|
1098
|
-
expect(
|
|
1099
|
-
await db()
|
|
1100
|
-
.select()
|
|
1101
|
-
.from(runningJobExecutions)
|
|
1102
|
-
.where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
|
|
1103
|
-
).toHaveLength(0);
|
|
1104
|
-
});
|
|
1105
|
-
|
|
1106
|
-
it('also sweeps a lingering pending row for the same job', async () => {
|
|
1107
|
-
await pendingJobFactory.create({workspaceId});
|
|
1108
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1109
|
-
// An orphan pending row left by a post-claim enqueue retry.
|
|
1110
|
-
await db()
|
|
1111
|
-
.insert(pendingJobExecutions)
|
|
1112
|
-
.values({
|
|
1113
|
-
workspaceId,
|
|
1114
|
-
workflowRunId: claimed?.workflowRunId as string,
|
|
1115
|
-
jobId: claimed?.jobId as string,
|
|
1116
|
-
jobExecutionId: claimed?.jobExecutionId as string,
|
|
1117
|
-
workflowRunAttemptId: claimed?.workflowRunAttemptId as string,
|
|
1118
|
-
projectId: claimed?.projectId as string,
|
|
1119
|
-
requiredLabels: ['linux'],
|
|
1120
|
-
});
|
|
1121
|
-
|
|
1122
|
-
await releaseJobExecution({jobExecutionId: claimed?.jobExecutionId as string});
|
|
1123
|
-
|
|
1124
|
-
expect(
|
|
1125
|
-
await db()
|
|
1126
|
-
.select()
|
|
1127
|
-
.from(runningJobExecutions)
|
|
1128
|
-
.where(eq(runningJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
|
|
1129
|
-
).toHaveLength(0);
|
|
1130
|
-
expect(
|
|
1131
|
-
await db()
|
|
1132
|
-
.select()
|
|
1133
|
-
.from(pendingJobExecutions)
|
|
1134
|
-
.where(eq(pendingJobExecutions.jobExecutionId, claimed?.jobExecutionId as string)),
|
|
1135
|
-
).toHaveLength(0);
|
|
1136
|
-
});
|
|
1137
|
-
});
|
|
1138
|
-
|
|
1139
883
|
describe('recordHeartbeat', () => {
|
|
1140
884
|
let workspaceId: string;
|
|
1141
885
|
let runnerSessionId: string;
|
|
@@ -1293,6 +1037,49 @@ describe('reconcileTerminalJobExecution', () => {
|
|
|
1293
1037
|
expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
|
|
1294
1038
|
});
|
|
1295
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
|
+
|
|
1296
1083
|
it('is idempotent: second call preserves the first timestamp', async () => {
|
|
1297
1084
|
const pending = await pendingJobFactory.create({workspaceId});
|
|
1298
1085
|
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
@@ -1454,68 +1241,6 @@ async function waitForLockWait(params: {queryLike: string}) {
|
|
|
1454
1241
|
throw new Error(`Timed out waiting for lock waiter matching ${params.queryLike}`);
|
|
1455
1242
|
}
|
|
1456
1243
|
|
|
1457
|
-
describe('cancelRunnerJobs', () => {
|
|
1458
|
-
let workspaceId: string;
|
|
1459
|
-
let runnerSessionId: string;
|
|
1460
|
-
|
|
1461
|
-
beforeEach(async () => {
|
|
1462
|
-
workspaceId = crypto.randomUUID();
|
|
1463
|
-
const runnerSession = await runnerSessionFactory.create({workspaceId});
|
|
1464
|
-
runnerSessionId = runnerSession.id;
|
|
1465
|
-
});
|
|
1466
|
-
|
|
1467
|
-
it('deletes queued jobs and requests cancellation for running jobs', async () => {
|
|
1468
|
-
const running = await pendingJobFactory.create({workspaceId});
|
|
1469
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1470
|
-
const queued = await pendingJobFactory.create({workspaceId});
|
|
1471
|
-
|
|
1472
|
-
await cancelRunnerJobs({jobIds: [queued.jobId, claimed?.jobId as string]});
|
|
1473
|
-
|
|
1474
|
-
expect(
|
|
1475
|
-
await db()
|
|
1476
|
-
.select()
|
|
1477
|
-
.from(pendingJobExecutions)
|
|
1478
|
-
.where(eq(pendingJobExecutions.workspaceId, workspaceId)),
|
|
1479
|
-
).toHaveLength(0);
|
|
1480
|
-
const rows = await db()
|
|
1481
|
-
.select()
|
|
1482
|
-
.from(runningJobExecutions)
|
|
1483
|
-
.where(eq(runningJobExecutions.workspaceId, workspaceId));
|
|
1484
|
-
expect(rows).toHaveLength(1);
|
|
1485
|
-
expect(rows[0]?.jobId).toBe(running.jobId);
|
|
1486
|
-
expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
|
|
1487
|
-
});
|
|
1488
|
-
|
|
1489
|
-
it('is idempotent and no-ops for absent jobs', async () => {
|
|
1490
|
-
const queued = await pendingJobFactory.create({workspaceId});
|
|
1491
|
-
|
|
1492
|
-
await cancelRunnerJobs({jobIds: [queued.jobId, crypto.randomUUID()]});
|
|
1493
|
-
await cancelRunnerJobs({jobIds: [queued.jobId, crypto.randomUUID()]});
|
|
1494
|
-
|
|
1495
|
-
expect(
|
|
1496
|
-
await db()
|
|
1497
|
-
.select()
|
|
1498
|
-
.from(pendingJobExecutions)
|
|
1499
|
-
.where(eq(pendingJobExecutions.workspaceId, workspaceId)),
|
|
1500
|
-
).toHaveLength(0);
|
|
1501
|
-
expect(
|
|
1502
|
-
await db()
|
|
1503
|
-
.select()
|
|
1504
|
-
.from(runningJobExecutions)
|
|
1505
|
-
.where(eq(runningJobExecutions.workspaceId, workspaceId)),
|
|
1506
|
-
).toHaveLength(0);
|
|
1507
|
-
});
|
|
1508
|
-
|
|
1509
|
-
it('prevents a cancelled queued job from being claimed', async () => {
|
|
1510
|
-
const queued = await pendingJobFactory.create({workspaceId});
|
|
1511
|
-
|
|
1512
|
-
await cancelRunnerJobs({jobIds: [queued.jobId]});
|
|
1513
|
-
|
|
1514
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
1515
|
-
expect(claimed).toBeNull();
|
|
1516
|
-
});
|
|
1517
|
-
});
|
|
1518
|
-
|
|
1519
1244
|
describe('detectAndExpireStuckJobs', () => {
|
|
1520
1245
|
let workspaceId: string;
|
|
1521
1246
|
let runnerSessionId: string;
|
|
@@ -1613,6 +1338,50 @@ describe('detectAndExpireStuckJobs', () => {
|
|
|
1613
1338
|
expect(payload.steps).toBeUndefined();
|
|
1614
1339
|
});
|
|
1615
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
|
+
|
|
1616
1385
|
it('does not requeue an execution after its lease has expired', async () => {
|
|
1617
1386
|
const stale = await makeStaleJob(600);
|
|
1618
1387
|
|
|
@@ -1629,6 +1398,7 @@ describe('detectAndExpireStuckJobs', () => {
|
|
|
1629
1398
|
jobExecutionId: stale.jobExecutionId,
|
|
1630
1399
|
projectId: stale.projectId,
|
|
1631
1400
|
requiredLabels: ['linux'],
|
|
1401
|
+
queuedAt: new Date(),
|
|
1632
1402
|
});
|
|
1633
1403
|
|
|
1634
1404
|
expect(
|
|
@@ -1780,7 +1550,7 @@ describe('detectAndExpireStuckJobs', () => {
|
|
|
1780
1550
|
expect(await outboxForJobs([jobId])).toHaveLength(1);
|
|
1781
1551
|
});
|
|
1782
1552
|
|
|
1783
|
-
it('sweeps an orphan pending row for the job it reaps
|
|
1553
|
+
it('sweeps an orphan pending row for the job it reaps', async () => {
|
|
1784
1554
|
const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, projectId} =
|
|
1785
1555
|
await makeStaleJob(600);
|
|
1786
1556
|
// A post-claim enqueue retry left a pending row whose job is already running;
|