Skip to content

Commit 40503d7

Browse files
ci: fix runtime shard JUnit metadata
Co-authored-by: mr-lee <1302416+mr-lee@users.noreply.github.com>
1 parent aa62363 commit 40503d7

18 files changed

Lines changed: 1101 additions & 111 deletions

.gitlab/scripts/get-riot-hashes.sh

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,55 @@
22
set -e -u -o pipefail
33

44
SUITE_NAME="${1:-}"
5+
CI_ALLOCATION_SUITE="${2:-${CI_ALLOCATION_SUITE:-${SUITE_NAME}}}"
56
strategy_args=()
67
if [[ -n "${CI_ALLOCATION_STRATEGY:-}" ]]; then
78
strategy_args=(--strategy "${CI_ALLOCATION_STRATEGY}")
89
fi
910

10-
riot list --hash-only "${SUITE_NAME}" | sort | \
11+
mapfile -t available_hashes < <(riot list --hash-only "${SUITE_NAME}" | sort)
12+
if [[ -n "${CI_ALLOCATION_ASSIGNMENTS:-}" ]]; then
13+
IFS=';' read -r -a planned_assignments <<< "${CI_ALLOCATION_ASSIGNMENTS}"
14+
node_index="${CI_NODE_INDEX:-1}"
15+
node_total="${CI_NODE_TOTAL:-1}"
16+
if [[ ! "${node_index}" =~ ^[1-9][0-9]*$ || ! "${node_total}" =~ ^[1-9][0-9]*$ ]]; then
17+
echo "CI node index and total must be positive integers" >&2
18+
exit 1
19+
fi
20+
if [[ "${#planned_assignments[@]}" -ne "${node_total}" ]]; then
21+
echo "Generated allocation count differs from CI_NODE_TOTAL" >&2
22+
exit 1
23+
fi
24+
if [[ "${node_index}" -gt "${#planned_assignments[@]}" ]]; then
25+
echo "CI_NODE_INDEX is outside the generated allocation" >&2
26+
exit 1
27+
fi
28+
CI_ALLOCATION_UNITS="${planned_assignments[$((node_index - 1))]}"
29+
fi
30+
if [[ -n "${CI_ALLOCATION_UNITS:-}" ]]; then
31+
declare -A available=()
32+
for riot_hash in "${available_hashes[@]}"; do
33+
available["${riot_hash}"]=1
34+
done
35+
IFS=',' read -r -a execution_units <<< "${CI_ALLOCATION_UNITS}"
36+
for unit in "${execution_units[@]}"; do
37+
if [[ ! "${unit}" =~ ^([0-9a-f]+)(@([1-9][0-9]*)/([1-9][0-9]*))?$ ]]; then
38+
echo "Invalid Riot execution unit: ${unit}" >&2
39+
exit 1
40+
fi
41+
riot_hash="${BASH_REMATCH[1]}"
42+
if [[ -z "${available[${riot_hash}]:-}" ]]; then
43+
echo "Generated Riot execution unit is not in ${SUITE_NAME}: ${unit}" >&2
44+
exit 1
45+
fi
46+
printf '%s\n' "${unit}"
47+
done
48+
exit 0
49+
fi
50+
51+
printf '%s\n' "${available_hashes[@]}" | \
1152
./scripts/ci_allocation_cli.py select \
12-
--suite "${SUITE_NAME}" \
53+
--suite "${CI_ALLOCATION_SUITE}" \
1354
--node-index "${CI_NODE_INDEX:-1}" \
1455
--node-total "${CI_NODE_TOTAL:-1}" \
1556
"${strategy_args[@]}"

.gitlab/scripts/get-riot-pip-cache-key.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
set -e -u -o pipefail
33

44
SUITE_NAME="${1:-}"
5-
hashes=( $(./.gitlab/scripts/get-riot-hashes.sh "${SUITE_NAME}") )
5+
# Cache identity covers the full semantic suite and must not depend on one
6+
# physical allocation job's node index or runtime test slice.
7+
hashes=( $(riot list --hash-only "${SUITE_NAME}" | sort -u) )
68
# Get the sha256sum of all the requirements files combined
79
for hash in "${hashes[@]}"; do
810
req_file="./.riot/requirements/${hash}.txt"

.gitlab/testrunner.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ variables:
2525
when: always
2626
paths:
2727
- core.*
28+
- test-results/ci-test-shard-inventory.*.json
2829
reports:
2930
junit: test-results/junit*.xml
3031
expire_in: 1 week

.gitlab/tests.yml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,24 @@ include:
3636
echo "No riot hashes found for ${SUITE_NAME}"
3737
exit 1
3838
fi
39-
for hash in "${hashes[@]}"
39+
for unit in "${hashes[@]}"
4040
do
41+
hash="${unit%%@*}"
42+
if [[ "${unit}" =~ ^[0-9a-f]+@([1-9][0-9]*)/([1-9][0-9]*)$ ]]; then
43+
test_shard_index="${BASH_REMATCH[1]}"
44+
test_shard_total="${BASH_REMATCH[2]}"
45+
else
46+
test_shard_index=1
47+
test_shard_total=1
48+
fi
4149
echo "Running riot hash: ${hash}"
50+
echo "Runtime test shard: ${test_shard_index}/${test_shard_total}"
4251
riot list "${hash}"
4352
export RIOT_HASH="${hash}"
53+
export RIOT_TEST_SHARD_INDEX="${test_shard_index}"
54+
export RIOT_TEST_SHARD_TOTAL="${test_shard_total}"
4455
export RIOT_CI_ALLOCATION_STRATEGY="${CI_ALLOCATION_STRATEGY:-legacy}"
45-
export _CI_DD_TAGS="test.configuration.riot_hash:${RIOT_HASH},test.configuration.ci_allocation_strategy:${RIOT_CI_ALLOCATION_STRATEGY}"
56+
export _CI_DD_TAGS="test.configuration.riot_hash:${RIOT_HASH},test.configuration.ci_allocation_strategy:${RIOT_CI_ALLOCATION_STRATEGY},test.configuration.runtime_shard_index:${RIOT_TEST_SHARD_INDEX},test.configuration.runtime_shard_total:${RIOT_TEST_SHARD_TOTAL}"
4657
${RIOT_RUN_CMD} "${hash}" -- --ddtrace
4758
done
4859
./scripts/check-diff ".riot/requirements/" \

ci/ci-allocation-policy.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
{
22
"allocation": {
33
"active_strategy": "legacy",
4-
"maximum_parallelism_per_suite": 25,
4+
"maximum_parallelism_per_suite": 50,
55
"maximum_runtime_model_bytes": 100000,
6+
"maximum_slices_per_hash": 5,
67
"target_jobs": 200,
78
"target_shard_seconds": 300
89
},

ci/ci-allocation-runtime-model.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2062,6 +2062,57 @@
20622062
"minimum_samples": 5,
20632063
"sparse_safety_factor": 1.25
20642064
},
2065+
"suite_estimates": {
2066+
"contrib::integration_registry": {
2067+
"2e9f3b5": 127.23747
2068+
},
2069+
"integration_registry": {
2070+
"2e9f3b5": 129.210514
2071+
},
2072+
"tracer": {
2073+
"16f089d": 651.383996,
2074+
"190d82d": 651.383996,
2075+
"1c97cf2": 651.383996,
2076+
"3d924d3": 651.383996,
2077+
"f953f1c": 651.383996
2078+
},
2079+
"tracer-uwsgi": {
2080+
"16f089d": 75.349606,
2081+
"190d82d": 75.349606,
2082+
"1c97cf2": 75.349606,
2083+
"3d924d3": 75.349606,
2084+
"f953f1c": 75.349606
2085+
}
2086+
},
2087+
"test_sharding": {
2088+
"command_fingerprints": {
2089+
"21c32b52591cbd41dd16f7df087cb15963733fe59de2ce9b011bf033a77c76b7": {
2090+
"minimum_items": 820,
2091+
"observed_hash": "759749c",
2092+
"source_commit": "3bd95510c20255b89fa41e1ad9822d617c1ffe3d",
2093+
"source_pipeline_id": "131076783"
2094+
},
2095+
"3d6032832c5128ba1764834c1340c9e50a459fb876aab90bb20c6c13585cb148": {
2096+
"minimum_items": 6922,
2097+
"observed_hash": "107d2ec",
2098+
"source_commit": "3bd95510c20255b89fa41e1ad9822d617c1ffe3d",
2099+
"source_pipeline_id": "131076783"
2100+
},
2101+
"6c31901e916dd7d21856d6795591a3f3f0be7bbaf6d5c3d5e1d1d1661c08bef9": {
2102+
"minimum_items": 187,
2103+
"observed_hash": "dbf2d5f",
2104+
"source_commit": "aa623634dbf9e9ff55ba89f822ad7dda228ce087",
2105+
"source_pipeline_id": "131104148"
2106+
},
2107+
"986b1f7f162587fd9fda02ea4a2430b87751117fe67cfa6d3273aa8b301604c8": {
2108+
"minimum_items": 2086,
2109+
"observed_hash": "b783dae",
2110+
"source_commit": "3bd95510c20255b89fa41e1ad9822d617c1ffe3d",
2111+
"source_pipeline_id": "131076783"
2112+
}
2113+
},
2114+
"source": "datadog-test-visibility-items"
2115+
},
20652116
"overheads": {
20662117
"global_seconds": 0,
20672118
"suite_seconds": {},

conftest.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@
99
import hashlib
1010
import json
1111
import os
12+
from pathlib import Path
1213
import re
1314
import sys
1415
from time import time
1516

1617
import hypothesis
1718
import pytest
1819

20+
from scripts.ci_allocation.planner import AllocationError
21+
from scripts.ci_allocation.runtime import build_runtime_inventory
22+
from scripts.ci_allocation.runtime import write_runtime_inventory
23+
1924

2025
# DEV: Enable "testdir" fixture https://docs.pytest.org/en/stable/reference.html#testdir
2126
pytest_plugins = ("pytester",)
@@ -63,11 +68,21 @@ def pytest_configure(config):
6368
fname, ext = os.path.splitext(config.option.xmlpath)
6469
strategy = os.getenv("RIOT_CI_ALLOCATION_STRATEGY")
6570
riot_hash = os.getenv("RIOT_HASH")
71+
test_shard_index = os.getenv("RIOT_TEST_SHARD_INDEX")
72+
test_shard_total = os.getenv("RIOT_TEST_SHARD_TOTAL")
73+
test_shard_identity = None
74+
if test_shard_index and test_shard_total and int(test_shard_total) > 1:
75+
test_shard_identity = f"s{test_shard_index}of{test_shard_total}"
6676
execution_digest = None
6777
if riot_hash:
6878
execution = {}
6979
for env, value in os.environ.items():
70-
if not env.startswith("RIOT_") or env in {"RIOT_HASH", "RIOT_CI_ALLOCATION_STRATEGY"}:
80+
if not env.startswith("RIOT_") or env in {
81+
"RIOT_HASH",
82+
"RIOT_CI_ALLOCATION_STRATEGY",
83+
"RIOT_TEST_SHARD_INDEX",
84+
"RIOT_TEST_SHARD_TOTAL",
85+
}:
7186
continue
7287
name = env[5:]
7388
prefix, _, suffix = name.partition("_")
@@ -82,6 +97,7 @@ def pytest_configure(config):
8297
(
8398
strategy,
8499
riot_hash,
100+
test_shard_identity,
85101
execution_digest,
86102
str(os.getpid()),
87103
),
@@ -95,6 +111,52 @@ def pytest_configure(config):
95111
config.option.benchmark_save = str(time()).replace(".", "_") + gc + "_py%d_%d" % sys.version_info[:2]
96112

97113

114+
@pytest.hookimpl(trylast=True)
115+
def pytest_collection_modifyitems(config, items):
116+
"""Select one deterministic runtime slice after the Riot command collects tests."""
117+
shard_index_value = os.getenv("RIOT_TEST_SHARD_INDEX")
118+
shard_total_value = os.getenv("RIOT_TEST_SHARD_TOTAL")
119+
if shard_index_value is None and shard_total_value is None:
120+
return
121+
if shard_index_value is None or shard_total_value is None:
122+
raise pytest.UsageError("Riot runtime test sharding requires both shard index and total")
123+
try:
124+
shard_index = int(shard_index_value)
125+
shard_total = int(shard_total_value)
126+
except ValueError as exc:
127+
raise pytest.UsageError("Riot runtime test shard index and total must be integers") from exc
128+
if shard_total == 1 and shard_index == 1:
129+
return
130+
131+
suite = os.getenv("CI_ALLOCATION_SUITE", "")
132+
riot_hash = os.getenv("RIOT_HASH", "")
133+
if not suite or not riot_hash:
134+
raise pytest.UsageError("Riot runtime test sharding requires suite and hash identity")
135+
try:
136+
inventory = build_runtime_inventory(
137+
suite=suite,
138+
riot_hash=riot_hash,
139+
shard_index=shard_index,
140+
shard_total=shard_total,
141+
collected_nodeids=[item.nodeid for item in items],
142+
)
143+
except AllocationError as exc:
144+
raise pytest.UsageError(str(exc)) from exc
145+
146+
selected = set(inventory["selected_nodeids"])
147+
deselected = [item for item in items if item.nodeid not in selected]
148+
items[:] = [item for item in items if item.nodeid in selected]
149+
if deselected:
150+
config.hook.pytest_deselected(items=deselected)
151+
152+
# AIDEV-NOTE: xdist workers must make the same selection, but only one
153+
# process writes the shared inventory artifact.
154+
worker = os.getenv("PYTEST_XDIST_WORKER")
155+
if worker in (None, "gw0"):
156+
path = Path("test-results") / (f"ci-test-shard-inventory.{riot_hash}.{shard_index}-of-{shard_total}.json")
157+
write_runtime_inventory(path, inventory)
158+
159+
98160
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
99161
def pytest_runtest_makereport(item, call):
100162
# Attach the outcome of the test (failed, passed, skipped) to the test node so that fixtures

docs/contributing-testing.rst

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,20 +131,31 @@ How CI allocates Riot environments
131131
Semantic test ownership remains in ``tests/suitespec.py`` and the distributed
132132
``suitespec.yml`` files. CI resolves every selected suite to its Riot environment
133133
hashes, then assigns those hashes to physical GitLab shards. A Riot environment is
134-
the smallest allocation unit, so duration-based balancing cannot change its command,
135-
Python version, services, environment, retry policy, or timeout.
134+
normally the smallest allocation unit, so duration-based balancing never changes its
135+
command, Python version, services, environment, retry policy, or timeout. For a
136+
measured long-running pytest command, CI may refine one hash into runtime execution
137+
units such as ``107d2ec@1/3``. Pytest still collects the suite-authored command, then
138+
a repository plugin deterministically selects one disjoint slice of the collected
139+
node IDs in each physical job. No item-level partition appears in ``suitespec.yml``.
136140

137141
``scripts/gen_gitlab_config.py`` writes ``.gitlab/ci-allocation-plan.json`` with the
138142
current round-robin plan and a duration-balanced plan. Generation fails unless both
139143
plans contain the exact same Riot hash set, with no duplicates or empty shards, and
140-
the execution metadata is represented by the same digest. The plan is retained as a
141-
CI artifact for review.
144+
the execution metadata is represented by the same digest. Runtime slices must also
145+
contain every index from one through their declared total. The plan is retained as a
146+
CI artifact for review. The generator embeds every balanced assignment into the
147+
suite's GitLab ``parallel`` job, and each physical job selects its assignment by
148+
``CI_NODE_INDEX``. The semantic suite and compact matrix representation therefore
149+
remain stable even when an assignment contains sub-hash execution units.
142150

143151
The active strategy and promotion thresholds are in
144152
``ci/ci-allocation-policy.json``. ``legacy`` is the rollback-safe default. The
145153
duration estimates in ``ci/ci-allocation-runtime-model.json`` are generated from
146-
Datadog Test Visibility session exports joined by
147-
``test.configuration.riot_hash``. The model uses a time-decayed p90, conservative
154+
Datadog Test Visibility session exports joined by semantic suite and
155+
``test.configuration.riot_hash``. Riot hashes identify environments rather than
156+
commands and can be shared by semantically different suites, so suite-scoped
157+
estimates override global hash estimates for those collisions. The model uses a
158+
time-decayed p90, conservative
148159
fallbacks for sparse hashes, and a recent holdout that is not used for fitting.
149160
CI job events are joined by pipeline and job identity. Riot setup and activation
150161
time outside the Test Visibility session is distributed over the atomic hashes in
@@ -155,6 +166,13 @@ model must remain below the size limit in the allocation policy. Failed and canc
155166
observations are retained as censored reliability evidence but are not treated as
156167
normal durations.
157168

169+
Sub-hash expansion is fail-closed. A command is eligible only when its command
170+
fingerprint has real Test Visibility item-count evidence and the fitted runtime
171+
exceeds the target. Fallback estimates never create slices because they may describe
172+
a one-test or non-pytest command. Each slice writes a compact collection inventory.
173+
Across all slice artifacts, verification requires the same collection digest, an
174+
exact disjoint union, and no empty slice.
175+
158176
The balanced strategy targets five minutes of modeled work per Riot shard. Promotion
159177
requires at least a 50 percent reduction in the median Riot critical path over paired
160178
live shadow runs. This objective covers the generated Riot child pipeline, not the
@@ -165,8 +183,10 @@ Balanced sizing is constrained to the legacy topology's total job count. When th
165183
duration target requests more jobs, the planner removes shards with the smallest
166184
modeled critical-path penalty, allowing capacity to move between semantic suites
167185
without increasing the job budget.
168-
If one atomic Riot environment exceeds the promotion target, the allocator must fail
169-
the gate; meeting that target requires a separately validated finer execution unit.
186+
Live critical-path measurement uses the actual interval from the first Riot job start
187+
to the last Riot job completion. It does not add semantic-stage maxima because the
188+
generated ``needs`` DAG lets those stages overlap. Modeled maxima are planning scores,
189+
not measured CI runtimes; promotion uses completed same-head shadow runs.
170190

171191
Use the allocation helper to normalize an export, build a candidate model, and
172192
replay it against the untouched holdout:
@@ -221,6 +241,16 @@ filename as a fallback for parallel runs that omit test suite properties:
221241
--balanced balanced/test-results/junit*.xml \
222242
--output junit-parity.json
223243
244+
For every sub-hash command, also download the runtime inventory artifacts and prove
245+
that all collected pytest items were executed exactly once:
246+
247+
.. code-block:: bash
248+
249+
$ scripts/ci_allocation_cli.py verify-runtime-shards \
250+
--plan ci-allocation-plan.json \
251+
--manifests balanced/test-results/ci-test-shard-inventory.*.json \
252+
--output runtime-shard-parity.json
253+
224254
Promotion requires the checked thresholds for sample count, median improvement,
225255
p75, p90, runner time, clean-success rate, and retry rate. A scheduled retuning task
226256
may propose a new model, but it must not change the active strategy automatically.

0 commit comments

Comments
 (0)