Skip to content

ci: quarantine flaky tests against a ticket instead of retrying past them - #777

Draft
rkennke wants to merge 4 commits into
mainfrom
ci/flake-visibility
Draft

ci: quarantine flaky tests against a ticket instead of retrying past them#777
rkennke wants to merge 4 commits into
mainfrom
ci/flake-visibility

Conversation

@rkennke

@rkennke rkennke commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?:

Makes flaky tests a tracked, expiring debt instead of something CI quietly retries past.

The retry now buys a label, not a pass:

flaky failed one attempt, passed another
broken failed every attempt
gating not on the quarantine list — red, whichever of the above it is

A flake fails the build until someone quarantines it against a PROF ticket in ddprof-test/quarantine.txt. Every entry carries a review_by date and CI fails once it passes, so the list expires rather than accumulating. Quarantined tests still run and report — only the gating is suspended, so the pass rate keeps saying whether the test is recovering or has quietly become permanently broken.

To keep the honest path the cheap one, the PR comment prints a ready-to-paste entry for each flaky test, with cells pre-narrowed to the axis that actually failed.

Motivation:

CI could not tell a flaky test from a broken one, and its one retry erased the evidence either way.

  • The retry existed only for ASan. A test that failed on attempt 1 and passed on attempt 2 left a green job and a ::warning:: in a log, naming nothing.
  • Test reports upload only if: failure() — so the run that recovered on a retry, the one worth studying, produced no artifact at all.
  • generate-test-summary.sh already downloaded (test-reports)* and grepped TEST-*.xml for failed test names, but prepare_reports.sh copies build/reports/tests (the HTML) and never build/test-results (the XML). It searched artifacts containing no XML, so every failed job rendered "No detailed failure information available". A ** glob with no shopt -s globstar would have stopped it recursing even had the files been there.

Answering "is this flaky?" therefore meant hand-comparing PRs by eye. Retrying until green would only have made the tolerance official.

Additional Notes:

Why a text table and not JSON/YAML. The list is edited by hand far more often than by machine, so real comments, one-line diffs and clean git blame matter more than a schema. It also has to parse inside the Alpine test containers, where PyYAML cannot be assumed — this needs nothing but str.split.

Failing to classify is itself a failure. If flake_report.py cannot run, the job goes red rather than inheriting a pass nothing examined. An earlier draft had || true there and turned a real flake green during testing; there's a regression test for it now.

Behaviour changes worth knowing: retry is enabled for all four platform jobs (previously ASan only), so a flake-shaped failure costs that cell a second run; slow suites are capped at one attempt to stay clear of the 180-minute timeout; ASan keeps its retry-on-abort via RETRY_ON_NO_TEST_FAILURES, since it names no test when it dies at init.

Deferred: auto-filing PROF tickets (needs dedupe and an Atlassian credential for CI), and the GitLab dd-trace integration matrix, which still gets one shot per config.

Not exercised on real CI: the retry and gating paths only run once something has failed, which is why they are covered by fixtures instead.

Review fixes (second commit). Two defects worth naming. Quarantine excused too much: when every named test failure was on the list the runner forced a green exit, including when the same invocation had also failed a native or verification task — it now refuses to zero the exit code if the attempt log shows Execution failed for task outside the test task. And the cell glob documented in quarantine.txt could never match: cells end in amd64/aarch64, so the advertised *arm64* narrowing silently quarantined everywhere. validate now rejects a glob naming an architecture CI never builds. Also: flaky requires observing an actual pass rather than mere absence from a later attempt; the runner clears its own evidence directory so a reused workspace cannot import a previous run's attempts; Docker-written results are made readable before snapshotting, which musl-aarch64 was failing silently.

Review fixes (third commit). The excuse was scoped to the wrong thing. The exit code quarantine overrides comes from the final attempt, but the failures weighed against it were aggregated across all of them — so a final attempt that failed without naming a test (a docker run failure on musl-aarch64, a Gradle configuration error, an OOM-killed daemon, or the very ASan init abort the retry exists for, which Gradle attributes to :ddprof-test:testasan) went green as soon as one entry matched an earlier attempt's failure. flake_report.py now reports the final attempt's own standing and the runner requires that attempt to have produced results with all of its own named failures quarantined; the non-test-task grep stays as a second check rather than the only one.

Also in that commit: validate rejects overlapping cell globs rather than only byte-identical ones, and an out-of-range review_by becomes an annotated problem instead of an uncaught ValueError that loses every other annotation in the file; the dead propose subcommand goes, leaving one renderer for the paste-ready entry; test ids and failure messages are sanitised before reaching the PR comment, so a test's own output cannot break out of the fenced proposal a reviewer is invited to copy; a summary with no readable reports says so instead of looking clean; the cell label carries the slow/regular axis so nightly's two invocations of one config stop colliding in ci-outcome/<cell>.json; and make_results_readable probes per-file ownership instead of the top of the tree.

How to test the change?:

.github/scripts/tests/test_quarantine.sh — 27 assertions, and .github/scripts/tests/test_generate_test_summary.sh — 2, both run by the new validate-quarantine job:

  • validator: missing ticket, non-PROF ticket, expired review_by, duplicate entry, malformed line, a missing list file, a cell glob naming an architecture CI never builds, two entries for one test on disjoint cells, and that the committed list is itself valid
  • matcher: cell globs (*aarch64* matches aarch64 cells and not amd64), class wildcards (a.B.*)
  • gating: an un-quarantined flake stays red; a quarantined failure goes green and is still recorded; a build error is never excused by the list; a quarantined failure alongside a non-test task failure stays red; a classifier that cannot run cannot yield green
  • classification: a test absent from the retry is not called flaky; a stray attempt-* directory is ignored while attempt-1 is still read; a testcase with no name is not counted
  • the green path: a suite that passes first time exits 0 with nothing recorded as flaky or persistent
  • summary rendering: a malformed outcome report is flagged unreadable rather than silently dropped
  • rendering: the PR summary produces the flaky table, a paste-ready proposal, and escapes pipes in failure messages

Each gating guard was mutation-checked: reverting it individually turns the corresponding assertion red.

For Datadog employees:

  • This PR doesn't touch any of that.
  • JIRA: PROF-15902

…them

CI could not tell a flaky test from a broken one, and its one retry erased the
evidence either way.

- The retry existed only for ASan. A test that failed on attempt 1 and passed
  on attempt 2 left a green job and a ::warning:: in a log, naming nothing.
- Test reports upload only `if: failure()`, so the run that recovered on a
  retry -- the one worth studying -- produced no artifact at all.
- generate-test-summary.sh already downloaded `(test-reports)*` and grepped
  TEST-*.xml for failed test names, but prepare_reports.sh copies
  build/reports/tests (the HTML) and never build/test-results (the XML), so it
  searched artifacts containing no XML and every failed job rendered "No
  detailed failure information available". A `**` glob with no `shopt -s
  globstar` would have stopped it recursing even had the files been there.

Retrying until green would only have made the tolerance official. Instead the
retry now buys a label and nothing else, and an explicit list decides what may
fail:

  flaky       failed one attempt, passed another
  broken      failed every attempt
  gating      not on the quarantine list -- red, whichever of the above it is

So a flake fails the build until somebody quarantines it against a PROF ticket.
ddprof-test/quarantine.txt is a plain "|"-separated table, one entry per line,
chosen over JSON/YAML because it is edited by hand far more than by machine:
real comments, one-line diffs, clean git blame, and no parser beyond str.split
(it must also load inside the Alpine containers, where PyYAML is not a given).

Every entry carries a ticket and a review_by date, and validate-quarantine
fails CI once that date passes -- otherwise the list only grows and quarantine
becomes a permanent mute rather than tracked debt. Quarantined tests still run
and still report; only the gating is suspended, so the pass rate keeps saying
whether the test is recovering or has quietly become permanently broken.

To keep the honest path the cheap one, the PR comment prints a filled-in entry
to paste, with a `cells` glob narrowed to the axis that actually failed. The
ticket and the judgement stay with a person; the typing does not.

Reporting is grouped by test rather than by cell -- one flaky test reddens a
dozen cells and so do a dozen unrelated breakages -- and per-cell outcomes now
upload whether the cell passed or failed, since a cell that failed only on its
first attempt produces no failure artifact.

Failing to classify is itself a failure: if flake_report.py cannot run, the job
goes red rather than inheriting a pass nothing examined. An earlier draft had
`|| true` there and turned a real flake green in testing.

test_quarantine.sh covers the gating decisions against fixtures, including that
an un-quarantined flake stays red, a quarantined one does not, a build error is
never excused by the list, and an unreadable list cannot yield green. It runs
in the validate-quarantine job. The retry path only executes once something has
failed, so CI would otherwise never exercise it.

Deferred: auto-filing PROF tickets (needs dedupe and an Atlassian credential
for CI) and the GitLab dd-trace integration matrix, which still gets one shot
per config.
@dd-octo-sts

dd-octo-sts Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Scan-Build Report

User:runner@runnervmejwal
Working Directory:/home/runner/work/java-profiler/java-profiler/ddprof-lib/src/test/make
Command Line:make -j4 all
Clang Version:Ubuntu clang version 18.1.3 (1ubuntu1)
Date:Fri Sep 4 12:33:20 2026

Bug Summary

Bug TypeQuantityDisplay?
All Bugs1
Logic error
Dereference of null pointer1

Reports

Bug Group Bug Type ▾ File Function/Method Line Path Length
Logic errorDereference of null pointerfaultInjection.cppcrashNow242

@dd-octo-sts

dd-octo-sts Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

CI Test Results

Run: #33873197693 | Commit: a2005af | Duration: 15m 5s (longest job)

All 32 test jobs passed

Status Overview

JDK glibc-aarch64/debug glibc-amd64/debug musl-aarch64/debug musl-amd64/debug
8 - - -
8-ibm - - -
8-j9 - -
8-librca - -
8-orcl - - -
11 - - -
11-j9 - -
11-librca - -
17 - -
17-graal - -
17-j9 - -
17-librca - -
21 - -
21-graal - -
21-librca - -
25 - -
25-graal - -
25-librca - -

Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled

Summary: Total: 32 | Passed: 32 | Failed: 0


Updated: 2026-09-04 12:50:30 UTC

Two defects that would have mattered:

Quarantine excused too much. When every named test failure was on the
list, the runner forced a green exit -- including when the same
invocation had also failed a native or verification task, which the
list has no business excusing. The runner now scans the attempt's log
for `Execution failed for task` naming anything outside the test task
and refuses to zero the exit code.

The documented cell glob could never match. Cells are named
<libc>-<jdk>-<config>-<arch> with arch amd64 or aarch64, so the
`*arm64*` in quarantine.txt's example and in flake_summary.py's axis
list matched nothing: the narrowing they advertised silently
quarantined everywhere. Both use aarch64 now, and `validate` rejects a
glob naming an architecture CI never builds.

Also:

- flaky now means failed once and observed passing on another attempt,
  not merely absent from it. An attempt that aborted early no longer
  turns every earlier failure into a flake with a paste-ready entry.
- the runner clears its own evidence directory, so a reused workspace
  cannot contribute a previous run's attempts to this run's gating.
- the counter and the gating read are checked rather than defaulted to
  zero, matching the fail-loud policy already applied to the classifier.
- Docker-written results are made readable before snapshotting, and the
  snapshot warns instead of discarding errors; musl-aarch64 was losing
  flake classification silently.
- pipes in failure messages are escaped, flaky tests appear in the
  per-job details, and an unparseable outcome report is visible rather
  than rendering as a clean non-test failure.
- validating a missing list fails instead of reporting zero problems;
  duplicate detection keys on the cell globs, so narrowing by cell is
  actually usable.
- one first-match helper shared by both selection paths.

The regression test for the classifier guard did not exercise it -- a
malformed line is skipped, not fatal, so the flake was the reason for
the red. It now points --list at a directory to make the classifier
genuinely fail. Each new guard was mutation-checked: reverting it turns
the corresponding test red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dd-octo-sts

dd-octo-sts Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

All 40 integration tests passed

📊 Dashboard · 👷 Pipeline · 📦 f6ddf20f

rkennke and others added 2 commits September 3, 2026 17:38
The exit code the quarantine list overrides comes from the final attempt,
but the failures it was weighed against were aggregated across all of them.
A final attempt that failed without naming a test -- a docker failure in
the musl-aarch64 job, a Gradle configuration error, an OOM-killed daemon,
the ASan init abort this retry exists for -- was excused as soon as one
entry matched a failure from an earlier attempt. flake_report.py now
reports the final attempt's own standing and the runner refuses to zero
the exit code unless that attempt produced results with every one of its
own named failures quarantined; the non-test-task grep stays as a second
line of defence rather than the only one.

Alongside it:

- validate rejects overlapping cell globs, not just byte-identical ones,
  and reports an out-of-range review_by as an annotated problem instead of
  an uncaught ValueError that loses every other annotation in the file
- the dead `propose` subcommand goes; flake_summary.py already renders the
  paste-ready entry CI actually uses
- test ids and failure messages are sanitised before they reach the PR
  comment, so a test's own output cannot break out of the fenced
  quarantine proposal a reviewer is invited to copy
- a summary with no readable outcome reports says so rather than looking
  like a clean run
- the cell label carries the slow/regular axis, so nightly's two invocations
  of the same config stop colliding in ci-outcome/<cell>.json
- testcase elements with no name are skipped instead of being counted and
  proposed for quarantine as "."
- make_results_readable probes per-file ownership rather than the top of the
  tree, and covers the parent so the pre-attempt rm -rf can unlink it
- flake-evidence/ and ci-outcome/ are gitignored

Tests: the clean-pass path and the final-attempt-named-no-test regression
are now covered (27 assertions), and a new test_generate_test_summary.sh
pins generate-test-summary.sh's jq failure branch -- verified by removing
the `!` and watching it go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A final attempt that crashes part-way through still writes JUnit XML for
the tests it reached, and those passed -- so it names no failure of its
own while every aggregated failure is quarantined. Gradle attributes the
abort to the test task itself, so the non-test-task check cannot see it
either. The final attempt's own exit code is the only thing that tells
this apart from an ordinary flaky-then-passed run, so the runner now
hands it to the classifier, which gates a non-zero exit that named
nothing.

Two evidence-integrity holes alongside it:

- snapshot() takes read access again before copying. The XML is written
  by the command that just ran, after the loop-top call, and under
  Docker it lands root-owned; without this the copy fails and the cell
  loses flake classification silently.
- make_results_readable() reports failure instead of warning and
  returning success, and its callers turn that into EVIDENCE_SUSPECT. A
  snapshot missing root-owned files is indistinguishable from an attempt
  whose missing tests all passed, which is exactly what the quarantine
  list must not be allowed to excuse.

Three assertions cover these; each was mutation-checked individually.
One of them asserts the ordinary quarantined-flake-recovers case stays
green, so the new gate cannot be satisfied by reddening everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rkennke rkennke added the sphinx:critical Sphinx: critical — human review required label Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sphinx:critical Sphinx: critical — human review required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant