This is an automated email from the ASF dual-hosted git repository.

Lee-W pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 1ff21149246 Reject inverted date windows in airflow partitions clear 
(#69454)
1ff21149246 is described below

commit 1ff21149246aced7c681e0f0daa7d9833278557a
Author: Wei Lee <[email protected]>
AuthorDate: Tue Jul 7 21:35:43 2026 +0800

    Reject inverted date windows in airflow partitions clear (#69454)
---
 .../src/airflow/cli/commands/partition_command.py  |  29 ++++-
 .../unit/cli/commands/test_partition_command.py    | 136 +++++++++++++++++++++
 2 files changed, 160 insertions(+), 5 deletions(-)

diff --git a/airflow-core/src/airflow/cli/commands/partition_command.py 
b/airflow-core/src/airflow/cli/commands/partition_command.py
index c948e7f4b16..13443c42490 100644
--- a/airflow-core/src/airflow/cli/commands/partition_command.py
+++ b/airflow-core/src/airflow/cli/commands/partition_command.py
@@ -44,7 +44,9 @@ def clear(args, *, session: Session = NEW_SESSION) -> None:
     the matching partitions; a date-only value (no time) is treated as local
     midnight.
     """
-    has_range = args.start_date is not None or args.end_date is not None or 
args.date is not None
+    has_start = args.start_date is not None
+    has_end = args.end_date is not None
+    has_range = has_start or has_end or args.date is not None
     selectors_used = sum([args.run_id is not None, args.partition_key is not 
None, has_range])
     if selectors_used != 1:
         raise SystemExit(
@@ -52,8 +54,10 @@ def clear(args, *, session: Session = NEW_SESSION) -> None:
             "(--start-date/--end-date or --date)."
         )
 
-    if args.date is not None:
-        if args.start_date is not None or args.end_date is not None:
+    from_date_flag = args.date is not None
+
+    if from_date_flag:
+        if has_start or has_end:
             raise SystemExit("--date cannot be combined with --start-date / 
--end-date.")
         raw = args.date
         parts = raw.split("~", 1)
@@ -64,9 +68,24 @@ def clear(args, *, session: Session = NEW_SESSION) -> None:
             args.end_date = parsedate(parts[1].strip())
         except ValueError:
             raise SystemExit("--date sides must be parseable as a date or 
datetime.")
+        has_start = has_end = True
+
+    has_date_window = has_start or has_end
+    if has_date_window:
+        dag = get_db_dag(bundle_names=None, dag_id=args.dag_id)
+        if has_start and has_end:
+            lower = dag.timetable.localize_partition_datetime(args.start_date)
+            upper = dag.timetable.localize_partition_datetime(args.end_date)
+            if lower > upper:
+                if from_date_flag:
+                    raise SystemExit(
+                        f"--date: the start of the range 
({parts[0].strip()!r}) must be on or before "
+                        f"the end ({parts[1].strip()!r})."
+                    )
+                raise SystemExit("--start-date must be on or before 
--end-date.")
+    else:
+        dag = None
 
-    has_date_window = args.start_date is not None or args.end_date is not None
-    dag = get_db_dag(bundle_names=None, dag_id=args.dag_id) if has_date_window 
else None
     clear_tis = bool(args.clear_task_instances)
 
     processed_any = False
diff --git a/airflow-core/tests/unit/cli/commands/test_partition_command.py 
b/airflow-core/tests/unit/cli/commands/test_partition_command.py
index efcbc9afe5f..7892c5bd74d 100644
--- a/airflow-core/tests/unit/cli/commands/test_partition_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_partition_command.py
@@ -760,6 +760,142 @@ class TestPartitionsClear:
             )
         assert "--date must be in the form 'a~b'" in str(excinfo.value.code)
 
+    @pytest.mark.parametrize(
+        ("cli_args", "expected_message"),
+        [
+            (
+                ["--start-date", "2026-01-03", "--end-date", "2026-01-01"],
+                "--start-date must be on or before --end-date.",
+            ),
+            (
+                ["--date", "2026-01-03~2026-01-01"],
+                "--date: the start of the range ('2026-01-03') must be on or 
before the end ('2026-01-01').",
+            ),
+        ],
+    )
+    def test_inverted_date_window_is_rejected(self, parser, cli_args, 
expected_message):
+        with pytest.raises(SystemExit) as excinfo:
+            partition_command.clear(parser.parse_args(["partitions", "clear", 
"--dag-id", DAG_ID, *cli_args]))
+        assert excinfo.value.code == expected_message
+
+    def test_equal_start_and_end_date_is_accepted(self, parser, capsys):
+        partition_command.clear(
+            parser.parse_args(
+                [
+                    "partitions",
+                    "clear",
+                    "--dag-id",
+                    DAG_ID,
+                    "--start-date",
+                    "2026-01-02",
+                    "--end-date",
+                    "2026-01-02",
+                ]
+            )
+        )
+        captured = capsys.readouterr()
+        assert captured.out == (
+            "DagRun part_run_2: partition_key='2026-01-02T00:00:00' -> None, "
+            "partition_date=2026-01-02T00:00:00+00:00 -> None\n"
+            "Cleared partition fields on 1 DagRun(s).\n"
+        )
+
+    def test_offset_aware_window_false_rejection_is_accepted(self, parser, 
dag_maker):
+        """Absolute-instant order disagrees with Asia/Taipei-localized 
wall-clock order.
+
+        --start-date 2026-01-01T00:00:00+00:00 (instant Jan1 00:00Z) is 
*after* the
+        instant of --end-date 2026-01-01T01:00:00+10:00 (Dec31 15:00Z), so a 
raw
+        instant comparison would wrongly flag this as an inverted window. Once 
each
+        bound's wall-clock reading is localized to the Dag's Asia/Taipei 
timetable
+        timezone (matching downstream ``apply_partition_date_window``), the 
window
+        becomes [Dec31 16:00Z, Dec31 17:00Z) -- lower before upper, valid -- 
and the
+        run seeded inside it must be cleared.
+        """
+        dag_id = "dag_taipei_offset_false_rejection"
+        clear_db_runs()
+        clear_db_dags()
+        with dag_maker(
+            dag_id,
+            schedule=CronPartitionTimetable("0 0 * * *", 
timezone="Asia/Taipei"),
+            start_date=datetime(2025, 1, 1, tzinfo=pendulum.UTC),
+            catchup=True,
+            serialized=True,
+        ):
+            EmptyOperator(task_id="t1")
+        dag_maker.create_dagrun(
+            run_id="in_window_run",
+            state=DagRunState.SUCCESS,
+            logical_date=None,
+            partition_date=datetime(2025, 12, 31, 16, 30, 0, 
tzinfo=pendulum.UTC),
+            partition_key="in-window",
+        )
+        dag_maker.sync_dagbag_to_db()
+
+        partition_command.clear(
+            parser.parse_args(
+                [
+                    "partitions",
+                    "clear",
+                    "--dag-id",
+                    dag_id,
+                    "--start-date",
+                    "2026-01-01T00:00:00+00:00",
+                    "--end-date",
+                    "2026-01-01T01:00:00+10:00",
+                ]
+            )
+        )
+
+        run = _get_run("in_window_run")
+        assert run.partition_key is None
+        assert run.partition_date is None
+
+        clear_db_runs()
+        clear_db_dags()
+
+    def test_offset_aware_window_false_acceptance_is_rejected(self, parser, 
dag_maker):
+        """Absolute-instant order disagrees with Asia/Taipei-localized 
wall-clock order.
+
+        --start-date 2026-01-01T05:00:00+00:00 (instant Jan1 05:00Z) is 
*before* the
+        instant of --end-date 2026-01-01T04:00:00-10:00 (Jan1 14:00Z), so a raw
+        instant comparison would wrongly accept this window. Once each bound's
+        wall-clock reading is localized to the Dag's Asia/Taipei timetable 
timezone,
+        the window is [Dec31 21:00Z, Dec31 20:00Z) -- lower after upper, 
inverted --
+        and clear() must reject it with the same message as the UTC-only case.
+        """
+        dag_id = "dag_taipei_offset_false_acceptance"
+        clear_db_runs()
+        clear_db_dags()
+        with dag_maker(
+            dag_id,
+            schedule=CronPartitionTimetable("0 0 * * *", 
timezone="Asia/Taipei"),
+            start_date=datetime(2025, 1, 1, tzinfo=pendulum.UTC),
+            catchup=True,
+            serialized=True,
+        ):
+            EmptyOperator(task_id="t1")
+        dag_maker.sync_dagbag_to_db()
+
+        with pytest.raises(SystemExit) as excinfo:
+            partition_command.clear(
+                parser.parse_args(
+                    [
+                        "partitions",
+                        "clear",
+                        "--dag-id",
+                        dag_id,
+                        "--start-date",
+                        "2026-01-01T05:00:00+00:00",
+                        "--end-date",
+                        "2026-01-01T04:00:00-10:00",
+                    ]
+                )
+            )
+        assert excinfo.value.code == "--start-date must be on or before 
--end-date."
+
+        clear_db_runs()
+        clear_db_dags()
+
     def test_date_range_syntax_mutually_exclusive_with_start_end(self, parser):
         with pytest.raises(SystemExit) as excinfo:
             partition_command.clear(

Reply via email to