This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch ci-duration-monitor-sustained-alerts
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit f74edbc36b44f2ea9af0340f4b8c37538336c5fd
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Sep 22 14:05:20 2026 +0200

    Alert on CI job slowdowns only when every recent run is slow
    
    The per-job duration alert compares the median of the last three canary
    runs against the baseline median. A job that was slow twice and has since
    recovered still clears that comparison, so the nightly report announced
    blips as regressions: on 2026-09-22 it flagged the MySQL 8.4:3.13
    serialization job at 25m -> 33m, from the runs 25m, 33m, 34m, where the
    most recent run was already back at the baseline.
    
    Replayed over the 17 successful canaries of 2026-09-12..21, requiring
    every run in the latest window to clear the threshold cuts the per-job
    alerts from 16 to 5 while keeping the sustained climbs - the
    constraints-version-check jobs whose work grows with every day of
    dependency drift - and dropping the DB-test and image-build one-nighters.
    
    The report now also carries the evidence behind each alert, so a reader
    can judge it without opening the API: the flagged job's last runs, the
    band it usually lands in, and the recent runs with their start times and
    both image-excluded and wall-clock durations.
    
    Generated-by: Claude Opus 5
---
 .github/workflows/ci-duration-monitor.yml         |   6 +
 scripts/ci/analyze_ci_job_durations.py            | 155 +++++++++++++++++++---
 scripts/tests/ci/test_analyze_ci_job_durations.py | 124 +++++++++++++++++
 3 files changed, 270 insertions(+), 15 deletions(-)

diff --git a/.github/workflows/ci-duration-monitor.yml 
b/.github/workflows/ci-duration-monitor.yml
index 52f2d06c2fb..65de09dcc58 100644
--- a/.github/workflows/ci-duration-monitor.yml
+++ b/.github/workflows/ci-duration-monitor.yml
@@ -63,6 +63,12 @@ jobs:
           # Network-bound jobs (constraint resolution, provider installs) 
legitimately swing
           # tens of minutes run-to-run; require a larger sustained jump before 
flagging them.
           JOB_MIN_ABS_INCREASE_MINUTES: "6"
+          # The median of the latest window still reports a job that was slow 
twice and has
+          # since recovered: the 2026-09-22 MySQL 8.4:3.13 alert came from 
25m, 33m, 34m,
+          # where the most recent run was already back at the 25m baseline. 
Requiring every
+          # run in the window to be slow drops those blips and keeps the 
sustained climbs
+          # (replayed over the 2026-09-12..21 canaries).
+          JOB_REQUIRE_SUSTAINED: "true"
           OUTPUT_FILE: "slack-message.json"
 
       - name: "Post duration alert to Slack"
diff --git a/scripts/ci/analyze_ci_job_durations.py 
b/scripts/ci/analyze_ci_job_durations.py
index 202dfb449e1..446bddb31b2 100644
--- a/scripts/ci/analyze_ci_job_durations.py
+++ b/scripts/ci/analyze_ci_job_durations.py
@@ -35,6 +35,12 @@ both a relative margin (``REL_THRESHOLD``) and an absolute 
floor
 (``MIN_ABS_INCREASE_MINUTES`` / ``JOB_MIN_ABS_INCREASE_MINUTES``) so short jobs
 with noisy timings do not trigger spurious alerts.
 
+Per-job alerts have a third gate, because a median over the latest window still
+reports a job that had two slow nights and then recovered. With
+``JOB_REQUIRE_SUSTAINED`` (the default) *every* run in the latest window must 
clear
+the relative threshold on its own, not just their median, so a blip that has 
already
+passed is not announced as a regression (:func:`is_sustained`).
+
 Steps that build, pull or push images (``IMAGE_WORK_STEP_PREFIXES`` — 
preparing the
 CI/PROD image in a test job, and the build+push of the image-cache jobs) 
occasionally
 balloon on a one-off cache miss, so their time is *excluded* from the run and 
per-job
@@ -57,6 +63,9 @@ Environment variables (optional):
   REL_THRESHOLD             - Relative increase over baseline to flag, e.g. 
0.25 = 25% (default: 0.25)
   MIN_ABS_INCREASE_MINUTES  - Absolute floor for the overall-run alert 
(default: 5)
   JOB_MIN_ABS_INCREASE_MINUTES - Absolute floor for per-job alerts (default: 3)
+  JOB_REQUIRE_SUSTAINED     - Require every run in the latest window to be 
above the
+                              relative threshold, not just their median 
("true"/"false",
+                              default: true)
   IMAGE_BUILD_PERSISTENCE_DAYS - Only report a slow image build once it has 
stayed
                               elevated for at least this many days (default: 2)
   ANALYZE_JOBS              - Whether to fetch per-job durations 
("true"/"false", default: true)
@@ -189,6 +198,37 @@ def median(values: list[float]) -> float:
     return (ordered[mid - 1] + ordered[mid]) / 2
 
 
+# How many recent runs the report shows per flagged job, and in its 
recent-runs list.
+# Enough to see the swing a job normally has; more turns the Slack message 
into a table.
+RECENT_VALUES_SHOWN = 8
+
+
+def usual_range(values: list[float]) -> tuple[float, float]:
+    """Return the middle half (25th-75th percentile, nearest rank) of 
``values``.
+
+    Reported as the band a job usually lands in. The middle half rather than 
min-max
+    because a window that happens to contain one cache-cold 58-minute night 
would
+    otherwise claim that night as "usual" and make the alert unreadable.
+    """
+    ordered = sorted(values)
+    last = len(ordered) - 1
+    return ordered[min(int(0.25 * len(ordered)), last)], ordered[min(int(0.75 
* len(ordered)), last)]
+
+
+def is_sustained(latest_values: list[float], baseline: float, rel_threshold: 
float) -> bool:
+    """Whether *every* run in the latest window is itself above the relative 
threshold.
+
+    The median of the latest window is not enough on its own. A job that ran 
25m, 33m,
+    34m has a latest median of 33m against a 25m baseline — but its most 
recent run was
+    back at baseline, so the two slow nights were a blip, not a trend. 
Requiring each run
+    in the window to clear the threshold is what separates that from a real 
regression,
+    where every run after the change is slow (verified against 17 canary runs 
of
+    2026-09-12..21: it keeps the constraints-check regressions and drops the 
DB-test and
+    image-build blips).
+    """
+    return all(value >= baseline * (1 + rel_threshold) for value in 
latest_values)
+
+
 def format_duration(seconds: float) -> str:
     """Format a duration in seconds as e.g. ``29m 41s``."""
     total = int(round(seconds))
@@ -198,6 +238,46 @@ def format_duration(seconds: float) -> str:
     return f"{minutes}m {secs:02d}s"
 
 
+def format_duration_compact(seconds: float) -> str:
+    """Format a duration for a series of many values, e.g. ``33m``.
+
+    Whole minutes: a reader scanning a job's last runs is judging a swing of 
minutes,
+    and the seconds turn the series into a wall of digits.
+    """
+    if seconds < 60:
+        return f"{int(round(seconds))}s"
+    return f"{int(round(seconds / 60))}m"
+
+
+def format_duration_series(values: list[float]) -> str:
+    """Render newest-first durations as ``33m · 31m · 42m``."""
+    return " · ".join(format_duration_compact(value) for value in values)
+
+
+def format_usual_range(band: tuple[float, float] | None) -> str:
+    """Render a baseline band as ``, usually 16m 00s–23m 00s``, or nothing 
when absent."""
+    if not band:
+        return ""
+    low, high = band
+    return f", usually {format_duration(low)}–{format_duration(high)}"
+
+
+def format_run_timestamp(created_at: str | None) -> str:
+    """Format a run's start as ``Sep 21 13:58`` (UTC), or empty when 
unparsable."""
+    started = parse_iso(created_at)
+    return started.strftime("%b %d %H:%M") if started else ""
+
+
+def adjusted_run_duration(run: dict) -> float:
+    """A run's wall-clock with its image-build time removed, as the trend 
measures it.
+
+    Runs carry the adjusted value once :func:`main` has their jobs; falling 
back to the
+    raw wall-clock keeps the report readable when jobs were not fetched at all
+    (``ANALYZE_JOBS=false``).
+    """
+    return run.get("adjusted_duration", run["duration"])
+
+
 def format_duration_delta(seconds: float) -> str:
     """Format a duration delta with an explicit sign."""
     if seconds < 0:
@@ -348,11 +428,14 @@ def detect_regression(
     baseline_values: list[float],
     rel_threshold: float,
     min_abs_increase_seconds: float,
+    require_sustained: bool = False,
 ) -> dict | None:
     """Compare latest durations against a baseline window.
 
     Returns a dict describing the regression when the latest median is above 
the
-    baseline median by both the relative threshold and the absolute floor, 
else None.
+    baseline median by the relative threshold and the absolute floor — and, 
when
+    ``require_sustained`` is set, when every run in the latest window clears 
the
+    relative threshold too — else None.
     """
     if not latest_values or not baseline_values:
         return None
@@ -360,12 +443,17 @@ def detect_regression(
     baseline = median(baseline_values)
     increase = latest - baseline
     rel_increase = increase / baseline if baseline > 0 else 0.0
-    if increase >= min_abs_increase_seconds and rel_increase >= rel_threshold:
+    if (
+        increase >= min_abs_increase_seconds
+        and rel_increase >= rel_threshold
+        and (not require_sustained or is_sustained(latest_values, baseline, 
rel_threshold))
+    ):
         return {
             "latest": latest,
             "baseline": baseline,
             "increase": increase,
             "rel_increase": rel_increase,
+            "usual_range": usual_range(baseline_values),
         }
     return None
 
@@ -386,12 +474,15 @@ def analyze_jobs(
     min_baseline_runs: int,
     rel_threshold: float,
     min_abs_increase_seconds: float,
+    require_sustained: bool,
 ) -> list[dict]:
     """Return the jobs whose latest duration regressed, image-build time 
excluded.
 
     The comparison uses :func:`calculate_work_duration` (wall-clock minus the 
image-build
     step) on both sides, so an occasional image rebuild spike does not flag a 
job
-    that did not actually get slower.
+    that did not actually get slower. Each job must additionally have been 
slow on every
+    run of the latest window (:func:`is_sustained`), so a job that has already 
recovered
+    does not fill the report.
     """
     latest_job_durations: dict[str, list[float]] = {}
     for run in latest_runs:
@@ -409,10 +500,13 @@ def analyze_jobs(
         if len(baseline_values) < min_baseline_runs:
             continue
         regression = detect_regression(
-            latest_values, baseline_values, rel_threshold, 
min_abs_increase_seconds
+            latest_values, baseline_values, rel_threshold, 
min_abs_increase_seconds, require_sustained
         )
         if regression:
             regression["job"] = name
+            # Newest first, latest window then baseline: the reader sees the 
step the alert
+            # is about with the run-to-run swing it stands out from underneath 
it.
+            regression["recent_values"] = (latest_values + 
baseline_values)[:RECENT_VALUES_SHOWN]
             regressions.append(regression)
 
     regressions.sort(key=lambda r: r["rel_increase"], reverse=True)
@@ -549,13 +643,18 @@ def format_slack_message(
         )
 
     if job_regressions:
-        lines = ["*Jobs that got slower (image build excluded):*"]
+        lines = ["*Jobs that got slower (image build excluded, slow on every 
recent run):*"]
         for reg in job_regressions[:15]:
             lines.append(
                 f"• *{escape_slack_mrkdwn(reg['job'])}* — "
                 f"{format_duration(reg['baseline'])} → 
*{format_duration(reg['latest'])}* "
-                f"(+{round(reg['rel_increase'] * 100, 1)}%)"
+                f"(+{round(reg['rel_increase'] * 100, 1)}%"
+                f"{format_usual_range(reg.get('usual_range'))})"
             )
+            if reg.get("recent_values"):
+                lines.append(
+                    f"    _last runs (newest first): 
{format_duration_series(reg['recent_values'])}_"
+                )
         text = "\n".join(lines)
         if len(text) > 2900:
             text = text[:2900] + "\n_...truncated_"
@@ -574,8 +673,22 @@ def format_slack_message(
             )
 
     if recent_runs:
+        run_values = [adjusted_run_duration(run) for run in recent_runs]
+        run_lines = [
+            f"*Last {min(len(recent_runs), RECENT_VALUES_SHOWN)} runs "
+            f"(image build excluded; median 
{format_duration(median(run_values))}"
+            f"{format_usual_range(usual_range(run_values))}):*"
+        ]
+        for run in recent_runs[:RECENT_VALUES_SHOWN]:
+            run_lines.append(
+                f"• <{run['html_url']}|#{run['run_number']}> "
+                f"{format_run_timestamp(run.get('created_at'))} — "
+                f"{format_duration(adjusted_run_duration(run))} "
+                f"(wall-clock {format_duration(run['duration'])})"
+            )
         latest_run = recent_runs[0]
         blocks.append({"type": "divider"})
+        blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": 
"\n".join(run_lines)}})
         blocks.append(
             {
                 "type": "context",
@@ -627,7 +740,8 @@ def write_step_summary(
         "## ⏱️ CI Duration Trend",
         "",
         f"Workflow `{workflow}` on `{branch}` — baseline from {baseline_count} 
preceding runs. "
-        "Image-build time is excluded from run/job durations and tracked 
separately.",
+        "Image-build time is excluded from run/job durations and tracked 
separately. "
+        "A job is only listed when every run in the latest window was slow, 
not just their median.",
         "",
     ]
 
@@ -660,13 +774,16 @@ def write_step_summary(
         lines += [
             "### ⚠️ Slower jobs",
             "",
-            "| Job | Baseline | Latest | Increase |",
-            "|-----|----------|--------|----------|",
+            "| Job | Baseline | Latest | Increase | Usually | Last runs 
(newest first) |",
+            
"|-----|----------|--------|----------|---------|--------------------------|",
         ]
         for reg in job_regressions[:25]:
+            low, high = reg.get("usual_range") or (0.0, 0.0)
             lines.append(
                 f"| {reg['job']} | {format_duration(reg['baseline'])} | "
-                f"{format_duration(reg['latest'])} | 
+{round(reg['rel_increase'] * 100, 1)}% |"
+                f"{format_duration(reg['latest'])} | 
+{round(reg['rel_increase'] * 100, 1)}% | "
+                f"{format_duration(low)}–{format_duration(high)} | "
+                f"{format_duration_series(reg.get('recent_values', []))} |"
             )
         lines.append("")
     else:
@@ -676,12 +793,14 @@ def write_step_summary(
         lines += [
             "### Recent run durations",
             "",
-            "| Run | Event | Duration |",
-            "|-----|-------|----------|",
+            "| Run | Started (UTC) | Image build excluded | Wall-clock |",
+            "|-----|---------------|----------------------|------------|",
         ]
         for run in recent_runs[:15]:
             lines.append(
-                f"| [#{run['run_number']}]({run['html_url']}) | {run['event']} 
| "
+                f"| [#{run['run_number']}]({run['html_url']}) | "
+                f"{format_run_timestamp(run.get('created_at'))} | "
+                f"{format_duration(adjusted_run_duration(run))} | "
                 f"{format_duration(run['duration'])} |"
             )
         lines.append("")
@@ -701,6 +820,7 @@ def main() -> None:
     rel_threshold = env_float("REL_THRESHOLD", 0.25)
     min_abs_increase_seconds = env_float("MIN_ABS_INCREASE_MINUTES", 5.0) * 60
     job_min_abs_increase_seconds = env_float("JOB_MIN_ABS_INCREASE_MINUTES", 
3.0) * 60
+    job_require_sustained = env_bool("JOB_REQUIRE_SUSTAINED", True)
     image_build_persistence_days = env_float("IMAGE_BUILD_PERSISTENCE_DAYS", 
2.0)
     do_analyze_jobs = env_bool("ANALYZE_JOBS", True)
     only_successful = env_bool("ONLY_SUCCESSFUL", True)
@@ -733,9 +853,13 @@ def main() -> None:
         image_build = 
calculate_image_build_seconds(jobs_by_run_id.get(run["id"], {})) or 0.0
         return max(run["duration"] - image_build, 0.0)
 
+    # Stored on the run so the report can show the same figure the trend is 
measured on.
+    for run in runs:
+        run["adjusted_duration"] = calculate_adjusted_run_duration(run)
+
     overall_regression = detect_regression(
-        [calculate_adjusted_run_duration(r) for r in latest_runs],
-        [calculate_adjusted_run_duration(r) for r in baseline_runs],
+        [adjusted_run_duration(r) for r in latest_runs],
+        [adjusted_run_duration(r) for r in baseline_runs],
         rel_threshold,
         min_abs_increase_seconds,
     )
@@ -759,6 +883,7 @@ def main() -> None:
             min_baseline_runs,
             rel_threshold,
             job_min_abs_increase_seconds,
+            job_require_sustained,
         )
         print(f"Jobs that regressed: {len(job_regressions)}")
 
diff --git a/scripts/tests/ci/test_analyze_ci_job_durations.py 
b/scripts/tests/ci/test_analyze_ci_job_durations.py
index be5b3cbc2e1..d82c0c3eb8d 100644
--- a/scripts/tests/ci/test_analyze_ci_job_durations.py
+++ b/scripts/tests/ci/test_analyze_ci_job_durations.py
@@ -156,6 +156,77 @@ class TestDetectRegression:
         assert durations_module.detect_regression([], [1, 2], 0.25, 300) is 
None
         assert durations_module.detect_regression([1], [], 0.25, 300) is None
 
+    def test_a_recovered_blip_is_not_flagged(self, durations_module):
+        """The 2026-09-22 MySQL 8.4:3.13 alert: 25m, 33m, 34m over a 25m 
baseline.
+
+        The latest median is 33m, but the most recent run was already back at 
baseline —
+        two slow nights, not a regression.
+        """
+        regression = durations_module.detect_regression(
+            latest_values=[1500, 1980, 2040],
+            baseline_values=[1440, 1500, 1560, 1440, 1500, 1500, 1620],
+            rel_threshold=0.25,
+            min_abs_increase_seconds=360,
+            require_sustained=True,
+        )
+        assert regression is None
+
+    def test_a_sustained_climb_is_flagged(self, durations_module):
+        """The same window's Deps 3.10 alert: 31m, 33m, 27m over an 18m 
baseline."""
+        regression = durations_module.detect_regression(
+            latest_values=[1860, 1980, 1620],
+            baseline_values=[960, 1020, 1080, 1080, 1380, 1020, 960],
+            rel_threshold=0.25,
+            min_abs_increase_seconds=360,
+            require_sustained=True,
+        )
+        assert regression is not None
+        assert regression["latest"] == 1860
+
+    def test_the_same_blip_is_flagged_without_the_sustained_gate(self, 
durations_module):
+        """Guards the gate itself: the blip clears the margin and the floor on 
its own."""
+        regression = durations_module.detect_regression(
+            latest_values=[1500, 1980, 2040],
+            baseline_values=[1440, 1500, 1560, 1440, 1500, 1500, 1620],
+            rel_threshold=0.25,
+            min_abs_increase_seconds=360,
+        )
+        assert regression is not None
+
+    def test_reports_the_usual_range(self, durations_module):
+        regression = durations_module.detect_regression(
+            latest_values=[2700],
+            baseline_values=[1500, 1700, 1800, 1900, 2000],
+            rel_threshold=0.25,
+            min_abs_increase_seconds=300,
+        )
+        assert regression is not None
+        assert regression["usual_range"] == (1700, 1900)
+
+
+class TestUsualRange:
+    def test_middle_half_of_the_values(self, durations_module):
+        assert durations_module.usual_range([10, 20, 30, 40]) == (20, 40)
+
+    def test_excludes_a_single_extreme(self, durations_module):
+        """A cache-cold 58-minute night must not be reported as part of the 
usual band."""
+        low, high = durations_module.usual_range([16, 17, 18, 18, 17, 16, 58])
+        assert (low, high) == (16, 18)
+
+    def test_single_value(self, durations_module):
+        assert durations_module.usual_range([42]) == (42, 42)
+
+
+class TestIsSustained:
+    def test_true_when_every_run_is_above_the_threshold(self, 
durations_module):
+        assert durations_module.is_sustained([1860, 1980, 1620], 1080, 0.25) 
is True
+
+    def test_false_when_one_run_is_back_at_baseline(self, durations_module):
+        assert durations_module.is_sustained([1500, 1980, 2040], 1500, 0.25) 
is False
+
+    def test_true_for_a_single_latest_run(self, durations_module):
+        assert durations_module.is_sustained([2000], 1000, 0.25) is True
+
 
 class TestGetRecentRuns:
     def _runs_payload(self):
@@ -429,6 +500,7 @@ class TestAnalyzeJobs:
             min_baseline_runs=5,
             rel_threshold=0.25,
             min_abs_increase_seconds=180,
+            require_sustained=True,
         )
         names = [r["job"] for r in regressions]
         # slow-job regressed; stable-job did not; new-job lacks baseline 
samples
@@ -452,6 +524,7 @@ class TestAnalyzeJobs:
             min_baseline_runs=5,
             rel_threshold=0.25,
             min_abs_increase_seconds=60,
+            require_sustained=True,
         )
         assert regressions == []
 
@@ -487,6 +560,7 @@ class TestAnalyzeJobs:
             min_baseline_runs=5,
             rel_threshold=0.25,
             min_abs_increase_seconds=360,
+            require_sustained=True,
         )
         assert regressions == []
 
@@ -644,6 +718,56 @@ class TestFormatSlackMessage:
         assert "18m 00s" in json.dumps(msg)  # 1080s latest
         assert "image build slow" in msg["text"].lower()
 
+    def test_reports_the_last_runs_and_the_spread(self, durations_module):
+        """The alert has to carry the evidence: what the job did on each 
recent run."""
+        msg = durations_module.format_slack_message(
+            repo="apache/airflow",
+            workflow="ci-amd.yml",
+            branch="main",
+            overall_regression=None,
+            job_regressions=[
+                {
+                    "job": "Tests",
+                    "latest": 1500,
+                    "baseline": 1000,
+                    "increase": 500,
+                    "rel_increase": 0.5,
+                    "usual_range": (960, 1080),
+                    "recent_values": [1500, 1020, 960, 1080],
+                }
+            ],
+            image_build_regression=None,
+            recent_runs=[
+                {
+                    "run_number": 102,
+                    "html_url": "https://example/2";,
+                    "duration": 2700,
+                    "adjusted_duration": 2400,
+                    "created_at": "2026-09-21T13:58:37Z",
+                },
+                {
+                    "run_number": 101,
+                    "html_url": "https://example/1";,
+                    "duration": 2500,
+                    "adjusted_duration": 2200,
+                    "created_at": "2026-09-21T01:58:37Z",
+                },
+            ],
+            rel_threshold=0.25,
+            channel="internal-airflow-ci-cd",
+        )
+        # Read the block text directly: json.dumps escapes the non-ASCII 
separators away.
+        blob = "\n".join(
+            block["text"]["text"] for block in msg["blocks"] if 
block.get("text", {}).get("text")
+        )
+        assert "usually 16m 00s–18m 00s" in blob
+        assert "last runs (newest first): 25m · 17m · 16m · 18m" in blob
+        assert "Last 2 runs" in blob
+        assert "median 38m 20s, usually 36m 40s–40m 00s" in blob
+        # Both the figure the trend is measured on and the raw wall-clock, per 
run.
+        assert "Sep 21 13:58 — 40m 00s (wall-clock 45m 00s)" in blob
+        assert "Sep 21 01:58 — 36m 40s (wall-clock 41m 40s)" in blob
+
     def test_omits_image_build_section_when_none(self, durations_module):
         msg = durations_module.format_slack_message(
             repo="apache/airflow",

Reply via email to