@shipfox/api-runners 12.3.0 → 12.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +36 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -2
- package/dist/config.js.map +1 -1
- package/dist/core/demand.d.ts +4 -2
- package/dist/core/demand.d.ts.map +1 -1
- package/dist/core/demand.js +22 -6
- package/dist/core/demand.js.map +1 -1
- package/dist/core/entities/reservation.d.ts +1 -0
- package/dist/core/entities/reservation.d.ts.map +1 -1
- package/dist/core/entities/reservation.js.map +1 -1
- package/dist/core/entities/runner-instance.d.ts +3 -0
- package/dist/core/entities/runner-instance.d.ts.map +1 -1
- package/dist/core/entities/runner-instance.js.map +1 -1
- package/dist/core/errors.d.ts +5 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +9 -2
- package/dist/core/errors.js.map +1 -1
- package/dist/core/runner-activation.d.ts +4 -1
- package/dist/core/runner-activation.d.ts.map +1 -1
- package/dist/core/runner-activation.js +32 -3
- package/dist/core/runner-activation.js.map +1 -1
- package/dist/core/runner-control-sessions.d.ts +8 -4
- package/dist/core/runner-control-sessions.d.ts.map +1 -1
- package/dist/core/runner-control-sessions.js +116 -20
- package/dist/core/runner-control-sessions.js.map +1 -1
- package/dist/core/runner-instances.d.ts +1 -0
- package/dist/core/runner-instances.d.ts.map +1 -1
- package/dist/core/runner-instances.js +1 -0
- package/dist/core/runner-instances.js.map +1 -1
- package/dist/db/db.d.ts +68 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
- package/dist/db/ephemeral-registration-tokens.js +1 -1
- package/dist/db/ephemeral-registration-tokens.js.map +1 -1
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +35 -4
- package/dist/db/job-executions.js.map +1 -1
- package/dist/db/reservation-locks.d.ts +6 -0
- package/dist/db/reservation-locks.d.ts.map +1 -0
- package/dist/db/reservation-locks.js +15 -0
- package/dist/db/reservation-locks.js.map +1 -0
- package/dist/db/reservations.d.ts +17 -3
- package/dist/db/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +246 -51
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/runner-assignments.d.ts +25 -1
- package/dist/db/runner-assignments.d.ts.map +1 -1
- package/dist/db/runner-assignments.js +160 -18
- package/dist/db/runner-assignments.js.map +1 -1
- package/dist/db/runner-instances.d.ts +11 -2
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +167 -24
- package/dist/db/runner-instances.js.map +1 -1
- package/dist/db/runner-sessions.d.ts.map +1 -1
- package/dist/db/runner-sessions.js +14 -1
- package/dist/db/runner-sessions.js.map +1 -1
- package/dist/db/runner-states.d.ts +2 -0
- package/dist/db/runner-states.d.ts.map +1 -0
- package/dist/db/runner-states.js +7 -0
- package/dist/db/runner-states.js.map +1 -0
- package/dist/db/schema/reservations.d.ts +18 -0
- package/dist/db/schema/reservations.d.ts.map +1 -1
- package/dist/db/schema/reservations.js +7 -1
- package/dist/db/schema/reservations.js.map +1 -1
- package/dist/db/schema/runner-instances.d.ts +18 -0
- package/dist/db/schema/runner-instances.d.ts.map +1 -1
- package/dist/db/schema/runner-instances.js +8 -0
- package/dist/db/schema/runner-instances.js.map +1 -1
- package/dist/metrics/index.d.ts +2 -2
- package/dist/metrics/index.d.ts.map +1 -1
- package/dist/metrics/index.js +1 -1
- package/dist/metrics/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +72 -2
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +161 -1
- package/dist/metrics/instance.js.map +1 -1
- package/dist/metrics/service.d.ts.map +1 -1
- package/dist/metrics/service.js +25 -4
- package/dist/metrics/service.js.map +1 -1
- package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
- package/dist/presentation/dto/poll-demand.js +3 -0
- package/dist/presentation/dto/poll-demand.js.map +1 -1
- package/dist/presentation/dto/runner-instances.d.ts +4 -1
- package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
- package/dist/presentation/dto/runner-instances.js +4 -1
- package/dist/presentation/dto/runner-instances.js.map +1 -1
- package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
- package/dist/presentation/routes/assign-runner-instances.js +10 -4
- package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
- package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
- package/dist/presentation/routes/poll-demand.js +1 -0
- package/dist/presentation/routes/poll-demand.js.map +1 -1
- package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
- package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
- package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
- package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
- package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
- package/dist/presentation/routes/runner-enrollment.js +11 -6
- package/dist/presentation/routes/runner-enrollment.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0005_previous_lenny_balinger.sql +5 -0
- package/drizzle/meta/0005_snapshot.json +2220 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -6
- package/src/config.test.ts +22 -0
- package/src/config.ts +15 -2
- package/src/core/demand.test.ts +192 -3
- package/src/core/demand.ts +44 -4
- package/src/core/entities/reservation.ts +1 -0
- package/src/core/entities/runner-instance.ts +4 -0
- package/src/core/errors.ts +30 -1
- package/src/core/runner-activation.ts +48 -2
- package/src/core/runner-control-sessions.test.ts +135 -3
- package/src/core/runner-control-sessions.ts +141 -19
- package/src/core/runner-instances.test.ts +19 -0
- package/src/core/runner-instances.ts +2 -0
- package/src/core/runner-sessions.test.ts +121 -1
- package/src/db/ephemeral-registration-tokens.test.ts +20 -1
- package/src/db/ephemeral-registration-tokens.ts +1 -0
- package/src/db/job-executions.test.ts +98 -12
- package/src/db/job-executions.ts +52 -4
- package/src/db/reservation-locks.ts +22 -0
- package/src/db/reservations.test.ts +733 -22
- package/src/db/reservations.ts +408 -144
- package/src/db/runner-assignments.test.ts +105 -1
- package/src/db/runner-assignments.ts +245 -25
- package/src/db/runner-instances.test.ts +357 -2
- package/src/db/runner-instances.ts +289 -55
- package/src/db/runner-sessions.ts +19 -1
- package/src/db/runner-states.ts +7 -0
- package/src/db/schema/reservations.ts +5 -1
- package/src/db/schema/runner-instances.ts +9 -0
- package/src/metrics/index.ts +24 -1
- package/src/metrics/instance.test.ts +118 -0
- package/src/metrics/instance.ts +247 -3
- package/src/metrics/service.test.ts +46 -1
- package/src/metrics/service.ts +55 -8
- package/src/presentation/dto/poll-demand.ts +3 -0
- package/src/presentation/dto/runner-instances.ts +8 -1
- package/src/presentation/routes/assign-runner-instances.ts +14 -2
- package/src/presentation/routes/late-runner-enrollment.test.ts +219 -2
- package/src/presentation/routes/poll-demand.test.ts +56 -0
- package/src/presentation/routes/poll-demand.ts +1 -0
- package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
- package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
- package/src/presentation/routes/runner-enrollment.test.ts +316 -3
- package/src/presentation/routes/runner-enrollment.ts +8 -5
- package/test/factories/reservation.ts +2 -0
- package/test/factories/runner-instance.ts +6 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/dist/db/reservations.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { canonicalizeLabels } from '@shipfox/runner-labels';
|
|
2
|
-
import { and, arrayContains, asc, eq, exists, gt, inArray, isNotNull, isNull, lt, notExists, or, sql } from 'drizzle-orm';
|
|
2
|
+
import { and, arrayContains, asc, eq, exists, gt, inArray, isNotNull, isNull, lt, notExists, notInArray, or, sql } from 'drizzle-orm';
|
|
3
|
+
import { recordProviderRunnerActivationOutcome } from '#metrics/instance.js';
|
|
3
4
|
import { db } from './db.js';
|
|
5
|
+
import { lockRunnerReservationAdvisoryKeysTx } from './reservation-locks.js';
|
|
6
|
+
import { terminalStates } from './runner-states.js';
|
|
4
7
|
import { pendingJobExecutions } from './schema/pending-job-executions.js';
|
|
5
8
|
import { provisionerCapabilitySnapshots } from './schema/provisioner-capability-snapshots.js';
|
|
6
9
|
import { provisionerTokens } from './schema/provisioner-tokens.js';
|
|
@@ -9,9 +12,13 @@ import { runnerActivationTokens } from './schema/runner-activation-tokens.js';
|
|
|
9
12
|
import { runnerControlSessions } from './schema/runner-control-sessions.js';
|
|
10
13
|
import { providerRunners } from './schema/runner-instances.js';
|
|
11
14
|
export async function pollDemandAndReserve(params) {
|
|
12
|
-
|
|
15
|
+
const result = await db().transaction(async (tx)=>{
|
|
13
16
|
return await pollDemandAndReserveTx(tx, params);
|
|
14
17
|
});
|
|
18
|
+
return {
|
|
19
|
+
stats: result.stats,
|
|
20
|
+
reservations: result.reservations
|
|
21
|
+
};
|
|
15
22
|
}
|
|
16
23
|
export async function pollDemandAndReserveTx(tx, params) {
|
|
17
24
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${params.workspaceId}))`);
|
|
@@ -38,13 +45,17 @@ export async function pollInstallationDemandAndReserve(params) {
|
|
|
38
45
|
const locked = lockResult.rows[0];
|
|
39
46
|
if (!locked?.locked) return {
|
|
40
47
|
stats: [],
|
|
41
|
-
reservations: []
|
|
48
|
+
reservations: [],
|
|
49
|
+
newlyReservedUnits: []
|
|
42
50
|
};
|
|
43
51
|
return await pollDemandAndReserveLockedTx(tx, {
|
|
44
52
|
workspaceId,
|
|
45
53
|
provisionerId: params.provisionerId,
|
|
46
54
|
maxReservations: remainingMaxReservations,
|
|
47
55
|
ttlSeconds: params.ttlSeconds,
|
|
56
|
+
...params.activationGraceSeconds !== undefined ? {
|
|
57
|
+
activationGraceSeconds: params.activationGraceSeconds
|
|
58
|
+
} : {},
|
|
48
59
|
templates: remainingTemplates.map((template)=>({
|
|
49
60
|
...template,
|
|
50
61
|
availableSlots: template.remainingSlots
|
|
@@ -56,15 +67,21 @@ export async function pollInstallationDemandAndReserve(params) {
|
|
|
56
67
|
results.push(result);
|
|
57
68
|
consumeInstallationTemplateSlots(remainingTemplates, result.reservations);
|
|
58
69
|
params.onReservations?.(result.reservations);
|
|
59
|
-
remainingMaxReservations -= result.
|
|
70
|
+
remainingMaxReservations -= result.newlyReservedUnits.reduce((total, reservation)=>total + reservation.count, 0);
|
|
60
71
|
}
|
|
72
|
+
const newlyReservedCount = results.reduce((total, result)=>total + result.newlyReservedUnits.reduce((subtotal, reservation)=>subtotal + reservation.count, 0), 0);
|
|
61
73
|
return {
|
|
62
74
|
stats: results.flatMap((result)=>result.stats),
|
|
63
|
-
reservations: results.flatMap((result)=>result.reservations)
|
|
75
|
+
reservations: results.flatMap((result)=>result.reservations),
|
|
76
|
+
...newlyReservedCount > 0 ? {
|
|
77
|
+
newlyReservedCount
|
|
78
|
+
} : {}
|
|
64
79
|
};
|
|
65
80
|
}
|
|
66
|
-
function consumeInstallationTemplateSlots(templates,
|
|
67
|
-
|
|
81
|
+
function consumeInstallationTemplateSlots(templates, launchGrants) {
|
|
82
|
+
// Adopted runners are already included in the running count behind availableSlots.
|
|
83
|
+
// Only units that still need a launch consume advertised template capacity.
|
|
84
|
+
for (const reservation of launchGrants){
|
|
68
85
|
const satisfyingTemplates = templates.filter((template)=>isSubset(reservation.labels, template.labels)).sort((a, b)=>a.labels.length - b.labels.length || a.templateKey.localeCompare(b.templateKey));
|
|
69
86
|
drawSlots(satisfyingTemplates, reservation.count);
|
|
70
87
|
}
|
|
@@ -93,10 +110,10 @@ async function pollDemandAndReserveLockedTx(tx, params) {
|
|
|
93
110
|
labelKey(row.requiredLabels),
|
|
94
111
|
row.reserved
|
|
95
112
|
]));
|
|
96
|
-
const activeProvisionerReservationRows = await tx
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
})
|
|
113
|
+
const activeProvisionerReservationRows = await listActiveProvisionerReservationRowsTx(tx, {
|
|
114
|
+
workspaceId: params.workspaceId,
|
|
115
|
+
provisionerId: params.provisionerId
|
|
116
|
+
});
|
|
100
117
|
const templates = params.templates.map((template)=>({
|
|
101
118
|
templateKey: template.templateKey,
|
|
102
119
|
labels: [
|
|
@@ -107,6 +124,7 @@ async function pollDemandAndReserveLockedTx(tx, params) {
|
|
|
107
124
|
deductProvisionerReservations(templates, activeProvisionerReservationRows);
|
|
108
125
|
const stats = [];
|
|
109
126
|
const grants = [];
|
|
127
|
+
const newlyReservedUnits = [];
|
|
110
128
|
let remainingMaxReservations = params.maxReservations;
|
|
111
129
|
const workspaceIdField = params.capabilityWindowSeconds === undefined ? {} : {
|
|
112
130
|
workspaceId: params.workspaceId
|
|
@@ -120,35 +138,68 @@ async function pollDemandAndReserveLockedTx(tx, params) {
|
|
|
120
138
|
const grant = Math.min(unreserved, capacity, remainingMaxReservations);
|
|
121
139
|
let reservedAfterGrant = reserved;
|
|
122
140
|
if (grant > 0 && params.maxReservations > 0) {
|
|
123
|
-
const
|
|
124
|
-
workspaceId: params.workspaceId,
|
|
125
|
-
provisionerId: params.provisionerId,
|
|
126
|
-
requiredLabels: demand.requiredLabels,
|
|
127
|
-
count: grant,
|
|
128
|
-
expiresAt: sql`now() + (${params.ttlSeconds} || ' seconds')::interval`
|
|
129
|
-
}).returning({
|
|
130
|
-
id: reservations.id,
|
|
131
|
-
expiresAt: reservations.expiresAt
|
|
132
|
-
});
|
|
133
|
-
if (!inserted) throw new Error('Insert returned no rows');
|
|
134
|
-
remainingMaxReservations -= grant;
|
|
135
|
-
drawSlots(satisfyingTemplates, grant);
|
|
136
|
-
await bindIdleRunnerInstancesTx(tx, {
|
|
141
|
+
const idleRunners = await listIdleRunnerInstancesTx(tx, {
|
|
137
142
|
provisionerId: params.provisionerId,
|
|
138
|
-
reservationId: inserted.id,
|
|
139
143
|
requiredLabels: demand.requiredLabels,
|
|
140
144
|
count: grant,
|
|
141
145
|
workspaceId: params.workspaceId,
|
|
142
146
|
scope: params.scope
|
|
143
147
|
});
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
+
remainingMaxReservations -= grant;
|
|
149
|
+
const boundCount = idleRunners.length;
|
|
150
|
+
if (boundCount > 0) {
|
|
151
|
+
const [boundReservation] = await tx.insert(reservations).values({
|
|
152
|
+
workspaceId: params.workspaceId,
|
|
153
|
+
provisionerId: params.provisionerId,
|
|
154
|
+
requiredLabels: demand.requiredLabels,
|
|
155
|
+
count: boundCount,
|
|
156
|
+
kind: 'bound',
|
|
157
|
+
expiresAt: sql`now() + (${params.activationGraceSeconds ?? params.ttlSeconds} || ' seconds')::interval`
|
|
158
|
+
}).returning({
|
|
159
|
+
id: reservations.id
|
|
160
|
+
});
|
|
161
|
+
if (!boundReservation) throw new Error('Insert returned no rows');
|
|
162
|
+
await bindIdleRunnerInstancesTx(tx, {
|
|
163
|
+
provisionerId: params.provisionerId,
|
|
164
|
+
reservationId: boundReservation.id,
|
|
165
|
+
workspaceId: params.workspaceId,
|
|
166
|
+
requiredLabels: demand.requiredLabels,
|
|
167
|
+
scope: params.scope,
|
|
168
|
+
idleRunners
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const launchCount = grant - boundCount;
|
|
172
|
+
drawSlots(satisfyingTemplates, launchCount);
|
|
173
|
+
let launchReservation;
|
|
174
|
+
if (launchCount > 0) {
|
|
175
|
+
const [inserted] = await tx.insert(reservations).values({
|
|
176
|
+
workspaceId: params.workspaceId,
|
|
177
|
+
provisionerId: params.provisionerId,
|
|
178
|
+
requiredLabels: demand.requiredLabels,
|
|
179
|
+
count: launchCount,
|
|
180
|
+
kind: 'launch',
|
|
181
|
+
expiresAt: sql`now() + (${params.ttlSeconds} || ' seconds')::interval`
|
|
182
|
+
}).returning({
|
|
183
|
+
id: reservations.id,
|
|
184
|
+
expiresAt: reservations.expiresAt
|
|
185
|
+
});
|
|
186
|
+
if (!inserted) throw new Error('Insert returned no rows');
|
|
187
|
+
launchReservation = inserted;
|
|
188
|
+
}
|
|
189
|
+
newlyReservedUnits.push({
|
|
148
190
|
labels: demand.requiredLabels,
|
|
149
|
-
count: grant
|
|
150
|
-
expiresAt: inserted.expiresAt
|
|
191
|
+
count: grant
|
|
151
192
|
});
|
|
193
|
+
reservedAfterGrant += grant;
|
|
194
|
+
if (launchReservation) {
|
|
195
|
+
grants.push({
|
|
196
|
+
reservationId: launchReservation.id,
|
|
197
|
+
...workspaceIdField,
|
|
198
|
+
labels: demand.requiredLabels,
|
|
199
|
+
count: launchCount,
|
|
200
|
+
expiresAt: launchReservation.expiresAt
|
|
201
|
+
});
|
|
202
|
+
}
|
|
152
203
|
}
|
|
153
204
|
stats.push({
|
|
154
205
|
...workspaceIdField,
|
|
@@ -160,33 +211,160 @@ async function pollDemandAndReserveLockedTx(tx, params) {
|
|
|
160
211
|
}
|
|
161
212
|
return {
|
|
162
213
|
stats,
|
|
163
|
-
reservations: grants
|
|
214
|
+
reservations: grants,
|
|
215
|
+
newlyReservedUnits
|
|
164
216
|
};
|
|
165
217
|
}
|
|
166
|
-
async function
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
218
|
+
async function listActiveProvisionerReservationRowsTx(tx, params) {
|
|
219
|
+
const candidateRows = await tx.select({
|
|
220
|
+
id: reservations.id
|
|
221
|
+
}).from(reservations).where(and(eq(reservations.workspaceId, params.workspaceId), eq(reservations.provisionerId, params.provisionerId), gt(reservations.expiresAt, sql`now()`)));
|
|
222
|
+
const candidateIds = candidateRows.map((row)=>row.id);
|
|
223
|
+
if (candidateIds.length === 0) return [];
|
|
224
|
+
// Assignment and provider-report transactions use this key before changing runner
|
|
225
|
+
// links. Lock it before counting so an in-flight assignment cannot expose its unit twice.
|
|
226
|
+
await lockRunnerReservationAdvisoryKeysTx(tx, {
|
|
227
|
+
provisionerId: params.provisionerId,
|
|
228
|
+
reservationIds: candidateIds
|
|
229
|
+
});
|
|
230
|
+
const activeRows = await tx.select({
|
|
231
|
+
id: reservations.id,
|
|
232
|
+
requiredLabels: reservations.requiredLabels,
|
|
233
|
+
count: reservations.count
|
|
234
|
+
}).from(reservations).where(and(eq(reservations.workspaceId, params.workspaceId), eq(reservations.provisionerId, params.provisionerId), gt(reservations.expiresAt, sql`now()`))).orderBy(asc(reservations.id)).for('update');
|
|
235
|
+
if (activeRows.length === 0) return [];
|
|
236
|
+
const activeIds = activeRows.map((row)=>row.id);
|
|
237
|
+
const activeIdSet = new Set(activeIds);
|
|
238
|
+
const usedRunnerRows = await tx.select({
|
|
239
|
+
id: providerRunners.id,
|
|
240
|
+
reservationId: providerRunners.reservationId,
|
|
241
|
+
intendedReservationId: providerRunners.intendedReservationId,
|
|
242
|
+
state: providerRunners.state
|
|
243
|
+
}).from(providerRunners).where(and(eq(providerRunners.provisionerId, params.provisionerId), isNull(providerRunners.reservationReleasedAt), or(inArray(providerRunners.reservationId, activeIds), and(inArray(providerRunners.intendedReservationId, activeIds), notInArray(providerRunners.state, [
|
|
244
|
+
...terminalStates
|
|
245
|
+
])))));
|
|
246
|
+
const usedByReservation = new Map();
|
|
247
|
+
for (const runner of usedRunnerRows){
|
|
248
|
+
if (runner.reservationId && activeIdSet.has(runner.reservationId)) {
|
|
249
|
+
addUsedRunner(usedByReservation, runner.reservationId, runner.id);
|
|
250
|
+
}
|
|
251
|
+
if (runner.intendedReservationId && activeIdSet.has(runner.intendedReservationId) && !terminalStates.includes(runner.state)) {
|
|
252
|
+
addUsedRunner(usedByReservation, runner.intendedReservationId, runner.id);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return activeRows.flatMap((reservation)=>{
|
|
256
|
+
const used = usedByReservation.get(reservation.id)?.size ?? 0;
|
|
257
|
+
const pending = Math.max(0, reservation.count - used);
|
|
258
|
+
return pending > 0 ? [
|
|
259
|
+
{
|
|
260
|
+
requiredLabels: reservation.requiredLabels,
|
|
261
|
+
reserved: pending
|
|
262
|
+
}
|
|
263
|
+
] : [];
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function addUsedRunner(usedByReservation, reservationId, runnerId) {
|
|
267
|
+
const runnerIds = usedByReservation.get(reservationId);
|
|
268
|
+
if (runnerIds) runnerIds.add(runnerId);
|
|
269
|
+
else usedByReservation.set(reservationId, new Set([
|
|
270
|
+
runnerId
|
|
271
|
+
]));
|
|
272
|
+
}
|
|
273
|
+
// An expired or missing reservation is stale. A live reservation protects a runner that is
|
|
274
|
+
// still booting or waiting for its activation grace period.
|
|
275
|
+
function canBindRunner(tx, params) {
|
|
276
|
+
return and(or(and(isNull(providerRunners.workspaceId), isNull(providerRunners.reservationId)), and(isNotNull(providerRunners.reservationId), params.scope === 'installation' ? undefined : or(isNull(providerRunners.workspaceId), eq(providerRunners.workspaceId, params.workspaceId)), notExists(tx.select({
|
|
277
|
+
id: reservations.id
|
|
278
|
+
}).from(reservations).where(and(eq(reservations.id, providerRunners.reservationId), gt(reservations.expiresAt, sql`now()`)))))), or(isNull(providerRunners.intendedReservationId), notExists(tx.select({
|
|
279
|
+
id: reservations.id
|
|
280
|
+
}).from(reservations).where(and(eq(reservations.id, providerRunners.intendedReservationId), gt(reservations.expiresAt, sql`now()`))))), isNull(providerRunners.reservationReleasedAt), isNull(providerRunners.runnerSessionId));
|
|
281
|
+
}
|
|
282
|
+
function isBindableRunner(tx, params) {
|
|
283
|
+
return and(eq(providerRunners.provisionerId, params.provisionerId), canBindRunner(tx, params), isNotNull(providerRunners.providerRunnerId), eq(providerRunners.state, 'running'), arrayContains(providerRunners.labels, params.requiredLabels), exists(tx.select({
|
|
177
284
|
id: runnerControlSessions.id
|
|
178
|
-
}).from(runnerControlSessions).where(and(eq(runnerControlSessions.runnerInstanceId, providerRunners.id), eq(runnerControlSessions.provisionerId, params.provisionerId), isNull(runnerControlSessions.closedAt), gt(runnerControlSessions.expiresAt, sql`now()`)))))
|
|
179
|
-
|
|
285
|
+
}).from(runnerControlSessions).where(and(eq(runnerControlSessions.runnerInstanceId, providerRunners.id), eq(runnerControlSessions.provisionerId, params.provisionerId), isNull(runnerControlSessions.closedAt), gt(runnerControlSessions.expiresAt, sql`now()`)))));
|
|
286
|
+
}
|
|
287
|
+
async function listIdleRunnerInstancesTx(tx, params) {
|
|
288
|
+
const lockedRunners = await selectIdleRunnerInstancesTx(tx, params);
|
|
289
|
+
if (lockedRunners.length === 0) return lockedRunners;
|
|
290
|
+
// A SELECT ... FOR UPDATE can wake with a fresh target row but the original
|
|
291
|
+
// statement snapshot for reservation subqueries. Re-check the full predicate
|
|
292
|
+
// in a new statement while retaining the row locks from the first query.
|
|
293
|
+
return await selectIdleRunnerInstancesTx(tx, {
|
|
294
|
+
...params,
|
|
295
|
+
runnerIds: lockedRunners.map((runner)=>runner.id),
|
|
296
|
+
count: lockedRunners.length
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
async function selectIdleRunnerInstancesTx(tx, params) {
|
|
300
|
+
if (params.runnerIds) {
|
|
301
|
+
// The first pass retains the row locks. Recheck the full predicate in a fresh statement so
|
|
302
|
+
// reservation subqueries see the state that was current when the lock was acquired.
|
|
303
|
+
return await tx.select({
|
|
304
|
+
id: providerRunners.id,
|
|
305
|
+
launchKind: providerRunners.launchKind
|
|
306
|
+
}).from(providerRunners).where(and(inArray(providerRunners.id, params.runnerIds), isBindableRunner(tx, params))).orderBy(asc(providerRunners.createdAt), asc(providerRunners.id)).limit(params.count);
|
|
307
|
+
}
|
|
308
|
+
// Select the oldest candidates first, but leave row locking to a second pass. The cleanup
|
|
309
|
+
// path locks runner rows by id, so the locking pass must use that same order. A skipped row
|
|
310
|
+
// rolls back only this nested savepoint and retries the bounded candidate scan, allowing the
|
|
311
|
+
// next-oldest eligible runner to refill the grant without retaining a partial lock set.
|
|
312
|
+
const retrySelection = Symbol('retry bindable runner selection');
|
|
313
|
+
const idleRunners = await (async ()=>{
|
|
314
|
+
while(true){
|
|
315
|
+
try {
|
|
316
|
+
return await tx.transaction(async (lockTx)=>{
|
|
317
|
+
const candidateRunners = await lockTx.select({
|
|
318
|
+
id: providerRunners.id,
|
|
319
|
+
launchKind: providerRunners.launchKind
|
|
320
|
+
}).from(providerRunners).where(isBindableRunner(lockTx, params)).orderBy(asc(providerRunners.createdAt), asc(providerRunners.id)).limit(params.count);
|
|
321
|
+
if (candidateRunners.length === 0) return candidateRunners;
|
|
322
|
+
// PostgreSQL can acquire row locks before sorting a multi-row FOR UPDATE result. Lock
|
|
323
|
+
// each selected candidate individually so both polling and cleanup use id order.
|
|
324
|
+
const lockedRunners = [];
|
|
325
|
+
for (const candidate of [
|
|
326
|
+
...candidateRunners
|
|
327
|
+
].sort(compareRunnerIds)){
|
|
328
|
+
const [runner] = await lockTx.select({
|
|
329
|
+
id: providerRunners.id,
|
|
330
|
+
launchKind: providerRunners.launchKind
|
|
331
|
+
}).from(providerRunners).where(and(eq(providerRunners.id, candidate.id), isBindableRunner(lockTx, params))).for('update');
|
|
332
|
+
if (runner) lockedRunners.push(runner);
|
|
333
|
+
}
|
|
334
|
+
if (lockedRunners.length !== candidateRunners.length) throw retrySelection;
|
|
335
|
+
return lockedRunners;
|
|
336
|
+
});
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (error !== retrySelection) throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
})();
|
|
342
|
+
return idleRunners;
|
|
343
|
+
}
|
|
344
|
+
async function bindIdleRunnerInstancesTx(tx, params) {
|
|
345
|
+
const idleRunnerIds = params.idleRunners.map((runner)=>runner.id);
|
|
180
346
|
await tx.update(runnerActivationTokens).set({
|
|
181
347
|
revokedAt: sql`now()`
|
|
182
|
-
}).where(and(inArray(runnerActivationTokens.runnerInstanceId,
|
|
183
|
-
|
|
348
|
+
}).where(and(inArray(runnerActivationTokens.runnerInstanceId, idleRunnerIds), isNull(runnerActivationTokens.consumedAt), isNull(runnerActivationTokens.revokedAt)));
|
|
349
|
+
// listIdleRunnerInstancesTx re-checks the full eligibility predicate in a fresh statement
|
|
350
|
+
// after acquiring row locks. Those locks prevent the validated set from changing before this
|
|
351
|
+
// update runs.
|
|
352
|
+
const boundRunners = await tx.update(providerRunners).set({
|
|
184
353
|
workspaceId: params.workspaceId,
|
|
185
354
|
reservationId: params.reservationId,
|
|
186
355
|
intendedReservationId: null,
|
|
187
356
|
assignedAt: sql`now()`,
|
|
188
357
|
updatedAt: sql`now()`
|
|
189
|
-
}).where(and(inArray(providerRunners.id,
|
|
358
|
+
}).where(and(inArray(providerRunners.id, idleRunnerIds), isBindableRunner(tx, params))).returning({
|
|
359
|
+
id: providerRunners.id,
|
|
360
|
+
launchKind: providerRunners.launchKind
|
|
361
|
+
});
|
|
362
|
+
if (boundRunners.length !== params.idleRunners.length) throw new Error('Locked idle runner set changed before binding');
|
|
363
|
+
const reboundCount = boundRunners.filter((runner)=>runner.launchKind === 'demand').length;
|
|
364
|
+
if (reboundCount > 0) recordProviderRunnerActivationOutcome({
|
|
365
|
+
outcome: 'rebound',
|
|
366
|
+
count: reboundCount
|
|
367
|
+
});
|
|
190
368
|
}
|
|
191
369
|
async function listInstallationDemandWorkspaceIds(eligibleWorkspaceIds) {
|
|
192
370
|
if (eligibleWorkspaceIds.size === 0) return [];
|
|
@@ -228,13 +406,27 @@ export async function deleteReservationsByIds(ids) {
|
|
|
228
406
|
}
|
|
229
407
|
async function deleteReservationsWithCleanupTx(tx, ids, params = {}) {
|
|
230
408
|
if (ids.length === 0) return 0;
|
|
231
|
-
const
|
|
409
|
+
const isAffectedRunner = ()=>or(and(inArray(providerRunners.reservationId, ids), isNull(providerRunners.runnerSessionId), isNull(providerRunners.reservationReleasedAt)), inArray(providerRunners.intendedReservationId, ids));
|
|
410
|
+
const candidateRunners = await tx.select({
|
|
232
411
|
id: providerRunners.id,
|
|
233
412
|
reservationId: providerRunners.reservationId,
|
|
234
413
|
intendedReservationId: providerRunners.intendedReservationId,
|
|
235
414
|
runnerSessionId: providerRunners.runnerSessionId,
|
|
236
415
|
reservationReleasedAt: providerRunners.reservationReleasedAt
|
|
237
|
-
}).from(providerRunners).where(
|
|
416
|
+
}).from(providerRunners).where(isAffectedRunner()).orderBy(asc(providerRunners.id));
|
|
417
|
+
const affectedRunners = [];
|
|
418
|
+
for (const candidate of [
|
|
419
|
+
...candidateRunners
|
|
420
|
+
].sort(compareRunnerIds)){
|
|
421
|
+
const [runner] = await tx.select({
|
|
422
|
+
id: providerRunners.id,
|
|
423
|
+
reservationId: providerRunners.reservationId,
|
|
424
|
+
intendedReservationId: providerRunners.intendedReservationId,
|
|
425
|
+
runnerSessionId: providerRunners.runnerSessionId,
|
|
426
|
+
reservationReleasedAt: providerRunners.reservationReleasedAt
|
|
427
|
+
}).from(providerRunners).where(and(eq(providerRunners.id, candidate.id), isAffectedRunner())).for('update');
|
|
428
|
+
if (runner) affectedRunners.push(runner);
|
|
429
|
+
}
|
|
238
430
|
const reservationRows = await tx.select({
|
|
239
431
|
id: reservations.id
|
|
240
432
|
}).from(reservations).where(and(inArray(reservations.id, ids), params.expiredOnly ? lt(reservations.expiresAt, sql`now()`) : undefined)).for('update');
|
|
@@ -290,6 +482,9 @@ function deductProvisionerReservations(templates, activeReservations) {
|
|
|
290
482
|
drawSlots(satisfyingTemplates, reservation.reserved);
|
|
291
483
|
}
|
|
292
484
|
}
|
|
485
|
+
function compareRunnerIds(left, right) {
|
|
486
|
+
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
487
|
+
}
|
|
293
488
|
function sortDemandRows(rows) {
|
|
294
489
|
return [
|
|
295
490
|
...rows
|