ci: balance shards and reuse pr artifacts

This commit is contained in:
Peter Steinberger 2026-03-24 04:18:58 +00:00
parent 26365f7daf
commit 1bfef17825
6 changed files with 1305 additions and 30 deletions

View File

@ -162,7 +162,7 @@ jobs:
# Build dist once for Node-relevant changes and share it with downstream jobs.
build-artifacts:
needs: [preflight]
if: github.event_name == 'push' && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
@ -234,7 +234,7 @@ jobs:
checks:
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && needs.build-artifacts.result == 'success'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
@ -327,27 +327,19 @@ jobs:
fi
- name: Download dist artifact
if: github.event_name == 'push' && matrix.task == 'test'
if: matrix.task == 'test'
uses: actions/download-artifact@v8
with:
name: dist-build
path: dist/
- name: Download A2UI bundle artifact
if: github.event_name == 'push' && (matrix.task == 'test' || matrix.task == 'channels')
if: matrix.task == 'test' || matrix.task == 'channels'
uses: actions/download-artifact@v8
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
- name: Build A2UI bundle
if: github.event_name != 'push' && (matrix.task == 'test' || matrix.task == 'channels')
run: pnpm canvas:a2ui:bundle
- name: Build dist
if: github.event_name != 'push' && matrix.task == 'test' && matrix.runtime == 'node'
run: pnpm build
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
run: ${{ matrix.command }}
@ -592,7 +584,7 @@ jobs:
checks-windows:
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_windows == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_windows == 'true' && needs.build-artifacts.result == 'success'
runs-on: blacksmith-32vcpu-windows-2025
timeout-minutes: 20
env:
@ -719,27 +711,19 @@ jobs:
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
- name: Download dist artifact
if: github.event_name == 'push' && matrix.task == 'test'
if: matrix.task == 'test'
uses: actions/download-artifact@v8
with:
name: dist-build
path: dist/
- name: Download A2UI bundle artifact
if: github.event_name == 'push' && matrix.task == 'test'
if: matrix.task == 'test'
uses: actions/download-artifact@v8
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
- name: Build A2UI bundle (Windows)
if: github.event_name != 'push' && matrix.task == 'test'
run: pnpm canvas:a2ui:bundle
- name: Build dist (Windows)
if: github.event_name != 'push' && matrix.task == 'test'
run: pnpm build
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}

View File

@ -17,11 +17,13 @@ import {
} from "./test-parallel-utils.mjs";
import {
dedupeFilesPreserveOrder,
loadChannelTimingManifest,
loadUnitMemoryHotspotManifest,
loadTestRunnerBehavior,
loadUnitTimingManifest,
selectUnitHeavyFileGroups,
packFilesByDuration,
packFilesByDurationWithBaseLoads,
} from "./test-runner-manifest.mjs";
// On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell
@ -312,6 +314,7 @@ const inferTarget = (fileFilter) => {
return { owner: "base", isolated };
};
const unitTimingManifest = loadUnitTimingManifest();
const channelTimingManifest = loadChannelTimingManifest();
const unitMemoryHotspotManifest = loadUnitMemoryHotspotManifest();
const parseEnvNumber = (name, fallback) => {
const parsed = Number.parseInt(process.env[name] ?? "", 10);
@ -385,6 +388,26 @@ const unitFastExcludedFiles = [
];
const estimateUnitDurationMs = (file) =>
unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs;
const estimateChannelDurationMs = (file) =>
channelTimingManifest.files[file]?.durationMs ?? channelTimingManifest.defaultDurationMs;
const resolveEntryTimingEstimator = (entry) => {
const configIndex = entry.args.findIndex((arg) => arg === "--config");
const config = configIndex >= 0 ? (entry.args[configIndex + 1] ?? "") : "";
if (config === "vitest.unit.config.ts") {
return estimateUnitDurationMs;
}
if (config === "vitest.channels.config.ts") {
return estimateChannelDurationMs;
}
return null;
};
const estimateEntryFilesDurationMs = (entry, files) => {
const estimateDurationMs = resolveEntryTimingEstimator(entry);
if (!estimateDurationMs) {
return files.length * 1_000;
}
return files.reduce((totalMs, file) => totalMs + estimateDurationMs(file), 0);
};
const splitFilesByDurationBudget = (files, targetDurationMs, estimateDurationMs) => {
if (!Number.isFinite(targetDurationMs) || targetDurationMs <= 0 || files.length <= 1) {
return [files];
@ -462,6 +485,11 @@ const unitFastEntries = unitFastBuckets.flatMap((files, index) => {
.map((batch, batchIndex) => ({
name: recycledBatches.length === 1 ? laneName : `${laneName}-batch-${String(batchIndex + 1)}`,
serialPhase: "unit-fast",
includeFiles: batch,
estimatedDurationMs: estimateEntryFilesDurationMs(
{ args: ["vitest", "run", "--config", "vitest.unit.config.ts"] },
batch,
),
env: {
OPENCLAW_VITEST_INCLUDE_FILE: writeTempJsonArtifact(
`vitest-unit-fast-include-${String(index + 1)}-${String(batchIndex + 1)}`,
@ -564,6 +592,7 @@ const baseRuns = [
...extensionIsolatedEntries,
{
name: "extensions",
includeFiles: extensionSharedCandidateFiles,
env:
extensionSharedCandidateFiles.length > 0
? {
@ -585,6 +614,11 @@ const baseRuns = [
})),
{
name: "channels",
includeFiles: channelSharedCandidateFiles,
estimatedDurationMs: estimateEntryFilesDurationMs(
{ args: ["vitest", "run", "--config", "vitest.channels.config.ts"] },
channelSharedCandidateFiles,
),
env:
channelSharedCandidateFiles.length > 0
? {
@ -772,6 +806,65 @@ const createPerFileTargetedEntry = (file) => {
name: `${formatPerFileEntryName(owner, file)}${target.isolated ? "-isolated" : ""}`,
};
};
const rebuildEntryArgsWithFilters = (entryArgs, filters) => {
const baseArgs = entryArgs.slice(0, 2);
const { optionArgs } = parsePassthroughArgs(entryArgs.slice(2));
return [...baseArgs, ...optionArgs, ...filters];
};
const createPinnedShardEntry = (entry, files, fixedShardIndex) => {
const nextEntry = {
...entry,
name: `${entry.name}-shard-${String(fixedShardIndex)}`,
fixedShardIndex,
estimatedDurationMs: estimateEntryFilesDurationMs(entry, files),
};
if (Array.isArray(entry.includeFiles) && entry.includeFiles.length > 0) {
return {
...nextEntry,
includeFiles: files,
env: {
...entry.env,
OPENCLAW_VITEST_INCLUDE_FILE: writeTempJsonArtifact(
`${sanitizeArtifactName(entry.name)}-shard-${String(fixedShardIndex)}-include`,
files,
),
},
args: rebuildEntryArgsWithFilters(entry.args, []),
};
}
return {
...nextEntry,
args: rebuildEntryArgsWithFilters(entry.args, files),
};
};
const expandEntryAcrossTopLevelShards = (entry) => {
if (configuredShardCount === null || shardCount <= 1 || entry.fixedShardIndex !== undefined) {
return [entry];
}
const estimateDurationMs = resolveEntryTimingEstimator(entry);
if (!estimateDurationMs) {
return [entry];
}
const candidateFiles =
Array.isArray(entry.includeFiles) && entry.includeFiles.length > 0
? entry.includeFiles
: getExplicitEntryFilters(entry.args);
if (candidateFiles.length <= 1) {
return [entry];
}
const effectiveShardCount = Math.min(shardCount, Math.max(1, candidateFiles.length - 1));
if (effectiveShardCount <= 1) {
return [entry];
}
const buckets = packFilesByDurationWithBaseLoads(
candidateFiles,
effectiveShardCount,
estimateDurationMs,
);
return buckets.flatMap((files, bucketIndex) =>
files.length > 0 ? [createPinnedShardEntry(entry, files, bucketIndex + 1)] : [],
);
};
const targetedEntries = (() => {
if (passthroughFileFilters.length === 0) {
return [];
@ -815,7 +908,13 @@ const targetedEntries = (() => {
return [createTargetedEntry(owner, false, uniqueFilters)];
}).flat();
})();
if (configuredShardCount !== null && shardCount > 1) {
runs = runs.flatMap((entry) => expandEntryAcrossTopLevelShards(entry));
}
const estimateTopLevelEntryDurationMs = (entry) => {
if (Number.isFinite(entry.estimatedDurationMs) && entry.estimatedDurationMs > 0) {
return entry.estimatedDurationMs;
}
const filters = getExplicitEntryFilters(entry.args);
if (filters.length === 0) {
return unitTimingManifest.defaultDurationMs;
@ -841,6 +940,9 @@ const topLevelSingleShardAssignments = (() => {
// Single-file and other non-shardable explicit lanes would otherwise run on
// every shard. Assign them to one top-level shard instead.
const entriesNeedingAssignment = runs.filter((entry) => {
if (entry.fixedShardIndex !== undefined) {
return false;
}
const explicitFilterCount = countExplicitEntryFilters(entry.args);
if (explicitFilterCount === null) {
return false;
@ -850,10 +952,22 @@ const topLevelSingleShardAssignments = (() => {
});
const assignmentMap = new Map();
const buckets = packFilesByDuration(
const pinnedShardLoadsMs = Array.from({ length: shardCount }, () => 0);
for (const entry of runs) {
if (entry.fixedShardIndex === undefined) {
continue;
}
const shardArrayIndex = entry.fixedShardIndex - 1;
if (shardArrayIndex < 0 || shardArrayIndex >= pinnedShardLoadsMs.length) {
continue;
}
pinnedShardLoadsMs[shardArrayIndex] += estimateTopLevelEntryDurationMs(entry);
}
const buckets = packFilesByDurationWithBaseLoads(
entriesNeedingAssignment,
shardCount,
estimateTopLevelEntryDurationMs,
pinnedShardLoadsMs,
);
for (const [bucketIndex, bucket] of buckets.entries()) {
for (const entry of bucket) {
@ -1363,6 +1477,12 @@ const runOnce = (entry, extraArgs = []) =>
});
const run = async (entry, extraArgs = []) => {
if (entry.fixedShardIndex !== undefined) {
if (shardIndexOverride !== null && shardIndexOverride !== entry.fixedShardIndex) {
return 0;
}
return runOnce(entry, extraArgs);
}
const explicitFilterCount = countExplicitEntryFilters(entry.args);
const topLevelAssignedShard = topLevelSingleShardAssignments.get(entry);
if (topLevelAssignedShard !== undefined) {

View File

@ -2,6 +2,7 @@ import { normalizeTrackedRepoPath, tryReadJsonFile } from "./test-report-utils.m
export const behaviorManifestPath = "test/fixtures/test-parallel.behavior.json";
export const unitTimingManifestPath = "test/fixtures/test-timings.unit.json";
export const channelTimingManifestPath = "test/fixtures/test-timings.channels.json";
export const unitMemoryHotspotManifestPath = "test/fixtures/test-memory-hotspots.unit.json";
const defaultTimingManifest = {
@ -9,6 +10,11 @@ const defaultTimingManifest = {
defaultDurationMs: 250,
files: {},
};
const defaultChannelTimingManifest = {
config: "vitest.channels.config.ts",
defaultDurationMs: 3000,
files: {},
};
const defaultMemoryHotspotManifest = {
config: "vitest.unit.config.ts",
defaultMinDeltaKb: 256 * 1024,
@ -87,12 +93,12 @@ export function loadTestRunnerBehavior() {
};
}
export function loadUnitTimingManifest() {
const raw = tryReadJsonFile(unitTimingManifestPath, defaultTimingManifest);
const loadTimingManifest = (manifestPath, fallbackManifest) => {
const raw = tryReadJsonFile(manifestPath, fallbackManifest);
const defaultDurationMs =
Number.isFinite(raw.defaultDurationMs) && raw.defaultDurationMs > 0
? raw.defaultDurationMs
: defaultTimingManifest.defaultDurationMs;
: fallbackManifest.defaultDurationMs;
const files = Object.fromEntries(
Object.entries(raw.files ?? {})
.map(([file, value]) => {
@ -116,12 +122,19 @@ export function loadUnitTimingManifest() {
);
return {
config:
typeof raw.config === "string" && raw.config ? raw.config : defaultTimingManifest.config,
config: typeof raw.config === "string" && raw.config ? raw.config : fallbackManifest.config,
generatedAt: typeof raw.generatedAt === "string" ? raw.generatedAt : "",
defaultDurationMs,
files,
};
};
export function loadUnitTimingManifest() {
return loadTimingManifest(unitTimingManifestPath, defaultTimingManifest);
}
export function loadChannelTimingManifest() {
return loadTimingManifest(channelTimingManifestPath, defaultChannelTimingManifest);
}
export function loadUnitMemoryHotspotManifest() {
@ -268,6 +281,40 @@ export function packFilesByDuration(files, bucketCount, estimateDurationMs) {
return buckets.map((bucket) => bucket.files).filter((bucket) => bucket.length > 0);
}
export function packFilesByDurationWithBaseLoads(
files,
bucketCount,
estimateDurationMs,
baseLoadsMs = [],
) {
const normalizedBucketCount = Math.max(0, Math.floor(bucketCount));
if (normalizedBucketCount <= 0) {
return [];
}
const buckets = Array.from({ length: normalizedBucketCount }, (_, index) => ({
totalMs:
Number.isFinite(baseLoadsMs[index]) && baseLoadsMs[index] >= 0
? Math.round(baseLoadsMs[index])
: 0,
files: [],
}));
const sortedFiles = [...files].toSorted((left, right) => {
return estimateDurationMs(right) - estimateDurationMs(left);
});
for (const file of sortedFiles) {
const bucket = buckets.reduce((lightest, current) =>
current.totalMs < lightest.totalMs ? current : lightest,
);
bucket.files.push(file);
bucket.totalMs += estimateDurationMs(file);
}
return buckets.map((bucket) => bucket.files);
}
export function dedupeFilesPreserveOrder(files, exclude = new Set()) {
const result = [];
const seen = new Set();

1096
test/fixtures/test-timings.channels.json vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
dedupeFilesPreserveOrder,
packFilesByDuration,
packFilesByDurationWithBaseLoads,
selectMemoryHeavyFiles,
selectTimedHeavyFiles,
selectUnitHeavyFileGroups,
@ -133,4 +134,21 @@ describe("packFilesByDuration", () => {
["src/b.test.ts", "src/c.test.ts"],
]);
});
it("accounts for existing shard load when packing new work", () => {
const durationByFile = {
"src/a.test.ts": 100,
"src/b.test.ts": 90,
"src/c.test.ts": 20,
} satisfies Record<string, number>;
expect(
packFilesByDurationWithBaseLoads(
Object.keys(durationByFile),
3,
(file) => durationByFile[file] ?? 0,
[0, 200, 10],
),
).toEqual([["src/a.test.ts", "src/c.test.ts"], [], ["src/b.test.ts"]]);
});
});

View File

@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { loadTestRunnerBehavior } from "../scripts/test-runner-manifest.mjs";
import {
loadChannelTimingManifest,
loadTestRunnerBehavior,
} from "../scripts/test-runner-manifest.mjs";
describe("loadTestRunnerBehavior", () => {
it("loads channel isolated entries from the behavior manifest", () => {
@ -16,4 +19,11 @@ describe("loadTestRunnerBehavior", () => {
expect(behavior.channels.isolatedPrefixes).toContain("extensions/discord/src/monitor/");
});
it("loads channel timing metadata from the timing manifest", () => {
const timings = loadChannelTimingManifest();
expect(timings.config).toBe("vitest.channels.config.ts");
expect(Object.keys(timings.files).length).toBeGreaterThan(0);
});
});