Files
pulse/.github/scripts/select-internal-api-shard.sh
T
courtmanr@gmail.com 06d78b66d3 Balance internal/api race shards by measured run time
The four Backend tests (api-N) shards took equal-count quarters of go
test's list, which left them at 4.0, 10.6, 4.0 and 16.8 minutes on the
first PR run, and at 1.9, 8.0, 1.5 and 10.3 minutes of go test time on
the following main push (run 36560070496). Almost all of it sits in a
few tests. One contract mock chart test runs about six minutes on the
runner, and the 28 integration server tests at the end of the list take
about twenty seconds each, so the fourth quarter held nearly half the
package.

Shards are still contiguous runs of go test's own order, since some
tests depend on package state from their neighbours, but the cut points
now come from estimated seconds.
.github/scripts/select-internal-api-shard.sh weighs each test from
.github/scripts/internal-api-test-seconds.txt (the 199 tests at 0.5s or
more, seeded from a local -race -json run scaled to that main run's
shard totals, with a 0.05s default for anything unlisted), binary
searches the smallest per-shard budget that fits the list into four
slices, and fills up to it. The weights only move cut points. Every test
is assigned exactly one shard in a single pass, so a stale file can
unbalance shards but never drop or repeat a test, and no shard is ever
empty.

With today's list the slices hold 1187, 23, 2556 and 21 tests, estimated
at 1.9, 6.9, 6.5 and 6.7 minutes of tests, or about 9.5 minutes per job
with the roughly 2.6 minutes of setup and race compile. The 2556-test
slice would need a 124 KB -run pattern, so a shard passes -skip of the
other shards' tests whenever that is shorter, which runs the same tests
in the same order.
2026-09-29 13:14:27 +01:00

108 lines
3.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# Print the internal/api top-level tests that belong to one CI shard.
#
# Usage: select-internal-api-shard.sh <weights-file> <shard-count> <shard-index>
# The full test list, in go test's own order, is read from stdin.
#
# Shards are contiguous runs of go test's order, because some internal/api
# tests still depend on package state left by the tests just before them.
# Cut points are chosen by estimated run time instead of test count. Every
# test weighs its seconds from the weights file, or DEFAULT_WEIGHT when the
# file does not name it (new tests, renamed tests). The smallest per-shard
# capacity that lets the list be cut into at most <shard-count> contiguous
# runs is found by binary search, then the list is filled greedily up to that
# capacity. If fewer runs than shards would result, the tail is cut early so
# every shard still gets at least one test.
#
# The weights only move the cut points. Each test is assigned exactly one
# shard index in 0..count-1 by a single pass over the list, so a stale,
# missing or wrong weight can make shards uneven but can never drop or repeat
# a test.
set -euo pipefail
if [ "$#" -ne 3 ]; then
echo "usage: $0 <weights-file> <shard-count> <shard-index> < tests" >&2
exit 2
fi
weights=$1
count=$2
index=$3
case "$count" in '' | *[!0-9]*) echo "shard count must be a positive integer" >&2; exit 2 ;; esac
case "$index" in '' | *[!0-9]*) echo "shard index must be a non-negative integer" >&2; exit 2 ;; esac
if [ "$count" -lt 1 ] || [ "$index" -ge "$count" ]; then
echo "shard index ${index} is outside 0..$((count - 1))" >&2
exit 2
fi
if [ ! -f "$weights" ]; then
echo "weights file ${weights} not found" >&2
exit 2
fi
awk -v n="$count" -v want="$index" -v default_weight="${DEFAULT_WEIGHT:-0.05}" '
function centis(seconds, c) {
c = int(seconds * 100 + 0.5)
return c < 1 ? 1 : c
}
# Number of contiguous runs a greedy fill up to capacity cap produces.
function runs(cap, k, used, groups) {
groups = 1
used = 0
for (k = 1; k <= total; k++) {
if (used > 0 && used + cost[k] > cap) {
groups++
used = 0
}
used += cost[k]
}
return groups
}
FILENAME == ARGV[1] {
if ($0 ~ /^[[:space:]]*(#|$)/) next
if (NF != 2 || $2 !~ /^[0-9]+(\.[0-9]+)?$/ || $2 + 0 <= 0) {
printf "invalid weights line %d: %s\n", FNR, $0 > "/dev/stderr"
bad = 1
exit 1
}
weight[$1] = $2 + 0
next
}
NF {
total++
name[total] = $1
cost[total] = centis(($1 in weight) ? weight[$1] : default_weight)
sum += cost[total]
if (cost[total] > heaviest) heaviest = cost[total]
}
END {
if (bad) exit 1
if (total < n) {
printf "%d tests cannot fill %d shards\n", total, n > "/dev/stderr"
exit 1
}
lo = heaviest
hi = sum
while (lo < hi) {
mid = int((lo + hi) / 2)
if (runs(mid) <= n) hi = mid
else lo = mid + 1
}
shard = 0
used = 0
for (k = 1; k <= total; k++) {
if (used > 0 && used + cost[k] > lo) {
shard++
used = 0
}
used += cost[k]
if (shard == want) print name[k]
# Once the tests left equal the shards left, give each its own shard
# so no shard is empty.
if (total - k > 0 && total - k == n - 1 - shard) {
shard++
used = 0
}
}
}
' "$weights" -