@shipfox/api-runners 10.2.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 +43 -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 +13 -13
- 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/list-active-runners.test.ts +8 -2
- package/src/presentation/routes/manual-registration-tokens.test.ts +8 -2
- package/src/presentation/routes/provisioner-tokens.test.ts +8 -2
- 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',
|
|
@@ -50,7 +50,7 @@ describe('GET /workspaces/:workspaceId/runners/active', () => {
|
|
|
50
50
|
beforeEach(async () => {
|
|
51
51
|
await closeApp();
|
|
52
52
|
workspaceId = crypto.randomUUID();
|
|
53
|
-
authenticatedMemberships = [{workspaceId, role: 'admin'}];
|
|
53
|
+
authenticatedMemberships = [{workspaceId, role: 'admin', workspaceStatus: 'active'}];
|
|
54
54
|
app = await createApp({
|
|
55
55
|
auth: [
|
|
56
56
|
fakeUserAuth,
|
|
@@ -353,7 +353,13 @@ describe('GET /workspaces/:workspaceId/runners/active', () => {
|
|
|
353
353
|
});
|
|
354
354
|
|
|
355
355
|
it('returns 403 when the user is not a workspace member', async () => {
|
|
356
|
-
authenticatedMemberships = [
|
|
356
|
+
authenticatedMemberships = [
|
|
357
|
+
{
|
|
358
|
+
workspaceId: crypto.randomUUID(),
|
|
359
|
+
role: 'admin',
|
|
360
|
+
workspaceStatus: 'active',
|
|
361
|
+
},
|
|
362
|
+
];
|
|
357
363
|
|
|
358
364
|
const res = await app.inject({
|
|
359
365
|
method: 'GET',
|
|
@@ -58,7 +58,7 @@ describe('manual registration token routes', () => {
|
|
|
58
58
|
beforeEach(async () => {
|
|
59
59
|
await closeApp();
|
|
60
60
|
workspaceId = crypto.randomUUID();
|
|
61
|
-
authenticatedMemberships = [{workspaceId, role: 'admin'}];
|
|
61
|
+
authenticatedMemberships = [{workspaceId, role: 'admin', workspaceStatus: 'active'}];
|
|
62
62
|
app = await createApp({
|
|
63
63
|
auth: [
|
|
64
64
|
fakeUserAuth,
|
|
@@ -109,7 +109,13 @@ describe('manual registration token routes', () => {
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
it('returns 403 when the user is not a workspace member', async () => {
|
|
112
|
-
authenticatedMemberships = [
|
|
112
|
+
authenticatedMemberships = [
|
|
113
|
+
{
|
|
114
|
+
workspaceId: crypto.randomUUID(),
|
|
115
|
+
role: 'admin',
|
|
116
|
+
workspaceStatus: 'active',
|
|
117
|
+
},
|
|
118
|
+
];
|
|
113
119
|
|
|
114
120
|
const res = await app.inject({
|
|
115
121
|
method: 'GET',
|
|
@@ -45,7 +45,7 @@ describe('provisioner token routes', () => {
|
|
|
45
45
|
beforeEach(async () => {
|
|
46
46
|
await closeApp();
|
|
47
47
|
workspaceId = crypto.randomUUID();
|
|
48
|
-
authenticatedMemberships = [{workspaceId, role: 'admin'}];
|
|
48
|
+
authenticatedMemberships = [{workspaceId, role: 'admin', workspaceStatus: 'active'}];
|
|
49
49
|
app = await createApp({
|
|
50
50
|
auth: [fakeUserAuth, createProvisionerTokenAuthMethod()],
|
|
51
51
|
routes: provisionerRoutes,
|
|
@@ -74,7 +74,13 @@ describe('provisioner token routes', () => {
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
it('returns 403 when the user is not a workspace member', async () => {
|
|
77
|
-
authenticatedMemberships = [
|
|
77
|
+
authenticatedMemberships = [
|
|
78
|
+
{
|
|
79
|
+
workspaceId: crypto.randomUUID(),
|
|
80
|
+
role: 'admin',
|
|
81
|
+
workspaceStatus: 'active',
|
|
82
|
+
},
|
|
83
|
+
];
|
|
78
84
|
|
|
79
85
|
const res = await app.inject({
|
|
80
86
|
method: 'GET',
|
|
@@ -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
|
}
|