This is an automated email from the ASF dual-hosted git repository.
hussein-awala 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 057f0099281 Make db clean --skip-archive delete rows directly (#74113)
057f0099281 is described below
commit 057f009928112df519ed6fc80f54d675857c4b9d
Author: safaehar <[email protected]>
AuthorDate: Tue Nov 10 09:16:53 2026 -0500
Make db clean --skip-archive delete rows directly (#74113)
* Delete db clean rows directly when skipping archive
* Keep db clean batch commits in delete loop
* Update db clean tests for current XCom storage
The XCom read projection no longer accepts writes after the storage split,
and direct deletion re-evaluates late reference guards without an archive
snapshot.
* Avoid repeated reflection during direct db cleanup
Large batched cleanups should not repeat schema introspection for a source
table that does not change between batches.
---
airflow-core/docs/howto/usage-cli.rst | 2 +-
airflow-core/src/airflow/cli/cli_config.py | 2 +-
airflow-core/src/airflow/utils/db_cleanup.py | 93 +++---
airflow-core/tests/unit/utils/test_db_cleanup.py | 348 +++++++++++++----------
4 files changed, 256 insertions(+), 189 deletions(-)
diff --git a/airflow-core/docs/howto/usage-cli.rst
b/airflow-core/docs/howto/usage-cli.rst
index 0a8ecf576a5..065f783c9f8 100644
--- a/airflow-core/docs/howto/usage-cli.rst
+++ b/airflow-core/docs/howto/usage-cli.rst
@@ -268,7 +268,7 @@ are rejected. The timestamp cutoff still applies, and any
Dag filters further re
airflow db clean --tables asset_event --asset-ids 1,2
--clean-before-timestamp '2024-01-01T00:00:00+00:00' --dry-run
-By default, ``db clean`` will archive purged rows in tables of the form
``_airflow_deleted__<table>__<timestamp>``. Cascading attempt data may use a
unique suffix instead of a timestamp. If you don't want the data preserved in
this way, you may supply argument ``--skip-archive``.
+By default, ``db clean`` will archive purged rows in tables of the form
``_airflow_deleted__<table>__<timestamp>``. Cascading attempt data may use a
unique suffix instead of a timestamp. If you supply ``--skip-archive``, the
command deletes rows directly from the source tables without creating archive
tables.
When you encounter an error without using ``--skip-archive``, an archive table
may remain in the DB. You can use ``db drop-archived`` to remove it. ``db
export-archived`` and ``db drop-archived`` also accept the pre-upgrade names
``xcom`` and ``task_instance_history`` to find older archives.
diff --git a/airflow-core/src/airflow/cli/cli_config.py
b/airflow-core/src/airflow/cli/cli_config.py
index 1ead45d21ea..61c7c46f21e 100644
--- a/airflow-core/src/airflow/cli/cli_config.py
+++ b/airflow-core/src/airflow/cli/cli_config.py
@@ -550,7 +550,7 @@ ARG_DB_DRY_RUN = Arg(
)
ARG_DB_SKIP_ARCHIVE = Arg(
("--skip-archive",),
- help="Don't preserve purged records in an archive table.",
+ help="Delete purged records directly without creating an archive table.",
action="store_true",
)
ARG_DB_EXPORT_FORMAT = Arg(
diff --git a/airflow-core/src/airflow/utils/db_cleanup.py
b/airflow-core/src/airflow/utils/db_cleanup.py
index 789f81cb513..dbf96159615 100644
--- a/airflow-core/src/airflow/utils/db_cleanup.py
+++ b/airflow-core/src/airflow/utils/db_cleanup.py
@@ -492,6 +492,7 @@ def _do_delete(
dialect_name = bind.dialect.name
batch_counter = itertools.count(1)
source_table_name = _format_table_name(orm_model.schema, orm_model.name)
+ direct_source_table: Table | None = None
attempt_archives: dict[str, Table] = {}
attempt_archive_suffix = uuid4().hex[:16]
@@ -508,6 +509,26 @@ def _do_delete(
else:
print(f"Performing Delete from {source_table_name}...")
+ if skip_archive:
+ try:
+ if direct_source_table is None:
+ metadata = reflect_tables([source_table_name], session)
+ direct_source_table = metadata.tables[source_table_name]
+ _delete_directly(
+ limited_query=limited_query,
+ source_table=direct_source_table,
+ session=session,
+ dialect_name=dialect_name,
+ )
+ session.commit()
+ except BaseException:
+ with suppress(Exception):
+ session.rollback()
+ raise
+ # The direct DELETE embeds limited_query, so skip_if_referenced is
re-evaluated in
+ # that statement. The short-delete warning below is only needed
after an archive copy.
+ continue
+
# using bulk delete
# create a new table and copy the rows there
timestamp_str = re.sub(r"[^\d]", "",
timezone.utcnow().isoformat())[:14]
@@ -516,11 +537,6 @@ def _do_delete(
f"{ARCHIVE_TABLE_PREFIX}{orm_model.name}__{timestamp_str}{suffix}",
)
print(f"Moving data from {source_table_name} to table
{target_table_name}")
- target_table = None
- # Lets the ``finally`` cleanup below tell the failure path (don't let a
- # cleanup error mask the original) from the success path (a cleanup
error
- # is a real problem and must propagate).
- error_raised = False
try:
if dialect_name == "mysql":
@@ -611,44 +627,49 @@ def _do_delete(
)
except BaseException:
- error_raised = True
- # Roll back the failed transaction so its locks are released before
- # the archive table is dropped in the ``finally`` block below.
- # ``rollback()`` itself can raise (e.g. the connection died);
suppress
- # it so it does not shadow the original error being re-raised.
with suppress(Exception):
session.rollback()
raise
- finally:
- if target_table is not None and skip_archive:
- # Drop the archive table on the session's own connection.
Binding
- # the drop to ``session.get_bind()`` (the Engine) would check
out a
- # *second* pooled connection, and on MySQL its ``DROP TABLE``
blocks
- # indefinitely on the metadata lock still held by this
session's
- # open transaction when the DELETE above failed -- the ``db
clean``
- # hang reported in #66177.
- try:
- target_table.drop(bind=session.connection())
- session.commit()
- except Exception:
- # If we are already unwinding from a delete failure, a
cleanup
- # error here must not replace the original exception
(Python
- # makes a ``finally``-raised error the top-level one). Log
and
- # let the original delete error keep propagating. On the
success
- # path (no delete error), a drop/commit failure is a real
- # problem, so re-raise it.
- if not error_raised:
- raise
- logger.warning(
- "Failed to drop archive table %s while cleaning up
after a "
- "delete failure; propagating the original delete error
instead.",
- target_table_name,
- exc_info=True,
- )
print(f"Finished Performing Delete from {source_table_name}")
+def _delete_directly(
+ *,
+ limited_query: Select,
+ source_table: Table,
+ session: Session,
+ dialect_name: str,
+) -> None:
+ pk_cols = list(source_table.primary_key.columns)
+ pk_query = limited_query.with_only_columns(
+ *[literal_column(f"{_BASE_TABLE_ALIAS}.{col.name}").label(col.name)
for col in pk_cols]
+ )
+ rows_to_delete = pk_query.subquery("rows_to_delete")
+
+ if dialect_name == "mysql":
+ delete = source_table.delete().where(
+ select(literal(1))
+ .select_from(rows_to_delete)
+ .where(and_(*[source_table.c[col.name] ==
rows_to_delete.c[col.name] for col in pk_cols]))
+ .exists()
+ )
+ elif len(pk_cols) == 1:
+ pk_col = pk_cols[0]
+ delete = source_table.delete().where(
+
source_table.c[pk_col.name].in_(select(rows_to_delete.c[pk_col.name]))
+ )
+ else:
+ delete = source_table.delete().where(
+ tuple_(*[source_table.c[col.name] for col in pk_cols]).in_(
+ select(*[rows_to_delete.c[col.name] for col in pk_cols])
+ )
+ )
+
+ logger.debug("direct delete statement:\n%s", delete.compile())
+ session.execute(delete)
+
+
def _subquery_keep_last(
*,
recency_column,
diff --git a/airflow-core/tests/unit/utils/test_db_cleanup.py
b/airflow-core/tests/unit/utils/test_db_cleanup.py
index 9202e7c2fdc..738d0c08465 100644
--- a/airflow-core/tests/unit/utils/test_db_cleanup.py
+++ b/airflow-core/tests/unit/utils/test_db_cleanup.py
@@ -20,11 +20,11 @@ from __future__ import annotations
import re
import threading
import time
-from contextlib import suppress
+from contextlib import contextmanager, suppress
from datetime import UTC, timedelta
from importlib import import_module
from io import StringIO
-from unittest.mock import MagicMock, call, mock_open, patch
+from unittest.mock import MagicMock, mock_open, patch
from uuid import uuid4
import pendulum
@@ -35,6 +35,7 @@ from sqlalchemy import (
Integer,
MetaData,
Table,
+ event,
func,
insert,
inspect,
@@ -65,8 +66,9 @@ from airflow.models.deadline import Deadline
from airflow.models.dynamic_region import DynamicRegion
from airflow.models.serialized_dag import SerializedDagModel
from airflow.models.task_state_store import TaskStateStoreModel
+from airflow.models.taskinstance import LegacyTaskDataOwner
from airflow.models.taskreschedule import TaskReschedule
-from airflow.models.xcom import XComModel, XComModelV2
+from airflow.models.xcom import XComModel, XComModelV1, XComModelV2
from airflow.providers.standard.operators.python import PythonOperator
from airflow.sdk.definitions.callback import AsyncCallback
from airflow.serialization.serialized_objects import LazyDeserializedDAG
@@ -113,6 +115,21 @@ from tests_common.test_utils.taskinstance import
create_task_instance
pytestmark = pytest.mark.db_test
+@contextmanager
+def capture_sql_statements(session):
+ statements = []
+ bind = session.get_bind()
+
+ def capture(_conn, _cursor, statement, _parameters, _context,
_executemany):
+ statements.append(statement)
+
+ event.listen(bind, "before_cursor_execute", capture)
+ try:
+ yield statements
+ finally:
+ event.remove(bind, "before_cursor_execute", capture)
+
+
@pytest.fixture(autouse=True)
def clean_database():
"""Fixture that cleans the database before and after every test."""
@@ -652,15 +669,14 @@ class TestDBCleanup:
clear_db_callbacks()
@pytest.mark.parametrize(
- ("skip_archive", "expected_archives"),
- [pytest.param(True, 0, id="skip_archive"), pytest.param(False, 1,
id="do_archive")],
+ ("skip_archive", "batch_size", "expected_archives"),
+ [
+ pytest.param(True, None, 0, id="skip_archive"),
+ pytest.param(True, 2, 0, id="skip_archive_batched"),
+ pytest.param(False, None, 1, id="do_archive"),
+ ],
)
- def test__skip_archive(self, skip_archive, expected_archives):
- """
- Verify that running cleanup_table with drops the archives when
requested.
-
- Archived tables from DB migration should be kept when skip_archive is
True.
- """
+ def test__skip_archive(self, skip_archive, batch_size, expected_archives,
capsys):
base_date = pendulum.DateTime(2022, 1, 1,
tzinfo=pendulum.timezone("UTC"))
num_tis = 10
create_tis(
@@ -672,14 +688,16 @@ class TestDBCleanup:
for name in _get_archived_table_names(["dag_run"], session):
session.execute(text(f"DROP TABLE IF EXISTS {name}"))
clean_before_date = base_date.add(days=5)
- _cleanup_table(
- **config_dict["dag_run"].__dict__,
- clean_before_timestamp=clean_before_date,
- dry_run=False,
- session=session,
- table_names=["dag_run"],
- skip_archive=skip_archive,
- )
+ with capture_sql_statements(session) as statements:
+ _cleanup_table(
+ **config_dict["dag_run"].__dict__,
+ clean_before_timestamp=clean_before_date,
+ dry_run=False,
+ session=session,
+ table_names=["dag_run"],
+ skip_archive=skip_archive,
+ batch_size=batch_size,
+ )
model = config_dict["dag_run"].orm_model
assert session.scalar(select(func.count()).select_from(model)) == 5
archives = [
@@ -689,6 +707,105 @@ class TestDBCleanup:
]
assert len(archives) == expected_archives
+ output = capsys.readouterr().out
+ archive_statements = [sql for sql in statements if
ARCHIVE_TABLE_PREFIX in sql]
+ if skip_archive:
+ assert archive_statements == []
+ assert "Moving data from dag_run to table" not in output
+ if batch_size:
+ assert "Performing Delete from dag_run (batch 3, max 2
rows)..." in output
+ else:
+ assert archive_statements
+ assert "Moving data from dag_run to table" in output
+
+ def test_skip_archive_keeps_latest_dag_run_per_dag(self):
+ base_date = pendulum.DateTime(2022, 1, 1,
tzinfo=pendulum.timezone("UTC"))
+ bundle_name = f"testing-{uuid4()}"
+ dag_ids = [f"test-dag-{uuid4()}", f"test-dag-{uuid4()}"]
+
+ with create_session() as session:
+ session.add(DagBundleModel(name=bundle_name))
+ session.flush()
+ for dag_id in dag_ids:
+ session.add(DagModel(dag_id=dag_id, bundle_name=bundle_name))
+ for day in range(3):
+ session.add(
+ DagRun(
+ dag_id,
+ run_id=f"{dag_id}-{day}",
+ run_type=DagRunType.SCHEDULED,
+ start_date=base_date.add(days=day),
+ )
+ )
+ session.commit()
+
+ _cleanup_table(
+ **config_dict["dag_run"].__dict__,
+ clean_before_timestamp=base_date.add(days=10),
+ dry_run=False,
+ session=session,
+ table_names=["dag_run"],
+ skip_archive=True,
+ )
+
+ remaining = session.execute(
+ select(DagRun.dag_id,
DagRun.start_date).where(DagRun.dag_id.in_(dag_ids))
+ ).all()
+
+ assert set(remaining) == {(dag_id, base_date.add(days=2)) for dag_id
in dag_ids}
+
+ @pytest.mark.parametrize(
+ "batch_size",
+ [pytest.param(None, id="unbatched"), pytest.param(2, id="batched")],
+ )
+ def test_skip_archive_deletes_xcom_rows_with_composite_primary_key(self,
batch_size):
+ base_date = pendulum.DateTime(2022, 1, 1,
tzinfo=pendulum.timezone("UTC"))
+ create_tis(base_date=base_date, num_tis=3)
+
+ with create_session() as session:
+ task_instances =
session.scalars(select(TaskInstance).order_by(TaskInstance.start_date)).all()
+ session.add_all(
+ LegacyTaskDataOwner(
+ dag_id=task_instance.dag_id,
+ task_id=task_instance.task_id,
+ run_id=task_instance.run_id,
+ map_index=task_instance.map_index,
+ task_instance_id=task_instance.id,
+ )
+ for task_instance in task_instances
+ )
+ session.flush()
+ for index, task_instance in enumerate(task_instances):
+ session.add(
+ XComModelV1(
+ dag_run_id=task_instance.dag_run.id,
+ task_id=task_instance.task_id,
+ map_index=task_instance.map_index,
+ key=f"key-{index}",
+ dag_id=task_instance.dag_id,
+ run_id=task_instance.run_id,
+ value=index,
+ timestamp=base_date.add(days=index),
+ )
+ )
+ session.commit()
+
+ _cleanup_table(
+ **config_dict["xcom_v1"].__dict__,
+ clean_before_timestamp=base_date.add(days=10),
+ dry_run=False,
+ session=session,
+ table_names=["xcom_v1"],
+ skip_archive=True,
+ batch_size=batch_size,
+ )
+
+ assert
session.scalar(select(func.count()).select_from(XComModelV1)) == 0
+ assert not any(
+ table_name.startswith(ARCHIVE_TABLE_PREFIX)
+ for table_name in _get_archived_table_names(["xcom_v1"],
session)
+ )
+
@pytest.mark.parametrize(
("batch_size", "clean_before_days", "expected_found_line",
"expected_delete_lines"),
[
@@ -852,28 +969,30 @@ class TestDBCleanup:
assert orphan_id not in remaining # old and unreferenced -> pruned
@pytest.mark.parametrize(
- ("extra_unreferenced", "expected_count"),
+ ("skip_archive", "extra_unreferenced", "expected_count"),
[
- pytest.param(0, "1 of 1", id="whole-batch-skipped"),
- pytest.param(1, "1 of 2", id="partial-skip"),
+ pytest.param(True, 0, None, id="direct-whole-batch-skipped"),
+ pytest.param(True, 1, None, id="direct-partial-skip"),
+ pytest.param(False, 0, "1 of 1", id="archive-whole-batch-skipped"),
+ pytest.param(False, 1, "1 of 2", id="archive-partial-skip"),
],
)
def test_do_delete_skip_if_referenced_guards_against_race(
- self, extra_unreferenced, expected_count, cap_structlog
+ self, skip_archive, extra_unreferenced, expected_count, cap_structlog
):
"""_do_delete must not issue a DELETE that violates an ON DELETE
RESTRICT FK.
Reproduces the real race: the dag_version row passes the SELECT filter
and is
- archived, and only then does a task_instance referencing it appear.
The
- skip_if_referenced guard on the DELETE must skip the row instead of
failing with
- IntegrityError, and the loop must still drain because the next SELECT
pass
- re-evaluates the same NOT EXISTS guard and excludes it.
+ selected, and only then does a task_instance referencing it appear. The
+ skip_if_referenced guard must skip the row instead of failing with
IntegrityError,
+ and the loop must still drain because the next SELECT pass
re-evaluates the same
+ NOT EXISTS guard and excludes it.
Parametrized over how many *unreferenced* rows share the pass, because
the guard
skipping part of a batch is not the same as it skipping all of it.
The archive has
already committed a copy of every row the SELECT found, so a skipped
row is both
- archived and still live and must be reported either way. The partial
case is the
- one a ``deleted == 0`` condition stays silent for.
+ archived and still live and must be reported either way. The
direct-delete path
+ instead re-evaluates the selection inside the DELETE and never copies
the row.
"""
from airflow.utils.db import reflect_tables
@@ -916,7 +1035,7 @@ class TestDBCleanup:
latest_id = latest.id
# Query built while nothing references raced_old, so the first
SELECT pass
- # returns it and _do_delete archives it.
+ # returns it.
cfg = config_dict["dag_version"]
query = _build_query(
**cfg.__dict__,
@@ -936,14 +1055,10 @@ class TestDBCleanup:
raced = False
def reflect_and_race(tables, session, **kwargs):
- # _do_delete reflects both source and target right after
committing the
- # archive CTAS and right before building the DELETE — this is
the only
- # seam between the two that fits the race window. MySQL
reflects the
- # target alone earlier in the same pass, so keying on the
two-table call
- # covers both branches. If this call site moves, the test
stops
- # reproducing the race and the ``raced`` assertion below will
catch it.
nonlocal raced
- if not raced and len(tables) == 2:
+ direct_delete_seam = skip_archive and tables == ["dag_version"]
+ archive_delete_seam = not skip_archive and len(tables) == 2
+ if not raced and (direct_delete_seam or archive_delete_seam):
raced = True
session.add_all([dag_run, ti])
session.commit()
@@ -953,7 +1068,7 @@ class TestDBCleanup:
_do_delete(
query=query,
orm_model=cfg.orm_model,
- skip_archive=True,
+ skip_archive=skip_archive,
session=session,
batch_size=None,
skip_if_referenced=cfg.skip_if_referenced,
@@ -966,12 +1081,13 @@ class TestDBCleanup:
assert raced_old_id in remaining, "dag_version referenced by a
task_instance must not be deleted"
assert latest_id in remaining, "the keep_last survivor must not be
deleted"
assert not unreferenced_ids & remaining, "unreferenced dag_versions
must still be deleted"
- # The count is what distinguishes a partial skip from a whole-batch
one, and the
- # partial case is what ``deleted == 0`` reported nothing for.
- assert {
- "event": re.compile(rf"{expected_count} rows from dag_version are
still referenced"),
- "log_level": "warning",
- } in cap_structlog, cap_structlog.entries
+ if expected_count is not None:
+ # The count distinguishes a partial skip from a whole-batch one.
The partial
+ # case is what a ``deleted == 0`` condition stays silent for.
+ assert {
+ "event": re.compile(rf"{expected_count} rows from dag_version
are still referenced"),
+ "log_level": "warning",
+ } in cap_structlog, cap_structlog.entries
def test_table_config_skip_if_referenced_requires_pk_column(self):
"""A misconfigured skip_if_referenced (pk not in columns) must fail
fast at construction."""
@@ -1026,24 +1142,35 @@ class TestDBCleanup:
assert "count(" not in compiled_query
session.execute.assert_not_called()
- def test_do_delete_rolls_back_before_drop_on_failure(self):
+ @patch("airflow.utils.db_cleanup.reflect_tables", autospec=True)
+ def test_do_delete_reflects_direct_source_once_across_batches(self,
reflect_tables_mock):
+ session = MagicMock(spec=Session)
+ session.get_bind.return_value.dialect.name = "sqlite"
+ session.scalars.return_value.one.side_effect = [True, True, False]
+ metadata, source_table, _, query = _build_do_delete_test_objects()
+ reflect_tables_mock.return_value = metadata
+
+ _do_delete(
+ query=query,
+ orm_model=source_table,
+ skip_archive=True,
+ session=session,
+ batch_size=100,
+ )
+
+ reflect_tables_mock.assert_called_once_with(["dag_version"], session)
+ assert session.commit.call_count == 2
+
+ def test_do_delete_directly_rolls_back_on_failure(self):
session = MagicMock(spec=Session)
session.get_bind.return_value.dialect.name = "mysql"
- session.connection.return_value = object()
- session.scalars.return_value.one.side_effect = [1, 0]
- tracker = MagicMock()
- tracker.attach_mock(session, "session")
+ session.scalars.return_value.one.return_value = 1
- metadata, source_table, target_table, query =
_build_do_delete_test_objects()
+ metadata, source_table, _target_table, query =
_build_do_delete_test_objects()
delete_failure = IntegrityError("DELETE FROM dag_version", {},
Exception("fk violation"))
- session.execute.side_effect = [None, None, delete_failure]
+ session.execute.side_effect = delete_failure
- with (
- patch("airflow.utils.db_cleanup.reflect_tables",
return_value=metadata),
- patch("airflow.utils.db_cleanup.timezone.utcnow",
return_value=_delete_test_timestamp()),
- patch.object(target_table, "drop") as drop_mock,
- ):
- tracker.attach_mock(drop_mock, "drop")
+ with patch("airflow.utils.db_cleanup.reflect_tables",
return_value=metadata):
with pytest.raises(IntegrityError) as exc_info:
_do_delete(
query=query,
@@ -1055,31 +1182,19 @@ class TestDBCleanup:
assert exc_info.value is delete_failure
session.rollback.assert_called_once_with()
- session.connection.assert_called_once_with()
assert session.get_bind.call_count == 1
- drop_mock.assert_called_once_with(bind=session.connection.return_value)
-
- rollback_call_index = tracker.mock_calls.index(call.session.rollback())
- drop_call_index =
tracker.mock_calls.index(call.drop(bind=session.connection.return_value))
- commit_call_index = tracker.mock_calls.index(call.session.commit(),
drop_call_index)
- assert rollback_call_index < drop_call_index < commit_call_index
def test_do_delete_propagates_original_error_when_rollback_fails(self):
session = MagicMock(spec=Session)
session.get_bind.return_value.dialect.name = "mysql"
- session.connection.return_value = object()
- session.scalars.return_value.one.side_effect = [1, 0]
+ session.scalars.return_value.one.return_value = 1
- metadata, source_table, target_table, query =
_build_do_delete_test_objects()
+ metadata, source_table, _target_table, query =
_build_do_delete_test_objects()
delete_failure = IntegrityError("DELETE FROM dag_version", {},
Exception("fk violation"))
- session.execute.side_effect = [None, None, delete_failure]
+ session.execute.side_effect = delete_failure
session.rollback.side_effect = OperationalError("ROLLBACK", {},
Exception("connection lost"))
- with (
- patch("airflow.utils.db_cleanup.reflect_tables",
return_value=metadata),
- patch("airflow.utils.db_cleanup.timezone.utcnow",
return_value=_delete_test_timestamp()),
- patch.object(target_table, "drop") as drop_mock,
- ):
+ with patch("airflow.utils.db_cleanup.reflect_tables",
return_value=metadata):
with pytest.raises(IntegrityError) as exc_info:
_do_delete(
query=query,
@@ -1091,25 +1206,25 @@ class TestDBCleanup:
assert exc_info.value is delete_failure
session.rollback.assert_called_once_with()
- drop_mock.assert_called_once_with(bind=session.connection.return_value)
@pytest.mark.parametrize(
("skip_archive", "expected_commit_count"),
- [pytest.param(True, 3, id="skip_archive"), pytest.param(False, 2,
id="keep_archive")],
+ [pytest.param(True, 1, id="skip_archive"), pytest.param(False, 2,
id="keep_archive")],
)
def test_do_delete_success_does_not_call_rollback(self, skip_archive,
expected_commit_count):
session = MagicMock(spec=Session)
session.get_bind.return_value.dialect.name = "mysql"
- session.connection.return_value = object()
session.scalars.return_value.one.side_effect = [1, 0]
- session.execute.side_effect = [None, None, MagicMock(rowcount=1)]
+ if skip_archive:
+ session.execute.side_effect = [MagicMock(rowcount=1)]
+ else:
+ session.execute.side_effect = [None, None, MagicMock(rowcount=1)]
- metadata, source_table, target_table, query =
_build_do_delete_test_objects()
+ metadata, source_table, _target_table, query =
_build_do_delete_test_objects()
with (
patch("airflow.utils.db_cleanup.reflect_tables",
return_value=metadata),
patch("airflow.utils.db_cleanup.timezone.utcnow",
return_value=_delete_test_timestamp()),
- patch.object(target_table, "drop") as drop_mock,
):
_do_delete(
query=query,
@@ -1121,79 +1236,10 @@ class TestDBCleanup:
session.rollback.assert_not_called()
assert session.commit.call_count == expected_commit_count
- if skip_archive:
- session.connection.assert_called_once_with()
-
drop_mock.assert_called_once_with(bind=session.connection.return_value)
- else:
- session.connection.assert_not_called()
- drop_mock.assert_not_called()
+ session.connection.assert_not_called()
- def test_do_delete_original_error_survives_archive_drop_failure(self):
- """On the failure path, a drop/commit error in the finally block must
not
- replace the original delete error (nailo2c review, #66296)."""
- session = MagicMock(spec=Session)
- session.get_bind.return_value.dialect.name = "mysql"
- session.connection.return_value = object()
- session.scalars.return_value.one.side_effect = [1, 0]
-
- metadata, source_table, target_table, query =
_build_do_delete_test_objects()
- delete_failure = IntegrityError("DELETE FROM dag_version", {},
Exception("fk violation"))
- session.execute.side_effect = [None, None, delete_failure]
- drop_failure = OperationalError("DROP TABLE", {}, Exception("server
has gone away"))
-
- with (
- patch("airflow.utils.db_cleanup.reflect_tables",
return_value=metadata),
- patch("airflow.utils.db_cleanup.timezone.utcnow",
return_value=_delete_test_timestamp()),
- patch.object(target_table, "drop", side_effect=drop_failure) as
drop_mock,
- ):
- with pytest.raises(IntegrityError) as exc_info:
- _do_delete(
- query=query,
- orm_model=source_table,
- skip_archive=True,
- session=session,
- batch_size=None,
- )
-
- assert exc_info.value is delete_failure
- drop_mock.assert_called_once_with(bind=session.connection.return_value)
-
- def test_do_delete_success_propagates_archive_drop_error(self):
- """On the success path, a drop/commit failure is a real error and must
- still surface (the failure-path guard must not swallow it)."""
- session = MagicMock(spec=Session)
- session.get_bind.return_value.dialect.name = "mysql"
- session.connection.return_value = object()
- session.scalars.return_value.one.side_effect = [1, 0]
- session.execute.side_effect = [None, None, MagicMock(rowcount=1)]
-
- metadata, source_table, target_table, query =
_build_do_delete_test_objects()
- drop_failure = OperationalError("DROP TABLE", {}, Exception("disk
full"))
-
- with (
- patch("airflow.utils.db_cleanup.reflect_tables",
return_value=metadata),
- patch("airflow.utils.db_cleanup.timezone.utcnow",
return_value=_delete_test_timestamp()),
- patch.object(target_table, "drop", side_effect=drop_failure),
- ):
- with pytest.raises(OperationalError) as exc_info:
- _do_delete(
- query=query,
- orm_model=source_table,
- skip_archive=True,
- session=session,
- batch_size=None,
- )
-
- assert exc_info.value is drop_failure
- session.rollback.assert_not_called()
-
- @patch("airflow.utils.db.reflect_tables")
- def test_skip_archive_failure_will_remove_table(self, reflect_tables_mock):
- """
- Verify that running cleanup_table with skip_archive = True, and
failure happens.
-
- The archive table should be removed from db if any exception.
- """
+ @patch("airflow.utils.db_cleanup.reflect_tables")
+ def test_skip_archive_failure_does_not_create_archive_table(self,
reflect_tables_mock):
reflect_tables_mock.side_effect = SQLAlchemyError("Deletion failed")
base_date = pendulum.DateTime(2022, 1, 1,
tzinfo=pendulum.timezone("UTC"))
num_tis = 10