Skip to content

Commit fbea214

Browse files
authored
Monitor the workflow run breeze actually dispatched (#71305)
* Monitor the workflow run breeze actually dispatched The docs-publish driver looked up the newest run of the workflow right after dispatching it. When the dispatch had not registered yet, that is the previous run - typically already finished - so the driver reported success and moved straight on to the airflow-site refresh and the S3-to-GitHub sync while the docs build it was supposed to gate on was still running. A scheduled run, or a run someone else started, was picked up the same way. Terminal conclusions other than success and failure were also treated as nothing to report, so a cancelled or timed-out run let the rest of the chain proceed as if the docs had been published. * Keep breeze workflow-run monitoring on the dispatched run only Several of the workflows breeze dispatches - apache/airflow-site's build.yml among them - also run on push and pull request. Such a run registering while we poll would be taken for the one we dispatched, so only workflow_dispatch runs are considered now. A transient gh failure during that poll also aborted the whole publishing chain, even though the caller is already retrying.
1 parent e671bcb commit fbea214

2 files changed

Lines changed: 156 additions & 17 deletions

File tree

dev/breeze/src/airflow_breeze/utils/gh_workflow_utils.py

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
from airflow_breeze.utils.github import run_gh_command
2929
from airflow_breeze.utils.shared_options import get_dry_run
3030

31+
NEW_RUN_TIMEOUT_SECONDS = 180
32+
NEW_RUN_POLL_SECONDS = 5
33+
3134

3235
def tigger_workflow(workflow_name: str, repo: str, branch: str = "main", **kwargs):
3336
"""
@@ -93,12 +96,20 @@ def make_sure_gh_is_installed():
9396
sys.exit(1)
9497

9598

96-
def get_workflow_run_id(workflow_name: str, repo: str) -> int:
99+
def get_latest_workflow_run_id(workflow_name: str, repo: str, *, exit_on_error: bool = True) -> int | None:
97100
"""
98-
Get the latest workflow run ID for a given workflow name and repository.
101+
Get the latest dispatched workflow run ID for a given workflow name and repository.
102+
103+
Only ``workflow_dispatch`` runs are considered - several of the workflows we drive here also run
104+
on push or pull request (apache/airflow-site's `build.yml`, for one), and such a run registering
105+
between our two lookups would otherwise be mistaken for the one we dispatched.
99106
100107
:param workflow_name: The name of the workflow to check.
101108
:param repo: The repository in the format 'owner/repo'.
109+
:param exit_on_error: Whether a failing `gh` call should terminate breeze. Pass False when the
110+
caller polls and can afford to retry a transient failure.
111+
:return: The run id, or None when the workflow has never been dispatched (or when the lookup
112+
failed and ``exit_on_error`` is False).
102113
"""
103114
make_sure_gh_is_installed()
104115
command = [
@@ -109,6 +120,8 @@ def get_workflow_run_id(workflow_name: str, repo: str) -> int:
109120
workflow_name,
110121
"--repo",
111122
repo,
123+
"--event",
124+
"workflow_dispatch",
112125
"--limit",
113126
"1",
114127
"--json",
@@ -117,21 +130,49 @@ def get_workflow_run_id(workflow_name: str, repo: str) -> int:
117130

118131
result = run_gh_command(command, capture_output=True)
119132
if result.returncode != 0:
133+
if not exit_on_error:
134+
console_print(f"[yellow]Error fetching workflow run ID: {result.stderr} - retrying.[/yellow]")
135+
return None
120136
console_print(f"[red]Error fetching workflow run ID: {result.stderr}[/red]")
121137
sys.exit(1)
122138

123139
runs_data = result.stdout.strip()
124140
if not runs_data:
125-
console_print("[red]No workflow runs found.[/red]")
126-
sys.exit(1)
141+
return None
127142

128-
run_id = json.loads(runs_data)[0].get("databaseId")
143+
runs = json.loads(runs_data)
144+
return runs[0].get("databaseId") if runs else None
129145

130-
console_print(
131-
f"[blue]Running workflow {workflow_name} at https://github.com/{repo}/actions/runs/{run_id}[/blue]",
132-
)
133146

134-
return run_id
147+
def wait_for_new_workflow_run(workflow_name: str, repo: str, previous_run_id: int | None) -> int:
148+
"""
149+
Wait until a run newer than ``previous_run_id`` shows up and return its id.
150+
151+
Run ids increase monotonically, so anything above the id observed just before the dispatch is
152+
the run we started. Taking whatever run is newest would instead latch onto an unrelated one -
153+
a scheduled run, or another maintainer's - whenever ours has not registered yet, and report
154+
that run's result as ours.
155+
156+
:param workflow_name: The name of the workflow that was dispatched.
157+
:param repo: The repository in the format 'owner/repo'.
158+
:param previous_run_id: The newest run id seen before dispatching, or None if there was none.
159+
"""
160+
deadline = time.monotonic() + NEW_RUN_TIMEOUT_SECONDS
161+
while True:
162+
run_id = get_latest_workflow_run_id(workflow_name, repo, exit_on_error=False)
163+
if run_id is not None and (previous_run_id is None or run_id > previous_run_id):
164+
console_print(
165+
f"[blue]Running workflow {workflow_name} at "
166+
f"https://github.com/{repo}/actions/runs/{run_id}[/blue]",
167+
)
168+
return run_id
169+
if time.monotonic() >= deadline:
170+
console_print(
171+
f"[red]Timed out after {NEW_RUN_TIMEOUT_SECONDS}s waiting for the dispatched run of "
172+
f"{workflow_name} in {repo} to appear.[/red]"
173+
)
174+
sys.exit(1)
175+
time.sleep(NEW_RUN_POLL_SECONDS)
135176

136177

137178
def get_workflow_run_info(run_id: str, repo: str, fields: str) -> dict:
@@ -188,12 +229,14 @@ def monitor_workflow_run(run_id: str, repo: str):
188229
if status == "completed":
189230
if conclusion == "success":
190231
console_print(f"[green]Workflow {name} run {run_id} completed successfully.[/green]")
191-
elif conclusion == "failure":
192-
console_print(
193-
f"[red]Workflow {name} run {run_id} failed, see for more info: https://github.com/{repo}/actions/runs/{run_id}[/red]"
194-
)
195-
sys.exit(1)
196-
break
232+
break
233+
# Anything else - failure, cancelled, timed_out, action_required - means the run did not
234+
# produce what the caller is about to chain further work onto.
235+
console_print(
236+
f"[red]Workflow {name} run {run_id} finished with conclusion '{conclusion}', "
237+
f"see for more info: https://github.com/{repo}/actions/runs/{run_id}[/red]"
238+
)
239+
sys.exit(1)
197240

198241
# Check status of jobs every 30 seconds
199242
time.sleep(30)
@@ -203,6 +246,7 @@ def trigger_workflow_and_monitor(
203246
workflow_name: str, repo: str, branch: str = "main", monitor=True, **workflow_fields
204247
):
205248
make_sure_gh_is_installed()
249+
previous_run_id = None if get_dry_run() else get_latest_workflow_run_id(workflow_name, repo)
206250
tigger_workflow(
207251
workflow_name=workflow_name,
208252
repo=repo,
@@ -213,9 +257,10 @@ def trigger_workflow_and_monitor(
213257
if get_dry_run():
214258
return
215259

216-
workflow_run_id = get_workflow_run_id(
260+
workflow_run_id = wait_for_new_workflow_run(
217261
workflow_name=workflow_name,
218262
repo=repo,
263+
previous_run_id=previous_run_id,
219264
)
220265

221266
console_print(

dev/breeze/tests/test_gh_workflow_utils.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,19 @@
1616
# under the License.
1717
from __future__ import annotations
1818

19+
import contextlib
20+
import subprocess
1921
from unittest import mock
2022

21-
from airflow_breeze.utils.gh_workflow_utils import trigger_workflow_and_monitor
23+
import pytest
24+
25+
from airflow_breeze.utils.gh_workflow_utils import (
26+
NEW_RUN_TIMEOUT_SECONDS,
27+
get_latest_workflow_run_id,
28+
monitor_workflow_run,
29+
trigger_workflow_and_monitor,
30+
wait_for_new_workflow_run,
31+
)
2232
from airflow_breeze.utils.shared_options import set_dry_run
2333

2434

@@ -38,3 +48,87 @@ def test_trigger_workflow_and_monitor_stops_after_the_dispatch_in_dry_run(_, moc
3848
set_dry_run(False)
3949

4050
mock_monitor.assert_not_called()
51+
52+
53+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.run_gh_command")
54+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.make_sure_gh_is_installed")
55+
def test_get_latest_workflow_run_id_only_looks_at_dispatched_runs(_, mock_run_gh_command):
56+
"""Push and pull_request runs of the same workflow must not be mistaken for the dispatched one."""
57+
mock_run_gh_command.return_value = subprocess.CompletedProcess(
58+
args=[], returncode=0, stdout='[{"databaseId": 123}]', stderr=""
59+
)
60+
61+
assert get_latest_workflow_run_id("build.yml", "apache/airflow-site") == 123
62+
63+
command = mock_run_gh_command.call_args.args[0]
64+
assert command[command.index("--event") + 1] == "workflow_dispatch"
65+
66+
67+
@pytest.mark.parametrize(
68+
("exit_on_error", "expectation"),
69+
[(True, pytest.raises(SystemExit)), (False, contextlib.nullcontext())],
70+
)
71+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.run_gh_command")
72+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.make_sure_gh_is_installed")
73+
def test_get_latest_workflow_run_id_lets_a_polling_caller_survive_a_failed_lookup(
74+
_, mock_run_gh_command, exit_on_error, expectation
75+
):
76+
mock_run_gh_command.return_value = subprocess.CompletedProcess(
77+
args=[], returncode=1, stdout="", stderr="could not connect to api.github.com"
78+
)
79+
80+
with expectation:
81+
assert get_latest_workflow_run_id("build.yml", "apache/airflow", exit_on_error=exit_on_error) is None
82+
83+
84+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.time.sleep")
85+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.get_latest_workflow_run_id")
86+
def test_wait_for_new_workflow_run_retries_after_a_failed_lookup(mock_latest, _):
87+
"""A transient `gh` failure while polling must not abort the whole publishing chain."""
88+
mock_latest.side_effect = [None, 222]
89+
90+
assert wait_for_new_workflow_run("build.yml", "apache/airflow-site", previous_run_id=111) == 222
91+
assert all(call.kwargs["exit_on_error"] is False for call in mock_latest.call_args_list)
92+
93+
94+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.time.sleep")
95+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.get_latest_workflow_run_id")
96+
def test_wait_for_new_workflow_run_ignores_the_run_that_predates_the_dispatch(mock_latest, _):
97+
mock_latest.side_effect = [111, 111, 222]
98+
99+
assert wait_for_new_workflow_run("publish-docs-to-s3.yml", "apache/airflow", previous_run_id=111) == 222
100+
101+
102+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.time.sleep")
103+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.get_latest_workflow_run_id")
104+
def test_wait_for_new_workflow_run_accepts_the_first_ever_run(mock_latest, _):
105+
mock_latest.return_value = 42
106+
107+
assert wait_for_new_workflow_run("build.yml", "apache/airflow-site", previous_run_id=None) == 42
108+
109+
110+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.time.monotonic")
111+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.time.sleep")
112+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.get_latest_workflow_run_id")
113+
def test_wait_for_new_workflow_run_gives_up_when_no_new_run_appears(mock_latest, _, mock_monotonic):
114+
mock_latest.return_value = 111
115+
mock_monotonic.side_effect = [0, 0, NEW_RUN_TIMEOUT_SECONDS]
116+
117+
with pytest.raises(SystemExit) as exc_info:
118+
wait_for_new_workflow_run("publish-docs-to-s3.yml", "apache/airflow", previous_run_id=111)
119+
120+
assert exc_info.value.code == 1
121+
122+
123+
@pytest.mark.parametrize("conclusion", ["failure", "cancelled", "timed_out", "action_required"])
124+
@mock.patch("airflow_breeze.utils.gh_workflow_utils.get_workflow_run_info")
125+
def test_monitor_workflow_run_fails_on_any_unsuccessful_conclusion(mock_info, conclusion):
126+
mock_info.side_effect = [
127+
{"jobs": []},
128+
{"status": "completed", "conclusion": conclusion, "name": "Publish Docs to S3"},
129+
]
130+
131+
with pytest.raises(SystemExit) as exc_info:
132+
monitor_workflow_run(run_id="123", repo="apache/airflow")
133+
134+
assert exc_info.value.code == 1

0 commit comments

Comments
 (0)