@shipfox/api-runners 9.3.0 → 10.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 +38 -0
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/core/runner-activation.d.ts +6 -0
- package/dist/core/runner-activation.d.ts.map +1 -1
- package/dist/core/runner-activation.js +20 -19
- package/dist/core/runner-activation.js.map +1 -1
- package/dist/core/runner-control-sessions.d.ts +2 -1
- package/dist/core/runner-control-sessions.d.ts.map +1 -1
- package/dist/core/runner-control-sessions.js +65 -19
- package/dist/core/runner-control-sessions.js.map +1 -1
- package/dist/db/db.d.ts +34 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/job-executions.d.ts +7 -0
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +25 -0
- package/dist/db/job-executions.js.map +1 -1
- package/dist/db/runner-assignments.d.ts +6 -0
- package/dist/db/runner-assignments.d.ts.map +1 -1
- package/dist/db/runner-assignments.js +57 -42
- package/dist/db/runner-assignments.js.map +1 -1
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +64 -19
- package/dist/db/runner-instances.js.map +1 -1
- package/dist/db/schema/runner-instances.d.ts +17 -0
- package/dist/db/schema/runner-instances.d.ts.map +1 -1
- package/dist/db/schema/runner-instances.js +3 -0
- package/dist/db/schema/runner-instances.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/inter-module.d.ts.map +1 -1
- package/dist/presentation/inter-module.js +7 -2
- package/dist/presentation/inter-module.js.map +1 -1
- package/dist/presentation/routes/runner-enrollment.d.ts +9 -0
- package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
- package/dist/presentation/routes/runner-enrollment.js +36 -23
- package/dist/presentation/routes/runner-enrollment.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_intended_reservation.sql +3 -0
- package/drizzle/meta/0001_snapshot.json +2093 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +10 -10
- package/src/config.test.ts +11 -0
- package/src/config.ts +1 -1
- package/src/core/runner-activation.ts +48 -38
- package/src/core/runner-control-sessions.ts +115 -33
- package/src/db/index.ts +1 -0
- package/src/db/job-executions.ts +28 -0
- package/src/db/runner-assignments.test.ts +81 -0
- package/src/db/runner-assignments.ts +134 -86
- package/src/db/runner-instances.test.ts +182 -0
- package/src/db/runner-instances.ts +123 -28
- package/src/db/schema/runner-instances.ts +9 -0
- package/src/index.ts +1 -0
- package/src/presentation/inter-module.ts +4 -0
- package/src/presentation/routes/poll-demand.test.ts +2 -2
- package/src/presentation/routes/runner-enrollment.test.ts +144 -1
- package/src/presentation/routes/runner-enrollment.ts +40 -20
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-runners",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "10.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -21,24 +21,24 @@
|
|
|
21
21
|
"@temporalio/workflow": "1.18.1",
|
|
22
22
|
"drizzle-orm": "^0.45.2",
|
|
23
23
|
"zod": "^4.4.3",
|
|
24
|
-
"@shipfox/api-auth-context": "
|
|
25
|
-
"@shipfox/api-
|
|
26
|
-
"@shipfox/api-runners-dto": "9.0.2",
|
|
27
|
-
"@shipfox/api-workflows-dto": "9.3.0",
|
|
24
|
+
"@shipfox/api-auth-context": "10.0.0",
|
|
25
|
+
"@shipfox/api-runners-dto": "10.0.0",
|
|
28
26
|
"@shipfox/config": "1.2.4",
|
|
29
|
-
"@shipfox/
|
|
27
|
+
"@shipfox/api-workflows-dto": "10.0.0",
|
|
28
|
+
"@shipfox/api-auth-dto": "10.0.0",
|
|
30
29
|
"@shipfox/node-drizzle": "0.3.4",
|
|
31
30
|
"@shipfox/node-error-monitoring": "0.3.0",
|
|
32
31
|
"@shipfox/node-auth-root-key": "0.2.3",
|
|
33
|
-
"@shipfox/node-
|
|
34
|
-
"@shipfox/node-module": "1.0.3",
|
|
32
|
+
"@shipfox/node-module": "1.0.4",
|
|
35
33
|
"@shipfox/node-opentelemetry": "0.6.3",
|
|
34
|
+
"@shipfox/node-fastify": "0.4.0",
|
|
35
|
+
"@shipfox/node-rate-limit": "0.3.2",
|
|
36
36
|
"@shipfox/node-outbox": "0.2.6",
|
|
37
37
|
"@shipfox/node-postgres": "0.4.4",
|
|
38
|
-
"@shipfox/node-rate-limit": "0.3.2",
|
|
39
38
|
"@shipfox/node-temporal": "0.4.4",
|
|
40
39
|
"@shipfox/node-tokens": "0.3.2",
|
|
41
|
-
"@shipfox/runner-labels": "0.1.3"
|
|
40
|
+
"@shipfox/runner-labels": "0.1.3",
|
|
41
|
+
"@shipfox/inter-module": "0.2.2"
|
|
42
42
|
},
|
|
43
43
|
"imports": {
|
|
44
44
|
"#*": "./dist/*"
|
package/src/config.test.ts
CHANGED
|
@@ -27,6 +27,17 @@ describe('EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS validation', () => {
|
|
|
27
27
|
expect(config.EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS).toBe(3600);
|
|
28
28
|
});
|
|
29
29
|
});
|
|
30
|
+
describe('runner assignment polling defaults', () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.unstubAllEnvs();
|
|
33
|
+
vi.resetModules();
|
|
34
|
+
});
|
|
35
|
+
it('uses a 250 millisecond interval between assignment reads', async () => {
|
|
36
|
+
vi.resetModules();
|
|
37
|
+
const {config} = await import('#config.js');
|
|
38
|
+
expect(config.RUNNER_ASSIGNMENT_POLL_INTERVAL_MS).toBe(250);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
30
41
|
describe('REGISTRATION_TOKEN_BATCH_MAX validation', () => {
|
|
31
42
|
afterEach(() => {
|
|
32
43
|
vi.unstubAllEnvs();
|
package/src/config.ts
CHANGED
|
@@ -24,7 +24,7 @@ export const config = createConfig({
|
|
|
24
24
|
}),
|
|
25
25
|
RUNNER_ASSIGNMENT_POLL_INTERVAL_MS: num({
|
|
26
26
|
desc: 'Delay between durable assignment reads while a runner-control session waits for an assignment, in milliseconds.',
|
|
27
|
-
default:
|
|
27
|
+
default: 250,
|
|
28
28
|
}),
|
|
29
29
|
EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS: num({
|
|
30
30
|
desc: `Lifetime of a provisioner-minted runner registration token, in seconds. The token can be exchanged once by a runner before this time passes. Set this between 1 and ${EPHEMERAL_REGISTRATION_TOKEN_TTL_HARD_MAX_SECONDS}.`,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {extractDisplayPrefix, generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
|
|
2
2
|
import {and, eq, isNull, sql} from 'drizzle-orm';
|
|
3
|
+
import type {Tx} from '#db/db.js';
|
|
3
4
|
import {db} from '#db/db.js';
|
|
4
5
|
import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
|
|
5
6
|
import {providerRunners} from '#db/schema/runner-instances.js';
|
|
@@ -9,46 +10,55 @@ export async function issueRunnerActivationToken(params: {
|
|
|
9
10
|
provisionerId: string;
|
|
10
11
|
ttlSeconds: number;
|
|
11
12
|
}): Promise<string | null> {
|
|
12
|
-
return await db().transaction(async (tx) =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
.
|
|
31
|
-
|
|
13
|
+
return await db().transaction(async (tx) => issueRunnerActivationTokenTx(tx, params));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function issueRunnerActivationTokenTx(
|
|
17
|
+
tx: Tx,
|
|
18
|
+
params: {
|
|
19
|
+
runnerInstanceId: string;
|
|
20
|
+
provisionerId: string;
|
|
21
|
+
ttlSeconds: number;
|
|
22
|
+
},
|
|
23
|
+
): Promise<string | null> {
|
|
24
|
+
await tx.execute(
|
|
25
|
+
sql`select pg_advisory_xact_lock(hashtext(${`runners_activation:${params.runnerInstanceId}`}))`,
|
|
26
|
+
);
|
|
27
|
+
const [runner] = await tx
|
|
28
|
+
.select({
|
|
29
|
+
workspaceId: providerRunners.workspaceId,
|
|
30
|
+
runnerSessionId: providerRunners.runnerSessionId,
|
|
31
|
+
state: providerRunners.state,
|
|
32
|
+
})
|
|
33
|
+
.from(providerRunners)
|
|
34
|
+
.where(
|
|
35
|
+
and(
|
|
36
|
+
eq(providerRunners.id, params.runnerInstanceId),
|
|
37
|
+
eq(providerRunners.provisionerId, params.provisionerId),
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
.limit(1)
|
|
41
|
+
.for('update');
|
|
42
|
+
if (!runner?.workspaceId || runner.runnerSessionId || runner.state !== 'running') return null;
|
|
32
43
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
});
|
|
50
|
-
return rawToken;
|
|
44
|
+
await tx
|
|
45
|
+
.update(runnerActivationTokens)
|
|
46
|
+
.set({revokedAt: sql`now()`})
|
|
47
|
+
.where(
|
|
48
|
+
and(
|
|
49
|
+
eq(runnerActivationTokens.runnerInstanceId, params.runnerInstanceId),
|
|
50
|
+
isNull(runnerActivationTokens.consumedAt),
|
|
51
|
+
isNull(runnerActivationTokens.revokedAt),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
const rawToken = generateOpaqueToken('runnerActivationToken');
|
|
55
|
+
await tx.insert(runnerActivationTokens).values({
|
|
56
|
+
runnerInstanceId: params.runnerInstanceId,
|
|
57
|
+
hashedToken: hashOpaqueToken(rawToken),
|
|
58
|
+
prefix: extractDisplayPrefix(rawToken),
|
|
59
|
+
expiresAt: new Date(Date.now() + params.ttlSeconds * 1000),
|
|
51
60
|
});
|
|
61
|
+
return rawToken;
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
export async function getRunnerAssignment(params: {
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import type {RunnerToolCapabilitiesDto} from '@shipfox/api-runners-dto';
|
|
2
|
+
import type {NodePgDatabase} from '@shipfox/node-drizzle';
|
|
3
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
4
|
import {extractDisplayPrefix, generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
|
|
3
5
|
import {canonicalizeLabels} from '@shipfox/runner-labels';
|
|
4
6
|
import {and, eq, gt, isNull, notInArray, or, sql} from 'drizzle-orm';
|
|
5
|
-
import {
|
|
7
|
+
import {config} from '#config.js';
|
|
8
|
+
import {
|
|
9
|
+
ReservationExpiredError,
|
|
10
|
+
ReservationNotFoundError,
|
|
11
|
+
RunnerInstanceAlreadyAssignedError,
|
|
12
|
+
RunnerInstanceNotAssignableError,
|
|
13
|
+
} from '#core/errors.js';
|
|
14
|
+
import {db, type schema, type Tx} from '#db/db.js';
|
|
15
|
+
import {assignRunnerInstancesTx} from '#db/runner-assignments.js';
|
|
6
16
|
import {terminalStates} from '#db/runner-instances.js';
|
|
7
17
|
import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
|
|
8
18
|
import {runnerBootstrapTokens, runnerControlSessions} from '#db/schema/runner-control-sessions.js';
|
|
9
19
|
import {providerRunners} from '#db/schema/runner-instances.js';
|
|
20
|
+
import {issueRunnerActivationTokenTx} from './runner-activation.js';
|
|
10
21
|
|
|
11
22
|
export class RunnerBootstrapTokenInvalidError extends Error {
|
|
12
23
|
constructor() {
|
|
@@ -25,7 +36,10 @@ export class RunnerControlSessionInvalidError extends Error {
|
|
|
25
36
|
export async function createRunnerInstancesWithBootstrapTokens(params: {
|
|
26
37
|
provisionerId: string;
|
|
27
38
|
providerKind?: string | null;
|
|
28
|
-
runnerInstances: Array<{
|
|
39
|
+
runnerInstances: Array<{
|
|
40
|
+
templateKey?: string | null;
|
|
41
|
+
reservationId?: string | null;
|
|
42
|
+
}>;
|
|
29
43
|
ttlSeconds: number;
|
|
30
44
|
}): Promise<Array<{runnerInstanceId: string; bootstrapToken: string}>> {
|
|
31
45
|
const expiresAt = new Date(Date.now() + params.ttlSeconds * 1000);
|
|
@@ -37,6 +51,7 @@ export async function createRunnerInstancesWithBootstrapTokens(params: {
|
|
|
37
51
|
provisionerId: params.provisionerId,
|
|
38
52
|
providerKind: params.providerKind ?? null,
|
|
39
53
|
templateKey: runner.templateKey ?? null,
|
|
54
|
+
intendedReservationId: runner.reservationId ?? null,
|
|
40
55
|
state: 'starting' as const,
|
|
41
56
|
labels: [],
|
|
42
57
|
reportedAt: new Date(),
|
|
@@ -123,28 +138,87 @@ export async function enrollRunnerControlSession(params: {
|
|
|
123
138
|
capabilities?: RunnerToolCapabilitiesDto | null;
|
|
124
139
|
providerKind: string;
|
|
125
140
|
protocolVersion: string;
|
|
126
|
-
}): Promise<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
141
|
+
}): Promise<string | null> {
|
|
142
|
+
return await db().transaction(async (tx) => {
|
|
143
|
+
const [current] = await tx
|
|
144
|
+
.select({intendedReservationId: providerRunners.intendedReservationId})
|
|
145
|
+
.from(providerRunners)
|
|
146
|
+
.where(
|
|
147
|
+
and(
|
|
148
|
+
eq(providerRunners.id, params.runnerInstanceId),
|
|
149
|
+
eq(providerRunners.provisionerId, params.provisionerId),
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
if (!current) throw new RunnerControlSessionInvalidError();
|
|
153
|
+
if (current.intendedReservationId)
|
|
154
|
+
await tx.execute(
|
|
155
|
+
sql`select pg_advisory_xact_lock(hashtext(${`runners_assignment:${params.provisionerId}:${current.intendedReservationId}`}))`,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const [updated] = await tx
|
|
159
|
+
.update(providerRunners)
|
|
160
|
+
.set({
|
|
161
|
+
labels: [...canonicalizeLabels(params.labels)],
|
|
162
|
+
providerKind: params.providerKind,
|
|
163
|
+
protocolVersion: params.protocolVersion,
|
|
164
|
+
capabilities: params.capabilities ?? null,
|
|
165
|
+
state: 'running',
|
|
166
|
+
reportedAt: sql`now()`,
|
|
167
|
+
updatedAt: sql`now()`,
|
|
168
|
+
})
|
|
169
|
+
.where(
|
|
170
|
+
and(
|
|
171
|
+
eq(providerRunners.id, params.runnerInstanceId),
|
|
172
|
+
eq(providerRunners.provisionerId, params.provisionerId),
|
|
173
|
+
notInArray(providerRunners.state, [...terminalStates]),
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
.returning({
|
|
177
|
+
id: providerRunners.id,
|
|
178
|
+
intendedReservationId: providerRunners.intendedReservationId,
|
|
179
|
+
});
|
|
180
|
+
if (!updated) throw new RunnerControlSessionInvalidError();
|
|
181
|
+
|
|
182
|
+
await updateRunnerControlSessionLastSeen(tx, params.runnerInstanceId, params.provisionerId);
|
|
183
|
+
|
|
184
|
+
const intendedReservationId = updated.intendedReservationId;
|
|
185
|
+
if (!intendedReservationId) return null;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
return await tx.transaction(async (promotionTx) => {
|
|
189
|
+
await assignRunnerInstancesTx(promotionTx, {
|
|
190
|
+
provisionerId: params.provisionerId,
|
|
191
|
+
reservationId: intendedReservationId,
|
|
192
|
+
runnerInstanceIds: [params.runnerInstanceId],
|
|
193
|
+
});
|
|
194
|
+
return await issueRunnerActivationTokenTx(promotionTx, {
|
|
195
|
+
runnerInstanceId: params.runnerInstanceId,
|
|
196
|
+
provisionerId: params.provisionerId,
|
|
197
|
+
ttlSeconds: config.RUNNER_ACTIVATION_TOKEN_TTL_SECONDS,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const expectedFailure =
|
|
202
|
+
error instanceof ReservationExpiredError ||
|
|
203
|
+
error instanceof ReservationNotFoundError ||
|
|
204
|
+
error instanceof RunnerInstanceAlreadyAssignedError ||
|
|
205
|
+
error instanceof RunnerInstanceNotAssignableError;
|
|
206
|
+
const details = {
|
|
207
|
+
err: error,
|
|
208
|
+
runnerInstanceId: params.runnerInstanceId,
|
|
209
|
+
provisionerId: params.provisionerId,
|
|
210
|
+
reservationId: intendedReservationId,
|
|
211
|
+
};
|
|
212
|
+
if (expectedFailure)
|
|
213
|
+
logger().debug(details, 'Runner reservation could not be promoted during enrollment');
|
|
214
|
+
else
|
|
215
|
+
logger().error(
|
|
216
|
+
details,
|
|
217
|
+
'Unexpected failure promoting runner reservation during enrollment',
|
|
218
|
+
);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
148
222
|
}
|
|
149
223
|
|
|
150
224
|
export async function attachRunnerControlProviderId(params: {
|
|
@@ -168,7 +242,24 @@ export async function attachRunnerControlProviderId(params: {
|
|
|
168
242
|
}
|
|
169
243
|
|
|
170
244
|
export async function touchRunnerControlSession(runnerInstanceId: string, provisionerId: string) {
|
|
245
|
+
await updateRunnerControlSessionLastSeen(db(), runnerInstanceId, provisionerId);
|
|
171
246
|
await db()
|
|
247
|
+
.update(providerRunners)
|
|
248
|
+
.set({reportedAt: sql`now()`, updatedAt: sql`now()`})
|
|
249
|
+
.where(
|
|
250
|
+
and(
|
|
251
|
+
eq(providerRunners.id, runnerInstanceId),
|
|
252
|
+
eq(providerRunners.provisionerId, provisionerId),
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function updateRunnerControlSessionLastSeen(
|
|
258
|
+
tx: Tx | NodePgDatabase<typeof schema>,
|
|
259
|
+
runnerInstanceId: string,
|
|
260
|
+
provisionerId: string,
|
|
261
|
+
) {
|
|
262
|
+
await tx
|
|
172
263
|
.update(runnerControlSessions)
|
|
173
264
|
.set({lastSeenAt: sql`now()`})
|
|
174
265
|
.where(
|
|
@@ -178,13 +269,4 @@ export async function touchRunnerControlSession(runnerInstanceId: string, provis
|
|
|
178
269
|
isNull(runnerControlSessions.closedAt),
|
|
179
270
|
),
|
|
180
271
|
);
|
|
181
|
-
await db()
|
|
182
|
-
.update(providerRunners)
|
|
183
|
-
.set({reportedAt: sql`now()`, updatedAt: sql`now()`})
|
|
184
|
-
.where(
|
|
185
|
-
and(
|
|
186
|
-
eq(providerRunners.id, runnerInstanceId),
|
|
187
|
-
eq(providerRunners.provisionerId, provisionerId),
|
|
188
|
-
),
|
|
189
|
-
);
|
|
190
272
|
}
|
package/src/db/index.ts
CHANGED
package/src/db/job-executions.ts
CHANGED
|
@@ -47,6 +47,34 @@ export interface EnqueueJobExecutionParams {
|
|
|
47
47
|
requiredLabels: string[];
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export async function getWorkspaceJobCounts(params: {
|
|
51
|
+
workspaceIds: string[];
|
|
52
|
+
}): Promise<Array<{workspaceId: string; queued: number; running: number}>> {
|
|
53
|
+
const [pendingRows, runningRows] = await Promise.all([
|
|
54
|
+
db()
|
|
55
|
+
.select({workspaceId: pendingJobExecutions.workspaceId, count: count()})
|
|
56
|
+
.from(pendingJobExecutions)
|
|
57
|
+
.where(inArray(pendingJobExecutions.workspaceId, params.workspaceIds))
|
|
58
|
+
.groupBy(pendingJobExecutions.workspaceId),
|
|
59
|
+
db()
|
|
60
|
+
.select({workspaceId: runningJobExecutions.workspaceId, count: count()})
|
|
61
|
+
.from(runningJobExecutions)
|
|
62
|
+
.where(inArray(runningJobExecutions.workspaceId, params.workspaceIds))
|
|
63
|
+
.groupBy(runningJobExecutions.workspaceId),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const queuedByWorkspace = new Map(pendingRows.map((row) => [row.workspaceId, Number(row.count)]));
|
|
67
|
+
const runningByWorkspace = new Map(
|
|
68
|
+
runningRows.map((row) => [row.workspaceId, Number(row.count)]),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return params.workspaceIds.map((workspaceId) => ({
|
|
72
|
+
workspaceId,
|
|
73
|
+
queued: queuedByWorkspace.get(workspaceId) ?? 0,
|
|
74
|
+
running: runningByWorkspace.get(workspaceId) ?? 0,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
50
78
|
// Idempotent while the job execution is still pending: a duplicate jobExecutionId already in
|
|
51
79
|
// `runners_pending_jobs` is a no-op. Temporal retries the enqueue activity
|
|
52
80
|
// at-least-once, so a unique-violation throw on a retry-after-lost-result
|
|
@@ -61,6 +61,25 @@ describe('assignRunnerInstances', () => {
|
|
|
61
61
|
expect(results).toEqual([[runner.id], [runner.id]]);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it('is idempotent after an assigned reservation is deleted', async () => {
|
|
65
|
+
const reservation = await createReservation();
|
|
66
|
+
const runner = await createEnrolledRunner();
|
|
67
|
+
await assignRunnerInstances({
|
|
68
|
+
provisionerId,
|
|
69
|
+
reservationId: reservation.id,
|
|
70
|
+
runnerInstanceIds: [runner.id],
|
|
71
|
+
});
|
|
72
|
+
await db().delete(reservations).where(eq(reservations.id, reservation.id));
|
|
73
|
+
|
|
74
|
+
const assigned = await assignRunnerInstances({
|
|
75
|
+
provisionerId,
|
|
76
|
+
reservationId: reservation.id,
|
|
77
|
+
runnerInstanceIds: [runner.id],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(assigned).toEqual([runner.id]);
|
|
81
|
+
});
|
|
82
|
+
|
|
64
83
|
it('rejects expired reservations', async () => {
|
|
65
84
|
const reservation = await createReservation({expiresAt: new Date(Date.now() - 1_000)});
|
|
66
85
|
const runner = await createEnrolledRunner();
|
|
@@ -120,6 +139,68 @@ describe('assignRunnerInstances', () => {
|
|
|
120
139
|
await expect(assignment).rejects.toThrow(RunnerInstanceNotAssignableError);
|
|
121
140
|
});
|
|
122
141
|
|
|
142
|
+
it('does not charge capacity for an already released reservation intent', async () => {
|
|
143
|
+
const reservation = await createReservation();
|
|
144
|
+
await db().insert(providerRunners).values({
|
|
145
|
+
provisionerId,
|
|
146
|
+
intendedReservationId: reservation.id,
|
|
147
|
+
reservationReleasedAt: new Date(),
|
|
148
|
+
providerRunnerId: crypto.randomUUID(),
|
|
149
|
+
state: 'failed',
|
|
150
|
+
reportedAt: new Date(),
|
|
151
|
+
});
|
|
152
|
+
const runner = await createEnrolledRunner();
|
|
153
|
+
|
|
154
|
+
await expect(
|
|
155
|
+
assignRunnerInstances({
|
|
156
|
+
provisionerId,
|
|
157
|
+
reservationId: reservation.id,
|
|
158
|
+
runnerInstanceIds: [runner.id],
|
|
159
|
+
}),
|
|
160
|
+
).resolves.toEqual([runner.id]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('does not charge capacity for an already released reservation assignment', async () => {
|
|
164
|
+
const reservation = await createReservation();
|
|
165
|
+
await db().insert(providerRunners).values({
|
|
166
|
+
provisionerId,
|
|
167
|
+
reservationId: reservation.id,
|
|
168
|
+
reservationReleasedAt: new Date(),
|
|
169
|
+
providerRunnerId: crypto.randomUUID(),
|
|
170
|
+
state: 'failed',
|
|
171
|
+
reportedAt: new Date(),
|
|
172
|
+
});
|
|
173
|
+
const runner = await createEnrolledRunner();
|
|
174
|
+
|
|
175
|
+
await expect(
|
|
176
|
+
assignRunnerInstances({
|
|
177
|
+
provisionerId,
|
|
178
|
+
reservationId: reservation.id,
|
|
179
|
+
runnerInstanceIds: [runner.id],
|
|
180
|
+
}),
|
|
181
|
+
).resolves.toEqual([runner.id]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('does not charge capacity for a terminal reservation intent before release bookkeeping', async () => {
|
|
185
|
+
const reservation = await createReservation();
|
|
186
|
+
await db().insert(providerRunners).values({
|
|
187
|
+
provisionerId,
|
|
188
|
+
intendedReservationId: reservation.id,
|
|
189
|
+
providerRunnerId: crypto.randomUUID(),
|
|
190
|
+
state: 'failed',
|
|
191
|
+
reportedAt: new Date(),
|
|
192
|
+
});
|
|
193
|
+
const runner = await createEnrolledRunner();
|
|
194
|
+
|
|
195
|
+
await expect(
|
|
196
|
+
assignRunnerInstances({
|
|
197
|
+
provisionerId,
|
|
198
|
+
reservationId: reservation.id,
|
|
199
|
+
runnerInstanceIds: [runner.id],
|
|
200
|
+
}),
|
|
201
|
+
).resolves.toEqual([runner.id]);
|
|
202
|
+
});
|
|
203
|
+
|
|
123
204
|
async function createReservation(
|
|
124
205
|
overrides: Partial<{expiresAt: Date; requiredLabels: string[]}> = {},
|
|
125
206
|
) {
|