Skip to content

Commit 1bfef17

Browse files
committed
ci: balance shards and reuse pr artifacts
1 parent 26365f7 commit 1bfef17

6 files changed

Lines changed: 1305 additions & 30 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ jobs:
162162
# Build dist once for Node-relevant changes and share it with downstream jobs.
163163
build-artifacts:
164164
needs: [preflight]
165-
if: github.event_name == 'push' && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
165+
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
166166
runs-on: blacksmith-16vcpu-ubuntu-2404
167167
timeout-minutes: 20
168168
steps:
@@ -234,7 +234,7 @@ jobs:
234234

235235
checks:
236236
needs: [preflight, build-artifacts]
237-
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
237+
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && needs.build-artifacts.result == 'success'
238238
runs-on: blacksmith-16vcpu-ubuntu-2404
239239
timeout-minutes: 20
240240
strategy:
@@ -327,27 +327,19 @@ jobs:
327327
fi
328328
329329
- name: Download dist artifact
330-
if: github.event_name == 'push' && matrix.task == 'test'
330+
if: matrix.task == 'test'
331331
uses: actions/download-artifact@v8
332332
with:
333333
name: dist-build
334334
path: dist/
335335

336336
- name: Download A2UI bundle artifact
337-
if: github.event_name == 'push' && (matrix.task == 'test' || matrix.task == 'channels')
337+
if: matrix.task == 'test' || matrix.task == 'channels'
338338
uses: actions/download-artifact@v8
339339
with:
340340
name: canvas-a2ui-bundle
341341
path: src/canvas-host/a2ui/
342342

343-
- name: Build A2UI bundle
344-
if: github.event_name != 'push' && (matrix.task == 'test' || matrix.task == 'channels')
345-
run: pnpm canvas:a2ui:bundle
346-
347-
- name: Build dist
348-
if: github.event_name != 'push' && matrix.task == 'test' && matrix.runtime == 'node'
349-
run: pnpm build
350-
351343
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
352344
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
353345
run: ${{ matrix.command }}
@@ -592,7 +584,7 @@ jobs:
592584

593585
checks-windows:
594586
needs: [preflight, build-artifacts]
595-
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_windows == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
587+
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_windows == 'true' && needs.build-artifacts.result == 'success'
596588
runs-on: blacksmith-32vcpu-windows-2025
597589
timeout-minutes: 20
598590
env:
@@ -719,27 +711,19 @@ jobs:
719711
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
720712
721713
- name: Download dist artifact
722-
if: github.event_name == 'push' && matrix.task == 'test'
714+
if: matrix.task == 'test'
723715
uses: actions/download-artifact@v8
724716
with:
725717
name: dist-build
726718
path: dist/
727719

728720
- name: Download A2UI bundle artifact
729-
if: github.event_name == 'push' && matrix.task == 'test'
721+
if: matrix.task == 'test'
730722
uses: actions/download-artifact@v8
731723
with:
732724
name: canvas-a2ui-bundle
733725
path: src/canvas-host/a2ui/
734726

735-
- name: Build A2UI bundle (Windows)
736-
if: github.event_name != 'push' && matrix.task == 'test'
737-
run: pnpm canvas:a2ui:bundle
738-
739-
- name: Build dist (Windows)
740-
if: github.event_name != 'push' && matrix.task == 'test'
741-
run: pnpm build
742-
743727
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
744728
run: ${{ matrix.command }}
745729

scripts/test-parallel.mjs

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ import {
1717
} from "./test-parallel-utils.mjs";
1818
import {
1919
dedupeFilesPreserveOrder,
20+
loadChannelTimingManifest,
2021
loadUnitMemoryHotspotManifest,
2122
loadTestRunnerBehavior,
2223
loadUnitTimingManifest,
2324
selectUnitHeavyFileGroups,
2425
packFilesByDuration,
26+
packFilesByDurationWithBaseLoads,
2527
} from "./test-runner-manifest.mjs";
2628

2729
// On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell
@@ -312,6 +314,7 @@ const inferTarget = (fileFilter) => {
312314
return { owner: "base", isolated };
313315
};
314316
const unitTimingManifest = loadUnitTimingManifest();
317+
const channelTimingManifest = loadChannelTimingManifest();
315318
const unitMemoryHotspotManifest = loadUnitMemoryHotspotManifest();
316319
const parseEnvNumber = (name, fallback) => {
317320
const parsed = Number.parseInt(process.env[name] ?? "", 10);
@@ -385,6 +388,26 @@ const unitFastExcludedFiles = [
385388
];
386389
const estimateUnitDurationMs = (file) =>
387390
unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs;
391+
const estimateChannelDurationMs = (file) =>
392+
channelTimingManifest.files[file]?.durationMs ?? channelTimingManifest.defaultDurationMs;
393+
const resolveEntryTimingEstimator = (entry) => {
394+
const configIndex = entry.args.findIndex((arg) => arg === "--config");
395+
const config = configIndex >= 0 ? (entry.args[configIndex + 1] ?? "") : "";
396+
if (config === "vitest.unit.config.ts") {
397+
return estimateUnitDurationMs;
398+
}
399+
if (config === "vitest.channels.config.ts") {
400+
return estimateChannelDurationMs;
401+
}
402+
return null;
403+
};
404+
const estimateEntryFilesDurationMs = (entry, files) => {
405+
const estimateDurationMs = resolveEntryTimingEstimator(entry);
406+
if (!estimateDurationMs) {
407+
return files.length * 1_000;
408+
}
409+
return files.reduce((totalMs, file) => totalMs + estimateDurationMs(file), 0);
410+
};
388411
const splitFilesByDurationBudget = (files, targetDurationMs, estimateDurationMs) => {
389412
if (!Number.isFinite(targetDurationMs) || targetDurationMs <= 0 || files.length <= 1) {
390413
return [files];
@@ -462,6 +485,11 @@ const unitFastEntries = unitFastBuckets.flatMap((files, index) => {
462485
.map((batch, batchIndex) => ({
463486
name: recycledBatches.length === 1 ? laneName : `${laneName}-batch-${String(batchIndex + 1)}`,
464487
serialPhase: "unit-fast",
488+
includeFiles: batch,
489+
estimatedDurationMs: estimateEntryFilesDurationMs(
490+
{ args: ["vitest", "run", "--config", "vitest.unit.config.ts"] },
491+
batch,
492+
),
465493
env: {
466494
OPENCLAW_VITEST_INCLUDE_FILE: writeTempJsonArtifact(
467495
`vitest-unit-fast-include-${String(index + 1)}-${String(batchIndex + 1)}`,
@@ -564,6 +592,7 @@ const baseRuns = [
564592
...extensionIsolatedEntries,
565593
{
566594
name: "extensions",
595+
includeFiles: extensionSharedCandidateFiles,
567596
env:
568597
extensionSharedCandidateFiles.length > 0
569598
? {
@@ -585,6 +614,11 @@ const baseRuns = [
585614
})),
586615
{
587616
name: "channels",
617+
includeFiles: channelSharedCandidateFiles,
618+
estimatedDurationMs: estimateEntryFilesDurationMs(
619+
{ args: ["vitest", "run", "--config", "vitest.channels.config.ts"] },
620+
channelSharedCandidateFiles,
621+
),
588622
env:
589623
channelSharedCandidateFiles.length > 0
590624
? {
@@ -772,6 +806,65 @@ const createPerFileTargetedEntry = (file) => {
772806
name: `${formatPerFileEntryName(owner, file)}${target.isolated ? "-isolated" : ""}`,
773807
};
774808
};
809+
const rebuildEntryArgsWithFilters = (entryArgs, filters) => {
810+
const baseArgs = entryArgs.slice(0, 2);
811+
const { optionArgs } = parsePassthroughArgs(entryArgs.slice(2));
812+
return [...baseArgs, ...optionArgs, ...filters];
813+
};
814+
const createPinnedShardEntry = (entry, files, fixedShardIndex) => {
815+
const nextEntry = {
816+
...entry,
817+
name: `${entry.name}-shard-${String(fixedShardIndex)}`,
818+
fixedShardIndex,
819+
estimatedDurationMs: estimateEntryFilesDurationMs(entry, files),
820+
};
821+
if (Array.isArray(entry.includeFiles) && entry.includeFiles.length > 0) {
822+
return {
823+
...nextEntry,
824+
includeFiles: files,
825+
env: {
826+
...entry.env,
827+
OPENCLAW_VITEST_INCLUDE_FILE: writeTempJsonArtifact(
828+
`${sanitizeArtifactName(entry.name)}-shard-${String(fixedShardIndex)}-include`,
829+
files,
830+
),
831+
},
832+
args: rebuildEntryArgsWithFilters(entry.args, []),
833+
};
834+
}
835+
return {
836+
...nextEntry,
837+
args: rebuildEntryArgsWithFilters(entry.args, files),
838+
};
839+
};
840+
const expandEntryAcrossTopLevelShards = (entry) => {
841+
if (configuredShardCount === null || shardCount <= 1 || entry.fixedShardIndex !== undefined) {
842+
return [entry];
843+
}
844+
const estimateDurationMs = resolveEntryTimingEstimator(entry);
845+
if (!estimateDurationMs) {
846+
return [entry];
847+
}
848+
const candidateFiles =
849+
Array.isArray(entry.includeFiles) && entry.includeFiles.length > 0
850+
? entry.includeFiles
851+
: getExplicitEntryFilters(entry.args);
852+
if (candidateFiles.length <= 1) {
853+
return [entry];
854+
}
855+
const effectiveShardCount = Math.min(shardCount, Math.max(1, candidateFiles.length - 1));
856+
if (effectiveShardCount <= 1) {
857+
return [entry];
858+
}
859+
const buckets = packFilesByDurationWithBaseLoads(
860+
candidateFiles,
861+
effectiveShardCount,
862+
estimateDurationMs,
863+
);
864+
return buckets.flatMap((files, bucketIndex) =>
865+
files.length > 0 ? [createPinnedShardEntry(entry, files, bucketIndex + 1)] : [],
866+
);
867+
};
775868
const targetedEntries = (() => {
776869
if (passthroughFileFilters.length === 0) {
777870
return [];
@@ -815,7 +908,13 @@ const targetedEntries = (() => {
815908
return [createTargetedEntry(owner, false, uniqueFilters)];
816909
}).flat();
817910
})();
911+
if (configuredShardCount !== null && shardCount > 1) {
912+
runs = runs.flatMap((entry) => expandEntryAcrossTopLevelShards(entry));
913+
}
818914
const estimateTopLevelEntryDurationMs = (entry) => {
915+
if (Number.isFinite(entry.estimatedDurationMs) && entry.estimatedDurationMs > 0) {
916+
return entry.estimatedDurationMs;
917+
}
819918
const filters = getExplicitEntryFilters(entry.args);
820919
if (filters.length === 0) {
821920
return unitTimingManifest.defaultDurationMs;
@@ -841,6 +940,9 @@ const topLevelSingleShardAssignments = (() => {
841940
// Single-file and other non-shardable explicit lanes would otherwise run on
842941
// every shard. Assign them to one top-level shard instead.
843942
const entriesNeedingAssignment = runs.filter((entry) => {
943+
if (entry.fixedShardIndex !== undefined) {
944+
return false;
945+
}
844946
const explicitFilterCount = countExplicitEntryFilters(entry.args);
845947
if (explicitFilterCount === null) {
846948
return false;
@@ -850,10 +952,22 @@ const topLevelSingleShardAssignments = (() => {
850952
});
851953

852954
const assignmentMap = new Map();
853-
const buckets = packFilesByDuration(
955+
const pinnedShardLoadsMs = Array.from({ length: shardCount }, () => 0);
956+
for (const entry of runs) {
957+
if (entry.fixedShardIndex === undefined) {
958+
continue;
959+
}
960+
const shardArrayIndex = entry.fixedShardIndex - 1;
961+
if (shardArrayIndex < 0 || shardArrayIndex >= pinnedShardLoadsMs.length) {
962+
continue;
963+
}
964+
pinnedShardLoadsMs[shardArrayIndex] += estimateTopLevelEntryDurationMs(entry);
965+
}
966+
const buckets = packFilesByDurationWithBaseLoads(
854967
entriesNeedingAssignment,
855968
shardCount,
856969
estimateTopLevelEntryDurationMs,
970+
pinnedShardLoadsMs,
857971
);
858972
for (const [bucketIndex, bucket] of buckets.entries()) {
859973
for (const entry of bucket) {
@@ -1363,6 +1477,12 @@ const runOnce = (entry, extraArgs = []) =>
13631477
});
13641478

13651479
const run = async (entry, extraArgs = []) => {
1480+
if (entry.fixedShardIndex !== undefined) {
1481+
if (shardIndexOverride !== null && shardIndexOverride !== entry.fixedShardIndex) {
1482+
return 0;
1483+
}
1484+
return runOnce(entry, extraArgs);
1485+
}
13661486
const explicitFilterCount = countExplicitEntryFilters(entry.args);
13671487
const topLevelAssignedShard = topLevelSingleShardAssignments.get(entry);
13681488
if (topLevelAssignedShard !== undefined) {

scripts/test-runner-manifest.mjs

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ import { normalizeTrackedRepoPath, tryReadJsonFile } from "./test-report-utils.m
22

33
export const behaviorManifestPath = "test/fixtures/test-parallel.behavior.json";
44
export const unitTimingManifestPath = "test/fixtures/test-timings.unit.json";
5+
export const channelTimingManifestPath = "test/fixtures/test-timings.channels.json";
56
export const unitMemoryHotspotManifestPath = "test/fixtures/test-memory-hotspots.unit.json";
67

78
const defaultTimingManifest = {
89
config: "vitest.unit.config.ts",
910
defaultDurationMs: 250,
1011
files: {},
1112
};
13+
const defaultChannelTimingManifest = {
14+
config: "vitest.channels.config.ts",
15+
defaultDurationMs: 3000,
16+
files: {},
17+
};
1218
const defaultMemoryHotspotManifest = {
1319
config: "vitest.unit.config.ts",
1420
defaultMinDeltaKb: 256 * 1024,
@@ -87,12 +93,12 @@ export function loadTestRunnerBehavior() {
8793
};
8894
}
8995

90-
export function loadUnitTimingManifest() {
91-
const raw = tryReadJsonFile(unitTimingManifestPath, defaultTimingManifest);
96+
const loadTimingManifest = (manifestPath, fallbackManifest) => {
97+
const raw = tryReadJsonFile(manifestPath, fallbackManifest);
9298
const defaultDurationMs =
9399
Number.isFinite(raw.defaultDurationMs) && raw.defaultDurationMs > 0
94100
? raw.defaultDurationMs
95-
: defaultTimingManifest.defaultDurationMs;
101+
: fallbackManifest.defaultDurationMs;
96102
const files = Object.fromEntries(
97103
Object.entries(raw.files ?? {})
98104
.map(([file, value]) => {
@@ -116,12 +122,19 @@ export function loadUnitTimingManifest() {
116122
);
117123

118124
return {
119-
config:
120-
typeof raw.config === "string" && raw.config ? raw.config : defaultTimingManifest.config,
125+
config: typeof raw.config === "string" && raw.config ? raw.config : fallbackManifest.config,
121126
generatedAt: typeof raw.generatedAt === "string" ? raw.generatedAt : "",
122127
defaultDurationMs,
123128
files,
124129
};
130+
};
131+
132+
export function loadUnitTimingManifest() {
133+
return loadTimingManifest(unitTimingManifestPath, defaultTimingManifest);
134+
}
135+
136+
export function loadChannelTimingManifest() {
137+
return loadTimingManifest(channelTimingManifestPath, defaultChannelTimingManifest);
125138
}
126139

127140
export function loadUnitMemoryHotspotManifest() {
@@ -268,6 +281,40 @@ export function packFilesByDuration(files, bucketCount, estimateDurationMs) {
268281
return buckets.map((bucket) => bucket.files).filter((bucket) => bucket.length > 0);
269282
}
270283

284+
export function packFilesByDurationWithBaseLoads(
285+
files,
286+
bucketCount,
287+
estimateDurationMs,
288+
baseLoadsMs = [],
289+
) {
290+
const normalizedBucketCount = Math.max(0, Math.floor(bucketCount));
291+
if (normalizedBucketCount <= 0) {
292+
return [];
293+
}
294+
295+
const buckets = Array.from({ length: normalizedBucketCount }, (_, index) => ({
296+
totalMs:
297+
Number.isFinite(baseLoadsMs[index]) && baseLoadsMs[index] >= 0
298+
? Math.round(baseLoadsMs[index])
299+
: 0,
300+
files: [],
301+
}));
302+
303+
const sortedFiles = [...files].toSorted((left, right) => {
304+
return estimateDurationMs(right) - estimateDurationMs(left);
305+
});
306+
307+
for (const file of sortedFiles) {
308+
const bucket = buckets.reduce((lightest, current) =>
309+
current.totalMs < lightest.totalMs ? current : lightest,
310+
);
311+
bucket.files.push(file);
312+
bucket.totalMs += estimateDurationMs(file);
313+
}
314+
315+
return buckets.map((bucket) => bucket.files);
316+
}
317+
271318
export function dedupeFilesPreserveOrder(files, exclude = new Set()) {
272319
const result = [];
273320
const seen = new Set();

0 commit comments

Comments
 (0)