Skip to content

Commit 7f95b79

Browse files
committed
ci: enforce CLI shard SLA
1 parent 6bb6d64 commit 7f95b79

4 files changed

Lines changed: 133 additions & 7 deletions

File tree

.github/workflows/main.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ jobs:
9393
path: artifacts/test-shards-summary
9494
retention-days: 30
9595

96+
- name: Enforce shard SLA
97+
run: python3 scripts/check-test-shard-sla.py
98+
artifacts/test-shards-summary/shards.csv
99+
--expected-shards 8
100+
--max-elapsed-seconds 720
101+
96102
test-cli:
97103
runs-on: ubuntu-latest
98104
strategy:

scripts/check-test-shard-sla.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env python3
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import csv
7+
from pathlib import Path
8+
9+
10+
def main() -> None:
11+
parser = argparse.ArgumentParser(description="Enforce completeness and elapsed-time SLAs for test shards.")
12+
parser.add_argument("shards_csv", type=Path)
13+
parser.add_argument("--expected-shards", type=int, required=True)
14+
parser.add_argument("--max-elapsed-seconds", type=float, required=True)
15+
args = parser.parse_args()
16+
17+
with args.shards_csv.open(encoding="utf-8", newline="") as stream:
18+
rows = list(csv.DictReader(stream))
19+
20+
errors: list[str] = []
21+
rows_by_shard: dict[int, dict[str, str]] = {}
22+
for row in rows:
23+
index = int(row["shard"])
24+
if index in rows_by_shard:
25+
errors.append(f"shard {index}: duplicate diagnostics")
26+
rows_by_shard[index] = row
27+
28+
expected = set(range(args.expected_shards))
29+
actual = set(rows_by_shard)
30+
for index in sorted(expected - actual):
31+
errors.append(f"shard {index}: diagnostics are missing")
32+
for index in sorted(actual - expected):
33+
errors.append(f"shard {index}: unexpected diagnostics")
34+
35+
for index in sorted(expected & actual):
36+
row = rows_by_shard[index]
37+
assigned = int(row["assigned"])
38+
reported = int(row["reported"])
39+
passed = int(row["passed"])
40+
failed = int(row["failed"])
41+
42+
if reported != assigned:
43+
errors.append(f"shard {index}: reported {reported} of {assigned} assigned tests")
44+
if failed:
45+
errors.append(f"shard {index}: {failed} tests failed")
46+
if passed != reported:
47+
errors.append(f"shard {index}: only {passed} of {reported} reported tests passed")
48+
49+
elapsed_value = row.get("elapsed_seconds", "")
50+
if not elapsed_value:
51+
errors.append(f"shard {index}: elapsed time is missing")
52+
continue
53+
elapsed_seconds = float(elapsed_value)
54+
if elapsed_seconds > args.max_elapsed_seconds:
55+
errors.append(
56+
f"shard {index}: elapsed {elapsed_seconds:.1f}s exceeds "
57+
f"{args.max_elapsed_seconds:.1f}s SLA"
58+
)
59+
60+
if errors:
61+
for error in errors:
62+
print(f"::error title=CLI integration shard SLA::{error}")
63+
raise SystemExit(1)
64+
65+
print(
66+
f"All {args.expected_shards} shards reported complete passing results within "
67+
f"{args.max_elapsed_seconds:.1f}s."
68+
)
69+
70+
71+
if __name__ == "__main__":
72+
main()

scripts/run-test-shard.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
set -euo pipefail
44

5+
shard_started_at=$SECONDS
6+
57
if [[ $# -lt 3 || $# -gt 4 ]]; then
68
echo "Usage: $0 <project> <zero-based-shard-index> <shard-count> [configuration]" >&2
79
exit 2
@@ -100,6 +102,7 @@ printf 'Running shard %d of %d with %d of %d tests.\n' \
100102
"${#filters[@]}" \
101103
"${#tests[@]}"
102104

105+
set +e
103106
dotnet test "$project" \
104107
--configuration "$configuration" \
105108
--nologo \
@@ -108,3 +111,8 @@ dotnet test "$project" \
108111
--results-directory "$shard_artifacts" \
109112
--no-build \
110113
--filter "$filter"
114+
test_exit_code=$?
115+
set -e
116+
117+
printf 'elapsed_seconds\t%s\n' "$((SECONDS - shard_started_at))" >> "$shard_artifacts/metadata.tsv"
118+
exit "$test_exit_code"

scripts/summarize-test-shards.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class ShardResult:
2828
index: int
2929
membership: tuple[str, ...]
3030
tests: tuple[TestResult, ...]
31+
elapsed_seconds: float | None
3132

3233

3334
def parse_duration(value: str) -> float:
@@ -70,6 +71,19 @@ def read_trx(path: Path) -> tuple[TestResult, ...]:
7071
return tuple(results)
7172

7273

74+
def read_elapsed_seconds(path: Path) -> float | None:
75+
if not path.exists():
76+
return None
77+
78+
with path.open(encoding="utf-8", newline="") as stream:
79+
metadata = {
80+
row["key"]: row["value"]
81+
for row in csv.DictReader(stream, delimiter="\t")
82+
}
83+
value = metadata.get("elapsed_seconds")
84+
return float(value) if value is not None else None
85+
86+
7387
def load_shards(input_directory: Path) -> tuple[ShardResult, ...]:
7488
shards = []
7589
for membership_path in sorted(input_directory.rglob("membership.txt")):
@@ -81,7 +95,15 @@ def load_shards(input_directory: Path) -> tuple[ShardResult, ...]:
8195
)
8296
trx_path = membership_path.with_name("results.trx")
8397
tests = read_trx(trx_path) if trx_path.exists() else ()
84-
shards.append(ShardResult(index=index, membership=membership, tests=tests))
98+
elapsed_seconds = read_elapsed_seconds(membership_path.with_name("metadata.tsv"))
99+
shards.append(
100+
ShardResult(
101+
index=index,
102+
membership=membership,
103+
tests=tests,
104+
elapsed_seconds=elapsed_seconds,
105+
)
106+
)
85107
return tuple(sorted(shards, key=lambda shard: shard.index))
86108

87109

@@ -104,7 +126,15 @@ def write_summary(output_directory: Path, shards: tuple[ShardResult, ...], expec
104126
passed = sum(test.outcome == "Passed" for test in shard.tests)
105127
failed = sum(test.outcome == "Failed" for test in shard.tests)
106128
shard_rows.append(
107-
(shard.index, len(shard.membership), len(shard.tests), passed, failed, f"{duration:.3f}")
129+
(
130+
shard.index,
131+
len(shard.membership),
132+
len(shard.tests),
133+
passed,
134+
failed,
135+
f"{duration:.3f}",
136+
f"{shard.elapsed_seconds:.3f}" if shard.elapsed_seconds is not None else "",
137+
)
108138
)
109139
for name in shard.membership:
110140
result = result_by_name.get(name)
@@ -121,7 +151,15 @@ def write_summary(output_directory: Path, shards: tuple[ShardResult, ...], expec
121151

122152
write_csv(
123153
output_directory / "shards.csv",
124-
("shard", "assigned", "reported", "passed", "failed", "duration_seconds"),
154+
(
155+
"shard",
156+
"assigned",
157+
"reported",
158+
"passed",
159+
"failed",
160+
"duration_seconds",
161+
"elapsed_seconds",
162+
),
125163
shard_rows,
126164
)
127165
write_csv(
@@ -144,12 +182,14 @@ def write_summary(output_directory: Path, shards: tuple[ShardResult, ...], expec
144182
f"Discovered artifacts for **{len(shards)} of {expected_shards}** shards. ",
145183
f"Assigned tests: **{total_assigned}**. Reported results: **{total_reported}**.",
146184
"",
147-
"| Shard | Assigned | Reported | Passed | Failed | Test duration |",
148-
"| ---: | ---: | ---: | ---: | ---: | ---: |",
185+
"| Shard | Assigned | Reported | Passed | Failed | Test duration | Elapsed |",
186+
"| ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
149187
]
150-
for index, assigned, reported, passed, failed, duration in shard_rows:
188+
for index, assigned, reported, passed, failed, duration, elapsed in shard_rows:
189+
elapsed_display = f"{float(elapsed) / 60:.1f} min" if elapsed else "missing"
151190
lines.append(
152-
f"| {index} | {assigned} | {reported} | {passed} | {failed} | {float(duration) / 60:.1f} min |"
191+
f"| {index} | {assigned} | {reported} | {passed} | {failed} | "
192+
f"{float(duration) / 60:.1f} min | {elapsed_display} |"
153193
)
154194
if missing_shards:
155195
lines.extend(("", f"Missing shard artifacts: {', '.join(map(str, missing_shards))}."))

0 commit comments

Comments
 (0)