@shipfox/api-runners 12.2.0 → 12.4.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 +34 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -3
- 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/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 +69 -11
- 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/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/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 +256 -45
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/runner-assignments.d.ts +17 -0
- package/dist/db/runner-assignments.d.ts.map +1 -1
- package/dist/db/runner-assignments.js +103 -8
- package/dist/db/runner-assignments.js.map +1 -1
- package/dist/db/runner-instances.d.ts +5 -2
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +134 -25
- 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/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 +24 -2
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +23 -0
- package/dist/metrics/instance.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/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 +3 -3
- 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 +4 -4
- package/src/config.test.ts +24 -2
- package/src/config.ts +16 -3
- 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/runner-activation.ts +48 -2
- package/src/core/runner-control-sessions.test.ts +230 -4
- package/src/core/runner-control-sessions.ts +84 -10
- 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/index.ts +1 -0
- package/src/db/reservation-locks.ts +22 -0
- package/src/db/reservations.test.ts +1110 -31
- package/src/db/reservations.ts +434 -105
- package/src/db/runner-assignments.test.ts +105 -1
- package/src/db/runner-assignments.ts +178 -9
- package/src/db/runner-instances.test.ts +496 -2
- package/src/db/runner-instances.ts +247 -56
- package/src/db/runner-sessions.ts +26 -15
- 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 +10 -1
- package/src/metrics/instance.ts +59 -2
- package/src/metrics/service.test.ts +106 -0
- package/src/metrics/service.ts +24 -4
- 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 +6 -2
- package/src/presentation/routes/late-runner-enrollment.test.ts +447 -0
- package/src/presentation/routes/poll-demand.test.ts +79 -6
- 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/test/fixtures/late-runner-enrollment.ts +103 -0
- package/test/index.ts +5 -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,18 +43,656 @@ 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'),
|
|
45
67
|
});
|
|
46
|
-
const newerRunner = await createIdleRunner({
|
|
68
|
+
const newerRunner = await createIdleRunner({
|
|
69
|
+
labels: ['linux'],
|
|
70
|
+
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
|
71
|
+
});
|
|
72
|
+
const unmatchedRunner = await createIdleRunner({
|
|
73
|
+
labels: ['macos'],
|
|
74
|
+
createdAt: new Date('2026-01-03T00:00:00.000Z'),
|
|
75
|
+
});
|
|
76
|
+
await createPendingJobs(3, ['linux']);
|
|
77
|
+
|
|
78
|
+
const result = await pollDemandAndReserve({
|
|
79
|
+
workspaceId,
|
|
80
|
+
provisionerId,
|
|
81
|
+
maxReservations: 3,
|
|
82
|
+
ttlSeconds: 60,
|
|
83
|
+
templates: [template('linux', ['linux'], 3)],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const rows = await db()
|
|
87
|
+
.select()
|
|
88
|
+
.from(providerRunners)
|
|
89
|
+
.where(inArray(providerRunners.id, [olderRunner.id, newerRunner.id, unmatchedRunner.id]));
|
|
90
|
+
const rowsById = new Map(rows.map((row) => [row.id, row]));
|
|
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;
|
|
100
|
+
|
|
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});
|
|
106
|
+
expect(rowsById.get(olderRunner.id)).toMatchObject({
|
|
107
|
+
workspaceId,
|
|
108
|
+
reservationId,
|
|
109
|
+
assignedAt: expect.any(Date),
|
|
110
|
+
});
|
|
111
|
+
expect(rowsById.get(newerRunner.id)).toMatchObject({
|
|
112
|
+
workspaceId,
|
|
113
|
+
reservationId,
|
|
114
|
+
assignedAt: expect.any(Date),
|
|
115
|
+
});
|
|
116
|
+
expect(rowsById.get(unmatchedRunner.id)).toMatchObject({
|
|
117
|
+
workspaceId: null,
|
|
118
|
+
reservationId: null,
|
|
119
|
+
assignedAt: null,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
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
|
+
|
|
538
|
+
it('binds a runner whose intended reservation has passed its activation grace period', async () => {
|
|
539
|
+
const intendedReservation = await createIntendedReservation({
|
|
540
|
+
workspaceId,
|
|
541
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
542
|
+
});
|
|
543
|
+
const runner = await createIdleRunner({
|
|
544
|
+
labels: ['linux'],
|
|
545
|
+
intendedReservationId: intendedReservation.id,
|
|
546
|
+
});
|
|
547
|
+
await createPendingJobs(1, ['linux']);
|
|
548
|
+
|
|
549
|
+
const result = await pollDemandAndReserve({
|
|
550
|
+
workspaceId,
|
|
551
|
+
provisionerId,
|
|
552
|
+
maxReservations: 1,
|
|
553
|
+
ttlSeconds: 60,
|
|
554
|
+
templates: [template('linux', ['linux'], 1)],
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const [storedRunner] = await db()
|
|
558
|
+
.select()
|
|
559
|
+
.from(providerRunners)
|
|
560
|
+
.where(eq(providerRunners.id, runner.id));
|
|
561
|
+
expect(result.reservations).toEqual([]);
|
|
562
|
+
expect(storedRunner).toMatchObject({
|
|
563
|
+
workspaceId,
|
|
564
|
+
reservationId: expect.any(String),
|
|
565
|
+
intendedReservationId: null,
|
|
566
|
+
assignedAt: expect.any(Date),
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('binds a runner whose intended reservation was deleted after its grace period', async () => {
|
|
571
|
+
const intendedReservation = await createIntendedReservation({
|
|
572
|
+
workspaceId,
|
|
573
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
574
|
+
});
|
|
575
|
+
await db().delete(reservations).where(eq(reservations.id, intendedReservation.id));
|
|
576
|
+
const runner = await createIdleRunner({
|
|
577
|
+
labels: ['linux'],
|
|
578
|
+
intendedReservationId: intendedReservation.id,
|
|
579
|
+
});
|
|
580
|
+
await createPendingJobs(1, ['linux']);
|
|
581
|
+
|
|
582
|
+
const result = await pollDemandAndReserve({
|
|
583
|
+
workspaceId,
|
|
584
|
+
provisionerId,
|
|
585
|
+
maxReservations: 1,
|
|
586
|
+
ttlSeconds: 60,
|
|
587
|
+
templates: [template('linux', ['linux'], 1)],
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const [storedRunner] = await db()
|
|
591
|
+
.select()
|
|
592
|
+
.from(providerRunners)
|
|
593
|
+
.where(eq(providerRunners.id, runner.id));
|
|
594
|
+
expect(result.reservations).toEqual([]);
|
|
595
|
+
expect(storedRunner).toMatchObject({
|
|
596
|
+
workspaceId,
|
|
597
|
+
reservationId: expect.any(String),
|
|
598
|
+
intendedReservationId: null,
|
|
599
|
+
assignedAt: expect.any(Date),
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('binds an assigned runner after its reservation grace period expires', async () => {
|
|
604
|
+
const staleReservation = await createIntendedReservation({
|
|
605
|
+
workspaceId,
|
|
606
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
607
|
+
});
|
|
608
|
+
const runner = await createIdleRunner({
|
|
609
|
+
labels: ['linux'],
|
|
610
|
+
workspaceId,
|
|
611
|
+
reservationId: staleReservation.id,
|
|
612
|
+
intendedReservationId: staleReservation.id,
|
|
613
|
+
});
|
|
614
|
+
const [activationToken] = await db()
|
|
615
|
+
.insert(runnerActivationTokens)
|
|
616
|
+
.values({
|
|
617
|
+
runnerInstanceId: runner.id,
|
|
618
|
+
hashedToken: crypto.randomUUID(),
|
|
619
|
+
prefix: 'test',
|
|
620
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
621
|
+
})
|
|
622
|
+
.returning();
|
|
623
|
+
if (!activationToken) throw new Error('Expected activation token');
|
|
624
|
+
await createPendingJobs(1, ['linux']);
|
|
625
|
+
|
|
626
|
+
const result = await pollDemandAndReserve({
|
|
627
|
+
workspaceId,
|
|
628
|
+
provisionerId,
|
|
629
|
+
maxReservations: 1,
|
|
630
|
+
ttlSeconds: 60,
|
|
631
|
+
templates: [template('linux', ['linux'], 1)],
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
const [storedRunner] = await db()
|
|
635
|
+
.select()
|
|
636
|
+
.from(providerRunners)
|
|
637
|
+
.where(eq(providerRunners.id, runner.id));
|
|
638
|
+
const [storedToken] = await db()
|
|
639
|
+
.select()
|
|
640
|
+
.from(runnerActivationTokens)
|
|
641
|
+
.where(eq(runnerActivationTokens.id, activationToken.id));
|
|
642
|
+
expect(result.reservations).toEqual([]);
|
|
643
|
+
expect(storedRunner).toMatchObject({
|
|
644
|
+
workspaceId,
|
|
645
|
+
reservationId: expect.any(String),
|
|
646
|
+
intendedReservationId: null,
|
|
647
|
+
assignedAt: expect.any(Date),
|
|
648
|
+
});
|
|
649
|
+
expect(storedToken?.revokedAt).toBeInstanceOf(Date);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('binds an assigned runner whose reservation was deleted', async () => {
|
|
653
|
+
const staleReservation = await createIntendedReservation({
|
|
654
|
+
workspaceId,
|
|
655
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
656
|
+
});
|
|
657
|
+
await db().delete(reservations).where(eq(reservations.id, staleReservation.id));
|
|
658
|
+
const runner = await createIdleRunner({
|
|
659
|
+
labels: ['linux'],
|
|
660
|
+
workspaceId,
|
|
661
|
+
reservationId: staleReservation.id,
|
|
662
|
+
intendedReservationId: staleReservation.id,
|
|
663
|
+
});
|
|
664
|
+
await createPendingJobs(1, ['linux']);
|
|
665
|
+
|
|
666
|
+
const result = await pollDemandAndReserve({
|
|
667
|
+
workspaceId,
|
|
668
|
+
provisionerId,
|
|
669
|
+
maxReservations: 1,
|
|
670
|
+
ttlSeconds: 60,
|
|
671
|
+
templates: [template('linux', ['linux'], 1)],
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const [storedRunner] = await db()
|
|
675
|
+
.select()
|
|
676
|
+
.from(providerRunners)
|
|
677
|
+
.where(eq(providerRunners.id, runner.id));
|
|
678
|
+
expect(result.reservations).toEqual([]);
|
|
679
|
+
expect(storedRunner).toMatchObject({
|
|
680
|
+
workspaceId,
|
|
681
|
+
reservationId: expect.any(String),
|
|
682
|
+
intendedReservationId: null,
|
|
683
|
+
assignedAt: expect.any(Date),
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('does not bind an assigned runner within its activation grace period', async () => {
|
|
688
|
+
const liveReservation = await createIntendedReservation({
|
|
689
|
+
workspaceId,
|
|
690
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
691
|
+
});
|
|
692
|
+
const runner = await createIdleRunner({
|
|
47
693
|
labels: ['linux'],
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const unmatchedRunner = await createIdleRunner({
|
|
51
|
-
labels: ['macos'],
|
|
52
|
-
createdAt: new Date('2026-01-03T00:00:00.000Z'),
|
|
694
|
+
workspaceId,
|
|
695
|
+
reservationId: liveReservation.id,
|
|
53
696
|
});
|
|
54
697
|
await createPendingJobs(2, ['linux']);
|
|
55
698
|
|
|
@@ -61,31 +704,336 @@ describe('pollDemandAndReserve', () => {
|
|
|
61
704
|
templates: [template('linux', ['linux'], 2)],
|
|
62
705
|
});
|
|
63
706
|
|
|
64
|
-
const
|
|
707
|
+
const [storedRunner] = await db()
|
|
65
708
|
.select()
|
|
66
709
|
.from(providerRunners)
|
|
67
|
-
.where(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
expect(reservationId).toEqual(expect.any(String));
|
|
72
|
-
expect(rowsById.get(olderRunner.id)).toMatchObject({
|
|
710
|
+
.where(eq(providerRunners.id, runner.id));
|
|
711
|
+
expect(result.reservations[0]?.count).toBe(1);
|
|
712
|
+
expect(storedRunner).toMatchObject({
|
|
73
713
|
workspaceId,
|
|
74
|
-
reservationId,
|
|
75
|
-
|
|
714
|
+
reservationId: liveReservation.id,
|
|
715
|
+
intendedReservationId: null,
|
|
716
|
+
assignedAt: null,
|
|
76
717
|
});
|
|
77
|
-
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('does not bind a runner whose intended reservation is within its activation grace period', async () => {
|
|
721
|
+
const intendedReservation = await createIntendedReservation({
|
|
722
|
+
workspaceId: crypto.randomUUID(),
|
|
723
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
724
|
+
});
|
|
725
|
+
const runner = await createIdleRunner({
|
|
726
|
+
labels: ['linux'],
|
|
727
|
+
intendedReservationId: intendedReservation.id,
|
|
728
|
+
});
|
|
729
|
+
await createPendingJobs(1, ['linux']);
|
|
730
|
+
|
|
731
|
+
const result = await pollDemandAndReserve({
|
|
78
732
|
workspaceId,
|
|
79
|
-
|
|
80
|
-
|
|
733
|
+
provisionerId,
|
|
734
|
+
maxReservations: 1,
|
|
735
|
+
ttlSeconds: 60,
|
|
736
|
+
templates: [template('linux', ['linux'], 1)],
|
|
81
737
|
});
|
|
82
|
-
|
|
738
|
+
|
|
739
|
+
const [storedRunner] = await db()
|
|
740
|
+
.select()
|
|
741
|
+
.from(providerRunners)
|
|
742
|
+
.where(eq(providerRunners.id, runner.id));
|
|
743
|
+
expect(result.reservations[0]?.count).toBe(1);
|
|
744
|
+
expect(storedRunner).toMatchObject({
|
|
83
745
|
workspaceId: null,
|
|
84
746
|
reservationId: null,
|
|
747
|
+
intendedReservationId: intendedReservation.id,
|
|
85
748
|
assignedAt: null,
|
|
86
749
|
});
|
|
87
750
|
});
|
|
88
751
|
|
|
752
|
+
it('does not rebind a stale runner from another workspace in a workspace-scoped poll', async () => {
|
|
753
|
+
const previousWorkspaceId = crypto.randomUUID();
|
|
754
|
+
const staleReservation = await createIntendedReservation({
|
|
755
|
+
workspaceId: previousWorkspaceId,
|
|
756
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
757
|
+
});
|
|
758
|
+
const runner = await createIdleRunner({
|
|
759
|
+
labels: ['linux'],
|
|
760
|
+
workspaceId: previousWorkspaceId,
|
|
761
|
+
reservationId: staleReservation.id,
|
|
762
|
+
intendedReservationId: staleReservation.id,
|
|
763
|
+
});
|
|
764
|
+
await createPendingJobs(1, ['linux']);
|
|
765
|
+
|
|
766
|
+
const result = await pollDemandAndReserve({
|
|
767
|
+
workspaceId,
|
|
768
|
+
provisionerId,
|
|
769
|
+
maxReservations: 1,
|
|
770
|
+
ttlSeconds: 60,
|
|
771
|
+
templates: [template('linux', ['linux'], 1)],
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
const [storedRunner] = await db()
|
|
775
|
+
.select()
|
|
776
|
+
.from(providerRunners)
|
|
777
|
+
.where(eq(providerRunners.id, runner.id));
|
|
778
|
+
expect(result.reservations[0]?.count).toBe(1);
|
|
779
|
+
expect(storedRunner).toMatchObject({
|
|
780
|
+
workspaceId: previousWorkspaceId,
|
|
781
|
+
reservationId: staleReservation.id,
|
|
782
|
+
intendedReservationId: staleReservation.id,
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('rebinds an unowned runner whose reservation was deleted', async () => {
|
|
787
|
+
const runner = await createIdleRunner({
|
|
788
|
+
labels: ['linux'],
|
|
789
|
+
reservationId: crypto.randomUUID(),
|
|
790
|
+
});
|
|
791
|
+
await createPendingJobs(1, ['linux']);
|
|
792
|
+
|
|
793
|
+
const result = await pollDemandAndReserve({
|
|
794
|
+
workspaceId,
|
|
795
|
+
provisionerId,
|
|
796
|
+
maxReservations: 1,
|
|
797
|
+
ttlSeconds: 60,
|
|
798
|
+
templates: [template('linux', ['linux'], 1)],
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
const [storedRunner] = await db()
|
|
802
|
+
.select()
|
|
803
|
+
.from(providerRunners)
|
|
804
|
+
.where(eq(providerRunners.id, runner.id));
|
|
805
|
+
expect(result.reservations).toEqual([]);
|
|
806
|
+
expect(storedRunner).toMatchObject({
|
|
807
|
+
workspaceId,
|
|
808
|
+
reservationId: expect.any(String),
|
|
809
|
+
intendedReservationId: null,
|
|
810
|
+
assignedAt: expect.any(Date),
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('rebinds a stale runner across workspaces for an installation-scoped poll', async () => {
|
|
815
|
+
const previousWorkspaceId = crypto.randomUUID();
|
|
816
|
+
const targetWorkspaceId = crypto.randomUUID();
|
|
817
|
+
const staleReservation = await createIntendedReservation({
|
|
818
|
+
workspaceId: previousWorkspaceId,
|
|
819
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
820
|
+
});
|
|
821
|
+
const runner = await createIdleRunner({
|
|
822
|
+
labels: ['linux'],
|
|
823
|
+
workspaceId: previousWorkspaceId,
|
|
824
|
+
reservationId: staleReservation.id,
|
|
825
|
+
intendedReservationId: staleReservation.id,
|
|
826
|
+
});
|
|
827
|
+
await pendingJobFactory.create({workspaceId: targetWorkspaceId, requiredLabels: ['linux']});
|
|
828
|
+
|
|
829
|
+
const result = await pollInstallationDemandAndReserve({
|
|
830
|
+
provisionerId,
|
|
831
|
+
maxReservations: 1,
|
|
832
|
+
ttlSeconds: 60,
|
|
833
|
+
templates: [template('linux', ['linux'], 1)],
|
|
834
|
+
capabilityWindowSeconds: 60,
|
|
835
|
+
eligibleWorkspaceIds: new Set([targetWorkspaceId]),
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
const [storedRunner] = await db()
|
|
839
|
+
.select()
|
|
840
|
+
.from(providerRunners)
|
|
841
|
+
.where(eq(providerRunners.id, runner.id));
|
|
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});
|
|
854
|
+
expect(storedRunner).toMatchObject({
|
|
855
|
+
workspaceId: targetWorkspaceId,
|
|
856
|
+
reservationId: storedReservation?.id,
|
|
857
|
+
intendedReservationId: null,
|
|
858
|
+
assignedAt: expect.any(Date),
|
|
859
|
+
});
|
|
860
|
+
});
|
|
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
|
+
|
|
1000
|
+
it('does not rebind a released runner after its reservation expires', async () => {
|
|
1001
|
+
const staleReservation = await createIntendedReservation({
|
|
1002
|
+
workspaceId,
|
|
1003
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
1004
|
+
});
|
|
1005
|
+
const releasedAt = new Date(Date.now() - 30_000);
|
|
1006
|
+
const runner = await createIdleRunner({
|
|
1007
|
+
labels: ['linux'],
|
|
1008
|
+
launchKind: 'demand',
|
|
1009
|
+
workspaceId,
|
|
1010
|
+
reservationId: staleReservation.id,
|
|
1011
|
+
intendedReservationId: staleReservation.id,
|
|
1012
|
+
reservationReleasedAt: releasedAt,
|
|
1013
|
+
});
|
|
1014
|
+
await createPendingJobs(1, ['linux']);
|
|
1015
|
+
|
|
1016
|
+
const result = await pollDemandAndReserve({
|
|
1017
|
+
workspaceId,
|
|
1018
|
+
provisionerId,
|
|
1019
|
+
maxReservations: 1,
|
|
1020
|
+
ttlSeconds: 60,
|
|
1021
|
+
templates: [template('linux', ['linux'], 1)],
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
const [storedRunner] = await db()
|
|
1025
|
+
.select()
|
|
1026
|
+
.from(providerRunners)
|
|
1027
|
+
.where(eq(providerRunners.id, runner.id));
|
|
1028
|
+
expect(result.reservations[0]?.count).toBe(1);
|
|
1029
|
+
expect(storedRunner).toMatchObject({
|
|
1030
|
+
workspaceId,
|
|
1031
|
+
reservationId: staleReservation.id,
|
|
1032
|
+
intendedReservationId: staleReservation.id,
|
|
1033
|
+
reservationReleasedAt: releasedAt,
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
|
|
89
1037
|
it('does not bind a running runner without an active control session', async () => {
|
|
90
1038
|
const [runner] = await db()
|
|
91
1039
|
.insert(providerRunners)
|
|
@@ -158,9 +1106,10 @@ describe('pollDemandAndReserve', () => {
|
|
|
158
1106
|
templates: [template('linux', ['linux'], 1)],
|
|
159
1107
|
});
|
|
160
1108
|
|
|
161
|
-
expect(firstResult.reservations).
|
|
1109
|
+
expect(firstResult.reservations).toEqual([]);
|
|
162
1110
|
expect(secondResult.reservations).toEqual([]);
|
|
163
1111
|
expect(secondResult.stats[0]).toMatchObject({queued: 1, reserved: 1});
|
|
1112
|
+
expect(await activeReservedCount()).toBe(1);
|
|
164
1113
|
});
|
|
165
1114
|
|
|
166
1115
|
it('allocates overlapping label sets most-specific-first', async () => {
|
|
@@ -225,6 +1174,53 @@ describe('pollDemandAndReserve', () => {
|
|
|
225
1174
|
]);
|
|
226
1175
|
});
|
|
227
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
|
+
|
|
228
1224
|
it('reports committed installation grants before an aborted poll stops allocating', async () => {
|
|
229
1225
|
const firstWorkspaceId = crypto.randomUUID();
|
|
230
1226
|
const secondWorkspaceId = crypto.randomUUID();
|
|
@@ -421,15 +1417,25 @@ describe('pollDemandAndReserve', () => {
|
|
|
421
1417
|
const runner = await createIdleRunner({labels: ['linux']});
|
|
422
1418
|
await createPendingJobs(1, ['linux']);
|
|
423
1419
|
|
|
424
|
-
|
|
1420
|
+
await pollDemandAndReserve({
|
|
425
1421
|
workspaceId,
|
|
426
1422
|
provisionerId,
|
|
427
1423
|
maxReservations: 1,
|
|
428
1424
|
ttlSeconds: 60,
|
|
429
1425
|
templates: [template('linux', ['linux'], 1)],
|
|
430
1426
|
});
|
|
431
|
-
const
|
|
432
|
-
|
|
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');
|
|
433
1439
|
|
|
434
1440
|
const [activationToken] = await db()
|
|
435
1441
|
.insert(runnerActivationTokens)
|
|
@@ -523,15 +1529,25 @@ describe('pollDemandAndReserve', () => {
|
|
|
523
1529
|
const runner = await createIdleRunner({labels: ['linux']});
|
|
524
1530
|
await createPendingJobs(1, ['linux']);
|
|
525
1531
|
|
|
526
|
-
|
|
1532
|
+
await pollDemandAndReserve({
|
|
527
1533
|
workspaceId,
|
|
528
1534
|
provisionerId,
|
|
529
1535
|
maxReservations: 1,
|
|
530
1536
|
ttlSeconds: 60,
|
|
531
1537
|
templates: [template('linux', ['linux'], 1)],
|
|
532
1538
|
});
|
|
533
|
-
const
|
|
534
|
-
|
|
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');
|
|
535
1551
|
|
|
536
1552
|
await db()
|
|
537
1553
|
.update(reservations)
|
|
@@ -619,18 +1635,18 @@ describe('pollDemandAndReserve', () => {
|
|
|
619
1635
|
expect(storedSurvivingRunner?.intendedReservationId).toBe(survivingReservation.id);
|
|
620
1636
|
|
|
621
1637
|
await createPendingJobs(1, ['linux']);
|
|
622
|
-
|
|
1638
|
+
await pollDemandAndReserve({
|
|
623
1639
|
workspaceId,
|
|
624
1640
|
provisionerId,
|
|
625
1641
|
maxReservations: 1,
|
|
626
1642
|
ttlSeconds: 60,
|
|
627
1643
|
templates: [template('linux', ['linux'], 1)],
|
|
628
1644
|
});
|
|
629
|
-
const reboundReservationId = result.reservations[0]?.reservationId;
|
|
630
1645
|
const [reboundRunner] = await db()
|
|
631
1646
|
.select()
|
|
632
1647
|
.from(providerRunners)
|
|
633
1648
|
.where(eq(providerRunners.id, runner.id));
|
|
1649
|
+
const reboundReservationId = reboundRunner?.reservationId;
|
|
634
1650
|
expect(reboundReservationId).toEqual(expect.any(String));
|
|
635
1651
|
expect(reboundRunner).toMatchObject({
|
|
636
1652
|
intendedReservationId: null,
|
|
@@ -808,21 +1824,50 @@ describe('pollDemandAndReserve', () => {
|
|
|
808
1824
|
}
|
|
809
1825
|
}
|
|
810
1826
|
|
|
1827
|
+
async function createIntendedReservation(params: {
|
|
1828
|
+
workspaceId: string;
|
|
1829
|
+
expiresAt: Date;
|
|
1830
|
+
}): Promise<{id: string}> {
|
|
1831
|
+
const [reservation] = await db()
|
|
1832
|
+
.insert(reservations)
|
|
1833
|
+
.values({
|
|
1834
|
+
workspaceId: params.workspaceId,
|
|
1835
|
+
provisionerId,
|
|
1836
|
+
requiredLabels: ['linux'],
|
|
1837
|
+
count: 1,
|
|
1838
|
+
expiresAt: params.expiresAt,
|
|
1839
|
+
})
|
|
1840
|
+
.returning({id: reservations.id});
|
|
1841
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
1842
|
+
return reservation;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
811
1845
|
async function createIdleRunner(params: {
|
|
1846
|
+
id?: string;
|
|
812
1847
|
labels: string[];
|
|
813
1848
|
createdAt?: Date;
|
|
814
1849
|
controlSessionExpiresAt?: Date;
|
|
815
|
-
|
|
1850
|
+
launchKind?: 'demand' | 'warm' | 'manual';
|
|
1851
|
+
workspaceId?: string | null;
|
|
1852
|
+
reservationId?: string | null;
|
|
1853
|
+
intendedReservationId?: string | null;
|
|
1854
|
+
reservationReleasedAt?: Date | null;
|
|
1855
|
+
state?: RunnerInstanceState;
|
|
816
1856
|
}) {
|
|
817
1857
|
const createdAt = params.createdAt ?? new Date();
|
|
818
1858
|
const [runner] = await db()
|
|
819
1859
|
.insert(providerRunners)
|
|
820
1860
|
.values({
|
|
1861
|
+
...(params.id ? {id: params.id} : {}),
|
|
821
1862
|
provisionerId,
|
|
1863
|
+
workspaceId: params.workspaceId,
|
|
1864
|
+
reservationId: params.reservationId,
|
|
822
1865
|
providerRunnerId: crypto.randomUUID(),
|
|
1866
|
+
launchKind: params.launchKind ?? 'manual',
|
|
823
1867
|
intendedReservationId: params.intendedReservationId,
|
|
1868
|
+
reservationReleasedAt: params.reservationReleasedAt,
|
|
824
1869
|
labels: params.labels,
|
|
825
|
-
state: 'running',
|
|
1870
|
+
state: params.state ?? 'running',
|
|
826
1871
|
reportedAt: createdAt,
|
|
827
1872
|
createdAt,
|
|
828
1873
|
updatedAt: createdAt,
|
|
@@ -868,3 +1913,37 @@ describe('pollDemandAndReserve', () => {
|
|
|
868
1913
|
return {templateKey, labels, availableSlots, starting: 0, running: 0};
|
|
869
1914
|
}
|
|
870
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
|
+
}
|