@shipfox/api-runners 12.3.0 → 12.4.1
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 +36 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -2
- package/dist/config.js.map +1 -1
- package/dist/core/demand.d.ts +4 -2
- package/dist/core/demand.d.ts.map +1 -1
- package/dist/core/demand.js +22 -6
- package/dist/core/demand.js.map +1 -1
- package/dist/core/entities/reservation.d.ts +1 -0
- package/dist/core/entities/reservation.d.ts.map +1 -1
- package/dist/core/entities/reservation.js.map +1 -1
- package/dist/core/entities/runner-instance.d.ts +3 -0
- package/dist/core/entities/runner-instance.d.ts.map +1 -1
- package/dist/core/entities/runner-instance.js.map +1 -1
- 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-activation.d.ts +4 -1
- package/dist/core/runner-activation.d.ts.map +1 -1
- package/dist/core/runner-activation.js +32 -3
- package/dist/core/runner-activation.js.map +1 -1
- package/dist/core/runner-control-sessions.d.ts +8 -4
- package/dist/core/runner-control-sessions.d.ts.map +1 -1
- package/dist/core/runner-control-sessions.js +116 -20
- package/dist/core/runner-control-sessions.js.map +1 -1
- package/dist/core/runner-instances.d.ts +1 -0
- package/dist/core/runner-instances.d.ts.map +1 -1
- package/dist/core/runner-instances.js +1 -0
- package/dist/core/runner-instances.js.map +1 -1
- package/dist/db/db.d.ts +68 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
- package/dist/db/ephemeral-registration-tokens.js +1 -1
- package/dist/db/ephemeral-registration-tokens.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/reservation-locks.d.ts +6 -0
- package/dist/db/reservation-locks.d.ts.map +1 -0
- package/dist/db/reservation-locks.js +15 -0
- package/dist/db/reservation-locks.js.map +1 -0
- package/dist/db/reservations.d.ts +17 -3
- package/dist/db/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +246 -51
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/runner-assignments.d.ts +25 -1
- package/dist/db/runner-assignments.d.ts.map +1 -1
- package/dist/db/runner-assignments.js +160 -18
- package/dist/db/runner-assignments.js.map +1 -1
- package/dist/db/runner-instances.d.ts +11 -2
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +167 -24
- 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/db/runner-states.d.ts +2 -0
- package/dist/db/runner-states.d.ts.map +1 -0
- package/dist/db/runner-states.js +7 -0
- package/dist/db/runner-states.js.map +1 -0
- package/dist/db/schema/reservations.d.ts +18 -0
- package/dist/db/schema/reservations.d.ts.map +1 -1
- package/dist/db/schema/reservations.js +7 -1
- package/dist/db/schema/reservations.js.map +1 -1
- package/dist/db/schema/runner-instances.d.ts +18 -0
- package/dist/db/schema/runner-instances.d.ts.map +1 -1
- package/dist/db/schema/runner-instances.js +8 -0
- package/dist/db/schema/runner-instances.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 +72 -2
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +161 -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/dto/poll-demand.d.ts.map +1 -1
- package/dist/presentation/dto/poll-demand.js +3 -0
- package/dist/presentation/dto/poll-demand.js.map +1 -1
- package/dist/presentation/dto/runner-instances.d.ts +4 -1
- package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
- package/dist/presentation/dto/runner-instances.js +4 -1
- package/dist/presentation/dto/runner-instances.js.map +1 -1
- package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
- package/dist/presentation/routes/assign-runner-instances.js +10 -4
- package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
- package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
- package/dist/presentation/routes/poll-demand.js +1 -0
- package/dist/presentation/routes/poll-demand.js.map +1 -1
- package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
- package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
- package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
- package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
- package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
- package/dist/presentation/routes/runner-enrollment.js +11 -6
- package/dist/presentation/routes/runner-enrollment.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0005_previous_lenny_balinger.sql +5 -0
- package/drizzle/meta/0005_snapshot.json +2220 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -6
- package/src/config.test.ts +22 -0
- package/src/config.ts +15 -2
- package/src/core/demand.test.ts +192 -3
- package/src/core/demand.ts +44 -4
- package/src/core/entities/reservation.ts +1 -0
- package/src/core/entities/runner-instance.ts +4 -0
- package/src/core/errors.ts +30 -1
- package/src/core/runner-activation.ts +48 -2
- package/src/core/runner-control-sessions.test.ts +135 -3
- package/src/core/runner-control-sessions.ts +141 -19
- package/src/core/runner-instances.test.ts +19 -0
- package/src/core/runner-instances.ts +2 -0
- package/src/core/runner-sessions.test.ts +121 -1
- package/src/db/ephemeral-registration-tokens.test.ts +20 -1
- package/src/db/ephemeral-registration-tokens.ts +1 -0
- package/src/db/job-executions.test.ts +98 -12
- package/src/db/job-executions.ts +52 -4
- package/src/db/reservation-locks.ts +22 -0
- package/src/db/reservations.test.ts +733 -22
- package/src/db/reservations.ts +408 -144
- package/src/db/runner-assignments.test.ts +105 -1
- package/src/db/runner-assignments.ts +245 -25
- package/src/db/runner-instances.test.ts +357 -2
- package/src/db/runner-instances.ts +289 -55
- package/src/db/runner-sessions.ts +19 -1
- package/src/db/runner-states.ts +7 -0
- package/src/db/schema/reservations.ts +5 -1
- package/src/db/schema/runner-instances.ts +9 -0
- package/src/metrics/index.ts +24 -1
- package/src/metrics/instance.test.ts +118 -0
- package/src/metrics/instance.ts +247 -3
- package/src/metrics/service.test.ts +46 -1
- package/src/metrics/service.ts +55 -8
- package/src/presentation/dto/poll-demand.ts +3 -0
- package/src/presentation/dto/runner-instances.ts +8 -1
- package/src/presentation/routes/assign-runner-instances.ts +14 -2
- package/src/presentation/routes/late-runner-enrollment.test.ts +219 -2
- package/src/presentation/routes/poll-demand.test.ts +56 -0
- package/src/presentation/routes/poll-demand.ts +1 -0
- package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
- package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
- package/src/presentation/routes/runner-enrollment.test.ts +316 -3
- package/src/presentation/routes/runner-enrollment.ts +8 -5
- package/test/factories/reservation.ts +2 -0
- package/test/factories/runner-instance.ts +6 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import {pgClient} from '@shipfox/node-postgres';
|
|
2
|
+
import {vi} from '@shipfox/vitest/vi';
|
|
1
3
|
import {and, eq, inArray, or, sql, sum} from 'drizzle-orm';
|
|
4
|
+
import type {RunnerInstanceState} from '#core/entities/runner-instance.js';
|
|
2
5
|
import {db} from '#db/db.js';
|
|
3
6
|
import {
|
|
4
7
|
deleteExpiredReservations,
|
|
@@ -7,10 +10,12 @@ import {
|
|
|
7
10
|
pollInstallationDemandAndReserve,
|
|
8
11
|
releaseReservationUnits,
|
|
9
12
|
} from '#db/reservations.js';
|
|
13
|
+
import {pendingJobExecutions} from '#db/schema/pending-job-executions.js';
|
|
10
14
|
import {reservations} from '#db/schema/reservations.js';
|
|
11
15
|
import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
|
|
12
16
|
import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
|
|
13
17
|
import {providerRunners} from '#db/schema/runner-instances.js';
|
|
18
|
+
import {providerRunnerActivationOutcomeCount} from '#metrics/instance.js';
|
|
14
19
|
import {pendingJobFactory, reservationFactory} from '#test/index.js';
|
|
15
20
|
|
|
16
21
|
describe('pollDemandAndReserve', () => {
|
|
@@ -38,7 +43,24 @@ describe('pollDemandAndReserve', () => {
|
|
|
38
43
|
expect(result.stats[0]).toMatchObject({labels: ['linux'], queued: 50, reserved: 20});
|
|
39
44
|
});
|
|
40
45
|
|
|
41
|
-
it('
|
|
46
|
+
it('does not create a bound reservation when no idle runner can be rebound', async () => {
|
|
47
|
+
await createPendingJobs(1, ['linux']);
|
|
48
|
+
|
|
49
|
+
const result = await pollDemandAndReserve({
|
|
50
|
+
workspaceId,
|
|
51
|
+
provisionerId,
|
|
52
|
+
maxReservations: 1,
|
|
53
|
+
ttlSeconds: 60,
|
|
54
|
+
templates: [template('linux', ['linux'], 1)],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const storedReservations = await reservationsForTest();
|
|
58
|
+
expect(result.reservations).toHaveLength(1);
|
|
59
|
+
expect(storedReservations).toHaveLength(1);
|
|
60
|
+
expect(storedReservations[0]).toMatchObject({kind: 'launch', count: 1});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('binds the oldest matching enrolled runners and returns only the launch remainder', async () => {
|
|
42
64
|
const olderRunner = await createIdleRunner({
|
|
43
65
|
labels: ['linux'],
|
|
44
66
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
@@ -51,14 +73,14 @@ describe('pollDemandAndReserve', () => {
|
|
|
51
73
|
labels: ['macos'],
|
|
52
74
|
createdAt: new Date('2026-01-03T00:00:00.000Z'),
|
|
53
75
|
});
|
|
54
|
-
await createPendingJobs(
|
|
76
|
+
await createPendingJobs(3, ['linux']);
|
|
55
77
|
|
|
56
78
|
const result = await pollDemandAndReserve({
|
|
57
79
|
workspaceId,
|
|
58
80
|
provisionerId,
|
|
59
|
-
maxReservations:
|
|
81
|
+
maxReservations: 3,
|
|
60
82
|
ttlSeconds: 60,
|
|
61
|
-
templates: [template('linux', ['linux'],
|
|
83
|
+
templates: [template('linux', ['linux'], 3)],
|
|
62
84
|
});
|
|
63
85
|
|
|
64
86
|
const rows = await db()
|
|
@@ -66,9 +88,21 @@ describe('pollDemandAndReserve', () => {
|
|
|
66
88
|
.from(providerRunners)
|
|
67
89
|
.where(inArray(providerRunners.id, [olderRunner.id, newerRunner.id, unmatchedRunner.id]));
|
|
68
90
|
const rowsById = new Map(rows.map((row) => [row.id, row]));
|
|
69
|
-
const
|
|
91
|
+
const [boundReservation] = await db()
|
|
92
|
+
.select()
|
|
93
|
+
.from(reservations)
|
|
94
|
+
.where(and(eq(reservations.workspaceId, workspaceId), eq(reservations.kind, 'bound')));
|
|
95
|
+
const [launchReservation] = await db()
|
|
96
|
+
.select()
|
|
97
|
+
.from(reservations)
|
|
98
|
+
.where(and(eq(reservations.workspaceId, workspaceId), eq(reservations.kind, 'launch')));
|
|
99
|
+
const reservationId = boundReservation?.id;
|
|
70
100
|
|
|
71
101
|
expect(reservationId).toEqual(expect.any(String));
|
|
102
|
+
expect(result.reservations).toEqual([
|
|
103
|
+
expect.objectContaining({reservationId: launchReservation?.id, count: 1}),
|
|
104
|
+
]);
|
|
105
|
+
expect(boundReservation).toMatchObject({kind: 'bound', count: 2});
|
|
72
106
|
expect(rowsById.get(olderRunner.id)).toMatchObject({
|
|
73
107
|
workspaceId,
|
|
74
108
|
reservationId,
|
|
@@ -86,6 +120,421 @@ describe('pollDemandAndReserve', () => {
|
|
|
86
120
|
});
|
|
87
121
|
});
|
|
88
122
|
|
|
123
|
+
it('does not deadlock polling with expired-reservation cleanup', async () => {
|
|
124
|
+
const oldestRunnerId = `ffffffff-${crypto.randomUUID().slice(9)}`;
|
|
125
|
+
const lowestIdRunnerId = `00000000-${crypto.randomUUID().slice(9)}`;
|
|
126
|
+
const [expiredReservation] = await db()
|
|
127
|
+
.insert(reservations)
|
|
128
|
+
.values({
|
|
129
|
+
workspaceId,
|
|
130
|
+
provisionerId,
|
|
131
|
+
requiredLabels: ['linux'],
|
|
132
|
+
count: 2,
|
|
133
|
+
expiresAt: new Date('2000-01-01T00:00:00.000Z'),
|
|
134
|
+
})
|
|
135
|
+
.returning({id: reservations.id});
|
|
136
|
+
if (!expiredReservation) throw new Error('Expected expired reservation');
|
|
137
|
+
|
|
138
|
+
const oldestRunner = await createIdleRunner({
|
|
139
|
+
id: oldestRunnerId,
|
|
140
|
+
labels: ['linux'],
|
|
141
|
+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
142
|
+
workspaceId,
|
|
143
|
+
reservationId: expiredReservation.id,
|
|
144
|
+
});
|
|
145
|
+
const lowestIdRunner = await createIdleRunner({
|
|
146
|
+
id: lowestIdRunnerId,
|
|
147
|
+
labels: ['linux'],
|
|
148
|
+
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
|
149
|
+
workspaceId,
|
|
150
|
+
reservationId: expiredReservation.id,
|
|
151
|
+
});
|
|
152
|
+
await createPendingJobs(2, ['linux']);
|
|
153
|
+
|
|
154
|
+
const lockClient = await pgClient().connect();
|
|
155
|
+
let transactionOpen = false;
|
|
156
|
+
let cleanupPromise: Promise<number> | undefined;
|
|
157
|
+
let pollPromise: ReturnType<typeof pollDemandAndReserve> | undefined;
|
|
158
|
+
try {
|
|
159
|
+
await lockClient.query('BEGIN');
|
|
160
|
+
transactionOpen = true;
|
|
161
|
+
const lockPidResult = await lockClient.query<{pid: number}>('SELECT pg_backend_pid() AS pid');
|
|
162
|
+
const lockPid = lockPidResult.rows[0]?.pid;
|
|
163
|
+
if (lockPid === undefined) throw new Error('Expected lock holder backend pid');
|
|
164
|
+
await lockClient.query('SELECT id FROM runners_runner_instances WHERE id = $1 FOR UPDATE', [
|
|
165
|
+
lowestIdRunner.id,
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
cleanupPromise = deleteExpiredReservations({limit: 1});
|
|
169
|
+
// Attach a handler before waiting for the second transaction so a deadlock loser cannot
|
|
170
|
+
// become an unhandled rejection while the test is coordinating the lock holders.
|
|
171
|
+
cleanupPromise.catch(() => undefined);
|
|
172
|
+
await waitForLockWait({blockingPid: lockPid});
|
|
173
|
+
|
|
174
|
+
pollPromise = pollDemandAndReserve({
|
|
175
|
+
workspaceId,
|
|
176
|
+
provisionerId,
|
|
177
|
+
maxReservations: 2,
|
|
178
|
+
ttlSeconds: 60,
|
|
179
|
+
templates: [template('linux', ['linux'], 2)],
|
|
180
|
+
});
|
|
181
|
+
pollPromise.catch(() => undefined);
|
|
182
|
+
await waitForLockWait({blockingPid: lockPid, minWaiters: 2});
|
|
183
|
+
|
|
184
|
+
await lockClient.query('COMMIT');
|
|
185
|
+
transactionOpen = false;
|
|
186
|
+
|
|
187
|
+
if (!cleanupPromise || !pollPromise) throw new Error('Expected concurrent operations');
|
|
188
|
+
const [deleted, result] = await Promise.all([cleanupPromise, pollPromise]);
|
|
189
|
+
|
|
190
|
+
expect(deleted).toBe(1);
|
|
191
|
+
expect(result.reservations).toEqual([]);
|
|
192
|
+
const rows = await db()
|
|
193
|
+
.select({id: providerRunners.id, reservationId: providerRunners.reservationId})
|
|
194
|
+
.from(providerRunners)
|
|
195
|
+
.where(inArray(providerRunners.id, [oldestRunner.id, lowestIdRunner.id]));
|
|
196
|
+
expect(rows).toHaveLength(2);
|
|
197
|
+
expect(rows.every((row) => row.reservationId !== expiredReservation.id)).toBe(true);
|
|
198
|
+
} finally {
|
|
199
|
+
if (transactionOpen) await lockClient.query('ROLLBACK');
|
|
200
|
+
if (cleanupPromise) await cleanupPromise.catch(() => undefined);
|
|
201
|
+
if (pollPromise) await pollPromise.catch(() => undefined);
|
|
202
|
+
lockClient.release();
|
|
203
|
+
}
|
|
204
|
+
}, 10_000);
|
|
205
|
+
|
|
206
|
+
it('refills with the next eligible runner when the oldest becomes ineligible', async () => {
|
|
207
|
+
const runner = await createIdleRunner({
|
|
208
|
+
labels: ['linux'],
|
|
209
|
+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
210
|
+
});
|
|
211
|
+
const replacementRunner = await createIdleRunner({
|
|
212
|
+
labels: ['linux'],
|
|
213
|
+
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
|
214
|
+
});
|
|
215
|
+
await createPendingJobs(1, ['linux']);
|
|
216
|
+
|
|
217
|
+
const lockClient = await pgClient().connect();
|
|
218
|
+
const eligibilityClient = await pgClient().connect();
|
|
219
|
+
let lockTransactionOpen = false;
|
|
220
|
+
let eligibilityTransactionOpen = false;
|
|
221
|
+
let eligibilityLockPromise: Promise<unknown> | undefined;
|
|
222
|
+
let pollPromise: ReturnType<typeof pollDemandAndReserve> | undefined;
|
|
223
|
+
try {
|
|
224
|
+
await lockClient.query('BEGIN');
|
|
225
|
+
lockTransactionOpen = true;
|
|
226
|
+
const lockPidResult = await lockClient.query<{pid: number}>('SELECT pg_backend_pid() AS pid');
|
|
227
|
+
const lockPid = lockPidResult.rows[0]?.pid;
|
|
228
|
+
if (lockPid === undefined) throw new Error('Expected lock holder backend pid');
|
|
229
|
+
await lockClient.query('SELECT id FROM runners_runner_instances WHERE id = $1 FOR UPDATE', [
|
|
230
|
+
runner.id,
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
await eligibilityClient.query('BEGIN');
|
|
234
|
+
eligibilityTransactionOpen = true;
|
|
235
|
+
const eligibilityPidResult = await eligibilityClient.query<{pid: number}>(
|
|
236
|
+
'SELECT pg_backend_pid() AS pid',
|
|
237
|
+
);
|
|
238
|
+
const eligibilityPid = eligibilityPidResult.rows[0]?.pid;
|
|
239
|
+
if (eligibilityPid === undefined) throw new Error('Expected eligibility backend pid');
|
|
240
|
+
eligibilityLockPromise = eligibilityClient.query(
|
|
241
|
+
'SELECT id FROM runners_runner_instances WHERE id = $1 FOR UPDATE',
|
|
242
|
+
[runner.id],
|
|
243
|
+
);
|
|
244
|
+
// Attach a handler before starting the poll so cleanup can safely await a failed lock query.
|
|
245
|
+
eligibilityLockPromise.catch(() => undefined);
|
|
246
|
+
await waitForLockWait({blockingPid: lockPid});
|
|
247
|
+
|
|
248
|
+
pollPromise = pollDemandAndReserve({
|
|
249
|
+
workspaceId,
|
|
250
|
+
provisionerId,
|
|
251
|
+
maxReservations: 1,
|
|
252
|
+
ttlSeconds: 60,
|
|
253
|
+
templates: [template('linux', ['linux'], 1)],
|
|
254
|
+
});
|
|
255
|
+
pollPromise.catch(() => undefined);
|
|
256
|
+
await waitForLockWait({blockingPid: eligibilityPid});
|
|
257
|
+
|
|
258
|
+
await lockClient.query('COMMIT');
|
|
259
|
+
lockTransactionOpen = false;
|
|
260
|
+
await eligibilityLockPromise;
|
|
261
|
+
await eligibilityClient.query(
|
|
262
|
+
"UPDATE runners_runner_instances SET state = 'terminated', terminated_at = now(), updated_at = now() WHERE id = $1",
|
|
263
|
+
[runner.id],
|
|
264
|
+
);
|
|
265
|
+
await eligibilityClient.query(
|
|
266
|
+
"UPDATE runners_runner_control_sessions SET closed_at = now(), close_reason = 'test' WHERE runner_instance_id = $1 AND closed_at IS NULL",
|
|
267
|
+
[runner.id],
|
|
268
|
+
);
|
|
269
|
+
await eligibilityClient.query('COMMIT');
|
|
270
|
+
eligibilityTransactionOpen = false;
|
|
271
|
+
|
|
272
|
+
if (!pollPromise) throw new Error('Expected concurrent poll');
|
|
273
|
+
const result = await pollPromise;
|
|
274
|
+
|
|
275
|
+
expect(result.reservations).toEqual([]);
|
|
276
|
+
const rows = await db()
|
|
277
|
+
.select({
|
|
278
|
+
id: providerRunners.id,
|
|
279
|
+
state: providerRunners.state,
|
|
280
|
+
workspaceId: providerRunners.workspaceId,
|
|
281
|
+
reservationId: providerRunners.reservationId,
|
|
282
|
+
})
|
|
283
|
+
.from(providerRunners)
|
|
284
|
+
.where(inArray(providerRunners.id, [runner.id, replacementRunner.id]));
|
|
285
|
+
const rowsById = new Map(rows.map((row) => [row.id, row]));
|
|
286
|
+
expect(rowsById.get(runner.id)).toMatchObject({
|
|
287
|
+
state: 'terminated',
|
|
288
|
+
workspaceId: null,
|
|
289
|
+
reservationId: null,
|
|
290
|
+
});
|
|
291
|
+
expect(rowsById.get(replacementRunner.id)).toMatchObject({
|
|
292
|
+
state: 'running',
|
|
293
|
+
workspaceId,
|
|
294
|
+
reservationId: expect.any(String),
|
|
295
|
+
});
|
|
296
|
+
} finally {
|
|
297
|
+
if (lockTransactionOpen) await lockClient.query('ROLLBACK');
|
|
298
|
+
if (eligibilityTransactionOpen) await eligibilityClient.query('ROLLBACK');
|
|
299
|
+
if (eligibilityLockPromise) await eligibilityLockPromise.catch(() => undefined);
|
|
300
|
+
if (pollPromise) await pollPromise.catch(() => undefined);
|
|
301
|
+
lockClient.release();
|
|
302
|
+
eligibilityClient.release();
|
|
303
|
+
}
|
|
304
|
+
}, 10_000);
|
|
305
|
+
|
|
306
|
+
it('splits rebound and launch capacity with separate reservation lifetimes', async () => {
|
|
307
|
+
const runner = await createIdleRunner({labels: ['linux']});
|
|
308
|
+
await createPendingJobs(2, ['linux']);
|
|
309
|
+
|
|
310
|
+
const before = Date.now();
|
|
311
|
+
const result = await pollDemandAndReserve({
|
|
312
|
+
workspaceId,
|
|
313
|
+
provisionerId,
|
|
314
|
+
maxReservations: 2,
|
|
315
|
+
ttlSeconds: 120,
|
|
316
|
+
activationGraceSeconds: 5,
|
|
317
|
+
templates: [template('linux', ['linux'], 2)],
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(result.reservations).toHaveLength(1);
|
|
321
|
+
expect(result.reservations[0]).toMatchObject({count: 1});
|
|
322
|
+
const rows = await db()
|
|
323
|
+
.select()
|
|
324
|
+
.from(reservations)
|
|
325
|
+
.where(
|
|
326
|
+
and(
|
|
327
|
+
eq(reservations.workspaceId, workspaceId),
|
|
328
|
+
eq(reservations.provisionerId, provisionerId),
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
const boundReservation = rows.find((row) => row.kind === 'bound');
|
|
332
|
+
const launchReservation = rows.find((row) => row.kind === 'launch');
|
|
333
|
+
expect(boundReservation).toMatchObject({count: 1});
|
|
334
|
+
expect(launchReservation).toMatchObject({count: 1});
|
|
335
|
+
expect(boundReservation?.expiresAt.getTime()).toBeGreaterThan(before + 4_000);
|
|
336
|
+
expect(boundReservation?.expiresAt.getTime()).toBeLessThan(
|
|
337
|
+
launchReservation?.expiresAt.getTime() ?? 0,
|
|
338
|
+
);
|
|
339
|
+
expect(launchReservation?.id).toBe(result.reservations[0]?.reservationId);
|
|
340
|
+
const [storedRunner] = await db()
|
|
341
|
+
.select()
|
|
342
|
+
.from(providerRunners)
|
|
343
|
+
.where(eq(providerRunners.id, runner.id));
|
|
344
|
+
expect(storedRunner?.reservationId).toBe(boundReservation?.id);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('records a rebound outcome for a demand-backed idle runner', async () => {
|
|
348
|
+
const outcomeSpy = vi.spyOn(providerRunnerActivationOutcomeCount, 'add');
|
|
349
|
+
await createIdleRunner({labels: ['linux'], launchKind: 'demand'});
|
|
350
|
+
await createPendingJobs(1, ['linux']);
|
|
351
|
+
const callsBefore = outcomeSpy.mock.calls.length;
|
|
352
|
+
|
|
353
|
+
await pollDemandAndReserve({
|
|
354
|
+
workspaceId,
|
|
355
|
+
provisionerId,
|
|
356
|
+
maxReservations: 1,
|
|
357
|
+
ttlSeconds: 60,
|
|
358
|
+
templates: [template('linux', ['linux'], 1)],
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(
|
|
362
|
+
outcomeSpy.mock.calls
|
|
363
|
+
.slice(callsBefore)
|
|
364
|
+
.filter(
|
|
365
|
+
([value, attributes]) =>
|
|
366
|
+
value === 1 && JSON.stringify(attributes) === JSON.stringify({outcome: 'rebound'}),
|
|
367
|
+
),
|
|
368
|
+
).toHaveLength(1);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('does not charge adopted runners against later overlapping demand', async () => {
|
|
372
|
+
const adoptedRunner = await createIdleRunner({labels: ['linux', 'gpu']});
|
|
373
|
+
await createPendingJobs(1, ['linux', 'gpu']);
|
|
374
|
+
|
|
375
|
+
const firstPoll = await pollDemandAndReserve({
|
|
376
|
+
workspaceId,
|
|
377
|
+
provisionerId,
|
|
378
|
+
maxReservations: 1,
|
|
379
|
+
ttlSeconds: 60,
|
|
380
|
+
templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
|
|
381
|
+
});
|
|
382
|
+
expect(firstPoll.reservations).toEqual([]);
|
|
383
|
+
|
|
384
|
+
await createPendingJobs(1, ['linux']);
|
|
385
|
+
const result = await pollDemandAndReserve({
|
|
386
|
+
workspaceId,
|
|
387
|
+
provisionerId,
|
|
388
|
+
maxReservations: 1,
|
|
389
|
+
ttlSeconds: 60,
|
|
390
|
+
templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const storedReservations = await reservationsForTest();
|
|
394
|
+
const boundReservation = storedReservations.find(
|
|
395
|
+
(reservation) => reservation.requiredLabels.includes('gpu') && reservation.kind === 'bound',
|
|
396
|
+
);
|
|
397
|
+
const launchReservation = storedReservations.find(
|
|
398
|
+
(reservation) => !reservation.requiredLabels.includes('gpu') && reservation.kind === 'launch',
|
|
399
|
+
);
|
|
400
|
+
const [storedRunner] = await db()
|
|
401
|
+
.select()
|
|
402
|
+
.from(providerRunners)
|
|
403
|
+
.where(eq(providerRunners.id, adoptedRunner.id));
|
|
404
|
+
|
|
405
|
+
expect(result.reservations).toEqual([
|
|
406
|
+
expect.objectContaining({
|
|
407
|
+
reservationId: launchReservation?.id,
|
|
408
|
+
labels: ['linux'],
|
|
409
|
+
count: 1,
|
|
410
|
+
}),
|
|
411
|
+
]);
|
|
412
|
+
expect(boundReservation).toMatchObject({kind: 'bound', count: 1});
|
|
413
|
+
expect(storedRunner?.reservationId).toBe(boundReservation?.id);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('deducts only pending launch units from a partially adopted reservation', async () => {
|
|
417
|
+
await createIdleRunner({labels: ['linux', 'gpu']});
|
|
418
|
+
await createIdleRunner({labels: ['linux', 'gpu']});
|
|
419
|
+
await createPendingJobs(3, ['linux', 'gpu']);
|
|
420
|
+
|
|
421
|
+
const firstPoll = await pollDemandAndReserve({
|
|
422
|
+
workspaceId,
|
|
423
|
+
provisionerId,
|
|
424
|
+
maxReservations: 3,
|
|
425
|
+
ttlSeconds: 60,
|
|
426
|
+
templates: [template('linux-gpu', ['linux', 'gpu'], 3)],
|
|
427
|
+
});
|
|
428
|
+
expect(firstPoll.reservations).toEqual([expect.objectContaining({count: 1})]);
|
|
429
|
+
|
|
430
|
+
await createPendingJobs(1, ['linux']);
|
|
431
|
+
const secondPoll = await pollDemandAndReserve({
|
|
432
|
+
workspaceId,
|
|
433
|
+
provisionerId,
|
|
434
|
+
maxReservations: 1,
|
|
435
|
+
ttlSeconds: 60,
|
|
436
|
+
templates: [template('linux-gpu', ['linux', 'gpu'], 2)],
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
expect(secondPoll.reservations).toEqual([
|
|
440
|
+
expect.objectContaining({labels: ['linux'], count: 1}),
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('deducts a launch reservation with no adopted units in full', async () => {
|
|
445
|
+
await createPendingJobs(1, ['linux', 'gpu']);
|
|
446
|
+
|
|
447
|
+
const firstPoll = await pollDemandAndReserve({
|
|
448
|
+
workspaceId,
|
|
449
|
+
provisionerId,
|
|
450
|
+
maxReservations: 1,
|
|
451
|
+
ttlSeconds: 60,
|
|
452
|
+
templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
|
|
453
|
+
});
|
|
454
|
+
expect(firstPoll.reservations).toEqual([expect.objectContaining({count: 1})]);
|
|
455
|
+
|
|
456
|
+
await createPendingJobs(1, ['linux']);
|
|
457
|
+
const secondPoll = await pollDemandAndReserve({
|
|
458
|
+
workspaceId,
|
|
459
|
+
provisionerId,
|
|
460
|
+
maxReservations: 1,
|
|
461
|
+
ttlSeconds: 60,
|
|
462
|
+
templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
expect(secondPoll.reservations).toEqual([]);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('counts an intended runner while its reservation assignment is in flight', async () => {
|
|
469
|
+
const intendedReservation = await createIntendedReservation({
|
|
470
|
+
workspaceId,
|
|
471
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
472
|
+
});
|
|
473
|
+
await createIdleRunner({
|
|
474
|
+
labels: ['linux', 'gpu'],
|
|
475
|
+
intendedReservationId: intendedReservation.id,
|
|
476
|
+
});
|
|
477
|
+
await createPendingJobs(1, ['linux', 'gpu']);
|
|
478
|
+
|
|
479
|
+
const result = await pollDemandAndReserve({
|
|
480
|
+
workspaceId,
|
|
481
|
+
provisionerId,
|
|
482
|
+
maxReservations: 1,
|
|
483
|
+
ttlSeconds: 60,
|
|
484
|
+
templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
expect(result.reservations).toEqual([
|
|
488
|
+
expect.objectContaining({labels: ['gpu', 'linux'], count: 1}),
|
|
489
|
+
]);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('deducts a released runner reservation unit as pending', async () => {
|
|
493
|
+
const reservation = await createIntendedReservation({
|
|
494
|
+
workspaceId,
|
|
495
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
496
|
+
});
|
|
497
|
+
await createIdleRunner({
|
|
498
|
+
labels: ['linux'],
|
|
499
|
+
reservationId: reservation.id,
|
|
500
|
+
reservationReleasedAt: new Date(Date.now() - 1_000),
|
|
501
|
+
});
|
|
502
|
+
await createPendingJobs(1, ['linux', 'gpu']);
|
|
503
|
+
|
|
504
|
+
const result = await pollDemandAndReserve({
|
|
505
|
+
workspaceId,
|
|
506
|
+
provisionerId,
|
|
507
|
+
maxReservations: 1,
|
|
508
|
+
ttlSeconds: 60,
|
|
509
|
+
templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
expect(result.reservations).toEqual([]);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('deducts a terminal intended runner reservation unit as pending', async () => {
|
|
516
|
+
const reservation = await createIntendedReservation({
|
|
517
|
+
workspaceId,
|
|
518
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
519
|
+
});
|
|
520
|
+
await createIdleRunner({
|
|
521
|
+
labels: ['linux'],
|
|
522
|
+
intendedReservationId: reservation.id,
|
|
523
|
+
state: 'terminated',
|
|
524
|
+
});
|
|
525
|
+
await createPendingJobs(1, ['linux', 'gpu']);
|
|
526
|
+
|
|
527
|
+
const result = await pollDemandAndReserve({
|
|
528
|
+
workspaceId,
|
|
529
|
+
provisionerId,
|
|
530
|
+
maxReservations: 1,
|
|
531
|
+
ttlSeconds: 60,
|
|
532
|
+
templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
expect(result.reservations).toEqual([]);
|
|
536
|
+
});
|
|
537
|
+
|
|
89
538
|
it('binds a runner whose intended reservation has passed its activation grace period', async () => {
|
|
90
539
|
const intendedReservation = await createIntendedReservation({
|
|
91
540
|
workspaceId,
|
|
@@ -109,9 +558,10 @@ describe('pollDemandAndReserve', () => {
|
|
|
109
558
|
.select()
|
|
110
559
|
.from(providerRunners)
|
|
111
560
|
.where(eq(providerRunners.id, runner.id));
|
|
561
|
+
expect(result.reservations).toEqual([]);
|
|
112
562
|
expect(storedRunner).toMatchObject({
|
|
113
563
|
workspaceId,
|
|
114
|
-
reservationId:
|
|
564
|
+
reservationId: expect.any(String),
|
|
115
565
|
intendedReservationId: null,
|
|
116
566
|
assignedAt: expect.any(Date),
|
|
117
567
|
});
|
|
@@ -141,9 +591,10 @@ describe('pollDemandAndReserve', () => {
|
|
|
141
591
|
.select()
|
|
142
592
|
.from(providerRunners)
|
|
143
593
|
.where(eq(providerRunners.id, runner.id));
|
|
594
|
+
expect(result.reservations).toEqual([]);
|
|
144
595
|
expect(storedRunner).toMatchObject({
|
|
145
596
|
workspaceId,
|
|
146
|
-
reservationId:
|
|
597
|
+
reservationId: expect.any(String),
|
|
147
598
|
intendedReservationId: null,
|
|
148
599
|
assignedAt: expect.any(Date),
|
|
149
600
|
});
|
|
@@ -188,9 +639,10 @@ describe('pollDemandAndReserve', () => {
|
|
|
188
639
|
.select()
|
|
189
640
|
.from(runnerActivationTokens)
|
|
190
641
|
.where(eq(runnerActivationTokens.id, activationToken.id));
|
|
642
|
+
expect(result.reservations).toEqual([]);
|
|
191
643
|
expect(storedRunner).toMatchObject({
|
|
192
644
|
workspaceId,
|
|
193
|
-
reservationId:
|
|
645
|
+
reservationId: expect.any(String),
|
|
194
646
|
intendedReservationId: null,
|
|
195
647
|
assignedAt: expect.any(Date),
|
|
196
648
|
});
|
|
@@ -223,9 +675,10 @@ describe('pollDemandAndReserve', () => {
|
|
|
223
675
|
.select()
|
|
224
676
|
.from(providerRunners)
|
|
225
677
|
.where(eq(providerRunners.id, runner.id));
|
|
678
|
+
expect(result.reservations).toEqual([]);
|
|
226
679
|
expect(storedRunner).toMatchObject({
|
|
227
680
|
workspaceId,
|
|
228
|
-
reservationId:
|
|
681
|
+
reservationId: expect.any(String),
|
|
229
682
|
intendedReservationId: null,
|
|
230
683
|
assignedAt: expect.any(Date),
|
|
231
684
|
});
|
|
@@ -349,9 +802,10 @@ describe('pollDemandAndReserve', () => {
|
|
|
349
802
|
.select()
|
|
350
803
|
.from(providerRunners)
|
|
351
804
|
.where(eq(providerRunners.id, runner.id));
|
|
805
|
+
expect(result.reservations).toEqual([]);
|
|
352
806
|
expect(storedRunner).toMatchObject({
|
|
353
807
|
workspaceId,
|
|
354
|
-
reservationId:
|
|
808
|
+
reservationId: expect.any(String),
|
|
355
809
|
intendedReservationId: null,
|
|
356
810
|
assignedAt: expect.any(Date),
|
|
357
811
|
});
|
|
@@ -385,15 +839,164 @@ describe('pollDemandAndReserve', () => {
|
|
|
385
839
|
.select()
|
|
386
840
|
.from(providerRunners)
|
|
387
841
|
.where(eq(providerRunners.id, runner.id));
|
|
388
|
-
|
|
842
|
+
const [storedReservation] = await db()
|
|
843
|
+
.select()
|
|
844
|
+
.from(reservations)
|
|
845
|
+
.where(
|
|
846
|
+
and(
|
|
847
|
+
eq(reservations.workspaceId, targetWorkspaceId),
|
|
848
|
+
eq(reservations.provisionerId, provisionerId),
|
|
849
|
+
eq(reservations.kind, 'bound'),
|
|
850
|
+
),
|
|
851
|
+
);
|
|
852
|
+
expect(result.reservations).toEqual([]);
|
|
853
|
+
expect(storedReservation).toMatchObject({workspaceId: targetWorkspaceId, count: 1});
|
|
389
854
|
expect(storedRunner).toMatchObject({
|
|
390
855
|
workspaceId: targetWorkspaceId,
|
|
391
|
-
reservationId:
|
|
856
|
+
reservationId: storedReservation?.id,
|
|
392
857
|
intendedReservationId: null,
|
|
393
858
|
assignedAt: expect.any(Date),
|
|
394
859
|
});
|
|
395
860
|
});
|
|
396
861
|
|
|
862
|
+
it('does not rebind a runner reserved by a concurrent installation poll', async () => {
|
|
863
|
+
const previousWorkspaceId = crypto.randomUUID();
|
|
864
|
+
const targetWorkspaceId = crypto.randomUUID();
|
|
865
|
+
const runner = await createIdleRunner({labels: ['linux']});
|
|
866
|
+
await pendingJobFactory.create({workspaceId: targetWorkspaceId, requiredLabels: ['linux']});
|
|
867
|
+
|
|
868
|
+
const lockClient = await pgClient().connect();
|
|
869
|
+
let transactionOpen = false;
|
|
870
|
+
let pollPromise: ReturnType<typeof pollInstallationDemandAndReserve> | undefined;
|
|
871
|
+
try {
|
|
872
|
+
await lockClient.query('BEGIN');
|
|
873
|
+
transactionOpen = true;
|
|
874
|
+
const lockPidResult = await lockClient.query<{pid: number}>('SELECT pg_backend_pid() AS pid');
|
|
875
|
+
const lockPid = lockPidResult.rows[0]?.pid;
|
|
876
|
+
if (lockPid === undefined) throw new Error('Expected lock holder backend pid');
|
|
877
|
+
await lockClient.query('SELECT id FROM runners_runner_instances WHERE id = $1 FOR UPDATE', [
|
|
878
|
+
runner.id,
|
|
879
|
+
]);
|
|
880
|
+
|
|
881
|
+
const reservationId = crypto.randomUUID();
|
|
882
|
+
await lockClient.query(
|
|
883
|
+
`INSERT INTO runners_reservations
|
|
884
|
+
(id, workspace_id, provisioner_id, required_labels, count, kind, expires_at)
|
|
885
|
+
VALUES ($1, $2, $3, $4, $5, 'bound', $6)`,
|
|
886
|
+
[
|
|
887
|
+
reservationId,
|
|
888
|
+
previousWorkspaceId,
|
|
889
|
+
provisionerId,
|
|
890
|
+
['linux'],
|
|
891
|
+
1,
|
|
892
|
+
new Date(Date.now() + 60_000),
|
|
893
|
+
],
|
|
894
|
+
);
|
|
895
|
+
await lockClient.query(
|
|
896
|
+
`UPDATE runners_runner_instances
|
|
897
|
+
SET workspace_id = $1, reservation_id = $2, updated_at = now()
|
|
898
|
+
WHERE id = $3`,
|
|
899
|
+
[previousWorkspaceId, reservationId, runner.id],
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
pollPromise = pollInstallationDemandAndReserve({
|
|
903
|
+
provisionerId,
|
|
904
|
+
maxReservations: 1,
|
|
905
|
+
ttlSeconds: 60,
|
|
906
|
+
templates: [template('linux', ['linux'], 1)],
|
|
907
|
+
capabilityWindowSeconds: 60,
|
|
908
|
+
eligibleWorkspaceIds: new Set([targetWorkspaceId]),
|
|
909
|
+
});
|
|
910
|
+
// waitForLockWait yields to the macrotask queue, so an early poll rejection would
|
|
911
|
+
// surface as an unhandled rejection before the finally block attaches its handler.
|
|
912
|
+
pollPromise.catch(() => undefined);
|
|
913
|
+
await waitForLockWait({blockingPid: lockPid});
|
|
914
|
+
|
|
915
|
+
await lockClient.query('COMMIT');
|
|
916
|
+
transactionOpen = false;
|
|
917
|
+
const result = await pollPromise;
|
|
918
|
+
const [storedRunner] = await db()
|
|
919
|
+
.select()
|
|
920
|
+
.from(providerRunners)
|
|
921
|
+
.where(eq(providerRunners.id, runner.id));
|
|
922
|
+
const storedReservations = await db()
|
|
923
|
+
.select()
|
|
924
|
+
.from(reservations)
|
|
925
|
+
.where(eq(reservations.provisionerId, provisionerId));
|
|
926
|
+
|
|
927
|
+
expect(result.reservations).toHaveLength(1);
|
|
928
|
+
expect(result.reservations[0]).toMatchObject({workspaceId: targetWorkspaceId, count: 1});
|
|
929
|
+
expect(storedRunner).toMatchObject({
|
|
930
|
+
workspaceId: previousWorkspaceId,
|
|
931
|
+
reservationId,
|
|
932
|
+
});
|
|
933
|
+
expect(storedReservations).toHaveLength(2);
|
|
934
|
+
expect(
|
|
935
|
+
storedReservations.find((reservation) => reservation.workspaceId === targetWorkspaceId),
|
|
936
|
+
).toMatchObject({kind: 'launch', count: 1});
|
|
937
|
+
} finally {
|
|
938
|
+
if (transactionOpen) await lockClient.query('ROLLBACK');
|
|
939
|
+
if (pollPromise) await pollPromise.catch(() => undefined);
|
|
940
|
+
lockClient.release();
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it.each([
|
|
945
|
+
['template capacity', 5, 2, 2],
|
|
946
|
+
['global reservation budget', 2, 5, 1],
|
|
947
|
+
])('does not overcount rebound units for the %s case', async (_case, maxReservations, availableSlots, expectedLaunchCount) => {
|
|
948
|
+
const reboundWorkspaceId = crypto.randomUUID();
|
|
949
|
+
const launchWorkspaceId = crypto.randomUUID();
|
|
950
|
+
await pendingJobFactory.create({
|
|
951
|
+
workspaceId: reboundWorkspaceId,
|
|
952
|
+
requiredLabels: ['linux'],
|
|
953
|
+
});
|
|
954
|
+
await pendingJobFactory.create({
|
|
955
|
+
workspaceId: launchWorkspaceId,
|
|
956
|
+
requiredLabels: ['linux'],
|
|
957
|
+
});
|
|
958
|
+
await pendingJobFactory.create({
|
|
959
|
+
workspaceId: launchWorkspaceId,
|
|
960
|
+
requiredLabels: ['linux'],
|
|
961
|
+
});
|
|
962
|
+
await db()
|
|
963
|
+
.update(pendingJobExecutions)
|
|
964
|
+
.set({createdAt: new Date('2026-01-01T00:00:00.000Z')})
|
|
965
|
+
.where(eq(pendingJobExecutions.workspaceId, reboundWorkspaceId));
|
|
966
|
+
await db()
|
|
967
|
+
.update(pendingJobExecutions)
|
|
968
|
+
.set({createdAt: new Date('2026-01-02T00:00:00.000Z')})
|
|
969
|
+
.where(eq(pendingJobExecutions.workspaceId, launchWorkspaceId));
|
|
970
|
+
await createIdleRunner({labels: ['linux']});
|
|
971
|
+
|
|
972
|
+
const result = await pollInstallationDemandAndReserve({
|
|
973
|
+
provisionerId,
|
|
974
|
+
maxReservations,
|
|
975
|
+
ttlSeconds: 60,
|
|
976
|
+
templates: [template('linux', ['linux'], availableSlots)],
|
|
977
|
+
capabilityWindowSeconds: 60,
|
|
978
|
+
eligibleWorkspaceIds: new Set([reboundWorkspaceId, launchWorkspaceId]),
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
const storedReservations = await db()
|
|
982
|
+
.select()
|
|
983
|
+
.from(reservations)
|
|
984
|
+
.where(eq(reservations.provisionerId, provisionerId));
|
|
985
|
+
expect(result.reservations).toEqual([
|
|
986
|
+
expect.objectContaining({workspaceId: launchWorkspaceId, count: expectedLaunchCount}),
|
|
987
|
+
]);
|
|
988
|
+
expect(storedReservations).toEqual(
|
|
989
|
+
expect.arrayContaining([
|
|
990
|
+
expect.objectContaining({workspaceId: reboundWorkspaceId, kind: 'bound', count: 1}),
|
|
991
|
+
expect.objectContaining({
|
|
992
|
+
workspaceId: launchWorkspaceId,
|
|
993
|
+
kind: 'launch',
|
|
994
|
+
count: expectedLaunchCount,
|
|
995
|
+
}),
|
|
996
|
+
]),
|
|
997
|
+
);
|
|
998
|
+
});
|
|
999
|
+
|
|
397
1000
|
it('does not rebind a released runner after its reservation expires', async () => {
|
|
398
1001
|
const staleReservation = await createIntendedReservation({
|
|
399
1002
|
workspaceId,
|
|
@@ -402,6 +1005,7 @@ describe('pollDemandAndReserve', () => {
|
|
|
402
1005
|
const releasedAt = new Date(Date.now() - 30_000);
|
|
403
1006
|
const runner = await createIdleRunner({
|
|
404
1007
|
labels: ['linux'],
|
|
1008
|
+
launchKind: 'demand',
|
|
405
1009
|
workspaceId,
|
|
406
1010
|
reservationId: staleReservation.id,
|
|
407
1011
|
intendedReservationId: staleReservation.id,
|
|
@@ -502,9 +1106,10 @@ describe('pollDemandAndReserve', () => {
|
|
|
502
1106
|
templates: [template('linux', ['linux'], 1)],
|
|
503
1107
|
});
|
|
504
1108
|
|
|
505
|
-
expect(firstResult.reservations).
|
|
1109
|
+
expect(firstResult.reservations).toEqual([]);
|
|
506
1110
|
expect(secondResult.reservations).toEqual([]);
|
|
507
1111
|
expect(secondResult.stats[0]).toMatchObject({queued: 1, reserved: 1});
|
|
1112
|
+
expect(await activeReservedCount()).toBe(1);
|
|
508
1113
|
});
|
|
509
1114
|
|
|
510
1115
|
it('allocates overlapping label sets most-specific-first', async () => {
|
|
@@ -569,6 +1174,53 @@ describe('pollDemandAndReserve', () => {
|
|
|
569
1174
|
]);
|
|
570
1175
|
});
|
|
571
1176
|
|
|
1177
|
+
it('does not charge adopted installation runners against later launch capacity', async () => {
|
|
1178
|
+
const olderWorkspaceId = crypto.randomUUID();
|
|
1179
|
+
const newerWorkspaceId = crypto.randomUUID();
|
|
1180
|
+
const runner = await createIdleRunner({labels: ['linux', 'gpu']});
|
|
1181
|
+
await pendingJobFactory.create({
|
|
1182
|
+
workspaceId: olderWorkspaceId,
|
|
1183
|
+
requiredLabels: ['linux', 'gpu'],
|
|
1184
|
+
});
|
|
1185
|
+
await pendingJobFactory.create({workspaceId: newerWorkspaceId, requiredLabels: ['linux']});
|
|
1186
|
+
|
|
1187
|
+
const result = await pollInstallationDemandAndReserve({
|
|
1188
|
+
provisionerId,
|
|
1189
|
+
maxReservations: 2,
|
|
1190
|
+
ttlSeconds: 60,
|
|
1191
|
+
templates: [template('linux-gpu', ['linux', 'gpu'], 1)],
|
|
1192
|
+
capabilityWindowSeconds: 60,
|
|
1193
|
+
eligibleWorkspaceIds: new Set([olderWorkspaceId, newerWorkspaceId]),
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
const storedReservations = await db()
|
|
1197
|
+
.select()
|
|
1198
|
+
.from(reservations)
|
|
1199
|
+
.where(eq(reservations.provisionerId, provisionerId));
|
|
1200
|
+
const boundReservation = storedReservations.find(
|
|
1201
|
+
(reservation) => reservation.workspaceId === olderWorkspaceId,
|
|
1202
|
+
);
|
|
1203
|
+
const launchReservation = storedReservations.find(
|
|
1204
|
+
(reservation) => reservation.workspaceId === newerWorkspaceId,
|
|
1205
|
+
);
|
|
1206
|
+
const [storedRunner] = await db()
|
|
1207
|
+
.select()
|
|
1208
|
+
.from(providerRunners)
|
|
1209
|
+
.where(eq(providerRunners.id, runner.id));
|
|
1210
|
+
|
|
1211
|
+
expect(result.reservations).toEqual([
|
|
1212
|
+
expect.objectContaining({
|
|
1213
|
+
reservationId: launchReservation?.id,
|
|
1214
|
+
workspaceId: newerWorkspaceId,
|
|
1215
|
+
labels: ['linux'],
|
|
1216
|
+
count: 1,
|
|
1217
|
+
}),
|
|
1218
|
+
]);
|
|
1219
|
+
expect(boundReservation?.count).toBe(1);
|
|
1220
|
+
expect(storedRunner?.workspaceId).toBe(olderWorkspaceId);
|
|
1221
|
+
expect(storedRunner?.reservationId).toBe(boundReservation?.id);
|
|
1222
|
+
});
|
|
1223
|
+
|
|
572
1224
|
it('reports committed installation grants before an aborted poll stops allocating', async () => {
|
|
573
1225
|
const firstWorkspaceId = crypto.randomUUID();
|
|
574
1226
|
const secondWorkspaceId = crypto.randomUUID();
|
|
@@ -765,15 +1417,25 @@ describe('pollDemandAndReserve', () => {
|
|
|
765
1417
|
const runner = await createIdleRunner({labels: ['linux']});
|
|
766
1418
|
await createPendingJobs(1, ['linux']);
|
|
767
1419
|
|
|
768
|
-
|
|
1420
|
+
await pollDemandAndReserve({
|
|
769
1421
|
workspaceId,
|
|
770
1422
|
provisionerId,
|
|
771
1423
|
maxReservations: 1,
|
|
772
1424
|
ttlSeconds: 60,
|
|
773
1425
|
templates: [template('linux', ['linux'], 1)],
|
|
774
1426
|
});
|
|
775
|
-
const
|
|
776
|
-
|
|
1427
|
+
const [boundReservation] = await db()
|
|
1428
|
+
.select()
|
|
1429
|
+
.from(reservations)
|
|
1430
|
+
.where(
|
|
1431
|
+
and(
|
|
1432
|
+
eq(reservations.workspaceId, workspaceId),
|
|
1433
|
+
eq(reservations.provisionerId, provisionerId),
|
|
1434
|
+
eq(reservations.kind, 'bound'),
|
|
1435
|
+
),
|
|
1436
|
+
);
|
|
1437
|
+
const reservationId = boundReservation?.id;
|
|
1438
|
+
if (!reservationId) throw new Error('Expected bound reservation');
|
|
777
1439
|
|
|
778
1440
|
const [activationToken] = await db()
|
|
779
1441
|
.insert(runnerActivationTokens)
|
|
@@ -867,15 +1529,25 @@ describe('pollDemandAndReserve', () => {
|
|
|
867
1529
|
const runner = await createIdleRunner({labels: ['linux']});
|
|
868
1530
|
await createPendingJobs(1, ['linux']);
|
|
869
1531
|
|
|
870
|
-
|
|
1532
|
+
await pollDemandAndReserve({
|
|
871
1533
|
workspaceId,
|
|
872
1534
|
provisionerId,
|
|
873
1535
|
maxReservations: 1,
|
|
874
1536
|
ttlSeconds: 60,
|
|
875
1537
|
templates: [template('linux', ['linux'], 1)],
|
|
876
1538
|
});
|
|
877
|
-
const
|
|
878
|
-
|
|
1539
|
+
const [boundReservation] = await db()
|
|
1540
|
+
.select()
|
|
1541
|
+
.from(reservations)
|
|
1542
|
+
.where(
|
|
1543
|
+
and(
|
|
1544
|
+
eq(reservations.workspaceId, workspaceId),
|
|
1545
|
+
eq(reservations.provisionerId, provisionerId),
|
|
1546
|
+
eq(reservations.kind, 'bound'),
|
|
1547
|
+
),
|
|
1548
|
+
);
|
|
1549
|
+
const reservationId = boundReservation?.id;
|
|
1550
|
+
if (!reservationId) throw new Error('Expected bound reservation');
|
|
879
1551
|
|
|
880
1552
|
await db()
|
|
881
1553
|
.update(reservations)
|
|
@@ -963,18 +1635,18 @@ describe('pollDemandAndReserve', () => {
|
|
|
963
1635
|
expect(storedSurvivingRunner?.intendedReservationId).toBe(survivingReservation.id);
|
|
964
1636
|
|
|
965
1637
|
await createPendingJobs(1, ['linux']);
|
|
966
|
-
|
|
1638
|
+
await pollDemandAndReserve({
|
|
967
1639
|
workspaceId,
|
|
968
1640
|
provisionerId,
|
|
969
1641
|
maxReservations: 1,
|
|
970
1642
|
ttlSeconds: 60,
|
|
971
1643
|
templates: [template('linux', ['linux'], 1)],
|
|
972
1644
|
});
|
|
973
|
-
const reboundReservationId = result.reservations[0]?.reservationId;
|
|
974
1645
|
const [reboundRunner] = await db()
|
|
975
1646
|
.select()
|
|
976
1647
|
.from(providerRunners)
|
|
977
1648
|
.where(eq(providerRunners.id, runner.id));
|
|
1649
|
+
const reboundReservationId = reboundRunner?.reservationId;
|
|
978
1650
|
expect(reboundReservationId).toEqual(expect.any(String));
|
|
979
1651
|
expect(reboundRunner).toMatchObject({
|
|
980
1652
|
intendedReservationId: null,
|
|
@@ -1171,26 +1843,31 @@ describe('pollDemandAndReserve', () => {
|
|
|
1171
1843
|
}
|
|
1172
1844
|
|
|
1173
1845
|
async function createIdleRunner(params: {
|
|
1846
|
+
id?: string;
|
|
1174
1847
|
labels: string[];
|
|
1175
1848
|
createdAt?: Date;
|
|
1176
1849
|
controlSessionExpiresAt?: Date;
|
|
1850
|
+
launchKind?: 'demand' | 'warm' | 'manual';
|
|
1177
1851
|
workspaceId?: string | null;
|
|
1178
1852
|
reservationId?: string | null;
|
|
1179
1853
|
intendedReservationId?: string | null;
|
|
1180
1854
|
reservationReleasedAt?: Date | null;
|
|
1855
|
+
state?: RunnerInstanceState;
|
|
1181
1856
|
}) {
|
|
1182
1857
|
const createdAt = params.createdAt ?? new Date();
|
|
1183
1858
|
const [runner] = await db()
|
|
1184
1859
|
.insert(providerRunners)
|
|
1185
1860
|
.values({
|
|
1861
|
+
...(params.id ? {id: params.id} : {}),
|
|
1186
1862
|
provisionerId,
|
|
1187
1863
|
workspaceId: params.workspaceId,
|
|
1188
1864
|
reservationId: params.reservationId,
|
|
1189
1865
|
providerRunnerId: crypto.randomUUID(),
|
|
1866
|
+
launchKind: params.launchKind ?? 'manual',
|
|
1190
1867
|
intendedReservationId: params.intendedReservationId,
|
|
1191
1868
|
reservationReleasedAt: params.reservationReleasedAt,
|
|
1192
1869
|
labels: params.labels,
|
|
1193
|
-
state: 'running',
|
|
1870
|
+
state: params.state ?? 'running',
|
|
1194
1871
|
reportedAt: createdAt,
|
|
1195
1872
|
createdAt,
|
|
1196
1873
|
updatedAt: createdAt,
|
|
@@ -1236,3 +1913,37 @@ describe('pollDemandAndReserve', () => {
|
|
|
1236
1913
|
return {templateKey, labels, availableSlots, starting: 0, running: 0};
|
|
1237
1914
|
}
|
|
1238
1915
|
});
|
|
1916
|
+
|
|
1917
|
+
async function waitForLockWait(params: {blockingPid: number; minWaiters?: number}) {
|
|
1918
|
+
const minWaiters = params.minWaiters ?? 1;
|
|
1919
|
+
const deadline = Date.now() + 5_000;
|
|
1920
|
+
while (Date.now() < deadline) {
|
|
1921
|
+
const result = await pgClient().query<{count: number}>(
|
|
1922
|
+
`
|
|
1923
|
+
WITH RECURSIVE lock_waiters(waiter_pid, blocker_pid, path) AS (
|
|
1924
|
+
SELECT activity.pid, blockers.blocker_pid, ARRAY[activity.pid, blockers.blocker_pid]
|
|
1925
|
+
FROM pg_stat_activity AS activity
|
|
1926
|
+
CROSS JOIN LATERAL unnest(pg_blocking_pids(activity.pid)) AS blockers(blocker_pid)
|
|
1927
|
+
WHERE activity.datname = current_database()
|
|
1928
|
+
AND activity.pid <> pg_backend_pid()
|
|
1929
|
+
AND activity.state = 'active'
|
|
1930
|
+
AND activity.wait_event_type = 'Lock'
|
|
1931
|
+
UNION ALL
|
|
1932
|
+
SELECT waiters.waiter_pid, blockers.blocker_pid, waiters.path || blockers.blocker_pid
|
|
1933
|
+
FROM lock_waiters AS waiters
|
|
1934
|
+
CROSS JOIN LATERAL unnest(pg_blocking_pids(waiters.blocker_pid)) AS blockers(blocker_pid)
|
|
1935
|
+
WHERE NOT (blockers.blocker_pid = ANY(waiters.path))
|
|
1936
|
+
)
|
|
1937
|
+
SELECT count(DISTINCT waiter_pid)::int AS count
|
|
1938
|
+
FROM lock_waiters
|
|
1939
|
+
WHERE blocker_pid = $1::int
|
|
1940
|
+
`,
|
|
1941
|
+
[params.blockingPid],
|
|
1942
|
+
);
|
|
1943
|
+
if ((result.rows[0]?.count ?? 0) >= minWaiters) return;
|
|
1944
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1945
|
+
}
|
|
1946
|
+
throw new Error(
|
|
1947
|
+
`Timed out waiting for ${minWaiters} backend(s) blocked by pid ${params.blockingPid}`,
|
|
1948
|
+
);
|
|
1949
|
+
}
|