@elench/testkit 0.1.40 → 0.1.42
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/README.md +27 -13
- package/bin/testkit.mjs +6 -1
- package/lib/cli/args.mjs +0 -4
- package/lib/cli/args.test.mjs +0 -5
- package/lib/cli/index.mjs +4 -11
- package/lib/config/index.mjs +78 -24
- package/lib/database/index.mjs +19 -7
- package/lib/database/naming.mjs +2 -2
- package/lib/database/naming.test.mjs +2 -2
- package/lib/runner/default-runtime-runner.mjs +52 -55
- package/lib/runner/execution-config.mjs +31 -70
- package/lib/runner/execution-config.test.mjs +30 -74
- package/lib/runner/formatting.mjs +0 -15
- package/lib/runner/formatting.test.mjs +0 -18
- package/lib/runner/lifecycle.mjs +106 -8
- package/lib/runner/orchestrator.mjs +16 -10
- package/lib/runner/planning.mjs +66 -138
- package/lib/runner/planning.test.mjs +101 -167
- package/lib/runner/playwright-config.mjs +13 -2
- package/lib/runner/playwright-config.test.mjs +26 -6
- package/lib/runner/playwright-runner.mjs +50 -56
- package/lib/runner/readiness.mjs +2 -2
- package/lib/runner/reporting.mjs +4 -3
- package/lib/runner/reporting.test.mjs +2 -5
- package/lib/runner/results.mjs +1 -1
- package/lib/runner/results.test.mjs +1 -1
- package/lib/runner/runtime-contexts.mjs +20 -24
- package/lib/runner/runtime-manager.mjs +228 -0
- package/lib/runner/runtime-manager.test.mjs +206 -0
- package/lib/runner/services.mjs +8 -6
- package/lib/runner/state.mjs +1 -2
- package/lib/runner/state.test.mjs +2 -4
- package/lib/runner/template.mjs +90 -60
- package/lib/runner/template.test.mjs +59 -27
- package/lib/runner/worker-loop.mjs +35 -32
- package/lib/setup/index.d.ts +15 -10
- package/package.json +1 -1
- package/lib/runner/stack-manager.mjs +0 -146
|
@@ -54,7 +54,7 @@ describe("runner results", () => {
|
|
|
54
54
|
const tracker = trackers.get("api");
|
|
55
55
|
addTrackerError(tracker, "worker failed");
|
|
56
56
|
addTrackerError(tracker, "worker failed");
|
|
57
|
-
recordGraphError(trackers, {
|
|
57
|
+
recordGraphError(trackers, { targetNames: ["api"] }, "graph failed", 1300);
|
|
58
58
|
|
|
59
59
|
const result = finalizeServiceResult(tracker, 1000, 1500);
|
|
60
60
|
expect(result.failed).toBe(true);
|
|
@@ -3,29 +3,29 @@ import path from "path";
|
|
|
3
3
|
import { execaCommand } from "execa";
|
|
4
4
|
import { resolveServiceCwd } from "../config/index.mjs";
|
|
5
5
|
import { prepareDatabaseRuntime } from "../database/index.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import { taskNeedsLocalRuntime } from "./planning.mjs";
|
|
7
7
|
import { writeGraphMetadata } from "./state.mjs";
|
|
8
8
|
import { startLocalServices, stopLocalServices } from "./services.mjs";
|
|
9
|
+
import { buildExecutionEnv, resolveRuntimeInstanceConfigs } from "./template.mjs";
|
|
9
10
|
|
|
10
|
-
export function
|
|
11
|
+
export function createRuntimeInstanceContext(runtimeId, graph, productDir) {
|
|
11
12
|
const graphDir = path.join(productDir, ".testkit", "_graphs", graph.dirName);
|
|
12
|
-
const
|
|
13
|
-
fs.mkdirSync(
|
|
13
|
+
const runtimeDir = path.join(graphDir, "runtimes", runtimeId);
|
|
14
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
14
15
|
writeGraphMetadata(graphDir, graph);
|
|
15
16
|
|
|
16
|
-
const runtimeConfigs =
|
|
17
|
-
graph.
|
|
18
|
-
graph.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
);
|
|
17
|
+
const runtimeConfigs = resolveRuntimeInstanceConfigs(graph.runtimeConfigs, runtimeId, runtimeDir, {
|
|
18
|
+
graphDirName: graph.dirName,
|
|
19
|
+
portNamespaceIndex: graph.portNamespaceIndex,
|
|
20
|
+
portNamespaceStride: graph.portNamespaceStride,
|
|
21
|
+
});
|
|
22
22
|
|
|
23
23
|
return {
|
|
24
24
|
graphKey: graph.key,
|
|
25
|
-
|
|
25
|
+
targetNames: [...graph.targetNames],
|
|
26
26
|
graphDir,
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
runtimeDir,
|
|
28
|
+
runtimeId,
|
|
29
29
|
runtimeConfigs,
|
|
30
30
|
configByName: new Map(runtimeConfigs.map((config) => [config.name, config])),
|
|
31
31
|
prepared: false,
|
|
@@ -36,7 +36,7 @@ export function createStackContext(stackId, graph, productDir) {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export async function
|
|
39
|
+
export async function ensureRuntimeInstanceReady(context, task, lifecycle) {
|
|
40
40
|
if (!context.prepared) {
|
|
41
41
|
if (!context.preparationPromise) {
|
|
42
42
|
context.preparationPromise = (async () => {
|
|
@@ -49,7 +49,7 @@ export async function ensureStackContextReady(context, batch, lifecycle) {
|
|
|
49
49
|
await context.preparationPromise;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
if (
|
|
52
|
+
if (taskNeedsLocalRuntime(task) && !context.started) {
|
|
53
53
|
if (!context.startupPromise) {
|
|
54
54
|
context.startupPromise = (async () => {
|
|
55
55
|
context.startedServices = await startLocalServices(context.runtimeConfigs, lifecycle);
|
|
@@ -64,16 +64,16 @@ export async function ensureStackContextReady(context, batch, lifecycle) {
|
|
|
64
64
|
return context;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
export async function
|
|
67
|
+
export async function deactivateRuntimeInstanceContext(context, lifecycle) {
|
|
68
68
|
if (!context?.started) return;
|
|
69
69
|
await stopLocalServices(context.startedServices, lifecycle);
|
|
70
70
|
context.started = false;
|
|
71
71
|
context.startedServices = [];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
export async function
|
|
74
|
+
export async function cleanupRuntimeInstanceContext(context, lifecycle) {
|
|
75
75
|
if (!context) return;
|
|
76
|
-
await
|
|
76
|
+
await deactivateRuntimeInstanceContext(context, lifecycle);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
export async function prepareDatabases(runtimeConfigs) {
|
|
@@ -94,7 +94,7 @@ async function runMigrate(config, databaseUrl) {
|
|
|
94
94
|
const env = buildExecutionEnv(config, {}, process.env);
|
|
95
95
|
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
96
96
|
|
|
97
|
-
console.log(`\n── migrate:${config.
|
|
97
|
+
console.log(`\n── migrate:${config.runtimeLabel}:${config.name} ──`);
|
|
98
98
|
await execaCommand(migrate.cmd, {
|
|
99
99
|
cwd: resolveServiceCwd(config.productDir, migrate.cwd),
|
|
100
100
|
env,
|
|
@@ -110,7 +110,7 @@ async function runSeed(config, databaseUrl) {
|
|
|
110
110
|
const env = buildExecutionEnv(config, {}, process.env);
|
|
111
111
|
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
112
112
|
|
|
113
|
-
console.log(`\n── seed:${config.
|
|
113
|
+
console.log(`\n── seed:${config.runtimeLabel}:${config.name} ──`);
|
|
114
114
|
await execaCommand(seed.cmd, {
|
|
115
115
|
cwd: resolveServiceCwd(config.productDir, seed.cwd),
|
|
116
116
|
env,
|
|
@@ -118,7 +118,3 @@ async function runSeed(config, databaseUrl) {
|
|
|
118
118
|
shell: true,
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
|
-
|
|
122
|
-
function batchNeedsLocalRuntime(batch) {
|
|
123
|
-
return batch.type !== "dal";
|
|
124
|
-
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { buildRuntimeIds } from "./execution-config.mjs";
|
|
4
|
+
import {
|
|
5
|
+
cleanupRuntimeInstanceContext,
|
|
6
|
+
createRuntimeInstanceContext,
|
|
7
|
+
ensureRuntimeInstanceReady,
|
|
8
|
+
} from "./runtime-contexts.mjs";
|
|
9
|
+
|
|
10
|
+
export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {} }) {
|
|
11
|
+
const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
|
|
12
|
+
const pools = new Map();
|
|
13
|
+
const locks = new Map();
|
|
14
|
+
let nextLeaseCounter = 1;
|
|
15
|
+
const runtimeHooks = {
|
|
16
|
+
cleanupRuntimeInstanceContext,
|
|
17
|
+
createRuntimeInstanceContext,
|
|
18
|
+
ensureRuntimeInstanceReady,
|
|
19
|
+
sleep,
|
|
20
|
+
...hooks,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
canAcquire(task) {
|
|
25
|
+
const pool = getPool(pools, graphByKey, task, productDir, lifecycle);
|
|
26
|
+
if (!locksAvailable(locks, task.locks || [])) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return claimableRuntimeSlot(pool, task) !== null;
|
|
30
|
+
},
|
|
31
|
+
async acquire(task) {
|
|
32
|
+
const pool = getPool(pools, graphByKey, task, productDir, lifecycle);
|
|
33
|
+
if (lifecycle.isStopRequested()) {
|
|
34
|
+
throw lifecycle.signal.reason || new Error("testkit run interrupted");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const leaseId = `lease-${task.id}-${nextLeaseCounter}`;
|
|
38
|
+
nextLeaseCounter += 1;
|
|
39
|
+
|
|
40
|
+
if (!tryAcquireLocks(locks, task.locks || [], leaseId)) {
|
|
41
|
+
throw new Error(`Task ${task.id} was claimed before its locks were available`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const slot = claimRuntimeSlot(pool, task);
|
|
45
|
+
if (!slot) {
|
|
46
|
+
releaseLocks(locks, task.locks || [], leaseId);
|
|
47
|
+
throw new Error(`Task ${task.id} was claimed before runtime capacity was available`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const context = await getReadyContext(slot, productDir, task, lifecycle, runtimeHooks);
|
|
52
|
+
const leaseDir = path.join(context.runtimeDir, "leases", leaseId);
|
|
53
|
+
fs.mkdirSync(leaseDir, { recursive: true });
|
|
54
|
+
return {
|
|
55
|
+
leaseId,
|
|
56
|
+
leaseDir,
|
|
57
|
+
lockNames: task.locks || [],
|
|
58
|
+
resourceCost: task.resourceCost || 1,
|
|
59
|
+
slot,
|
|
60
|
+
context,
|
|
61
|
+
};
|
|
62
|
+
} catch (error) {
|
|
63
|
+
releaseRuntimeSlot(slot, task);
|
|
64
|
+
releaseLocks(locks, task.locks || [], leaseId);
|
|
65
|
+
cleanupLeaseDir({ leaseDir: path.join(slot.context?.runtimeDir || productDir, "leases", leaseId) });
|
|
66
|
+
await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
async release(lease, options = {}) {
|
|
71
|
+
if (!lease?.slot) return;
|
|
72
|
+
releaseLocks(locks, lease.lockNames || [], lease.leaseId);
|
|
73
|
+
releaseRuntimeSlot(lease.slot, { resourceCost: lease.resourceCost || 1 });
|
|
74
|
+
cleanupLeaseDir(lease);
|
|
75
|
+
if (options.invalidate) {
|
|
76
|
+
lease.slot.draining = true;
|
|
77
|
+
}
|
|
78
|
+
if (lease.slot.draining && lease.slot.activeLeaseCount === 0) {
|
|
79
|
+
await drainRuntimeSlot(lease.slot, lifecycle, runtimeHooks);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
async cleanupAll() {
|
|
83
|
+
for (const pool of pools.values()) {
|
|
84
|
+
for (const slot of pool.slots) {
|
|
85
|
+
slot.draining = true;
|
|
86
|
+
await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
getStats() {
|
|
91
|
+
return [...pools.values()]
|
|
92
|
+
.map((pool) => ({
|
|
93
|
+
graphKey: pool.slots[0]?.graph.key || null,
|
|
94
|
+
targetNames: [...(pool.slots[0]?.graph.targetNames || [])],
|
|
95
|
+
maxConcurrentTasks: Number.isFinite(pool.slots[0]?.graph.maxConcurrentTasks)
|
|
96
|
+
? pool.slots[0]?.graph.maxConcurrentTasks
|
|
97
|
+
: null,
|
|
98
|
+
runtimeCount: pool.slots.length,
|
|
99
|
+
runtimes: pool.slots.map((slot) => ({
|
|
100
|
+
runtimeId: slot.runtimeId,
|
|
101
|
+
peakLeaseCount: slot.peakLeaseCount,
|
|
102
|
+
peakResourceUnits: slot.peakResourceUnits,
|
|
103
|
+
})),
|
|
104
|
+
}))
|
|
105
|
+
.sort((left, right) => String(left.graphKey).localeCompare(String(right.graphKey)));
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getPool(pools, graphByKey, task, productDir, lifecycle) {
|
|
111
|
+
const existing = pools.get(task.graphKey);
|
|
112
|
+
if (existing) return existing;
|
|
113
|
+
|
|
114
|
+
const graph = graphByKey.get(task.graphKey);
|
|
115
|
+
if (!graph) {
|
|
116
|
+
throw new Error(`Unknown graph "${task.graphKey}"`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const pool = {
|
|
120
|
+
productDir,
|
|
121
|
+
lifecycle,
|
|
122
|
+
slots: buildRuntimeIds(graph.instanceCount).map((runtimeId) => ({
|
|
123
|
+
graph,
|
|
124
|
+
runtimeId,
|
|
125
|
+
context: null,
|
|
126
|
+
contextPromise: null,
|
|
127
|
+
activeLeaseCount: 0,
|
|
128
|
+
activeResourceUnits: 0,
|
|
129
|
+
peakLeaseCount: 0,
|
|
130
|
+
peakResourceUnits: 0,
|
|
131
|
+
draining: false,
|
|
132
|
+
})),
|
|
133
|
+
};
|
|
134
|
+
pools.set(task.graphKey, pool);
|
|
135
|
+
return pool;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function claimRuntimeSlot(pool, task) {
|
|
139
|
+
const resourceCost = task.resourceCost || 1;
|
|
140
|
+
const available = pool.slots.filter((slot) => !slot.draining);
|
|
141
|
+
if (available.length === 0) return null;
|
|
142
|
+
|
|
143
|
+
const slot = [...available]
|
|
144
|
+
.filter((candidate) => slotHasCapacity(candidate, resourceCost))
|
|
145
|
+
.sort(
|
|
146
|
+
(left, right) =>
|
|
147
|
+
left.activeResourceUnits - right.activeResourceUnits ||
|
|
148
|
+
left.activeLeaseCount - right.activeLeaseCount ||
|
|
149
|
+
left.runtimeId.localeCompare(right.runtimeId)
|
|
150
|
+
)[0];
|
|
151
|
+
if (!slot) return null;
|
|
152
|
+
slot.activeLeaseCount += 1;
|
|
153
|
+
slot.activeResourceUnits += resourceCost;
|
|
154
|
+
slot.peakLeaseCount = Math.max(slot.peakLeaseCount, slot.activeLeaseCount);
|
|
155
|
+
slot.peakResourceUnits = Math.max(slot.peakResourceUnits, slot.activeResourceUnits);
|
|
156
|
+
return slot;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function releaseRuntimeSlot(slot, task = {}) {
|
|
160
|
+
slot.activeLeaseCount = Math.max(0, slot.activeLeaseCount - 1);
|
|
161
|
+
slot.activeResourceUnits = Math.max(0, slot.activeResourceUnits - (task.resourceCost || 1));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function getReadyContext(slot, productDir, task, lifecycle, runtimeHooks) {
|
|
165
|
+
if (!slot.context) {
|
|
166
|
+
slot.context = runtimeHooks.createRuntimeInstanceContext(slot.runtimeId, slot.graph, productDir);
|
|
167
|
+
lifecycle.trackGraphContext(slot.context);
|
|
168
|
+
}
|
|
169
|
+
if (!slot.contextPromise) {
|
|
170
|
+
slot.contextPromise = Promise.resolve(slot.context);
|
|
171
|
+
}
|
|
172
|
+
await slot.contextPromise;
|
|
173
|
+
await runtimeHooks.ensureRuntimeInstanceReady(slot.context, task, lifecycle);
|
|
174
|
+
return slot.context;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function drainRuntimeSlot(slot, lifecycle, runtimeHooks) {
|
|
178
|
+
if (!slot.context || slot.activeLeaseCount > 0) return;
|
|
179
|
+
await runtimeHooks.cleanupRuntimeInstanceContext(slot.context, lifecycle);
|
|
180
|
+
slot.context = null;
|
|
181
|
+
slot.contextPromise = null;
|
|
182
|
+
slot.draining = false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function tryAcquireLocks(lockMap, lockNames, leaseId) {
|
|
186
|
+
const uniqueLocks = [...new Set(lockNames)].sort();
|
|
187
|
+
for (const lockName of uniqueLocks) {
|
|
188
|
+
if (lockMap.has(lockName)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for (const lockName of uniqueLocks) {
|
|
193
|
+
lockMap.set(lockName, leaseId);
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function locksAvailable(lockMap, lockNames) {
|
|
199
|
+
return [...new Set(lockNames)].every((lockName) => !lockMap.has(lockName));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function claimableRuntimeSlot(pool, task) {
|
|
203
|
+
const resourceCost = task.resourceCost || 1;
|
|
204
|
+
return (
|
|
205
|
+
pool.slots.find((slot) => !slot.draining && slotHasCapacity(slot, resourceCost)) || null
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function slotHasCapacity(slot, resourceCost) {
|
|
210
|
+
return slot.activeResourceUnits + resourceCost <= slot.graph.maxConcurrentTasks;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function releaseLocks(lockMap, lockNames, leaseId) {
|
|
214
|
+
for (const lockName of [...new Set(lockNames)].sort()) {
|
|
215
|
+
if (lockMap.get(lockName) === leaseId) {
|
|
216
|
+
lockMap.delete(lockName);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sleep(ms) {
|
|
222
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function cleanupLeaseDir(lease) {
|
|
226
|
+
if (!lease?.leaseDir) return;
|
|
227
|
+
fs.rmSync(lease.leaseDir, { recursive: true, force: true });
|
|
228
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { createRuntimeManager } from "./runtime-manager.mjs";
|
|
6
|
+
|
|
7
|
+
const cleanups = [];
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
while (cleanups.length > 0) {
|
|
11
|
+
cleanups.pop()();
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function makeTempDir(prefix) {
|
|
16
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
17
|
+
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeLifecycle() {
|
|
22
|
+
return {
|
|
23
|
+
signal: new AbortController().signal,
|
|
24
|
+
isStopRequested() {
|
|
25
|
+
return false;
|
|
26
|
+
},
|
|
27
|
+
trackGraphContext() {},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeTask(id, extras = {}) {
|
|
32
|
+
return {
|
|
33
|
+
id,
|
|
34
|
+
graphKey: "api",
|
|
35
|
+
locks: [],
|
|
36
|
+
...extras,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeHooks(events) {
|
|
41
|
+
return {
|
|
42
|
+
createRuntimeInstanceContext(runtimeId, graph, productDir) {
|
|
43
|
+
const runtimeDir = path.join(productDir, ".testkit", "_graphs", graph.dirName, "runtimes", runtimeId);
|
|
44
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
45
|
+
events.created.push(runtimeId);
|
|
46
|
+
return {
|
|
47
|
+
graphKey: graph.key,
|
|
48
|
+
runtimeDir,
|
|
49
|
+
runtimeId,
|
|
50
|
+
targetNames: [...graph.targetNames],
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
async ensureRuntimeInstanceReady(context) {
|
|
54
|
+
events.ready.push(context.runtimeId);
|
|
55
|
+
},
|
|
56
|
+
async cleanupRuntimeInstanceContext(context) {
|
|
57
|
+
events.cleaned.push(context.runtimeId);
|
|
58
|
+
fs.rmSync(context.runtimeDir, { recursive: true, force: true });
|
|
59
|
+
},
|
|
60
|
+
async sleep() {
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("runtime-manager", () => {
|
|
67
|
+
it("spreads concurrent leases across runtime instances", async () => {
|
|
68
|
+
const productDir = makeTempDir("testkit-runtime-manager-");
|
|
69
|
+
const events = { created: [], ready: [], cleaned: [] };
|
|
70
|
+
const manager = createRuntimeManager({
|
|
71
|
+
productDir,
|
|
72
|
+
lifecycle: makeLifecycle(),
|
|
73
|
+
graphs: [
|
|
74
|
+
{
|
|
75
|
+
key: "api",
|
|
76
|
+
dirName: "api",
|
|
77
|
+
targetNames: ["api"],
|
|
78
|
+
instanceCount: 2,
|
|
79
|
+
maxConcurrentTasks: 1,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
hooks: makeHooks(events),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const leaseOne = await manager.acquire(makeTask(1));
|
|
86
|
+
const leaseTwo = await manager.acquire(makeTask(2));
|
|
87
|
+
|
|
88
|
+
expect(leaseOne.slot.runtimeId).toBe("runtime-1");
|
|
89
|
+
expect(leaseTwo.slot.runtimeId).toBe("runtime-2");
|
|
90
|
+
expect(events.created).toEqual(["runtime-1", "runtime-2"]);
|
|
91
|
+
|
|
92
|
+
await manager.release(leaseOne);
|
|
93
|
+
await manager.release(leaseTwo);
|
|
94
|
+
await manager.cleanupAll();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("marks conflicting locks unavailable until the first lease releases", async () => {
|
|
98
|
+
const productDir = makeTempDir("testkit-runtime-manager-");
|
|
99
|
+
const manager = createRuntimeManager({
|
|
100
|
+
productDir,
|
|
101
|
+
lifecycle: makeLifecycle(),
|
|
102
|
+
graphs: [
|
|
103
|
+
{
|
|
104
|
+
key: "api",
|
|
105
|
+
dirName: "api",
|
|
106
|
+
targetNames: ["api"],
|
|
107
|
+
instanceCount: 1,
|
|
108
|
+
maxConcurrentTasks: 1,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const firstLease = await manager.acquire(makeTask(1, { locks: ["shared-lock"] }));
|
|
115
|
+
expect(manager.canAcquire(makeTask(2, { locks: ["shared-lock"] }))).toBe(false);
|
|
116
|
+
|
|
117
|
+
await manager.release(firstLease);
|
|
118
|
+
const secondLease = await manager.acquire(makeTask(2, { locks: ["shared-lock"] }));
|
|
119
|
+
|
|
120
|
+
await manager.release(secondLease);
|
|
121
|
+
await manager.cleanupAll();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("does not drain an invalidated runtime slot until all active leases release", async () => {
|
|
125
|
+
const productDir = makeTempDir("testkit-runtime-manager-");
|
|
126
|
+
const events = { created: [], ready: [], cleaned: [] };
|
|
127
|
+
const manager = createRuntimeManager({
|
|
128
|
+
productDir,
|
|
129
|
+
lifecycle: makeLifecycle(),
|
|
130
|
+
graphs: [
|
|
131
|
+
{
|
|
132
|
+
key: "api",
|
|
133
|
+
dirName: "api",
|
|
134
|
+
targetNames: ["api"],
|
|
135
|
+
instanceCount: 1,
|
|
136
|
+
maxConcurrentTasks: 2,
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
hooks: makeHooks(events),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const leaseOne = await manager.acquire(makeTask(1));
|
|
143
|
+
const leaseTwo = await manager.acquire(makeTask(2));
|
|
144
|
+
|
|
145
|
+
await manager.release(leaseOne, { invalidate: true });
|
|
146
|
+
expect(events.cleaned).toEqual([]);
|
|
147
|
+
|
|
148
|
+
await manager.release(leaseTwo);
|
|
149
|
+
expect(events.cleaned).toEqual(["runtime-1"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("removes lease directories on release", async () => {
|
|
153
|
+
const productDir = makeTempDir("testkit-runtime-manager-");
|
|
154
|
+
const manager = createRuntimeManager({
|
|
155
|
+
productDir,
|
|
156
|
+
lifecycle: makeLifecycle(),
|
|
157
|
+
graphs: [
|
|
158
|
+
{
|
|
159
|
+
key: "api",
|
|
160
|
+
dirName: "api",
|
|
161
|
+
targetNames: ["api"],
|
|
162
|
+
instanceCount: 1,
|
|
163
|
+
maxConcurrentTasks: 1,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const lease = await manager.acquire(makeTask(1));
|
|
170
|
+
expect(fs.existsSync(lease.leaseDir)).toBe(true);
|
|
171
|
+
|
|
172
|
+
await manager.release(lease);
|
|
173
|
+
expect(fs.existsSync(lease.leaseDir)).toBe(false);
|
|
174
|
+
|
|
175
|
+
await manager.cleanupAll();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("exposes runtime capacity through canAcquire", async () => {
|
|
179
|
+
const productDir = makeTempDir("testkit-runtime-manager-");
|
|
180
|
+
const manager = createRuntimeManager({
|
|
181
|
+
productDir,
|
|
182
|
+
lifecycle: makeLifecycle(),
|
|
183
|
+
graphs: [
|
|
184
|
+
{
|
|
185
|
+
key: "api",
|
|
186
|
+
dirName: "api",
|
|
187
|
+
targetNames: ["api"],
|
|
188
|
+
instanceCount: 1,
|
|
189
|
+
maxConcurrentTasks: 2,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(manager.canAcquire(makeTask(1))).toBe(true);
|
|
196
|
+
const leaseOne = await manager.acquire(makeTask(1));
|
|
197
|
+
expect(manager.canAcquire(makeTask(2))).toBe(true);
|
|
198
|
+
const leaseTwo = await manager.acquire(makeTask(2));
|
|
199
|
+
expect(manager.canAcquire(makeTask(3))).toBe(false);
|
|
200
|
+
|
|
201
|
+
await manager.release(leaseOne);
|
|
202
|
+
expect(manager.canAcquire(makeTask(3))).toBe(true);
|
|
203
|
+
await manager.release(leaseTwo);
|
|
204
|
+
await manager.cleanupAll();
|
|
205
|
+
});
|
|
206
|
+
});
|
package/lib/runner/services.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolveServiceCwd } from "../config/index.mjs";
|
|
2
2
|
import { buildExecutionEnv, numericPortFromUrl } from "./template.mjs";
|
|
3
3
|
import { DEFAULT_READY_TIMEOUT_MS, assertLocalServicePortsAvailable, isPortInUse, waitForReady } from "./readiness.mjs";
|
|
4
|
-
import { pipeOutput, startDetachedCommand, stopChildProcess, sleep } from "./processes.mjs";
|
|
4
|
+
import { killChildProcess, pipeOutput, startDetachedCommand, stopChildProcess, sleep } from "./processes.mjs";
|
|
5
5
|
import { readDatabaseUrl } from "./state-io.mjs";
|
|
6
6
|
|
|
7
7
|
export async function startLocalServices(runtimeConfigs, lifecycle) {
|
|
@@ -36,20 +36,22 @@ export async function startLocalService(config, lifecycle) {
|
|
|
36
36
|
|
|
37
37
|
await assertLocalServicePortsAvailable(config, isPortInUse);
|
|
38
38
|
|
|
39
|
-
console.log(`Starting ${config.
|
|
39
|
+
console.log(`Starting ${config.runtimeLabel}:${config.name}: ${config.testkit.local.start}`);
|
|
40
40
|
const child = startDetachedCommand(config.testkit.local.start, cwd, env);
|
|
41
41
|
|
|
42
42
|
const outputDrains = [
|
|
43
|
-
pipeOutput(child.stdout, `[${config.
|
|
44
|
-
pipeOutput(child.stderr, `[${config.
|
|
43
|
+
pipeOutput(child.stdout, `[${config.runtimeLabel}:${config.name}]`),
|
|
44
|
+
pipeOutput(child.stderr, `[${config.runtimeLabel}:${config.name}]`),
|
|
45
45
|
];
|
|
46
|
-
lifecycle.registerService(config, child, cwd)
|
|
46
|
+
lifecycle.registerService(config, child, cwd, () => {
|
|
47
|
+
killChildProcess(child, "SIGTERM");
|
|
48
|
+
});
|
|
47
49
|
|
|
48
50
|
const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
|
|
49
51
|
|
|
50
52
|
try {
|
|
51
53
|
await waitForReady({
|
|
52
|
-
name: `${config.
|
|
54
|
+
name: `${config.runtimeLabel}:${config.name}`,
|
|
53
55
|
url: config.testkit.local.readyUrl,
|
|
54
56
|
timeoutMs: readyTimeoutMs,
|
|
55
57
|
process: child,
|
package/lib/runner/state.mjs
CHANGED
|
@@ -46,8 +46,7 @@ export function writeGraphMetadata(graphDir, graph) {
|
|
|
46
46
|
fs.mkdirSync(graphDir, { recursive: true });
|
|
47
47
|
const metadata = {
|
|
48
48
|
runtimeServices: graph.runtimeNames,
|
|
49
|
-
|
|
50
|
-
rootService: graph.rootConfig.name,
|
|
49
|
+
targetServices: [...(graph.targetNames || [])].sort(),
|
|
51
50
|
};
|
|
52
51
|
fs.writeFileSync(
|
|
53
52
|
path.join(graphDir, GRAPH_METADATA),
|
|
@@ -48,14 +48,12 @@ describe("runner-state", () => {
|
|
|
48
48
|
const graphDir = path.join(productDir, ".testkit", "_graphs", "api__frontend");
|
|
49
49
|
writeGraphMetadata(graphDir, {
|
|
50
50
|
runtimeNames: ["api", "frontend"],
|
|
51
|
-
|
|
52
|
-
rootConfig: { name: "api" },
|
|
51
|
+
targetNames: ["frontend", "api"],
|
|
53
52
|
});
|
|
54
53
|
|
|
55
54
|
expect(readGraphMetadata(graphDir)).toEqual({
|
|
56
55
|
runtimeServices: ["api", "frontend"],
|
|
57
|
-
|
|
58
|
-
rootService: "api",
|
|
56
|
+
targetServices: ["api", "frontend"],
|
|
59
57
|
});
|
|
60
58
|
|
|
61
59
|
expect(findGraphDirsForService(productDir, "frontend")).toEqual([graphDir]);
|