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

potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new 0b03b14cada [v3-3-test] Show prepare breeze timing in CI duration 
alerts (#69732) (#69753)
0b03b14cada is described below

commit 0b03b14cada1d59c69926516b1a4b1d4864ae68c
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sat Jul 11 15:45:16 2026 +0200

    [v3-3-test] Show prepare breeze timing in CI duration alerts (#69732) 
(#69753)
    
    (cherry picked from commit 2e5962245f1e33d5efc8ca880643754009380c5e)
    
    Co-authored-by: GPK <[email protected]>
---
 scripts/ci/analyze_ci_job_durations.py            |  85 ++++++++++++++--
 scripts/tests/ci/test_analyze_ci_job_durations.py | 119 +++++++++++++++++++++-
 2 files changed, 191 insertions(+), 13 deletions(-)

diff --git a/scripts/ci/analyze_ci_job_durations.py 
b/scripts/ci/analyze_ci_job_durations.py
index 6cc512f645b..a03ddf7a57e 100644
--- a/scripts/ci/analyze_ci_job_durations.py
+++ b/scripts/ci/analyze_ci_job_durations.py
@@ -66,8 +66,15 @@ import subprocess
 import sys
 from datetime import datetime
 from pathlib import Path
+from typing import TypedDict
 
 ISO_SUFFIX_Z = "Z"
+PREPARE_BREEZE_STEP_PREFIX = "Prepare breeze & CI image"
+
+
+class JobDuration(TypedDict):
+    duration: float
+    prepare_breeze_duration: float | None
 
 
 def env_float(name: str, default: float) -> float:
@@ -171,6 +178,25 @@ def format_duration(seconds: float) -> str:
     return f"{minutes}m {secs:02d}s"
 
 
+def format_duration_delta(seconds: float) -> str:
+    """Format a duration delta with an explicit sign."""
+    if seconds < 0:
+        return f"-{format_duration(abs(seconds))}"
+    return f"+{format_duration(seconds)}"
+
+
+def format_prepare_breeze_timing(regression: dict) -> str | None:
+    """Format prepare breeze timing details for a job regression."""
+    if prepare_breeze := regression.get("prepare_breeze"):
+        return (
+            f"{PREPARE_BREEZE_STEP_PREFIX}: "
+            f"{format_duration(prepare_breeze['baseline'])} → "
+            f"{format_duration(prepare_breeze['latest'])} "
+            f"({format_duration_delta(prepare_breeze['increase'])})"
+        )
+    return None
+
+
 # Wall-clock shorter than this almost always means a run that was cancelled,
 # skipped by selective checks, or never really executed the test matrix — not a
 # representative "main build". Such runs would corrupt the duration baseline.
@@ -227,8 +253,18 @@ def get_recent_runs(
     return runs
 
 
-def get_run_jobs(repo: str, run_id: int) -> dict[str, float]:
-    """Return a mapping of job name -> duration in seconds for a single run.
+def get_prepare_breeze_step_duration(job: dict) -> float | None:
+    """Return the prepare breeze step duration for a job, when the step 
exists."""
+    for step in job.get("steps", []):
+        name = step.get("name", "")
+        if not name.startswith(PREPARE_BREEZE_STEP_PREFIX):
+            continue
+        return duration_seconds(step.get("startedAt"), step.get("completedAt"))
+    return None
+
+
+def get_run_jobs(repo: str, run_id: int) -> dict[str, JobDuration]:
+    """Return a mapping of job name -> duration details for a single run.
 
     Only jobs that completed successfully are included, so that a job which was
     cancelled or skipped on a particular run does not pollute its duration 
trend.
@@ -247,7 +283,7 @@ def get_run_jobs(repo: str, run_id: int) -> dict[str, 
float]:
     except json.JSONDecodeError:
         return {}
 
-    durations: dict[str, float] = {}
+    durations: dict[str, JobDuration] = {}
     for job in data.get("jobs", []):
         if job.get("conclusion") != "success":
             continue
@@ -256,7 +292,12 @@ def get_run_jobs(repo: str, run_id: int) -> dict[str, 
float]:
             continue
         name = job.get("name", "unknown")
         # A matrix can surface the same job name more than once per run; keep 
the longest.
-        durations[name] = max(durations.get(name, 0.0), seconds)
+        existing = durations.get(name)
+        if existing is None or seconds > existing["duration"]:
+            durations[name] = {
+                "duration": seconds,
+                "prepare_breeze_duration": 
get_prepare_breeze_step_duration(job),
+            }
     return durations
 
 
@@ -297,14 +338,22 @@ def analyze_jobs(
 ) -> list[dict]:
     """Fetch per-job durations and return the jobs whose latest duration 
regressed."""
     latest_job_durations: dict[str, list[float]] = {}
+    latest_prepare_breeze_durations: dict[str, list[float]] = {}
     for run in latest_runs:
-        for name, seconds in get_run_jobs(repo, run["id"]).items():
-            latest_job_durations.setdefault(name, []).append(seconds)
+        for name, job_data in get_run_jobs(repo, run["id"]).items():
+            latest_job_durations.setdefault(name, 
[]).append(job_data["duration"])
+            prepare_breeze_duration = job_data["prepare_breeze_duration"]
+            if prepare_breeze_duration is not None:
+                latest_prepare_breeze_durations.setdefault(name, 
[]).append(prepare_breeze_duration)
 
     baseline_job_durations: dict[str, list[float]] = {}
+    baseline_prepare_breeze_durations: dict[str, list[float]] = {}
     for run in baseline_runs:
-        for name, seconds in get_run_jobs(repo, run["id"]).items():
-            baseline_job_durations.setdefault(name, []).append(seconds)
+        for name, job_data in get_run_jobs(repo, run["id"]).items():
+            baseline_job_durations.setdefault(name, 
[]).append(job_data["duration"])
+            prepare_breeze_duration = job_data["prepare_breeze_duration"]
+            if prepare_breeze_duration is not None:
+                baseline_prepare_breeze_durations.setdefault(name, 
[]).append(prepare_breeze_duration)
 
     regressions: list[dict] = []
     for name, latest_values in latest_job_durations.items():
@@ -315,6 +364,16 @@ def analyze_jobs(
             latest_values, baseline_values, rel_threshold, 
min_abs_increase_seconds
         )
         if regression:
+            latest_prepare_breeze_values = 
latest_prepare_breeze_durations.get(name, [])
+            baseline_prepare_breeze_values = 
baseline_prepare_breeze_durations.get(name, [])
+            if latest_prepare_breeze_values and 
len(baseline_prepare_breeze_values) >= min_baseline_runs:
+                latest_prepare_breeze = median(latest_prepare_breeze_values)
+                baseline_prepare_breeze = 
median(baseline_prepare_breeze_values)
+                regression["prepare_breeze"] = {
+                    "latest": latest_prepare_breeze,
+                    "baseline": baseline_prepare_breeze,
+                    "increase": latest_prepare_breeze - 
baseline_prepare_breeze,
+                }
             regression["job"] = name
             regressions.append(regression)
 
@@ -373,11 +432,14 @@ def format_slack_message(
     if job_regressions:
         lines = ["*Jobs that got slower:*"]
         for reg in job_regressions[:15]:
-            lines.append(
+            line = (
                 f"• *{escape_slack_mrkdwn(reg['job'])}* — "
                 f"{format_duration(reg['baseline'])} → 
*{format_duration(reg['latest'])}* "
                 f"(+{round(reg['rel_increase'] * 100, 1)}%)"
             )
+            if prepare_breeze_timing := format_prepare_breeze_timing(reg):
+                line += f"\n  {escape_slack_mrkdwn(prepare_breeze_timing)}"
+            lines.append(line)
         text = "\n".join(lines)
         if len(text) > 2900:
             text = text[:2900] + "\n_...truncated_"
@@ -470,8 +532,11 @@ def write_step_summary(
             "|-----|----------|--------|----------|",
         ]
         for reg in job_regressions[:25]:
+            job = reg["job"]
+            if prepare_breeze_timing := format_prepare_breeze_timing(reg):
+                job += f"<br>{prepare_breeze_timing}"
             lines.append(
-                f"| {reg['job']} | {format_duration(reg['baseline'])} | "
+                f"| {job} | {format_duration(reg['baseline'])} | "
                 f"{format_duration(reg['latest'])} | 
+{round(reg['rel_increase'] * 100, 1)}% |"
             )
         lines.append("")
diff --git a/scripts/tests/ci/test_analyze_ci_job_durations.py 
b/scripts/tests/ci/test_analyze_ci_job_durations.py
index 432fefe44a5..6bac585b60c 100644
--- a/scripts/tests/ci/test_analyze_ci_job_durations.py
+++ b/scripts/tests/ci/test_analyze_ci_job_durations.py
@@ -101,6 +101,14 @@ class TestFormatDuration:
         assert durations_module.format_duration(60 + 5) == "1m 05s"
 
 
+class TestFormatDurationDelta:
+    def test_positive(self, durations_module):
+        assert durations_module.format_duration_delta(60 + 5) == "+1m 05s"
+
+    def test_negative(self, durations_module):
+        assert durations_module.format_duration_delta(-(60 + 5)) == "-1m 05s"
+
+
 class TestDetectRegression:
     def test_flags_regression_above_both_thresholds(self, durations_module):
         # baseline median ~1800s (30m), latest 2700s (45m) -> +50%, +15m
@@ -238,6 +246,13 @@ class TestGetRunJobs:
                         "conclusion": "success",
                         "startedAt": "2026-06-10T13:00:00Z",
                         "completedAt": "2026-06-10T13:20:00Z",
+                        "steps": [
+                            {
+                                "name": "Prepare breeze & CI image: 3.10",
+                                "startedAt": "2026-06-10T13:00:00Z",
+                                "completedAt": "2026-06-10T13:05:00Z",
+                            }
+                        ],
                     },
                     {
                         "name": "Skipped job",
@@ -251,7 +266,58 @@ class TestGetRunJobs:
         completed = subprocess.CompletedProcess(args=[], returncode=0, 
stdout=payload, stderr="")
         with patch.object(subprocess, "run", return_value=completed):
             jobs = durations_module.get_run_jobs("apache/airflow", 2)
-        assert jobs == {"Tests": 20 * 60}
+        assert jobs == {"Tests": {"duration": 20 * 60, 
"prepare_breeze_duration": 5 * 60}}
+
+    def test_omits_prepare_breeze_duration_when_step_missing(self, 
durations_module):
+        payload = json.dumps(
+            {
+                "jobs": [
+                    {
+                        "name": "Tests",
+                        "conclusion": "success",
+                        "startedAt": "2026-06-10T13:00:00Z",
+                        "completedAt": "2026-06-10T13:20:00Z",
+                        "steps": [],
+                    }
+                ]
+            }
+        )
+        completed = subprocess.CompletedProcess(args=[], returncode=0, 
stdout=payload, stderr="")
+        with patch.object(subprocess, "run", return_value=completed):
+            jobs = durations_module.get_run_jobs("apache/airflow", 2)
+        assert jobs == {"Tests": {"duration": 20 * 60, 
"prepare_breeze_duration": None}}
+
+    def test_keeps_longest_duplicate_job_name(self, durations_module):
+        payload = json.dumps(
+            {
+                "jobs": [
+                    {
+                        "name": "Tests",
+                        "conclusion": "success",
+                        "startedAt": "2026-06-10T13:00:00Z",
+                        "completedAt": "2026-06-10T13:10:00Z",
+                        "steps": [],
+                    },
+                    {
+                        "name": "Tests",
+                        "conclusion": "success",
+                        "startedAt": "2026-06-10T13:00:00Z",
+                        "completedAt": "2026-06-10T13:20:00Z",
+                        "steps": [
+                            {
+                                "name": "Prepare breeze & CI image: 3.10",
+                                "startedAt": "2026-06-10T13:00:00Z",
+                                "completedAt": "2026-06-10T13:05:00Z",
+                            }
+                        ],
+                    },
+                ]
+            }
+        )
+        completed = subprocess.CompletedProcess(args=[], returncode=0, 
stdout=payload, stderr="")
+        with patch.object(subprocess, "run", return_value=completed):
+            jobs = durations_module.get_run_jobs("apache/airflow", 2)
+        assert jobs == {"Tests": {"duration": 20 * 60, 
"prepare_breeze_duration": 5 * 60}}
 
     def test_empty_on_command_failure(self, durations_module):
         completed = subprocess.CompletedProcess(args=[], returncode=1, 
stdout="", stderr="boom")
@@ -266,9 +332,16 @@ class TestAnalyzeJobs:
 
         def fake_jobs(_repo, run_id):
             if run_id == 100:
-                return {"slow-job": 2700, "stable-job": 600, "new-job": 999}
+                return {
+                    "slow-job": {"duration": 2700, "prepare_breeze_duration": 
900},
+                    "stable-job": {"duration": 600, "prepare_breeze_duration": 
None},
+                    "new-job": {"duration": 999, "prepare_breeze_duration": 
300},
+                }
             # baseline runs
-            return {"slow-job": 1800, "stable-job": 590}
+            return {
+                "slow-job": {"duration": 1800, "prepare_breeze_duration": 300},
+                "stable-job": {"duration": 590, "prepare_breeze_duration": 
None},
+            }
 
         with patch.object(durations_module, "get_run_jobs", 
side_effect=fake_jobs):
             regressions = durations_module.analyze_jobs(
@@ -282,6 +355,7 @@ class TestAnalyzeJobs:
         names = [r["job"] for r in regressions]
         # slow-job regressed; stable-job did not; new-job lacks baseline 
samples
         assert names == ["slow-job"]
+        assert regressions[0]["prepare_breeze"] == {"latest": 900, "baseline": 
300, "increase": 600}
 
 
 class TestFormatSlackMessage:
@@ -308,3 +382,42 @@ class TestFormatSlackMessage:
         text_blob = json.dumps(msg)
         assert "Tests" in text_blob
         assert "main" in msg["text"]
+
+    def test_includes_prepare_breeze_timing_when_available(self, 
durations_module):
+        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,
+                    "prepare_breeze": {"latest": 600, "baseline": 300, 
"increase": 300},
+                }
+            ],
+            recent_runs=[{"run_number": 102, "html_url": "https://example/2";, 
"duration": 2700}],
+            rel_threshold=0.25,
+            channel="internal-airflow-ci-cd",
+        )
+        text_blob = json.dumps(msg)
+        assert "Prepare breeze &amp; CI image: 5m 00s" in text_blob
+        assert "10m 00s" in text_blob
+
+    def test_omits_prepare_breeze_timing_when_unavailable(self, 
durations_module):
+        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}
+            ],
+            recent_runs=[{"run_number": 102, "html_url": "https://example/2";, 
"duration": 2700}],
+            rel_threshold=0.25,
+            channel="internal-airflow-ci-cd",
+        )
+        assert "Prepare breeze" not in json.dumps(msg)

Reply via email to