Repository: apache/airflow

Issue:
CLI: `airflow dags test { dag w/ schedule_interval=None } ` error: "No run dates were found"
### Apache Airflow version

2.2.0b2 (beta snapshot)

### Operating System

ubuntu 20.04

### Versions of Apache Airflow Providers

n/a

### Deployment

Virtualenv installation

### Deployment details

pip install /path/to/airflow/src

### What happened

Given any DAG initialized with: `schedule_interval=None`

Run `airflow dags test mydagname $(date +%Y-%m-%d)` and get an error:

```
INFO - No run dates were found for the given dates and dag interval.
```

This behavior changed in https://github.com/apache/airflow/pull/15397, it used to trigger a backfill dagrun at the given date.

### What you expected to happen

I expected a backfill dagrun with the given date, regardless of whether it fit into the `schedule_interval`.

If AIP-39 made that an unrealistic expectation, then I'd hope for some way to define unscheduled dags which can still be tested from the command line (which, so far as I know, is the fastest way to iterate on a DAG.).

As it is, I keep changing `schedule_interval` back and forth depending on whether I want to iterate via `astro dev start` (which tolerates `None` but does superfluous work if the dag is scheduled) or via `airflow dags test ...` (which doesn't tolerate `None`).

### How to reproduce

Initialize a DAG with: `schedule_interval=None` and run it via `airflow dags test mydagname $(date +%Y-%m-%d)`

### Anything else

_No response_

### Are you willing to submit PR?

- [ ] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:
I had filed this issue in the wrong repo previously (oops).  Here's a comment about it from @uranusjr :


> Hmm, this is some weird inherited design. So airflow dag test triggers backfill runs (in this instance one backfill run on start_date) but ignores all the scheduling limitations specifies by the DAG. It schedules backfills even for a None schedule (which logically never backfills), and ignore start_date, end_date, etc. The timetable implementation unified the scheduling logic and causes a None schedule to never backfill, and a backfill to never happen outside of the DAG and its tasks’ start_date and end_date.
> 
> I think we have a design decision to make. We could stick to the current logic and change airflow dag test to trigger manual runs instead, but that’s affect the behaviour of DAGs that have a schedule since now the backfilled time to run more than once (in the previous implementation, if a date was run by airflow dag test, the scheduler won’t schedule a backfill at that time again). Or we need to add some additional logic to the timetable protocol (and users’ custom timetables) to account for this “hey I know this timetable don’t schedule backfills during this time but please give me a backfilling schedule anyway” logic.
Some additional unorganised thoughts:

1. It’s possible to match the pre-AIP-39 behaviour.
2. But I am not very keen to do it and would prefer other possibilities if possible because the previous design really does not make sense.
3. For this specific use case, changing the test runs to manual would be sufficient. But that may cause issues for DAGs that do have a backfilling schedule (changing the type to manual would result in duplicated runs, one backfilled and one manual).
4. Do we really need `airflow dags test` now since we can do `airflow dags trigger -e`? This has clearer semantic (obviously manual), and `airflow backfill -s ... -e ...` is available if you want backfill runs instead. Maybe we can match the old behaviour and deprecate the entire command instead.
regarding (4), the trouble with using those commands as a substitute for `airflow dags test` is that they don't show you the task logs, so they're not especially useful if your dev workflow looks like:

- edit mydag.py
- `airflow dags test mydag`
- look at task logs
- edit some more

Also, they require that you have a scheduler running, which is a small step, but still an extra setup/teardown to keep track of.
That’s a good point. It’s difficult to have the same behaviour the same for `trigger` right now (needs some refactoring), so if this is an important feature I’m inclined to restore the previous behaviour for 2.2 and plan for a deprecation/unification later. (I’m still not entirely convinced we should cover `schedule_interval=None` though, it really feels weird).
cc @ashb for thoughts.

Resolving patch:
diff --git a/airflow/cli/commands/dag_command.py b/airflow/cli/commands/dag_command.py
--- a/airflow/cli/commands/dag_command.py
+++ b/airflow/cli/commands/dag_command.py
@@ -408,7 +408,15 @@ def dag_test(args, session=None):
     dag = get_dag(subdir=args.subdir, dag_id=args.dag_id)
     dag.clear(start_date=args.execution_date, end_date=args.execution_date, dag_run_state=State.NONE)
     try:
-        dag.run(executor=DebugExecutor(), start_date=args.execution_date, end_date=args.execution_date)
+        dag.run(
+            executor=DebugExecutor(),
+            start_date=args.execution_date,
+            end_date=args.execution_date,
+            # 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))
 
diff --git a/airflow/jobs/backfill_job.py b/airflow/jobs/backfill_job.py
--- a/airflow/jobs/backfill_job.py
+++ b/airflow/jobs/backfill_job.py
@@ -137,6 +137,7 @@ def __init__(
         conf=None,
         rerun_failed_tasks=False,
         run_backwards=False,
+        run_at_least_once=False,
         *args,
         **kwargs,
     ):
@@ -167,6 +168,9 @@ def __init__(
         :type rerun_failed_tasks: bool
         :param run_backwards: Whether to process the dates from most to least recent
         :type run_backwards bool
+        :param run_at_least_once: If true, always run the DAG at least once even
+            if no logical run exists within the time range.
+        :type: bool
         :param args:
         :param kwargs:
         """
@@ -184,6 +188,7 @@ def __init__(
         self.conf = conf
         self.rerun_failed_tasks = rerun_failed_tasks
         self.run_backwards = run_backwards
+        self.run_at_least_once = run_at_least_once
         super().__init__(*args, **kwargs)
 
     @provide_session
@@ -778,10 +783,11 @@ def _execute(self, session=None):
                 )
             dagrun_infos = dagrun_infos[::-1]
 
-        dagrun_info_count = len(dagrun_infos)
-        if dagrun_info_count == 0:
-            self.log.info("No run dates were found for the given dates and dag interval.")
-            return
+        if not dagrun_infos:
+            if not self.run_at_least_once:
+                self.log.info("No run dates were found for the given dates and dag interval.")
+                return
+            dagrun_infos = [DagRunInfo.interval(dagrun_start_date, dagrun_end_date)]
 
         # picklin'
         pickle_id = None
@@ -800,7 +806,7 @@ def _execute(self, session=None):
         executor.job_id = "backfill"
         executor.start()
 
-        ti_status.total_runs = dagrun_info_count  # total dag runs in backfill
+        ti_status.total_runs = len(dagrun_infos)  # total dag runs in backfill
 
         try:
             remaining_dates = ti_status.total_runs
diff --git a/airflow/models/dag.py b/airflow/models/dag.py
--- a/airflow/models/dag.py
+++ b/airflow/models/dag.py
@@ -2184,6 +2184,7 @@ def run(
         conf=None,
         rerun_failed_tasks=False,
         run_backwards=False,
+        run_at_least_once=False,
     ):
         """
         Runs the DAG.
@@ -2218,7 +2219,9 @@ def run(
         :type: bool
         :param run_backwards:
         :type: bool
-
+        :param run_at_least_once: If true, always run the DAG at least once even
+            if no logical run exists within the time range.
+        :type: bool
         """
         from airflow.jobs.backfill_job import BackfillJob
 
@@ -2245,6 +2248,7 @@ def run(
             conf=conf,
             rerun_failed_tasks=rerun_failed_tasks,
             run_backwards=run_backwards,
+            run_at_least_once=run_at_least_once,
         )
         job.run()
 


Test patch:
diff --git a/tests/cli/commands/test_dag_command.py b/tests/cli/commands/test_dag_command.py
--- a/tests/cli/commands/test_dag_command.py
+++ b/tests/cli/commands/test_dag_command.py
@@ -513,6 +513,7 @@ def test_dag_test(self, mock_get_dag, mock_executor):
                     executor=mock_executor.return_value,
                     start_date=cli_args.execution_date,
                     end_date=cli_args.execution_date,
+                    run_at_least_once=True,
                 ),
             ]
         )
@@ -543,6 +544,7 @@ def test_dag_test_show_dag(self, mock_get_dag, mock_executor, mock_render_dag):
                     executor=mock_executor.return_value,
                     start_date=cli_args.execution_date,
                     end_date=cli_args.execution_date,
+                    run_at_least_once=True,
                 ),
             ]
         )