@shipfox/api-runners 12.5.0 → 12.7.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 +15 -0
- package/dist/db/job-executions.d.ts +3 -0
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +23 -1
- 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 +137 -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/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/db/job-executions.test.ts +119 -0
- package/src/db/job-executions.ts +24 -2
- 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 +311 -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 +250 -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/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-runners",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "12.
|
|
4
|
+
"version": "12.7.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@shipfox/api-auth-context": "12.2.0",
|
|
26
26
|
"@shipfox/api-auth-dto": "12.0.0",
|
|
27
27
|
"@shipfox/api-runners-dto": "12.4.0",
|
|
28
|
-
"@shipfox/api-workflows-dto": "12.
|
|
28
|
+
"@shipfox/api-workflows-dto": "12.7.0",
|
|
29
29
|
"@shipfox/config": "1.2.4",
|
|
30
30
|
"@shipfox/inter-module": "0.2.3",
|
|
31
31
|
"@shipfox/node-drizzle": "0.3.5",
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from './job-executions.js';
|
|
30
30
|
import {runnersOutbox} from './schema/outbox.js';
|
|
31
31
|
import {pendingJobExecutions} from './schema/pending-job-executions.js';
|
|
32
|
+
import {reservations} from './schema/reservations.js';
|
|
32
33
|
import {providerRunners} from './schema/runner-instances.js';
|
|
33
34
|
import {runnerSessions} from './schema/runner-sessions.js';
|
|
34
35
|
import {runningJobExecutions} from './schema/running-job-executions.js';
|
|
@@ -969,6 +970,124 @@ describe('releaseJobExecution', () => {
|
|
|
969
970
|
).resolves.toBeUndefined();
|
|
970
971
|
});
|
|
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
|
+
|
|
972
1091
|
it('releases regardless of which session holds the lease', async () => {
|
|
973
1092
|
await pendingJobFactory.create({workspaceId});
|
|
974
1093
|
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
package/src/db/job-executions.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from '#metrics/instance.js';
|
|
40
40
|
import type {Tx} from './db.js';
|
|
41
41
|
import {db} from './db.js';
|
|
42
|
+
import {releaseTerminalRunnerInstanceReservationsByIds} from './reservations.js';
|
|
42
43
|
import {runnersOutbox} from './schema/outbox.js';
|
|
43
44
|
import {pendingJobExecutions} from './schema/pending-job-executions.js';
|
|
44
45
|
import {providerRunners} from './schema/runner-instances.js';
|
|
@@ -405,6 +406,9 @@ async function touchRunnerSessionLiveness(params: {
|
|
|
405
406
|
/**
|
|
406
407
|
* Releases a job execution's lease when the orchestration workflow finalizes it: deletes the
|
|
407
408
|
* running-job-execution row AND any lingering pending row for the same execution, in one tx.
|
|
409
|
+
* When the deleted lease was the last one for a terminal provider runner, it also releases the
|
|
410
|
+
* runner's reservation in the same runner-owned transaction. Terminal reports and workflow lease
|
|
411
|
+
* finalization remain independently retryable; they do not share a cross-module scope lock.
|
|
408
412
|
* Idempotent (0-row no-op), no token scope (the workflow is authoritative), and
|
|
409
413
|
* emits no event: the workflow already owns the outcome. Sweeping the pending row
|
|
410
414
|
* too closes the at-least-once window where an enqueue retry left an orphan that a
|
|
@@ -421,9 +425,27 @@ export async function releaseJobExecution(params: {jobExecutionId: string}): Pro
|
|
|
421
425
|
await tx
|
|
422
426
|
.delete(pendingJobExecutions)
|
|
423
427
|
.where(eq(pendingJobExecutions.jobExecutionId, params.jobExecutionId));
|
|
424
|
-
await tx
|
|
428
|
+
const deletedRunningRows = await tx
|
|
425
429
|
.delete(runningJobExecutions)
|
|
426
|
-
.where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId))
|
|
430
|
+
.where(eq(runningJobExecutions.jobExecutionId, params.jobExecutionId))
|
|
431
|
+
.returning({
|
|
432
|
+
workspaceId: runningJobExecutions.workspaceId,
|
|
433
|
+
provisionerId: runningJobExecutions.provisionerId,
|
|
434
|
+
providerRunnerId: runningJobExecutions.providerRunnerId,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const deletedRunningRow = deletedRunningRows[0];
|
|
438
|
+
if (deletedRunningRow?.provisionerId && deletedRunningRow.providerRunnerId) {
|
|
439
|
+
await releaseTerminalRunnerInstanceReservationsByIds(tx, {
|
|
440
|
+
workspaceId: null,
|
|
441
|
+
provisionerId: deletedRunningRow.provisionerId,
|
|
442
|
+
providerRunnerIds: [deletedRunningRow.providerRunnerId],
|
|
443
|
+
requireUnlinkedSession: false,
|
|
444
|
+
// Lock and re-check the runner row locally so terminal reporting remains retryable
|
|
445
|
+
// without a workflow/runner scope lock.
|
|
446
|
+
requireTerminalState: false,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
427
449
|
});
|
|
428
450
|
}
|
|
429
451
|
|
|
@@ -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,
|