@shipfox/api-runners 13.0.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 +8 -0
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +44 -2
- package/dist/db/job-executions.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/db/job-executions.test.ts +100 -8
- package/src/db/job-executions.ts +75 -9
- 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": "13.
|
|
4
|
+
"version": "13.1.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": "13.0.0",
|
|
28
|
-
"@shipfox/api-workflows-dto": "13.
|
|
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",
|
|
@@ -482,6 +482,79 @@ describe('claimPendingJobExecution', () => {
|
|
|
482
482
|
expect(runner?.firstClaimedAt).toBeInstanceOf(Date);
|
|
483
483
|
});
|
|
484
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
|
+
|
|
485
558
|
it('allows a manual session to claim repeatedly', async () => {
|
|
486
559
|
const first = await pendingJobFactory.create({workspaceId});
|
|
487
560
|
const second = await pendingJobFactory.create({workspaceId});
|
|
@@ -1037,7 +1110,7 @@ describe('reconcileTerminalJobExecution', () => {
|
|
|
1037
1110
|
expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
|
|
1038
1111
|
});
|
|
1039
1112
|
|
|
1040
|
-
it('
|
|
1113
|
+
it('does not double-release a reservation when a claimed runner becomes terminal', async () => {
|
|
1041
1114
|
const provisionerId = crypto.randomUUID();
|
|
1042
1115
|
const providerRunnerId = crypto.randomUUID();
|
|
1043
1116
|
const [reservation] = await db()
|
|
@@ -1046,7 +1119,7 @@ describe('reconcileTerminalJobExecution', () => {
|
|
|
1046
1119
|
workspaceId,
|
|
1047
1120
|
provisionerId,
|
|
1048
1121
|
requiredLabels: sessionLabels,
|
|
1049
|
-
count:
|
|
1122
|
+
count: 2,
|
|
1050
1123
|
expiresAt: new Date(Date.now() + 60_000),
|
|
1051
1124
|
})
|
|
1052
1125
|
.returning({id: reservations.id});
|
|
@@ -1057,12 +1130,29 @@ describe('reconcileTerminalJobExecution', () => {
|
|
|
1057
1130
|
providerRunnerId,
|
|
1058
1131
|
reservationId: reservation.id,
|
|
1059
1132
|
runnerSessionId,
|
|
1060
|
-
state: '
|
|
1061
|
-
terminatedAt: new Date(),
|
|
1133
|
+
state: 'running',
|
|
1062
1134
|
});
|
|
1135
|
+
await db()
|
|
1136
|
+
.update(runnerSessions)
|
|
1137
|
+
.set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
|
|
1138
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
1063
1139
|
const pending = await pendingJobFactory.create({workspaceId});
|
|
1064
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims:
|
|
1140
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
|
|
1065
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));
|
|
1066
1156
|
await db()
|
|
1067
1157
|
.update(runningJobExecutions)
|
|
1068
1158
|
.set({provisionerId, providerRunnerId})
|
|
@@ -1070,9 +1160,11 @@ describe('reconcileTerminalJobExecution', () => {
|
|
|
1070
1160
|
|
|
1071
1161
|
await reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
|
|
1072
1162
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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);
|
|
1076
1168
|
const [runner] = await db()
|
|
1077
1169
|
.select({reservationReleasedAt: providerRunners.reservationReleasedAt})
|
|
1078
1170
|
.from(providerRunners)
|
package/src/db/job-executions.ts
CHANGED
|
@@ -38,9 +38,14 @@ import {
|
|
|
38
38
|
} from '#metrics/instance.js';
|
|
39
39
|
import type {Tx} from './db.js';
|
|
40
40
|
import {db} from './db.js';
|
|
41
|
-
import {
|
|
41
|
+
import {lockRunnerReservationAdvisoryKeysTx} from './reservation-locks.js';
|
|
42
|
+
import {
|
|
43
|
+
releaseReservationUnits,
|
|
44
|
+
releaseTerminalRunnerInstanceReservationsByIds,
|
|
45
|
+
} from './reservations.js';
|
|
42
46
|
import {runnersOutbox} from './schema/outbox.js';
|
|
43
47
|
import {pendingJobExecutions} from './schema/pending-job-executions.js';
|
|
48
|
+
import {reservations} from './schema/reservations.js';
|
|
44
49
|
import {providerRunners} from './schema/runner-instances.js';
|
|
45
50
|
import {runnerSessions} from './schema/runner-sessions.js';
|
|
46
51
|
import {runningJobExecutions} from './schema/running-job-executions.js';
|
|
@@ -262,6 +267,32 @@ export async function claimPendingJobExecution(params: {
|
|
|
262
267
|
providerRunnerId = session.providerRunnerId;
|
|
263
268
|
}
|
|
264
269
|
|
|
270
|
+
const runnerInstanceCondition = runnerInstanceId
|
|
271
|
+
? eq(providerRunners.id, runnerInstanceId)
|
|
272
|
+
: provisionerId && providerRunnerId
|
|
273
|
+
? and(
|
|
274
|
+
eq(providerRunners.provisionerId, provisionerId),
|
|
275
|
+
eq(providerRunners.providerRunnerId, providerRunnerId),
|
|
276
|
+
)
|
|
277
|
+
: null;
|
|
278
|
+
if (runnerInstanceCondition && provisionerId) {
|
|
279
|
+
const [runner] = await tx
|
|
280
|
+
.select({
|
|
281
|
+
reservationId: providerRunners.reservationId,
|
|
282
|
+
intendedReservationId: providerRunners.intendedReservationId,
|
|
283
|
+
})
|
|
284
|
+
.from(providerRunners)
|
|
285
|
+
.where(runnerInstanceCondition)
|
|
286
|
+
.limit(1);
|
|
287
|
+
await lockRunnerReservationAdvisoryKeysTx(tx, {
|
|
288
|
+
provisionerId,
|
|
289
|
+
reservationIds: [runner?.reservationId, runner?.intendedReservationId].filter(
|
|
290
|
+
(reservationId): reservationId is string =>
|
|
291
|
+
reservationId !== null && reservationId !== undefined,
|
|
292
|
+
),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
265
296
|
// `id` is a uuidv7 (time-ordered), so it is a deterministic FIFO tiebreaker
|
|
266
297
|
// for rows sharing a created_at within a batch. Lock only the FIFO candidate before
|
|
267
298
|
// attempting its execution advisory lock; putting pg_try_advisory_xact_lock in this
|
|
@@ -328,14 +359,6 @@ export async function claimPendingJobExecution(params: {
|
|
|
328
359
|
launchKind: params.maxClaims === null ? 'manual' : 'unknown',
|
|
329
360
|
};
|
|
330
361
|
|
|
331
|
-
const runnerInstanceCondition = runnerInstanceId
|
|
332
|
-
? eq(providerRunners.id, runnerInstanceId)
|
|
333
|
-
: provisionerId && providerRunnerId
|
|
334
|
-
? and(
|
|
335
|
-
eq(providerRunners.provisionerId, provisionerId),
|
|
336
|
-
eq(providerRunners.providerRunnerId, providerRunnerId),
|
|
337
|
-
)
|
|
338
|
-
: null;
|
|
339
362
|
if (runnerInstanceCondition) {
|
|
340
363
|
const [claimedRunner] = await tx
|
|
341
364
|
.update(providerRunners)
|
|
@@ -350,6 +373,8 @@ export async function claimPendingJobExecution(params: {
|
|
|
350
373
|
provider: providerRunners.providerKind,
|
|
351
374
|
launchKind: providerRunners.launchKind,
|
|
352
375
|
runnerInstanceId: providerRunners.id,
|
|
376
|
+
reservationId: providerRunners.reservationId,
|
|
377
|
+
intendedReservationId: providerRunners.intendedReservationId,
|
|
353
378
|
sessionCreatedAtEpochMs: sql<number | null>`(
|
|
354
379
|
select extract(epoch from ${runnerSessions.createdAt})::double precision * 1000
|
|
355
380
|
from ${runnerSessions}
|
|
@@ -360,6 +385,47 @@ export async function claimPendingJobExecution(params: {
|
|
|
360
385
|
queueTimeObservation.provider = claimedRunner.provider;
|
|
361
386
|
queueTimeObservation.launchKind = claimedRunner.launchKind;
|
|
362
387
|
}
|
|
388
|
+
if (claimedRunner?.isFirstClaim) {
|
|
389
|
+
const [releasedRunner] = await tx
|
|
390
|
+
.update(providerRunners)
|
|
391
|
+
.set({
|
|
392
|
+
intendedReservationId: null,
|
|
393
|
+
reservationReleasedAt: sql`now()`,
|
|
394
|
+
updatedAt: sql`now()`,
|
|
395
|
+
})
|
|
396
|
+
.where(
|
|
397
|
+
and(
|
|
398
|
+
eq(providerRunners.id, claimedRunner.runnerInstanceId),
|
|
399
|
+
or(
|
|
400
|
+
isNotNull(providerRunners.reservationId),
|
|
401
|
+
isNotNull(providerRunners.intendedReservationId),
|
|
402
|
+
),
|
|
403
|
+
isNull(providerRunners.reservationReleasedAt),
|
|
404
|
+
),
|
|
405
|
+
)
|
|
406
|
+
.returning({
|
|
407
|
+
provisionerId: providerRunners.provisionerId,
|
|
408
|
+
});
|
|
409
|
+
const reservationId = claimedRunner.intendedReservationId ?? claimedRunner.reservationId;
|
|
410
|
+
if (releasedRunner?.provisionerId && reservationId) {
|
|
411
|
+
const [reservation] = await tx
|
|
412
|
+
.select({workspaceId: reservations.workspaceId})
|
|
413
|
+
.from(reservations)
|
|
414
|
+
.where(
|
|
415
|
+
and(
|
|
416
|
+
eq(reservations.id, reservationId),
|
|
417
|
+
eq(reservations.provisionerId, releasedRunner.provisionerId),
|
|
418
|
+
),
|
|
419
|
+
)
|
|
420
|
+
.limit(1);
|
|
421
|
+
if (reservation)
|
|
422
|
+
await releaseReservationUnits(tx, {
|
|
423
|
+
workspaceId: reservation.workspaceId,
|
|
424
|
+
provisionerId: releasedRunner.provisionerId,
|
|
425
|
+
releases: [{reservationId, count: 1}],
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
363
429
|
if (
|
|
364
430
|
claimedRunner?.isFirstClaim &&
|
|
365
431
|
claimedRunner.firstClaimedAt !== null &&
|