@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
@@ -3,6 +3,7 @@ import {and, eq, inArray} from 'drizzle-orm';
3
3
  import {
4
4
  ActiveEphemeralRegistrationTokensExistError,
5
5
  type RegistrationTokenBatchExceedsReservationError,
6
+ ReservationNotFoundError,
6
7
  } from '#core/errors.js';
7
8
  import {db} from '#db/db.js';
8
9
  import {
@@ -56,6 +57,20 @@ describe('createEphemeralRegistrationTokensBatch', () => {
56
57
  ).toEqual(expect.objectContaining({providerRunnerId: 'provisioned-runner-a'}));
57
58
  });
58
59
 
60
+ it('rejects bound reservations', async () => {
61
+ const boundReservationId = await createReservation({count: 1, kind: 'bound'});
62
+
63
+ await expect(
64
+ createEphemeralRegistrationTokensBatch({
65
+ workspaceId,
66
+ provisionerId,
67
+ reservationId: boundReservationId,
68
+ expiresAt: new Date(Date.now() + 300_000),
69
+ rows: [row('bound-runner', generateOpaqueToken('ephemeralRegistrationToken'))],
70
+ }),
71
+ ).rejects.toBeInstanceOf(ReservationNotFoundError);
72
+ });
73
+
59
74
  it('rejects the batch and inserts no rows when any provisioned runner already has an active token', async () => {
60
75
  await ephemeralRegistrationTokenFactory.create({
61
76
  workspaceId,
@@ -186,7 +201,10 @@ describe('createEphemeralRegistrationTokensBatch', () => {
186
201
  } satisfies Partial<RegistrationTokenBatchExceedsReservationError>);
187
202
  });
188
203
 
189
- async function createReservation(params: {count: number}): Promise<string> {
204
+ async function createReservation(params: {
205
+ count: number;
206
+ kind?: 'bound' | 'launch';
207
+ }): Promise<string> {
190
208
  const [reservation] = await db()
191
209
  .insert(reservations)
192
210
  .values({
@@ -194,6 +212,7 @@ describe('createEphemeralRegistrationTokensBatch', () => {
194
212
  provisionerId,
195
213
  requiredLabels: ['linux'],
196
214
  count: params.count,
215
+ kind: params.kind ?? 'launch',
197
216
  expiresAt: new Date(Date.now() + 60_000),
198
217
  })
199
218
  .returning({id: reservations.id});
@@ -133,6 +133,7 @@ export async function createEphemeralRegistrationTokensBatch(
133
133
  eq(reservations.id, params.reservationId),
134
134
  eq(reservations.workspaceId, params.workspaceId),
135
135
  eq(reservations.provisionerId, params.provisionerId),
136
+ eq(reservations.kind, 'launch'),
136
137
  ),
137
138
  )
138
139
  .limit(1);
@@ -8,6 +8,7 @@ import {eq, sql} from 'drizzle-orm';
8
8
  import {EmptyRequiredLabelsError, RunnerSessionExhaustedError} from '#core/errors.js';
9
9
  import {claimJobExecution} from '#core/job-executions.js';
10
10
  import {detectAndExpireStuckJobs} from '#core/maintenance.js';
11
+ import * as runnerMetrics from '#metrics/instance.js';
11
12
  import {
12
13
  getLeaseTokenClaims,
13
14
  pendingJobFactory,
@@ -242,20 +243,95 @@ describe('claimPendingJobExecution', () => {
242
243
  expect(new Date(payload.claimedAt).getTime()).toBe(running?.startedAt.getTime());
243
244
  });
244
245
 
246
+ it('records queue time from the pending row creation to the runner claim', async () => {
247
+ const created = await pendingJobFactory.create({workspaceId});
248
+ await db()
249
+ .update(pendingJobExecutions)
250
+ .set({createdAt: sql`now() - interval '60 seconds'`})
251
+ .where(eq(pendingJobExecutions.jobExecutionId, created.jobExecutionId));
252
+ const [pending] = await db()
253
+ .select({createdAt: pendingJobExecutions.createdAt})
254
+ .from(pendingJobExecutions)
255
+ .where(eq(pendingJobExecutions.jobExecutionId, created.jobExecutionId));
256
+ const recordQueueTime = vi.spyOn(runnerMetrics, 'recordJobExecutionQueueTime');
257
+
258
+ try {
259
+ await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
260
+
261
+ const [running] = await db()
262
+ .select({startedAt: runningJobExecutions.startedAt})
263
+ .from(runningJobExecutions)
264
+ .where(eq(runningJobExecutions.jobExecutionId, created.jobExecutionId));
265
+ const observation = recordQueueTime.mock.calls[0]?.[0];
266
+ if (!pending || !running) throw new Error('Expected pending and running job rows');
267
+ expect(recordQueueTime).toHaveBeenCalledTimes(1);
268
+ expect(observation).toMatchObject({provider: null, launchKind: 'manual'});
269
+ expect(observation?.durationMilliseconds).toBe(
270
+ running.startedAt.getTime() - pending.createdAt.getTime(),
271
+ );
272
+ expect(observation?.durationMilliseconds).toBeGreaterThanOrEqual(60_000);
273
+ expect(observation?.durationMilliseconds).toBeLessThan(120_000);
274
+ } finally {
275
+ recordQueueTime.mockRestore();
276
+ }
277
+ });
278
+
279
+ it('records bounded queue labels for a provisioned runner claim', async () => {
280
+ const provisionerId = crypto.randomUUID();
281
+ const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
282
+ await db()
283
+ .update(runnerSessions)
284
+ .set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
285
+ .where(eq(runnerSessions.id, runnerSessionId));
286
+ await db().insert(providerRunners).values({
287
+ workspaceId,
288
+ provisionerId,
289
+ providerRunnerId,
290
+ providerKind: 'ec2',
291
+ launchKind: 'demand',
292
+ labels: sessionLabels,
293
+ state: 'running',
294
+ reportedAt: new Date(),
295
+ });
296
+ await pendingJobFactory.create({workspaceId});
297
+ const recordQueueTime = vi.spyOn(runnerMetrics, 'recordJobExecutionQueueTime');
298
+
299
+ try {
300
+ await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
301
+
302
+ expect(recordQueueTime).toHaveBeenCalledWith(
303
+ expect.objectContaining({provider: 'ec2', launchKind: 'demand'}),
304
+ );
305
+ expect(recordQueueTime.mock.calls[0]?.[0].durationMilliseconds).toBeGreaterThanOrEqual(0);
306
+ } finally {
307
+ recordQueueTime.mockRestore();
308
+ }
309
+ });
310
+
245
311
  it('emits no claimed event when there is nothing to claim', async () => {
312
+ const recordQueueTime = vi.spyOn(runnerMetrics, 'recordJobExecutionQueueTime');
246
313
  const before = await db()
247
314
  .select()
248
315
  .from(runnersOutbox)
249
316
  .where(eq(runnersOutbox.eventType, RUNNER_JOB_CLAIMED));
250
317
 
251
- const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
318
+ try {
319
+ const claimed = await claimPendingJobExecution({
320
+ workspaceId,
321
+ runnerSessionId,
322
+ maxClaims: null,
323
+ });
252
324
 
253
- const after = await db()
254
- .select()
255
- .from(runnersOutbox)
256
- .where(eq(runnersOutbox.eventType, RUNNER_JOB_CLAIMED));
257
- expect(claimed).toBeNull();
258
- expect(after).toHaveLength(before.length);
325
+ const after = await db()
326
+ .select()
327
+ .from(runnersOutbox)
328
+ .where(eq(runnersOutbox.eventType, RUNNER_JOB_CLAIMED));
329
+ expect(claimed).toBeNull();
330
+ expect(after).toHaveLength(before.length);
331
+ expect(recordQueueTime).not.toHaveBeenCalled();
332
+ } finally {
333
+ recordQueueTime.mockRestore();
334
+ }
259
335
  });
260
336
 
261
337
  it('emits no claimed event when dropping an orphan pending row', async () => {
@@ -273,13 +349,23 @@ describe('claimPendingJobExecution', () => {
273
349
  });
274
350
  // Clear the initial claim's events so this assertion only covers the orphan claim.
275
351
  const beforeOrphanClaim = await outboxEventsForJob(RUNNER_JOB_CLAIMED, created.jobId);
352
+ const recordQueueTime = vi.spyOn(runnerMetrics, 'recordJobExecutionQueueTime');
276
353
 
277
- const second = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: null});
354
+ try {
355
+ const second = await claimPendingJobExecution({
356
+ workspaceId,
357
+ runnerSessionId,
358
+ maxClaims: null,
359
+ });
278
360
 
279
- expect(second).toBeNull();
280
- expect(await outboxEventsForJob(RUNNER_JOB_CLAIMED, created.jobId)).toHaveLength(
281
- beforeOrphanClaim.length,
282
- );
361
+ expect(second).toBeNull();
362
+ expect(await outboxEventsForJob(RUNNER_JOB_CLAIMED, created.jobId)).toHaveLength(
363
+ beforeOrphanClaim.length,
364
+ );
365
+ expect(recordQueueTime).not.toHaveBeenCalled();
366
+ } finally {
367
+ recordQueueTime.mockRestore();
368
+ }
283
369
  });
284
370
 
285
371
  it('returns the job ids when a job is available', async () => {
@@ -29,7 +29,14 @@ import {
29
29
  RunnerSessionExhaustedError,
30
30
  RunningJobExecutionNotFoundError,
31
31
  } from '#core/errors.js';
32
- import {jobExecutionEnqueuedCount, jobExecutionLeaseExpiredCount} from '#metrics/instance.js';
32
+ import {
33
+ type JobExecutionQueueTimeObservation,
34
+ jobExecutionEnqueuedCount,
35
+ jobExecutionLeaseExpiredCount,
36
+ type ProviderRunnerLifecycleObservation,
37
+ recordJobExecutionQueueTime,
38
+ recordProviderRunnerActivationToFirstClaim,
39
+ } from '#metrics/instance.js';
33
40
  import type {Tx} from './db.js';
34
41
  import {db} from './db.js';
35
42
  import {runnersOutbox} from './schema/outbox.js';
@@ -192,7 +199,9 @@ export async function claimPendingJobExecution(params: {
192
199
 
193
200
  if (params.sessionLabels.length === 0) return null;
194
201
 
195
- return await db().transaction(async (tx) => {
202
+ let activationToFirstClaimObservation: ProviderRunnerLifecycleObservation | null = null;
203
+ let queueTimeObservation: JobExecutionQueueTimeObservation | null = null;
204
+ const result = await db().transaction(async (tx) => {
196
205
  let provisionerId: string | null = null;
197
206
  let providerRunnerId: string | null = null;
198
207
  let runnerInstanceId: string | null = null;
@@ -288,6 +297,12 @@ export async function claimPendingJobExecution(params: {
288
297
  const claimed = inserted[0];
289
298
  if (!claimed) return null;
290
299
 
300
+ queueTimeObservation = {
301
+ durationMilliseconds: claimed.claimedAt.getTime() - row.createdAt.getTime(),
302
+ provider: null,
303
+ launchKind: params.maxClaims === null ? 'manual' : 'unknown',
304
+ };
305
+
291
306
  const runnerInstanceCondition = runnerInstanceId
292
307
  ? eq(providerRunners.id, runnerInstanceId)
293
308
  : provisionerId && providerRunnerId
@@ -297,13 +312,42 @@ export async function claimPendingJobExecution(params: {
297
312
  )
298
313
  : null;
299
314
  if (runnerInstanceCondition) {
300
- await tx
315
+ const [claimedRunner] = await tx
301
316
  .update(providerRunners)
302
317
  .set({
303
318
  firstClaimedAt: sql`coalesce(${providerRunners.firstClaimedAt}, ${claimed.claimedAt})`,
304
319
  updatedAt: sql`now()`,
305
320
  })
306
- .where(runnerInstanceCondition);
321
+ .where(runnerInstanceCondition)
322
+ .returning({
323
+ firstClaimedAt: providerRunners.firstClaimedAt,
324
+ isFirstClaim: sql<boolean>`${providerRunners.firstClaimedAt} = ${claimed.claimedAt}`,
325
+ provider: providerRunners.providerKind,
326
+ launchKind: providerRunners.launchKind,
327
+ runnerInstanceId: providerRunners.id,
328
+ sessionCreatedAtEpochMs: sql<number | null>`(
329
+ select extract(epoch from ${runnerSessions.createdAt})::double precision * 1000
330
+ from ${runnerSessions}
331
+ where ${runnerSessions.id} = ${params.runnerSessionId}
332
+ )`,
333
+ });
334
+ if (claimedRunner && queueTimeObservation) {
335
+ queueTimeObservation.provider = claimedRunner.provider;
336
+ queueTimeObservation.launchKind = claimedRunner.launchKind;
337
+ }
338
+ if (
339
+ claimedRunner?.isFirstClaim &&
340
+ claimedRunner.firstClaimedAt !== null &&
341
+ claimedRunner.sessionCreatedAtEpochMs !== null &&
342
+ claimedRunner.sessionCreatedAtEpochMs !== undefined
343
+ )
344
+ activationToFirstClaimObservation = {
345
+ durationMilliseconds:
346
+ claimedRunner.firstClaimedAt.getTime() - claimedRunner.sessionCreatedAtEpochMs,
347
+ provider: claimedRunner.provider,
348
+ launchKind: claimedRunner.launchKind,
349
+ runnerInstanceId: claimedRunner.runnerInstanceId,
350
+ };
307
351
  }
308
352
 
309
353
  if (params.maxClaims !== null) {
@@ -335,6 +379,10 @@ export async function claimPendingJobExecution(params: {
335
379
  projectId: row.projectId,
336
380
  };
337
381
  });
382
+ if (queueTimeObservation) recordJobExecutionQueueTime(queueTimeObservation);
383
+ if (activationToFirstClaimObservation)
384
+ recordProviderRunnerActivationToFirstClaim(activationToFirstClaimObservation);
385
+ return result;
338
386
  }
339
387
 
340
388
  async function touchRunnerSessionLiveness(params: {
@@ -0,0 +1,22 @@
1
+ import {sql} from 'drizzle-orm';
2
+ import type {Tx} from './db.js';
3
+
4
+ export async function lockRunnerReservationAdvisoryKeysTx(
5
+ tx: Tx,
6
+ params: {provisionerId: string; reservationIds: readonly string[]},
7
+ ): Promise<void> {
8
+ const reservationIds = [...new Set(params.reservationIds)].sort();
9
+ if (reservationIds.length === 0) return;
10
+
11
+ const lockKeys = sql.join(
12
+ reservationIds.map(
13
+ (reservationId) => sql`(${`runners_assignment:${params.provisionerId}:${reservationId}`})`,
14
+ ),
15
+ sql`, `,
16
+ );
17
+ await tx.execute(sql`
18
+ select pg_advisory_xact_lock(hashtext(lock_key))
19
+ from (values ${lockKeys}) as reservation_locks(lock_key)
20
+ order by lock_key
21
+ `);
22
+ }