@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/src/db/job-executions.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
RUNNER_JOB_CLAIMED,
|
|
3
3
|
RUNNER_JOB_LEASE_EXPIRED,
|
|
4
|
-
RUNNER_JOB_QUEUED,
|
|
5
4
|
type RunnersEventMap,
|
|
6
5
|
type RunnerToolCapabilitiesDto,
|
|
7
6
|
} from '@shipfox/api-runners-dto';
|
|
@@ -39,6 +38,7 @@ import {
|
|
|
39
38
|
} from '#metrics/instance.js';
|
|
40
39
|
import type {Tx} from './db.js';
|
|
41
40
|
import {db} from './db.js';
|
|
41
|
+
import {releaseTerminalRunnerInstanceReservationsByIds} from './reservations.js';
|
|
42
42
|
import {runnersOutbox} from './schema/outbox.js';
|
|
43
43
|
import {pendingJobExecutions} from './schema/pending-job-executions.js';
|
|
44
44
|
import {providerRunners} from './schema/runner-instances.js';
|
|
@@ -53,6 +53,39 @@ async function lockJobExecution(tx: Tx, jobExecutionId: string): Promise<void> {
|
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
async function releaseReservationsForTerminalRunningRows(
|
|
57
|
+
tx: Tx,
|
|
58
|
+
rows: ReadonlyArray<{
|
|
59
|
+
provisionerId: string | null;
|
|
60
|
+
providerRunnerId: string | null;
|
|
61
|
+
}>,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
// Converge after the lease fact changes. The reservation helper locks each provider runner
|
|
64
|
+
// and releases only when the runner is terminal and no uncancelled lease remains.
|
|
65
|
+
const providerRunnerIdsByProvisionerId = new Map<string, Set<string>>();
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
if (row.provisionerId === null || row.providerRunnerId === null) continue;
|
|
68
|
+
const providerRunnerIds =
|
|
69
|
+
providerRunnerIdsByProvisionerId.get(row.provisionerId) ?? new Set<string>();
|
|
70
|
+
providerRunnerIds.add(row.providerRunnerId);
|
|
71
|
+
providerRunnerIdsByProvisionerId.set(row.provisionerId, providerRunnerIds);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const provisionerId of [...providerRunnerIdsByProvisionerId.keys()].sort()) {
|
|
75
|
+
const providerRunnerIds = providerRunnerIdsByProvisionerId.get(provisionerId);
|
|
76
|
+
if (!providerRunnerIds) continue;
|
|
77
|
+
await releaseTerminalRunnerInstanceReservationsByIds(tx, {
|
|
78
|
+
workspaceId: null,
|
|
79
|
+
provisionerId,
|
|
80
|
+
providerRunnerIds: [...providerRunnerIds].sort(),
|
|
81
|
+
requireUnlinkedSession: false,
|
|
82
|
+
// Lock and re-check the runner row locally so lease finalization remains retryable
|
|
83
|
+
// without a workflow/runner scope lock.
|
|
84
|
+
requireTerminalState: false,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
56
89
|
export interface EnqueueJobExecutionParams {
|
|
57
90
|
workspaceId: string;
|
|
58
91
|
workflowRunId: string;
|
|
@@ -61,6 +94,7 @@ export interface EnqueueJobExecutionParams {
|
|
|
61
94
|
jobExecutionId: string;
|
|
62
95
|
projectId: string;
|
|
63
96
|
requiredLabels: string[];
|
|
97
|
+
queuedAt: Date;
|
|
64
98
|
}
|
|
65
99
|
|
|
66
100
|
export async function getWorkspaceJobCounts(params: {
|
|
@@ -91,11 +125,9 @@ export async function getWorkspaceJobCounts(params: {
|
|
|
91
125
|
}));
|
|
92
126
|
}
|
|
93
127
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// The per-execution advisory lock serializes retries with lease expiry/reconciliation, and the
|
|
98
|
-
// durable lease-expired event prevents a retry from re-queueing an execution already reaped.
|
|
128
|
+
// The workflows outbox delivers queue facts at least once. The per-execution advisory lock and
|
|
129
|
+
// pending/running checks make replay a no-op across the claim transition. A durable lease-expired
|
|
130
|
+
// event also prevents a delayed replay from resurrecting an execution already reaped locally.
|
|
99
131
|
export async function enqueueJobExecution(params: EnqueueJobExecutionParams): Promise<void> {
|
|
100
132
|
const requiredLabels = [...canonicalizeLabels(params.requiredLabels)];
|
|
101
133
|
if (requiredLabels.length === 0) throw new EmptyRequiredLabelsError();
|
|
@@ -103,6 +135,13 @@ export async function enqueueJobExecution(params: EnqueueJobExecutionParams): Pr
|
|
|
103
135
|
const enqueued = await db().transaction(async (tx) => {
|
|
104
136
|
await lockJobExecution(tx, params.jobExecutionId);
|
|
105
137
|
|
|
138
|
+
const [running] = await tx
|
|
139
|
+
.select({jobExecutionId: runningJobExecutions.jobExecutionId})
|
|
140
|
+
.from(runningJobExecutions)
|
|
141
|
+
.where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId))
|
|
142
|
+
.limit(1);
|
|
143
|
+
if (running) return false;
|
|
144
|
+
|
|
106
145
|
const [leaseExpired] = await tx
|
|
107
146
|
.select({id: runnersOutbox.id})
|
|
108
147
|
.from(runnersOutbox)
|
|
@@ -125,26 +164,12 @@ export async function enqueueJobExecution(params: EnqueueJobExecutionParams): Pr
|
|
|
125
164
|
jobExecutionId: params.jobExecutionId,
|
|
126
165
|
projectId: params.projectId,
|
|
127
166
|
requiredLabels,
|
|
167
|
+
createdAt: params.queuedAt,
|
|
128
168
|
})
|
|
129
169
|
.onConflictDoNothing({target: pendingJobExecutions.jobExecutionId})
|
|
130
170
|
.returning({createdAt: pendingJobExecutions.createdAt});
|
|
131
171
|
|
|
132
|
-
|
|
133
|
-
// emitted the queued event (durably, in the outbox), so re-emitting would
|
|
134
|
-
// only add a redundant row the subscriber coalesces away. Skip it.
|
|
135
|
-
if (!inserted) return false;
|
|
136
|
-
|
|
137
|
-
await writeOutboxEvent<RunnersEventMap>(tx, runnersOutbox, {
|
|
138
|
-
type: RUNNER_JOB_QUEUED,
|
|
139
|
-
payload: {
|
|
140
|
-
workflowRunId: params.workflowRunId,
|
|
141
|
-
workflowRunAttemptId: params.workflowRunAttemptId,
|
|
142
|
-
jobId: params.jobId,
|
|
143
|
-
jobExecutionId: params.jobExecutionId,
|
|
144
|
-
queuedAt: inserted.createdAt.toISOString(),
|
|
145
|
-
},
|
|
146
|
-
});
|
|
147
|
-
return true;
|
|
172
|
+
return inserted !== undefined;
|
|
148
173
|
});
|
|
149
174
|
|
|
150
175
|
if (enqueued) jobExecutionEnqueuedCount.add(1);
|
|
@@ -402,43 +427,18 @@ async function touchRunnerSessionLiveness(params: {
|
|
|
402
427
|
);
|
|
403
428
|
}
|
|
404
429
|
|
|
405
|
-
/**
|
|
406
|
-
* Releases a job execution's lease when the orchestration workflow finalizes it: deletes the
|
|
407
|
-
* running-job-execution row AND any lingering pending row for the same execution, in one tx.
|
|
408
|
-
* Idempotent (0-row no-op), no token scope (the workflow is authoritative), and
|
|
409
|
-
* emits no event: the workflow already owns the outcome. Sweeping the pending row
|
|
410
|
-
* too closes the at-least-once window where an enqueue retry left an orphan that a
|
|
411
|
-
* later claim would otherwise pick up for an already-finished job execution.
|
|
412
|
-
*/
|
|
413
|
-
export async function releaseJobExecution(params: {jobExecutionId: string}): Promise<void> {
|
|
414
|
-
await db().transaction(async (tx) => {
|
|
415
|
-
await lockJobExecution(tx, params.jobExecutionId);
|
|
416
|
-
|
|
417
|
-
// Delete pending before running to match `claimPendingJobExecution`'s lock-acquisition
|
|
418
|
-
// order (it locks the pending row first, then the running row). A concurrent
|
|
419
|
-
// claim picking up an orphan pending row for this same job execution would otherwise
|
|
420
|
-
// deadlock against the reverse order here.
|
|
421
|
-
await tx
|
|
422
|
-
.delete(pendingJobExecutions)
|
|
423
|
-
.where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
|
|
424
|
-
await tx
|
|
425
|
-
.delete(runningJobExecutions)
|
|
426
|
-
.where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId));
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
|
|
430
430
|
/**
|
|
431
431
|
* Reaps stale leases (bounded by `limit`), emitting one
|
|
432
432
|
* `runners.job.lease_expired` event per reaped job execution.
|
|
433
433
|
*
|
|
434
434
|
* The cutoff is re-checked in the DELETE, not just the locking subquery, so a
|
|
435
435
|
* heartbeat landing mid-call spares the live row. Each reaped execution also sweeps its
|
|
436
|
-
* pending row
|
|
437
|
-
* that a later claim re-runs as an already-finished job execution.
|
|
436
|
+
* pending row so an at-least-once queue replay cannot leave an orphan.
|
|
438
437
|
*
|
|
439
|
-
* Locks pending-then-running to match `claimPendingJobExecution
|
|
440
|
-
* `reconcileTerminalJobExecution`. The stale candidate scan intentionally does not lock
|
|
441
|
-
* rows first; the running-row DELETE re-checks the stale predicate after the
|
|
438
|
+
* Locks pending-then-running to match `claimPendingJobExecution` and
|
|
439
|
+
* `reconcileTerminalJobExecution`. The stale candidate scan intentionally does not lock
|
|
440
|
+
* running rows first; the running-row DELETE re-checks the stale predicate after the
|
|
441
|
+
* pending-row sweep.
|
|
442
442
|
*/
|
|
443
443
|
export async function expireStuckJobExecutions(params: {
|
|
444
444
|
thresholdSeconds: number;
|
|
@@ -515,10 +515,14 @@ export async function expireStuckJobExecutions(params: {
|
|
|
515
515
|
workflowRunAttemptId: runningJobExecutions.workflowRunAttemptId,
|
|
516
516
|
jobId: runningJobExecutions.jobId,
|
|
517
517
|
jobExecutionId: runningJobExecutions.jobExecutionId,
|
|
518
|
+
provisionerId: runningJobExecutions.provisionerId,
|
|
519
|
+
providerRunnerId: runningJobExecutions.providerRunnerId,
|
|
518
520
|
});
|
|
519
521
|
|
|
520
522
|
if (deleted.length === 0) return [];
|
|
521
523
|
|
|
524
|
+
await releaseReservationsForTerminalRunningRows(tx, deleted);
|
|
525
|
+
|
|
522
526
|
await writeOutboxEvents<RunnersEventMap>(
|
|
523
527
|
tx,
|
|
524
528
|
runnersOutbox,
|
|
@@ -533,7 +537,12 @@ export async function expireStuckJobExecutions(params: {
|
|
|
533
537
|
})),
|
|
534
538
|
);
|
|
535
539
|
|
|
536
|
-
return deleted
|
|
540
|
+
return deleted.map(({workflowRunId, workflowRunAttemptId, jobId, jobExecutionId}) => ({
|
|
541
|
+
workflowRunId,
|
|
542
|
+
workflowRunAttemptId,
|
|
543
|
+
jobId,
|
|
544
|
+
jobExecutionId,
|
|
545
|
+
}));
|
|
537
546
|
});
|
|
538
547
|
|
|
539
548
|
if (reaped.length > 0) jobExecutionLeaseExpiredCount.add(reaped.length);
|
|
@@ -781,8 +790,8 @@ export async function recordHeartbeat(params: {
|
|
|
781
790
|
|
|
782
791
|
/**
|
|
783
792
|
* Reconciles a terminal job execution with runner state in one transaction: removes its pending
|
|
784
|
-
* queue row
|
|
785
|
-
* idempotent and preserves the first cancellation-request timestamp.
|
|
793
|
+
* queue row, requests cancellation on its running lease, and converges any linked reservation.
|
|
794
|
+
* The operation is idempotent and preserves the first cancellation-request timestamp.
|
|
786
795
|
*/
|
|
787
796
|
export async function reconcileTerminalJobExecution(params: {
|
|
788
797
|
jobExecutionId: string;
|
|
@@ -796,25 +805,17 @@ export async function reconcileTerminalJobExecution(params: {
|
|
|
796
805
|
await tx
|
|
797
806
|
.delete(pendingJobExecutions)
|
|
798
807
|
.where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
|
|
799
|
-
await tx
|
|
808
|
+
const cancelledRunningRows = await tx
|
|
800
809
|
.update(runningJobExecutions)
|
|
801
810
|
.set({
|
|
802
811
|
cancellationRequestedAt: sql`COALESCE(${runningJobExecutions.cancellationRequestedAt}, now())`,
|
|
803
812
|
})
|
|
804
|
-
.where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId))
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
if (params.jobIds.length === 0) return;
|
|
813
|
+
.where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId))
|
|
814
|
+
.returning({
|
|
815
|
+
provisionerId: runningJobExecutions.provisionerId,
|
|
816
|
+
providerRunnerId: runningJobExecutions.providerRunnerId,
|
|
817
|
+
});
|
|
810
818
|
|
|
811
|
-
|
|
812
|
-
await tx.delete(pendingJobExecutions).where(inArray(pendingJobExecutions.jobId, params.jobIds));
|
|
813
|
-
await tx
|
|
814
|
-
.update(runningJobExecutions)
|
|
815
|
-
.set({
|
|
816
|
-
cancellationRequestedAt: sql`COALESCE(${runningJobExecutions.cancellationRequestedAt}, now())`,
|
|
817
|
-
})
|
|
818
|
-
.where(inArray(runningJobExecutions.jobId, params.jobIds));
|
|
819
|
+
await releaseReservationsForTerminalRunningRows(tx, cancelledRunningRows);
|
|
819
820
|
});
|
|
820
821
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
touchProvisionerLastSeen,
|
|
11
11
|
} from '#db/provisioner-tokens.js';
|
|
12
12
|
import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
|
|
13
|
+
import {reservations} from '#db/schema/reservations.js';
|
|
13
14
|
import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
|
|
14
15
|
import {runnerBootstrapTokens, runnerControlSessions} from '#db/schema/runner-control-sessions.js';
|
|
15
16
|
import {providerRunners} from '#db/schema/runner-instances.js';
|
|
@@ -105,10 +106,22 @@ describe('provisioner token db', () => {
|
|
|
105
106
|
it("cascades revocation to the provisioner's unclaimed runner credentials and sessions", async () => {
|
|
106
107
|
const workspaceId = crypto.randomUUID();
|
|
107
108
|
const provisioner = await provisionerTokenFactory.create({workspaceId});
|
|
109
|
+
const [reservation] = await db()
|
|
110
|
+
.insert(reservations)
|
|
111
|
+
.values({
|
|
112
|
+
workspaceId,
|
|
113
|
+
provisionerId: provisioner.id,
|
|
114
|
+
requiredLabels: ['linux'],
|
|
115
|
+
count: 1,
|
|
116
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
117
|
+
})
|
|
118
|
+
.returning();
|
|
119
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
108
120
|
const runner = await providerRunnerFactory.create({
|
|
109
121
|
workspaceId,
|
|
110
122
|
provisionerId: provisioner.id,
|
|
111
123
|
runnerSessionId: null,
|
|
124
|
+
reservationId: reservation.id,
|
|
112
125
|
});
|
|
113
126
|
const future = new Date(Date.now() + 60_000);
|
|
114
127
|
const tokenValue = (name: string) => hashOpaqueToken(`${name}-${crypto.randomUUID()}`);
|
|
@@ -192,6 +205,10 @@ describe('provisioner token db', () => {
|
|
|
192
205
|
.select()
|
|
193
206
|
.from(providerRunners)
|
|
194
207
|
.where(eq(providerRunners.id, runner.id));
|
|
208
|
+
const [releasedReservation] = await db()
|
|
209
|
+
.select()
|
|
210
|
+
.from(reservations)
|
|
211
|
+
.where(eq(reservations.id, reservation.id));
|
|
195
212
|
const [revokedSession] = await db()
|
|
196
213
|
.select()
|
|
197
214
|
.from(runnerSessions)
|
|
@@ -207,6 +224,8 @@ describe('provisioner token db', () => {
|
|
|
207
224
|
expect(activation?.revokedAt).toBeInstanceOf(Date);
|
|
208
225
|
expect(terminatedRunner).toMatchObject({state: 'terminated'});
|
|
209
226
|
expect(terminatedRunner?.terminatedAt).toBeInstanceOf(Date);
|
|
227
|
+
expect(terminatedRunner?.reservationReleasedAt).toBeInstanceOf(Date);
|
|
228
|
+
expect(releasedReservation).toBeUndefined();
|
|
210
229
|
expect(revokedSession?.revokedAt).toBeInstanceOf(Date);
|
|
211
230
|
expect(preservedSession?.revokedAt).toBeNull();
|
|
212
231
|
});
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
} from '#core/entities/provisioner-token.js';
|
|
13
13
|
import {ProvisionerAdminIdempotencyKeyReuseError} from '#core/errors.js';
|
|
14
14
|
import {db} from './db.js';
|
|
15
|
+
import {releaseTerminalRunnerInstanceReservationsByIds} from './reservations.js';
|
|
15
16
|
import {runnersAdminCommandResults} from './schema/admin-command-results.js';
|
|
16
17
|
import {runnersOutbox} from './schema/outbox.js';
|
|
17
18
|
import {provisionerTokens, toProvisionerToken} from './schema/provisioner-tokens.js';
|
|
@@ -62,7 +63,7 @@ async function cascadeProvisionerRevocation(tx: Tx, provisionerId: string) {
|
|
|
62
63
|
),
|
|
63
64
|
),
|
|
64
65
|
);
|
|
65
|
-
await tx
|
|
66
|
+
const terminatedRows = await tx
|
|
66
67
|
.update(providerRunners)
|
|
67
68
|
.set({state: 'terminated', terminatedAt: sql`now()`, updatedAt: sql`now()`})
|
|
68
69
|
.where(
|
|
@@ -70,7 +71,17 @@ async function cascadeProvisionerRevocation(tx: Tx, provisionerId: string) {
|
|
|
70
71
|
eq(providerRunners.provisionerId, provisionerId),
|
|
71
72
|
isNull(providerRunners.runnerSessionId),
|
|
72
73
|
),
|
|
73
|
-
)
|
|
74
|
+
)
|
|
75
|
+
.returning({id: providerRunners.id});
|
|
76
|
+
const runnerInstanceIds = terminatedRows.map((row) => row.id);
|
|
77
|
+
if (runnerInstanceIds.length > 0) {
|
|
78
|
+
await releaseTerminalRunnerInstanceReservationsByIds(tx, {
|
|
79
|
+
workspaceId: null,
|
|
80
|
+
provisionerId,
|
|
81
|
+
runnerInstanceIds,
|
|
82
|
+
requireUnlinkedSession: false,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
74
85
|
await tx
|
|
75
86
|
.update(runnerSessions)
|
|
76
87
|
.set({revokedAt: sql`now()`})
|
|
@@ -535,6 +535,66 @@ describe('pollDemandAndReserve', () => {
|
|
|
535
535
|
expect(result.reservations).toEqual([]);
|
|
536
536
|
});
|
|
537
537
|
|
|
538
|
+
it('deducts a terminal assigned runner reservation unit as pending', async () => {
|
|
539
|
+
const reservation = await createIntendedReservation({
|
|
540
|
+
workspaceId,
|
|
541
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
542
|
+
});
|
|
543
|
+
await createIdleRunner({
|
|
544
|
+
labels: ['linux'],
|
|
545
|
+
reservationId: reservation.id,
|
|
546
|
+
state: 'terminated',
|
|
547
|
+
});
|
|
548
|
+
await createPendingJobs(1, ['linux', 'gpu']);
|
|
549
|
+
|
|
550
|
+
const result = await pollDemandAndReserve({
|
|
551
|
+
workspaceId,
|
|
552
|
+
provisionerId,
|
|
553
|
+
maxReservations: 1,
|
|
554
|
+
ttlSeconds: 60,
|
|
555
|
+
templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
expect(result.reservations).toEqual([]);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('counts only active assigned runners when deducting provisioner reservations', async () => {
|
|
562
|
+
const [reservation] = await db()
|
|
563
|
+
.insert(reservations)
|
|
564
|
+
.values({
|
|
565
|
+
workspaceId,
|
|
566
|
+
provisionerId,
|
|
567
|
+
requiredLabels: ['linux'],
|
|
568
|
+
count: 2,
|
|
569
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
570
|
+
})
|
|
571
|
+
.returning({id: reservations.id});
|
|
572
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
573
|
+
await createIdleRunner({
|
|
574
|
+
labels: ['linux'],
|
|
575
|
+
reservationId: reservation.id,
|
|
576
|
+
state: 'running',
|
|
577
|
+
});
|
|
578
|
+
await createIdleRunner({
|
|
579
|
+
labels: ['linux'],
|
|
580
|
+
reservationId: reservation.id,
|
|
581
|
+
state: 'failed',
|
|
582
|
+
});
|
|
583
|
+
await createPendingJobs(2, ['linux', 'gpu']);
|
|
584
|
+
|
|
585
|
+
const result = await pollDemandAndReserve({
|
|
586
|
+
workspaceId,
|
|
587
|
+
provisionerId,
|
|
588
|
+
maxReservations: 2,
|
|
589
|
+
ttlSeconds: 60,
|
|
590
|
+
templates: [template('linux-gpu', ['linux', 'gpu'], 2)],
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
expect(result.reservations).toHaveLength(1);
|
|
594
|
+
expect(result.reservations[0]?.count).toBe(1);
|
|
595
|
+
expect(result.stats[0]).toMatchObject({queued: 2, reserved: 1});
|
|
596
|
+
});
|
|
597
|
+
|
|
538
598
|
it('binds a runner whose intended reservation has passed its activation grace period', async () => {
|
|
539
599
|
const intendedReservation = await createIntendedReservation({
|
|
540
600
|
workspaceId,
|
|
@@ -1716,6 +1776,84 @@ describe('pollDemandAndReserve', () => {
|
|
|
1716
1776
|
expect(rows[0]?.count).toBe(1);
|
|
1717
1777
|
});
|
|
1718
1778
|
|
|
1779
|
+
it('releases units from multiple reservations in one transaction', async () => {
|
|
1780
|
+
const [first] = await db()
|
|
1781
|
+
.insert(reservations)
|
|
1782
|
+
.values({
|
|
1783
|
+
workspaceId,
|
|
1784
|
+
provisionerId,
|
|
1785
|
+
requiredLabels: ['linux'],
|
|
1786
|
+
count: 3,
|
|
1787
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
1788
|
+
})
|
|
1789
|
+
.returning({id: reservations.id});
|
|
1790
|
+
const [second] = await db()
|
|
1791
|
+
.insert(reservations)
|
|
1792
|
+
.values({
|
|
1793
|
+
workspaceId,
|
|
1794
|
+
provisionerId,
|
|
1795
|
+
requiredLabels: ['linux', 'gpu'],
|
|
1796
|
+
count: 2,
|
|
1797
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
1798
|
+
})
|
|
1799
|
+
.returning({id: reservations.id});
|
|
1800
|
+
if (!first || !second) throw new Error('Expected reservations');
|
|
1801
|
+
|
|
1802
|
+
const released = await db().transaction((tx) =>
|
|
1803
|
+
releaseReservationUnits(tx, {
|
|
1804
|
+
workspaceId,
|
|
1805
|
+
provisionerId,
|
|
1806
|
+
releases: [
|
|
1807
|
+
{reservationId: first.id, count: 2},
|
|
1808
|
+
{reservationId: second.id, count: 1},
|
|
1809
|
+
],
|
|
1810
|
+
}),
|
|
1811
|
+
);
|
|
1812
|
+
|
|
1813
|
+
const rows = await reservationsForTest();
|
|
1814
|
+
expect(released).toBe(3);
|
|
1815
|
+
expect(rows).toEqual(
|
|
1816
|
+
expect.arrayContaining([
|
|
1817
|
+
expect.objectContaining({id: first.id, count: 1}),
|
|
1818
|
+
expect.objectContaining({id: second.id, count: 1}),
|
|
1819
|
+
]),
|
|
1820
|
+
);
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
it('does not strand units when concurrent releases drain one reservation', async () => {
|
|
1824
|
+
const [reservation] = await db()
|
|
1825
|
+
.insert(reservations)
|
|
1826
|
+
.values({
|
|
1827
|
+
workspaceId,
|
|
1828
|
+
provisionerId,
|
|
1829
|
+
requiredLabels: ['linux'],
|
|
1830
|
+
count: 2,
|
|
1831
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
1832
|
+
})
|
|
1833
|
+
.returning({id: reservations.id});
|
|
1834
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
1835
|
+
|
|
1836
|
+
const [firstReleased, secondReleased] = await Promise.all([
|
|
1837
|
+
db().transaction((tx) =>
|
|
1838
|
+
releaseReservationUnits(tx, {
|
|
1839
|
+
workspaceId,
|
|
1840
|
+
provisionerId,
|
|
1841
|
+
releases: [{reservationId: reservation.id, count: 1}],
|
|
1842
|
+
}),
|
|
1843
|
+
),
|
|
1844
|
+
db().transaction((tx) =>
|
|
1845
|
+
releaseReservationUnits(tx, {
|
|
1846
|
+
workspaceId,
|
|
1847
|
+
provisionerId,
|
|
1848
|
+
releases: [{reservationId: reservation.id, count: 1}],
|
|
1849
|
+
}),
|
|
1850
|
+
),
|
|
1851
|
+
]);
|
|
1852
|
+
|
|
1853
|
+
expect(firstReleased + secondReleased).toBe(2);
|
|
1854
|
+
expect(await reservationsForTest()).toHaveLength(0);
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1719
1857
|
it('deletes reservations when releasing all remaining units', async () => {
|
|
1720
1858
|
await reservationFactory.create({
|
|
1721
1859
|
workspaceId,
|