@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
package/src/db/reservations.ts
CHANGED
|
@@ -10,18 +10,23 @@ import {
|
|
|
10
10
|
isNotNull,
|
|
11
11
|
isNull,
|
|
12
12
|
lt,
|
|
13
|
+
notExists,
|
|
14
|
+
notInArray,
|
|
13
15
|
or,
|
|
14
16
|
sql,
|
|
15
17
|
} from 'drizzle-orm';
|
|
18
|
+
import {recordProviderRunnerActivationOutcome} from '#metrics/instance.js';
|
|
16
19
|
import type {Tx} from './db.js';
|
|
17
20
|
import {db} from './db.js';
|
|
21
|
+
import {lockRunnerReservationAdvisoryKeysTx} from './reservation-locks.js';
|
|
22
|
+
import {terminalStates} from './runner-states.js';
|
|
18
23
|
import {pendingJobExecutions} from './schema/pending-job-executions.js';
|
|
19
24
|
import {provisionerCapabilitySnapshots} from './schema/provisioner-capability-snapshots.js';
|
|
20
25
|
import {provisionerTokens} from './schema/provisioner-tokens.js';
|
|
21
26
|
import {reservations} from './schema/reservations.js';
|
|
22
27
|
import {runnerActivationTokens} from './schema/runner-activation-tokens.js';
|
|
23
28
|
import {runnerControlSessions} from './schema/runner-control-sessions.js';
|
|
24
|
-
import {providerRunners} from './schema/runner-instances.js';
|
|
29
|
+
import {type providerRunnerLaunchKindEnum, providerRunners} from './schema/runner-instances.js';
|
|
25
30
|
|
|
26
31
|
export interface ReservationTemplate {
|
|
27
32
|
templateKey: string;
|
|
@@ -52,6 +57,8 @@ export interface PollDemandAndReserveParams {
|
|
|
52
57
|
provisionerId: string;
|
|
53
58
|
maxReservations: number;
|
|
54
59
|
ttlSeconds: number;
|
|
60
|
+
/** Lifetime of the short reservation that gives rebound runners time to activate. */
|
|
61
|
+
activationGraceSeconds?: number;
|
|
55
62
|
templates: ReservationTemplate[];
|
|
56
63
|
capabilityWindowSeconds?: number;
|
|
57
64
|
}
|
|
@@ -60,6 +67,8 @@ export interface InstallationPollDemandAndReserveParams {
|
|
|
60
67
|
provisionerId: string;
|
|
61
68
|
maxReservations: number;
|
|
62
69
|
ttlSeconds: number;
|
|
70
|
+
/** Lifetime of the short reservation that gives rebound runners time to activate. */
|
|
71
|
+
activationGraceSeconds?: number;
|
|
63
72
|
templates: ReservationTemplate[];
|
|
64
73
|
capabilityWindowSeconds: number;
|
|
65
74
|
eligibleWorkspaceIds: ReadonlySet<string>;
|
|
@@ -67,6 +76,28 @@ export interface InstallationPollDemandAndReserveParams {
|
|
|
67
76
|
onReservations?: (reservations: ReservationGrant[]) => void;
|
|
68
77
|
}
|
|
69
78
|
|
|
79
|
+
type DemandScope = 'installation' | 'workspace';
|
|
80
|
+
|
|
81
|
+
interface IdleRunnerCandidate {
|
|
82
|
+
id: string;
|
|
83
|
+
launchKind: (typeof providerRunnerLaunchKindEnum.enumValues)[number];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface BindableRunnerParams {
|
|
87
|
+
provisionerId: string;
|
|
88
|
+
workspaceId: string;
|
|
89
|
+
requiredLabels: string[];
|
|
90
|
+
scope: DemandScope;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface IdleRunnerSelectionParams extends BindableRunnerParams {
|
|
94
|
+
count: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type PollDemandAndReserveLockedParams = PollDemandAndReserveParams & {
|
|
98
|
+
scope: DemandScope;
|
|
99
|
+
};
|
|
100
|
+
|
|
70
101
|
interface NormalizedTemplate {
|
|
71
102
|
templateKey: string;
|
|
72
103
|
labels: string[];
|
|
@@ -79,29 +110,47 @@ interface DemandRow {
|
|
|
79
110
|
oldestQueuedAt: Date;
|
|
80
111
|
}
|
|
81
112
|
|
|
113
|
+
interface NewReservationUnits {
|
|
114
|
+
labels: string[];
|
|
115
|
+
count: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface PollDemandAndReserveResult {
|
|
119
|
+
stats: DemandStat[];
|
|
120
|
+
/** Only launch reservations are exposed to the provisioner. */
|
|
121
|
+
reservations: ReservationGrant[];
|
|
122
|
+
/** Internal allocation accounting for bound and launch rows together. */
|
|
123
|
+
newlyReservedUnits: NewReservationUnits[];
|
|
124
|
+
}
|
|
125
|
+
|
|
82
126
|
export async function pollDemandAndReserve(
|
|
83
127
|
params: PollDemandAndReserveParams,
|
|
84
128
|
): Promise<{stats: DemandStat[]; reservations: ReservationGrant[]}> {
|
|
85
|
-
|
|
129
|
+
const result = await db().transaction(async (tx) => {
|
|
86
130
|
return await pollDemandAndReserveTx(tx, params);
|
|
87
131
|
});
|
|
132
|
+
return {stats: result.stats, reservations: result.reservations};
|
|
88
133
|
}
|
|
89
134
|
|
|
90
135
|
export async function pollDemandAndReserveTx(
|
|
91
136
|
tx: Tx,
|
|
92
137
|
params: PollDemandAndReserveParams,
|
|
93
|
-
): Promise<
|
|
138
|
+
): Promise<PollDemandAndReserveResult> {
|
|
94
139
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${params.workspaceId}))`);
|
|
95
|
-
return await pollDemandAndReserveLockedTx(tx, params);
|
|
140
|
+
return await pollDemandAndReserveLockedTx(tx, {...params, scope: 'workspace'});
|
|
96
141
|
}
|
|
97
142
|
|
|
98
143
|
export async function pollInstallationDemandAndReserve(
|
|
99
144
|
params: InstallationPollDemandAndReserveParams,
|
|
100
|
-
): Promise<{
|
|
145
|
+
): Promise<{
|
|
146
|
+
stats: DemandStat[];
|
|
147
|
+
reservations: ReservationGrant[];
|
|
148
|
+
newlyReservedCount?: number;
|
|
149
|
+
}> {
|
|
101
150
|
const candidateWorkspaceIds = await listInstallationDemandWorkspaceIds(
|
|
102
151
|
params.eligibleWorkspaceIds,
|
|
103
152
|
);
|
|
104
|
-
const results:
|
|
153
|
+
const results: PollDemandAndReserveResult[] = [];
|
|
105
154
|
let remainingMaxReservations = params.maxReservations;
|
|
106
155
|
const remainingTemplates = params.templates.map((template) => ({
|
|
107
156
|
...template,
|
|
@@ -115,38 +164,51 @@ export async function pollInstallationDemandAndReserve(
|
|
|
115
164
|
sql`select pg_try_advisory_xact_lock(hashtext(${workspaceId})) as locked`,
|
|
116
165
|
);
|
|
117
166
|
const locked = lockResult.rows[0];
|
|
118
|
-
if (!locked?.locked) return {stats: [], reservations: []};
|
|
167
|
+
if (!locked?.locked) return {stats: [], reservations: [], newlyReservedUnits: []};
|
|
119
168
|
return await pollDemandAndReserveLockedTx(tx, {
|
|
120
169
|
workspaceId,
|
|
121
170
|
provisionerId: params.provisionerId,
|
|
122
171
|
maxReservations: remainingMaxReservations,
|
|
123
172
|
ttlSeconds: params.ttlSeconds,
|
|
173
|
+
...(params.activationGraceSeconds !== undefined
|
|
174
|
+
? {activationGraceSeconds: params.activationGraceSeconds}
|
|
175
|
+
: {}),
|
|
124
176
|
templates: remainingTemplates.map((template) => ({
|
|
125
177
|
...template,
|
|
126
178
|
availableSlots: template.remainingSlots,
|
|
127
179
|
})),
|
|
128
180
|
capabilityWindowSeconds: params.capabilityWindowSeconds,
|
|
181
|
+
scope: 'installation',
|
|
129
182
|
});
|
|
130
183
|
});
|
|
131
184
|
results.push(result);
|
|
132
185
|
consumeInstallationTemplateSlots(remainingTemplates, result.reservations);
|
|
133
186
|
params.onReservations?.(result.reservations);
|
|
134
|
-
remainingMaxReservations -= result.
|
|
187
|
+
remainingMaxReservations -= result.newlyReservedUnits.reduce(
|
|
135
188
|
(total, reservation) => total + reservation.count,
|
|
136
189
|
0,
|
|
137
190
|
);
|
|
138
191
|
}
|
|
192
|
+
const newlyReservedCount = results.reduce(
|
|
193
|
+
(total, result) =>
|
|
194
|
+
total +
|
|
195
|
+
result.newlyReservedUnits.reduce((subtotal, reservation) => subtotal + reservation.count, 0),
|
|
196
|
+
0,
|
|
197
|
+
);
|
|
139
198
|
return {
|
|
140
199
|
stats: results.flatMap((result) => result.stats),
|
|
141
200
|
reservations: results.flatMap((result) => result.reservations),
|
|
201
|
+
...(newlyReservedCount > 0 ? {newlyReservedCount} : {}),
|
|
142
202
|
};
|
|
143
203
|
}
|
|
144
204
|
|
|
145
205
|
function consumeInstallationTemplateSlots(
|
|
146
206
|
templates: NormalizedTemplate[],
|
|
147
|
-
|
|
207
|
+
launchGrants: ReservationGrant[],
|
|
148
208
|
): void {
|
|
149
|
-
|
|
209
|
+
// Adopted runners are already included in the running count behind availableSlots.
|
|
210
|
+
// Only units that still need a launch consume advertised template capacity.
|
|
211
|
+
for (const reservation of launchGrants) {
|
|
150
212
|
const satisfyingTemplates = templates
|
|
151
213
|
.filter((template) => isSubset(reservation.labels, template.labels))
|
|
152
214
|
.sort(
|
|
@@ -158,8 +220,8 @@ function consumeInstallationTemplateSlots(
|
|
|
158
220
|
|
|
159
221
|
async function pollDemandAndReserveLockedTx(
|
|
160
222
|
tx: Tx,
|
|
161
|
-
params:
|
|
162
|
-
): Promise<
|
|
223
|
+
params: PollDemandAndReserveLockedParams,
|
|
224
|
+
): Promise<PollDemandAndReserveResult> {
|
|
163
225
|
let demandRows = (
|
|
164
226
|
await tx
|
|
165
227
|
.select({
|
|
@@ -198,20 +260,10 @@ async function pollDemandAndReserveLockedTx(
|
|
|
198
260
|
const reservedByLabels = new Map(
|
|
199
261
|
activeReservationRows.map((row) => [labelKey(row.requiredLabels), row.reserved]),
|
|
200
262
|
);
|
|
201
|
-
const activeProvisionerReservationRows = await tx
|
|
202
|
-
.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
})
|
|
206
|
-
.from(reservations)
|
|
207
|
-
.where(
|
|
208
|
-
and(
|
|
209
|
-
eq(reservations.workspaceId, params.workspaceId),
|
|
210
|
-
eq(reservations.provisionerId, params.provisionerId),
|
|
211
|
-
gt(reservations.expiresAt, sql`now()`),
|
|
212
|
-
),
|
|
213
|
-
)
|
|
214
|
-
.groupBy(reservations.requiredLabels);
|
|
263
|
+
const activeProvisionerReservationRows = await listActiveProvisionerReservationRowsTx(tx, {
|
|
264
|
+
workspaceId: params.workspaceId,
|
|
265
|
+
provisionerId: params.provisionerId,
|
|
266
|
+
});
|
|
215
267
|
|
|
216
268
|
const templates = params.templates.map((template) => ({
|
|
217
269
|
templateKey: template.templateKey,
|
|
@@ -221,6 +273,7 @@ async function pollDemandAndReserveLockedTx(
|
|
|
221
273
|
deductProvisionerReservations(templates, activeProvisionerReservationRows);
|
|
222
274
|
const stats: DemandStat[] = [];
|
|
223
275
|
const grants: ReservationGrant[] = [];
|
|
276
|
+
const newlyReservedUnits: NewReservationUnits[] = [];
|
|
224
277
|
let remainingMaxReservations = params.maxReservations;
|
|
225
278
|
const workspaceIdField =
|
|
226
279
|
params.capabilityWindowSeconds === undefined ? {} : {workspaceId: params.workspaceId};
|
|
@@ -244,36 +297,73 @@ async function pollDemandAndReserveLockedTx(
|
|
|
244
297
|
let reservedAfterGrant = reserved;
|
|
245
298
|
|
|
246
299
|
if (grant > 0 && params.maxReservations > 0) {
|
|
247
|
-
const
|
|
248
|
-
.insert(reservations)
|
|
249
|
-
.values({
|
|
250
|
-
workspaceId: params.workspaceId,
|
|
251
|
-
provisionerId: params.provisionerId,
|
|
252
|
-
requiredLabels: demand.requiredLabels,
|
|
253
|
-
count: grant,
|
|
254
|
-
expiresAt: sql`now() + (${params.ttlSeconds} || ' seconds')::interval`,
|
|
255
|
-
})
|
|
256
|
-
.returning({id: reservations.id, expiresAt: reservations.expiresAt});
|
|
257
|
-
|
|
258
|
-
if (!inserted) throw new Error('Insert returned no rows');
|
|
259
|
-
|
|
260
|
-
remainingMaxReservations -= grant;
|
|
261
|
-
drawSlots(satisfyingTemplates, grant);
|
|
262
|
-
await bindIdleRunnerInstancesTx(tx, {
|
|
300
|
+
const idleRunners = await listIdleRunnerInstancesTx(tx, {
|
|
263
301
|
provisionerId: params.provisionerId,
|
|
264
|
-
reservationId: inserted.id,
|
|
265
302
|
requiredLabels: demand.requiredLabels,
|
|
266
303
|
count: grant,
|
|
267
304
|
workspaceId: params.workspaceId,
|
|
305
|
+
scope: params.scope,
|
|
268
306
|
});
|
|
307
|
+
|
|
308
|
+
remainingMaxReservations -= grant;
|
|
309
|
+
|
|
310
|
+
const boundCount = idleRunners.length;
|
|
311
|
+
if (boundCount > 0) {
|
|
312
|
+
const [boundReservation] = await tx
|
|
313
|
+
.insert(reservations)
|
|
314
|
+
.values({
|
|
315
|
+
workspaceId: params.workspaceId,
|
|
316
|
+
provisionerId: params.provisionerId,
|
|
317
|
+
requiredLabels: demand.requiredLabels,
|
|
318
|
+
count: boundCount,
|
|
319
|
+
kind: 'bound',
|
|
320
|
+
expiresAt: sql`now() + (${params.activationGraceSeconds ?? params.ttlSeconds} || ' seconds')::interval`,
|
|
321
|
+
})
|
|
322
|
+
.returning({id: reservations.id});
|
|
323
|
+
|
|
324
|
+
if (!boundReservation) throw new Error('Insert returned no rows');
|
|
325
|
+
|
|
326
|
+
await bindIdleRunnerInstancesTx(tx, {
|
|
327
|
+
provisionerId: params.provisionerId,
|
|
328
|
+
reservationId: boundReservation.id,
|
|
329
|
+
workspaceId: params.workspaceId,
|
|
330
|
+
requiredLabels: demand.requiredLabels,
|
|
331
|
+
scope: params.scope,
|
|
332
|
+
idleRunners,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const launchCount = grant - boundCount;
|
|
337
|
+
drawSlots(satisfyingTemplates, launchCount);
|
|
338
|
+
let launchReservation: {id: string; expiresAt: Date} | undefined;
|
|
339
|
+
if (launchCount > 0) {
|
|
340
|
+
const [inserted] = await tx
|
|
341
|
+
.insert(reservations)
|
|
342
|
+
.values({
|
|
343
|
+
workspaceId: params.workspaceId,
|
|
344
|
+
provisionerId: params.provisionerId,
|
|
345
|
+
requiredLabels: demand.requiredLabels,
|
|
346
|
+
count: launchCount,
|
|
347
|
+
kind: 'launch',
|
|
348
|
+
expiresAt: sql`now() + (${params.ttlSeconds} || ' seconds')::interval`,
|
|
349
|
+
})
|
|
350
|
+
.returning({id: reservations.id, expiresAt: reservations.expiresAt});
|
|
351
|
+
|
|
352
|
+
if (!inserted) throw new Error('Insert returned no rows');
|
|
353
|
+
launchReservation = inserted;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
newlyReservedUnits.push({labels: demand.requiredLabels, count: grant});
|
|
269
357
|
reservedAfterGrant += grant;
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
358
|
+
if (launchReservation) {
|
|
359
|
+
grants.push({
|
|
360
|
+
reservationId: launchReservation.id,
|
|
361
|
+
...workspaceIdField,
|
|
362
|
+
labels: demand.requiredLabels,
|
|
363
|
+
count: launchCount,
|
|
364
|
+
expiresAt: launchReservation.expiresAt,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
277
367
|
}
|
|
278
368
|
|
|
279
369
|
stats.push({
|
|
@@ -285,72 +375,293 @@ async function pollDemandAndReserveLockedTx(
|
|
|
285
375
|
});
|
|
286
376
|
}
|
|
287
377
|
|
|
288
|
-
return {stats, reservations: grants};
|
|
378
|
+
return {stats, reservations: grants, newlyReservedUnits};
|
|
289
379
|
}
|
|
290
380
|
|
|
291
|
-
async function
|
|
381
|
+
async function listActiveProvisionerReservationRowsTx(
|
|
292
382
|
tx: Tx,
|
|
293
|
-
params: {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
|
|
383
|
+
params: {workspaceId: string; provisionerId: string},
|
|
384
|
+
): Promise<Array<{requiredLabels: string[]; reserved: number}>> {
|
|
385
|
+
const candidateRows = await tx
|
|
386
|
+
.select({id: reservations.id})
|
|
387
|
+
.from(reservations)
|
|
388
|
+
.where(
|
|
389
|
+
and(
|
|
390
|
+
eq(reservations.workspaceId, params.workspaceId),
|
|
391
|
+
eq(reservations.provisionerId, params.provisionerId),
|
|
392
|
+
gt(reservations.expiresAt, sql`now()`),
|
|
393
|
+
),
|
|
394
|
+
);
|
|
395
|
+
const candidateIds = candidateRows.map((row) => row.id);
|
|
396
|
+
if (candidateIds.length === 0) return [];
|
|
397
|
+
|
|
398
|
+
// Assignment and provider-report transactions use this key before changing runner
|
|
399
|
+
// links. Lock it before counting so an in-flight assignment cannot expose its unit twice.
|
|
400
|
+
await lockRunnerReservationAdvisoryKeysTx(tx, {
|
|
401
|
+
provisionerId: params.provisionerId,
|
|
402
|
+
reservationIds: candidateIds,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const activeRows = await tx
|
|
406
|
+
.select({
|
|
407
|
+
id: reservations.id,
|
|
408
|
+
requiredLabels: reservations.requiredLabels,
|
|
409
|
+
count: reservations.count,
|
|
410
|
+
})
|
|
411
|
+
.from(reservations)
|
|
412
|
+
.where(
|
|
413
|
+
and(
|
|
414
|
+
eq(reservations.workspaceId, params.workspaceId),
|
|
415
|
+
eq(reservations.provisionerId, params.provisionerId),
|
|
416
|
+
gt(reservations.expiresAt, sql`now()`),
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
.orderBy(asc(reservations.id))
|
|
420
|
+
.for('update');
|
|
421
|
+
if (activeRows.length === 0) return [];
|
|
422
|
+
|
|
423
|
+
const activeIds = activeRows.map((row) => row.id);
|
|
424
|
+
const activeIdSet = new Set(activeIds);
|
|
425
|
+
const usedRunnerRows = await tx
|
|
426
|
+
.select({
|
|
427
|
+
id: providerRunners.id,
|
|
428
|
+
reservationId: providerRunners.reservationId,
|
|
429
|
+
intendedReservationId: providerRunners.intendedReservationId,
|
|
430
|
+
state: providerRunners.state,
|
|
431
|
+
})
|
|
303
432
|
.from(providerRunners)
|
|
304
433
|
.where(
|
|
305
434
|
and(
|
|
306
435
|
eq(providerRunners.provisionerId, params.provisionerId),
|
|
307
|
-
isNull(providerRunners.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
436
|
+
isNull(providerRunners.reservationReleasedAt),
|
|
437
|
+
or(
|
|
438
|
+
inArray(providerRunners.reservationId, activeIds),
|
|
439
|
+
and(
|
|
440
|
+
inArray(providerRunners.intendedReservationId, activeIds),
|
|
441
|
+
notInArray(providerRunners.state, [...terminalStates]),
|
|
442
|
+
),
|
|
443
|
+
),
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
const usedByReservation = new Map<string, Set<string>>();
|
|
447
|
+
for (const runner of usedRunnerRows) {
|
|
448
|
+
if (runner.reservationId && activeIdSet.has(runner.reservationId)) {
|
|
449
|
+
addUsedRunner(usedByReservation, runner.reservationId, runner.id);
|
|
450
|
+
}
|
|
451
|
+
if (
|
|
452
|
+
runner.intendedReservationId &&
|
|
453
|
+
activeIdSet.has(runner.intendedReservationId) &&
|
|
454
|
+
!terminalStates.includes(runner.state as (typeof terminalStates)[number])
|
|
455
|
+
) {
|
|
456
|
+
addUsedRunner(usedByReservation, runner.intendedReservationId, runner.id);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return activeRows.flatMap((reservation) => {
|
|
461
|
+
const used = usedByReservation.get(reservation.id)?.size ?? 0;
|
|
462
|
+
const pending = Math.max(0, reservation.count - used);
|
|
463
|
+
return pending > 0 ? [{requiredLabels: reservation.requiredLabels, reserved: pending}] : [];
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function addUsedRunner(
|
|
468
|
+
usedByReservation: Map<string, Set<string>>,
|
|
469
|
+
reservationId: string,
|
|
470
|
+
runnerId: string,
|
|
471
|
+
): void {
|
|
472
|
+
const runnerIds = usedByReservation.get(reservationId);
|
|
473
|
+
if (runnerIds) runnerIds.add(runnerId);
|
|
474
|
+
else usedByReservation.set(reservationId, new Set([runnerId]));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// An expired or missing reservation is stale. A live reservation protects a runner that is
|
|
478
|
+
// still booting or waiting for its activation grace period.
|
|
479
|
+
function canBindRunner(tx: Tx, params: BindableRunnerParams) {
|
|
480
|
+
return and(
|
|
481
|
+
or(
|
|
482
|
+
and(isNull(providerRunners.workspaceId), isNull(providerRunners.reservationId)),
|
|
483
|
+
and(
|
|
484
|
+
isNotNull(providerRunners.reservationId),
|
|
485
|
+
params.scope === 'installation'
|
|
486
|
+
? undefined
|
|
487
|
+
: or(
|
|
488
|
+
isNull(providerRunners.workspaceId),
|
|
489
|
+
eq(providerRunners.workspaceId, params.workspaceId),
|
|
490
|
+
),
|
|
491
|
+
notExists(
|
|
315
492
|
tx
|
|
316
|
-
.select({id:
|
|
317
|
-
.from(
|
|
493
|
+
.select({id: reservations.id})
|
|
494
|
+
.from(reservations)
|
|
318
495
|
.where(
|
|
319
496
|
and(
|
|
320
|
-
eq(
|
|
321
|
-
|
|
322
|
-
isNull(runnerControlSessions.closedAt),
|
|
323
|
-
gt(runnerControlSessions.expiresAt, sql`now()`),
|
|
497
|
+
eq(reservations.id, providerRunners.reservationId),
|
|
498
|
+
gt(reservations.expiresAt, sql`now()`),
|
|
324
499
|
),
|
|
325
500
|
),
|
|
326
501
|
),
|
|
327
502
|
),
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
503
|
+
),
|
|
504
|
+
or(
|
|
505
|
+
isNull(providerRunners.intendedReservationId),
|
|
506
|
+
notExists(
|
|
507
|
+
tx
|
|
508
|
+
.select({id: reservations.id})
|
|
509
|
+
.from(reservations)
|
|
510
|
+
.where(
|
|
511
|
+
and(
|
|
512
|
+
eq(reservations.id, providerRunners.intendedReservationId),
|
|
513
|
+
gt(reservations.expiresAt, sql`now()`),
|
|
514
|
+
),
|
|
515
|
+
),
|
|
516
|
+
),
|
|
517
|
+
),
|
|
518
|
+
isNull(providerRunners.reservationReleasedAt),
|
|
519
|
+
isNull(providerRunners.runnerSessionId),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function isBindableRunner(tx: Tx, params: BindableRunnerParams) {
|
|
524
|
+
return and(
|
|
525
|
+
eq(providerRunners.provisionerId, params.provisionerId),
|
|
526
|
+
canBindRunner(tx, params),
|
|
527
|
+
isNotNull(providerRunners.providerRunnerId),
|
|
528
|
+
eq(providerRunners.state, 'running'),
|
|
529
|
+
arrayContains(providerRunners.labels, params.requiredLabels),
|
|
530
|
+
exists(
|
|
531
|
+
tx
|
|
532
|
+
.select({id: runnerControlSessions.id})
|
|
533
|
+
.from(runnerControlSessions)
|
|
534
|
+
.where(
|
|
535
|
+
and(
|
|
536
|
+
eq(runnerControlSessions.runnerInstanceId, providerRunners.id),
|
|
537
|
+
eq(runnerControlSessions.provisionerId, params.provisionerId),
|
|
538
|
+
isNull(runnerControlSessions.closedAt),
|
|
539
|
+
gt(runnerControlSessions.expiresAt, sql`now()`),
|
|
540
|
+
),
|
|
541
|
+
),
|
|
542
|
+
),
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function listIdleRunnerInstancesTx(
|
|
547
|
+
tx: Tx,
|
|
548
|
+
params: IdleRunnerSelectionParams & {count: number},
|
|
549
|
+
): Promise<IdleRunnerCandidate[]> {
|
|
550
|
+
const lockedRunners = await selectIdleRunnerInstancesTx(tx, params);
|
|
551
|
+
if (lockedRunners.length === 0) return lockedRunners;
|
|
552
|
+
|
|
553
|
+
// A SELECT ... FOR UPDATE can wake with a fresh target row but the original
|
|
554
|
+
// statement snapshot for reservation subqueries. Re-check the full predicate
|
|
555
|
+
// in a new statement while retaining the row locks from the first query.
|
|
556
|
+
return await selectIdleRunnerInstancesTx(tx, {
|
|
557
|
+
...params,
|
|
558
|
+
runnerIds: lockedRunners.map((runner) => runner.id),
|
|
559
|
+
count: lockedRunners.length,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function selectIdleRunnerInstancesTx(
|
|
564
|
+
tx: Tx,
|
|
565
|
+
params: IdleRunnerSelectionParams & {count: number; runnerIds?: string[]},
|
|
566
|
+
): Promise<IdleRunnerCandidate[]> {
|
|
567
|
+
if (params.runnerIds) {
|
|
568
|
+
// The first pass retains the row locks. Recheck the full predicate in a fresh statement so
|
|
569
|
+
// reservation subqueries see the state that was current when the lock was acquired.
|
|
570
|
+
return await tx
|
|
571
|
+
.select({id: providerRunners.id, launchKind: providerRunners.launchKind})
|
|
572
|
+
.from(providerRunners)
|
|
573
|
+
.where(and(inArray(providerRunners.id, params.runnerIds), isBindableRunner(tx, params)))
|
|
574
|
+
.orderBy(asc(providerRunners.createdAt), asc(providerRunners.id))
|
|
575
|
+
.limit(params.count);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Select the oldest candidates first, but leave row locking to a second pass. The cleanup
|
|
579
|
+
// path locks runner rows by id, so the locking pass must use that same order. A skipped row
|
|
580
|
+
// rolls back only this nested savepoint and retries the bounded candidate scan, allowing the
|
|
581
|
+
// next-oldest eligible runner to refill the grant without retaining a partial lock set.
|
|
582
|
+
const retrySelection = Symbol('retry bindable runner selection');
|
|
583
|
+
const idleRunners = await (async () => {
|
|
584
|
+
while (true) {
|
|
585
|
+
try {
|
|
586
|
+
return await tx.transaction(async (lockTx) => {
|
|
587
|
+
const candidateRunners = await lockTx
|
|
588
|
+
.select({id: providerRunners.id, launchKind: providerRunners.launchKind})
|
|
589
|
+
.from(providerRunners)
|
|
590
|
+
.where(isBindableRunner(lockTx, params))
|
|
591
|
+
.orderBy(asc(providerRunners.createdAt), asc(providerRunners.id))
|
|
592
|
+
.limit(params.count);
|
|
593
|
+
|
|
594
|
+
if (candidateRunners.length === 0) return candidateRunners;
|
|
595
|
+
|
|
596
|
+
// PostgreSQL can acquire row locks before sorting a multi-row FOR UPDATE result. Lock
|
|
597
|
+
// each selected candidate individually so both polling and cleanup use id order.
|
|
598
|
+
const lockedRunners: typeof candidateRunners = [];
|
|
599
|
+
for (const candidate of [...candidateRunners].sort(compareRunnerIds)) {
|
|
600
|
+
const [runner] = await lockTx
|
|
601
|
+
.select({id: providerRunners.id, launchKind: providerRunners.launchKind})
|
|
602
|
+
.from(providerRunners)
|
|
603
|
+
.where(and(eq(providerRunners.id, candidate.id), isBindableRunner(lockTx, params)))
|
|
604
|
+
.for('update');
|
|
605
|
+
if (runner) lockedRunners.push(runner);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (lockedRunners.length !== candidateRunners.length) throw retrySelection;
|
|
609
|
+
return lockedRunners;
|
|
610
|
+
});
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if (error !== retrySelection) throw error;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
})();
|
|
332
616
|
|
|
333
|
-
|
|
617
|
+
return idleRunners;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function bindIdleRunnerInstancesTx(
|
|
621
|
+
tx: Tx,
|
|
622
|
+
params: {
|
|
623
|
+
provisionerId: string;
|
|
624
|
+
reservationId: string;
|
|
625
|
+
workspaceId: string;
|
|
626
|
+
requiredLabels: string[];
|
|
627
|
+
scope: DemandScope;
|
|
628
|
+
idleRunners: IdleRunnerCandidate[];
|
|
629
|
+
},
|
|
630
|
+
): Promise<void> {
|
|
631
|
+
const idleRunnerIds = params.idleRunners.map((runner) => runner.id);
|
|
334
632
|
|
|
335
633
|
await tx
|
|
634
|
+
.update(runnerActivationTokens)
|
|
635
|
+
.set({revokedAt: sql`now()`})
|
|
636
|
+
.where(
|
|
637
|
+
and(
|
|
638
|
+
inArray(runnerActivationTokens.runnerInstanceId, idleRunnerIds),
|
|
639
|
+
isNull(runnerActivationTokens.consumedAt),
|
|
640
|
+
isNull(runnerActivationTokens.revokedAt),
|
|
641
|
+
),
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
// listIdleRunnerInstancesTx re-checks the full eligibility predicate in a fresh statement
|
|
645
|
+
// after acquiring row locks. Those locks prevent the validated set from changing before this
|
|
646
|
+
// update runs.
|
|
647
|
+
const boundRunners = await tx
|
|
336
648
|
.update(providerRunners)
|
|
337
649
|
.set({
|
|
338
650
|
workspaceId: params.workspaceId,
|
|
339
651
|
reservationId: params.reservationId,
|
|
652
|
+
intendedReservationId: null,
|
|
340
653
|
assignedAt: sql`now()`,
|
|
341
654
|
updatedAt: sql`now()`,
|
|
342
655
|
})
|
|
343
|
-
.where(
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
),
|
|
353
|
-
);
|
|
656
|
+
.where(and(inArray(providerRunners.id, idleRunnerIds), isBindableRunner(tx, params)))
|
|
657
|
+
.returning({id: providerRunners.id, launchKind: providerRunners.launchKind});
|
|
658
|
+
|
|
659
|
+
if (boundRunners.length !== params.idleRunners.length)
|
|
660
|
+
throw new Error('Locked idle runner set changed before binding');
|
|
661
|
+
|
|
662
|
+
const reboundCount = boundRunners.filter((runner) => runner.launchKind === 'demand').length;
|
|
663
|
+
if (reboundCount > 0)
|
|
664
|
+
recordProviderRunnerActivationOutcome({outcome: 'rebound', count: reboundCount});
|
|
354
665
|
}
|
|
355
666
|
|
|
356
667
|
async function listInstallationDemandWorkspaceIds(eligibleWorkspaceIds: ReadonlySet<string>) {
|
|
@@ -433,7 +744,16 @@ async function deleteReservationsWithCleanupTx(
|
|
|
433
744
|
): Promise<number> {
|
|
434
745
|
if (ids.length === 0) return 0;
|
|
435
746
|
|
|
436
|
-
const
|
|
747
|
+
const isAffectedRunner = () =>
|
|
748
|
+
or(
|
|
749
|
+
and(
|
|
750
|
+
inArray(providerRunners.reservationId, ids),
|
|
751
|
+
isNull(providerRunners.runnerSessionId),
|
|
752
|
+
isNull(providerRunners.reservationReleasedAt),
|
|
753
|
+
),
|
|
754
|
+
inArray(providerRunners.intendedReservationId, ids),
|
|
755
|
+
);
|
|
756
|
+
const candidateRunners = await tx
|
|
437
757
|
.select({
|
|
438
758
|
id: providerRunners.id,
|
|
439
759
|
reservationId: providerRunners.reservationId,
|
|
@@ -442,18 +762,23 @@ async function deleteReservationsWithCleanupTx(
|
|
|
442
762
|
reservationReleasedAt: providerRunners.reservationReleasedAt,
|
|
443
763
|
})
|
|
444
764
|
.from(providerRunners)
|
|
445
|
-
.where(
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
765
|
+
.where(isAffectedRunner())
|
|
766
|
+
.orderBy(asc(providerRunners.id));
|
|
767
|
+
const affectedRunners: typeof candidateRunners = [];
|
|
768
|
+
for (const candidate of [...candidateRunners].sort(compareRunnerIds)) {
|
|
769
|
+
const [runner] = await tx
|
|
770
|
+
.select({
|
|
771
|
+
id: providerRunners.id,
|
|
772
|
+
reservationId: providerRunners.reservationId,
|
|
773
|
+
intendedReservationId: providerRunners.intendedReservationId,
|
|
774
|
+
runnerSessionId: providerRunners.runnerSessionId,
|
|
775
|
+
reservationReleasedAt: providerRunners.reservationReleasedAt,
|
|
776
|
+
})
|
|
777
|
+
.from(providerRunners)
|
|
778
|
+
.where(and(eq(providerRunners.id, candidate.id), isAffectedRunner()))
|
|
779
|
+
.for('update');
|
|
780
|
+
if (runner) affectedRunners.push(runner);
|
|
781
|
+
}
|
|
457
782
|
const reservationRows = await tx
|
|
458
783
|
.select({id: reservations.id})
|
|
459
784
|
.from(reservations)
|
|
@@ -595,6 +920,10 @@ function deductProvisionerReservations(
|
|
|
595
920
|
}
|
|
596
921
|
}
|
|
597
922
|
|
|
923
|
+
function compareRunnerIds(left: {id: string}, right: {id: string}): number {
|
|
924
|
+
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
925
|
+
}
|
|
926
|
+
|
|
598
927
|
function sortDemandRows(rows: DemandRow[]): DemandRow[] {
|
|
599
928
|
return [...rows].sort((a, b) => {
|
|
600
929
|
const specificity = b.requiredLabels.length - a.requiredLabels.length;
|