@elench/testkit 0.1.40 → 0.1.41
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 +25 -13
- package/lib/cli/args.mjs +0 -4
- package/lib/cli/args.test.mjs +0 -5
- package/lib/cli/index.mjs +0 -9
- package/lib/config/index.mjs +67 -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 +31 -53
- package/lib/runner/execution-config.mjs +14 -70
- package/lib/runner/execution-config.test.mjs +22 -74
- package/lib/runner/formatting.mjs +0 -15
- package/lib/runner/formatting.test.mjs +0 -18
- package/lib/runner/lifecycle.mjs +7 -7
- package/lib/runner/orchestrator.mjs +9 -10
- package/lib/runner/planning.mjs +42 -136
- package/lib/runner/planning.test.mjs +70 -174
- package/lib/runner/playwright-config.mjs +8 -2
- package/lib/runner/playwright-config.test.mjs +20 -5
- package/lib/runner/playwright-runner.mjs +32 -54
- package/lib/runner/readiness.mjs +2 -2
- package/lib/runner/reporting.mjs +2 -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 +181 -0
- package/lib/runner/runtime-manager.test.mjs +181 -0
- package/lib/runner/services.mjs +4 -4
- 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 +29 -32
- package/lib/setup/index.d.ts +14 -10
- package/package.json +1 -1
- package/lib/runner/stack-manager.mjs +0 -146
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { execa } from "execa";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
} from "../
|
|
6
|
-
import {
|
|
7
|
-
import { formatPlaywrightBatchFiles } from "./formatting.mjs";
|
|
8
|
-
import { printBufferedOutput } from "./processes.mjs";
|
|
3
|
+
import { parsePlaywrightJsonResults } from "../reporters/playwright.mjs";
|
|
4
|
+
import { resolveServiceCwd } from "../config/index.mjs";
|
|
5
|
+
import { formatFileTimeoutBudgetError } from "../shared/file-timeout.mjs";
|
|
6
|
+
import { settleSubprocess } from "./default-runtime-runner.mjs";
|
|
9
7
|
import { ensurePlaywrightTestConfig } from "./playwright-config.mjs";
|
|
10
|
-
import {
|
|
8
|
+
import { printBufferedOutput } from "./processes.mjs";
|
|
11
9
|
import { normalizePathSeparators } from "./state.mjs";
|
|
10
|
+
import { buildPlaywrightEnv } from "./template.mjs";
|
|
12
11
|
|
|
13
|
-
export async function
|
|
12
|
+
export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
|
|
14
13
|
const local = targetConfig.testkit.local;
|
|
15
14
|
if (!local?.baseUrl) {
|
|
16
15
|
throw new Error(
|
|
@@ -18,69 +17,48 @@ export async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
|
|
|
18
17
|
);
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
console.log(
|
|
22
|
-
`\n── ${targetConfig.stackLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"})${formatPlaywrightBatchFiles(batch)} ──`
|
|
23
|
-
);
|
|
20
|
+
console.log(`\n── ${targetConfig.runtimeLabel} playwright:${targetConfig.name} (${task.file}) ──`);
|
|
24
21
|
|
|
25
22
|
const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
);
|
|
29
|
-
const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles);
|
|
23
|
+
const requestedFile = path.relative(cwd, path.join(targetConfig.productDir, task.file));
|
|
24
|
+
const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, [requestedFile], lease);
|
|
30
25
|
const startedAt = Date.now();
|
|
31
|
-
const
|
|
26
|
+
const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
|
|
27
|
+
const subprocess = execa(
|
|
32
28
|
"npx",
|
|
33
29
|
["playwright", "test", "--config", playwrightConfigPath, "--reporter=json"],
|
|
34
30
|
{
|
|
35
31
|
cwd,
|
|
36
|
-
env: buildPlaywrightEnv(targetConfig, local.baseUrl, process.env),
|
|
32
|
+
env: buildPlaywrightEnv(targetConfig, local.baseUrl, lease, process.env),
|
|
37
33
|
reject: false,
|
|
38
34
|
cancelSignal: lifecycle.signal,
|
|
39
35
|
forceKillAfterDelay: 5_000,
|
|
40
36
|
}
|
|
41
37
|
);
|
|
38
|
+
const { result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds);
|
|
42
39
|
|
|
43
40
|
if (result.stderr) {
|
|
44
|
-
printBufferedOutput(result.stderr, `[${targetConfig.
|
|
41
|
+
printBufferedOutput(result.stderr, `[${targetConfig.runtimeLabel}:${targetConfig.name}:playwright]`);
|
|
45
42
|
}
|
|
46
43
|
|
|
47
|
-
const parsed = parsePlaywrightJsonResults(result.stdout, cwd);
|
|
44
|
+
const parsed = parsePlaywrightJsonResults(result.stdout || "", cwd);
|
|
48
45
|
const finishedAt = Date.now();
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
46
|
+
const durationMs = finishedAt - startedAt;
|
|
47
|
+
const relativeFile = normalizePathSeparators(requestedFile);
|
|
48
|
+
const fileResult = parsed.fileResults.get(relativeFile);
|
|
49
|
+
const genericError = timedOut
|
|
50
|
+
? formatFileTimeoutBudgetError(fileTimeoutSeconds)
|
|
51
|
+
: result.exitCode === 0
|
|
52
52
|
? parsed.errors[0] || null
|
|
53
|
-
: parsed.errors[0] ||
|
|
54
|
-
result.stderr.trim() ||
|
|
55
|
-
`Playwright exited with code ${result.exitCode}`;
|
|
56
|
-
|
|
57
|
-
return batch.tasks.map((task) => {
|
|
58
|
-
const relativeFile = normalizePathSeparators(
|
|
59
|
-
path.relative(cwd, path.join(targetConfig.productDir, task.file))
|
|
60
|
-
);
|
|
61
|
-
const fileResult = parsed.fileResults.get(relativeFile);
|
|
62
|
-
if (fileResult) {
|
|
63
|
-
return {
|
|
64
|
-
task,
|
|
65
|
-
failed: fileResult.status === "failed",
|
|
66
|
-
status: fileResult.status,
|
|
67
|
-
error: fileResult.error,
|
|
68
|
-
durationMs:
|
|
69
|
-
fileResult.durationMs > 0
|
|
70
|
-
? fileResult.durationMs
|
|
71
|
-
: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
|
|
72
|
-
startedAt,
|
|
73
|
-
finishedAt,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
53
|
+
: parsed.errors[0] || result.stderr.trim() || `Playwright exited with code ${result.exitCode}`;
|
|
76
54
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
55
|
+
return {
|
|
56
|
+
task,
|
|
57
|
+
failed: timedOut ? true : fileResult ? fileResult.status === "failed" : result.exitCode !== 0,
|
|
58
|
+
status: timedOut ? "failed" : fileResult?.status || (result.exitCode === 0 ? "passed" : "failed"),
|
|
59
|
+
error: timedOut ? genericError : fileResult?.error || genericError,
|
|
60
|
+
durationMs: fileResult?.durationMs > 0 ? fileResult.durationMs : durationMs,
|
|
61
|
+
startedAt,
|
|
62
|
+
finishedAt,
|
|
63
|
+
};
|
|
86
64
|
}
|
package/lib/runner/readiness.mjs
CHANGED
|
@@ -54,11 +54,11 @@ export async function assertLocalServicePortsAvailable(config, isPortInUse) {
|
|
|
54
54
|
const owner = findPortOwner(config.productDir, socket);
|
|
55
55
|
const ownerDetail = owner
|
|
56
56
|
? owner.active
|
|
57
|
-
? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.
|
|
57
|
+
? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.runtimeLabel}:${owner.service.serviceName}.`
|
|
58
58
|
: ` Stale testkit run ${formatRunSummary(owner.manifest)} owns ${key}.`
|
|
59
59
|
: "";
|
|
60
60
|
throw new Error(
|
|
61
|
-
`Cannot start "${config.
|
|
61
|
+
`Cannot start "${config.runtimeLabel}:${config.name}" because ${key} is already in use. ` +
|
|
62
62
|
`Stop the existing process and rerun testkit.${ownerDetail}`
|
|
63
63
|
);
|
|
64
64
|
}
|
package/lib/runner/reporting.mjs
CHANGED
|
@@ -96,7 +96,7 @@ export function buildRunArtifact({
|
|
|
96
96
|
finishedAt,
|
|
97
97
|
execution,
|
|
98
98
|
workerCount,
|
|
99
|
-
|
|
99
|
+
runtimeInstanceCount,
|
|
100
100
|
typeValues,
|
|
101
101
|
suiteSelectors,
|
|
102
102
|
fileNames,
|
|
@@ -137,8 +137,7 @@ export function buildRunArtifact({
|
|
|
137
137
|
workers: execution.workers,
|
|
138
138
|
fileTimeoutSeconds: execution.fileTimeoutSeconds,
|
|
139
139
|
workerCount,
|
|
140
|
-
|
|
141
|
-
stackCount,
|
|
140
|
+
runtimeInstanceCount,
|
|
142
141
|
dbBackend,
|
|
143
142
|
types: typeValues,
|
|
144
143
|
suiteSelectors: suiteSelectors.map((selector) => selector.raw),
|
|
@@ -54,11 +54,9 @@ describe("runner reporting", () => {
|
|
|
54
54
|
execution: {
|
|
55
55
|
workers: 2,
|
|
56
56
|
fileTimeoutSeconds: 60,
|
|
57
|
-
stackMode: "shared",
|
|
58
|
-
stackCount: 1,
|
|
59
57
|
},
|
|
60
58
|
workerCount: 1,
|
|
61
|
-
|
|
59
|
+
runtimeInstanceCount: 2,
|
|
62
60
|
typeValues: ["all"],
|
|
63
61
|
suiteSelectors: [],
|
|
64
62
|
fileNames: [],
|
|
@@ -85,8 +83,7 @@ describe("runner reporting", () => {
|
|
|
85
83
|
workers: 2,
|
|
86
84
|
fileTimeoutSeconds: 60,
|
|
87
85
|
workerCount: 1,
|
|
88
|
-
|
|
89
|
-
stackCount: 1,
|
|
86
|
+
runtimeInstanceCount: 2,
|
|
90
87
|
});
|
|
91
88
|
expect(artifact.summary.services).toEqual({
|
|
92
89
|
total: 1,
|
package/lib/runner/results.mjs
CHANGED
|
@@ -142,7 +142,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
export function recordGraphError(trackers, graph, message, now = Date.now()) {
|
|
145
|
-
const targetNames = graph?.
|
|
145
|
+
const targetNames = graph?.targetNames || [];
|
|
146
146
|
for (const targetName of targetNames) {
|
|
147
147
|
const tracker = trackers.get(targetName);
|
|
148
148
|
if (tracker && !tracker.skipped) {
|
|
@@ -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,181 @@
|
|
|
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
|
+
async acquire(task) {
|
|
25
|
+
const pool = getPool(pools, graphByKey, task, productDir, lifecycle);
|
|
26
|
+
|
|
27
|
+
while (true) {
|
|
28
|
+
if (lifecycle.isStopRequested()) {
|
|
29
|
+
throw lifecycle.signal.reason || new Error("testkit run interrupted");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const leaseId = `lease-${task.id}-${nextLeaseCounter}`;
|
|
33
|
+
nextLeaseCounter += 1;
|
|
34
|
+
|
|
35
|
+
if (!tryAcquireLocks(locks, task.locks || [], leaseId)) {
|
|
36
|
+
await runtimeHooks.sleep(10);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const slot = claimRuntimeSlot(pool);
|
|
41
|
+
if (!slot) {
|
|
42
|
+
releaseLocks(locks, task.locks || [], leaseId);
|
|
43
|
+
await runtimeHooks.sleep(10);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const context = await getReadyContext(slot, productDir, task, lifecycle, runtimeHooks);
|
|
49
|
+
const leaseDir = path.join(context.runtimeDir, "leases", leaseId);
|
|
50
|
+
fs.mkdirSync(leaseDir, { recursive: true });
|
|
51
|
+
return {
|
|
52
|
+
leaseId,
|
|
53
|
+
leaseDir,
|
|
54
|
+
lockNames: task.locks || [],
|
|
55
|
+
slot,
|
|
56
|
+
context,
|
|
57
|
+
};
|
|
58
|
+
} catch (error) {
|
|
59
|
+
releaseRuntimeSlot(slot);
|
|
60
|
+
releaseLocks(locks, task.locks || [], leaseId);
|
|
61
|
+
cleanupLeaseDir({ leaseDir: path.join(slot.context?.runtimeDir || productDir, "leases", leaseId) });
|
|
62
|
+
await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async release(lease, options = {}) {
|
|
68
|
+
if (!lease?.slot) return;
|
|
69
|
+
releaseLocks(locks, lease.lockNames || [], lease.leaseId);
|
|
70
|
+
releaseRuntimeSlot(lease.slot);
|
|
71
|
+
cleanupLeaseDir(lease);
|
|
72
|
+
if (options.invalidate) {
|
|
73
|
+
lease.slot.draining = true;
|
|
74
|
+
}
|
|
75
|
+
if (lease.slot.draining && lease.slot.activeLeaseCount === 0) {
|
|
76
|
+
await drainRuntimeSlot(lease.slot, lifecycle, runtimeHooks);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async cleanupAll() {
|
|
80
|
+
for (const pool of pools.values()) {
|
|
81
|
+
for (const slot of pool.slots) {
|
|
82
|
+
slot.draining = true;
|
|
83
|
+
await drainRuntimeSlot(slot, lifecycle, runtimeHooks);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getPool(pools, graphByKey, task, productDir, lifecycle) {
|
|
91
|
+
const existing = pools.get(task.graphKey);
|
|
92
|
+
if (existing) return existing;
|
|
93
|
+
|
|
94
|
+
const graph = graphByKey.get(task.graphKey);
|
|
95
|
+
if (!graph) {
|
|
96
|
+
throw new Error(`Unknown graph "${task.graphKey}"`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const pool = {
|
|
100
|
+
productDir,
|
|
101
|
+
lifecycle,
|
|
102
|
+
slots: buildRuntimeIds(graph.instanceCount).map((runtimeId) => ({
|
|
103
|
+
graph,
|
|
104
|
+
runtimeId,
|
|
105
|
+
context: null,
|
|
106
|
+
contextPromise: null,
|
|
107
|
+
activeLeaseCount: 0,
|
|
108
|
+
draining: false,
|
|
109
|
+
})),
|
|
110
|
+
};
|
|
111
|
+
pools.set(task.graphKey, pool);
|
|
112
|
+
return pool;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function claimRuntimeSlot(pool) {
|
|
116
|
+
const available = pool.slots.filter((slot) => !slot.draining);
|
|
117
|
+
if (available.length === 0) return null;
|
|
118
|
+
|
|
119
|
+
const slot = [...available].sort(
|
|
120
|
+
(left, right) =>
|
|
121
|
+
left.activeLeaseCount - right.activeLeaseCount ||
|
|
122
|
+
left.runtimeId.localeCompare(right.runtimeId)
|
|
123
|
+
)[0];
|
|
124
|
+
slot.activeLeaseCount += 1;
|
|
125
|
+
return slot;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function releaseRuntimeSlot(slot) {
|
|
129
|
+
slot.activeLeaseCount = Math.max(0, slot.activeLeaseCount - 1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function getReadyContext(slot, productDir, task, lifecycle, runtimeHooks) {
|
|
133
|
+
if (!slot.context) {
|
|
134
|
+
slot.context = runtimeHooks.createRuntimeInstanceContext(slot.runtimeId, slot.graph, productDir);
|
|
135
|
+
lifecycle.trackGraphContext(slot.context);
|
|
136
|
+
}
|
|
137
|
+
if (!slot.contextPromise) {
|
|
138
|
+
slot.contextPromise = Promise.resolve(slot.context);
|
|
139
|
+
}
|
|
140
|
+
await slot.contextPromise;
|
|
141
|
+
await runtimeHooks.ensureRuntimeInstanceReady(slot.context, task, lifecycle);
|
|
142
|
+
return slot.context;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function drainRuntimeSlot(slot, lifecycle, runtimeHooks) {
|
|
146
|
+
if (!slot.context || slot.activeLeaseCount > 0) return;
|
|
147
|
+
await runtimeHooks.cleanupRuntimeInstanceContext(slot.context, lifecycle);
|
|
148
|
+
slot.context = null;
|
|
149
|
+
slot.contextPromise = null;
|
|
150
|
+
slot.draining = false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function tryAcquireLocks(lockMap, lockNames, leaseId) {
|
|
154
|
+
const uniqueLocks = [...new Set(lockNames)].sort();
|
|
155
|
+
for (const lockName of uniqueLocks) {
|
|
156
|
+
if (lockMap.has(lockName)) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const lockName of uniqueLocks) {
|
|
161
|
+
lockMap.set(lockName, leaseId);
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function releaseLocks(lockMap, lockNames, leaseId) {
|
|
167
|
+
for (const lockName of [...new Set(lockNames)].sort()) {
|
|
168
|
+
if (lockMap.get(lockName) === leaseId) {
|
|
169
|
+
lockMap.delete(lockName);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sleep(ms) {
|
|
175
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function cleanupLeaseDir(lease) {
|
|
179
|
+
if (!lease?.leaseDir) return;
|
|
180
|
+
fs.rmSync(lease.leaseDir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
},
|
|
80
|
+
],
|
|
81
|
+
hooks: makeHooks(events),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const leaseOne = await manager.acquire(makeTask(1));
|
|
85
|
+
const leaseTwo = await manager.acquire(makeTask(2));
|
|
86
|
+
|
|
87
|
+
expect(leaseOne.slot.runtimeId).toBe("runtime-1");
|
|
88
|
+
expect(leaseTwo.slot.runtimeId).toBe("runtime-2");
|
|
89
|
+
expect(events.created).toEqual(["runtime-1", "runtime-2"]);
|
|
90
|
+
|
|
91
|
+
await manager.release(leaseOne);
|
|
92
|
+
await manager.release(leaseTwo);
|
|
93
|
+
await manager.cleanupAll();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("blocks conflicting locks until the first lease releases", async () => {
|
|
97
|
+
const productDir = makeTempDir("testkit-runtime-manager-");
|
|
98
|
+
const manager = createRuntimeManager({
|
|
99
|
+
productDir,
|
|
100
|
+
lifecycle: makeLifecycle(),
|
|
101
|
+
graphs: [
|
|
102
|
+
{
|
|
103
|
+
key: "api",
|
|
104
|
+
dirName: "api",
|
|
105
|
+
targetNames: ["api"],
|
|
106
|
+
instanceCount: 1,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const firstLease = await manager.acquire(makeTask(1, { locks: ["shared-lock"] }));
|
|
113
|
+
let secondSettled = false;
|
|
114
|
+
const secondPromise = manager.acquire(makeTask(2, { locks: ["shared-lock"] })).then((lease) => {
|
|
115
|
+
secondSettled = true;
|
|
116
|
+
return lease;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
120
|
+
expect(secondSettled).toBe(false);
|
|
121
|
+
|
|
122
|
+
await manager.release(firstLease);
|
|
123
|
+
const secondLease = await secondPromise;
|
|
124
|
+
expect(secondSettled).toBe(true);
|
|
125
|
+
|
|
126
|
+
await manager.release(secondLease);
|
|
127
|
+
await manager.cleanupAll();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("does not drain an invalidated runtime slot until all active leases release", async () => {
|
|
131
|
+
const productDir = makeTempDir("testkit-runtime-manager-");
|
|
132
|
+
const events = { created: [], ready: [], cleaned: [] };
|
|
133
|
+
const manager = createRuntimeManager({
|
|
134
|
+
productDir,
|
|
135
|
+
lifecycle: makeLifecycle(),
|
|
136
|
+
graphs: [
|
|
137
|
+
{
|
|
138
|
+
key: "api",
|
|
139
|
+
dirName: "api",
|
|
140
|
+
targetNames: ["api"],
|
|
141
|
+
instanceCount: 1,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
hooks: makeHooks(events),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const leaseOne = await manager.acquire(makeTask(1));
|
|
148
|
+
const leaseTwo = await manager.acquire(makeTask(2));
|
|
149
|
+
|
|
150
|
+
await manager.release(leaseOne, { invalidate: true });
|
|
151
|
+
expect(events.cleaned).toEqual([]);
|
|
152
|
+
|
|
153
|
+
await manager.release(leaseTwo);
|
|
154
|
+
expect(events.cleaned).toEqual(["runtime-1"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("removes lease directories on release", async () => {
|
|
158
|
+
const productDir = makeTempDir("testkit-runtime-manager-");
|
|
159
|
+
const manager = createRuntimeManager({
|
|
160
|
+
productDir,
|
|
161
|
+
lifecycle: makeLifecycle(),
|
|
162
|
+
graphs: [
|
|
163
|
+
{
|
|
164
|
+
key: "api",
|
|
165
|
+
dirName: "api",
|
|
166
|
+
targetNames: ["api"],
|
|
167
|
+
instanceCount: 1,
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
hooks: makeHooks({ created: [], ready: [], cleaned: [] }),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const lease = await manager.acquire(makeTask(1));
|
|
174
|
+
expect(fs.existsSync(lease.leaseDir)).toBe(true);
|
|
175
|
+
|
|
176
|
+
await manager.release(lease);
|
|
177
|
+
expect(fs.existsSync(lease.leaseDir)).toBe(false);
|
|
178
|
+
|
|
179
|
+
await manager.cleanupAll();
|
|
180
|
+
});
|
|
181
|
+
});
|