@shipfox/api-runners 12.2.0 → 12.3.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 +12 -0
- package/dist/config.js +3 -3
- package/dist/config.js.map +1 -1
- package/dist/core/runner-control-sessions.d.ts.map +1 -1
- package/dist/core/runner-control-sessions.js +14 -2
- package/dist/core/runner-control-sessions.js.map +1 -1
- 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/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +22 -6
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/runner-instances.d.ts +3 -0
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +10 -1
- 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 +22 -15
- package/dist/db/runner-sessions.js.map +1 -1
- package/dist/metrics/service.d.ts.map +1 -1
- package/dist/metrics/service.js +20 -4
- package/dist/metrics/service.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/config.test.ts +2 -2
- package/src/config.ts +3 -3
- package/src/core/runner-control-sessions.test.ts +169 -4
- package/src/core/runner-control-sessions.ts +22 -1
- package/src/db/index.ts +1 -0
- package/src/db/reservations.test.ts +369 -1
- package/src/db/reservations.ts +74 -9
- package/src/db/runner-instances.test.ts +231 -0
- package/src/db/runner-instances.ts +32 -1
- package/src/db/runner-sessions.ts +26 -15
- package/src/metrics/service.test.ts +99 -0
- package/src/metrics/service.ts +24 -4
- package/src/presentation/routes/late-runner-enrollment.test.ts +230 -0
- package/src/presentation/routes/poll-demand.test.ts +23 -6
- package/test/fixtures/late-runner-enrollment.ts +103 -0
- package/test/index.ts +5 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -4,6 +4,7 @@ import {db} from '#db/db.js';
|
|
|
4
4
|
import {createRunnerSessionConsumingEphemeralToken} from '#db/ephemeral-registration-tokens.js';
|
|
5
5
|
import {
|
|
6
6
|
attachRunnerInstanceProviderId,
|
|
7
|
+
countStaleEnrolledRunnerInstances,
|
|
7
8
|
listActiveRunnerInstanceCountsByTemplateTx,
|
|
8
9
|
listActiveRunnerInstances,
|
|
9
10
|
listProvisionerTerminateIntentRowsTx,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
} from '#db/runner-instances.js';
|
|
15
16
|
import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
|
|
16
17
|
import {reservations} from '#db/schema/reservations.js';
|
|
18
|
+
import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
|
|
17
19
|
import {providerRunners} from '#db/schema/runner-instances.js';
|
|
18
20
|
import {runnerSessions} from '#db/schema/runner-sessions.js';
|
|
19
21
|
import {runningJobExecutions} from '#db/schema/running-job-executions.js';
|
|
@@ -231,6 +233,139 @@ describe('reportRunnerInstances', () => {
|
|
|
231
233
|
expect(rows[0]?.reason).toBe('fresh');
|
|
232
234
|
});
|
|
233
235
|
|
|
236
|
+
it('preserves a rebound reservation through stale reports and releases it on termination', async () => {
|
|
237
|
+
const staleReservationId = await createReservation(1);
|
|
238
|
+
await db()
|
|
239
|
+
.update(reservations)
|
|
240
|
+
.set({expiresAt: new Date(Date.now() - 60_000)})
|
|
241
|
+
.where(eq(reservations.id, staleReservationId));
|
|
242
|
+
const reboundReservationId = await createReservation(2);
|
|
243
|
+
const initialReportedAt = new Date(Date.now() - 120_000);
|
|
244
|
+
const runningReportedAt = new Date(Date.now() - 60_000);
|
|
245
|
+
await providerRunnerFactory.create({
|
|
246
|
+
workspaceId,
|
|
247
|
+
provisionerId,
|
|
248
|
+
providerRunnerId: 'rebound-runner',
|
|
249
|
+
reservationId: reboundReservationId,
|
|
250
|
+
state: 'running',
|
|
251
|
+
reportedAt: initialReportedAt,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const runningReport = await reportRunnerInstances({
|
|
255
|
+
scope: 'workspace',
|
|
256
|
+
workspaceId,
|
|
257
|
+
provisionerId,
|
|
258
|
+
events: [
|
|
259
|
+
event({
|
|
260
|
+
providerRunnerId: 'rebound-runner',
|
|
261
|
+
reservationId: staleReservationId,
|
|
262
|
+
reportedAt: runningReportedAt,
|
|
263
|
+
}),
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
const terminalReport = await reportRunnerInstances({
|
|
267
|
+
scope: 'workspace',
|
|
268
|
+
workspaceId,
|
|
269
|
+
provisionerId,
|
|
270
|
+
events: [
|
|
271
|
+
event({
|
|
272
|
+
providerRunnerId: 'rebound-runner',
|
|
273
|
+
reservationId: staleReservationId,
|
|
274
|
+
state: 'failed',
|
|
275
|
+
reportedAt: new Date(runningReportedAt.getTime() + 1_000),
|
|
276
|
+
}),
|
|
277
|
+
],
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const [runner] = await db()
|
|
281
|
+
.select()
|
|
282
|
+
.from(providerRunners)
|
|
283
|
+
.where(eq(providerRunners.providerRunnerId, 'rebound-runner'));
|
|
284
|
+
const reservationRows = await reservationRowsFor({workspaceId, provisionerId});
|
|
285
|
+
expect(runningReport).toEqual({
|
|
286
|
+
accepted: 1,
|
|
287
|
+
reservationsReleased: 0,
|
|
288
|
+
terminateIntentsHonored: [],
|
|
289
|
+
});
|
|
290
|
+
expect(terminalReport).toEqual({
|
|
291
|
+
accepted: 1,
|
|
292
|
+
reservationsReleased: 1,
|
|
293
|
+
terminateIntentsHonored: [],
|
|
294
|
+
});
|
|
295
|
+
expect(runner).toMatchObject({
|
|
296
|
+
reservationId: reboundReservationId,
|
|
297
|
+
reservationReleasedAt: expect.any(Date),
|
|
298
|
+
});
|
|
299
|
+
expect(
|
|
300
|
+
reservationRows.find((reservation) => reservation.id === reboundReservationId)?.count,
|
|
301
|
+
).toBe(1);
|
|
302
|
+
expect(
|
|
303
|
+
reservationRows.find((reservation) => reservation.id === staleReservationId)?.count,
|
|
304
|
+
).toBe(1);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('keeps the stored reservation when a report carries a stale reservation id', async () => {
|
|
308
|
+
const staleReservationId = await createReservation(1);
|
|
309
|
+
const boundReservationId = await createReservation(1);
|
|
310
|
+
await db()
|
|
311
|
+
.insert(providerRunners)
|
|
312
|
+
.values({
|
|
313
|
+
workspaceId,
|
|
314
|
+
provisionerId,
|
|
315
|
+
reservationId: boundReservationId,
|
|
316
|
+
providerRunnerId: 'provisioned-runner-1',
|
|
317
|
+
state: 'running',
|
|
318
|
+
reportedAt: new Date('2025-01-01T00:00:00.000Z'),
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await reportRunnerInstances({
|
|
322
|
+
scope: 'workspace',
|
|
323
|
+
workspaceId,
|
|
324
|
+
provisionerId,
|
|
325
|
+
events: [
|
|
326
|
+
event({
|
|
327
|
+
providerRunnerId: 'provisioned-runner-1',
|
|
328
|
+
reservationId: staleReservationId,
|
|
329
|
+
state: 'running',
|
|
330
|
+
reportedAt: new Date('2025-01-01T00:01:00.000Z'),
|
|
331
|
+
}),
|
|
332
|
+
],
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const rows = await providerRunnerRowsFor({workspaceId, provisionerId});
|
|
336
|
+
expect(rows[0]?.reservationId).toBe(boundReservationId);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('adopts a reported reservation id when the runner carries none', async () => {
|
|
340
|
+
const reservationId = await createReservation(1);
|
|
341
|
+
await db()
|
|
342
|
+
.insert(providerRunners)
|
|
343
|
+
.values({
|
|
344
|
+
workspaceId,
|
|
345
|
+
provisionerId,
|
|
346
|
+
providerRunnerId: 'provisioned-runner-1',
|
|
347
|
+
state: 'starting',
|
|
348
|
+
reportedAt: new Date('2025-01-01T00:00:00.000Z'),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await reportRunnerInstances({
|
|
352
|
+
scope: 'workspace',
|
|
353
|
+
workspaceId,
|
|
354
|
+
provisionerId,
|
|
355
|
+
events: [
|
|
356
|
+
event({
|
|
357
|
+
providerRunnerId: 'provisioned-runner-1',
|
|
358
|
+
reservationId,
|
|
359
|
+
state: 'running',
|
|
360
|
+
reportedAt: new Date('2025-01-01T00:01:00.000Z'),
|
|
361
|
+
}),
|
|
362
|
+
],
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const rows = await providerRunnerRowsFor({workspaceId, provisionerId});
|
|
366
|
+
expect(rows[0]?.reservationId).toBe(reservationId);
|
|
367
|
+
});
|
|
368
|
+
|
|
234
369
|
it('does not let equal-timestamp lower-priority reports flip terminal state', async () => {
|
|
235
370
|
const reservationId = await createReservation(1);
|
|
236
371
|
const reportedAt = new Date('2025-01-01T00:00:00.000Z');
|
|
@@ -2046,6 +2181,102 @@ describe('reconcileRunnerInstances', () => {
|
|
|
2046
2181
|
}
|
|
2047
2182
|
});
|
|
2048
2183
|
|
|
2184
|
+
describe('countStaleEnrolledRunnerInstances', () => {
|
|
2185
|
+
let provisionerId: string;
|
|
2186
|
+
|
|
2187
|
+
beforeEach(() => {
|
|
2188
|
+
provisionerId = crypto.randomUUID();
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
function staleAt(): Date {
|
|
2192
|
+
return new Date(Date.now() - 120_000);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
it('counts only stale reported runners with a live control session and no assignment', async () => {
|
|
2196
|
+
const baseline = await countStaleEnrolledRunnerInstances({graceSeconds: 60});
|
|
2197
|
+
const stale = await createRunner({updatedAt: staleAt()});
|
|
2198
|
+
await createControlSession(stale);
|
|
2199
|
+
|
|
2200
|
+
const fresh = await createRunner({updatedAt: new Date()});
|
|
2201
|
+
await createControlSession(fresh);
|
|
2202
|
+
|
|
2203
|
+
const recentlyMutated = await createRunner({reportedAt: staleAt(), updatedAt: new Date()});
|
|
2204
|
+
await createControlSession(recentlyMutated);
|
|
2205
|
+
|
|
2206
|
+
const freshReport = await createRunner({reportedAt: new Date(), updatedAt: staleAt()});
|
|
2207
|
+
await createControlSession(freshReport);
|
|
2208
|
+
|
|
2209
|
+
const assigned = await createRunner({workspaceId: crypto.randomUUID(), updatedAt: staleAt()});
|
|
2210
|
+
await createControlSession(assigned);
|
|
2211
|
+
|
|
2212
|
+
const activated = await createRunner({
|
|
2213
|
+
runnerSessionId: crypto.randomUUID(),
|
|
2214
|
+
updatedAt: staleAt(),
|
|
2215
|
+
});
|
|
2216
|
+
await createControlSession(activated);
|
|
2217
|
+
|
|
2218
|
+
await createRunner({updatedAt: staleAt()});
|
|
2219
|
+
const expiredControlSession = await createRunner({updatedAt: staleAt()});
|
|
2220
|
+
await createControlSession(expiredControlSession, {
|
|
2221
|
+
expiresAt: new Date(Date.now() - 1_000),
|
|
2222
|
+
});
|
|
2223
|
+
const closedControlSession = await createRunner({updatedAt: staleAt()});
|
|
2224
|
+
await createControlSession(closedControlSession, {
|
|
2225
|
+
closedAt: new Date(),
|
|
2226
|
+
});
|
|
2227
|
+
const stopped = await createRunner({state: 'stopped', updatedAt: staleAt()});
|
|
2228
|
+
await createControlSession(stopped);
|
|
2229
|
+
|
|
2230
|
+
const count = await countStaleEnrolledRunnerInstances({graceSeconds: 60});
|
|
2231
|
+
|
|
2232
|
+
expect(count - baseline).toBe(2);
|
|
2233
|
+
});
|
|
2234
|
+
|
|
2235
|
+
async function createRunner(params: {
|
|
2236
|
+
state?: 'running' | 'stopped';
|
|
2237
|
+
workspaceId?: string | null;
|
|
2238
|
+
runnerSessionId?: string | null;
|
|
2239
|
+
reportedAt?: Date;
|
|
2240
|
+
updatedAt?: Date;
|
|
2241
|
+
}) {
|
|
2242
|
+
const reportedAt = params.reportedAt ?? params.updatedAt ?? new Date();
|
|
2243
|
+
const [runner] = await db()
|
|
2244
|
+
.insert(providerRunners)
|
|
2245
|
+
.values({
|
|
2246
|
+
workspaceId: params.workspaceId ?? null,
|
|
2247
|
+
provisionerId,
|
|
2248
|
+
providerRunnerId: crypto.randomUUID(),
|
|
2249
|
+
templateKey: 'linux',
|
|
2250
|
+
labels: ['linux'],
|
|
2251
|
+
state: params.state ?? 'running',
|
|
2252
|
+
runnerSessionId: params.runnerSessionId ?? null,
|
|
2253
|
+
providerKind: 'docker',
|
|
2254
|
+
reportedAt,
|
|
2255
|
+
updatedAt: params.updatedAt ?? reportedAt,
|
|
2256
|
+
})
|
|
2257
|
+
.returning({id: providerRunners.id});
|
|
2258
|
+
if (!runner) throw new Error('Expected runner instance');
|
|
2259
|
+
return runner.id;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
async function createControlSession(
|
|
2263
|
+
runnerInstanceId: string,
|
|
2264
|
+
params: {expiresAt?: Date; closedAt?: Date} = {},
|
|
2265
|
+
) {
|
|
2266
|
+
await db()
|
|
2267
|
+
.insert(runnerControlSessions)
|
|
2268
|
+
.values({
|
|
2269
|
+
runnerInstanceId,
|
|
2270
|
+
provisionerId,
|
|
2271
|
+
hashedToken: crypto.randomUUID(),
|
|
2272
|
+
prefix: 'test',
|
|
2273
|
+
expiresAt: params.expiresAt ?? new Date(Date.now() + 60_000),
|
|
2274
|
+
closedAt: params.closedAt,
|
|
2275
|
+
closeReason: params.closedAt ? 'test' : null,
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2049
2280
|
describe('runner instance provider attachment', () => {
|
|
2050
2281
|
it('updates the pre-created runner instance before its first lifecycle report', async () => {
|
|
2051
2282
|
const provisionerId = crypto.randomUUID();
|
|
@@ -29,6 +29,7 @@ import {releaseReservationUnits} from './reservations.js';
|
|
|
29
29
|
import {ephemeralRegistrationTokens} from './schema/ephemeral-registration-tokens.js';
|
|
30
30
|
import {provisionerTokens} from './schema/provisioner-tokens.js';
|
|
31
31
|
import {reservations} from './schema/reservations.js';
|
|
32
|
+
import {runnerControlSessions} from './schema/runner-control-sessions.js';
|
|
32
33
|
import {providerRunners, toRunnerInstance} from './schema/runner-instances.js';
|
|
33
34
|
import {runnerSessions} from './schema/runner-sessions.js';
|
|
34
35
|
import {runningJobExecutions} from './schema/running-job-executions.js';
|
|
@@ -190,7 +191,7 @@ export async function reportRunnerInstances(params: ReportRunnerInstancesParams)
|
|
|
190
191
|
target: [providerRunners.provisionerId, providerRunners.providerRunnerId],
|
|
191
192
|
targetWhere: isNotNull(providerRunners.providerRunnerId),
|
|
192
193
|
set: {
|
|
193
|
-
reservationId: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN coalesce(
|
|
194
|
+
reservationId: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN coalesce(${providerRunners.reservationId}, excluded.reservation_id) ELSE ${providerRunners.reservationId} END`,
|
|
194
195
|
templateKey: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN coalesce(excluded.template_key, ${providerRunners.templateKey}) ELSE ${providerRunners.templateKey} END`,
|
|
195
196
|
labels: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN excluded.labels ELSE ${providerRunners.labels} END`,
|
|
196
197
|
state: sql`CASE WHEN ${providerRunnerProjectionUpdateCondition()} THEN excluded.state ELSE ${providerRunners.state} END`,
|
|
@@ -247,6 +248,36 @@ export async function listActiveRunnerInstanceCountsByTemplateTx(
|
|
|
247
248
|
);
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
export async function countStaleEnrolledRunnerInstances(params: {
|
|
252
|
+
graceSeconds: number;
|
|
253
|
+
}): Promise<number> {
|
|
254
|
+
const [row] = await db()
|
|
255
|
+
.select({count: sql<number>`count(*)::int`})
|
|
256
|
+
.from(providerRunners)
|
|
257
|
+
.where(
|
|
258
|
+
and(
|
|
259
|
+
eq(providerRunners.state, 'running'),
|
|
260
|
+
isNull(providerRunners.workspaceId),
|
|
261
|
+
isNull(providerRunners.runnerSessionId),
|
|
262
|
+
lt(providerRunners.reportedAt, staleRunnerInstanceCutoff(params.graceSeconds)),
|
|
263
|
+
exists(
|
|
264
|
+
db()
|
|
265
|
+
.select({id: runnerControlSessions.id})
|
|
266
|
+
.from(runnerControlSessions)
|
|
267
|
+
.where(
|
|
268
|
+
and(
|
|
269
|
+
eq(runnerControlSessions.runnerInstanceId, providerRunners.id),
|
|
270
|
+
isNull(runnerControlSessions.closedAt),
|
|
271
|
+
gt(runnerControlSessions.expiresAt, sql`now()`),
|
|
272
|
+
),
|
|
273
|
+
),
|
|
274
|
+
),
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
return row?.count ?? 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
250
281
|
export async function listActiveRunnerInstances(params: {
|
|
251
282
|
workspaceId: string;
|
|
252
283
|
windowSeconds: number;
|
|
@@ -59,33 +59,44 @@ export async function createRunnerSessionConsumingActivationToken(params: {
|
|
|
59
59
|
toolCapabilities?: RunnerToolCapabilitiesDto | null;
|
|
60
60
|
}) {
|
|
61
61
|
return await db().transaction(async (tx) => {
|
|
62
|
-
const [
|
|
62
|
+
const [runner] = await tx
|
|
63
63
|
.select({
|
|
64
|
-
|
|
65
|
-
runnerInstanceId:
|
|
64
|
+
activationTokenId: runnerActivationTokens.id,
|
|
65
|
+
runnerInstanceId: providerRunners.id,
|
|
66
66
|
workspaceId: providerRunners.workspaceId,
|
|
67
67
|
provisionerId: providerRunners.provisionerId,
|
|
68
68
|
providerRunnerId: providerRunners.providerRunnerId,
|
|
69
|
+
runnerSessionId: providerRunners.runnerSessionId,
|
|
69
70
|
})
|
|
70
71
|
.from(runnerActivationTokens)
|
|
71
72
|
.innerJoin(providerRunners, eq(providerRunners.id, runnerActivationTokens.runnerInstanceId))
|
|
73
|
+
.where(eq(runnerActivationTokens.id, params.activationTokenId))
|
|
74
|
+
.limit(1)
|
|
75
|
+
.for('update', {of: providerRunners});
|
|
76
|
+
if (!runner?.workspaceId || !runner.providerRunnerId || runner.runnerSessionId)
|
|
77
|
+
throw new Error('Runner activation token is invalid, expired, or has already been used');
|
|
78
|
+
|
|
79
|
+
const [activationToken] = await tx
|
|
80
|
+
.select({id: runnerActivationTokens.id})
|
|
81
|
+
.from(runnerActivationTokens)
|
|
72
82
|
.where(
|
|
73
83
|
and(
|
|
74
|
-
eq(runnerActivationTokens.id,
|
|
84
|
+
eq(runnerActivationTokens.id, runner.activationTokenId),
|
|
85
|
+
eq(runnerActivationTokens.runnerInstanceId, runner.runnerInstanceId),
|
|
75
86
|
isNull(runnerActivationTokens.consumedAt),
|
|
76
87
|
isNull(runnerActivationTokens.revokedAt),
|
|
77
88
|
gt(runnerActivationTokens.expiresAt, sql`now()`),
|
|
78
|
-
isNull(providerRunners.runnerSessionId),
|
|
79
89
|
),
|
|
80
90
|
)
|
|
81
91
|
.limit(1)
|
|
82
92
|
.for('update');
|
|
83
|
-
if (!
|
|
93
|
+
if (!activationToken)
|
|
84
94
|
throw new Error('Runner activation token is invalid, expired, or has already been used');
|
|
95
|
+
|
|
85
96
|
const [provisioner] = await tx
|
|
86
97
|
.select({scope: provisionerTokens.scope})
|
|
87
98
|
.from(provisionerTokens)
|
|
88
|
-
.where(eq(provisionerTokens.id,
|
|
99
|
+
.where(eq(provisionerTokens.id, runner.provisionerId))
|
|
89
100
|
.limit(1);
|
|
90
101
|
const labels = sanitizeRunnerLabelsOrThrow(params.labels, {
|
|
91
102
|
scope: provisioner?.scope ?? 'workspace',
|
|
@@ -95,13 +106,13 @@ export async function createRunnerSessionConsumingActivationToken(params: {
|
|
|
95
106
|
const [session] = await tx
|
|
96
107
|
.insert(runnerSessions)
|
|
97
108
|
.values({
|
|
98
|
-
workspaceId:
|
|
109
|
+
workspaceId: runner.workspaceId,
|
|
99
110
|
scope: 'workspace',
|
|
100
|
-
registrationTokenId:
|
|
111
|
+
registrationTokenId: activationToken.id,
|
|
101
112
|
registrationTokenKind: 'activation',
|
|
102
|
-
runnerInstanceId:
|
|
103
|
-
provisionerId:
|
|
104
|
-
providerRunnerId:
|
|
113
|
+
runnerInstanceId: runner.runnerInstanceId,
|
|
114
|
+
provisionerId: runner.provisionerId,
|
|
115
|
+
providerRunnerId: runner.providerRunnerId,
|
|
105
116
|
labels,
|
|
106
117
|
toolCapabilities: params.toolCapabilities ?? null,
|
|
107
118
|
toolCapabilitiesReportedAt: params.toolCapabilities ? sql`now()` : null,
|
|
@@ -113,13 +124,13 @@ export async function createRunnerSessionConsumingActivationToken(params: {
|
|
|
113
124
|
await tx
|
|
114
125
|
.update(runnerActivationTokens)
|
|
115
126
|
.set({consumedAt: sql`now()`, consumedSessionId: session.id})
|
|
116
|
-
.where(eq(runnerActivationTokens.id,
|
|
127
|
+
.where(eq(runnerActivationTokens.id, activationToken.id));
|
|
117
128
|
await tx
|
|
118
129
|
.update(providerRunners)
|
|
119
130
|
.set({runnerSessionId: session.id, updatedAt: sql`now()`})
|
|
120
131
|
.where(
|
|
121
132
|
and(
|
|
122
|
-
eq(providerRunners.id,
|
|
133
|
+
eq(providerRunners.id, runner.runnerInstanceId),
|
|
123
134
|
isNull(providerRunners.runnerSessionId),
|
|
124
135
|
),
|
|
125
136
|
);
|
|
@@ -128,7 +139,7 @@ export async function createRunnerSessionConsumingActivationToken(params: {
|
|
|
128
139
|
.set({closedAt: sql`now()`, closeReason: 'activated'})
|
|
129
140
|
.where(
|
|
130
141
|
and(
|
|
131
|
-
eq(runnerControlSessions.runnerInstanceId,
|
|
142
|
+
eq(runnerControlSessions.runnerInstanceId, runner.runnerInstanceId),
|
|
132
143
|
isNull(runnerControlSessions.closedAt),
|
|
133
144
|
),
|
|
134
145
|
);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const mocks = vi.hoisted(() => {
|
|
2
|
+
const gauges = {
|
|
3
|
+
enrolledRunnersWithoutRecentReport: {},
|
|
4
|
+
pendingJobExecutions: {},
|
|
5
|
+
runningJobExecutions: {},
|
|
6
|
+
};
|
|
7
|
+
const gaugeByName = {
|
|
8
|
+
runners_enrolled_without_recent_report: gauges.enrolledRunnersWithoutRecentReport,
|
|
9
|
+
runners_pending_job_executions: gauges.pendingJobExecutions,
|
|
10
|
+
runners_running_job_executions: gauges.runningJobExecutions,
|
|
11
|
+
};
|
|
12
|
+
return {
|
|
13
|
+
addBatchObservableCallback: vi.fn(),
|
|
14
|
+
countStaleEnrolledRunnerInstances: vi.fn(),
|
|
15
|
+
createObservableGauge: vi.fn((name: string) => gaugeByName[name as keyof typeof gaugeByName]),
|
|
16
|
+
gauges,
|
|
17
|
+
getMeter: vi.fn(),
|
|
18
|
+
getJobExecutionQueueDepth: vi.fn(),
|
|
19
|
+
getServiceMetricsProvider: vi.fn(),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
vi.mock('@shipfox/node-opentelemetry', () => ({
|
|
24
|
+
getServiceMetricsProvider: mocks.getServiceMetricsProvider,
|
|
25
|
+
}));
|
|
26
|
+
vi.mock('#config.js', () => ({
|
|
27
|
+
config: {RUNNER_STALE_PROVISIONED_RUNNER_THRESHOLD_SECONDS: 300},
|
|
28
|
+
}));
|
|
29
|
+
vi.mock('#db/job-executions.js', () => ({
|
|
30
|
+
getJobExecutionQueueDepth: mocks.getJobExecutionQueueDepth,
|
|
31
|
+
}));
|
|
32
|
+
vi.mock('#db/runner-instances.js', () => ({
|
|
33
|
+
countStaleEnrolledRunnerInstances: mocks.countStaleEnrolledRunnerInstances,
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
import {registerRunnersServiceMetrics} from './service.js';
|
|
37
|
+
|
|
38
|
+
describe('registerRunnersServiceMetrics', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
mocks.addBatchObservableCallback.mockReset();
|
|
41
|
+
mocks.countStaleEnrolledRunnerInstances.mockReset();
|
|
42
|
+
mocks.createObservableGauge.mockClear();
|
|
43
|
+
mocks.getJobExecutionQueueDepth.mockReset();
|
|
44
|
+
mocks.getMeter.mockReset();
|
|
45
|
+
mocks.getServiceMetricsProvider.mockReset();
|
|
46
|
+
mocks.getJobExecutionQueueDepth.mockResolvedValue({
|
|
47
|
+
pendingJobExecutions: 0,
|
|
48
|
+
runningJobExecutions: 0,
|
|
49
|
+
});
|
|
50
|
+
mocks.getMeter.mockReturnValue({
|
|
51
|
+
createObservableGauge: mocks.createObservableGauge,
|
|
52
|
+
addBatchObservableCallback: mocks.addBatchObservableCallback,
|
|
53
|
+
});
|
|
54
|
+
mocks.getServiceMetricsProvider.mockReturnValue({getMeter: mocks.getMeter});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('observes enrolled runners without recent reports after the grace window', async () => {
|
|
58
|
+
mocks.countStaleEnrolledRunnerInstances.mockResolvedValue(2);
|
|
59
|
+
|
|
60
|
+
registerRunnersServiceMetrics();
|
|
61
|
+
const callback = mocks.addBatchObservableCallback.mock.calls[0]?.[0];
|
|
62
|
+
if (typeof callback !== 'function') throw new Error('Expected metrics callback');
|
|
63
|
+
const observer = {observe: vi.fn()};
|
|
64
|
+
|
|
65
|
+
await callback(observer);
|
|
66
|
+
|
|
67
|
+
expect(mocks.createObservableGauge).toHaveBeenCalledWith(
|
|
68
|
+
'runners_enrolled_without_recent_report',
|
|
69
|
+
{
|
|
70
|
+
description:
|
|
71
|
+
'Running enrolled runners with a live control session, no workspace or runner session, and no recent provisioner report after the stale-runner grace window',
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
expect(mocks.countStaleEnrolledRunnerInstances).toHaveBeenCalledWith({graceSeconds: 300});
|
|
75
|
+
expect(observer.observe).toHaveBeenCalledWith(
|
|
76
|
+
mocks.gauges.enrolledRunnersWithoutRecentReport,
|
|
77
|
+
2,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps queue gauges observable when the enrolled-runner query fails', async () => {
|
|
82
|
+
mocks.getJobExecutionQueueDepth.mockResolvedValue({
|
|
83
|
+
pendingJobExecutions: 3,
|
|
84
|
+
runningJobExecutions: 4,
|
|
85
|
+
});
|
|
86
|
+
mocks.countStaleEnrolledRunnerInstances.mockRejectedValue(new Error('database unavailable'));
|
|
87
|
+
|
|
88
|
+
registerRunnersServiceMetrics();
|
|
89
|
+
const callback = mocks.addBatchObservableCallback.mock.calls[0]?.[0];
|
|
90
|
+
if (typeof callback !== 'function') throw new Error('Expected metrics callback');
|
|
91
|
+
const observer = {observe: vi.fn()};
|
|
92
|
+
|
|
93
|
+
await callback(observer);
|
|
94
|
+
|
|
95
|
+
expect(observer.observe).toHaveBeenCalledWith(mocks.gauges.pendingJobExecutions, 3);
|
|
96
|
+
expect(observer.observe).toHaveBeenCalledWith(mocks.gauges.runningJobExecutions, 4);
|
|
97
|
+
expect(observer.observe).toHaveBeenCalledTimes(2);
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/metrics/service.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {getServiceMetricsProvider} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import {config} from '#config.js';
|
|
2
3
|
import {getJobExecutionQueueDepth} from '#db/job-executions.js';
|
|
4
|
+
import {countStaleEnrolledRunnerInstances} from '#db/runner-instances.js';
|
|
3
5
|
|
|
4
6
|
export function registerRunnersServiceMetrics(): void {
|
|
5
7
|
const meter = getServiceMetricsProvider().getMeter('runners');
|
|
@@ -10,13 +12,31 @@ export function registerRunnersServiceMetrics(): void {
|
|
|
10
12
|
const runningJobExecutions = meter.createObservableGauge('runners_running_job_executions', {
|
|
11
13
|
description: 'Job executions currently claimed by a runner and in progress',
|
|
12
14
|
});
|
|
15
|
+
const enrolledRunnersWithoutRecentReport = meter.createObservableGauge(
|
|
16
|
+
'runners_enrolled_without_recent_report',
|
|
17
|
+
{
|
|
18
|
+
description:
|
|
19
|
+
'Running enrolled runners with a live control session, no workspace or runner session, and no recent provisioner report after the stale-runner grace window',
|
|
20
|
+
},
|
|
21
|
+
);
|
|
13
22
|
|
|
14
23
|
meter.addBatchObservableCallback(
|
|
15
24
|
async (observer) => {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
const [depthResult, staleEnrolledRunnerCountResult] = await Promise.allSettled([
|
|
26
|
+
getJobExecutionQueueDepth(),
|
|
27
|
+
countStaleEnrolledRunnerInstances({
|
|
28
|
+
graceSeconds: config.RUNNER_STALE_PROVISIONED_RUNNER_THRESHOLD_SECONDS,
|
|
29
|
+
}),
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
if (depthResult.status === 'fulfilled') {
|
|
33
|
+
observer.observe(pendingJobExecutions, depthResult.value.pendingJobExecutions);
|
|
34
|
+
observer.observe(runningJobExecutions, depthResult.value.runningJobExecutions);
|
|
35
|
+
}
|
|
36
|
+
if (staleEnrolledRunnerCountResult.status === 'fulfilled') {
|
|
37
|
+
observer.observe(enrolledRunnersWithoutRecentReport, staleEnrolledRunnerCountResult.value);
|
|
38
|
+
}
|
|
19
39
|
},
|
|
20
|
-
[pendingJobExecutions, runningJobExecutions],
|
|
40
|
+
[pendingJobExecutions, runningJobExecutions, enrolledRunnersWithoutRecentReport],
|
|
21
41
|
);
|
|
22
42
|
}
|