@shipfox/api-runners 12.4.0 → 12.5.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 +21 -0
- package/dist/core/errors.d.ts +5 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +9 -2
- package/dist/core/errors.js.map +1 -1
- package/dist/core/runner-control-sessions.d.ts.map +1 -1
- package/dist/core/runner-control-sessions.js +62 -12
- package/dist/core/runner-control-sessions.js.map +1 -1
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +35 -4
- package/dist/db/job-executions.js.map +1 -1
- package/dist/db/runner-assignments.d.ts +8 -1
- package/dist/db/runner-assignments.d.ts.map +1 -1
- package/dist/db/runner-assignments.js +57 -10
- package/dist/db/runner-assignments.js.map +1 -1
- package/dist/db/runner-instances.d.ts +9 -0
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +43 -0
- package/dist/db/runner-instances.js.map +1 -1
- package/dist/db/runner-sessions.d.ts.map +1 -1
- package/dist/db/runner-sessions.js +14 -1
- package/dist/db/runner-sessions.js.map +1 -1
- package/dist/metrics/index.d.ts +2 -2
- package/dist/metrics/index.d.ts.map +1 -1
- package/dist/metrics/index.js +1 -1
- package/dist/metrics/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +48 -0
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +138 -1
- package/dist/metrics/instance.js.map +1 -1
- package/dist/metrics/service.d.ts.map +1 -1
- package/dist/metrics/service.js +25 -4
- package/dist/metrics/service.js.map +1 -1
- package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
- package/dist/presentation/routes/assign-runner-instances.js +7 -1
- package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/core/errors.ts +30 -1
- package/src/core/runner-control-sessions.test.ts +71 -0
- package/src/core/runner-control-sessions.ts +79 -10
- package/src/db/job-executions.test.ts +98 -12
- package/src/db/job-executions.ts +52 -4
- package/src/db/runner-assignments.ts +67 -16
- package/src/db/runner-instances.test.ts +92 -0
- package/src/db/runner-instances.ts +74 -0
- package/src/db/runner-sessions.ts +19 -1
- package/src/metrics/index.ts +14 -0
- package/src/metrics/instance.test.ts +118 -0
- package/src/metrics/instance.ts +188 -1
- package/src/metrics/service.test.ts +38 -0
- package/src/metrics/service.ts +55 -8
- package/src/presentation/routes/assign-runner-instances.ts +8 -0
- 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.5.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.5.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/core/errors.ts
CHANGED
|
@@ -109,18 +109,47 @@ export class ReservationExpiredError extends Error {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
export type RunnerInstanceNotAssignableReason =
|
|
113
|
+
| 'runner-not-found'
|
|
114
|
+
| 'runner-not-running'
|
|
115
|
+
| 'provider-identity-missing'
|
|
116
|
+
| 'control-session-not-active'
|
|
117
|
+
| 'labels-mismatch'
|
|
118
|
+
| 'capacity-exhausted';
|
|
119
|
+
|
|
120
|
+
export type RunnerAssignmentRejectionReason =
|
|
121
|
+
| 'reservation-not-found'
|
|
122
|
+
| 'reservation-expired'
|
|
123
|
+
| 'already-assigned'
|
|
124
|
+
| RunnerInstanceNotAssignableReason;
|
|
125
|
+
|
|
112
126
|
export class RunnerInstanceNotAssignableError extends Error {
|
|
113
|
-
constructor(
|
|
127
|
+
constructor(
|
|
128
|
+
public readonly runnerInstanceId: string,
|
|
129
|
+
public readonly reason: RunnerInstanceNotAssignableReason,
|
|
130
|
+
) {
|
|
114
131
|
super(`Runner instance cannot be assigned: ${runnerInstanceId}`);
|
|
115
132
|
this.name = 'RunnerInstanceNotAssignableError';
|
|
116
133
|
}
|
|
117
134
|
}
|
|
135
|
+
|
|
118
136
|
export class RunnerInstanceAlreadyAssignedError extends Error {
|
|
119
137
|
constructor(public readonly runnerInstanceId: string) {
|
|
120
138
|
super(`Runner instance is already assigned: ${runnerInstanceId}`);
|
|
121
139
|
this.name = 'RunnerInstanceAlreadyAssignedError';
|
|
122
140
|
}
|
|
123
141
|
}
|
|
142
|
+
|
|
143
|
+
export function getRunnerAssignmentRejectionReason(
|
|
144
|
+
error: unknown,
|
|
145
|
+
): RunnerAssignmentRejectionReason | null {
|
|
146
|
+
if (error instanceof ReservationNotFoundError) return 'reservation-not-found';
|
|
147
|
+
if (error instanceof ReservationExpiredError) return 'reservation-expired';
|
|
148
|
+
if (error instanceof RunnerInstanceAlreadyAssignedError) return 'already-assigned';
|
|
149
|
+
if (error instanceof RunnerInstanceNotAssignableError) return error.reason;
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
124
153
|
export class ReservationAlreadyAssignedError extends Error {
|
|
125
154
|
constructor(public readonly reservationId: string) {
|
|
126
155
|
super(`Reservation is already assigned: ${reservationId}`);
|
|
@@ -359,6 +359,77 @@ describe('enrollRunnerControlSession', () => {
|
|
|
359
359
|
vi.resetModules();
|
|
360
360
|
}
|
|
361
361
|
});
|
|
362
|
+
|
|
363
|
+
it('records a rolled-back assignment rejection without recording its assignment timing', async () => {
|
|
364
|
+
const provisionerId = crypto.randomUUID();
|
|
365
|
+
const reservation = await createReservation({provisionerId});
|
|
366
|
+
const runnerInstanceId = await createRunner({
|
|
367
|
+
provisionerId,
|
|
368
|
+
intendedReservationId: reservation.id,
|
|
369
|
+
});
|
|
370
|
+
const assignmentTimingRecord = vi.fn();
|
|
371
|
+
const assignmentRejectionRecord = vi.fn();
|
|
372
|
+
const promotionFailureRecord = vi.fn();
|
|
373
|
+
|
|
374
|
+
vi.resetModules();
|
|
375
|
+
const {RunnerInstanceNotAssignableError} = await import('#core/errors.js');
|
|
376
|
+
const activationIssueError = new RunnerInstanceNotAssignableError(
|
|
377
|
+
runnerInstanceId,
|
|
378
|
+
'capacity-exhausted',
|
|
379
|
+
);
|
|
380
|
+
vi.doMock('#metrics/index.js', async (importOriginal) => ({
|
|
381
|
+
...(await importOriginal<typeof import('#metrics/index.js')>()),
|
|
382
|
+
recordProviderRunnerControlSessionToAssignment: assignmentTimingRecord,
|
|
383
|
+
recordProviderRunnerAssignmentRejected: assignmentRejectionRecord,
|
|
384
|
+
recordRunnerReservationPromotionFailure: promotionFailureRecord,
|
|
385
|
+
}));
|
|
386
|
+
vi.doMock('./runner-activation.js', async (importOriginal) => ({
|
|
387
|
+
...(await importOriginal<typeof import('./runner-activation.js')>()),
|
|
388
|
+
issueRunnerActivationTokenTx: vi.fn().mockRejectedValue(activationIssueError),
|
|
389
|
+
}));
|
|
390
|
+
|
|
391
|
+
const {closePostgresClient, createPostgresClient} = await import('@shipfox/node-postgres');
|
|
392
|
+
createPostgresClient();
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const {enrollRunnerControlSession: enroll} = await import('./runner-control-sessions.js');
|
|
396
|
+
const result = await enroll({
|
|
397
|
+
runnerInstanceId,
|
|
398
|
+
provisionerId,
|
|
399
|
+
labels: ['linux'],
|
|
400
|
+
providerKind: 'docker',
|
|
401
|
+
protocolVersion: '1',
|
|
402
|
+
});
|
|
403
|
+
const [runner] = await db()
|
|
404
|
+
.select({
|
|
405
|
+
intendedReservationId: providerRunners.intendedReservationId,
|
|
406
|
+
reservationId: providerRunners.reservationId,
|
|
407
|
+
workspaceId: providerRunners.workspaceId,
|
|
408
|
+
assignedAt: providerRunners.assignedAt,
|
|
409
|
+
})
|
|
410
|
+
.from(providerRunners)
|
|
411
|
+
.where(eq(providerRunners.id, runnerInstanceId));
|
|
412
|
+
|
|
413
|
+
expect(result).toBeNull();
|
|
414
|
+
expect(runner).toEqual({
|
|
415
|
+
intendedReservationId: reservation.id,
|
|
416
|
+
reservationId: null,
|
|
417
|
+
workspaceId: null,
|
|
418
|
+
assignedAt: null,
|
|
419
|
+
});
|
|
420
|
+
expect(assignmentTimingRecord).not.toHaveBeenCalled();
|
|
421
|
+
expect(assignmentRejectionRecord).toHaveBeenCalledWith({
|
|
422
|
+
reason: 'capacity-exhausted',
|
|
423
|
+
surface: 'enrollment',
|
|
424
|
+
});
|
|
425
|
+
expect(promotionFailureRecord).toHaveBeenCalledWith('not-assignable');
|
|
426
|
+
} finally {
|
|
427
|
+
await closePostgresClient();
|
|
428
|
+
vi.doUnmock('#metrics/index.js');
|
|
429
|
+
vi.doUnmock('./runner-activation.js');
|
|
430
|
+
vi.resetModules();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
362
433
|
});
|
|
363
434
|
|
|
364
435
|
async function expectPromotionFailure(params: {
|
|
@@ -5,6 +5,7 @@ import {extractDisplayPrefix, generateOpaqueToken, hashOpaqueToken} from '@shipf
|
|
|
5
5
|
import {and, eq, gt, isNull, notInArray, or, sql} from 'drizzle-orm';
|
|
6
6
|
import {config} from '#config.js';
|
|
7
7
|
import {
|
|
8
|
+
getRunnerAssignmentRejectionReason,
|
|
8
9
|
ReservationExpiredError,
|
|
9
10
|
ReservationNotFoundError,
|
|
10
11
|
RunnerInstanceAlreadyAssignedError,
|
|
@@ -22,6 +23,9 @@ import {runnerBootstrapTokens, runnerControlSessions} from '#db/schema/runner-co
|
|
|
22
23
|
import {providerRunners} from '#db/schema/runner-instances.js';
|
|
23
24
|
import {
|
|
24
25
|
type RunnerReservationPromotionFailureReason,
|
|
26
|
+
recordProviderRunnerAssignmentRejected,
|
|
27
|
+
recordProviderRunnerControlSessionToAssignment,
|
|
28
|
+
recordProviderRunnerCreatedToControlSession,
|
|
25
29
|
recordRunnerReservationCapacityFailure,
|
|
26
30
|
recordRunnerReservationPromotionFailure,
|
|
27
31
|
} from '#metrics/index.js';
|
|
@@ -137,7 +141,7 @@ export async function exchangeRunnerBootstrapToken(params: {
|
|
|
137
141
|
}): Promise<{runnerInstanceId: string; controlSessionToken: string; expiresAt: Date}> {
|
|
138
142
|
const controlSessionToken = generateOpaqueToken('runnerControlSession');
|
|
139
143
|
const expiresAt = new Date(Date.now() + params.ttlSeconds * 1000);
|
|
140
|
-
|
|
144
|
+
const result = await db().transaction(async (tx) => {
|
|
141
145
|
const [bootstrap] = await tx
|
|
142
146
|
.update(runnerBootstrapTokens)
|
|
143
147
|
.set({consumedAt: sql`now()`})
|
|
@@ -151,6 +155,21 @@ export async function exchangeRunnerBootstrapToken(params: {
|
|
|
151
155
|
)
|
|
152
156
|
.returning();
|
|
153
157
|
if (!bootstrap) throw new RunnerBootstrapTokenInvalidError();
|
|
158
|
+
const [runner] = await tx
|
|
159
|
+
.select({
|
|
160
|
+
runnerInstanceId: providerRunners.id,
|
|
161
|
+
createdAt: providerRunners.createdAt,
|
|
162
|
+
provider: providerRunners.providerKind,
|
|
163
|
+
launchKind: providerRunners.launchKind,
|
|
164
|
+
})
|
|
165
|
+
.from(providerRunners)
|
|
166
|
+
.where(
|
|
167
|
+
and(
|
|
168
|
+
eq(providerRunners.id, bootstrap.runnerInstanceId),
|
|
169
|
+
eq(providerRunners.provisionerId, bootstrap.provisionerId),
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
.limit(1);
|
|
154
173
|
const [session] = await tx
|
|
155
174
|
.insert(runnerControlSessions)
|
|
156
175
|
.values({
|
|
@@ -160,10 +179,28 @@ export async function exchangeRunnerBootstrapToken(params: {
|
|
|
160
179
|
prefix: extractDisplayPrefix(controlSessionToken),
|
|
161
180
|
expiresAt,
|
|
162
181
|
})
|
|
163
|
-
.returning({
|
|
182
|
+
.returning({createdAt: runnerControlSessions.createdAt});
|
|
164
183
|
if (!session) throw new Error('Runner control session insert returned no row');
|
|
165
|
-
return {
|
|
184
|
+
return {
|
|
185
|
+
runner,
|
|
186
|
+
session,
|
|
187
|
+
runnerInstanceId: bootstrap.runnerInstanceId,
|
|
188
|
+
controlSessionToken,
|
|
189
|
+
expiresAt,
|
|
190
|
+
};
|
|
166
191
|
});
|
|
192
|
+
if (result.runner)
|
|
193
|
+
recordProviderRunnerCreatedToControlSession({
|
|
194
|
+
durationMilliseconds: result.session.createdAt.getTime() - result.runner.createdAt.getTime(),
|
|
195
|
+
provider: result.runner.provider,
|
|
196
|
+
launchKind: result.runner.launchKind,
|
|
197
|
+
runnerInstanceId: result.runner.runnerInstanceId,
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
runnerInstanceId: result.runnerInstanceId,
|
|
201
|
+
controlSessionToken: result.controlSessionToken,
|
|
202
|
+
expiresAt: result.expiresAt,
|
|
203
|
+
};
|
|
167
204
|
}
|
|
168
205
|
|
|
169
206
|
export async function resolveRunnerControlSession(rawToken: string) {
|
|
@@ -195,7 +232,7 @@ export async function enrollRunnerControlSession(params: {
|
|
|
195
232
|
providerKind: string;
|
|
196
233
|
protocolVersion: string;
|
|
197
234
|
}): Promise<string | null> {
|
|
198
|
-
|
|
235
|
+
const result = await db().transaction(async (tx) => {
|
|
199
236
|
const [current] = await tx
|
|
200
237
|
.select({
|
|
201
238
|
intendedReservationId: providerRunners.intendedReservationId,
|
|
@@ -266,23 +303,38 @@ export async function enrollRunnerControlSession(params: {
|
|
|
266
303
|
await updateRunnerControlSessionLastSeen(tx, params.runnerInstanceId, params.provisionerId);
|
|
267
304
|
|
|
268
305
|
const intendedReservationId = updated.intendedReservationId;
|
|
269
|
-
if (!intendedReservationId)
|
|
306
|
+
if (!intendedReservationId)
|
|
307
|
+
return {
|
|
308
|
+
activationToken: null,
|
|
309
|
+
controlSessionToAssignment: [],
|
|
310
|
+
assignmentRejectedReason: null,
|
|
311
|
+
promotionFailureReason: null,
|
|
312
|
+
};
|
|
270
313
|
|
|
271
314
|
try {
|
|
272
|
-
|
|
273
|
-
await assignRunnerInstancesTx(promotionTx, {
|
|
315
|
+
const promotion = await tx.transaction(async (promotionTx) => {
|
|
316
|
+
const assignment = await assignRunnerInstancesTx(promotionTx, {
|
|
274
317
|
provisionerId: params.provisionerId,
|
|
275
318
|
reservationId: intendedReservationId,
|
|
276
319
|
runnerInstanceIds: [params.runnerInstanceId],
|
|
320
|
+
surface: 'enrollment',
|
|
277
321
|
});
|
|
278
|
-
|
|
322
|
+
const activationToken = await issueRunnerActivationTokenTx(promotionTx, {
|
|
279
323
|
runnerInstanceId: params.runnerInstanceId,
|
|
280
324
|
provisionerId: params.provisionerId,
|
|
281
325
|
ttlSeconds: config.RUNNER_ACTIVATION_TOKEN_TTL_SECONDS,
|
|
282
326
|
surface: 'enrollment',
|
|
283
327
|
});
|
|
328
|
+
return {activationToken, controlSessionToAssignment: assignment.controlSessionToAssignment};
|
|
284
329
|
});
|
|
330
|
+
return {
|
|
331
|
+
activationToken: promotion.activationToken,
|
|
332
|
+
controlSessionToAssignment: promotion.controlSessionToAssignment,
|
|
333
|
+
assignmentRejectedReason: null,
|
|
334
|
+
promotionFailureReason: null,
|
|
335
|
+
};
|
|
285
336
|
} catch (error) {
|
|
337
|
+
const assignmentRejectionReason = getRunnerAssignmentRejectionReason(error);
|
|
286
338
|
const expectedFailureReason = getRunnerReservationPromotionFailureReason(error);
|
|
287
339
|
const details = {
|
|
288
340
|
err: error,
|
|
@@ -291,7 +343,6 @@ export async function enrollRunnerControlSession(params: {
|
|
|
291
343
|
reservationId: intendedReservationId,
|
|
292
344
|
};
|
|
293
345
|
if (expectedFailureReason) {
|
|
294
|
-
recordRunnerReservationPromotionFailure(expectedFailureReason);
|
|
295
346
|
logger().debug(details, 'Runner reservation could not be promoted during enrollment');
|
|
296
347
|
} else {
|
|
297
348
|
logger().error(
|
|
@@ -299,9 +350,27 @@ export async function enrollRunnerControlSession(params: {
|
|
|
299
350
|
'Unexpected failure promoting runner reservation during enrollment',
|
|
300
351
|
);
|
|
301
352
|
}
|
|
302
|
-
return
|
|
353
|
+
return {
|
|
354
|
+
activationToken: null,
|
|
355
|
+
controlSessionToAssignment: [],
|
|
356
|
+
assignmentRejectedReason: assignmentRejectionReason,
|
|
357
|
+
promotionFailureReason: expectedFailureReason,
|
|
358
|
+
};
|
|
303
359
|
}
|
|
304
360
|
});
|
|
361
|
+
for (const observation of result.controlSessionToAssignment)
|
|
362
|
+
recordProviderRunnerControlSessionToAssignment(observation);
|
|
363
|
+
// These counters intentionally overlap: assignment_rejected records the assignment-facing
|
|
364
|
+
// reason, while reservation_promotion_failure records the enrollment recovery outcome. They
|
|
365
|
+
// answer different questions and must not be summed.
|
|
366
|
+
if (result.assignmentRejectedReason)
|
|
367
|
+
recordProviderRunnerAssignmentRejected({
|
|
368
|
+
reason: result.assignmentRejectedReason,
|
|
369
|
+
surface: 'enrollment',
|
|
370
|
+
});
|
|
371
|
+
if (result.promotionFailureReason)
|
|
372
|
+
recordRunnerReservationPromotionFailure(result.promotionFailureReason);
|
|
373
|
+
return result.activationToken;
|
|
305
374
|
}
|
|
306
375
|
|
|
307
376
|
function getRunnerReservationPromotionFailureReason(
|
|
@@ -8,6 +8,7 @@ import {eq, sql} from 'drizzle-orm';
|
|
|
8
8
|
import {EmptyRequiredLabelsError, RunnerSessionExhaustedError} from '#core/errors.js';
|
|
9
9
|
import {claimJobExecution} from '#core/job-executions.js';
|
|
10
10
|
import {detectAndExpireStuckJobs} from '#core/maintenance.js';
|
|
11
|
+
import * as runnerMetrics from '#metrics/instance.js';
|
|
11
12
|
import {
|
|
12
13
|
getLeaseTokenClaims,
|
|
13
14
|
pendingJobFactory,
|
|
@@ -242,20 +243,95 @@ describe('claimPendingJobExecution', () => {
|
|
|
242
243
|
expect(new Date(payload.claimedAt).getTime()).toBe(running?.startedAt.getTime());
|
|
243
244
|
});
|
|
244
245
|
|
|
246
|
+
it('records queue time from the pending row creation to the runner claim', async () => {
|
|
247
|
+
const created = await pendingJobFactory.create({workspaceId});
|
|
248
|
+
await db()
|
|
249
|
+
.update(pendingJobExecutions)
|
|
250
|
+
.set({createdAt: sql`now() - interval '60 seconds'`})
|
|
251
|
+
.where(eq(pendingJobExecutions.jobExecutionId, created.jobExecutionId));
|
|
252
|
+
const [pending] = await db()
|
|
253
|
+
.select({createdAt: pendingJobExecutions.createdAt})
|
|
254
|
+
.from(pendingJobExecutions)
|
|
255
|
+
.where(eq(pendingJobExecutions.jobExecutionId, created.jobExecutionId));
|
|
256
|
+
const recordQueueTime = vi.spyOn(runnerMetrics, 'recordJobExecutionQueueTime');
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
|
|
260
|
+
|
|
261
|
+
const [running] = await db()
|
|
262
|
+
.select({startedAt: runningJobExecutions.startedAt})
|
|
263
|
+
.from(runningJobExecutions)
|
|
264
|
+
.where(eq(runningJobExecutions.jobExecutionId, created.jobExecutionId));
|
|
265
|
+
const observation = recordQueueTime.mock.calls[0]?.[0];
|
|
266
|
+
if (!pending || !running) throw new Error('Expected pending and running job rows');
|
|
267
|
+
expect(recordQueueTime).toHaveBeenCalledTimes(1);
|
|
268
|
+
expect(observation).toMatchObject({provider: null, launchKind: 'manual'});
|
|
269
|
+
expect(observation?.durationMilliseconds).toBe(
|
|
270
|
+
running.startedAt.getTime() - pending.createdAt.getTime(),
|
|
271
|
+
);
|
|
272
|
+
expect(observation?.durationMilliseconds).toBeGreaterThanOrEqual(60_000);
|
|
273
|
+
expect(observation?.durationMilliseconds).toBeLessThan(120_000);
|
|
274
|
+
} finally {
|
|
275
|
+
recordQueueTime.mockRestore();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('records bounded queue labels for a provisioned runner claim', async () => {
|
|
280
|
+
const provisionerId = crypto.randomUUID();
|
|
281
|
+
const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
|
|
282
|
+
await db()
|
|
283
|
+
.update(runnerSessions)
|
|
284
|
+
.set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
|
|
285
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
286
|
+
await db().insert(providerRunners).values({
|
|
287
|
+
workspaceId,
|
|
288
|
+
provisionerId,
|
|
289
|
+
providerRunnerId,
|
|
290
|
+
providerKind: 'ec2',
|
|
291
|
+
launchKind: 'demand',
|
|
292
|
+
labels: sessionLabels,
|
|
293
|
+
state: 'running',
|
|
294
|
+
reportedAt: new Date(),
|
|
295
|
+
});
|
|
296
|
+
await pendingJobFactory.create({workspaceId});
|
|
297
|
+
const recordQueueTime = vi.spyOn(runnerMetrics, 'recordJobExecutionQueueTime');
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
|
|
301
|
+
|
|
302
|
+
expect(recordQueueTime).toHaveBeenCalledWith(
|
|
303
|
+
expect.objectContaining({provider: 'ec2', launchKind: 'demand'}),
|
|
304
|
+
);
|
|
305
|
+
expect(recordQueueTime.mock.calls[0]?.[0].durationMilliseconds).toBeGreaterThanOrEqual(0);
|
|
306
|
+
} finally {
|
|
307
|
+
recordQueueTime.mockRestore();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
245
311
|
it('emits no claimed event when there is nothing to claim', async () => {
|
|
312
|
+
const recordQueueTime = vi.spyOn(runnerMetrics, 'recordJobExecutionQueueTime');
|
|
246
313
|
const before = await db()
|
|
247
314
|
.select()
|
|
248
315
|
.from(runnersOutbox)
|
|
249
316
|
.where(eq(runnersOutbox.eventType, RUNNER_JOB_CLAIMED));
|
|
250
317
|
|
|
251
|
-
|
|
318
|
+
try {
|
|
319
|
+
const claimed = await claimPendingJobExecution({
|
|
320
|
+
workspaceId,
|
|
321
|
+
runnerSessionId,
|
|
322
|
+
maxClaims: null,
|
|
323
|
+
});
|
|
252
324
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
325
|
+
const after = await db()
|
|
326
|
+
.select()
|
|
327
|
+
.from(runnersOutbox)
|
|
328
|
+
.where(eq(runnersOutbox.eventType, RUNNER_JOB_CLAIMED));
|
|
329
|
+
expect(claimed).toBeNull();
|
|
330
|
+
expect(after).toHaveLength(before.length);
|
|
331
|
+
expect(recordQueueTime).not.toHaveBeenCalled();
|
|
332
|
+
} finally {
|
|
333
|
+
recordQueueTime.mockRestore();
|
|
334
|
+
}
|
|
259
335
|
});
|
|
260
336
|
|
|
261
337
|
it('emits no claimed event when dropping an orphan pending row', async () => {
|
|
@@ -273,13 +349,23 @@ describe('claimPendingJobExecution', () => {
|
|
|
273
349
|
});
|
|
274
350
|
// Clear the initial claim's events so this assertion only covers the orphan claim.
|
|
275
351
|
const beforeOrphanClaim = await outboxEventsForJob(RUNNER_JOB_CLAIMED, created.jobId);
|
|
352
|
+
const recordQueueTime = vi.spyOn(runnerMetrics, 'recordJobExecutionQueueTime');
|
|
276
353
|
|
|
277
|
-
|
|
354
|
+
try {
|
|
355
|
+
const second = await claimPendingJobExecution({
|
|
356
|
+
workspaceId,
|
|
357
|
+
runnerSessionId,
|
|
358
|
+
maxClaims: null,
|
|
359
|
+
});
|
|
278
360
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
361
|
+
expect(second).toBeNull();
|
|
362
|
+
expect(await outboxEventsForJob(RUNNER_JOB_CLAIMED, created.jobId)).toHaveLength(
|
|
363
|
+
beforeOrphanClaim.length,
|
|
364
|
+
);
|
|
365
|
+
expect(recordQueueTime).not.toHaveBeenCalled();
|
|
366
|
+
} finally {
|
|
367
|
+
recordQueueTime.mockRestore();
|
|
368
|
+
}
|
|
283
369
|
});
|
|
284
370
|
|
|
285
371
|
it('returns the job ids when a job is available', async () => {
|
package/src/db/job-executions.ts
CHANGED
|
@@ -29,7 +29,14 @@ import {
|
|
|
29
29
|
RunnerSessionExhaustedError,
|
|
30
30
|
RunningJobExecutionNotFoundError,
|
|
31
31
|
} from '#core/errors.js';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
type JobExecutionQueueTimeObservation,
|
|
34
|
+
jobExecutionEnqueuedCount,
|
|
35
|
+
jobExecutionLeaseExpiredCount,
|
|
36
|
+
type ProviderRunnerLifecycleObservation,
|
|
37
|
+
recordJobExecutionQueueTime,
|
|
38
|
+
recordProviderRunnerActivationToFirstClaim,
|
|
39
|
+
} from '#metrics/instance.js';
|
|
33
40
|
import type {Tx} from './db.js';
|
|
34
41
|
import {db} from './db.js';
|
|
35
42
|
import {runnersOutbox} from './schema/outbox.js';
|
|
@@ -192,7 +199,9 @@ export async function claimPendingJobExecution(params: {
|
|
|
192
199
|
|
|
193
200
|
if (params.sessionLabels.length === 0) return null;
|
|
194
201
|
|
|
195
|
-
|
|
202
|
+
let activationToFirstClaimObservation: ProviderRunnerLifecycleObservation | null = null;
|
|
203
|
+
let queueTimeObservation: JobExecutionQueueTimeObservation | null = null;
|
|
204
|
+
const result = await db().transaction(async (tx) => {
|
|
196
205
|
let provisionerId: string | null = null;
|
|
197
206
|
let providerRunnerId: string | null = null;
|
|
198
207
|
let runnerInstanceId: string | null = null;
|
|
@@ -288,6 +297,12 @@ export async function claimPendingJobExecution(params: {
|
|
|
288
297
|
const claimed = inserted[0];
|
|
289
298
|
if (!claimed) return null;
|
|
290
299
|
|
|
300
|
+
queueTimeObservation = {
|
|
301
|
+
durationMilliseconds: claimed.claimedAt.getTime() - row.createdAt.getTime(),
|
|
302
|
+
provider: null,
|
|
303
|
+
launchKind: params.maxClaims === null ? 'manual' : 'unknown',
|
|
304
|
+
};
|
|
305
|
+
|
|
291
306
|
const runnerInstanceCondition = runnerInstanceId
|
|
292
307
|
? eq(providerRunners.id, runnerInstanceId)
|
|
293
308
|
: provisionerId && providerRunnerId
|
|
@@ -297,13 +312,42 @@ export async function claimPendingJobExecution(params: {
|
|
|
297
312
|
)
|
|
298
313
|
: null;
|
|
299
314
|
if (runnerInstanceCondition) {
|
|
300
|
-
await tx
|
|
315
|
+
const [claimedRunner] = await tx
|
|
301
316
|
.update(providerRunners)
|
|
302
317
|
.set({
|
|
303
318
|
firstClaimedAt: sql`coalesce(${providerRunners.firstClaimedAt}, ${claimed.claimedAt})`,
|
|
304
319
|
updatedAt: sql`now()`,
|
|
305
320
|
})
|
|
306
|
-
.where(runnerInstanceCondition)
|
|
321
|
+
.where(runnerInstanceCondition)
|
|
322
|
+
.returning({
|
|
323
|
+
firstClaimedAt: providerRunners.firstClaimedAt,
|
|
324
|
+
isFirstClaim: sql<boolean>`${providerRunners.firstClaimedAt} = ${claimed.claimedAt}`,
|
|
325
|
+
provider: providerRunners.providerKind,
|
|
326
|
+
launchKind: providerRunners.launchKind,
|
|
327
|
+
runnerInstanceId: providerRunners.id,
|
|
328
|
+
sessionCreatedAtEpochMs: sql<number | null>`(
|
|
329
|
+
select extract(epoch from ${runnerSessions.createdAt})::double precision * 1000
|
|
330
|
+
from ${runnerSessions}
|
|
331
|
+
where ${runnerSessions.id} = ${params.runnerSessionId}
|
|
332
|
+
)`,
|
|
333
|
+
});
|
|
334
|
+
if (claimedRunner && queueTimeObservation) {
|
|
335
|
+
queueTimeObservation.provider = claimedRunner.provider;
|
|
336
|
+
queueTimeObservation.launchKind = claimedRunner.launchKind;
|
|
337
|
+
}
|
|
338
|
+
if (
|
|
339
|
+
claimedRunner?.isFirstClaim &&
|
|
340
|
+
claimedRunner.firstClaimedAt !== null &&
|
|
341
|
+
claimedRunner.sessionCreatedAtEpochMs !== null &&
|
|
342
|
+
claimedRunner.sessionCreatedAtEpochMs !== undefined
|
|
343
|
+
)
|
|
344
|
+
activationToFirstClaimObservation = {
|
|
345
|
+
durationMilliseconds:
|
|
346
|
+
claimedRunner.firstClaimedAt.getTime() - claimedRunner.sessionCreatedAtEpochMs,
|
|
347
|
+
provider: claimedRunner.provider,
|
|
348
|
+
launchKind: claimedRunner.launchKind,
|
|
349
|
+
runnerInstanceId: claimedRunner.runnerInstanceId,
|
|
350
|
+
};
|
|
307
351
|
}
|
|
308
352
|
|
|
309
353
|
if (params.maxClaims !== null) {
|
|
@@ -335,6 +379,10 @@ export async function claimPendingJobExecution(params: {
|
|
|
335
379
|
projectId: row.projectId,
|
|
336
380
|
};
|
|
337
381
|
});
|
|
382
|
+
if (queueTimeObservation) recordJobExecutionQueueTime(queueTimeObservation);
|
|
383
|
+
if (activationToFirstClaimObservation)
|
|
384
|
+
recordProviderRunnerActivationToFirstClaim(activationToFirstClaimObservation);
|
|
385
|
+
return result;
|
|
338
386
|
}
|
|
339
387
|
|
|
340
388
|
async function touchRunnerSessionLiveness(params: {
|