@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.
Files changed (149) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +34 -0
  3. package/dist/config.d.ts +1 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +10 -3
  6. package/dist/config.js.map +1 -1
  7. package/dist/core/demand.d.ts +4 -2
  8. package/dist/core/demand.d.ts.map +1 -1
  9. package/dist/core/demand.js +22 -6
  10. package/dist/core/demand.js.map +1 -1
  11. package/dist/core/entities/reservation.d.ts +1 -0
  12. package/dist/core/entities/reservation.d.ts.map +1 -1
  13. package/dist/core/entities/reservation.js.map +1 -1
  14. package/dist/core/entities/runner-instance.d.ts +3 -0
  15. package/dist/core/entities/runner-instance.d.ts.map +1 -1
  16. package/dist/core/entities/runner-instance.js.map +1 -1
  17. package/dist/core/runner-activation.d.ts +4 -1
  18. package/dist/core/runner-activation.d.ts.map +1 -1
  19. package/dist/core/runner-activation.js +32 -3
  20. package/dist/core/runner-activation.js.map +1 -1
  21. package/dist/core/runner-control-sessions.d.ts +8 -4
  22. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  23. package/dist/core/runner-control-sessions.js +69 -11
  24. package/dist/core/runner-control-sessions.js.map +1 -1
  25. package/dist/core/runner-instances.d.ts +1 -0
  26. package/dist/core/runner-instances.d.ts.map +1 -1
  27. package/dist/core/runner-instances.js +1 -0
  28. package/dist/core/runner-instances.js.map +1 -1
  29. package/dist/db/db.d.ts +68 -0
  30. package/dist/db/db.d.ts.map +1 -1
  31. package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
  32. package/dist/db/ephemeral-registration-tokens.js +1 -1
  33. package/dist/db/ephemeral-registration-tokens.js.map +1 -1
  34. package/dist/db/index.d.ts +1 -1
  35. package/dist/db/index.d.ts.map +1 -1
  36. package/dist/db/index.js +1 -1
  37. package/dist/db/index.js.map +1 -1
  38. package/dist/db/reservation-locks.d.ts +6 -0
  39. package/dist/db/reservation-locks.d.ts.map +1 -0
  40. package/dist/db/reservation-locks.js +15 -0
  41. package/dist/db/reservation-locks.js.map +1 -0
  42. package/dist/db/reservations.d.ts +17 -3
  43. package/dist/db/reservations.d.ts.map +1 -1
  44. package/dist/db/reservations.js +256 -45
  45. package/dist/db/reservations.js.map +1 -1
  46. package/dist/db/runner-assignments.d.ts +17 -0
  47. package/dist/db/runner-assignments.d.ts.map +1 -1
  48. package/dist/db/runner-assignments.js +103 -8
  49. package/dist/db/runner-assignments.js.map +1 -1
  50. package/dist/db/runner-instances.d.ts +5 -2
  51. package/dist/db/runner-instances.d.ts.map +1 -1
  52. package/dist/db/runner-instances.js +134 -25
  53. package/dist/db/runner-instances.js.map +1 -1
  54. package/dist/db/runner-sessions.d.ts.map +1 -1
  55. package/dist/db/runner-sessions.js +22 -15
  56. package/dist/db/runner-sessions.js.map +1 -1
  57. package/dist/db/runner-states.d.ts +2 -0
  58. package/dist/db/runner-states.d.ts.map +1 -0
  59. package/dist/db/runner-states.js +7 -0
  60. package/dist/db/runner-states.js.map +1 -0
  61. package/dist/db/schema/reservations.d.ts +18 -0
  62. package/dist/db/schema/reservations.d.ts.map +1 -1
  63. package/dist/db/schema/reservations.js +7 -1
  64. package/dist/db/schema/reservations.js.map +1 -1
  65. package/dist/db/schema/runner-instances.d.ts +18 -0
  66. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  67. package/dist/db/schema/runner-instances.js +8 -0
  68. package/dist/db/schema/runner-instances.js.map +1 -1
  69. package/dist/metrics/index.d.ts +2 -2
  70. package/dist/metrics/index.d.ts.map +1 -1
  71. package/dist/metrics/index.js +1 -1
  72. package/dist/metrics/index.js.map +1 -1
  73. package/dist/metrics/instance.d.ts +24 -2
  74. package/dist/metrics/instance.d.ts.map +1 -1
  75. package/dist/metrics/instance.js +23 -0
  76. package/dist/metrics/instance.js.map +1 -1
  77. package/dist/metrics/service.d.ts.map +1 -1
  78. package/dist/metrics/service.js +20 -4
  79. package/dist/metrics/service.js.map +1 -1
  80. package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
  81. package/dist/presentation/dto/poll-demand.js +3 -0
  82. package/dist/presentation/dto/poll-demand.js.map +1 -1
  83. package/dist/presentation/dto/runner-instances.d.ts +4 -1
  84. package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
  85. package/dist/presentation/dto/runner-instances.js +4 -1
  86. package/dist/presentation/dto/runner-instances.js.map +1 -1
  87. package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
  88. package/dist/presentation/routes/assign-runner-instances.js +3 -3
  89. package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
  90. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  91. package/dist/presentation/routes/poll-demand.js +1 -0
  92. package/dist/presentation/routes/poll-demand.js.map +1 -1
  93. package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
  94. package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
  95. package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
  96. package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
  97. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  98. package/dist/presentation/routes/runner-enrollment.js +11 -6
  99. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  100. package/dist/tsconfig.test.tsbuildinfo +1 -1
  101. package/drizzle/0005_previous_lenny_balinger.sql +5 -0
  102. package/drizzle/meta/0005_snapshot.json +2220 -0
  103. package/drizzle/meta/_journal.json +7 -0
  104. package/package.json +4 -4
  105. package/src/config.test.ts +24 -2
  106. package/src/config.ts +16 -3
  107. package/src/core/demand.test.ts +192 -3
  108. package/src/core/demand.ts +44 -4
  109. package/src/core/entities/reservation.ts +1 -0
  110. package/src/core/entities/runner-instance.ts +4 -0
  111. package/src/core/runner-activation.ts +48 -2
  112. package/src/core/runner-control-sessions.test.ts +230 -4
  113. package/src/core/runner-control-sessions.ts +84 -10
  114. package/src/core/runner-instances.test.ts +19 -0
  115. package/src/core/runner-instances.ts +2 -0
  116. package/src/core/runner-sessions.test.ts +121 -1
  117. package/src/db/ephemeral-registration-tokens.test.ts +20 -1
  118. package/src/db/ephemeral-registration-tokens.ts +1 -0
  119. package/src/db/index.ts +1 -0
  120. package/src/db/reservation-locks.ts +22 -0
  121. package/src/db/reservations.test.ts +1110 -31
  122. package/src/db/reservations.ts +434 -105
  123. package/src/db/runner-assignments.test.ts +105 -1
  124. package/src/db/runner-assignments.ts +178 -9
  125. package/src/db/runner-instances.test.ts +496 -2
  126. package/src/db/runner-instances.ts +247 -56
  127. package/src/db/runner-sessions.ts +26 -15
  128. package/src/db/runner-states.ts +7 -0
  129. package/src/db/schema/reservations.ts +5 -1
  130. package/src/db/schema/runner-instances.ts +9 -0
  131. package/src/metrics/index.ts +10 -1
  132. package/src/metrics/instance.ts +59 -2
  133. package/src/metrics/service.test.ts +106 -0
  134. package/src/metrics/service.ts +24 -4
  135. package/src/presentation/dto/poll-demand.ts +3 -0
  136. package/src/presentation/dto/runner-instances.ts +8 -1
  137. package/src/presentation/routes/assign-runner-instances.ts +6 -2
  138. package/src/presentation/routes/late-runner-enrollment.test.ts +447 -0
  139. package/src/presentation/routes/poll-demand.test.ts +79 -6
  140. package/src/presentation/routes/poll-demand.ts +1 -0
  141. package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
  142. package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
  143. package/src/presentation/routes/runner-enrollment.test.ts +316 -3
  144. package/src/presentation/routes/runner-enrollment.ts +8 -5
  145. package/test/factories/reservation.ts +2 -0
  146. package/test/factories/runner-instance.ts +6 -0
  147. package/test/fixtures/late-runner-enrollment.ts +103 -0
  148. package/test/index.ts +5 -0
  149. package/tsconfig.build.tsbuildinfo +1 -1
@@ -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
- return await db().transaction(async (tx) => {
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<{stats: DemandStat[]; reservations: ReservationGrant[]}> {
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<{stats: DemandStat[]; reservations: ReservationGrant[]}> {
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: Array<{stats: DemandStat[]; reservations: ReservationGrant[]}> = [];
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.reservations.reduce(
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
- reservations: ReservationGrant[],
207
+ launchGrants: ReservationGrant[],
148
208
  ): void {
149
- for (const reservation of reservations) {
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: PollDemandAndReserveParams,
162
- ): Promise<{stats: DemandStat[]; reservations: ReservationGrant[]}> {
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
- .select({
203
- requiredLabels: reservations.requiredLabels,
204
- reserved: sql<number>`coalesce(sum(${reservations.count}), 0)::int`,
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 [inserted] = await tx
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
- grants.push({
271
- reservationId: inserted.id,
272
- ...workspaceIdField,
273
- labels: demand.requiredLabels,
274
- count: grant,
275
- expiresAt: inserted.expiresAt,
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 bindIdleRunnerInstancesTx(
381
+ async function listActiveProvisionerReservationRowsTx(
292
382
  tx: Tx,
293
- params: {
294
- provisionerId: string;
295
- reservationId: string;
296
- workspaceId: string;
297
- requiredLabels: string[];
298
- count: number;
299
- },
300
- ): Promise<void> {
301
- const idleRunners = await tx
302
- .select({id: providerRunners.id})
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.workspaceId),
308
- isNull(providerRunners.reservationId),
309
- isNull(providerRunners.intendedReservationId),
310
- isNull(providerRunners.runnerSessionId),
311
- isNotNull(providerRunners.providerRunnerId),
312
- eq(providerRunners.state, 'running'),
313
- arrayContains(providerRunners.labels, params.requiredLabels),
314
- exists(
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: runnerControlSessions.id})
317
- .from(runnerControlSessions)
493
+ .select({id: reservations.id})
494
+ .from(reservations)
318
495
  .where(
319
496
  and(
320
- eq(runnerControlSessions.runnerInstanceId, providerRunners.id),
321
- eq(runnerControlSessions.provisionerId, params.provisionerId),
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
- .orderBy(asc(providerRunners.createdAt), asc(providerRunners.id))
330
- .limit(params.count)
331
- .for('update');
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
- if (idleRunners.length === 0) return;
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
- and(
345
- inArray(
346
- providerRunners.id,
347
- idleRunners.map((runner) => runner.id),
348
- ),
349
- isNull(providerRunners.workspaceId),
350
- isNull(providerRunners.reservationId),
351
- isNull(providerRunners.runnerSessionId),
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 affectedRunners = await tx
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
- or(
447
- and(
448
- inArray(providerRunners.reservationId, ids),
449
- isNull(providerRunners.runnerSessionId),
450
- isNull(providerRunners.reservationReleasedAt),
451
- ),
452
- inArray(providerRunners.intendedReservationId, ids),
453
- ),
454
- )
455
- .orderBy(asc(providerRunners.id))
456
- .for('update');
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;