@shipfox/api-runners 11.0.0 → 12.1.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.
@@ -1,28 +1,25 @@
1
+ import {
2
+ type ConsumeRateLimitParams,
3
+ type ConsumeRateLimitResult,
4
+ createRateLimitPersistence,
5
+ } from '@shipfox/node-rate-limit';
1
6
  import {lt, sql} from 'drizzle-orm';
2
7
  import {db} from './db.js';
3
8
  import {runnersRateLimits} from './schema/rate-limits.js';
4
9
 
5
- export interface ConsumeRunnersRateLimitParams {
6
- action: string;
7
- scope: string;
8
- identifierHmac: string;
9
- windowStart: Date;
10
- expiresAt: Date;
11
- timeoutMs: number;
12
- }
13
-
14
- export interface ConsumeRunnersRateLimitResult {
15
- count: number;
16
- expiresAt: Date;
17
- }
18
-
19
- export async function consumeRunnersRateLimit(
20
- params: ConsumeRunnersRateLimitParams,
21
- ): Promise<ConsumeRunnersRateLimitResult> {
22
- return await db().transaction(async (tx) => {
23
- await tx.execute(sql`select set_config('statement_timeout', ${`${params.timeoutMs}ms`}, true)`);
24
-
25
- const rows = await tx
10
+ type RunnersRateLimitTransaction = Parameters<
11
+ Parameters<ReturnType<typeof db>['transaction']>[0]
12
+ >[0];
13
+
14
+ const persistence = createRateLimitPersistence<RunnersRateLimitTransaction>({
15
+ transaction: (callback) => db().transaction(callback),
16
+ setStatementTimeout: async (transaction, timeoutMs) => {
17
+ await transaction.execute(
18
+ sql`select set_config('statement_timeout', ${`${timeoutMs}ms`}, true)`,
19
+ );
20
+ },
21
+ consume: async (transaction, params) => {
22
+ const rows = await transaction
26
23
  .insert(runnersRateLimits)
27
24
  .values({
28
25
  action: params.action,
@@ -46,24 +43,15 @@ export async function consumeRunnersRateLimit(
46
43
  })
47
44
  .returning({count: runnersRateLimits.count, expiresAt: runnersRateLimits.expiresAt});
48
45
 
49
- const row = rows[0];
50
- if (!row) throw new Error('Rate limit upsert returned no rows');
51
- return row;
52
- });
53
- }
54
-
55
- let nextPruneAt = 0;
56
-
57
- export async function pruneExpiredRunnersRateLimits(
58
- params: {now?: Date | undefined; minIntervalMs?: number | undefined} = {},
59
- ): Promise<number | undefined> {
60
- const now = params.now ?? new Date();
61
- const minIntervalMs = params.minIntervalMs ?? 60_000;
62
- if (minIntervalMs > 0 && now.getTime() < nextPruneAt) return undefined;
63
-
64
- nextPruneAt = now.getTime() + minIntervalMs;
65
-
66
- const result = await db().delete(runnersRateLimits).where(lt(runnersRateLimits.expiresAt, now));
67
-
68
- return result.rowCount ?? 0;
69
- }
46
+ return rows[0];
47
+ },
48
+ prune: async (now) => {
49
+ const result = await db().delete(runnersRateLimits).where(lt(runnersRateLimits.expiresAt, now));
50
+ return result.rowCount ?? 0;
51
+ },
52
+ });
53
+
54
+ export type ConsumeRunnersRateLimitParams = ConsumeRateLimitParams<string, string>;
55
+ export type ConsumeRunnersRateLimitResult = ConsumeRateLimitResult;
56
+ export const consumeRunnersRateLimit = persistence.consume;
57
+ export const pruneExpiredRunnersRateLimits = persistence.prune;
@@ -6,7 +6,7 @@ import {eq} from 'drizzle-orm';
6
6
  import type {FastifyInstance} from 'fastify';
7
7
  import {claimJobExecution} from '#core/job-executions.js';
8
8
  import {db} from '#db/db.js';
9
- import {requestJobExecutionCancellation} from '#db/job-executions.js';
9
+ import {reconcileTerminalJobExecution} from '#db/job-executions.js';
10
10
  import {runnerSessions} from '#db/schema/runner-sessions.js';
11
11
  import {runningJobExecutions} from '#db/schema/running-job-executions.js';
12
12
  import {createRunnerRegistrationTokenAuthMethod} from '#presentation/auth/index.js';
@@ -223,10 +223,10 @@ describe('POST /runners/jobs/:jobId/heartbeat', () => {
223
223
  expect(session?.toolCapabilitiesReportedAt).toEqual(new Date('2026-01-01T00:00:00.000Z'));
224
224
  });
225
225
 
226
- it('returns 200 + cancel:true after requestJobExecutionCancellation', async () => {
226
+ it('returns 200 + cancel:true after reconcileTerminalJobExecution', async () => {
227
227
  const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, leaseToken} =
228
228
  await claimAvailableJob();
229
- await requestJobExecutionCancellation({jobExecutionId});
229
+ await reconcileTerminalJobExecution({jobExecutionId});
230
230
 
231
231
  const res = await app.inject({
232
232
  method: 'POST',
@@ -1,11 +1,8 @@
1
1
  import {requireProvisionerContext} from '@shipfox/api-auth-context';
2
2
  import {ClientError, type FastifyReply, type FastifyRequest} from '@shipfox/node-fastify';
3
+ import {enforceRateLimit as enforceSharedRateLimit} from '@shipfox/node-rate-limit';
3
4
  import {config} from '#config.js';
4
- import {
5
- checkRunnersRateLimit,
6
- RunnersRateLimitExceededError,
7
- RunnersRateLimitUnavailableError,
8
- } from '#core/rate-limit.js';
5
+ import {checkRunnersRateLimit} from '#core/rate-limit.js';
9
6
  import {getRunnerContext} from '#presentation/auth/index.js';
10
7
 
11
8
  function routeName(request: FastifyRequest): string {
@@ -21,65 +18,37 @@ async function enforceRateLimit(params: {
21
18
  limit: number;
22
19
  windowSeconds: number;
23
20
  }): Promise<void> {
24
- try {
25
- await checkRunnersRateLimit({
26
- action: params.action,
27
- scope: params.scope,
28
- identifier: params.identifier,
29
- limit: params.limit,
30
- windowSeconds: params.windowSeconds,
31
- });
32
- } catch (error) {
33
- if (error instanceof RunnersRateLimitExceededError) {
34
- params.request.log.warn(
35
- {
36
- action: error.action,
37
- scope: error.scope,
38
- route: routeName(params.request),
39
- retryAfterSeconds: error.retryAfterSeconds,
40
- identifierHmacPrefix: error.identifierHmacPrefix,
41
- },
42
- 'Runners rate limit blocked request',
43
- );
44
- params.reply.header('Retry-After', String(error.retryAfterSeconds));
45
- throw new ClientError('Rate limit exceeded', 'rate-limited', {
46
- status: 429,
47
- details: {retry_after_seconds: error.retryAfterSeconds},
48
- data: {
49
- action: error.action,
50
- scope: error.scope,
51
- route: routeName(params.request),
52
- identifierHmacPrefix: error.identifierHmacPrefix,
53
- },
54
- cause: error,
55
- });
56
- }
57
-
58
- if (error instanceof RunnersRateLimitUnavailableError) {
59
- params.request.log.error(
60
- {
61
- action: error.action,
62
- scope: error.scope,
63
- route: routeName(params.request),
64
- identifierHmacPrefix: error.identifierHmacPrefix,
65
- err: error,
66
- },
67
- 'Runners rate limiter unavailable',
68
- );
69
- throw new ClientError('Runners rate limiter unavailable', 'runners-rate-limit-unavailable', {
70
- status: 503,
71
- data: {
72
- action: error.action,
73
- scope: error.scope,
74
- route: routeName(params.request),
75
- identifierHmacPrefix: error.identifierHmacPrefix,
76
- },
77
- cause: error,
78
- });
79
- }
80
-
81
- throw error;
82
- }
21
+ await enforceSharedRateLimit({
22
+ request: params.request,
23
+ reply: params.reply,
24
+ check: () =>
25
+ checkRunnersRateLimit({
26
+ action: params.action,
27
+ scope: params.scope,
28
+ identifier: params.identifier,
29
+ limit: params.limit,
30
+ windowSeconds: params.windowSeconds,
31
+ }),
32
+ route: routeName(params.request),
33
+ unavailableCode: 'runners-rate-limit-unavailable',
34
+ unavailableMessage: 'Runners rate limiter unavailable',
35
+ setRetryAfter: (reply, retryAfterSeconds) => {
36
+ reply.header('Retry-After', String(retryAfterSeconds));
37
+ },
38
+ logWarn: (request, context) => {
39
+ request.log.warn(context, 'Runners rate limit blocked request');
40
+ },
41
+ logError: (request, context) => {
42
+ request.log.error(context, 'Runners rate limiter unavailable');
43
+ },
44
+ createClientError: (presentation, cause) =>
45
+ new ClientError(presentation.message, presentation.code, {
46
+ status: presentation.status,
47
+ ...(presentation.details ? {details: presentation.details} : {}),
48
+ data: presentation.data,
49
+ cause,
50
+ }),
51
+ });
83
52
  }
84
53
 
85
54
  export function createProvisionerMintRateLimitPreHandler() {
@@ -2,6 +2,7 @@ import type {WorkflowsJobExecutionTimedOutEventDto} from '@shipfox/api-workflows
2
2
  import {eq} from 'drizzle-orm';
3
3
  import {db} from '#db/db.js';
4
4
  import {claimPendingJobExecution} from '#db/job-executions.js';
5
+ import {pendingJobExecutions} from '#db/schema/pending-job-executions.js';
5
6
  import {runningJobExecutions} from '#db/schema/running-job-executions.js';
6
7
  import {pendingJobFactory, runnerSessionFactory} from '#test/index.js';
7
8
  import {onWorkflowsJobExecutionTimedOut} from './on-workflows-job-execution-timed-out.js';
@@ -24,6 +25,21 @@ describe('onWorkflowsJobExecutionTimedOut', () => {
24
25
  runnerSessionId = runnerSession.id;
25
26
  });
26
27
 
28
+ it('deletes an unclaimed pending execution', async () => {
29
+ const pending = await pendingJobFactory.create({workspaceId});
30
+
31
+ await onWorkflowsJobExecutionTimedOut(
32
+ buildPayload(pending.jobId, pending.jobExecutionId, pending.workflowRunAttemptId),
33
+ );
34
+
35
+ expect(
36
+ await db()
37
+ .select()
38
+ .from(pendingJobExecutions)
39
+ .where(eq(pendingJobExecutions.jobExecutionId, pending.jobExecutionId)),
40
+ ).toHaveLength(0);
41
+ });
42
+
27
43
  it('sets cancellation_requested_at on the matching running_jobs row', async () => {
28
44
  await pendingJobFactory.create({workspaceId});
29
45
  const claimed = await claimPendingJobExecution({
@@ -1,6 +1,6 @@
1
1
  import type {WorkflowsJobExecutionTimedOutEventDto} from '@shipfox/api-workflows-dto';
2
2
  import {logger} from '@shipfox/node-opentelemetry';
3
- import {requestJobExecutionCancellation} from '#db/job-executions.js';
3
+ import {reconcileTerminalJobExecution} from '#db/job-executions.js';
4
4
 
5
5
  export async function onWorkflowsJobExecutionTimedOut(
6
6
  payload: WorkflowsJobExecutionTimedOutEventDto,
@@ -11,7 +11,7 @@ export async function onWorkflowsJobExecutionTimedOut(
11
11
  jobExecutionId: payload.jobExecutionId,
12
12
  workflowRunAttemptId: payload.workflowRunAttemptId,
13
13
  },
14
- 'Requesting runner cancellation for timed-out job execution',
14
+ 'Reconciling runner state for timed-out job execution',
15
15
  );
16
- await requestJobExecutionCancellation({jobExecutionId: payload.jobExecutionId});
16
+ await reconcileTerminalJobExecution({jobExecutionId: payload.jobExecutionId});
17
17
  }