-
Notifications
You must be signed in to change notification settings - Fork 17.4k
Create a more efficient airflow dag test command that also has better local logging #26400
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e28f0e0
3a63377
47c3ac3
dc7850a
d35be7d
39bd327
75e0a4d
f2df9cf
f4020f8
ffe878c
7d5d5b3
d5b2cbc
87cdcb0
f46b636
be3f821
c6f1f1b
fbce1cb
4c34e79
21c4c60
6526aef
55bc945
62cdf52
13b5e39
d9b1d67
55fc7b8
fb527b7
10995bc
e255dc7
b885b90
9d8b70e
794c680
3a16074
ae531cb
ee246be
49763d2
a2aa803
a65e290
bc5b9a5
d2a1256
2b5dd8e
b09365f
ffb2b87
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -73,4 +73,4 @@ | |
| this_will_skip >> run_this_last | ||
|
|
||
| if __name__ == "__main__": | ||
| dag.cli() | ||
| dag.test() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,10 +63,11 @@ | |
| import airflow.templates | ||
| from airflow import settings, utils | ||
| from airflow.compat.functools import cached_property | ||
| from airflow.configuration import conf | ||
| from airflow.configuration import conf, secrets_backend_list | ||
| from airflow.exceptions import ( | ||
| AirflowDagInconsistent, | ||
| AirflowException, | ||
| AirflowSkipException, | ||
| DuplicateTaskIdFound, | ||
| RemovedInAirflow3Warning, | ||
| TaskNotFound, | ||
|
|
@@ -80,6 +81,7 @@ | |
| from airflow.models.operator import Operator | ||
| from airflow.models.param import DagParam, ParamsDict | ||
| from airflow.models.taskinstance import Context, TaskInstance, TaskInstanceKey, clear_task_instances | ||
| from airflow.secrets.local_filesystem import LocalFilesystemBackend | ||
| from airflow.security import permissions | ||
| from airflow.stats import Stats | ||
| from airflow.timetables.base import DagRunInfo, DataInterval, TimeRestriction, Timetable | ||
|
|
@@ -2435,6 +2437,74 @@ def cli(self): | |
| args = parser.parse_args() | ||
| args.func(args, self) | ||
|
|
||
| @provide_session | ||
| def test( | ||
| self, | ||
| execution_date: datetime | None = None, | ||
| run_conf: dict[str, Any] | None = None, | ||
| conn_file_path: str | None = None, | ||
| variable_file_path: str | None = None, | ||
| session: Session = NEW_SESSION, | ||
| ) -> None: | ||
| """Execute one single DagRun for a given DAG and execution date.""" | ||
|
|
||
| def add_logger_if_needed(ti: TaskInstance): | ||
| """ | ||
| Add a formatted logger to the taskinstance so all logs are surfaced to the command line instead | ||
| of into a task file. Since this is a local test run, it is much better for the user to see logs | ||
| in the command line, rather than needing to search for a log file. | ||
| Args: | ||
| ti: The taskinstance that will receive a logger | ||
|
|
||
| """ | ||
| format = logging.Formatter("[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s") | ||
| handler = logging.StreamHandler(sys.stdout) | ||
| handler.level = logging.INFO | ||
| handler.setFormatter(format) | ||
| # only add log handler once | ||
| if not any(isinstance(h, logging.StreamHandler) for h in ti.log.handlers): | ||
| self.log.debug("Adding Streamhandler to taskinstance %s", ti.task_id) | ||
| ti.log.addHandler(handler) | ||
|
|
||
| if conn_file_path or variable_file_path: | ||
| local_secrets = LocalFilesystemBackend( | ||
| variables_file_path=variable_file_path, connections_file_path=conn_file_path | ||
| ) | ||
| secrets_backend_list.insert(0, local_secrets) | ||
|
|
||
| execution_date = execution_date or timezone.utcnow() | ||
| self.log.debug("Clearing existing task instances for execution date %s", execution_date) | ||
| self.clear( | ||
| start_date=execution_date, | ||
| end_date=execution_date, | ||
| dag_run_state=False, # type: ignore | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should fix
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, but maybe we can make that a different ticket and mark it
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would like to fix this issue. Could you explain me more about this @uranusjr @dimberman ? |
||
| session=session, | ||
| ) | ||
|
dimberman marked this conversation as resolved.
|
||
| self.log.debug("Getting dagrun for dag %s", self.dag_id) | ||
| dr: DagRun = _get_or_create_dagrun( | ||
| dag=self, | ||
| start_date=execution_date, | ||
| execution_date=execution_date, | ||
| run_id=DagRun.generate_run_id(DagRunType.MANUAL, execution_date), | ||
| session=session, | ||
| conf=run_conf, | ||
| ) | ||
|
|
||
| tasks = self.task_dict | ||
| self.log.debug("starting dagrun") | ||
| # Instead of starting a scheduler, we run the minimal loop possible to check | ||
| # for task readiness and dependency management. This is notably faster | ||
| # than creating a BackfillJob and allows us to surface logs to the user | ||
| while dr.state == State.RUNNING: | ||
| schedulable_tis, _ = dr.update_state(session=session) | ||
| for ti in schedulable_tis: | ||
| add_logger_if_needed(ti) | ||
| ti.task = tasks[ti.task_id] | ||
| _run_task(ti, session=session) | ||
|
Comment on lines
+2495
to
+2503
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if it’s a good idea to extract the logic in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel somewhat hesitant on that as the |
||
| if conn_file_path or variable_file_path: | ||
| # Remove the local variables we have added to the secrets_backend_list | ||
| secrets_backend_list.pop(0) | ||
|
|
||
| @provide_session | ||
| def create_dagrun( | ||
| self, | ||
|
|
@@ -3505,3 +3575,66 @@ def get_current_dag(cls) -> DAG | None: | |
| return cls._context_managed_dags[0] | ||
| except IndexError: | ||
| return None | ||
|
|
||
|
|
||
| def _run_task(ti: TaskInstance, session): | ||
| """ | ||
| Run a single task instance, and push result to Xcom for downstream tasks. Bypasses a lot of | ||
| extra steps used in `task.run` to keep our local running as fast as possible | ||
| This function is only meant for the `dag.test` function as a helper function. | ||
|
|
||
| Args: | ||
| ti: TaskInstance to run | ||
| """ | ||
| log.info("*****************************************************") | ||
| if ti.map_index > 0: | ||
| log.info("Running task %s index %d", ti.task_id, ti.map_index) | ||
| else: | ||
| log.info("Running task %s", ti.task_id) | ||
| try: | ||
| ti._run_raw_task(session=session) | ||
| session.flush() | ||
| log.info("%s ran successfully!", ti.task_id) | ||
| except AirflowSkipException: | ||
| log.info("Task Skipped, continuing") | ||
| log.info("*****************************************************") | ||
|
|
||
|
|
||
| def _get_or_create_dagrun( | ||
| dag: DAG, | ||
| conf: dict[Any, Any] | None, | ||
| start_date: datetime, | ||
| execution_date: datetime, | ||
| run_id: str, | ||
| session: Session, | ||
| ) -> DagRun: | ||
| """ | ||
| Create a DAGRun, but only after clearing the previous instance of said dagrun to prevent collisions. | ||
| This function is only meant for the `dag.test` function as a helper function. | ||
| :param dag: Dag to be used to find dagrun | ||
| :param conf: configuration to pass to newly created dagrun | ||
| :param start_date: start date of new dagrun, defaults to execution_date | ||
| :param execution_date: execution_date for finding the dagrun | ||
| :param run_id: run_id to pass to new dagrun | ||
| :param session: sqlalchemy session | ||
| :return: | ||
| """ | ||
| log.info("dagrun id: %s", dag.dag_id) | ||
| dr: DagRun = ( | ||
| session.query(DagRun) | ||
| .filter(DagRun.dag_id == dag.dag_id, DagRun.execution_date == execution_date) | ||
| .first() | ||
| ) | ||
| if dr: | ||
| session.delete(dr) | ||
| session.commit() | ||
|
Comment on lines
+3623
to
+3630
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It might make sense to create
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we foresee other use-cases to delete dagruns? FWIW I believe @ashb plans to look into a way for us to do this without ever actually touching the DB which would make this irrelevant in the long run. |
||
| dr = dag.create_dagrun( | ||
| state=DagRunState.RUNNING, | ||
| execution_date=execution_date, | ||
| run_id=run_id, | ||
| start_date=start_date or execution_date, | ||
| session=session, | ||
| conf=conf, # type: ignore | ||
| ) | ||
| log.info("created dagrun " + str(dr)) | ||
| return dr | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,11 +15,59 @@ | |
| specific language governing permissions and limitations | ||
| under the License. | ||
|
|
||
| Testing DAGs with dag.test() | ||
| ============================= | ||
|
|
||
| To debug DAGs in an IDE, you can set up the ``dag.test`` command in your dag file and run through your DAG in a single | ||
| serialized python process. | ||
|
|
||
| This approach can be used with any supported database (including a local SQLite database) and will | ||
| *fail fast* as all tasks run in a single process. | ||
|
|
||
| To set up ``dag.test``, add these two lines to the bottom of your dag file: | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| if __name__ == "__main__": | ||
| dag.test() | ||
|
|
||
| and that's it! You can add argument such as ``execution_date`` if you want to test argument-specific dagruns, but otherwise | ||
| you can run or debug DAGs as needed. | ||
|
|
||
| Comparison with DebugExecutor | ||
| ***************************** | ||
|
|
||
| The ``dag.test`` command has the following benefits over the :class:`~airflow.executors.debug_executor.DebugExecutor` | ||
| class, which is now deprecated: | ||
|
|
||
| 1. It does not require running an executor at all. Tasks are run one at a time with no executor or scheduler logs. | ||
| 2. It is significantly faster than running code with a DebugExecutor as it does not need to go through a scheduler loop. | ||
| 3. It does not perform a backfill. | ||
|
|
||
|
|
||
| Debugging Airflow DAGs on the command line | ||
| ========================================== | ||
|
|
||
| With the same two line addition as mentioned in the above section, you can now easily debug a DAG using pdb as well. | ||
| Run ``python -m pdb <path to dag file>.py`` for an interactive debugging experience on the command line. | ||
|
|
||
| .. code-block:: bash | ||
|
|
||
| root@ef2c84ad4856:/opt/airflow# python -m pdb airflow/example_dags/example_bash_operator.py | ||
| > /opt/airflow/airflow/example_dags/example_bash_operator.py(18)<module>() | ||
| -> """Example DAG demonstrating the usage of the BashOperator.""" | ||
| (Pdb) b 45 | ||
| Breakpoint 1 at /opt/airflow/airflow/example_dags/example_bash_operator.py:45 | ||
| (Pdb) c | ||
| > /opt/airflow/airflow/example_dags/example_bash_operator.py(45)<module>() | ||
| -> bash_command='echo 1', | ||
| (Pdb) run_this_last | ||
| <Task(EmptyOperator): run_this_last> | ||
|
|
||
| .. _executor:DebugExecutor: | ||
|
|
||
| Debug Executor | ||
| ================== | ||
| Debug Executor (deprecated) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this mean we want to remove DebugExecutor in Airflow 3?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 to @eladkal question. Currently AIP-47 system tests depend on the DebugExecutor:
So we'd need to do some migration there if we were to deprecate this executor (CC @potiuk @mnojek). I know @bhirsz has mentioned before they're using a different executor altogether for their system tests, maybe that's a more viable option. We should also add deprecation warnings if we plan to deprecate this executor. |
||
| =========================== | ||
|
|
||
| The :class:`~airflow.executors.debug_executor.DebugExecutor` is meant as | ||
| a debug tool and can be used from IDE. It is a single process executor that | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| ``airflow dags test`` no longer performs a backfill job. | ||
|
|
||
| In order to make ``airflow dags test`` more useful as a testing and debugging tool, we no | ||
| longer run a backfill job and instead run a "local task runner". Users can still backfill | ||
| their DAGs using the ``airflow dags backfill`` command. |
Uh oh!
There was an error while loading. Please reload this page.