@shipfox/api-runners 11.0.0 → 12.0.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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +36 -0
- package/dist/db/job-executions.d.ts +10 -5
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +59 -23
- package/dist/db/job-executions.js.map +1 -1
- package/dist/db/rate-limits.d.ts +6 -15
- package/dist/db/rate-limits.d.ts.map +1 -1
- package/dist/db/rate-limits.js +17 -18
- package/dist/db/rate-limits.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +33 -54
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js +3 -3
- package/dist/presentation/subscribers/on-workflows-job-execution-timed-out.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +12 -12
- package/src/db/job-executions.test.ts +270 -22
- package/src/db/job-executions.ts +101 -32
- package/src/db/rate-limits.ts +30 -42
- package/src/presentation/routes/heartbeat.test.ts +3 -3
- package/src/presentation/routes/rate-limit.ts +33 -64
- package/src/presentation/subscribers/on-workflows-job-execution-timed-out.test.ts +16 -0
- package/src/presentation/subscribers/on-workflows-job-execution-timed-out.ts +3 -3
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/db/rate-limits.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
export
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 {
|
|
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
|
|
226
|
+
it('returns 200 + cancel:true after reconcileTerminalJobExecution', async () => {
|
|
227
227
|
const {jobId, jobExecutionId, workflowRunId, workflowRunAttemptId, leaseToken} =
|
|
228
228
|
await claimAvailableJob();
|
|
229
|
-
await
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 {
|
|
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
|
-
'
|
|
14
|
+
'Reconciling runner state for timed-out job execution',
|
|
15
15
|
);
|
|
16
|
-
await
|
|
16
|
+
await reconcileTerminalJobExecution({jobExecutionId: payload.jobExecutionId});
|
|
17
17
|
}
|