This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 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 92de22825e6 [v3-3-test] Stop CI duration alerts firing on one-off
image rebuilds (#71548) (#72914)
92de22825e6 is described below
commit 92de22825e6ffc588427c8db6b7c67f709090c23
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Sep 11 11:35:00 2026 +0530
[v3-3-test] Stop CI duration alerts firing on one-off image rebuilds
(#71548) (#72914)
* Stop CI duration alerts firing on one-off image rebuilds
The trend excluded a single step, "Prepare breeze & CI image", from a job's
wall clock. The image-cache push jobs do not have that step - they build and
push the image in a step of their own - so when a change to an early image
layer invalidates every layer after it, the resulting full rebuild and full
re-push is counted as work time and reported as a regression. The same
CI-only prefix also missed every job that prepares a PROD image.
Image build time is meant to be watched by the persistence check instead,
which only reports a slowdown that lasted for days rather than a single
night, so the exclusion has to cover the steps that carry it.
* Track one image-work figure instead of two overlapping ones
The prepare-breeze step is one of the image steps, so carrying its duration
alongside the wider image-work total meant two fields, two extraction
functions and fixtures that had to keep both in step - while the persistence
check still watched only the narrower of the two, so a slow build in a
cache-push job never reached it.
(cherry picked from commit 438f2bda03b5d7f4b1ac7245dc86c54711fd82a9)
Co-authored-by: Jarek Potiuk <[email protected]>
---
scripts/ci/analyze_ci_job_durations.py | 78 +++++++-----
scripts/tests/ci/test_analyze_ci_job_durations.py | 143 +++++++++++++++++-----
2 files changed, 158 insertions(+), 63 deletions(-)
diff --git a/scripts/ci/analyze_ci_job_durations.py
b/scripts/ci/analyze_ci_job_durations.py
index 56f3c463333..202dfb449e1 100644
--- a/scripts/ci/analyze_ci_job_durations.py
+++ b/scripts/ci/analyze_ci_job_durations.py
@@ -35,10 +35,11 @@ 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.
-The image-build step ("Prepare breeze & CI image") occasionally balloons on a
-one-off cache miss, so its time is *excluded* from the run and per-job
durations
-used for the trend above. The image build is instead watched on its own and
only
-reported when it has stayed slow for longer than
``IMAGE_BUILD_PERSISTENCE_DAYS``
+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
+durations used for the trend above. That same image time is instead watched on
its own
+and only reported when it has stayed slow for longer than
``IMAGE_BUILD_PERSISTENCE_DAYS``
(so a single slow night never alerts).
Environment variables (required):
@@ -77,12 +78,23 @@ from pathlib import Path
from typing import TypedDict
ISO_SUFFIX_Z = "Z"
-PREPARE_BREEZE_STEP_PREFIX = "Prepare breeze & CI image"
+# Steps that build, pull or push a Docker image rather than doing test/work. A
change to
+# an early image layer (an apt package, a Dockerfile line) invalidates every
layer after
+# it, so on the first run after such a merge these steps take multiples of
their usual
+# time - a one-off that must not read as a work-time regression. Their names
come from
+# .github/workflows/push-image-cache.yml and
.github/actions/prepare_breeze_and_image.
+IMAGE_WORK_STEP_PREFIXES = (
+ "Prepare breeze & CI image",
+ "Prepare breeze & PROD image",
+ "Push CI ",
+ "Push PROD ",
+)
+IMAGE_WORK_LABEL = "Image build, pull & push"
class JobDuration(TypedDict):
duration: float
- prepare_breeze_duration: float | None
+ image_work_duration: float | None
def env_float(name: str, default: float) -> float:
@@ -249,39 +261,45 @@ def get_recent_runs(
return runs
-def get_prepare_breeze_step_duration(job: dict) -> float | None:
- """Return the prepare breeze step duration for a job, when the step
exists."""
+def get_image_work_seconds(job: dict) -> float | None:
+ """Return the total time a job spent building, pulling or pushing images.
+
+ Summed rather than first-match: the cache-push jobs both build an image
and push
+ it in separate steps, and a test job can prepare a CI and a PROD image.
+ """
+ total: float | None = None
for step in job.get("steps", []):
- name = step.get("name", "")
- if not name.startswith(PREPARE_BREEZE_STEP_PREFIX):
+ if not step.get("name", "").startswith(IMAGE_WORK_STEP_PREFIXES):
continue
- return duration_seconds(step.get("startedAt"), step.get("completedAt"))
- return None
+ seconds = duration_seconds(step.get("startedAt"),
step.get("completedAt"))
+ if seconds is not None:
+ total = seconds if total is None else total + seconds
+ return total
def calculate_work_duration(job_data: JobDuration) -> float:
- """Return a job's wall-clock with the image-build (prepare breeze) step
removed.
+ """Return a job's wall-clock with the image build/pull/push steps removed.
- The image build occasionally balloons — a cache miss forces a full rebuild
- (minutes → tens of minutes) — which would otherwise inflate the job's total
- and flag an unrelated job as "slower". The duration trend should track the
- actual test/work time, so image build is discounted from it and watched
- separately by :func:`detect_image_build_regression`.
+ Image work occasionally balloons — an invalidated early layer forces a
full rebuild
+ and a full re-push (minutes → tens of minutes) — which would otherwise
inflate the
+ job's total and flag a job that did not actually get slower. The duration
trend
+ should track the actual test/work time, so image work is discounted from
it and
+ watched separately by :func:`detect_image_build_regression`.
"""
- prepare_breeze = job_data["prepare_breeze_duration"] or 0.0
- return max(job_data["duration"] - prepare_breeze, 0.0)
+ image_work = job_data["image_work_duration"] or 0.0
+ return max(job_data["duration"] - image_work, 0.0)
def calculate_image_build_seconds(jobs: dict[str, JobDuration]) -> float |
None:
- """Return a representative image-build duration for a run.
+ """Return a representative image-work duration for a run.
- The same CI image is prepared by every job, so the median prepare-breeze
- duration across the run's jobs is a robust single figure for that run
- (ignoring jobs where the step is absent). None when no job recorded it.
+ Nearly every job prepares the same image, so the median image-work duration
+ across the run's jobs is a robust single figure for that run (ignoring jobs
+ that did no image work). The handful of cache-push jobs, which build and
push
+ rather than pull, sit far above that median and so do not move it. None
when
+ no job recorded any image work.
"""
- values = [
- job["prepare_breeze_duration"] for job in jobs.values() if
job["prepare_breeze_duration"] is not None
- ]
+ values = [job["image_work_duration"] for job in jobs.values() if
job["image_work_duration"] is not None]
if not values:
return None
return median(values)
@@ -320,7 +338,7 @@ def get_run_jobs(repo: str, run_id: int) -> dict[str,
JobDuration]:
if existing is None or seconds > existing["duration"]:
durations[name] = {
"duration": seconds,
- "prepare_breeze_duration":
get_prepare_breeze_step_duration(job),
+ "image_work_duration": get_image_work_seconds(job),
}
return durations
@@ -504,7 +522,7 @@ def format_slack_message(
f"🐳 *CI image build slow for
{image_build_regression['span_days']:.1f} days* "
f"(across {image_build_regression['elevated_runs']}
runs) — not a one-off "
f"cache miss:\n"
- f"• {PREPARE_BREEZE_STEP_PREFIX}: "
+ f"• {IMAGE_WORK_LABEL}: "
f"{format_duration(image_build_regression['baseline'])} → "
f"*{format_duration(image_build_regression['latest'])}* "
f"(+{round(image_build_regression['rel_increase'] *
100, 1)}%)"
@@ -617,7 +635,7 @@ def write_step_summary(
lines += [
f"### 🐳 CI image build slow for
{image_build_regression['span_days']:.1f} days",
"",
- f"- {PREPARE_BREEZE_STEP_PREFIX}: "
+ f"- {IMAGE_WORK_LABEL}: "
f"**{format_duration(image_build_regression['latest'])}** "
f"(baseline {format_duration(image_build_regression['baseline'])},
"
f"+{round(image_build_regression['rel_increase'] * 100, 1)}%) "
diff --git a/scripts/tests/ci/test_analyze_ci_job_durations.py
b/scripts/tests/ci/test_analyze_ci_job_durations.py
index efb930539b3..be5b3cbc2e1 100644
--- a/scripts/tests/ci/test_analyze_ci_job_durations.py
+++ b/scripts/tests/ci/test_analyze_ci_job_durations.py
@@ -266,9 +266,9 @@ 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": {"duration": 20 * 60,
"prepare_breeze_duration": 5 * 60}}
+ assert jobs == {"Tests": {"duration": 20 * 60, "image_work_duration":
5 * 60}}
- def test_omits_prepare_breeze_duration_when_step_missing(self,
durations_module):
+ def test_omits_image_work_duration_when_no_image_step(self,
durations_module):
payload = json.dumps(
{
"jobs": [
@@ -285,7 +285,7 @@ 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": {"duration": 20 * 60,
"prepare_breeze_duration": None}}
+ assert jobs == {"Tests": {"duration": 20 * 60, "image_work_duration":
None}}
def test_keeps_longest_duplicate_job_name(self, durations_module):
payload = json.dumps(
@@ -317,7 +317,7 @@ 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": {"duration": 20 * 60,
"prepare_breeze_duration": 5 * 60}}
+ assert jobs == {"Tests": {"duration": 20 * 60, "image_work_duration":
5 * 60}}
def test_empty_on_command_failure(self, durations_module):
completed = subprocess.CompletedProcess(args=[], returncode=1,
stdout="", stderr="boom")
@@ -325,43 +325,82 @@ class TestGetRunJobs:
assert durations_module.get_run_jobs("apache/airflow", 2) == {}
-class TestWorkDuration:
- def test_subtracts_image_build(self, durations_module):
- assert (
- durations_module.calculate_work_duration({"duration": 1200,
"prepare_breeze_duration": 300})
- == 900
+class TestGetImageWorkSeconds:
+ def _job(self, *steps):
+ return {"steps": [{"name": n, "startedAt": s, "completedAt": c} for n,
s, c in steps]}
+
+ def test_sums_every_image_step_of_a_cache_push_job(self, durations_module):
+ # The real shape of "Push CI Regular AMD:3.12 image cache": the
build+push step and
+ # the cache push step both belong to the image, and neither is a
"Prepare breeze" step.
+ job = self._job(
+ ("Install Breeze", "2026-08-13T04:47:45Z", "2026-08-13T04:48:05Z"),
+ (
+ "Push CI latest images: 3.12 (linux/amd64 only)",
+ "2026-08-13T04:48:05Z",
+ "2026-08-13T05:17:43Z",
+ ),
+ ("Push CI Regular AMD cache:3.12:linux/amd64",
"2026-08-13T05:17:43Z", "2026-08-13T05:17:56Z"),
)
+ assert durations_module.get_image_work_seconds(job) == 29 * 60 + 38 +
13
+
+ def test_covers_prod_prepare_and_push_steps(self, durations_module):
+ job = self._job(
+ ("Prepare breeze & PROD image: 3.10", "2026-08-13T04:00:00Z",
"2026-08-13T04:05:00Z"),
+ (
+ "Push PROD latest image: 3.10 (linux/amd64 ONLY)",
+ "2026-08-13T04:05:00Z",
+ "2026-08-13T04:15:00Z",
+ ),
+ )
+ assert durations_module.get_image_work_seconds(job) == 15 * 60
- def test_full_duration_when_no_image_build_step(self, durations_module):
- assert (
- durations_module.calculate_work_duration({"duration": 1200,
"prepare_breeze_duration": None})
- == 1200
+ def test_none_when_the_job_touches_no_image(self, durations_module):
+ job = self._job(("Run unit tests", "2026-08-13T04:00:00Z",
"2026-08-13T04:30:00Z"))
+ assert durations_module.get_image_work_seconds(job) is None
+
+ def test_ignores_steps_with_unusable_timestamps(self, durations_module):
+ job = self._job(
+ ("Prepare breeze & CI image: 3.10", "2026-08-13T04:00:00Z",
"2026-08-13T04:05:00Z"),
+ ("Push CI latest images: 3.10 (linux/amd64 only)", "", ""),
)
+ assert durations_module.get_image_work_seconds(job) == 5 * 60
- def test_never_negative(self, durations_module):
+
+class TestWorkDuration:
+ def test_subtracts_image_work(self, durations_module):
+ assert durations_module.calculate_work_duration({"duration": 1200,
"image_work_duration": 300}) == 900
+
+ def test_subtracts_image_work_beyond_the_prepare_step(self,
durations_module):
+ # A cache-push job: nothing was "prepared", but 25 of its 26 minutes
were image work.
+ assert durations_module.calculate_work_duration({"duration": 1560,
"image_work_duration": 1500}) == 60
+
+ def test_full_duration_when_no_image_step(self, durations_module):
assert (
- durations_module.calculate_work_duration({"duration": 100,
"prepare_breeze_duration": 300}) == 0
+ durations_module.calculate_work_duration({"duration": 1200,
"image_work_duration": None}) == 1200
)
+ def test_never_negative(self, durations_module):
+ assert durations_module.calculate_work_duration({"duration": 100,
"image_work_duration": 300}) == 0
+
class TestRunImageBuildSeconds:
def test_median_across_jobs(self, durations_module):
jobs = {
- "a": {"duration": 0, "prepare_breeze_duration": 300},
- "b": {"duration": 0, "prepare_breeze_duration": 500},
- "c": {"duration": 0, "prepare_breeze_duration": 400},
+ "a": {"duration": 0, "image_work_duration": 300},
+ "b": {"duration": 0, "image_work_duration": 500},
+ "c": {"duration": 0, "image_work_duration": 400},
}
assert durations_module.calculate_image_build_seconds(jobs) == 400
- def test_ignores_jobs_without_the_step(self, durations_module):
+ def test_ignores_jobs_that_did_no_image_work(self, durations_module):
jobs = {
- "a": {"duration": 0, "prepare_breeze_duration": None},
- "b": {"duration": 0, "prepare_breeze_duration": 500},
+ "a": {"duration": 0, "image_work_duration": None},
+ "b": {"duration": 0, "image_work_duration": 500},
}
assert durations_module.calculate_image_build_seconds(jobs) == 500
- def test_none_when_no_job_recorded_the_step(self, durations_module):
- jobs = {"a": {"duration": 0, "prepare_breeze_duration": None}}
+ def test_none_when_no_job_did_image_work(self, durations_module):
+ jobs = {"a": {"duration": 0, "image_work_duration": None}}
assert durations_module.calculate_image_build_seconds(jobs) is None
@@ -372,15 +411,15 @@ class TestAnalyzeJobs:
# slow-job work time (image build excluded): latest 2400 vs baseline
1500 -> +60%.
jobs_by_run_id = {
100: {
- "slow-job": {"duration": 2700, "prepare_breeze_duration": 300},
- "stable-job": {"duration": 600, "prepare_breeze_duration":
None},
- "new-job": {"duration": 999, "prepare_breeze_duration": 300},
+ "slow-job": {"duration": 2700, "image_work_duration": 300},
+ "stable-job": {"duration": 600, "image_work_duration": None},
+ "new-job": {"duration": 999, "image_work_duration": 300},
}
}
for i in range(5):
jobs_by_run_id[i] = {
- "slow-job": {"duration": 1800, "prepare_breeze_duration": 300},
- "stable-job": {"duration": 590, "prepare_breeze_duration":
None},
+ "slow-job": {"duration": 1800, "image_work_duration": 300},
+ "stable-job": {"duration": 590, "image_work_duration": None},
}
regressions = durations_module.analyze_jobs(
@@ -394,17 +433,17 @@ 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"]
- # Job regressions no longer carry image-build detail; that is reported
separately.
- assert "prepare_breeze" not in regressions[0]
+ # Job regressions no longer carry image-work detail; that is reported
separately.
+ assert "image_work" not in regressions[0]
def test_image_build_spike_alone_does_not_flag_a_job(self,
durations_module):
"""A job whose total ballooned only because the image build spiked is
not flagged."""
latest_runs = [{"id": 100}]
baseline_runs = [{"id": i} for i in range(5)]
# latest total 1500 = +150% vs baseline 600, but work time (300) is
unchanged.
- jobs_by_run_id = {100: {"job": {"duration": 1500,
"prepare_breeze_duration": 1200}}}
+ jobs_by_run_id = {100: {"job": {"duration": 1500,
"image_work_duration": 1200}}}
for i in range(5):
- jobs_by_run_id[i] = {"job": {"duration": 600,
"prepare_breeze_duration": 300}}
+ jobs_by_run_id[i] = {"job": {"duration": 600,
"image_work_duration": 300}}
regressions = durations_module.analyze_jobs(
jobs_by_run_id,
@@ -416,6 +455,41 @@ class TestAnalyzeJobs:
)
assert regressions == []
+ def
test_cache_push_job_is_not_flagged_when_only_the_image_rebuild_grew(self,
durations_module):
+ """The image-cache push jobs have no prepare-breeze step - their
build+push is the image.
+
+ Modelled on the 2026-08-13 canary: an apt package added to an early
image layer
+ invalidated every layer after it, so the build+push step went 11m ->
30m while the
+ job did no more work than usual.
+ """
+ latest_runs = [{"id": 100}]
+ baseline_runs = [{"id": i} for i in range(5)]
+ jobs_by_run_id = {
+ 100: {
+ "Push CI Regular AMD:3.12 image cache": {
+ "duration": 1860,
+ "image_work_duration": 1791,
+ }
+ }
+ }
+ for i in range(5):
+ jobs_by_run_id[i] = {
+ "Push CI Regular AMD:3.12 image cache": {
+ "duration": 690,
+ "image_work_duration": 621,
+ }
+ }
+
+ regressions = durations_module.analyze_jobs(
+ jobs_by_run_id,
+ latest_runs,
+ baseline_runs,
+ min_baseline_runs=5,
+ rel_threshold=0.25,
+ min_abs_increase_seconds=360,
+ )
+ assert regressions == []
+
class TestDetectImageBuildRegression:
@staticmethod
@@ -426,7 +500,10 @@ class TestDetectImageBuildRegression:
for run_id, created_at, image_seconds in specs:
runs.append({"id": run_id, "created_at": created_at})
jobs_by_run_id[run_id] = {
- "job": {"duration": image_seconds, "prepare_breeze_duration":
image_seconds}
+ "job": {
+ "duration": image_seconds,
+ "image_work_duration": image_seconds,
+ }
}
return runs, jobs_by_run_id