Skip to content

Commit 72d4edd

Browse files
committed
feat(parity): implement sharding for parallel CI jobs and add tests for partitioning
1 parent 28f7ee7 commit 72d4edd

3 files changed

Lines changed: 112 additions & 6 deletions

File tree

.github/workflows/go-cli.yml

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,15 +138,25 @@ jobs:
138138
# ~45 min, so scripts/parity-affected.sh maps the diff to a
139139
# PARITY_COMMAND allowlist (or "all"/"none"). The daily parity-runtime-full job
140140
# is the backstop that always runs the whole thing.
141-
parity-runtime:
141+
parity-runtime-shard:
142142
if: github.event_name == 'push' || github.event_name == 'pull_request'
143143
runs-on: ubuntu-latest
144144
needs: lint-and-test
145+
strategy:
146+
# Split the affected runtime cases across independent runners (each with its
147+
# own docker daemon). fail-fast off so one shard's failure still reports the
148+
# others. The harness selects this shard's slice via PARITY_SHARD_INDEX.
149+
fail-fast: false
150+
matrix:
151+
shard: [0, 1]
145152
env:
146153
PARITY_RUNTIME_TIMEOUT: "10m"
147-
# 2-core hosted runners can't sustain 2 concurrent heavy docker builds; run
148-
# serially for gate determinism (override with PARITY_PARALLEL for beefy runners).
149-
PARITY_PARALLEL: "1"
154+
# Two shards, each running two cases at a time: the per-case isolation
155+
# (unique id-labels / COMPOSE_PROJECT_NAME / BUILDX_BUILDER) makes -parallel 2
156+
# safe on a 2-core runner now that each runner only owns half the cases.
157+
PARITY_PARALLEL: "2"
158+
PARITY_SHARD_INDEX: ${{ matrix.shard }}
159+
PARITY_SHARD_TOTAL: "2"
150160
steps:
151161
- uses: actions/checkout@v7
152162
with:
@@ -193,12 +203,28 @@ jobs:
193203
- uses: actions/upload-artifact@v7
194204
if: always() && steps.affected.outputs.run == 'true'
195205
with:
196-
name: parity-runtime-v0.88.0
206+
name: parity-runtime-v0.88.0-shard-${{ matrix.shard }}
197207
path: artifacts/
198208
if-no-files-found: error
199209
- if: always() && steps.affected.outputs.run == 'true'
200210
run: task clean
201211

212+
# Stable required-check name aggregating the sharded runtime lane, so sharding
213+
# does not change the check name branch protection depends on.
214+
parity-runtime:
215+
if: always() && (github.event_name == 'push' || github.event_name == 'pull_request')
216+
runs-on: ubuntu-latest
217+
needs: parity-runtime-shard
218+
steps:
219+
- name: Gate on all runtime shards
220+
run: |
221+
result="${{ needs.parity-runtime-shard.result }}"
222+
echo "parity-runtime-shard aggregate result: $result"
223+
case "$result" in
224+
success|skipped) exit 0 ;;
225+
*) echo "one or more runtime shards failed"; exit 1 ;;
226+
esac
227+
202228
# Full runtime parity matrix + publish parity. Runs once a day (schedule) and
203229
# on manual dispatch — the exhaustive backstop for the scoped push/PR job above.
204230
parity-runtime-full:
@@ -207,7 +233,10 @@ jobs:
207233
needs: lint-and-test
208234
env:
209235
PARITY_RUNTIME_TIMEOUT: "10m"
210-
PARITY_PARALLEL: "1"
236+
# Exhaustive daily run on a dedicated (non-gating) job: raise parallelism to
237+
# 3 now that per-case isolation makes concurrent builds safe. Not sharded
238+
# here because this lane also measures coverage (single covdata merge).
239+
PARITY_PARALLEL: "3"
211240
steps:
212241
- uses: actions/checkout@v7
213242
with:

internal/cli/parity_matrix_helpers_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,34 @@ func TestExtractCLIResultEnv_EmbeddedJSON(t *testing.T) {
167167
t.Fatalf("IMAGE_NAME = %q", got["IMAGE_NAME"])
168168
}
169169
}
170+
171+
// TestInShardPartitions proves the shard split is a proper partition: with N
172+
// shards, every case is claimed by exactly one shard and the union is the whole
173+
// set (no case dropped, none run twice).
174+
func TestInShardPartitions(t *testing.T) {
175+
const cases = 189
176+
for _, total := range []int{1, 2, 3, 4, 7} {
177+
claimed := make([]int, cases)
178+
for shard := 0; shard < total; shard++ {
179+
for i := 0; i < cases; i++ {
180+
if inShard(i, shard, total) {
181+
claimed[i]++
182+
}
183+
}
184+
}
185+
for i, c := range claimed {
186+
if c != 1 {
187+
t.Errorf("total=%d: case %d claimed by %d shards, want exactly 1", total, i, c)
188+
}
189+
}
190+
}
191+
}
192+
193+
// TestInShardNoShardingRunsEverything proves total<=1 disables sharding.
194+
func TestInShardNoShardingRunsEverything(t *testing.T) {
195+
for i := 0; i < 10; i++ {
196+
if !inShard(i, 0, 1) {
197+
t.Errorf("inShard(%d,0,1) = false, want every case to run when unsharded", i)
198+
}
199+
}
200+
}

internal/cli/parity_matrix_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"path/filepath"
2929
"regexp"
3030
"sort"
31+
"strconv"
3132
"strings"
3233
"sync"
3334
"testing"
@@ -243,6 +244,14 @@ func TestParityMatrix(t *testing.T) {
243244

244245
dockerAvailable := isDockerAvailable()
245246

247+
// Sharding: split the selected cases round-robin across parallel CI jobs, so
248+
// N runners (with independent docker daemons) share the wall-clock. Inert when
249+
// PARITY_SHARD_TOTAL is unset or <= 1. Cases in other shards stay not-selected,
250+
// which the strict gate ignores (it only fails on inconclusive cases), so each
251+
// shard gates its own subset and the union covers everything exactly once.
252+
shardIndex, shardTotal := parityShard(t)
253+
selected := 0
254+
246255
for _, tc := range matrix.InitialCases {
247256
tc := tc
248257
if !matchesFilter(tc) {
@@ -253,6 +262,13 @@ func TestParityMatrix(t *testing.T) {
253262
if tc.Lane == "runtime" && os.Getenv("PARITY_LANE") == "" {
254263
continue
255264
}
265+
// Round-robin over the selected set (stable matrix order); consecutive
266+
// cases land on different shards, which spreads the heavy build.* cases.
267+
caseIndex := selected
268+
selected++
269+
if !inShard(caseIndex, shardIndex, shardTotal) {
270+
continue
271+
}
256272

257273
t.Run(tc.ID, func(t *testing.T) {
258274
outcome := parityMatched
@@ -1075,6 +1091,36 @@ func envOr(key, fallback string) string {
10751091
return fallback
10761092
}
10771093

1094+
// inShard reports whether the case at caseIndex (its position in the selected
1095+
// stream) belongs to shard index of total. total <= 1 means no sharding (every
1096+
// case runs). The modulo partitions the stream into disjoint shards whose union
1097+
// is the whole set.
1098+
func inShard(caseIndex, index, total int) bool {
1099+
return total <= 1 || caseIndex%total == index
1100+
}
1101+
1102+
// parityShard reads PARITY_SHARD_INDEX / PARITY_SHARD_TOTAL for CI sharding.
1103+
// Total defaults to 1 (no sharding). An invalid pair fails the test so a
1104+
// misconfigured matrix is loud rather than silently skipping cases.
1105+
func parityShard(t *testing.T) (index, total int) {
1106+
total = 1
1107+
if v := os.Getenv("PARITY_SHARD_TOTAL"); v != "" {
1108+
n, err := strconv.Atoi(v)
1109+
if err != nil || n < 1 {
1110+
t.Fatalf("PARITY_SHARD_TOTAL=%q: must be a positive integer", v)
1111+
}
1112+
total = n
1113+
}
1114+
if v := os.Getenv("PARITY_SHARD_INDEX"); v != "" {
1115+
n, err := strconv.Atoi(v)
1116+
if err != nil || n < 0 || n >= total {
1117+
t.Fatalf("PARITY_SHARD_INDEX=%q: must be in [0,%d)", v, total)
1118+
}
1119+
index = n
1120+
}
1121+
return index, total
1122+
}
1123+
10781124
func isDockerAvailable() bool {
10791125
cmd := exec.Command("docker", "info")
10801126
cmd.Stdout = nil

0 commit comments

Comments
 (0)