ferruzzi commented on code in PR #66350:
URL: https://github.com/apache/airflow/pull/66350#discussion_r4039095321


##########
airflow-core/src/airflow/utils/db_cleanup.py:
##########
@@ -392,10 +417,31 @@ def _do_delete(
                 delete = source_table.delete().where(
                     and_(*[col == target_table.c[col.name] for col in 
source_table.primary_key.columns])
                 )
+            # Re-apply skip_if_referenced on the DELETE to guard against a 
race where a new
+            # referencing row is created after the archive INSERT committed 
but before the DELETE
+            # runs. Without this the DELETE would violate the ON DELETE 
RESTRICT FK and fail.
+            if skip_if_referenced:
+                pk_col = source_table.c[referenced_pk_column]
+                for referencing_table_name, fk_column in skip_if_referenced:
+                    referencing = table(referencing_table_name, 
column(fk_column))
+                    delete = delete.where(
+                        ~select(literal(1))
+                        .select_from(referencing)
+                        .where(referencing.c[fk_column] == pk_col)
+                        .correlate(source_table)
+                        .exists()
+                    )
             logger.debug("delete statement:\n%s", delete.compile())
-            session.execute(delete)
+            deleted = session.execute(delete).rowcount
             session.commit()
 
+            # A guarded DELETE (skip_if_referenced) may affect fewer rows than 
the SELECT
+            # saw. If it affects zero, the next SELECT would return the same 
rows and we
+            # would archive them forever, so break out. This also caps the 
loop in any
+            # edge case where the DELETE unexpectedly removes nothing.
+            if deleted == 0:
+                break

Review Comment:
   Your 11 Sep version had the sequence right, but it passed 
`select(archive_table)`, which doesn't have a NOT EXISTS guard.  That's why 
`continue` hung.  The calling code always runs a guarded query built by 
`_build_query`, so a skipped row drops out of the next pass, but an unguarded 
SELECT keeps returning that row forever.  Switching to `_build_query` fixed the 
hang, but now the first pass returns zero rows, the DELETE is never reached, 
and the guard isn't actually hit int he test.  It needs both: the real 
`_build_query` query and the TI inserted mid-pass rather than up front.  Try 
this:
   
   ```python
       def test_do_delete_skip_if_referenced_guards_against_race(self):
           """_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.
           """
           from airflow.utils.db import reflect_tables
   
           base_date = pendulum.DateTime(2020, 1, 1, 
tzinfo=pendulum.timezone("UTC"))
           bundle_name = f"race-test-{uuid4()}"
           dag_id = f"race_dag_{uuid4()}"
   
           with create_session() as session:
               session.add(DagBundleModel(name=bundle_name))
               session.flush()
               session.add(DagModel(dag_id=dag_id, bundle_name=bundle_name))
               session.flush()
   
               raced_old = DagVersion(
                   dag_id=dag_id,
                   version_number=1,
                   bundle_name=bundle_name,
                   created_at=base_date,
                   last_updated=base_date,
               )
               # dag_version is configured keep_last per dag_id, so a lone 
version is always the
               # keep_last survivor and never eligible.  A second, newer 
version takes that role
               # and leaves raced_old as the deletion candidate.
               latest = DagVersion(
                   dag_id=dag_id,
                   version_number=2,
                   bundle_name=bundle_name,
                   created_at=base_date.add(minutes=1),
                   last_updated=base_date.add(minutes=1),
               )
               session.add_all([raced_old, latest])
               session.flush()
               raced_old_id, latest_id = raced_old.id, latest.id
   
               # Built while nothing references raced_old, so the first SELECT 
pass returns it
               # and _do_delete archives it.
               cfg = config_dict["dag_version"]
               query = _build_query(
                   **cfg.__dict__,
                   clean_before_timestamp=base_date.add(days=10),
                   session=session,
               )
   
               dag_run = DagRun(dag_id, run_id="race-run", 
run_type=DagRunType.MANUAL, start_date=base_date)
               ti = create_task_instance(
                   PythonOperator(task_id="dummy-task", python_callable=print),
                   run_id=dag_run.run_id,
                   dag_version_id=raced_old_id,
               )
               ti.dag_id = dag_id
               ti.start_date = base_date
   
               raced = False
   
               def reflect_and_race(tables, session, **kwargs):
                   """Insert the referencing TI in the window the race needs.
   
                   _do_delete reflects both source and target immediately after 
committing
                   the archive CTAS and immediately before building the DELETE, 
so this call
                   site is the only seam between the two.  The MySQL branch 
reflects the
                   target alone earlier in the same pass, hence keying on the 
two-table call.
                   If that call is ever moved or inlined, this test stops 
reproducing the
                   race -- and would pass while testing nothing, so the 
``raced`` assertion
                   below is not optional.
                   """
                   nonlocal raced
                   if not raced and len(tables) == 2:
                       raced = True
                       session.add_all([dag_run, ti])
                       session.commit()
                   return reflect_tables(tables, session, **kwargs)
   
               with patch("airflow.utils.db_cleanup.reflect_tables", 
side_effect=reflect_and_race):
                   _do_delete(
                       query=query,
                       orm_model=cfg.orm_model,
                       skip_archive=True,
                       session=session,
                       batch_size=None,
                       skip_if_referenced=cfg.skip_if_referenced,
                       referenced_pk_column=cfg.referenced_pk_column,
                   )
   
               remaining = 
set(session.scalars(select(DagVersion.id).where(DagVersion.dag_id == 
dag_id)).all())
   
           assert raced, "the TI was never inserted mid-pass; the race was not 
reproduced"
           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"
   ```
   
   What was happening is that with a single `DagVersion`, the SELECT was 
returning zero for two reasons.  `dag_version` is `keep_last` per `dag_id`, so 
a lone version is always the survivor and is excluded regardless of any TI.  
That's why this version creates two.  `latest` will take the `keep_last` slot 
so `raced_old` comes up for deletion.
   
   I've tested it on postgres and sqlite.  It works as written and fails with 
the `if skip_if_referenced:` block removed as expected, so I think it should be 
good regardless of backend.  I don't have a mysql backend handy, but the CI 
will test that side.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to