[WIP] Smartly select what tests to run on PR iterations#5998
Draft
janniklasrose wants to merge 2 commits into
Draft
[WIP] Smartly select what tests to run on PR iterations#5998janniklasrose wants to merge 2 commits into
janniklasrose wants to merge 2 commits into
Conversation
testmask maps a PR's changed files to CI test targets. Any file matching no known pattern falls into the catch-all `test` target, so a change to README.md or a changelog fragment runs the full test matrix and the cross-org integration suite. Classify pure documentation and contributor-metadata paths as inert: when every changed file is inert, GetTargets returns an empty target list and CI skips all test jobs (the empty list flows through the existing `contains(fromJSON(targets), ...)` guards in push.yml, so the integration-trigger job posts its synthetic green check and the expensive dispatch never runs). Matching is on exact repo-relative paths (or the inertPrefixes dirs), never bare basenames, so test-input fixtures such as acceptance/**/README.md and NOTICE (read by internal/build) are not misclassified. Guard tests lock in that invariant and that every inert path exists. Co-authored-by: Isaac
Adds tools/fingerprint, the primitive for reusing a green test result across PR iterations. It hashes the (path, blob-sha) of the tracked files matching a set of pathspecs at a commit, so two commits with the same fingerprint have byte-identical inputs. Unlike testmask, which diffs the cumulative PR range and re-runs a target whenever the PR ever touched it, a content fingerprint is stable across iterations that leave a target's inputs unchanged, and is force-push / rebase immune because it addresses content, not history. Split into a pure Fingerprint/ParseLsTree core (unit-tested for determinism, order-independence, content- and rename-sensitivity, and collision-safety via length-prefixed encoding) and a thin git ls-tree wrapper, mirroring testmask's structure. Not yet wired into CI; the result-reuse cache and cross-org integration marker are a follow-up. Co-authored-by: Isaac
Collaborator
Integration test reportCommit: 04ad594
8 interesting tests: 4 RECOVERED, 4 SKIP
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Content-fingerprint result reuse for PR iterations
Status: design, for review before implementation
Depends on: Stage 1 (
92d509b924, testmask docs-inert skip) — shippedAuthor: drafted by Isaac for Jan
Problem
A PR is not one CI run — it's many. During review a branch gets pushed
repeatedly: address a comment, fix lint, add a changelog fragment, merge
origin/main. Eachsynchronizeevent re-runs the full test matrix andre-dispatches the expensive cross-org integration suite
(
push.yml→trigger-tests→cli-isolated-pr.yml), even when the newiteration didn't touch anything the integration tests exercise.
That per-iteration re-run is the waste this stage removes. It is invisible in
main's history because the repo squash-merges (0 merge commits in the last100 on
main), so it can't be measured frommain— but every reviewed PRlives it.
Why Stage 1 does not already cover this
Stage 1 (testmask) diffs the cumulative merge-base range
base...head, so it answers "did this PR ever touch target T?" — not "didthis iteration touch T?". Concretely:
experimental/ssh/foo.go→ integration runs (correct)On iteration 2, the cumulative diff still shows the ssh change, so testmask
still emits
test-exp-sshand integration re-runs — despite iteration 2 addingnothing that ssh tests cover. Stage 1 cannot skip it; it has no memory of what
already passed.
Measured over the last 80 real commits on
main: ~90% selecttest(and thusintegration). The trigger set is broad, so "this iteration changed nothing
integration-relevant" is common during a review cycle — exactly the gap.
Approach: content-address the test inputs
Instead of diffing commit-to-commit, hash the content of a target's
inputs and reuse a prior green result when the hash matches.
For target
T:git ls-tree -r --format='%(objectname) %(path)' <commit> -- <paths...>yieldspath+blob-sha directly (verified on git 2.50). Blob shas are already computed by
git, so this is cheap and needs no file reads.
Protocol:
Tpasses at fingerprintF, record a marker keyed byF.F. If a pass marker forFexists → skipTand reuse the green result.
Why this shape is right:
the same
F⇒ skip. This is the case Stage 1 structurally cannot handle.history-addressed. This is the "snapshot" robustness the original ask hoped
for, without depending on
pull_request.synchronize's fragilebefore/afterSHAs (which dangle under force-push).
F.Force-pushes (rebase / amend / squash / reset)
Force-pushes are covered by design — content-addressing is the reason we chose
this shape over a diff-from-last-iteration scheme. There are two layers:
Layer 1 — computation.
Fhashes(path, blob-sha)fromgit ls-treeat asingle commit. It reads no parent, commit SHA, author, or diff, so rewriting
history cannot change it. Verified: re-committing a commit's exact tree onto a
different parent (a simulated force-push) produces a new commit SHA but a
byte-identical
F. This is why we do not key offpull_request.synchronize'sbefore/afterSHAs — thebeforeSHA danglesafter a force-push, breaking any history-relative scheme.
Layer 2 — cache readback. The marker key is
pass-<T>-<F>(no SHA in it),and GitHub Actions cache is scoped by git ref, not commit. A force-push keeps
the same ref, so iteration N+1 still reads the namespace iteration N wrote.
Edge cases, all correct:
Fmain/ conflict resolution (tree changes)go.sum)FFThe one real hazard is the third row: a rebase can silently change the
environment with no product-code diff. It is covered only because
Fincludes Go version,
go.sum, and the harness/tool version (see safety point 3below). Without that, a force-push-rebase could wrongly reuse.
Non-hazard: cache eviction (10 GB LRU / 7-day idle) only ever causes a miss →
safe re-run, force-push or not. Markers are written only by a genuinely
green run on the ref, never seeded speculatively, so nothing stale can be
resurrected by a rewrite-then-restore.
The safety asymmetry (the crux)
testmask is a trigger: over-running wastes CI, under-running is safe because
a catch-all backstops it. A result-reuse cache is the opposite:
under-covering the fingerprint means skipping a run that would have caught a
regression. A missed input is a correctness bug, not a cost bug.
Consequences for the design:
The fingerprint must hash the true input set, not testmask's trigger
globs. Those globs are deliberately conservative triggers; e.g.
test-accruns
--packages ./acceptance/...with no-runfilter, so the realacceptance coverage is broader than any single target's
sources:. Thefingerprint's input set must be a superset of what actually affects the
outcome. When unsure, hash more (a superfluous input only causes a false
cache-miss = a safe re-run).
Default to miss. Any error computing
F(git failure, unknown path,tooling change) ⇒ run the tests. Never skip on uncertainty.
Include the tooling in
F. Go version,go.sum, the test harness, thefingerprint tool's own version. A test-runner change can flip a result with
zero product-code change; if it's not in
F, reuse is unsound.Bound blast radius on rollout — see below.
Scaling: fingerprint compute and cache size
A large PR does not stress this design, because nothing here scales with the
diff. Three distinct "sizes", only the first has any cost, and it answers to
repo size, not PR size:
ls-treewall)\n)pass-<T>-<F>Verified: the largest PR in the last 200 commits (375 files changed)
fingerprinted in the same ~20 ms as a one-file PR.
ls-treereads the target'sinput tree at HEAD and never looks at the diff, so diff size is irrelevant to
compute.
Cache footprint is bounded by fingerprint churn, not files-per-PR:
cardinality is
targets × distinct-fingerprints-ever-seen. A 375-file PR stillmints exactly one fingerprint per target per iteration, same as a one-file
PR. With ~5 targets and one new fingerprint per meaningful iteration, even a busy
week is single-digit MB against GitHub's 10 GB/repo budget — and GitHub already
GCs it (10 GB LRU + 7-day idle). An evicted or stale marker only ever causes a
safe cache miss (→ re-run), never a wrong skip.
The one repo-scale caveat: cold
git ls-treeover the whole tree was ~0.6 swall (60 ms warm) at 9.6k files; it is O(files-in-input-set), so a
million-file monorepo would grow it. Non-issue at current scale. If it ever
mattered: fingerprint scoped targets (ssh's input tree is 5 KB of ls-tree vs
954 KB whole-repo) rather than the whole tree, and memoize
ls-treepercommit SHA.
Prior art:
internal.ChangedTests(#5880)PR #5880 (pietern,
extract-changed-tests, open) extracts theDATABRICKS_TEST_SKIPLOCAL=withchangeddetection into a reusableacceptance/internal.ChangedTests. It is orthogonal (it selects whichacceptance tests inside a run execute; testmask/this stage select whether the
job runs at all) and lives in the root module, so
tools/testmaskcannotimport it (dependency would invert). But its dependency model is exactly the
cross-directory correctness problem this stage shares, and should be mirrored:
script.prepare/script.cleanup/test.tomlaffects itswhole subtree → those files must be in the fingerprint input set of every
descendant target;
acceptance/bin/**affects every test → in the common inputset;
_script, a fixture read via$TESTDIR/..)affects its subtree.
If this stage ever moves detection into the root module,
ChangedTestsbecomesdirectly reusable. For now, mirror the rules and cite #5880.
Storage
GitHub Actions cache (already the substrate for the Go build cache in
setup-build-environment/action.yml):pass-<T>-<F>; presence = "T passed at F".and a branch reads caches it wrote plus the base branch's — so iteration N+1
reads iteration N's marker, and any branch reads markers seeded by
main.Sibling PR branches can't read each other's, which is correct (no
cross-contamination).
test-*jobs.The integration-test wrinkle (cross-org)
Unit/acceptance jobs run in
databricks/cliand can record their own markersinline. Integration is different: it's dispatched to
databricks-eng/eng-dev-ecosystemand the result comes back asynchronouslyas the
Integration Testscheck written onto the head SHA. So the CLI repodoesn't directly observe the pass.
Options (pick during review):
Fforthe SHA it tested and saves
pass-integration-<F>to the CLI repo's cache onsuccess. Most direct; needs a change in the other repo + cache-write scope.
check_run.completedworkflow indatabricks/clifires when
Integration Testsconcludes success, recomputesFfor that SHA,and saves the marker. Keeps the fingerprint definition authoritative in this
repo; no eng-dev change. Slightly more moving parts (one listener workflow).
Either way the skip decision stays in
databricks/cli:trigger-testscomputes
Fand, on a marker hit, posts the synthetic greenIntegration Testscheck (reusing the exact path
integration-triggeralready uses for thedocs-skip) instead of dispatching. Recommendation: (B) — keeps the
fingerprint contract in one repo.
Rollout / guardrails
F, log hit/miss and what would have beenskipped, but still run everything. Validate hit-rate and zero false-hits over
a week before enforcing.
test-*jobs arelower-stakes and self-contained; prove the mechanism there first, then extend
to the cross-org integration path.
log()every skip withTandF, and count skips — silent truncationreads as "covered" when it didn't (the acc: make changed-test detection reusable #5880
maxChangedLocalTests"log whatwas dropped" discipline).
full-ci), for when a maintainer distrusts a reuse.main/ merge_group. Seed markers there, but always run, sothe authoritative branch is never reused-into.
Verified end-to-end (fingerprint primitive)
tools/fingerprint/implements the primitive:Fingerprint(entries)(pure,order-independent, content- and rename-sensitive, collision-safe via
length-prefixed encoding) +
TreeEntriesAt(commit, pathspecs)overgit ls-tree. Demonstrated on realmainhistory:d6636f4d51)9e81da898)681f8b098e)The stable cases are exactly what Stage 1 (cumulative diff) cannot skip once the
PR has touched ssh at all — the concrete Stage-2 win.
Design coupling this surfaced: a naive whole-tree fingerprint for the broad
testtarget flips on a README change (README is in the tree), which woulddefeat the docs skip. So the
testtarget's fingerprint input set mustexclude Stage 1's inert paths — Stage 1's
inertFiles/inertPrefixesbecomesshared input to Stage 2. This answers open-question #2: per-target input sets,
with the broad target computed as "whole tree minus inert".
Open questions for review
(Recommend B.)
we want per-acceptance-dir (finer, closer to acc: make changed-test detection reusable #5880, but only helps if we also
split the jobs)?
integration(biggest cost), or alltest-*?Non-goals
main/ merge_group behaviour beyond seeding markers.