This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new 634ba4700d9 [v3-4-test] Spend fewer GitHub writes on fork CI job
statuses and watch the limits (#76154) (#76158)
634ba4700d9 is described below
commit 634ba4700d96771803cef9a3853a1e9978c198cd
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sat Oct 31 18:14:02 2026 +0100
[v3-4-test] Spend fewer GitHub writes on fork CI job statuses and watch the
limits (#76154) (#76158)
* Spend fewer GitHub writes on fork CI job statuses and watch the limits
The fork CI monitor's per-job commit statuses are its largest use of
the GITHUB_TOKEN limits that every workflow of the repository shares
(500 content-creating requests an hour, 80 a minute). During the pilot
they already fill the monitor's per-run budget whenever a couple of
full-matrix fork runs are active, which would leave little headroom for
other workflows once fork CI is enabled for every contributor.
A "running" status for each job doubled the writes while saying little:
the monitor only runs every 15 minutes, so it lagged behind the fork's
own run page, and the aggregate Fork CI status already shows the run as
running and links it.
Until now the only sign of hitting the limits was failures, so each run
now logs the quota left and how many calls GitHub rate-limited, and
Slack hears about rate limiting that persists.
Generated-by: Claude Opus 5
* Report pull requests whose fork job statuses lag far behind
(cherry picked from commit 468ab0a5834c8ee676f99e0be8bd90b440423862)
Generated-by: Claude Opus 5
Co-authored-by: Jarek Potiuk <[email protected]>
---
dev/AGENTS.md | 7 +-
.../doc/ci/08_fork_ci_for_external_contributors.md | 58 +++++----
.../src/airflow_breeze/utils/fork_ci_github.py | 3 +
.../src/airflow_breeze/utils/fork_ci_messages.py | 7 +-
.../src/airflow_breeze/utils/fork_ci_reconcile.py | 105 +++++++++++++---
dev/breeze/tests/test_fork_ci_github.py | 6 +-
dev/breeze/tests/test_fork_ci_reconcile.py | 133 +++++++++++++++++++--
7 files changed, 259 insertions(+), 60 deletions(-)
diff --git a/dev/AGENTS.md b/dev/AGENTS.md
index 24267879fdb..f9e999acfc4 100644
--- a/dev/AGENTS.md
+++ b/dev/AGENTS.md
@@ -55,8 +55,9 @@ workflow of `apache/airflow` shares (see "Write budget" in
If the budget is exceeded, turn the job statuses off: set `job_statuses =
false` in
`.github/fork-ci-gate.toml`. Signs that it is exceeded are the monitor's Slack
note that job
statuses "have not fit in the budget" (posted only after three runs in a row
deferred them)
-staying for hours, rate-limit retries ("GitHub rate limit,
-retrying") or "still rate limited" errors in the monitor's run log or its
Slack messages, or other
-workflows of the repository failing on secondary rate limits. The aggregate
`Fork CI / Tests (AMD)`
+staying for hours, pull requests listed because their fork run finished more
than an hour ago with
+job statuses still not posted, its Slack note that GitHub rate-limited it in
two runs in a row, the
+"Rate limits left" line of its run log approaching zero, or other workflows of
the repository
+failing on secondary rate limits. The aggregate `Fork CI / Tests (AMD)`
status and `CI passed` are posted either way, so turning job statuses off
loses detail, not gating.
Lowering `MAX_JOB_STATUSES_PER_RUN` in `fork_ci_reconcile.py` is the softer
alternative.
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 51b595aa268..181180b5516 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
@@ -805,15 +805,18 @@ suites); its jobs come from `GET
/repos/<fork>/actions/runs/<id>/jobs?filter=lat
the prefix is what keeps a job from posting `Fork CI / Tests (AMD)`, from
which the reconciler
reads its decisions back, or `CI passed`, which the merge ruleset requires.
Jobs that share a
context, which GitHub compares case-insensitively, are reported as the worst
of them.
-- A queued or running job is `pending` ("Running in the fork"), a successful
one `success`, and a
- failed, timed-out, cancelled or unstartable one `failure`. Skipped and
neutral jobs, and the jobs
- every run has (`Fork CI gate`, `Check if the run is needed`, `Platform: AMD`
and `CI passed`), are
- not reported. Queued and running share one status so that starting a job
costs no write.
+- Only finished jobs are reported: a successful one as `success`, and a
failed, timed-out,
+ cancelled or unstartable one as `failure`. Queued and running jobs are not,
so each job costs one
+ write instead of two; `Fork CI / Tests (AMD)` shows the run as running,
linking it, and with a
+ 15-minute cadence a per-job "running" status was behind the fork's own run
page anyway. A job of
+ a re-run in the fork keeps the result of the earlier attempt until it
finishes. Skipped and
+ neutral jobs, and the jobs every run has (`Fork CI gate`, `Check if the run
is needed`,
+ `Platform: AMD` and `CI passed`), are not reported.
- The desired statuses are compared with the head commit's current ones
(`GET /repos/apache/airflow/commits/<sha>/status`), and only differences are
posted, failures
first. GraphQL has no mutation for commit statuses (`createCheckRun` exists,
but see above for
why check runs do not fit), so each one is a REST call, and they count
towards GitHub's limits on
- content-creating requests: a reconciler run posts at most
`MAX_JOB_STATUSES_PER_RUN` (60) of
+ content-creating requests: a reconciler run posts at most
`MAX_JOB_STATUSES_PER_RUN` (40) of
them, each write charged as it is made. What does not fit is posted by the
next run, and once
the budget is spent no further pull request's jobs or statuses are read in
that run. One fork run
of the full matrix (about 130 jobs) needs more than a run's budget on its
own, which the next
@@ -821,7 +824,11 @@ suites); its jobs come from `GET
/repos/<fork>/actions/runs/<id>/jobs?filter=lat
requests, and only when three runs in a row deferred, about 45 minutes of
the budget not
keeping up, is a note added to the Slack message. The count is kept in the
record the monitor
carries between runs (`--reported-file`); without a readable record the note
is added at once.
- Its wording is fixed, so it is posted once while deferrals persist.
+ Its wording is fixed, so it is posted once while deferrals persist. A pull
request whose fork run
+ finished more than an hour ago (the `updatedAt` of its completed check
suite) and still has
+ statuses waiting is listed on its own as an attention item, so a delay that
a busy backlog keeps
+ pushing back shows which pull request it holds up; the item names the run,
so it is posted once
+ for that run.
- Commit statuses cannot be deleted and have no neutral state. When a run has
completed, a job
context posted for an earlier run of the same commit that the completed run
does not have, or
skipped, is overwritten with `success` and the description "Not in the
latest fork run - see
@@ -839,9 +846,14 @@ on `Fork CI / Tests (AMD)`; a job status never changes
either.
`job_statuses = false` in `.github/fork-ci-gate.toml` turns them off; the
reconciler then posts
only `Fork CI / Tests (AMD)` and `CI passed`, as before. Statuses already
posted stay on their
-commits. Turn them off when the fork CI monitor's runs hit GitHub's rate
limits (rate-limit
-retries in the run log, or "still rate limited" errors in Slack);
`dev/AGENTS.md` records this for
-agents.
+commits. Turn them off when the fork CI monitor's runs hit GitHub's rate
limits; `dev/AGENTS.md`
+records this for agents.
+
+Each run logs what is left of the token's REST and GraphQL quotas (`GET
/rate_limit`, which does
+not count against them) and how many of its calls GitHub rate-limited; the
client retries those with
+backoff. Rate limiting in two runs in a row adds a note to the Slack message,
with fixed wording so
+it is posted once while it persists; the count is kept in the same record as
that of deferred job
+statuses.
#### Write budget
@@ -857,25 +869,25 @@ jobs whose conclusion is not `skipped`, minus the four
jobs that are not reporte
| CI change (full tests) |
[40207881565](https://github.com/apache/airflow/actions/runs/40207881565) | 131
| 127 |
`apache/airflow` had 1898 `Tests (AMD)` pull request runs from 2026-10-18 to
2026-10-24, about 270 a
-day or 11 an hour. Each reported job costs at most two writes (running, then
its result) and one
-when it starts and finishes between two reconciler runs; a fork run of 30-60
minutes spans two to
-four of them, so most jobs cost two.
+day or 11 an hour. Each reported job costs one write, its result.
The limits that matter are GitHub's documented ones for the `GITHUB_TOKEN` of
`apache/airflow`,
shared by every workflow of the repository: 1000 REST requests an hour, and 80
content-creating
requests a minute and 500 an hour. Assuming about 35 reported jobs for an
average push:
-- **Contributor pilot**, a few gated pushes a day: about 70 writes a push,
under 20 an hour, under
- 5% of the content-creating limit.
+- **Contributor pilot**, a few gated pushes a day: about 35 writes a push,
under 10 an hour, under
+ 2% of the content-creating limit. A single full-matrix run (about 127 jobs)
still spans a few
+ monitor runs.
- **Gate enabled**, assuming about half of the pull request runs come from
gated pull requests
- (about 5-6 pushes an hour): about 400 writes an hour without a cap, 80% of
the content-creating
- limit and more at peaks. The cap of 60 writes a run, four runs an hour,
holds job statuses to at
- most 240 an hour: 48% of the content-creating limit and 24% of the REST
limit, plus two reads per
- pull request with a run in progress. Job statuses then lag behind at busy
times, the aggregate
- status does not. The `CI run started` statuses of pull requests whose CI
runs in
- `apache/airflow` come out of the same 60 writes a run, first and at most 30
of them (see "The
- `CI run started` check"), so they do not raise the total; at about 11 pull
request runs an hour
- they take about 3 writes a run.
+ (about 5-6 pushes an hour): about 200 writes an hour without a cap, 40% of
the content-creating
+ limit and more at peaks. The cap of 40 writes a run, four runs an hour,
holds job statuses to at
+ most 160 an hour: 32% of the content-creating limit, leaving the rest to the
reconciler's other
+ writes (labels, comments, the aggregate statuses) and to the other workflows
that share it. Each
+ run posts at most 40 statuses at about one a second, well under the
80-a-minute limit. Job
+ statuses then lag behind at busy times, the aggregate status does not. The
`CI run started`
+ statuses of pull requests whose CI runs in `apache/airflow` come out of the
same 40 writes a run,
+ first and at most 20 of them (see "The `CI run started` check"), so they do
not raise the total;
+ at about 11 pull request runs an hour they take about 3 writes a run.
These are estimates: the share of gated runs, the jobs per push and the pushes
per pull request
are assumptions, not measurements. Watch the monitor's call counts in its run
log after enabling
@@ -936,7 +948,7 @@ with state `success` linking the run:
Folding the check into the scheduled monitor costs no runner per push, at the
price of a pull
request waiting for the next monitor run before it can merge, which a CI run
takes longer than
anyway. Each new head commit needs one write, taken first out of the per-run
budget of job
-statuses (see "Job statuses"), since merging waits for it; at most 30 such
writes per run, so job
+statuses (see "Job statuses"), since merging waits for it; at most 20 such
writes per run, so job
statuses keep at least half of the budget, and after the check became required
the open pull
requests are caught up a batch at a time. A release branch has no monitor of
its own, but `main`'s monitor covers pull
requests to every base branch.
diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py
b/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py
index 77a7ef91011..bc763c7e546 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py
@@ -58,6 +58,8 @@ class GitHubClient:
self.repo = repo
self.dry_run = dry_run
self.calls: Counter[str] = Counter()
+ # Calls GitHub answered with a rate limit, each retried or given up on.
+ self.rate_limited = 0
def _gh(
self,
@@ -94,6 +96,7 @@ class GitHubClient:
if result.returncode == 0:
return json.loads(result.stdout) if result.stdout.strip() else
None
rate_limited = any(marker in result.stderr.lower() for marker in
RATE_LIMIT_MARKERS)
+ self.rate_limited += rate_limited
connection_error = bool(CONNECTION_ERROR.search(result.stderr))
if (
rate_limited
diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
index 0814021495b..6fb8d04aac5 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
@@ -304,11 +304,10 @@ _STATUS_DESCRIPTIONS = {
"gate_modified": "Pull request changes the fork CI gate - see the comment",
}
-# (commit status state, description) of a fork job, by its status and
conclusion. A queued job is
-# reported like a running one: each change is one more write, and commit
statuses can only be posted
-# one at a time (GraphQL has no mutation for them).
+# (commit status state, description) of a finished fork job, by its
conclusion. Queued and running
+# jobs are not reported: each status is one write, commit statuses can only be
posted one at a time
+# (GraphQL has no mutation for them), and `Fork CI / Tests (AMD)` already
shows the run as running.
JOB_STATUSES = {
- "running": ("pending", "Running in the fork - see Details"),
"success": ("success", "Passed in the fork - see Details"),
"failure": ("failure", "Failed in the fork - see Details"),
"timed_out": ("failure", "Timed out in the fork - see Details"),
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 686ccc44136..d940d8a21ce 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
@@ -155,18 +155,29 @@ JOB_STATE_RANKS = {"failure": 0, "pending": 1, "success":
2}
# Commit statuses count towards GitHub's limit of 500 content-creating
requests per hour, shared by
# every workflow of the repository. At four runs an hour this keeps job
statuses to at most about
# half of it; the rest follow on the next run, failures first. See "Job
statuses" in the design doc.
-MAX_JOB_STATUSES_PER_RUN = 60
+MAX_JOB_STATUSES_PER_RUN = 40
# `CI run started` statuses of pull requests whose CI runs in apache/airflow
come out of that budget
# first: one write per new head commit, which merging waits for, while job
statuses are informational.
# At most this many, so job statuses keep the rest; the first runs after the
check became required
# catch up on the open pull requests a batch at a time, most recently updated
first.
-MAX_CI_RUN_STARTED_PER_RUN = 30
+MAX_CI_RUN_STARTED_PER_RUN = 20
# The GitHub Actions app, whose check suites are the workflow runs of a commit.
GITHUB_ACTIONS_APP_ID = 15368
# A single large fork run can need more writes than one run's budget, which
the next run catches up
# on, so deferring is only reported when it lasts this many consecutive runs:
then the budget does
# not keep up with the pull requests.
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
+# 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)
+RATE_LIMITED_NOTE = (
+ f"GitHub rate-limited the fork CI monitor in
{RATE_LIMITED_RUNS_BEFORE_ALERT} runs in a row; the "
+ "repository's shared GITHUB_TOKEN limits are close to exhausted. Consider
`job_statuses = false` in "
+ ".github/fork-ci-gate.toml (see dev/AGENTS.md)"
+)
# Fixed text, so the note is posted to Slack once while deferrals persist; the
run log lists the pull
# requests.
JOB_STATUSES_DEFERRED_NOTE = (
@@ -277,6 +288,8 @@ class ForkObservation:
run_url: str | None = None
run_id: int | None = None
run_attempt: int | None = None
+ # When the fork's check suite last changed, for a completed one when the
run finished.
+ completed_at: datetime | None = None
workflow_state: str | None = None
error: str | None = None
@@ -1248,7 +1261,7 @@ def fetch_fork_observations(
declarations.append(f"$o{index}: String!, $n{index}: String!,
$s{index}: GitObjectID!")
fields.append(
f"pr{index}: repository(owner: $o{index}, name: $n{index}) {{
object(oid: $s{index}) {{ "
- "... on Commit { checkSuites(first: 30) { nodes { createdAt
status conclusion "
+ "... on Commit { checkSuites(first: 30) { nodes { createdAt
updatedAt status conclusion "
"workflowRun { databaseId runAttempt url event workflow {
resourcePath } } } } } } }"
)
variables.update({f"o{index}": owner, f"n{index}": name,
f"s{index}": pr.head_sha})
@@ -1279,24 +1292,27 @@ def fetch_fork_observations(
run_url=latest["workflowRun"]["url"],
run_id=latest["workflowRun"].get("databaseId"),
run_attempt=latest["workflowRun"].get("runAttempt"),
+ completed_at=(
+ parse_github_datetime(latest.get("updatedAt") or "")
+ if latest["status"] == "COMPLETED"
+ else None
+ ),
)
return observations
def build_job_statuses(jobs: Iterable[dict[str, Any]]) -> dict[str, tuple[str,
str, str]]:
- """The ``(state, target URL, description)`` to report for each queued or
started fork job, by context."""
+ """The ``(state, target URL, description)`` to report for each finished
fork job, by context."""
statuses: dict[str, tuple[str, str, str]] = {}
# GitHub compares contexts case-insensitively, so differently capitalised
names are one context.
contexts: dict[str, str] = {}
for job in jobs:
if job["name"] in UNREPORTED_JOB_NAMES:
continue
- if job.get("status") in RUNNING_JOB_STATUSES:
- outcome = "running"
- else:
- outcome = job.get("conclusion") or ""
+ outcome = "" if job.get("status") in RUNNING_JOB_STATUSES else
job.get("conclusion") or ""
if outcome not in JOB_STATUSES:
- # Skipped and neutral jobs did no work worth a check on the pull
request.
+ # Unfinished jobs are left to `Fork CI / Tests (AMD)`; skipped and
neutral ones did no work
+ # worth a check on the pull request.
continue
state, description = JOB_STATUSES[outcome]
context = contexts.setdefault(
@@ -1312,11 +1328,38 @@ def build_job_statuses(jobs: Iterable[dict[str, Any]])
-> dict[str, tuple[str, s
class JobStatusReporter:
"""Reports each job of a pull request's fork run as a commit status,
within a per-run budget."""
- def __init__(self, client: GitHubClient, *, repo: str, budget: int =
MAX_JOB_STATUSES_PER_RUN):
+ def __init__(
+ self,
+ client: GitHubClient,
+ *,
+ repo: str,
+ budget: int = MAX_JOB_STATUSES_PER_RUN,
+ now: datetime | None = None,
+ ):
+ """:param now: when given, deferring the statuses of a run that
finished long ago is reported."""
self._client = client
self._repo = repo
+ self._now = now
self.remaining = budget
self.deferred: list[int] = []
+ # (pull request, attention item) for runs whose statuses are deferred
long after they finished.
+ self.delayed: list[tuple[int, str]] = []
+
+ def _defer(self, pr: PullRequestSnapshot, fork: ForkObservation) -> None:
+ self.deferred.append(pr.number)
+ if (
+ self._now is not None
+ and fork.completed_at is not None
+ and self._now - fork.completed_at >= MIRROR_DELAY_BEFORE_ALERT
+ ):
+ # Fixed text per run, so it is posted to Slack once while the
delay lasts.
+ self.delayed.append(
+ (
+ pr.number,
+ f"job statuses of fork run {fork.run_url} are still not
all posted more than "
+ f"{MIRROR_DELAY_BEFORE_ALERT} after it finished",
+ )
+ )
def report(self, pr: PullRequestSnapshot, fork: ForkObservation) -> str |
None:
"""
@@ -1340,7 +1383,7 @@ class JobStatusReporter:
# Without writes left, the reads are spent for nothing: a backlog of
hundreds of pull requests
# would otherwise cost two reads each on every run.
if self.remaining == 0:
- self.deferred.append(pr.number)
+ self._defer(pr, fork)
return None
jobs = self._client.rest_get(
f"repos/{pr.head_repo}/actions/runs/{fork.run_id}/jobs?filter=latest&per_page=100",
@@ -1376,7 +1419,7 @@ class JobStatusReporter:
)
for context, (state, target_url, description) in changes:
if self.remaining == 0:
- self.deferred.append(pr.number)
+ self._defer(pr, fork)
return None
self._client.set_status(pr.number, pr.head_sha, state, target_url,
description, context=context)
# Charged per write, so writes made before a later one fails still
count.
@@ -2041,6 +2084,7 @@ def drop_reported(
notes: list[str],
record: bool = True,
deferred_runs: int = 0,
+ rate_limited_runs: int = 0,
) -> 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.
@@ -2049,12 +2093,14 @@ def drop_reported(
again. A missing or unreadable file reports everything.
: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.
"""
current: dict[str, Any] = {
"unknown": [list(item) for item in unknown],
"attention": [list(item) for item in attention],
"notes": notes,
"deferred_runs": deferred_runs,
+ "rate_limited_runs": rate_limited_runs,
}
try:
previous = json.loads(reported_file.read_text())
@@ -2072,8 +2118,8 @@ def drop_reported(
return drop("unknown", unknown), drop("attention", attention),
drop("notes", notes)
-def read_deferred_runs(reported_file: Path | None) -> int | None:
- """The consecutive runs before this one that deferred job statuses; None
without a readable record."""
+def read_run_count(reported_file: Path | None, key: str) -> int | None:
+ """A count of consecutive runs the previous run recorded; None without a
readable record."""
if reported_file is None:
return None
try:
@@ -2083,10 +2129,26 @@ def read_deferred_runs(reported_file: Path | None) ->
int | None:
if not isinstance(previous, dict):
return None
# A record written before the count was kept has none.
- count = previous.get("deferred_runs", 0)
+ count = previous.get(key, 0)
return count if isinstance(count, int) and not isinstance(count, bool)
else 0
+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:
+ resources = client.rest_get("rate_limit")["resources"]
+ # Only logged: failing to read the quotas must not fail a run that did its
work.
+ except Exception as e:
+ console_print(f"[warning]Could not read the rate limits: {e}[/]")
+ return
+ left = ", ".join(
+ f"{name} {resources[name]['remaining']}/{resources[name]['limit']}"
+ for name in ("core", "graphql")
+ if name in resources
+ )
+ console_print(f"[info]Rate limits left: {left}; rate-limited calls this
run: {client.rate_limited}[/]")
+
+
def run_reconcile(
client: GitHubClient,
config: GateConfig,
@@ -2163,7 +2225,7 @@ def run_reconcile(
f"reporting `{CI_RUN_STARTED_CONTEXT}` for project CI failed:
{type(e).__name__}"
)
job_reporter = JobStatusReporter(
- client, repo=repo, budget=max(MAX_JOB_STATUSES_PER_RUN -
ci_run_started_writes, 0)
+ client, repo=repo, budget=max(MAX_JOB_STATUSES_PER_RUN -
ci_run_started_writes, 0), now=now
)
shared_heads = find_heads_shared_across_bases(prs)
@@ -2224,10 +2286,11 @@ def run_reconcile(
unknown.append((listed.number, f"{type(e).__name__}: {e}"))
attention.extend(bypass_finder.ambiguous)
+ attention.extend(job_reporter.delayed)
notes = [bypass_finder.incomplete] if bypass_finder.incomplete else []
if ci_run_started_note:
notes.append(ci_run_started_note)
- previous_deferred_runs = read_deferred_runs(reported_file)
+ previous_deferred_runs = read_run_count(reported_file, "deferred_runs")
deferred_runs = (previous_deferred_runs or 0) + 1 if job_reporter.deferred
else 0
if job_reporter.deferred:
console_print(
@@ -2242,6 +2305,13 @@ def run_reconcile(
for number, reason in [*unknown, *attention]:
console_print(f"[warning]#{number}: {reason}[/]")
console_print(f"[info]Reconciled {len(prs)} pull requests; GitHub calls:
{dict(client.calls)}[/]")
+ log_rate_limits(client)
+ previous_rate_limited_runs = read_run_count(reported_file,
"rate_limited_runs")
+ rate_limited_runs = (previous_rate_limited_runs or 0) + 1 if
client.rate_limited else 0
+ if client.rate_limited and (
+ previous_rate_limited_runs is None or rate_limited_runs >=
RATE_LIMITED_RUNS_BEFORE_ALERT
+ ):
+ notes.append(RATE_LIMITED_NOTE)
if draft_changes_file is not None:
draft_changes_file.write_text(json.dumps([asdict(change) for change in
draft_changes]))
if reported_file is not None:
@@ -2252,6 +2322,7 @@ def run_reconcile(
notes=notes,
record=not client.dry_run,
deferred_runs=deferred_runs,
+ rate_limited_runs=rate_limited_runs,
)
has_alerts = bool(unknown or attention or notes)
if has_alerts:
diff --git a/dev/breeze/tests/test_fork_ci_github.py
b/dev/breeze/tests/test_fork_ci_github.py
index 9f6505e2954..25ae3f105c6 100644
--- a/dev/breeze/tests/test_fork_ci_github.py
+++ b/dev/breeze/tests/test_fork_ci_github.py
@@ -40,6 +40,7 @@ def test_retries_on_rate_limit(mock_run, mock_sleep):
assert client.graphql("query { ok }") == {"ok": True}
assert client.calls["graphql"] == 2
+ assert client.rate_limited == 1
mock_sleep.assert_called_once_with(30)
@@ -48,9 +49,12 @@ def test_retries_on_rate_limit(mock_run, mock_sleep):
def test_gives_up_after_the_last_attempt_without_waiting(mock_run, mock_sleep):
mock_run.return_value = _completed(1, stderr="API rate limit exceeded")
+ client = GitHubClient()
+
with pytest.raises(GitHubError, match="still rate limited after 4
attempts"):
- GitHubClient().graphql("query { ok }")
+ client.graphql("query { ok }")
assert [c.args[0] for c in mock_sleep.call_args_list] == [30, 60, 120]
+ assert client.rate_limited == 4
@pytest.mark.parametrize(
diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py
b/dev/breeze/tests/test_fork_ci_reconcile.py
index eeaf790570a..9c72881dc46 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,
+ RATE_LIMITED_NOTE,
BaseGateConfigs,
BypassFinder,
DraftChange,
@@ -71,6 +72,7 @@ from airflow_breeze.utils.fork_ci_reconcile import (
is_own_pilot_change,
jobs_show_project_ci,
load_workflow_triggers,
+ log_rate_limits,
mark_older_pilot_pull_request,
parse_pull_request,
pull_request_changes_ci_files,
@@ -950,6 +952,7 @@ def client():
github = mock.create_autospec(GitHubClient, instance=True)
github.repo = REPO
github.calls = {}
+ github.rate_limited = 0
return github
@@ -1821,6 +1824,7 @@ def
test_fork_observations_use_the_fork_name_and_latest_suite(client):
},
{
"createdAt": "2026-11-02T00:00:00Z",
+ "updatedAt": "2026-11-02T00:40:00Z",
"status": "COMPLETED",
"conclusion": "SUCCESS",
"workflowRun": {
@@ -1860,7 +1864,12 @@ def
test_fork_observations_use_the_fork_name_and_latest_suite(client):
observations = fetch_fork_observations(client, [pr])
assert observations[1] == ForkObservation(
- suite_status="COMPLETED", suite_conclusion="SUCCESS", run_url="new",
run_id=77, run_attempt=2
+ suite_status="COMPLETED",
+ suite_conclusion="SUCCESS",
+ run_url="new",
+ run_id=77,
+ run_attempt=2,
+ completed_at=datetime(2026, 11, 2, 0, 40, tzinfo=UTC),
)
assert client.graphql.call_args.args[1] == {"o0": "someone", "n0":
"my-airflow", "s0": "sha1"}
@@ -2193,6 +2202,7 @@ def
test_drop_reported_reports_everything_without_a_readable_previous_run(tmp_pa
"attention": [],
"notes": [],
"deferred_runs": 0,
+ "rate_limited_runs": 0,
}
@@ -2320,7 +2330,7 @@ def _fork_job(name, status="completed",
conclusion="success", url=None):
}
-def test_build_job_statuses_reports_each_started_or_queued_job():
+def test_build_job_statuses_reports_each_finished_job():
jobs = [
_fork_job("Static checks"),
_fork_job("Postgres tests: core / A", conclusion="failure"),
@@ -2340,8 +2350,6 @@ def
test_build_job_statuses_reports_each_started_or_queued_job():
} == {
"Static checks": "success",
"Postgres tests: core / A": "failure",
- "Helm tests": "pending",
- "Kubernetes tests": "pending",
"Special tests": "failure",
}
assert statuses[f"{JOB_STATUS_CONTEXT_PREFIX}Static checks"][1] ==
"https://job/Static checks"
@@ -2420,10 +2428,11 @@ def
test_job_status_reporter_leaves_what_does_not_fit_in_the_budget_for_the_next
def test_job_status_reporter_does_not_record_a_run_in_progress(client):
- _reporter_client(client, [_fork_job("A", status="in_progress",
conclusion=None)])
+ _reporter_client(client, [_fork_job("A"), _fork_job("B",
status="in_progress", conclusion=None)])
assert JobStatusReporter(client, repo=REPO).report(_pr(), RUNNING_RUN) is
None
client.set_status.assert_called_once()
+ assert client.set_status.call_args.kwargs["context"] ==
f"{JOB_STATUS_CONTEXT_PREFIX}A"
@pytest.mark.parametrize(
@@ -2475,6 +2484,7 @@ def
test_run_reconcile_reports_job_statuses_before_classifying(
mock_classify.return_value = Plan(state=ForkState.RUNNING)
mock_reporter.return_value.report.return_value = "7:SUCCESS"
mock_reporter.return_value.deferred = []
+ mock_reporter.return_value.delayed = []
client.graphql.side_effect = [
_search_result(),
_search_result(),
@@ -2499,11 +2509,9 @@ def
test_run_reconcile_reports_job_statuses_before_classifying(
assert mock_classify.call_args.kwargs["jobs_reported"] == "7:SUCCESS"
-def test_build_job_statuses_reports_a_queued_job_like_a_running_one():
- queued = build_job_statuses([_fork_job("A", status="queued",
conclusion=None, url="u")])
- running = build_job_statuses([_fork_job("A", status="in_progress",
conclusion=None, url="u")])
-
- assert queued == running
[email protected]("status", ["queued", "in_progress", "waiting"])
+def
test_build_job_statuses_leaves_unfinished_jobs_to_the_aggregate_status(status):
+ assert build_job_statuses([_fork_job("A", status=status, conclusion=None,
url="u")]) == {}
def _stale(state):
@@ -2586,6 +2594,7 @@ def
test_run_reconcile_classifies_when_reporting_job_statuses_fails(
mock_classify.return_value = Plan(state=ForkState.RUNNING)
mock_reporter.return_value.report.side_effect = GitHubError("HTTP 502")
mock_reporter.return_value.deferred = []
+ mock_reporter.return_value.delayed = []
assert _reconcile_with_reporter(client, tmp_path) is True
@@ -2598,6 +2607,7 @@ def
test_run_reconcile_classifies_when_reporting_job_statuses_fails(
def test_run_reconcile_without_job_statuses(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 = []
_reconcile_with_reporter(client, tmp_path,
config=dataclasses.replace(CONFIG, job_statuses=False))
@@ -2622,6 +2632,7 @@ def
test_run_reconcile_reports_deferred_job_statuses_to_slack(
mock_classify.return_value = Plan(state=ForkState.RUNNING)
mock_reporter.return_value.report.return_value = None
mock_reporter.return_value.deferred = deferred
+ mock_reporter.return_value.delayed = []
assert _reconcile_with_reporter(client, tmp_path) is has_alerts
@@ -2642,6 +2653,7 @@ def
test_run_reconcile_reports_deferred_job_statuses_only_when_deferring_persist
alerts = []
for deferred in ([1], [1], [], [1], [1], [1]):
mock_reporter.return_value.deferred = deferred
+ mock_reporter.return_value.delayed = []
alerts.append(_reconcile_with_reporter(client, tmp_path,
reported_file=reported_file))
assert alerts == [False, False, False, False, False, True]
@@ -3155,7 +3167,7 @@ def
test_run_reconcile_retries_a_pull_request_whose_head_moved(client, tmp_path)
@pytest.mark.parametrize(
("fails", "job_status_budget"),
- [pytest.param(False, 58, id="reported"), pytest.param(True, 30,
id="fails")],
+ [pytest.param(False, 38, id="reported"), pytest.param(True, 20,
id="fails")],
)
@mock.patch("airflow_breeze.utils.fork_ci_reconcile.JobStatusReporter",
autospec=True)
@mock.patch("airflow_breeze.utils.fork_ci_reconcile.report_project_ci_runs_started",
autospec=True)
@@ -3169,6 +3181,7 @@ def
test_run_reconcile_marks_older_pilot_pull_requests_and_reports_project_ci_ru
mock_report.return_value = [7, 8]
mock_reporter.return_value.report.return_value = None
mock_reporter.return_value.deferred = []
+ mock_reporter.return_value.delayed = []
client.graphql.side_effect = [
_search_result(),
_search_result(),
@@ -3191,7 +3204,7 @@ def
test_run_reconcile_marks_older_pilot_pull_requests_and_reports_project_ci_ru
assert mock_classify.call_args.args[0].ci_run_at == datetime(2026, 11, 2,
tzinfo=UTC)
mock_report.assert_called_once_with(client, repo=REPO, skip={1})
- mock_reporter.assert_called_once_with(client, repo=REPO,
budget=job_status_budget)
+ mock_reporter.assert_called_once_with(client, repo=REPO,
budget=job_status_budget, now=NOW)
assert has_alerts is fails
if fails:
assert "reporting `CI run started` for project CI failed: GitHubError"
in output.read_text()
@@ -3232,3 +3245,99 @@ def
test_older_pilot_pull_request_stays_checked_for_tampering_after_its_gated_ru
)
assert find_bypass.called
+
+
[email protected](
+ ("resources", "logged"),
+ [
+ pytest.param(
+ {"core": {"remaining": 900, "limit": 1000}, "graphql":
{"remaining": 990, "limit": 1000}},
+ "core 900/1000, graphql 990/1000",
+ id="read",
+ ),
+ pytest.param(
+ GitHubError("gh api failed: HTTP 500"), "Could not read the rate
limits", id="unreadable"
+ ),
+ ],
+)
[email protected]("airflow_breeze.utils.fork_ci_reconcile.console_print",
autospec=True)
+def test_log_rate_limits(mock_print, client, resources, logged):
+ client.rest_get.side_effect = (
+ [resources] if isinstance(resources, Exception) else [{"resources":
resources}]
+ )
+
+ log_rate_limits(client)
+
+ client.rest_get.assert_called_once_with("rate_limit")
+ assert logged in mock_print.call_args.args[0]
+
+
[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_rate_limiting_only_when_it_persists(
+ mock_classify, mock_reporter, client, tmp_path
+):
+ mock_classify.return_value = Plan(state=ForkState.RUNNING)
+ mock_reporter.return_value.report.return_value = None
+ 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 = []
+ for rate_limited in (1, 0, 3, 2):
+ client.rate_limited = rate_limited
+ alerts.append(_reconcile_with_reporter(client, tmp_path,
reported_file=reported_file))
+
+ assert alerts == [False, False, False, True]
+ assert RATE_LIMITED_NOTE in (tmp_path / "s.json").read_text()
+ assert json.loads(reported_file.read_text())["rate_limited_runs"] == 2
+
+
[email protected](
+ ("completed_minutes_ago", "delayed"),
+ [
+ pytest.param(61, True, id="finished-over-an-hour-ago"),
+ pytest.param(30, False, id="finished-recently"),
+ pytest.param(None, False, id="still-running"),
+ ],
+)
+def
test_job_status_reporter_reports_statuses_deferred_long_after_the_run_finished(
+ client, completed_minutes_ago, delayed
+):
+ fork = dataclasses.replace(
+ COMPLETED_RUN,
+ completed_at=None
+ if completed_minutes_ago is None
+ else NOW - timedelta(minutes=completed_minutes_ago),
+ )
+ reporter = JobStatusReporter(client, repo=REPO, budget=0, now=NOW)
+
+ assert reporter.report(_pr(), fork) is None
+
+ assert reporter.deferred == [42]
+ assert reporter.delayed == (
+ [
+ (
+ 42,
+ f"job statuses of fork run {fork.run_url} are still not all
posted more than 1:00:00 after it finished",
+ )
+ ]
+ if delayed
+ else []
+ )
+
+
[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_delayed_job_statuses_as_attention_items(
+ mock_classify, mock_reporter, client, tmp_path
+):
+ mock_classify.return_value = Plan(state=ForkState.RUNNING)
+ mock_reporter.return_value.report.return_value = None
+ mock_reporter.return_value.deferred = [1]
+ mock_reporter.return_value.delayed = [(1, "job statuses of fork run R are
still not all posted")]
+
+ assert _reconcile_with_reporter(client, tmp_path) is True
+
+ assert "job statuses of fork run R are still not all posted" in (tmp_path
/ "s.json").read_text()