@shipfox/api-runners 12.7.0 → 13.1.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 -24
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +106 -83
- 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 +197 -335
- package/src/db/job-executions.ts +145 -100
- 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.1.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.1.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', () => {
|
|
@@ -538,6 +482,79 @@ describe('claimPendingJobExecution', () => {
|
|
|
538
482
|
expect(runner?.firstClaimedAt).toBeInstanceOf(Date);
|
|
539
483
|
});
|
|
540
484
|
|
|
485
|
+
it('releases a provisioned runner reservation on its first claim only', async () => {
|
|
486
|
+
const provisionerId = crypto.randomUUID();
|
|
487
|
+
const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
|
|
488
|
+
const [reservation] = await db()
|
|
489
|
+
.insert(reservations)
|
|
490
|
+
.values({
|
|
491
|
+
workspaceId,
|
|
492
|
+
provisionerId,
|
|
493
|
+
requiredLabels: sessionLabels,
|
|
494
|
+
count: 2,
|
|
495
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
496
|
+
})
|
|
497
|
+
.returning({id: reservations.id});
|
|
498
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
499
|
+
|
|
500
|
+
const runner = await providerRunnerFactory.create({
|
|
501
|
+
workspaceId,
|
|
502
|
+
provisionerId,
|
|
503
|
+
providerRunnerId,
|
|
504
|
+
reservationId: reservation.id,
|
|
505
|
+
runnerSessionId,
|
|
506
|
+
state: 'running',
|
|
507
|
+
labels: sessionLabels,
|
|
508
|
+
});
|
|
509
|
+
await db()
|
|
510
|
+
.update(runnerSessions)
|
|
511
|
+
.set({
|
|
512
|
+
registrationTokenKind: 'ephemeral',
|
|
513
|
+
maxClaims: 2,
|
|
514
|
+
provisionerId,
|
|
515
|
+
providerRunnerId,
|
|
516
|
+
})
|
|
517
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
518
|
+
|
|
519
|
+
const firstPending = await pendingJobFactory.create({workspaceId});
|
|
520
|
+
const firstClaim = await claimPendingJobExecution({
|
|
521
|
+
workspaceId,
|
|
522
|
+
runnerSessionId,
|
|
523
|
+
maxClaims: 2,
|
|
524
|
+
});
|
|
525
|
+
const [afterFirstClaim] = await db()
|
|
526
|
+
.select({count: reservations.count})
|
|
527
|
+
.from(reservations)
|
|
528
|
+
.where(eq(reservations.id, reservation.id));
|
|
529
|
+
const [runnerAfterFirstClaim] = await db()
|
|
530
|
+
.select({reservationReleasedAt: providerRunners.reservationReleasedAt})
|
|
531
|
+
.from(providerRunners)
|
|
532
|
+
.where(eq(providerRunners.id, runner.id));
|
|
533
|
+
const firstReleaseAt = runnerAfterFirstClaim?.reservationReleasedAt;
|
|
534
|
+
|
|
535
|
+
const secondPending = await pendingJobFactory.create({workspaceId});
|
|
536
|
+
const secondClaim = await claimPendingJobExecution({
|
|
537
|
+
workspaceId,
|
|
538
|
+
runnerSessionId,
|
|
539
|
+
maxClaims: 2,
|
|
540
|
+
});
|
|
541
|
+
const [afterSecondClaim] = await db()
|
|
542
|
+
.select({count: reservations.count})
|
|
543
|
+
.from(reservations)
|
|
544
|
+
.where(eq(reservations.id, reservation.id));
|
|
545
|
+
const [runnerAfterSecondClaim] = await db()
|
|
546
|
+
.select({reservationReleasedAt: providerRunners.reservationReleasedAt})
|
|
547
|
+
.from(providerRunners)
|
|
548
|
+
.where(eq(providerRunners.id, runner.id));
|
|
549
|
+
|
|
550
|
+
expect(firstClaim?.jobExecutionId).toBe(firstPending.jobExecutionId);
|
|
551
|
+
expect(afterFirstClaim?.count).toBe(1);
|
|
552
|
+
expect(firstReleaseAt).toBeInstanceOf(Date);
|
|
553
|
+
expect(secondClaim?.jobExecutionId).toBe(secondPending.jobExecutionId);
|
|
554
|
+
expect(afterSecondClaim?.count).toBe(1);
|
|
555
|
+
expect(runnerAfterSecondClaim?.reservationReleasedAt).toEqual(firstReleaseAt);
|
|
556
|
+
});
|
|
557
|
+
|
|
541
558
|
it('allows a manual session to claim repeatedly', async () => {
|
|
542
559
|
const first = await pendingJobFactory.create({workspaceId});
|
|
543
560
|
const second = await pendingJobFactory.create({workspaceId});
|
|
@@ -827,7 +844,7 @@ describe('claimPendingJobExecution', () => {
|
|
|
827
844
|
expect(running[0]?.jobId).toBe(created.jobId);
|
|
828
845
|
});
|
|
829
846
|
|
|
830
|
-
it('leaves a non-matching orphan unclaimed until
|
|
847
|
+
it('leaves a non-matching orphan unclaimed until terminal reconciliation sweeps it', async () => {
|
|
831
848
|
const created = await pendingJobFactory.create({workspaceId});
|
|
832
849
|
const first = await claimPendingJobExecution({workspaceId, runnerSessionId});
|
|
833
850
|
if (!first) throw new Error('Expected pending job to be claimed');
|
|
@@ -847,15 +864,15 @@ describe('claimPendingJobExecution', () => {
|
|
|
847
864
|
runnerSessionId,
|
|
848
865
|
sessionLabels: ['macos'],
|
|
849
866
|
});
|
|
850
|
-
await
|
|
867
|
+
await reconcileTerminalJobExecution({jobExecutionId: created.jobExecutionId});
|
|
851
868
|
|
|
852
869
|
expect(second).toBeNull();
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
).
|
|
870
|
+
const [running] = await db()
|
|
871
|
+
.select()
|
|
872
|
+
.from(runningJobExecutions)
|
|
873
|
+
.where(eq(runningJobExecutions.workspaceId, workspaceId));
|
|
874
|
+
expect(running?.jobExecutionId).toBe(created.jobExecutionId);
|
|
875
|
+
expect(running?.cancellationRequestedAt).not.toBeNull();
|
|
859
876
|
expect(
|
|
860
877
|
await db()
|
|
861
878
|
.select()
|
|
@@ -936,206 +953,6 @@ describe('claimJobExecution', () => {
|
|
|
936
953
|
});
|
|
937
954
|
});
|
|
938
955
|
|
|
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
956
|
describe('recordHeartbeat', () => {
|
|
1140
957
|
let workspaceId: string;
|
|
1141
958
|
let runnerSessionId: string;
|
|
@@ -1293,6 +1110,68 @@ describe('reconcileTerminalJobExecution', () => {
|
|
|
1293
1110
|
expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
|
|
1294
1111
|
});
|
|
1295
1112
|
|
|
1113
|
+
it('does not double-release a reservation when a claimed runner becomes terminal', async () => {
|
|
1114
|
+
const provisionerId = crypto.randomUUID();
|
|
1115
|
+
const providerRunnerId = crypto.randomUUID();
|
|
1116
|
+
const [reservation] = await db()
|
|
1117
|
+
.insert(reservations)
|
|
1118
|
+
.values({
|
|
1119
|
+
workspaceId,
|
|
1120
|
+
provisionerId,
|
|
1121
|
+
requiredLabels: sessionLabels,
|
|
1122
|
+
count: 2,
|
|
1123
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
1124
|
+
})
|
|
1125
|
+
.returning({id: reservations.id});
|
|
1126
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
1127
|
+
await providerRunnerFactory.create({
|
|
1128
|
+
workspaceId,
|
|
1129
|
+
provisionerId,
|
|
1130
|
+
providerRunnerId,
|
|
1131
|
+
reservationId: reservation.id,
|
|
1132
|
+
runnerSessionId,
|
|
1133
|
+
state: 'running',
|
|
1134
|
+
});
|
|
1135
|
+
await db()
|
|
1136
|
+
.update(runnerSessions)
|
|
1137
|
+
.set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
|
|
1138
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
1139
|
+
const pending = await pendingJobFactory.create({workspaceId});
|
|
1140
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
|
|
1141
|
+
expect(claimed?.jobExecutionId).toBe(pending.jobExecutionId);
|
|
1142
|
+
const [afterClaim] = await db()
|
|
1143
|
+
.select({count: reservations.count})
|
|
1144
|
+
.from(reservations)
|
|
1145
|
+
.where(eq(reservations.id, reservation.id));
|
|
1146
|
+
const [runnerAfterClaim] = await db()
|
|
1147
|
+
.select({reservationReleasedAt: providerRunners.reservationReleasedAt})
|
|
1148
|
+
.from(providerRunners)
|
|
1149
|
+
.where(eq(providerRunners.providerRunnerId, providerRunnerId));
|
|
1150
|
+
expect(afterClaim?.count).toBe(1);
|
|
1151
|
+
expect(runnerAfterClaim?.reservationReleasedAt).toBeInstanceOf(Date);
|
|
1152
|
+
await db()
|
|
1153
|
+
.update(providerRunners)
|
|
1154
|
+
.set({state: 'terminated', terminatedAt: new Date()})
|
|
1155
|
+
.where(eq(providerRunners.providerRunnerId, providerRunnerId));
|
|
1156
|
+
await db()
|
|
1157
|
+
.update(runningJobExecutions)
|
|
1158
|
+
.set({provisionerId, providerRunnerId})
|
|
1159
|
+
.where(eq(runningJobExecutions.jobExecutionId, pending.jobExecutionId));
|
|
1160
|
+
|
|
1161
|
+
await reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
|
|
1162
|
+
|
|
1163
|
+
const [afterTerminal] = await db()
|
|
1164
|
+
.select({count: reservations.count})
|
|
1165
|
+
.from(reservations)
|
|
1166
|
+
.where(eq(reservations.id, reservation.id));
|
|
1167
|
+
expect(afterTerminal?.count).toBe(1);
|
|
1168
|
+
const [runner] = await db()
|
|
1169
|
+
.select({reservationReleasedAt: providerRunners.reservationReleasedAt})
|
|
1170
|
+
.from(providerRunners)
|
|
1171
|
+
.where(eq(providerRunners.providerRunnerId, providerRunnerId));
|
|
1172
|
+
expect(runner?.reservationReleasedAt).toBeInstanceOf(Date);
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1296
1175
|
it('is idempotent: second call preserves the first timestamp', async () => {
|
|
1297
1176
|
const pending = await pendingJobFactory.create({workspaceId});
|
|
1298
1177
|
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
@@ -1454,68 +1333,6 @@ async function waitForLockWait(params: {queryLike: string}) {
|
|
|
1454
1333
|
throw new Error(`Timed out waiting for lock waiter matching ${params.queryLike}`);
|
|
1455
1334
|
}
|
|
1456
1335
|
|
|
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
1336
|
describe('detectAndExpireStuckJobs', () => {
|
|
1520
1337
|
let workspaceId: string;
|
|
1521
1338
|
let runnerSessionId: string;
|
|
@@ -1613,6 +1430,50 @@ describe('detectAndExpireStuckJobs', () => {
|
|
|
1613
1430
|
expect(payload.steps).toBeUndefined();
|
|
1614
1431
|
});
|
|
1615
1432
|
|
|
1433
|
+
it('releases a terminal runner reservation when its stuck lease is reaped', async () => {
|
|
1434
|
+
const stale = await makeStaleJob(600);
|
|
1435
|
+
const provisionerId = crypto.randomUUID();
|
|
1436
|
+
const providerRunnerId = crypto.randomUUID();
|
|
1437
|
+
const [reservation] = await db()
|
|
1438
|
+
.insert(reservations)
|
|
1439
|
+
.values({
|
|
1440
|
+
workspaceId,
|
|
1441
|
+
provisionerId,
|
|
1442
|
+
requiredLabels: ['linux'],
|
|
1443
|
+
count: 1,
|
|
1444
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
1445
|
+
})
|
|
1446
|
+
.returning();
|
|
1447
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
1448
|
+
await providerRunnerFactory.create({
|
|
1449
|
+
workspaceId,
|
|
1450
|
+
provisionerId,
|
|
1451
|
+
providerRunnerId,
|
|
1452
|
+
reservationId: reservation.id,
|
|
1453
|
+
runnerSessionId,
|
|
1454
|
+
state: 'terminated',
|
|
1455
|
+
terminatedAt: new Date(),
|
|
1456
|
+
});
|
|
1457
|
+
await db()
|
|
1458
|
+
.update(runningJobExecutions)
|
|
1459
|
+
.set({provisionerId, providerRunnerId})
|
|
1460
|
+
.where(eq(runningJobExecutions.jobExecutionId, stale.jobExecutionId));
|
|
1461
|
+
|
|
1462
|
+
await expireStuckJobExecutions({
|
|
1463
|
+
noFirstHeartbeatGraceSeconds: 60,
|
|
1464
|
+
thresholdSeconds: 180,
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
expect(
|
|
1468
|
+
await db().select().from(reservations).where(eq(reservations.id, reservation.id)),
|
|
1469
|
+
).toHaveLength(0);
|
|
1470
|
+
const [runner] = await db()
|
|
1471
|
+
.select()
|
|
1472
|
+
.from(providerRunners)
|
|
1473
|
+
.where(eq(providerRunners.providerRunnerId, providerRunnerId));
|
|
1474
|
+
expect(runner?.reservationReleasedAt).toBeInstanceOf(Date);
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1616
1477
|
it('does not requeue an execution after its lease has expired', async () => {
|
|
1617
1478
|
const stale = await makeStaleJob(600);
|
|
1618
1479
|
|
|
@@ -1629,6 +1490,7 @@ describe('detectAndExpireStuckJobs', () => {
|
|
|
1629
1490
|
jobExecutionId: stale.jobExecutionId,
|
|
1630
1491
|
projectId: stale.projectId,
|
|
1631
1492
|
requiredLabels: ['linux'],
|
|
1493
|
+
queuedAt: new Date(),
|
|
1632
1494
|
});
|
|
1633
1495
|
|
|
1634
1496
|
expect(
|
|
@@ -1780,7 +1642,7 @@ describe('detectAndExpireStuckJobs', () => {
|
|
|
1780
1642
|
expect(await outboxForJobs([jobId])).toHaveLength(1);
|
|
1781
1643
|
});
|
|
1782
1644
|
|
|
1783
|
-
it('sweeps an orphan pending row for the job it reaps
|
|
1645
|
+
it('sweeps an orphan pending row for the job it reaps', async () => {
|
|
1784
1646
|
const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, projectId} =
|
|
1785
1647
|
await makeStaleJob(600);
|
|
1786
1648
|
// A post-claim enqueue retry left a pending row whose job is already running;
|