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

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 82456dd938a Report failing fork job statuses only when the failure 
persists (#76185)
82456dd938a is described below

commit 82456dd938ad2042f09a4c4c10d1294da84f7c5c
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Nov 3 21:49:55 2026 +0100

    Report failing fork job statuses only when the failure persists (#76185)
    
    The fork CI monitor posted a Slack item each time reading a fork run's
    jobs hit a transient GitHub server error (HTTP 502, a reset HTTP/2
    stream). The item carried the error text, so a changed error re-posted
    it, and one successful run in between re-posted it too.
    
    Count the consecutive runs whose job statuses failed per pull request,
    report only after three in a row, and use fixed wording so the item is
    posted once while the failure persists. The run log keeps each error.
    
    Generated-by: Claude Opus 5
---
 .../doc/ci/08_fork_ci_for_external_contributors.md | 11 ++--
 .../src/airflow_breeze/utils/fork_ci_reconcile.py  | 47 ++++++++++++++++-
 dev/breeze/tests/test_fork_ci_reconcile.py         | 60 +++++++++++++++++++++-
 3 files changed, 111 insertions(+), 7 deletions(-)

diff --git a/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md 
b/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md
index 5378363b6ef..f1007448d61 100644
--- a/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md
+++ b/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md
@@ -861,9 +861,14 @@ suites); its jobs come from `GET 
/repos/<fork>/actions/runs/<id>/jobs?filter=lat
   `jobs: "<run id>:<attempt>:<conclusion>"` (`runAttempt` is fetched with the 
check suites), so
   later runs do not read its jobs again. A run in progress is read on every 
run, and a re-run in
   the fork is read again because its attempt changes, even when it ends with 
the same conclusion.
-- Reporting job statuses is caught on its own: when it fails, the failure is 
listed with the
-  unknown items for Slack, and the pull request is still classified, so for 
example a `CI passed`
-  that no longer holds is still revoked.
+- Reporting job statuses is caught on its own: when it fails, the pull request 
is still
+  classified, so for example a `CI passed` that no longer holds is still 
revoked. Reading a fork
+  run's jobs fails now and then on GitHub server errors (HTTP 502, a reset 
HTTP/2 stream) that the
+  next run gets past, so each failure is only logged, and the pull request is 
listed with the
+  unknown items for Slack once its job statuses failed in three runs in a row. 
The per pull request
+  count is kept in the same record as that of deferred job statuses; without a 
readable record it
+  starts again. The item's wording is fixed whatever the error, so it is 
posted once while the
+  failure persists, and the run log has each run's error.
 
 The job statuses are informational. Merging still depends on `CI passed`, and 
the state machine
 on `Fork CI / Tests (AMD)`; a job status never changes either.
diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py 
b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
index d15d3a8da37..c9a6865cd96 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
@@ -185,6 +185,16 @@ DEFERRED_RUNS_BEFORE_ALERT = 3
 # GitHub answering with rate limits in this many consecutive runs is reported: 
a single burst is
 # retried, while a persisting one means the repository's shared limits are 
close to exhausted.
 RATE_LIMITED_RUNS_BEFORE_ALERT = 2
+# Reading a fork run's jobs fails now and then on GitHub server errors (HTTP 
502, a reset HTTP/2
+# stream) that the next run gets past, so a pull request whose job statuses 
failed is only reported
+# when it failed in this many consecutive runs.
+JOB_STATUS_FAILURE_RUNS_BEFORE_ALERT = 3
+# Fixed text, so the item is posted to Slack once while the failure persists, 
whatever the error;
+# the run log has each run's error.
+JOB_STATUSES_FAILED_REASON = (
+    f"reporting job statuses failed in {JOB_STATUS_FAILURE_RUNS_BEFORE_ALERT} 
runs in a row; "
+    "the run log has the errors"
+)
 # A pull request whose fork run finished this long ago with job statuses still 
not all posted is
 # reported by itself: deferring is expected to catch up within a few runs, not 
to leave one behind.
 MIRROR_DELAY_BEFORE_ALERT = timedelta(hours=1)
@@ -2100,6 +2110,7 @@ def drop_reported(
     record: bool = True,
     deferred_runs: int = 0,
     rate_limited_runs: int = 0,
+    job_status_failure_runs: dict[int, int] | None = None,
 ) -> tuple[list[tuple[int, str]], list[tuple[int, str]], list[str]]:
     """
     Leave out the items the previous run already reported, and record this 
run's items.
@@ -2109,6 +2120,8 @@ def drop_reported(
 
     :param deferred_runs: the consecutive runs that deferred job statuses, 
recorded for the next run.
     :param rate_limited_runs: the consecutive runs GitHub rate-limited, 
recorded for the next run.
+    :param job_status_failure_runs: per pull request, the consecutive runs 
whose job statuses failed,
+        recorded for the next run.
     """
     current: dict[str, Any] = {
         "unknown": [list(item) for item in unknown],
@@ -2116,6 +2129,9 @@ def drop_reported(
         "notes": notes,
         "deferred_runs": deferred_runs,
         "rate_limited_runs": rate_limited_runs,
+        "job_status_failure_runs": {
+            str(number): runs for number, runs in (job_status_failure_runs or 
{}).items()
+        },
     }
     try:
         previous = json.loads(reported_file.read_text())
@@ -2148,6 +2164,24 @@ def read_run_count(reported_file: Path | None, key: str) 
-> int | None:
     return count if isinstance(count, int) and not isinstance(count, bool) 
else 0
 
 
+def read_job_status_failure_runs(reported_file: Path | None) -> dict[int, int]:
+    """Per pull request, the consecutive runs whose job statuses failed, as 
the previous run recorded."""
+    if reported_file is None:
+        return {}
+    try:
+        previous = json.loads(reported_file.read_text())
+    except (OSError, ValueError):
+        return {}
+    counts = previous.get("job_status_failure_runs") if isinstance(previous, 
dict) else None
+    if not isinstance(counts, dict):
+        return {}
+    return {
+        int(number): runs
+        for number, runs in counts.items()
+        if number.isdigit() and isinstance(runs, int) and not isinstance(runs, 
bool)
+    }
+
+
 def log_rate_limits(client: GitHubClient) -> None:
     """Log what is left of the token's REST and GraphQL quotas; reading them 
does not count against them."""
     try:
@@ -2243,6 +2277,8 @@ def run_reconcile(
         client, repo=repo, budget=max(MAX_JOB_STATUSES_PER_RUN - 
ci_run_started_writes, 0), now=now
     )
 
+    previous_job_status_failure_runs = 
read_job_status_failure_runs(reported_file)
+    job_status_failure_runs: dict[int, int] = {}
     shared_heads = find_heads_shared_across_bases(prs)
     gate_change_finder = GateChangeFinder(client, repo=repo)
     for listed in prs:
@@ -2262,8 +2298,14 @@ def run_reconcile(
                 try:
                     jobs_reported = job_reporter.report(pr, fork)
                 except Exception as e:
-                    console_print(f"[warning]#{pr.number}: reporting job 
statuses failed: {e}[/]")
-                    unknown.append((pr.number, f"reporting job statuses 
failed: {type(e).__name__}: {e}"))
+                    failure_runs = 
previous_job_status_failure_runs.get(pr.number, 0) + 1
+                    job_status_failure_runs[pr.number] = failure_runs
+                    console_print(
+                        f"[warning]#{pr.number}: reporting job statuses failed 
({failure_runs} runs in a "
+                        f"row): {type(e).__name__}: {e}[/]"
+                    )
+                    if failure_runs >= JOB_STATUS_FAILURE_RUNS_BEFORE_ALERT:
+                        unknown.append((pr.number, JOB_STATUSES_FAILED_REASON))
 
             base_config = configs.get(pr.base_ref)
             if base_config is not None:
@@ -2338,6 +2380,7 @@ def run_reconcile(
             record=not client.dry_run,
             deferred_runs=deferred_runs,
             rate_limited_runs=rate_limited_runs,
+            job_status_failure_runs=job_status_failure_runs,
         )
     has_alerts = bool(unknown or attention or notes)
     if has_alerts:
diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py 
b/dev/breeze/tests/test_fork_ci_reconcile.py
index 330157e83e0..81962145a43 100644
--- a/dev/breeze/tests/test_fork_ci_reconcile.py
+++ b/dev/breeze/tests/test_fork_ci_reconcile.py
@@ -42,6 +42,7 @@ from airflow_breeze.utils.fork_ci_messages import (
 )
 from airflow_breeze.utils.fork_ci_reconcile import (
     JOB_STATUSES_DEFERRED_NOTE,
+    JOB_STATUSES_FAILED_REASON,
     RATE_LIMITED_NOTE,
     BaseGateConfigs,
     BypassFinder,
@@ -78,6 +79,7 @@ from airflow_breeze.utils.fork_ci_reconcile import (
     pull_request_changes_ci_files,
     read_ci_run_at,
     read_head_status,
+    read_job_status_failure_runs,
     read_project_ci_run_url,
     report_project_ci_runs_started,
     run_reconcile,
@@ -2252,6 +2254,7 @@ def 
test_drop_reported_reports_everything_without_a_readable_previous_run(tmp_pa
         "notes": [],
         "deferred_runs": 0,
         "rate_limited_runs": 0,
+        "job_status_failure_runs": {},
     }
 
 
@@ -2646,10 +2649,63 @@ def 
test_run_reconcile_classifies_when_reporting_job_statuses_fails(
     mock_reporter.return_value.deferred = []
     mock_reporter.return_value.delayed = []
 
-    assert _reconcile_with_reporter(client, tmp_path) is True
+    _reconcile_with_reporter(client, tmp_path)
 
     assert mock_classify.call_args.kwargs["jobs_reported"] is None
-    assert "reporting job statuses failed" in (tmp_path / "s.json").read_text()
+
+
[email protected]("airflow_breeze.utils.fork_ci_reconcile.JobStatusReporter", 
autospec=True)
[email protected]("airflow_breeze.utils.fork_ci_reconcile.classify", autospec=True)
+def test_run_reconcile_reports_failing_job_statuses_once_when_failing_persists(
+    mock_classify, mock_reporter, client, tmp_path
+):
+    mock_classify.return_value = Plan(state=ForkState.RUNNING)
+    mock_reporter.return_value.deferred = []
+    mock_reporter.return_value.delayed = []
+    client.dry_run = False
+    reported_file = tmp_path / "reported.json"
+    reported_file.write_text("{}")
+    alerts = []
+    errors = [
+        GitHubError("HTTP 502"),
+        GitHubError("HTTP 502"),
+        None,
+        GitHubError("HTTP 502"),
+        GitHubError("stream ID 1; CANCEL"),
+        GitHubError("HTTP 502"),
+        GitHubError("stream ID 1; CANCEL"),
+    ]
+    for error in errors:
+        mock_reporter.return_value.report.side_effect = error
+        mock_reporter.return_value.report.return_value = None
+        alerts.append(_reconcile_with_reporter(client, tmp_path, 
reported_file=reported_file))
+
+    assert alerts == [False, False, False, False, False, True, False]
+    assert JOB_STATUSES_FAILED_REASON in (tmp_path / "s.json").read_text()
+    assert json.loads(reported_file.read_text())["job_status_failure_runs"] == 
{"1": 4}
+
+
[email protected](
+    ("content", "expected"),
+    [
+        pytest.param(None, {}, id="missing"),
+        pytest.param("not json", {}, id="not-json"),
+        pytest.param("[]", {}, id="not-a-dict"),
+        pytest.param('{"deferred_runs": 1}', {}, 
id="recorded-before-the-count"),
+        pytest.param('{"job_status_failure_runs": []}', {}, 
id="malformed-counts"),
+        pytest.param(
+            '{"job_status_failure_runs": {"1": 2, "x": 3, "4": true, "5": 
"6"}}',
+            {1: 2},
+            id="malformed-entries",
+        ),
+    ],
+)
+def test_read_job_status_failure_runs(tmp_path, content, expected):
+    reported_file = tmp_path / "reported.json"
+    if content is not None:
+        reported_file.write_text(content)
+
+    assert read_job_status_failure_runs(reported_file) == expected
 
 
 @mock.patch("airflow_breeze.utils.fork_ci_reconcile.JobStatusReporter", 
autospec=True)

Reply via email to