@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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +21 -0
  3. package/dist/core/errors.d.ts +5 -1
  4. package/dist/core/errors.d.ts.map +1 -1
  5. package/dist/core/errors.js +9 -2
  6. package/dist/core/errors.js.map +1 -1
  7. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  8. package/dist/core/runner-control-sessions.js +62 -12
  9. package/dist/core/runner-control-sessions.js.map +1 -1
  10. package/dist/db/job-executions.d.ts.map +1 -1
  11. package/dist/db/job-executions.js +35 -4
  12. package/dist/db/job-executions.js.map +1 -1
  13. package/dist/db/runner-assignments.d.ts +8 -1
  14. package/dist/db/runner-assignments.d.ts.map +1 -1
  15. package/dist/db/runner-assignments.js +57 -10
  16. package/dist/db/runner-assignments.js.map +1 -1
  17. package/dist/db/runner-instances.d.ts +9 -0
  18. package/dist/db/runner-instances.d.ts.map +1 -1
  19. package/dist/db/runner-instances.js +43 -0
  20. package/dist/db/runner-instances.js.map +1 -1
  21. package/dist/db/runner-sessions.d.ts.map +1 -1
  22. package/dist/db/runner-sessions.js +14 -1
  23. package/dist/db/runner-sessions.js.map +1 -1
  24. package/dist/metrics/index.d.ts +2 -2
  25. package/dist/metrics/index.d.ts.map +1 -1
  26. package/dist/metrics/index.js +1 -1
  27. package/dist/metrics/index.js.map +1 -1
  28. package/dist/metrics/instance.d.ts +48 -0
  29. package/dist/metrics/instance.d.ts.map +1 -1
  30. package/dist/metrics/instance.js +138 -1
  31. package/dist/metrics/instance.js.map +1 -1
  32. package/dist/metrics/service.d.ts.map +1 -1
  33. package/dist/metrics/service.js +25 -4
  34. package/dist/metrics/service.js.map +1 -1
  35. package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
  36. package/dist/presentation/routes/assign-runner-instances.js +7 -1
  37. package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
  38. package/dist/tsconfig.test.tsbuildinfo +1 -1
  39. package/package.json +2 -2
  40. package/src/core/errors.ts +30 -1
  41. package/src/core/runner-control-sessions.test.ts +71 -0
  42. package/src/core/runner-control-sessions.ts +79 -10
  43. package/src/db/job-executions.test.ts +98 -12
  44. package/src/db/job-executions.ts +52 -4
  45. package/src/db/runner-assignments.ts +67 -16
  46. package/src/db/runner-instances.test.ts +92 -0
  47. package/src/db/runner-instances.ts +74 -0
  48. package/src/db/runner-sessions.ts +19 -1
  49. package/src/metrics/index.ts +14 -0
  50. package/src/metrics/instance.test.ts +118 -0
  51. package/src/metrics/instance.ts +188 -1
  52. package/src/metrics/service.test.ts +38 -0
  53. package/src/metrics/service.ts +55 -8
  54. package/src/presentation/routes/assign-runner-instances.ts +8 -0
  55. 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.0",
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.3.0",
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",
@@ -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(public readonly runnerInstanceId: string) {
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
- return await db().transaction(async (tx) => {
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({id: runnerControlSessions.id});
182
+ .returning({createdAt: runnerControlSessions.createdAt});
164
183
  if (!session) throw new Error('Runner control session insert returned no row');
165
- return {runnerInstanceId: bootstrap.runnerInstanceId, controlSessionToken, expiresAt};
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
- return await db().transaction(async (tx) => {
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) return null;
306
+ if (!intendedReservationId)
307
+ return {
308
+ activationToken: null,
309
+ controlSessionToAssignment: [],
310
+ assignmentRejectedReason: null,
311
+ promotionFailureReason: null,
312
+ };
270
313
 
271
314
  try {
272
- return await tx.transaction(async (promotionTx) => {
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
- return await issueRunnerActivationTokenTx(promotionTx, {
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 null;
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
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
318
+ try {
319
+ const claimed = await claimPendingJobExecution({
320
+ workspaceId,
321
+ runnerSessionId,
322
+ maxClaims: null,
323
+ });
252
324
 
253
- const after = await db()
254
- .select()
255
- .from(runnersOutbox)
256
- .where(eq(runnersOutbox.eventType, RUNNER_JOB_CLAIMED));
257
- expect(claimed).toBeNull();
258
- expect(after).toHaveLength(before.length);
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
- const second = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
354
+ try {
355
+ const second = await claimPendingJobExecution({
356
+ workspaceId,
357
+ runnerSessionId,
358
+ maxClaims: null,
359
+ });
278
360
 
279
- expect(second).toBeNull();
280
- expect(await outboxEventsForJob(RUNNER_JOB_CLAIMED, created.jobId)).toHaveLength(
281
- beforeOrphanClaim.length,
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 () => {
@@ -29,7 +29,14 @@ import {
29
29
  RunnerSessionExhaustedError,
30
30
  RunningJobExecutionNotFoundError,
31
31
  } from '#core/errors.js';
32
- import {jobExecutionEnqueuedCount, jobExecutionLeaseExpiredCount} from '#metrics/instance.js';
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
- return await db().transaction(async (tx) => {
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: {