Skip to content

Commit 1858db8

Browse files
authored
ci: shard Windows go test into 6 balanced parallel jobs (LPT controller) (#95)
Windows go test took ~15 min: ~330 serial tests, each spawning several git subprocesses, and Windows CreateProcess+Defender scan is ~10x Linux. Inline Defender exclusions were already applied and are not enough. In-process t.Parallel() is unsafe (the package swaps ~14 global function-seams), so shard across processes. - .github/scripts/ci_shard.py: LPT makespan-minimization shard selector (stdin test list -> go test -run regex; --timings calibration; --verify). - .github/scripts/test_ci_shard.py: partition/determinism/balance unit tests. - ci.yml: drop windows from the test matrix; add a sharded test-windows job (shard 0..5); auto-merge-sync now needs [test, test-windows]. Mirrors operatorstack/intelligence-flow (.github is control-plane, not projected by labkit). Note: renames the Windows required status check; branch protection must be updated after merge.
1 parent 0fe1e07 commit 1858db8

4 files changed

Lines changed: 436 additions & 27 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Windows CI test sharding
2+
3+
## The problem
4+
5+
The Boatstack Go suite (`boatstack/`) is ~330 tests in one package. Almost every
6+
test builds a real on-disk git repo, spawning several `git` subprocesses — on the
7+
order of 1,000–2,000 process spawns across the suite, run **serially**
8+
(`t.Parallel()` is not used anywhere).
9+
10+
On Linux/macOS this is ~1–2 min. On Windows each `CreateProcess` (plus Microsoft
11+
Defender scanning the freshly written compile/link output) costs ~10× the Linux
12+
`fork+exec`, so the serial suite took **~15 min** — the process-spawn *latency*,
13+
not CPU, is the entire gap. Inline Defender exclusions in `ci.yml` were already
14+
applied and are not enough on their own.
15+
16+
In-process `t.Parallel()` is **not** a safe fix here: the package swaps ~14
17+
mutable package-global function-seams (`runGitCommand`, `operationNow`,
18+
`hookDiagnosticRunner`, `fetchLatestRelease`, …) and uses many `t.Setenv` sites.
19+
Parallel tests within one process would race on that shared global state.
20+
21+
## The fix: job-level sharding
22+
23+
Run the Windows suite as **N separate runner processes**, each executing a
24+
disjoint, balanced subset of tests serially. Globals are per-process, so each
25+
shard keeps today's exact serial semantics; wall-clock drops ~N×. We use N=6,
26+
targeting a slowest-shard time well under 5 min.
27+
28+
`.github/workflows/ci.yml` runs Unix (`test` job) as the full, unsharded
29+
correctness reference and Windows (`test-windows` job) as a
30+
`matrix: { shard: [0..5] }`. Each shard (working-directory `boatstack`):
31+
32+
```bash
33+
regex=$(go test -list '^Test' ./... | python .github/scripts/ci_shard.py --total 6 --index <shard>)
34+
go test -run "$regex" ./...
35+
```
36+
37+
`go test -list` and `go test -run` share the warm GOCACHE within a job, so the
38+
second compile is a cache hit.
39+
40+
## The controller: `ci_shard.py`
41+
42+
Assigning tests to shards to minimize the slowest shard is the classic
43+
multiprocessor-scheduling / makespan-minimization problem (P || Cmax, NP-hard).
44+
`ci_shard.py` uses the standard **LPT (Longest-Processing-Time-first) greedy**
45+
approximation (a 4/3 bound on optimal makespan): sort tests by descending
46+
estimated cost, place each on the currently-lightest shard.
47+
48+
- **Default weight** is 1 per test (count-balanced) — already good because heavy
49+
tests are spread across many names.
50+
- **Calibration (optional):** pass `--timings <json>` (a `{test-name: seconds}`
51+
map from a prior `go test -json` run) to weight by measured runtime and close
52+
the loop against the real cost envelope. No profile is committed yet; the
53+
controller degrades gracefully to count-balancing without one.
54+
- `--verify` asserts the shards partition the input exactly (no dropped or
55+
duplicated test). This invariant is unit-tested in `test_ci_shard.py`, which
56+
the `ci-policy` workflow runs — a broken partition can't merge.
57+
58+
## Keep the two copies in sync
59+
60+
`.github/` is control-plane and is **not** projected by labkit, so the upstream
61+
`operatorstack/intelligence-flow` monorepo carries its own copy of `ci_shard.py`
62+
and its own sharded `runtime-windows` job in
63+
`.github/workflows/boatstack-lab.yml`. When you change the controller here,
64+
mirror it there (and vice versa).
65+
66+
## Operational note
67+
68+
Sharding renames the Windows status check (`test (windows-latest)`
69+
`test-windows (shard 0..5)`). Update the branch-protection required-status-checks
70+
after merging, or PRs will wait on a check that no longer runs.

.github/scripts/ci_shard.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#!/usr/bin/env python3
2+
"""Balanced test-shard selector — a makespan-minimization controller for CI.
3+
4+
Why this exists
5+
---------------
6+
The Boatstack Go suite (~325 tests in one package) runs strictly serially, and
7+
almost every test spawns several `git` subprocesses. On Windows each process
8+
spawn is ~10x costlier than on Linux, so the serial suite takes ~15 min there
9+
(vs ~1 min on Linux) — process-spawn *latency*, not CPU, is the bottleneck.
10+
In-process `t.Parallel()` is unsafe here because the package swaps ~14 mutable
11+
global function-seams; parallel tests would race. The safe lever is job-level
12+
sharding: run N shards as N separate processes (globals are per-process), each
13+
running a disjoint subset of tests serially, so wall-clock drops ~Nx.
14+
15+
Control-theory framing
16+
----------------------
17+
Assigning tests to shards to minimize the slowest shard is the classic
18+
multiprocessor-scheduling / makespan-minimization problem (P || Cmax), which is
19+
NP-hard. We use the standard **LPT (Longest-Processing-Time-first) greedy**
20+
approximation: sort tests by descending estimated cost, then place each on the
21+
currently-lightest shard. LPT is a 4/3-approximation of optimal makespan.
22+
23+
Cost is a per-test weight. With no data every test weighs 1 (count-balanced,
24+
which is already good because the heavy tests are spread across many names).
25+
Passing `--timings <json>` (a map of test-name -> measured seconds) closes the
26+
loop with the measured runtime envelope, matching the calibration pattern used
27+
elsewhere in the repo.
28+
29+
Usage
30+
-----
31+
go test -list '^Test' ./... | \
32+
python .github/scripts/ci_shard.py --total 6 --index 0
33+
# -> prints a `go test -run` regex: ^(TestA|TestB|...)$
34+
35+
... | python .github/scripts/ci_shard.py --total 6 --verify
36+
# -> exits non-zero if the 6 shards don't partition the input exactly
37+
38+
The workflow captures the printed regex and runs `go test -run "<regex>" ./...`.
39+
An empty selection prints nothing (exit 0); the caller must treat empty as
40+
"skip this shard", never as `go test -run ''` (which would run everything).
41+
"""
42+
from __future__ import annotations
43+
44+
import argparse
45+
import json
46+
import re
47+
import sys
48+
49+
# `go test -list` prints one test name per line plus a trailing "ok <pkg> <t>"
50+
# summary line (and possibly blank lines). Real test names are Go identifiers
51+
# beginning with "Test"; keep only those.
52+
TEST_NAME = re.compile(r"^Test[A-Za-z0-9_]*$")
53+
54+
55+
def read_test_names(stream) -> list[str]:
56+
"""Parse `go test -list` output from a stream into a sorted, de-duped list."""
57+
names = set()
58+
for line in stream:
59+
name = line.strip()
60+
if TEST_NAME.match(name):
61+
names.add(name)
62+
return sorted(names)
63+
64+
65+
def assign_shards(names: list[str], total: int, timings: dict[str, float]) -> list[list[str]]:
66+
"""Partition `names` into `total` shards via LPT greedy on estimated cost.
67+
68+
Returns a list of `total` shard lists. Deterministic: tests are ordered by
69+
(descending weight, name) before placement, and ties in shard load are
70+
broken by lowest shard index, so the same input always yields the same
71+
partition regardless of platform or run.
72+
"""
73+
if total < 1:
74+
raise ValueError("--total must be >= 1")
75+
76+
# Descending weight, then name, for a stable ordering.
77+
ordered = sorted(names, key=lambda n: (-float(timings.get(n, 1.0)), n))
78+
79+
shards: list[list[str]] = [[] for _ in range(total)]
80+
loads = [0.0] * total
81+
for name in ordered:
82+
# Lightest shard wins; ties -> lowest index (min is stable on first).
83+
target = min(range(total), key=lambda i: (loads[i], i))
84+
shards[target].append(name)
85+
loads[target] += float(timings.get(name, 1.0))
86+
87+
# Emit each shard sorted by name for readable, stable regexes.
88+
return [sorted(shard) for shard in shards]
89+
90+
91+
def shard_regex(shard: list[str]) -> str:
92+
"""Build an anchored `go test -run` alternation for one shard.
93+
94+
Go test names are identifiers, but escape defensively so a stray character
95+
can never turn into an unintended regex. Empty shard -> empty string.
96+
"""
97+
if not shard:
98+
return ""
99+
alternation = "|".join(re.escape(name) for name in shard)
100+
return f"^({alternation})$"
101+
102+
103+
def main(argv: list[str] | None = None) -> int:
104+
parser = argparse.ArgumentParser(description=__doc__)
105+
parser.add_argument("--total", type=int, required=True, help="Number of shards.")
106+
parser.add_argument("--index", type=int, help="Shard index to emit (0-based).")
107+
parser.add_argument(
108+
"--timings",
109+
help="Optional JSON file mapping test name -> measured seconds (LPT weights).",
110+
)
111+
parser.add_argument(
112+
"--verify",
113+
action="store_true",
114+
help="Assert the shards partition the input exactly; print a summary; no regex.",
115+
)
116+
args = parser.parse_args(argv)
117+
118+
if args.total < 1:
119+
print("error: --total must be >= 1", file=sys.stderr)
120+
return 2
121+
if not args.verify and args.index is None:
122+
print("error: --index is required unless --verify is set", file=sys.stderr)
123+
return 2
124+
if args.index is not None and not (0 <= args.index < args.total):
125+
print(f"error: --index must be in [0, {args.total})", file=sys.stderr)
126+
return 2
127+
128+
timings: dict[str, float] = {}
129+
if args.timings:
130+
with open(args.timings, "r", encoding="utf-8") as fh:
131+
timings = {str(k): float(v) for k, v in json.load(fh).items()}
132+
133+
names = read_test_names(sys.stdin)
134+
shards = assign_shards(names, args.total, timings)
135+
136+
if args.verify:
137+
assigned = [n for shard in shards for n in shard]
138+
if sorted(assigned) != names or len(assigned) != len(names):
139+
print(
140+
"error: shards do not partition the input exactly "
141+
f"(input={len(names)}, assigned={len(assigned)})",
142+
file=sys.stderr,
143+
)
144+
return 1
145+
sizes = ", ".join(f"#{i}={len(s)}" for i, s in enumerate(shards))
146+
print(f"partition OK: {len(names)} tests across {args.total} shards ({sizes})")
147+
return 0
148+
149+
print(shard_regex(shards[args.index]))
150+
return 0
151+
152+
153+
if __name__ == "__main__":
154+
raise SystemExit(main())

.github/scripts/test_ci_shard.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/usr/bin/env python3
2+
"""Unit tests for the balanced test-shard selector (ci_shard.py).
3+
4+
Run: python .github/scripts/test_ci_shard.py
5+
The CI-speed-policy workflow runs these so the sharding controller is itself
6+
gated — a broken partition would silently drop tests from every shard.
7+
"""
8+
from __future__ import annotations
9+
10+
import io
11+
import unittest
12+
13+
import ci_shard
14+
15+
16+
def names(n: int) -> list[str]:
17+
return [f"Test{i:03d}" for i in range(n)]
18+
19+
20+
class ReadTestNames(unittest.TestCase):
21+
def test_filters_non_test_lines(self):
22+
raw = "TestAlpha\nTestBeta\nok \texample/pkg\t1.2s\n\nhelperFunc\n"
23+
got = ci_shard.read_test_names(io.StringIO(raw))
24+
self.assertEqual(got, ["TestAlpha", "TestBeta"])
25+
26+
def test_dedupes_and_sorts(self):
27+
raw = "TestB\nTestA\nTestB\n"
28+
self.assertEqual(ci_shard.read_test_names(io.StringIO(raw)), ["TestA", "TestB"])
29+
30+
31+
class Partition(unittest.TestCase):
32+
def test_union_is_input_no_overlap(self):
33+
for total in (1, 2, 3, 6, 7):
34+
for count in (0, 1, 5, 50, 325):
35+
ns = names(count)
36+
shards = ci_shard.assign_shards(ns, total, {})
37+
self.assertEqual(len(shards), total)
38+
flat = [n for s in shards for n in s]
39+
self.assertEqual(sorted(flat), ns, (total, count))
40+
self.assertEqual(len(flat), len(set(flat)), (total, count))
41+
42+
def test_deterministic(self):
43+
ns = names(97)
44+
a = ci_shard.assign_shards(ns, 6, {})
45+
b = ci_shard.assign_shards(list(reversed(ns)), 6, {})
46+
self.assertEqual(a, b)
47+
48+
def test_count_balanced_without_timings(self):
49+
shards = ci_shard.assign_shards(names(300), 6, {})
50+
sizes = [len(s) for s in shards]
51+
self.assertLessEqual(max(sizes) - min(sizes), 1)
52+
53+
def test_lpt_balances_weighted_load(self):
54+
# One very heavy test plus many light ones: LPT must isolate the heavy
55+
# one and spread the rest so the makespan stays near optimal.
56+
ns = names(20)
57+
timings = {ns[0]: 100.0}
58+
for n in ns[1:]:
59+
timings[n] = 1.0
60+
shards = ci_shard.assign_shards(ns, 4, timings)
61+
loads = [sum(timings[n] for n in s) for s in shards]
62+
# Optimal makespan is dominated by the 100s test; LPT must not exceed it
63+
# by more than one light unit of slack per the 4/3 bound on this input.
64+
self.assertLessEqual(max(loads), 106.0)
65+
66+
def test_more_shards_than_tests_leaves_empties(self):
67+
shards = ci_shard.assign_shards(names(2), 6, {})
68+
non_empty = [s for s in shards if s]
69+
self.assertEqual(sum(len(s) for s in shards), 2)
70+
self.assertEqual(len(non_empty), 2)
71+
72+
73+
class Regex(unittest.TestCase):
74+
def test_anchored_alternation(self):
75+
self.assertEqual(ci_shard.shard_regex(["TestA", "TestB"]), "^(TestA|TestB)$")
76+
77+
def test_empty_shard_is_empty_string(self):
78+
self.assertEqual(ci_shard.shard_regex([]), "")
79+
80+
def test_escapes_metacharacters(self):
81+
# Defensive: a name with regex metacharacters must be escaped, not
82+
# interpreted, so it can never widen the selection.
83+
self.assertEqual(ci_shard.shard_regex(["Test.A+"]), r"^(Test\.A\+)$")
84+
85+
86+
class Cli(unittest.TestCase):
87+
def _run(self, argv, stdin_text):
88+
import contextlib
89+
90+
out, err = io.StringIO(), io.StringIO()
91+
stdin = io.StringIO(stdin_text)
92+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
93+
import sys
94+
95+
saved = sys.stdin
96+
sys.stdin = stdin
97+
try:
98+
code = ci_shard.main(argv)
99+
finally:
100+
sys.stdin = saved
101+
return code, out.getvalue(), err.getvalue()
102+
103+
def test_index_prints_regex(self):
104+
code, out, _ = self._run(["--total", "2", "--index", "0"], "TestA\nTestB\n")
105+
self.assertEqual(code, 0)
106+
self.assertTrue(out.strip().startswith("^("))
107+
108+
def test_verify_ok(self):
109+
stdin = "".join(f"{n}\n" for n in names(50))
110+
code, out, _ = self._run(["--total", "6", "--verify"], stdin)
111+
self.assertEqual(code, 0)
112+
self.assertIn("partition OK", out)
113+
114+
def test_index_out_of_range(self):
115+
code, _, err = self._run(["--total", "2", "--index", "5"], "TestA\n")
116+
self.assertEqual(code, 2)
117+
self.assertIn("--index", err)
118+
119+
def test_missing_index_without_verify(self):
120+
code, _, err = self._run(["--total", "2"], "TestA\n")
121+
self.assertEqual(code, 2)
122+
self.assertIn("--index is required", err)
123+
124+
def test_empty_shard_prints_nothing(self):
125+
# 2 tests, 6 shards: shards 2..5 are empty -> empty stdout, exit 0.
126+
code, out, _ = self._run(["--total", "6", "--index", "5"], "TestA\nTestB\n")
127+
self.assertEqual(code, 0)
128+
self.assertEqual(out.strip(), "")
129+
130+
131+
if __name__ == "__main__":
132+
unittest.main()

0 commit comments

Comments
 (0)