mirror of https://github.com/openclaw/openclaw.git
ci: balance shards and reuse pr artifacts
This commit is contained in:
parent
26365f7daf
commit
1bfef17825
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"]]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue