@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.
Files changed (153) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +36 -0
  3. package/dist/config.d.ts +1 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +9 -2
  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/errors.d.ts +5 -1
  18. package/dist/core/errors.d.ts.map +1 -1
  19. package/dist/core/errors.js +9 -2
  20. package/dist/core/errors.js.map +1 -1
  21. package/dist/core/runner-activation.d.ts +4 -1
  22. package/dist/core/runner-activation.d.ts.map +1 -1
  23. package/dist/core/runner-activation.js +32 -3
  24. package/dist/core/runner-activation.js.map +1 -1
  25. package/dist/core/runner-control-sessions.d.ts +8 -4
  26. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  27. package/dist/core/runner-control-sessions.js +116 -20
  28. package/dist/core/runner-control-sessions.js.map +1 -1
  29. package/dist/core/runner-instances.d.ts +1 -0
  30. package/dist/core/runner-instances.d.ts.map +1 -1
  31. package/dist/core/runner-instances.js +1 -0
  32. package/dist/core/runner-instances.js.map +1 -1
  33. package/dist/db/db.d.ts +68 -0
  34. package/dist/db/db.d.ts.map +1 -1
  35. package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
  36. package/dist/db/ephemeral-registration-tokens.js +1 -1
  37. package/dist/db/ephemeral-registration-tokens.js.map +1 -1
  38. package/dist/db/job-executions.d.ts.map +1 -1
  39. package/dist/db/job-executions.js +35 -4
  40. package/dist/db/job-executions.js.map +1 -1
  41. package/dist/db/reservation-locks.d.ts +6 -0
  42. package/dist/db/reservation-locks.d.ts.map +1 -0
  43. package/dist/db/reservation-locks.js +15 -0
  44. package/dist/db/reservation-locks.js.map +1 -0
  45. package/dist/db/reservations.d.ts +17 -3
  46. package/dist/db/reservations.d.ts.map +1 -1
  47. package/dist/db/reservations.js +246 -51
  48. package/dist/db/reservations.js.map +1 -1
  49. package/dist/db/runner-assignments.d.ts +25 -1
  50. package/dist/db/runner-assignments.d.ts.map +1 -1
  51. package/dist/db/runner-assignments.js +160 -18
  52. package/dist/db/runner-assignments.js.map +1 -1
  53. package/dist/db/runner-instances.d.ts +11 -2
  54. package/dist/db/runner-instances.d.ts.map +1 -1
  55. package/dist/db/runner-instances.js +167 -24
  56. package/dist/db/runner-instances.js.map +1 -1
  57. package/dist/db/runner-sessions.d.ts.map +1 -1
  58. package/dist/db/runner-sessions.js +14 -1
  59. package/dist/db/runner-sessions.js.map +1 -1
  60. package/dist/db/runner-states.d.ts +2 -0
  61. package/dist/db/runner-states.d.ts.map +1 -0
  62. package/dist/db/runner-states.js +7 -0
  63. package/dist/db/runner-states.js.map +1 -0
  64. package/dist/db/schema/reservations.d.ts +18 -0
  65. package/dist/db/schema/reservations.d.ts.map +1 -1
  66. package/dist/db/schema/reservations.js +7 -1
  67. package/dist/db/schema/reservations.js.map +1 -1
  68. package/dist/db/schema/runner-instances.d.ts +18 -0
  69. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  70. package/dist/db/schema/runner-instances.js +8 -0
  71. package/dist/db/schema/runner-instances.js.map +1 -1
  72. package/dist/metrics/index.d.ts +2 -2
  73. package/dist/metrics/index.d.ts.map +1 -1
  74. package/dist/metrics/index.js +1 -1
  75. package/dist/metrics/index.js.map +1 -1
  76. package/dist/metrics/instance.d.ts +72 -2
  77. package/dist/metrics/instance.d.ts.map +1 -1
  78. package/dist/metrics/instance.js +161 -1
  79. package/dist/metrics/instance.js.map +1 -1
  80. package/dist/metrics/service.d.ts.map +1 -1
  81. package/dist/metrics/service.js +25 -4
  82. package/dist/metrics/service.js.map +1 -1
  83. package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
  84. package/dist/presentation/dto/poll-demand.js +3 -0
  85. package/dist/presentation/dto/poll-demand.js.map +1 -1
  86. package/dist/presentation/dto/runner-instances.d.ts +4 -1
  87. package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
  88. package/dist/presentation/dto/runner-instances.js +4 -1
  89. package/dist/presentation/dto/runner-instances.js.map +1 -1
  90. package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
  91. package/dist/presentation/routes/assign-runner-instances.js +10 -4
  92. package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
  93. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  94. package/dist/presentation/routes/poll-demand.js +1 -0
  95. package/dist/presentation/routes/poll-demand.js.map +1 -1
  96. package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
  97. package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
  98. package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
  99. package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
  100. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  101. package/dist/presentation/routes/runner-enrollment.js +11 -6
  102. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  103. package/dist/tsconfig.test.tsbuildinfo +1 -1
  104. package/drizzle/0005_previous_lenny_balinger.sql +5 -0
  105. package/drizzle/meta/0005_snapshot.json +2220 -0
  106. package/drizzle/meta/_journal.json +7 -0
  107. package/package.json +6 -6
  108. package/src/config.test.ts +22 -0
  109. package/src/config.ts +15 -2
  110. package/src/core/demand.test.ts +192 -3
  111. package/src/core/demand.ts +44 -4
  112. package/src/core/entities/reservation.ts +1 -0
  113. package/src/core/entities/runner-instance.ts +4 -0
  114. package/src/core/errors.ts +30 -1
  115. package/src/core/runner-activation.ts +48 -2
  116. package/src/core/runner-control-sessions.test.ts +135 -3
  117. package/src/core/runner-control-sessions.ts +141 -19
  118. package/src/core/runner-instances.test.ts +19 -0
  119. package/src/core/runner-instances.ts +2 -0
  120. package/src/core/runner-sessions.test.ts +121 -1
  121. package/src/db/ephemeral-registration-tokens.test.ts +20 -1
  122. package/src/db/ephemeral-registration-tokens.ts +1 -0
  123. package/src/db/job-executions.test.ts +98 -12
  124. package/src/db/job-executions.ts +52 -4
  125. package/src/db/reservation-locks.ts +22 -0
  126. package/src/db/reservations.test.ts +733 -22
  127. package/src/db/reservations.ts +408 -144
  128. package/src/db/runner-assignments.test.ts +105 -1
  129. package/src/db/runner-assignments.ts +245 -25
  130. package/src/db/runner-instances.test.ts +357 -2
  131. package/src/db/runner-instances.ts +289 -55
  132. package/src/db/runner-sessions.ts +19 -1
  133. package/src/db/runner-states.ts +7 -0
  134. package/src/db/schema/reservations.ts +5 -1
  135. package/src/db/schema/runner-instances.ts +9 -0
  136. package/src/metrics/index.ts +24 -1
  137. package/src/metrics/instance.test.ts +118 -0
  138. package/src/metrics/instance.ts +247 -3
  139. package/src/metrics/service.test.ts +46 -1
  140. package/src/metrics/service.ts +55 -8
  141. package/src/presentation/dto/poll-demand.ts +3 -0
  142. package/src/presentation/dto/runner-instances.ts +8 -1
  143. package/src/presentation/routes/assign-runner-instances.ts +14 -2
  144. package/src/presentation/routes/late-runner-enrollment.test.ts +219 -2
  145. package/src/presentation/routes/poll-demand.test.ts +56 -0
  146. package/src/presentation/routes/poll-demand.ts +1 -0
  147. package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
  148. package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
  149. package/src/presentation/routes/runner-enrollment.test.ts +316 -3
  150. package/src/presentation/routes/runner-enrollment.ts +8 -5
  151. package/test/factories/reservation.ts +2 -0
  152. package/test/factories/runner-instance.ts +6 -0
  153. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,6 +1,7 @@
1
1
  import {eq} from 'drizzle-orm';
2
2
  import {
3
3
  ReservationExpiredError,
4
+ ReservationNotFoundError,
4
5
  RunnerInstanceAlreadyAssignedError,
5
6
  RunnerInstanceNotAssignableError,
6
7
  } from '#core/errors.js';
@@ -41,6 +42,50 @@ describe('assignRunnerInstances', () => {
41
42
  });
42
43
  });
43
44
 
45
+ it('repairs a provider-reported reservation before assignment commit', async () => {
46
+ const reservation = await createReservation();
47
+ const [runner] = await db()
48
+ .insert(providerRunners)
49
+ .values({
50
+ provisionerId,
51
+ intendedReservationId: reservation.id,
52
+ reservationId: reservation.id,
53
+ providerRunnerId: crypto.randomUUID(),
54
+ labels: ['linux'],
55
+ state: 'running',
56
+ reportedAt: new Date(),
57
+ })
58
+ .returning();
59
+ if (!runner) throw new Error('Runner instance insert returned no row');
60
+ await db()
61
+ .insert(runnerControlSessions)
62
+ .values({
63
+ runnerInstanceId: runner.id,
64
+ provisionerId,
65
+ hashedToken: crypto.randomUUID(),
66
+ prefix: 'test',
67
+ expiresAt: new Date(Date.now() + 60_000),
68
+ });
69
+
70
+ const assigned = await assignRunnerInstances({
71
+ provisionerId,
72
+ reservationId: reservation.id,
73
+ runnerInstanceIds: [runner.id],
74
+ });
75
+
76
+ expect(assigned).toEqual([runner.id]);
77
+ const [stored] = await db()
78
+ .select()
79
+ .from(providerRunners)
80
+ .where(eq(providerRunners.id, runner.id));
81
+ expect(stored).toMatchObject({
82
+ workspaceId,
83
+ reservationId: reservation.id,
84
+ intendedReservationId: null,
85
+ assignedAt: expect.any(Date),
86
+ });
87
+ });
88
+
44
89
  it('is idempotent for concurrent retries of the same assignment', async () => {
45
90
  const reservation = await createReservation();
46
91
  const runner = await createEnrolledRunner();
@@ -80,6 +125,32 @@ describe('assignRunnerInstances', () => {
80
125
  expect(assigned).toEqual([runner.id]);
81
126
  });
82
127
 
128
+ it('is idempotent for a committed bound reservation assignment', async () => {
129
+ const reservation = await createReservation({kind: 'bound'});
130
+ const runner = await createEnrolledRunner();
131
+ await db()
132
+ .update(providerRunners)
133
+ .set({
134
+ workspaceId,
135
+ reservationId: reservation.id,
136
+ assignedAt: new Date(),
137
+ })
138
+ .where(eq(providerRunners.id, runner.id));
139
+
140
+ const assigned = await assignRunnerInstances({
141
+ provisionerId,
142
+ reservationId: reservation.id,
143
+ runnerInstanceIds: [runner.id],
144
+ });
145
+
146
+ expect(assigned).toEqual([runner.id]);
147
+ const [stored] = await db()
148
+ .select()
149
+ .from(providerRunners)
150
+ .where(eq(providerRunners.id, runner.id));
151
+ expect(stored?.intendedReservationId).toBeNull();
152
+ });
153
+
83
154
  it('rejects expired reservations', async () => {
84
155
  const reservation = await createReservation({expiresAt: new Date(Date.now() - 1_000)});
85
156
  const runner = await createEnrolledRunner();
@@ -93,6 +164,19 @@ describe('assignRunnerInstances', () => {
93
164
  await expect(assignment).rejects.toThrow(ReservationExpiredError);
94
165
  });
95
166
 
167
+ it('rejects bound reservations', async () => {
168
+ const reservation = await createReservation({kind: 'bound'});
169
+ const runner = await createEnrolledRunner();
170
+
171
+ const assignment = assignRunnerInstances({
172
+ provisionerId,
173
+ reservationId: reservation.id,
174
+ runnerInstanceIds: [runner.id],
175
+ });
176
+
177
+ await expect(assignment).rejects.toThrow(ReservationNotFoundError);
178
+ });
179
+
96
180
  it('rejects unenrolled or incompatible runners', async () => {
97
181
  const reservation = await createReservation({requiredLabels: ['linux', 'gpu']});
98
182
  const runner = await createEnrolledRunner({labels: ['linux']});
@@ -139,6 +223,25 @@ describe('assignRunnerInstances', () => {
139
223
  await expect(assignment).rejects.toThrow(RunnerInstanceNotAssignableError);
140
224
  });
141
225
 
226
+ it('charges capacity for a committed runner replayed alongside a new one', async () => {
227
+ const reservation = await createReservation();
228
+ const firstRunner = await createEnrolledRunner();
229
+ const secondRunner = await createEnrolledRunner();
230
+ await assignRunnerInstances({
231
+ provisionerId,
232
+ reservationId: reservation.id,
233
+ runnerInstanceIds: [firstRunner.id],
234
+ });
235
+
236
+ const assignment = assignRunnerInstances({
237
+ provisionerId,
238
+ reservationId: reservation.id,
239
+ runnerInstanceIds: [firstRunner.id, secondRunner.id],
240
+ });
241
+
242
+ await expect(assignment).rejects.toThrow(RunnerInstanceNotAssignableError);
243
+ });
244
+
142
245
  it('does not charge capacity for an already released reservation intent', async () => {
143
246
  const reservation = await createReservation();
144
247
  await db().insert(providerRunners).values({
@@ -202,7 +305,7 @@ describe('assignRunnerInstances', () => {
202
305
  });
203
306
 
204
307
  async function createReservation(
205
- overrides: Partial<{expiresAt: Date; requiredLabels: string[]}> = {},
308
+ overrides: Partial<{expiresAt: Date; requiredLabels: string[]; kind: 'bound' | 'launch'}> = {},
206
309
  ) {
207
310
  const [reservation] = await db()
208
311
  .insert(reservations)
@@ -211,6 +314,7 @@ describe('assignRunnerInstances', () => {
211
314
  provisionerId,
212
315
  requiredLabels: overrides.requiredLabels ?? ['linux'],
213
316
  count: 1,
317
+ kind: overrides.kind ?? 'launch',
214
318
  expiresAt: overrides.expiresAt ?? new Date(Date.now() + 60_000),
215
319
  })
216
320
  .returning();
@@ -1,13 +1,19 @@
1
- import {and, eq, inArray, isNull, notInArray, or, sql} from 'drizzle-orm';
1
+ import {and, eq, inArray, isNull, ne, notInArray, or, sql} from 'drizzle-orm';
2
2
  import {
3
3
  ReservationExpiredError,
4
4
  ReservationNotFoundError,
5
5
  RunnerInstanceAlreadyAssignedError,
6
6
  RunnerInstanceNotAssignableError,
7
7
  } from '#core/errors.js';
8
+ import {
9
+ type ProviderRunnerAssignmentObservation,
10
+ type RunnerAssignmentSurface,
11
+ recordProviderRunnerControlSessionToAssignment,
12
+ } from '#metrics/instance.js';
8
13
  import type {Tx} from './db.js';
9
14
  import {db} from './db.js';
10
- import {terminalStates} from './runner-instances.js';
15
+ import {lockRunnerReservationAdvisoryKeysTx} from './reservation-locks.js';
16
+ import {terminalStates} from './runner-states.js';
11
17
  import {reservations} from './schema/reservations.js';
12
18
  import {runnerControlSessions} from './schema/runner-control-sessions.js';
13
19
  import {providerRunners} from './schema/runner-instances.js';
@@ -18,7 +24,17 @@ export async function assignRunnerInstances(params: {
18
24
  reservationId: string;
19
25
  runnerInstanceIds: string[];
20
26
  }): Promise<string[]> {
21
- return await db().transaction(async (tx) => assignRunnerInstancesTx(tx, params));
27
+ const result = await db().transaction(async (tx) =>
28
+ assignRunnerInstancesTx(tx, {...params, surface: 'provisioner'}),
29
+ );
30
+ for (const observation of result.controlSessionToAssignment)
31
+ recordProviderRunnerControlSessionToAssignment(observation);
32
+ return result.runnerInstanceIds;
33
+ }
34
+
35
+ interface AssignRunnerInstancesTxResult {
36
+ runnerInstanceIds: string[];
37
+ controlSessionToAssignment: ProviderRunnerAssignmentObservation[];
22
38
  }
23
39
 
24
40
  export async function assignRunnerInstancesTx(
@@ -27,19 +43,24 @@ export async function assignRunnerInstancesTx(
27
43
  provisionerId: string;
28
44
  reservationId: string;
29
45
  runnerInstanceIds: string[];
46
+ surface: RunnerAssignmentSurface;
30
47
  },
31
- ): Promise<string[]> {
48
+ ): Promise<AssignRunnerInstancesTxResult> {
32
49
  const runnerInstanceIds = [...params.runnerInstanceIds].sort();
33
- await tx.execute(
34
- sql`select pg_advisory_xact_lock(hashtext(${`runners_assignment:${params.provisionerId}:${params.reservationId}`}))`,
35
- );
50
+ await lockRunnerReservationAdvisoryKeysTx(tx, {
51
+ provisionerId: params.provisionerId,
52
+ reservationIds: [params.reservationId],
53
+ });
36
54
  const runnerRows = await tx
37
55
  .select({
38
56
  id: providerRunners.id,
39
57
  reservationId: providerRunners.reservationId,
40
58
  intendedReservationId: providerRunners.intendedReservationId,
41
59
  workspaceId: providerRunners.workspaceId,
60
+ assignedAt: providerRunners.assignedAt,
42
61
  providerRunnerId: providerRunners.providerRunnerId,
62
+ provider: providerRunners.providerKind,
63
+ launchKind: providerRunners.launchKind,
43
64
  labels: providerRunners.labels,
44
65
  state: providerRunners.state,
45
66
  })
@@ -53,11 +74,16 @@ export async function assignRunnerInstancesTx(
53
74
  .for('update');
54
75
 
55
76
  // The assignment outlives its short reservation row. A retry after maintenance
56
- // cleanup is complete when every owned runner already carries the requested assignment.
77
+ // cleanup is complete when every owned runner already carries the committed assignment.
57
78
  if (
58
79
  runnerInstanceIds.length > 0 &&
59
80
  runnerRows.length === runnerInstanceIds.length &&
60
- runnerRows.every((runner) => runner.reservationId === params.reservationId)
81
+ runnerRows.every(
82
+ (runner) =>
83
+ runner.reservationId === params.reservationId &&
84
+ runner.assignedAt !== null &&
85
+ runner.workspaceId !== null,
86
+ )
61
87
  ) {
62
88
  await tx
63
89
  .update(providerRunners)
@@ -68,7 +94,10 @@ export async function assignRunnerInstancesTx(
68
94
  inArray(providerRunners.id, runnerInstanceIds),
69
95
  ),
70
96
  );
71
- return params.runnerInstanceIds;
97
+ return {
98
+ runnerInstanceIds: params.runnerInstanceIds,
99
+ controlSessionToAssignment: [],
100
+ };
72
101
  }
73
102
 
74
103
  const [reservation] = await tx
@@ -78,6 +107,7 @@ export async function assignRunnerInstancesTx(
78
107
  and(
79
108
  eq(reservations.id, params.reservationId),
80
109
  eq(reservations.provisionerId, params.provisionerId),
110
+ eq(reservations.kind, 'launch'),
81
111
  ),
82
112
  )
83
113
  .limit(1)
@@ -85,10 +115,13 @@ export async function assignRunnerInstancesTx(
85
115
  if (!reservation) throw new ReservationNotFoundError(params.reservationId);
86
116
  if (reservation.expiresAt <= new Date()) throw new ReservationExpiredError(params.reservationId);
87
117
  if (runnerRows.length !== runnerInstanceIds.length)
88
- throw new RunnerInstanceNotAssignableError(runnerInstanceIds[0] ?? '');
118
+ throw new RunnerInstanceNotAssignableError(runnerInstanceIds[0] ?? '', 'runner-not-found');
89
119
 
90
120
  const activeControlSessions = await tx
91
- .select({runnerInstanceId: runnerControlSessions.runnerInstanceId})
121
+ .select({
122
+ runnerInstanceId: runnerControlSessions.runnerInstanceId,
123
+ createdAt: runnerControlSessions.createdAt,
124
+ })
92
125
  .from(runnerControlSessions)
93
126
  .where(
94
127
  and(
@@ -102,21 +135,32 @@ export async function assignRunnerInstancesTx(
102
135
  const runnerInstanceIdsWithControlSession = new Set(
103
136
  activeControlSessions.map((session) => session.runnerInstanceId),
104
137
  );
138
+ const controlSessionCreatedAtByRunner = new Map(
139
+ activeControlSessions.map((session) => [session.runnerInstanceId, session.createdAt]),
140
+ );
105
141
  const runners = runnerRows.map((runner) => ({
106
142
  ...runner,
107
143
  controlSessionId: runnerInstanceIdsWithControlSession.has(runner.id) ? runner.id : null,
144
+ controlSessionCreatedAt: controlSessionCreatedAtByRunner.get(runner.id) ?? null,
108
145
  }));
109
146
 
110
147
  const alreadyAssigned = runners.filter((runner) => runner.reservationId !== null);
111
148
  if (alreadyAssigned.some((runner) => runner.reservationId !== reservation.id))
112
149
  throw new RunnerInstanceAlreadyAssignedError(alreadyAssigned[0]?.id ?? '');
113
- const newRunners = runners.filter((runner) => runner.reservationId === null);
150
+ const newRunners = runners.filter(
151
+ (runner) =>
152
+ runner.reservationId === null ||
153
+ (runner.reservationId === reservation.id &&
154
+ (runner.assignedAt === null || runner.workspaceId === null)),
155
+ );
156
+ const newRunnerIds = newRunners.map((runner) => runner.id);
114
157
  const assignedCount = await tx
115
158
  .select({count: sql<number>`count(*)::int`})
116
159
  .from(providerRunners)
117
160
  .where(
118
161
  and(
119
162
  eq(providerRunners.provisionerId, params.provisionerId),
163
+ notInArray(providerRunners.id, newRunnerIds),
120
164
  or(
121
165
  and(
122
166
  eq(providerRunners.reservationId, reservation.id),
@@ -124,7 +168,6 @@ export async function assignRunnerInstancesTx(
124
168
  ),
125
169
  and(
126
170
  eq(providerRunners.intendedReservationId, reservation.id),
127
- notInArray(providerRunners.id, runnerInstanceIds),
128
171
  isNull(providerRunners.reservationReleasedAt),
129
172
  notInArray(providerRunners.state, [...terminalStates]),
130
173
  ),
@@ -132,18 +175,19 @@ export async function assignRunnerInstancesTx(
132
175
  ),
133
176
  );
134
177
  if ((assignedCount[0]?.count ?? 0) + newRunners.length > reservation.count)
135
- throw new RunnerInstanceNotAssignableError(newRunners[0]?.id ?? '');
178
+ throw new RunnerInstanceNotAssignableError(newRunners[0]?.id ?? '', 'capacity-exhausted');
136
179
  for (const runner of newRunners) {
137
- if (
138
- runner.state !== 'running' ||
139
- !runner.providerRunnerId ||
140
- !runner.controlSessionId ||
141
- !reservation.requiredLabels.every((label) => runner.labels.includes(label))
142
- )
143
- throw new RunnerInstanceNotAssignableError(runner.id);
180
+ if (runner.state !== 'running')
181
+ throw new RunnerInstanceNotAssignableError(runner.id, 'runner-not-running');
182
+ if (!runner.providerRunnerId)
183
+ throw new RunnerInstanceNotAssignableError(runner.id, 'provider-identity-missing');
184
+ if (!runner.controlSessionId)
185
+ throw new RunnerInstanceNotAssignableError(runner.id, 'control-session-not-active');
186
+ if (!reservation.requiredLabels.every((label) => runner.labels.includes(label)))
187
+ throw new RunnerInstanceNotAssignableError(runner.id, 'labels-mismatch');
144
188
  }
145
189
  if (newRunners.length > 0) {
146
- await tx
190
+ const assignedRows = await tx
147
191
  .update(providerRunners)
148
192
  .set({
149
193
  workspaceId: reservation.workspaceId,
@@ -157,7 +201,183 @@ export async function assignRunnerInstancesTx(
157
201
  providerRunners.id,
158
202
  newRunners.map((runner) => runner.id),
159
203
  ),
160
- );
204
+ )
205
+ .returning({id: providerRunners.id, assignedAt: providerRunners.assignedAt});
206
+ const runnersById = new Map(newRunners.map((runner) => [runner.id, runner]));
207
+ const controlSessionToAssignment: ProviderRunnerAssignmentObservation[] = [];
208
+ for (const assigned of assignedRows) {
209
+ const runner = runnersById.get(assigned.id);
210
+ const controlSessionCreatedAt = runner?.controlSessionCreatedAt;
211
+ if (!runner || !assigned.assignedAt || !controlSessionCreatedAt) continue;
212
+ controlSessionToAssignment.push({
213
+ durationMilliseconds: assigned.assignedAt.getTime() - controlSessionCreatedAt.getTime(),
214
+ provider: runner.provider,
215
+ launchKind: runner.launchKind,
216
+ surface: params.surface,
217
+ runnerInstanceId: runner.id,
218
+ });
219
+ }
220
+ return {
221
+ runnerInstanceIds: params.runnerInstanceIds,
222
+ controlSessionToAssignment,
223
+ };
161
224
  }
162
- return params.runnerInstanceIds;
225
+ return {
226
+ runnerInstanceIds: params.runnerInstanceIds,
227
+ controlSessionToAssignment: [],
228
+ };
229
+ }
230
+
231
+ export type RunnerReservationCapacityFailureReason =
232
+ | 'reservation-not-found'
233
+ | 'reservation-kind-mismatch'
234
+ | 'reservation-expired'
235
+ | 'capacity-exhausted';
236
+
237
+ export interface RunnerReservationCapacityValidation {
238
+ acceptedByReservation: Map<string, number>;
239
+ unavailableByReservation: Map<
240
+ string,
241
+ {reason: RunnerReservationCapacityFailureReason; count: number}
242
+ >;
243
+ }
244
+
245
+ export async function validateRunnerReservationCapacityTx(
246
+ tx: Tx,
247
+ params: {
248
+ provisionerId: string;
249
+ requests: readonly {reservationId: string; count: number}[];
250
+ },
251
+ options: {advisoryLocksHeld?: boolean} = {},
252
+ ): Promise<RunnerReservationCapacityValidation> {
253
+ const requestedByReservation = new Map<string, number>();
254
+ for (const request of params.requests) {
255
+ if (request.count <= 0) continue;
256
+ requestedByReservation.set(
257
+ request.reservationId,
258
+ (requestedByReservation.get(request.reservationId) ?? 0) + request.count,
259
+ );
260
+ }
261
+ const reservationIds = [...requestedByReservation.keys()].sort();
262
+ if (reservationIds.length === 0)
263
+ return {acceptedByReservation: new Map(), unavailableByReservation: new Map()};
264
+
265
+ if (!options.advisoryLocksHeld)
266
+ await lockRunnerReservationAdvisoryKeysTx(tx, {
267
+ provisionerId: params.provisionerId,
268
+ reservationIds,
269
+ });
270
+
271
+ const reservationRows = await tx
272
+ .select({
273
+ id: reservations.id,
274
+ count: reservations.count,
275
+ expiresAt: reservations.expiresAt,
276
+ isExpired: sql<boolean>`${reservations.expiresAt} <= now()`,
277
+ })
278
+ .from(reservations)
279
+ .where(
280
+ and(
281
+ eq(reservations.provisionerId, params.provisionerId),
282
+ inArray(reservations.id, reservationIds),
283
+ eq(reservations.kind, 'launch'),
284
+ ),
285
+ )
286
+ .for('update');
287
+ const nonLaunchReservationRows = await tx
288
+ .select({id: reservations.id})
289
+ .from(reservations)
290
+ .where(
291
+ and(
292
+ eq(reservations.provisionerId, params.provisionerId),
293
+ inArray(reservations.id, reservationIds),
294
+ ne(reservations.kind, 'launch'),
295
+ ),
296
+ )
297
+ .for('update');
298
+ const reservationsById = new Map(
299
+ reservationRows.map((reservation) => [reservation.id, reservation]),
300
+ );
301
+ const nonLaunchReservationIds = new Set(
302
+ nonLaunchReservationRows.map((reservation) => reservation.id),
303
+ );
304
+ const requestedReservationIds = new Set(reservationIds);
305
+ const usedRunnerIdsByReservation = new Map<string, Set<string>>();
306
+ const usedRunnerRows = await tx
307
+ .select({
308
+ id: providerRunners.id,
309
+ reservationId: providerRunners.reservationId,
310
+ intendedReservationId: providerRunners.intendedReservationId,
311
+ state: providerRunners.state,
312
+ })
313
+ .from(providerRunners)
314
+ .where(
315
+ and(
316
+ eq(providerRunners.provisionerId, params.provisionerId),
317
+ isNull(providerRunners.reservationReleasedAt),
318
+ or(
319
+ inArray(providerRunners.reservationId, reservationIds),
320
+ and(
321
+ inArray(providerRunners.intendedReservationId, reservationIds),
322
+ notInArray(providerRunners.state, [...terminalStates]),
323
+ ),
324
+ ),
325
+ ),
326
+ );
327
+ for (const runner of usedRunnerRows) {
328
+ if (runner.reservationId && requestedReservationIds.has(runner.reservationId)) {
329
+ const usedRunnerIds =
330
+ usedRunnerIdsByReservation.get(runner.reservationId) ?? new Set<string>();
331
+ usedRunnerIds.add(runner.id);
332
+ usedRunnerIdsByReservation.set(runner.reservationId, usedRunnerIds);
333
+ }
334
+ if (
335
+ runner.intendedReservationId &&
336
+ requestedReservationIds.has(runner.intendedReservationId) &&
337
+ !terminalStates.includes(runner.state as (typeof terminalStates)[number])
338
+ ) {
339
+ const usedRunnerIds =
340
+ usedRunnerIdsByReservation.get(runner.intendedReservationId) ?? new Set<string>();
341
+ usedRunnerIds.add(runner.id);
342
+ usedRunnerIdsByReservation.set(runner.intendedReservationId, usedRunnerIds);
343
+ }
344
+ }
345
+
346
+ const acceptedByReservation = new Map<string, number>();
347
+ const unavailableByReservation = new Map<
348
+ string,
349
+ {reason: RunnerReservationCapacityFailureReason; count: number}
350
+ >();
351
+ for (const reservationId of reservationIds) {
352
+ const requested = requestedByReservation.get(reservationId) ?? 0;
353
+ const reservation = reservationsById.get(reservationId);
354
+ if (!reservation) {
355
+ unavailableByReservation.set(reservationId, {
356
+ reason: nonLaunchReservationIds.has(reservationId)
357
+ ? 'reservation-kind-mismatch'
358
+ : 'reservation-not-found',
359
+ count: requested,
360
+ });
361
+ continue;
362
+ }
363
+ if (reservation.isExpired) {
364
+ unavailableByReservation.set(reservationId, {
365
+ reason: 'reservation-expired',
366
+ count: requested,
367
+ });
368
+ continue;
369
+ }
370
+
371
+ const used = usedRunnerIdsByReservation.get(reservationId)?.size ?? 0;
372
+ const accepted = Math.min(requested, Math.max(0, reservation.count - used));
373
+ acceptedByReservation.set(reservationId, accepted);
374
+ if (accepted < requested) {
375
+ unavailableByReservation.set(reservationId, {
376
+ reason: 'capacity-exhausted',
377
+ count: requested - accepted,
378
+ });
379
+ }
380
+ }
381
+
382
+ return {acceptedByReservation, unavailableByReservation};
163
383
  }