Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
e28f0e0
Create a more efficient command that also has better local logging
Sep 13, 2022
3a63377
nit
Sep 13, 2022
47c3ac3
logging cleanup
Sep 14, 2022
dc7850a
skip exception
Sep 14, 2022
d35be7d
fix
Sep 14, 2022
39bd327
lint
Sep 14, 2022
75e0a4d
more lint
Sep 14, 2022
f2df9cf
Merge branch 'main' of https://github.com/astronomer/airflow into imp…
Sep 14, 2022
f4020f8
simpler
Sep 14, 2022
ffe878c
Update airflow/cli/commands/dag_command.py
dimberman Sep 14, 2022
7d5d5b3
Update airflow/cli/commands/dag_command.py
dimberman Sep 14, 2022
d5b2cbc
typing
Sep 14, 2022
87cdcb0
Update airflow/cli/commands/dag_command.py
dimberman Sep 14, 2022
f46b636
add to dag.cli
Sep 14, 2022
be3f821
mrege wtih main
Sep 14, 2022
c6f1f1b
python <dag>.py support
Sep 14, 2022
fbce1cb
python <dag>.py support
Sep 14, 2022
4c34e79
fix tests
Sep 15, 2022
21c4c60
fix tests
Sep 15, 2022
6526aef
properly add handler
Sep 15, 2022
55bc945
Update airflow/models/dag.py
dimberman Sep 15, 2022
62cdf52
Update airflow/models/dag.py
dimberman Sep 15, 2022
13b5e39
add docs
Sep 15, 2022
d9b1d67
Apply suggestions from code review
dimberman Sep 15, 2022
55fc7b8
doc fix
Sep 15, 2022
fb527b7
add docstring
Sep 16, 2022
10995bc
add release note
Sep 19, 2022
e255dc7
add release note
Sep 19, 2022
b885b90
add release note
Sep 19, 2022
9d8b70e
Apply suggestions from code review
dimberman Sep 20, 2022
794c680
ability to add connections file
Sep 20, 2022
3a16074
remove unneeded db call
Sep 20, 2022
ae531cb
add some basic tests
Sep 20, 2022
ee246be
add dependency check mapped
Sep 20, 2022
49763d2
add dependency check mapped
Sep 20, 2022
a2aa803
Merge branch 'main' of https://github.com/astronomer/airflow into imp…
Sep 20, 2022
a65e290
Apply suggestions from code review
dimberman Sep 21, 2022
bc5b9a5
Apply suggestions from code review
dimberman Sep 21, 2022
d2a1256
Merge branch 'main' of https://github.com/astronomer/airflow into imp…
Sep 21, 2022
2b5dd8e
add dependency check mapped
Sep 21, 2022
b09365f
Merge branch 'main' of https://github.com/astronomer/airflow into imp…
Sep 21, 2022
ffb2b87
Merge branch 'main' of https://github.com/astronomer/airflow into imp…
Sep 22, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion airflow/cli/cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2035,7 +2035,7 @@ def _remove_dag_id_opt(command: ActionCommand):
subcommands=[
_remove_dag_id_opt(sp)
for sp in DAGS_COMMANDS
if sp.name in ['backfill', 'list-runs', 'pause', 'unpause']
if sp.name in ['backfill', 'list-runs', 'pause', 'unpause', 'test']
],
),
GroupCommand(
Expand Down
23 changes: 4 additions & 19 deletions airflow/cli/commands/dag_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@
from airflow.api.client import get_current_api_client
from airflow.cli.simple_table import AirflowConsole
from airflow.configuration import conf
from airflow.exceptions import AirflowException, BackfillUnfinished, RemovedInAirflow3Warning
from airflow.executors.debug_executor import DebugExecutor
from airflow.exceptions import AirflowException, RemovedInAirflow3Warning
from airflow.jobs.base_job import BaseJob
from airflow.models import DagBag, DagModel, DagRun, TaskInstance
from airflow.models.dag import DAG
Expand Down Expand Up @@ -449,7 +448,7 @@ def dag_list_dag_runs(args, dag=None, session=NEW_SESSION):

@provide_session
@cli_utils.action_cli
def dag_test(args, session=None):
def dag_test(args, dag=None, session=None):
"""Execute one single DagRun for a given DAG and execution date, using the DebugExecutor."""
run_conf = None
if args.conf:
Expand All @@ -458,22 +457,8 @@ def dag_test(args, session=None):
except ValueError as e:
raise SystemExit(f"Configuration {args.conf!r} is not valid JSON. Error: {e}")
execution_date = args.execution_date or timezone.utcnow()
dag = get_dag(subdir=args.subdir, dag_id=args.dag_id)
dag.clear(start_date=execution_date, end_date=execution_date, dag_run_state=False)
try:
dag.run(
executor=DebugExecutor(),
start_date=execution_date,
end_date=execution_date,
conf=run_conf,
# Always run the DAG at least once even if no logical runs are
# available. This does not make a lot of sense, but Airflow has
# been doing this prior to 2.2 so we keep compatibility.
run_at_least_once=True,
)
except BackfillUnfinished as e:
print(str(e))

dag = dag or get_dag(subdir=args.subdir, dag_id=args.dag_id)
dag.test(execution_date=execution_date, run_conf=run_conf, session=session)
show_dagrun = args.show_dagrun
imgcat = args.imgcat_dagrun
filename = args.save_dagrun
Expand Down
2 changes: 1 addition & 1 deletion airflow/example_dags/example_bash_operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,4 @@
this_will_skip >> run_this_last

if __name__ == "__main__":
dag.cli()
dag.test()
135 changes: 134 additions & 1 deletion airflow/models/dag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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):
Comment thread
dimberman marked this conversation as resolved.
"""
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should fix clear’s annotation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 good_for_beginners?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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,
)
Comment thread
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 SchedulerJob._schedule_dag_run() (which this loop replicates).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel somewhat hesitant on that as the _schedule_dag_run seems to be much more interested in scheduling than it is in actually running. We'd need to cut at some potentially awkward places to ensure that tasks are run instead of just scheduled to run. I also think that would involve touching some very core code in the scheduler unnecessarily. @ashb WDYT?

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,
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might make sense to create _delete_dagrun for that. Mainly because of the low-level session calls imo. WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
52 changes: 50 additions & 2 deletions docs/apache-airflow/executor/debug.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean we want to remove DebugExecutor in Airflow 3?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to @eladkal question.
I stumbled across this while working on something entirely different. I'm very curious to have this discussion as well.

Currently AIP-47 system tests depend on the DebugExecutor:

The tests should be structured in the way that they are easy to run as “standalone” tests manually but they should also nicely be integrated into pytest test execution environment. This can be achieved by leveraging the DebugExecutor and utilising modern pytest test discovery mechanism

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
Expand Down
5 changes: 5 additions & 0 deletions newsfragments/26400.significant.rst
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.
Loading