Skip to content

Commit c9a5422

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 c9a5422

3 files changed

Lines changed: 162 additions & 66 deletions

File tree

.github/workflows/ci.yml

Lines changed: 33 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -16,51 +16,45 @@ concurrency:
1616
cancel-in-progress: true
1717

1818
jobs:
19-
lint:
20-
name: Lint (${{ matrix.shard }})
19+
# Partition the workspace packages into balanced shards. Alphabetical
20+
# sharding put ~43% of packages in the [s-z] shard (the `uc-*` packages all
21+
# start with `u`), making it the CI long pole. This computes shards balanced
22+
# by source weight so every static-check leg finishes at roughly the same
23+
# time. It only reads package.json files, so it needs no dependency install.
24+
setup:
25+
name: Setup shards
2126
runs-on:
2227
group: databricks-protected-runner-group
2328
labels: linux-ubuntu-latest
24-
strategy:
25-
fail-fast: false
26-
matrix:
27-
# Shards partition packages by the first letter after `@databricks/sdk-`.
28-
# Together the four ranges cover a-z.
29-
shard: ['[a-c]*', '[d-l]*', '[m-r]*', '[s-z]*']
29+
outputs:
30+
shards: ${{ steps.shards.outputs.shards }}
3031
steps:
3132
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
3233

33-
- name: Setup JFrog CLI
34-
uses: jfrog/setup-jfrog-cli@279b1f629f43dd5bc658d8361ac4802a7ef8d2d5 # v4.9.1
35-
env:
36-
JF_URL: https://databricks.jfrog.io
37-
with:
38-
oidc-provider-name: github-actions
39-
4034
- name: Setup Node.js
4135
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
4236
with:
4337
node-version: '22'
44-
cache: 'npm'
45-
46-
- name: Configure npm for JFrog
47-
run: jf npmc --repo-resolve=db-npm
48-
49-
- name: Install dependencies
50-
run: jf npm ci
5138

52-
- name: Run lint and format checks
53-
run: npm run checks -- --filter='@databricks/sdk-${{ matrix.shard }}'
54-
55-
typecheck:
56-
name: Type Check (${{ matrix.shard }})
39+
- name: Compute balanced shards
40+
id: shards
41+
run: echo "shards=$(node scripts/ci-shards.mjs 8)" >> "$GITHUB_OUTPUT"
42+
43+
# Build, lint, and format-check each shard in one job. Lint and build both
44+
# depend on `^build`, so running them together builds each shard's dependency
45+
# closure once instead of repeating it across separate jobs. The build task is
46+
# `tsc -b`, which type-checks `src` while emitting, so a standalone `tsc
47+
# --noEmit` typecheck over the same files would be redundant and is omitted.
48+
static:
49+
name: Static (shard ${{ matrix.shard.id }})
50+
needs: setup
5751
runs-on:
5852
group: databricks-protected-runner-group
5953
labels: linux-ubuntu-latest
6054
strategy:
6155
fail-fast: false
6256
matrix:
63-
shard: ['[a-c]*', '[d-l]*', '[m-r]*', '[s-z]*']
57+
shard: ${{ fromJSON(needs.setup.outputs.shards) }}
6458
steps:
6559
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
6660

@@ -83,8 +77,16 @@ jobs:
8377
- name: Install dependencies
8478
run: jf npm ci
8579

86-
- name: Run type check
87-
run: npm run typecheck -- --filter='@databricks/sdk-${{ matrix.shard }}'
80+
- name: Build, lint, and format-check shard
81+
shell: bash
82+
env:
83+
SHARD_PKGS: ${{ matrix.shard.pkgs }}
84+
run: |
85+
filters=""
86+
for pkg in $SHARD_PKGS; do
87+
filters="$filters --filter=$pkg"
88+
done
89+
npm run ci:verify -- $filters
8890
8991
test:
9092
name: Test (Node.js ${{ matrix.node-version }})
@@ -157,41 +159,6 @@ jobs:
157159
- name: Run browser tests
158160
run: npm run test:browser
159161

160-
build:
161-
name: Build (Node.js ${{ matrix.node-version }}, ${{ matrix.shard }})
162-
runs-on:
163-
group: databricks-protected-runner-group
164-
labels: linux-ubuntu-latest
165-
strategy:
166-
fail-fast: false
167-
matrix:
168-
node-version: ['22', '24']
169-
shard: ['[a-c]*', '[d-l]*', '[m-r]*', '[s-z]*']
170-
steps:
171-
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
172-
173-
- name: Setup JFrog CLI
174-
uses: jfrog/setup-jfrog-cli@279b1f629f43dd5bc658d8361ac4802a7ef8d2d5 # v4.9.1
175-
env:
176-
JF_URL: https://databricks.jfrog.io
177-
with:
178-
oidc-provider-name: github-actions
179-
180-
- name: Setup Node.js ${{ matrix.node-version }}
181-
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
182-
with:
183-
node-version: ${{ matrix.node-version }}
184-
cache: 'npm'
185-
186-
- name: Configure npm for JFrog
187-
run: jf npmc --repo-resolve=db-npm
188-
189-
- name: Install dependencies
190-
run: jf npm ci
191-
192-
- name: Build
193-
run: npm run build -- --filter='@databricks/sdk-${{ matrix.shard }}'
194-
195162
check-licenses:
196163
name: Check Licenses
197164
runs-on:
@@ -214,7 +181,7 @@ jobs:
214181
ci-gate:
215182
name: CI Gate
216183
if: always()
217-
needs: [lint, typecheck, test, test-browser, build, check-licenses]
184+
needs: [setup, static, test, test-browser, check-licenses]
218185
runs-on:
219186
group: databricks-protected-runner-group
220187
labels: linux-ubuntu-latest

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)