Skip to content

Commit b5b2b2c

Browse files
ci: skip riot runs based on diff from last successful run (second attempt) [backport 1.18] (#6636)
Backport 5875e8a from #6631 to 1.18. __Based on #6600, which was reverted from 1.x__ This pull request reduces the amount of duplicated work done in CI on pull request builds by skipping a given riot job based on the changes since the last time that job succeeded. The approach taken here reduces the amount of time spent waiting for jobs to pass after updating a branch from trunk, ensuring that only those jobs that might have been affected by the update are run. This logic is supported by CircleCI's `save_cache` and `restore_cache` directives. The cache key includes the branch name, job name, and node number to ensure uniqueness for each branch/job/node combination. `restore_cache` uses a prefix match on this key format. No new skips are applied on `*.x` branches, which mitigates much of the risk of this change. Here's a detailed description of the logical flow. 1. A commit with SHA `A` is pushed to a new branch, changing `contrib/cherrypy/middleware.py`. 2. As on current 1.x, CI jobs are selected based on the commit's diff. The `cherrypy` job starts. 3. The `restore_cache` step in the `cherrypy` job finds no cache. 4. The `cherrypy` job succeeds and writes a file to the CI runner's filesystem containing the Git hash of the current HEAD, `A`. This is the "latest successful commit" for the `cherrypy` job on this branch. 5. The `save_cache` step caches this file under a key that includes the branch name, `cherrypy`, and the epoch timestamp. 6. Another commit with SHA `B` is pushed to the branch, changing `contrib/falcon/patch.py`. The branch now contains changes to `falcon` and `cherrypy`. 7. The dynamic configuration generator selects the `falcon` and `cherrypy` jobs to run. 8. In the `falcon` job, the `restore_cache` step finds no cache at the key `*-falcon-*`. In the `cherrypy` job, this step finds a cache. 9. The `falcon` job runs similarly to steps 4 and 5. The `cherrypy` job opens the file it found in the cache and passes the resulting Git SHA to `needs_testrun.py`. This script runs `git diff B A`, finds that none of the changes in that diff affect `cherrypy`, and exits before running `riot`. **Why not do this check during the setup step?** The new diff check added in this pull request requires access to the CircleCI cache on a per-job basis, which is only available from inside each job. **How was this tested?** 1. [This commit](https://app.circleci.com/pipelines/github/DataDog/dd-trace-py/43108/workflows/c149c29b-d3b2-47dc-b1f0-06696d865134) changed the `pylons` and `asynctest` suites, causing the `asynctest` suite to fail. CI correctly shows both the pass and the fail. 2. [This commit](https://app.circleci.com/pipelines/github/DataDog/dd-trace-py/43117/workflows/0ffa0c7e-7e9c-41ee-852c-3b691b120f1a) fixed the `asynctest` failure. CI shows that the `pylons` job was skipped and the `asynctest` job was re-run. 3. [This commit](https://app.circleci.com/pipelines/github/DataDog/dd-trace-py/43118/workflows/00174ec3-98ad-46a1-8b58-35811feda777) reverted the changes to both suites. CI did not queue either of them. ## Checklist - [x] Change(s) are motivated and described in the PR description. - [x] Testing strategy is described if automated tests are not included in the PR. - [x] Risk is outlined (performance impact, potential for breakage, maintainability, etc). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) are followed. If no release note is required, add label `changelog/no-changelog`. - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [x] Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Title is accurate. - [x] No unnecessary changes are introduced. - [x] Description motivates each change. - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [x] Testing strategy adequately addresses listed risk(s). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] Release note makes sense to a user of the library. - [x] Reviewer has explicitly acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment. - [x] Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) Co-authored-by: Emmett Butler <[email protected]>
1 parent cf8de28 commit b5b2b2c

File tree

3 files changed

+79
-39
lines changed

3 files changed

+79
-39
lines changed

.circleci/config.templ.yml

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ commands:
126126
- checkout
127127
- attach_workspace:
128128
at: .
129+
- restore_cache:
130+
keys:
131+
- lastsuccess-{{ .Environment.CIRCLE_BRANCH }}-<<parameters.pattern>>-{{ .Environment.CIRCLE_NODE_INDEX }}
129132
- when:
130133
condition:
131134
<< parameters.snapshot >>
@@ -139,9 +142,7 @@ commands:
139142
DD_TRACE_AGENT_URL: << parameters.trace_agent_url >>
140143
RIOT_RUN_RECOMPILE_REQS: "<< pipeline.parameters.riot_run_latest >>"
141144
command: |
142-
# Sort the hashes to ensure a consistent ordering/division between each node
143-
riot list --hash-only '<<parameters.pattern>>' | sort | circleci tests split | xargs -n 1 -I {} ./scripts/ddtest riot -v run --exitfirst --pass-env -s {} $([ -v _CI_DD_API_KEY ] && echo '--ddtrace' ) $([[ << pipeline.parameters.coverage >> == false ]] && echo '--no-cov' )
144-
./scripts/check-diff ".riot/requirements/" "Changes detected after running riot. Consider deleting changed files, running scripts/compile-and-prune-test-requirements and committing the result."
145+
./scripts/run-test-suite '<<parameters.pattern>>' <<pipeline.parameters.coverage>> 1
145146
- unless:
146147
condition:
147148
<< parameters.snapshot >>
@@ -166,9 +167,7 @@ commands:
166167
environment:
167168
RIOT_RUN_RECOMPILE_REQS: "<< pipeline.parameters.riot_run_latest >>"
168169
command: |
169-
# Sort the hashes to ensure a consistent ordering/division between each node
170-
riot list --hash-only '<<parameters.pattern>>' | sort | circleci tests split | xargs -n 1 -I {} riot -v run --exitfirst --pass-env -s {} $([ -v _CI_DD_API_KEY ] && echo '--ddtrace' ) $([[ << pipeline.parameters.coverage >> == false ]] && echo '--no-cov' )
171-
./scripts/check-diff ".riot/requirements/" "Changes detected after running riot. Consider deleting changed files, running scripts/compile-and-prune-test-requirements and committing the result."
170+
./scripts/run-test-suite '<<parameters.pattern>>' <<pipeline.parameters.coverage>>
172171
- when:
173172
condition:
174173
and:
@@ -180,6 +179,10 @@ commands:
180179
path: test-results
181180
- store_artifacts:
182181
path: test-results
182+
- save_cache:
183+
key: lastsuccess-{{ .Environment.CIRCLE_BRANCH }}-<<parameters.pattern>>-{{ .Environment.CIRCLE_NODE_INDEX }}-{{ epoch }}
184+
paths:
185+
- ./latest-success-commit
183186
- run:
184187
name: Get APM Test Agent Trace Check Results
185188
when: always
@@ -427,9 +430,7 @@ jobs:
427430
environment:
428431
RIOT_RUN_RECOMPILE_REQS: "<< pipeline.parameters.riot_run_latest >>"
429432
command: |
430-
# Sort the hashes to ensure a consistent ordering/division between each node
431-
riot list --hash-only 'integration-latest' | sort | circleci tests split | xargs -n 1 -I {} ./scripts/ddtest riot -v run --pass-env -s {}
432-
./scripts/check-diff ".riot/requirements/" "Changes detected after running riot. Consider deleting changed files, running scripts/compile-and-prune-test-requirements and committing the result."
433+
./scripts/run-test-suite 'integration-latest' <<pipeline.parameters.coverage>> 1
433434
434435
integration_testagent:
435436
<<: *machine_executor

scripts/needs_testrun.py

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -58,43 +58,36 @@ def get_merge_base(pr_number: int) -> str:
5858

5959

6060
@cache
61-
def get_changed_files(pr_number: int) -> t.Set[str]:
61+
def get_changed_files(pr_number: int, sha: t.Optional[str] = None) -> t.Set[str]:
6262
"""Get the files changed in a PR
6363
64+
Try with the GitHub REST API for the most accurate result. If that fails,
65+
or if there is a specific SHA given, use the less accurate method of
66+
diffing against a base commit, either the given SHA or the merge-base.
67+
6468
>>> sorted(get_changed_files(6388)) # doctest: +NORMALIZE_WHITESPACE
6569
['ddtrace/debugging/_expressions.py',
6670
'releasenotes/notes/fix-debugger-expressions-none-literal-30f3328d2e386f40.yaml',
6771
'tests/debugging/test_expressions.py']
6872
"""
69-
try:
70-
# Try with the GitHub REST API for the most accurate result
71-
url = f"https://api.github.com/repos/datadog/dd-trace-py/pulls/{pr_number}/files"
72-
headers = {"Accept": "application/vnd.github+json"}
73-
74-
return {_["filename"] for _ in json.load(urlopen(Request(url, headers=headers)))}
75-
76-
except Exception:
77-
# If that fails use the less accurate method of diffing against the
78-
# merge-base w.r.t. the base branch
79-
LOGGER.warning("Failed to get changed files from GitHub API, using git diff instead")
80-
return set(
81-
check_output(
82-
[
83-
"git",
84-
"diff",
85-
"--name-only",
86-
"HEAD",
87-
get_merge_base(pr_number),
88-
]
89-
)
90-
.decode("utf-8")
91-
.strip()
92-
.splitlines()
93-
)
73+
rest_check_failed = False
74+
if sha is None:
75+
try:
76+
url = f"https://api.github.com/repos/datadog/dd-trace-py/pulls/{pr_number}/files"
77+
headers = {"Accept": "application/vnd.github+json"}
78+
return {_["filename"] for _ in json.load(urlopen(Request(url, headers=headers)))}
79+
except Exception:
80+
rest_check_failed = True
81+
LOGGER.warning("Failed to get changed files from GitHub API")
82+
83+
if sha is not None or rest_check_failed:
84+
diff_base = sha or get_merge_base(pr_number)
85+
LOGGER.info("Checking changed files against commit %s", diff_base)
86+
return set(check_output(["git", "diff", "--name-only", "HEAD", diff_base]).decode("utf-8").strip().splitlines())
9487

9588

9689
@cache
97-
def needs_testrun(suite: str, pr_number: int) -> bool:
90+
def needs_testrun(suite: str, pr_number: int, sha: t.Optional[str] = None) -> bool:
9891
"""Check if a testrun is needed for a suite and PR
9992
10093
>>> needs_testrun("debugger", 6485)
@@ -115,7 +108,7 @@ def needs_testrun(suite: str, pr_number: int) -> bool:
115108
return True
116109

117110
try:
118-
changed_files = get_changed_files(pr_number)
111+
changed_files = get_changed_files(pr_number, sha=sha)
119112
except Exception:
120113
LOGGER.error("Failed to get changed files")
121114
return True
@@ -181,15 +174,16 @@ def main() -> bool:
181174
argp = ArgumentParser()
182175

183176
argp.add_argument("suite", help="The suite to use", type=str)
184-
argp.add_argument("pr", help="The PR number", type=int)
177+
argp.add_argument("--pr", help="The PR number", type=int, default=_get_pr_number())
178+
argp.add_argument("--sha", help="Commit hash to use as diff base (defaults to PR merge root)", type=lambda v: v or None)
185179
argp.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
186180

187181
args = argp.parse_args()
188182

189183
if args.verbose:
190184
LOGGER.setLevel(logging.INFO)
191185

192-
return needs_testrun(args.suite, args.pr)
186+
return needs_testrun(args.suite, args.pr, sha=args.sha)
193187

194188

195189
if __name__ == "__main__":

scripts/run-test-suite

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env bash
2+
3+
CHECKPOINT_FILENAME="latest-success-commit"
4+
RIOT_PATTERN=${1}
5+
if [[ -v CIRCLECI ]]; then
6+
RIOT_HASHES=$(riot list --hash-only $RIOT_PATTERN | sort | circleci tests split)
7+
else
8+
RIOT_HASHES=$(riot list --hash-only $RIOT_PATTERN | sort)
9+
fi
10+
DDTRACE_FLAG=$([ -v _CI_DD_API_KEY ] && echo '--ddtrace')
11+
COVERAGE_FLAG=$([[ "${2:-false}" == false ]] && echo '--no-cov')
12+
DDTEST_CMD=$([[ ${3} == "1" ]] && echo "./scripts/ddtest")
13+
14+
set -e
15+
16+
if ! [[ -v CIRCLECI && $CIRCLE_BRANCH =~ [0-9]\.x ]]; then
17+
if [[ -f "$CHECKPOINT_FILENAME" ]]; then
18+
latest_success_commit=$(cat $CHECKPOINT_FILENAME)
19+
if ! ./scripts/needs_testrun.py $CIRCLE_JOB --sha $latest_success_commit; then
20+
echo "The $CIRCLE_JOB job succeeded at commit $latest_success_commit."
21+
echo "None of the changes on this branch since that commit affect the $CIRCLE_JOB job."
22+
echo "Skipping this job."
23+
circleci step halt
24+
exit 0
25+
fi
26+
fi
27+
fi
28+
29+
for hash in $RIOT_HASHES; do
30+
if ! $DDTEST_CMD riot -v run --exitfirst --pass-env -s $hash $DDTRACE_FLAG $COVERAGE_FLAG; then
31+
if [[ -v CIRCLECI ]]; then
32+
circleci step halt
33+
fi
34+
exit 1
35+
fi
36+
done
37+
38+
rm -f $CHECKPOINT_FILENAME
39+
echo $CIRCLE_SHA1 > $CHECKPOINT_FILENAME
40+
echo "All tests passed. Saved $CIRCLE_SHA1 as the latest successful commit for job $CIRCLE_JOB"
41+
42+
./scripts/check-diff \
43+
".riot/requirements/" \
44+
"Changes detected after running riot. Consider deleting changed files, \
45+
running scripts/compile-and-prune-test-requirements and committing the result."

0 commit comments

Comments
 (0)