Skip to content

Commit 000f849

Browse files
committed
Balance CI shards and consolidate static checks to cut wallclock
Alphabetical sharding piled 37 of 86 packages into the [s-z] shard (every uc-* package starts with `u`), making Lint([s-z]) the ~232s CI long pole. The protected runner pool also serves only ~8-12 jobs before autoscaling kicks in, so simply adding shards trades work time for queue time and does not help wallclock. Replace alphabetical sharding with weight-balanced shards computed by scripts/ci-shards.mjs (8 shards, sized to the runner pool), and fold build, lint, and format:check into one per-shard `static` job so each shard builds its dependency closure once instead of repeating it across separate lint, typecheck, and build jobs. The standalone typecheck pass is dropped because the build task (`tsc -b`) already type-checks src while emitting, making a separate `tsc --noEmit` over the same files redundant. Co-authored-by: Isaac
1 parent 13b5c9b commit 000f849

2 files changed

Lines changed: 129 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"format": "turbo run format",
1717
"format:check": "turbo run format:check",
1818
"checks": "turbo run lint format:check",
19+
"ci:verify": "turbo run build lint format:check",
1920
"typecheck": "turbo run typecheck",
2021
"clean": "turbo run clean",
2122
"check:licenses": "node scripts/check-licenses.mjs",

scripts/ci-shards.mjs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env node
2+
// Partition the workspace packages into balanced CI shards.
3+
//
4+
// CI runs the static checks (build + lint + typecheck + format) as a matrix
5+
// where each shard handles a subset of packages. Alphabetical sharding is
6+
// badly unbalanced because ~25% of packages share the `uc-` prefix, so this
7+
// script balances shards by source weight instead.
8+
//
9+
// Usage:
10+
// node scripts/ci-shards.mjs [shardCount]
11+
// Prints a single-line JSON array of shard objects to stdout, suitable for
12+
// a GitHub Actions matrix: [{ "id": "1", "pkgs": "<space-separated names>" }].
13+
//
14+
// node scripts/ci-shards.mjs [shardCount] --pretty
15+
// Prints a human-readable balance report to stderr (for local inspection).
16+
//
17+
// Weighting: each package's weight is the total byte size of its TypeScript
18+
// source files under `src/`, plus a fixed base to account for the per-package
19+
// fixed cost (turbo task startup, dependency build). Shards are filled with a
20+
// largest-processing-time-first greedy algorithm, which keeps the heaviest
21+
// shard close to the theoretical optimum.
22+
23+
import { readdirSync, readFileSync, statSync } from 'node:fs';
24+
import { join, dirname } from 'node:path';
25+
import { fileURLToPath } from 'node:url';
26+
27+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
28+
const PACKAGES_DIR = join(REPO_ROOT, 'packages');
29+
const PACKAGE_PREFIX = '@databricks/sdk-';
30+
31+
// A fixed weight added to every package, in source-byte-equivalents. Type-aware
32+
// lint and typecheck have a large fixed cost per package (loading the TS program
33+
// and resolving the dependency type graph), so a shard's runtime is driven more
34+
// by how many packages it holds than by their total source size. Setting the
35+
// base near the median source size keeps shards balanced by package count while
36+
// still giving heavy packages (e.g. `jobs`) extra weight.
37+
const BASE_WEIGHT = 60_000;
38+
39+
function sourceWeight(packageDir) {
40+
const srcDir = join(packageDir, 'src');
41+
let total = 0;
42+
const walk = (dir) => {
43+
let entries;
44+
try {
45+
entries = readdirSync(dir, { withFileTypes: true });
46+
} catch {
47+
return;
48+
}
49+
for (const entry of entries) {
50+
const full = join(dir, entry.name);
51+
if (entry.isDirectory()) {
52+
walk(full);
53+
} else if (entry.isFile() && /\.tsx?$/.test(entry.name)) {
54+
total += statSync(full).size;
55+
}
56+
}
57+
};
58+
walk(srcDir);
59+
return total;
60+
}
61+
62+
function collectPackages() {
63+
// The root package.json declares `workspaces: ["packages/**"]`, so packages
64+
// are nested at arbitrary depth (e.g. packages/uc/catalogs). Walk the tree
65+
// and collect every package.json, skipping node_modules and build output.
66+
const packages = [];
67+
const walk = (dir) => {
68+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
69+
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
70+
const full = join(dir, entry.name);
71+
if (entry.isDirectory()) {
72+
walk(full);
73+
} else if (entry.isFile() && entry.name === 'package.json') {
74+
let pkg;
75+
try {
76+
pkg = JSON.parse(readFileSync(full, 'utf8'));
77+
} catch {
78+
continue;
79+
}
80+
if (typeof pkg.name === 'string' && pkg.name.startsWith(PACKAGE_PREFIX)) {
81+
packages.push({ name: pkg.name, weight: BASE_WEIGHT + sourceWeight(dir) });
82+
}
83+
}
84+
}
85+
};
86+
walk(PACKAGES_DIR);
87+
return packages;
88+
}
89+
90+
function partition(packages, shardCount) {
91+
// Largest-processing-time-first: assign the heaviest package to the
92+
// currently lightest shard.
93+
const shards = Array.from({ length: shardCount }, () => ({ pkgs: [], weight: 0 }));
94+
const sorted = [...packages].sort(
95+
(a, b) => b.weight - a.weight || a.name.localeCompare(b.name),
96+
);
97+
for (const pkg of sorted) {
98+
const lightest = shards.reduce((min, s) => (s.weight < min.weight ? s : min));
99+
lightest.pkgs.push(pkg.name);
100+
lightest.weight += pkg.weight;
101+
}
102+
return shards;
103+
}
104+
105+
const shardCount = Number.parseInt(process.argv[2] ?? '8', 10);
106+
const pretty = process.argv.includes('--pretty');
107+
108+
const packages = collectPackages();
109+
const shards = partition(packages, shardCount);
110+
111+
if (pretty) {
112+
const lines = shards.map((s, i) => {
113+
const kb = Math.round(s.weight / 1024);
114+
return `shard ${i + 1}: ${String(s.pkgs.length).padStart(2)} pkgs, ${String(kb).padStart(5)} KB`;
115+
});
116+
const weights = shards.map((s) => s.weight);
117+
const imbalance = ((Math.max(...weights) / Math.min(...weights) - 1) * 100).toFixed(1);
118+
process.stderr.write(
119+
`${packages.length} packages -> ${shardCount} shards\n${lines.join('\n')}\n` +
120+
`max/min weight imbalance: ${imbalance}%\n`,
121+
);
122+
}
123+
124+
const matrix = shards.map((s, i) => ({
125+
id: String(i + 1),
126+
pkgs: [...s.pkgs].sort().join(' '),
127+
}));
128+
process.stdout.write(JSON.stringify(matrix));

0 commit comments

Comments
 (0)