Skip to content

Commit ec57bee

Browse files
authored
add on_failure_lister plugin and always_fail.py example DAG (GoogleCloudPlatform#767)
1 parent 8b9e391 commit ec57bee

3 files changed

Lines changed: 300 additions & 0 deletions

File tree

dags/examples/always_fail.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import logging
2+
3+
from airflow import models
4+
5+
from airflow.decorators import task
6+
from airflow.exceptions import AirflowException
7+
from airflow.utils.task_group import TaskGroup
8+
9+
"""
10+
A DAG that always fails when triggered. This DAG is an example DAG used to
11+
trigger the on_failure_actions.py/DagRunListener plugin and post a Github
12+
Issue.
13+
"""
14+
15+
16+
@task
17+
def task_a():
18+
logging.info("task A")
19+
20+
21+
# Add or override the owner of the task, in order to assign issue assignees.
22+
@task(owner="airflow")
23+
def task_b():
24+
logging.info("task B")
25+
raise AirflowException("task B failed")
26+
27+
28+
@task
29+
def task_c():
30+
logging.info("task C")
31+
32+
33+
with models.DAG(
34+
dag_id="on_failure_actions_trigger",
35+
schedule=None,
36+
tags=[
37+
"test_dag",
38+
"on_failure_alert", # Add this to enable DagRunListener
39+
],
40+
catchup=False,
41+
default_args={
42+
"retries": 0, # Set to 0 for throwing exception immediately
43+
},
44+
) as dag:
45+
with TaskGroup(group_id="Test1") as group_a:
46+
with TaskGroup(group_id="Subgroup1") as group_b:
47+
taskA = task_a()
48+
49+
with TaskGroup(group_id="Test2") as group_c:
50+
with TaskGroup(group_id="Subgroup2") as group_d:
51+
taskB = task_b()
52+
53+
with TaskGroup(group_id="Test3") as group_e:
54+
with TaskGroup(group_id="Subgroup3") as group_f:
55+
taskC = task_c()
56+
57+
group_a >> group_c >> group_e

plugins/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
The "on_failure_actions" plugin listens to the DAGs run results and takes action for failed DAG runs. Currently, the action is to file a GitHub issue for the failed DAG run.
2+
3+
## Pre-requisites:
4+
To leverage the "on_failure_actions" plugin, ensure the following conditions are met:
5+
6+
### 1. **DAG Opt-In:**
7+
Each DAG intended to utilize this feature **must include the `"on_failure_alert"` tag** within its DAG definition. DAGs without this specific tag will be ignored by the plugin's failure-handling logic, and no GitHub issue will be filed for their failures.
8+
9+
with DAG(
10+
dag_id='my_critical_dag',
11+
# ... other DAG parameters ...
12+
tags=['data_ingestion', 'critical', 'on_failure_alert'], # <--- Add this tag
13+
) as dag:
14+
# ... tasks ...
15+
16+
### 2. **GitHub Owner Mapping:**
17+
For accurate issue assignment, ensure that the `owner` property defined for tests within your DAGs corresponds directly to valid **GitHub usernames**. The plugin will collect unique test owners from the failed DAG and attempt to assign the GitHub issue to these users.
18+
19+
#### Example task definition
20+
my_task = BashOperator(
21+
task_id='process_data',
22+
bash_command='...',
23+
owner='github_username_here', # This should be a valid GitHub username
24+
)
25+
26+
# Or
27+
28+
@task(owner='github_username_here') # This should be a valid GitHub username
29+
def task_a():
30+
pass
31+
32+
## Configuration and Installation:
33+
1. From GCP console UI, Your Composer Env -> Tab -> Pypi packages -> Edit -> Add 'apache-airflow-providers-github' -> Save
34+
35+
2. From GCP console UI, search for "Secret Manager", and add conn_id 'github_default' into Secret Manager.
36+
1. key: airflow-connections-<composer_environment_name>-github_default
37+
2. value:
38+
{
39+
"conn_type": "http",
40+
"host": "https://api.github.com",
41+
"password": "\<GitHub Personal Access Token\>"
42+
}
43+
44+
3. Composer -> Airflow configuration overrides -> Edit, reference: https://cloud.google.com/composer/docs/composer-1/configure-secret-manager
45+
1. secrets | backend | airflow.providers.google.cloud.secrets.secret_manager.CloudSecretManagerBackend
46+
2. secrets | backends_order | custom,environment_variable,metastore
47+
48+
4. Upload 'on_failure_actions.py' to \<DAG Bucket\>/plugins/

plugins/on_failure_actions.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import logging
2+
import os
3+
4+
from airflow.exceptions import AirflowException
5+
from airflow.listeners import hookimpl
6+
from airflow.models import DagRun, TaskInstance
7+
from airflow.plugins_manager import AirflowPlugin
8+
from airflow.providers.github.hooks.github import GithubHook
9+
from github import Github
10+
11+
from dags.common.vm_resource import Project
12+
from xlml.utils import composer
13+
from urllib import parse
14+
15+
_PROJECT_ID = Project.CLOUD_ML_AUTO_SOLUTIONS
16+
_REPO_NAME = "GoogleCloudPlatform/ml-auto-solutions"
17+
18+
19+
def generate_dag_run_link(
20+
proj_id: str,
21+
dag_id: str,
22+
dag_run_id: str,
23+
task_id: str,
24+
):
25+
airflow_link = composer.get_airflow_url(
26+
proj_id,
27+
os.environ.get("COMPOSER_LOCATION"),
28+
os.environ.get("COMPOSER_ENVIRONMENT"),
29+
)
30+
airflow_dag_run_link = (
31+
f"{airflow_link}/dags/{dag_id}/"
32+
f"grid?dag_run_id={parse.quote(dag_run_id)}&task_id={task_id}&tab=logs"
33+
)
34+
return airflow_dag_run_link
35+
36+
37+
"""
38+
Airflow Listener class to handle DAG run failures.
39+
40+
This listener specifically triggers actions when a DAG run fails.
41+
It checks for the 'on_failure_alert' tag on the failed DAG. If the tag is present,
42+
it proceeds to create a GitHub issue with details about the failed DAG run
43+
and its failed tasks. The issue is assigned to the owners of the failed tasks
44+
(excluding 'airflow' as an owner).
45+
46+
TODO: Implement more sophisticated issue filing strategies beyond a single failure, such as:
47+
- Consecutive Failures: Only file an issue if a DAG has failed for two or more
48+
consecutive runs to reduce noise from transient issues.
49+
- Reduced Pass Rate: File an issue if the current run failed AND the overall
50+
pass rate of the past N (e.g., 10) runs for this DAG falls below a certain threshold
51+
(e.g., 50%).
52+
"""
53+
54+
55+
class DagRunListener:
56+
57+
def __init__(self):
58+
self.log_prefix = self.__class__.__name__
59+
60+
@hookimpl
61+
def on_dag_run_success(self, dag_run: DagRun, msg: str):
62+
self.on_dag_finished(dag_run, msg)
63+
64+
@hookimpl
65+
def on_dag_run_failed(self, dag_run: DagRun, msg: str):
66+
self.on_dag_finished(dag_run, msg)
67+
68+
def on_dag_finished(self, dag_run: DagRun, msg: str):
69+
logging.info(f"[{self.log_prefix}] DAG run: {dag_run.dag_id} finished")
70+
logging.info(f"[{self.log_prefix}] msg: {msg}")
71+
72+
try:
73+
# Only DAGs with the 'on_failure_alert' tag will be processed.
74+
if "on_failure_alert" not in dag_run.dag.tags:
75+
logging.info(
76+
f"[{self.log_prefix}] DAG {dag_run.dag_id} isn't "
77+
f"'on_failure_alert' by tags. Return"
78+
)
79+
return
80+
logging.info(
81+
f"[{self.log_prefix}] DAG run {dag_run.dag_id} is 'on_failure_alert'"
82+
)
83+
84+
failed_task_instances = [
85+
ti for ti in dag_run.task_instances if ti.state == "failed"
86+
]
87+
if len(failed_task_instances) == 0:
88+
logging.info(
89+
f"[{self.log_prefix}] No failed tasks, GitHub Issue operation completed."
90+
)
91+
return
92+
93+
body = (
94+
f"- **Run ID**: {dag_run.run_id}\n"
95+
f"- **Execution Date**: {dag_run.execution_date}\n"
96+
)
97+
group_dict = {}
98+
for task_instance in failed_task_instances:
99+
group_id = self.get_group_id(task_instance)
100+
if group_id in group_dict:
101+
group_dict[group_id].append(task_instance)
102+
else:
103+
group_dict[group_id] = [task_instance]
104+
105+
client = self.get_github_client()
106+
for group_id, task_instances in group_dict.items():
107+
title = f"[{self.log_prefix}] {dag_run.dag_id} {group_id} failed"
108+
assignees = set()
109+
issue_body = body
110+
for task_instance in task_instances:
111+
link = generate_dag_run_link(
112+
proj_id=str(_PROJECT_ID),
113+
dag_id=dag_run.dag_id,
114+
dag_run_id=dag_run.run_id,
115+
task_id=task_instance.task_id,
116+
)
117+
issue_body += (
118+
f"- **Failed Task**: [{task_instance.task_id}](" f"{link})\n"
119+
)
120+
if task_instance.task.owner and task_instance.task.owner != "airflow":
121+
assignees.add(task_instance.task.owner)
122+
123+
issue = self.query_latest_issues(client, title)
124+
try:
125+
if issue:
126+
self.add_issue_comment(issue, issue_body)
127+
else:
128+
self.create_issue(client, title, issue_body, list(assignees))
129+
except Exception as e:
130+
if "422" not in str(e): # Invalid GitHub username as assignees
131+
raise e
132+
logging.error(
133+
f"[{self.log_prefix}] Invalid assignees, retrying without assignees. Original error: {e}"
134+
)
135+
if issue:
136+
self.add_issue_comment(issue, issue_body)
137+
else:
138+
self.create_issue(client, title, issue_body)
139+
140+
logging.error(f"[{self.log_prefix}] GitHub Issue operation completed.")
141+
except AirflowException as airflow_e:
142+
logging.error(
143+
f"[{self.log_prefix}] Airflow exception: {airflow_e}", exc_info=True
144+
)
145+
except Exception as e:
146+
logging.error(
147+
f"[{self.log_prefix}] Unexpected exception: {e}", exc_info=True
148+
)
149+
150+
@staticmethod
151+
def get_group_id(task_instance: TaskInstance):
152+
task = task_instance.task
153+
task_id = task.task_id
154+
group_id = task.task_group.group_id
155+
if group_id:
156+
# Benchmark ID would be the first section of group_id
157+
return group_id.split(".")[0]
158+
else:
159+
return task_id
160+
161+
def get_github_client(self) -> Github:
162+
environment_name = os.environ.get(
163+
"COMPOSER_ENVIRONMENT", default="ml-automation-solutions"
164+
)
165+
logging.error(f"[{self.log_prefix}] env: {environment_name}")
166+
conn_id = environment_name + "-github_default"
167+
return GithubHook(github_conn_id=conn_id).get_conn()
168+
169+
def query_latest_issues(self, client: Github, title: str):
170+
logging.error(f"[{self.log_prefix}] query open issues titled {title}")
171+
query_issues = f"{title} in:title state:open repo:{_REPO_NAME} is:issue"
172+
issues = list(
173+
client.search_issues(query=query_issues, sort="updated", order="desc")
174+
)
175+
return (
176+
sorted(issues, key=lambda i: i.updated_at, reverse=True)[0]
177+
if (len(issues) > 0)
178+
else None
179+
)
180+
181+
def create_issue(self, client, title, body, assignees=None):
182+
if not assignees:
183+
assignees = []
184+
logging.error(f"[{self.log_prefix}] Create a new one")
185+
repo = client.get_repo(full_name_or_id=_REPO_NAME)
186+
repo.create_issue(title=f"{title}", body=f"{body}", assignees=assignees)
187+
188+
def add_issue_comment(self, issue, body):
189+
logging.error(f"[{self.log_prefix}] Update the latest one")
190+
issue.create_comment(body=body)
191+
192+
193+
class ListenerPlugins(AirflowPlugin):
194+
name = "listener_plugins"
195+
listeners = [DagRunListener()]

0 commit comments

Comments
 (0)