Skip to content

Fix dataset-triggered DAGs firing without all upstream events#63939

Closed
joseotaviorf wants to merge 1 commit into
apache:v2-11-stablefrom
joseotaviorf:fix_datasets_for_2_11_2_pr
Closed

Fix dataset-triggered DAGs firing without all upstream events#63939
joseotaviorf wants to merge 1 commit into
apache:v2-11-stablefrom
joseotaviorf:fix_datasets_for_2_11_2_pr

Conversation

@joseotaviorf

Copy link
Copy Markdown

Backports #62501 to 2.x

Backports three targeted fixes to 2.11.2 to address the race condition reported in #56750, #56541 and #41101, where a dataset-dependent DAG fires with one or more upstream dataset events missing from consumed_dataset_events.

Root cause: stale created_at timestamps in DatasetDagRunQueue caused the scheduler's event-window query to exclude valid events, yet the presence-based readiness check still considered the DAG ready to run.

Changes:

  • manager.py: replace ON CONFLICT DO NOTHING with ON CONFLICT DO UPDATE (Postgres) and add a WHERE guard to prevent backwards timestamp drift; pass explicit created_at=utcnow() on the non-Postgres merge path so existing rows are always refreshed.
  • scheduler_job_runner.py: wrap create_dagrun in if dataset_events: to prevent phantom runs when the event window is empty; scope the DDRQ DELETE to created_at <= exec_date instead of deleting all rows, so events that arrived during processing are preserved for the next cycle.
  • tests/datasets/test_manager.py: add WHERE-guard regression test for the Postgres upsert path.

Was generative AI tooling used to co-author this PR?
  • Yes (Cursor with Claude Opus 4.6)

  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

…rt of apache#62501)

Backports three targeted fixes to 2.10.4 to address the race condition reported
in GH#56541 and GH#41101, where a dataset-dependent DAG fires with one or more
upstream dataset events missing from consumed_dataset_events.

Root cause: stale `created_at` timestamps in DatasetDagRunQueue caused the
scheduler's event-window query to exclude valid events, yet the presence-based
readiness check still considered the DAG ready to run.

Changes:
- manager.py: replace ON CONFLICT DO NOTHING with ON CONFLICT DO UPDATE
  (Postgres) and add a WHERE guard to prevent backwards timestamp drift;
  pass explicit created_at=utcnow() on the non-Postgres merge path so
  existing rows are always refreshed.
- scheduler_job_runner.py: wrap create_dagrun in `if dataset_events:` to
  prevent phantom runs when the event window is empty; scope the DDRQ
  DELETE to `created_at <= exec_date` instead of deleting all rows, so
  events that arrived during processing are preserved for the next cycle.
- tests/datasets/test_manager.py: add WHERE-guard regression test for the
  Postgres upsert path.
- dev/BUG_REPORT_DATASET_SCHEDULING.md: detailed bug report with timeline
  diagrams, related issues (apache#56541, apache#41101, apache#35870), root cause analysis,
  and explanation of each fix.
- reproduce_bug_56541.py: end-to-end reproduction and verification script.
@kaxil

kaxil commented Mar 19, 2026

Copy link
Copy Markdown
Member

@joseotaviorf We should wait for #62501 to get merged, before backporting it. I have reviewed and added some comments on that PR

@kaxil kaxil left a comment

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.

Left 3 comments on this PR.

@@ -184,7 +185,7 @@ def _slow_path_queue_dagruns(
cls, dataset_id: int, dags_to_queue: set[DagModel], session: Session
) -> None:
def _queue_dagrun_if_needed(dag: DagModel) -> str | None:

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.

The Postgres path has a WHERE guard (created_at < excluded.created_at) that prevents a slower transaction from overwriting a newer timestamp, but session.merge() here still overwrites unconditionally. Two concurrent tasks can race: if the slower one commits last, it writes an older created_at. Using utcnow() makes backward drift unlikely, but it's still possible under clock adjustments or NTP drift.


values = [{"target_dag_id": dag.dag_id} for dag in dags_to_queue]
stmt = insert(DatasetDagRunQueue).values(dataset_id=dataset_id).on_conflict_do_nothing()
values = [{"target_dag_id": dag.dag_id, "created_at": timezone.utcnow()} for dag in dags_to_queue]

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.

Each iteration calls timezone.utcnow() separately, so each DAG in dags_to_queue gets a slightly different timestamp. For the common single-DAG case this doesn't matter, but with multiple DAGs the timestamps diverge. Computing the timestamp once before the comprehension would be more consistent:

now = timezone.utcnow()
values = [{"target_dag_id": dag.dag_id, "created_at": now} for dag in dags_to_queue]

delete(DatasetDagRunQueue).where(DatasetDagRunQueue.target_dag_id == dag_run.dag_id)
delete(DatasetDagRunQueue).where(
DatasetDagRunQueue.target_dag_id == dag.dag_id,
DatasetDagRunQueue.created_at <= exec_date,

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.

The created_at <= exec_date scoping correctly preserves newer DDRQ rows that arrive during processing. But when dataset_events is empty and DDRQ rows are deleted without creating a DagRun, there's no log message. Operators debugging "my dataset-triggered DAG didn't fire" would have zero visibility into this silent cleanup. A self.log.warning(...) here would help.

@potiuk potiuk marked this pull request as draft March 20, 2026 11:40
@potiuk

potiuk commented Mar 20, 2026

Copy link
Copy Markdown
Member

@joseotaviorf This PR has been converted to draft because it does not yet meet our Pull Request quality criteria.

Issues found:

  • Build docs: Failing: CI image checks / Build documentation (--docs-only). Run breeze build-docs locally to reproduce. See Build docs docs.
  • Pre-commit / static checks: Failing: CI image checks / Static checks. Run prek run --from-ref main locally to find and fix issues. See Pre-commit / static checks docs.
  • Provider tests: Failing: Postgres tests / DB-providers:Postgres:13:3.10, MySQL tests / DB-providers:MySQL:8.0:3.10, Sqlite tests / DB-providers:Sqlite:3.10, Non-DB tests / Non-DB-providers::3.10, Lowest direct dependency providers tests / All-providers:LowestDeps-Postgres:13:3.10. Run provider tests with breeze run pytest <provider-test-path> -xvs. See Provider tests docs.
  • Other failing CI checks: Failing: CI image checks / Build documentation (--spellcheck-only). Run prek run --from-ref main locally to reproduce. See static checks docs.
  • ⚠️ Unresolved review comments: This PR has 3 unresolved review threads from maintainers: @kaxil (MEMBER): 3 unresolved threads. Please review and resolve all inline review comments before requesting another review. You can resolve a conversation by clicking 'Resolve conversation' on each thread after addressing the feedback. See pull request guidelines.

What to do next:

  • The comment informs you what you need to do.
  • Fix each issue, then mark the PR as "Ready for review" in the GitHub UI - but only after making sure that all the issues are fixed.
  • There is no rush — take your time and work at your own pace. We appreciate your contribution and are happy to wait for updates.
  • Maintainers will then proceed with a normal review.

Converting a PR to draft is not a rejection — it is an invitation to bring the PR up to the project's standards so that maintainer review time is spent productively. There is no rush — take your time and work at your own pace. We appreciate your contribution and are happy to wait for updates. If you have questions, feel free to ask on the Airflow Slack.


Note: This comment was drafted by an AI-assisted triage tool and may contain mistakes. Once you have addressed the points above, an Apache Airflow maintainer — a real person — will take the next look at your PR. We use this two-stage triage process so that our maintainers' limited time is spent where it matters most: the conversation with you.

@kaxil kaxil requested a review from Copilot April 2, 2026 00:44

Copilot AI left a comment

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.

Pull request overview

Backport of targeted fixes to Airflow 2.x to prevent dataset-triggered DAGs from being scheduled when one or more upstream dataset events are missing, addressing a race around stale DatasetDagRunQueue.created_at and scheduler event-window selection.

Changes:

  • Refresh DatasetDagRunQueue.created_at on conflicts/merges (Postgres and non-Postgres paths) to prevent stale timestamps from excluding valid events.
  • Guard dataset-triggered DagRun creation on non-empty dataset_events, and scope DDRQ cleanup by created_at <= exec_date.
  • Add regression tests around timestamp-refresh behavior and the Postgres upsert guard.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
airflow/datasets/manager.py Updates DDRQ enqueue logic to refresh created_at on conflict for Postgres and non-Postgres backends.
airflow/jobs/scheduler_job_runner.py Adjusts dataset-triggered DagRun creation and narrows DDRQ deletion window.
tests/datasets/test_manager.py Adds tests for timestamp refresh and Postgres upsert guard behavior.
Comments suppressed due to low confidence (1)

airflow/datasets/manager.py:194

  • In _slow_path_queue_dagruns, created_at=timezone.utcnow() is evaluated inside _queue_dagrun_if_needed for every DAG. If multiple DAGs are queued for the same dataset change, this can create slightly different created_at values and extra calls. Consider hoisting now = timezone.utcnow() once per _slow_path_queue_dagruns call and using it for all queued items.
        def _queue_dagrun_if_needed(dag: DagModel) -> str | None:
            item = DatasetDagRunQueue(target_dag_id=dag.dag_id, dataset_id=dataset_id, created_at=timezone.utcnow())
            # Don't error whole transaction when a single RunQueue item conflicts.
            # https://docs.sqlalchemy.org/en/14/orm/session_transaction.html#using-savepoint
            try:
                with session.begin_nested():
                    session.merge(item)
            except exc.IntegrityError:

Comment on lines +297 to +317
def test_postgres_queue_dagruns_where_guard_prevents_backwards_drift(self, session):
"""
The WHERE guard on the Postgres upsert should prevent a slower transaction
from overwriting a newer created_at with an older one.
"""
from datetime import timedelta

from airflow.utils import timezone

dsem = DatasetManager()
ds = DatasetModel(uri="test_postgres_where_guard")
dag = DagModel(dag_id="test_pg_guard_dag", is_active=True)
session.add_all([ds, dag])
session.flush()

newer_ts = timezone.utcnow()
session.add(DatasetDagRunQueue(dataset_id=ds.id, target_dag_id=dag.dag_id, created_at=newer_ts))
session.commit()

dsem._postgres_queue_dagruns(dataset_id=ds.id, dags_to_queue={dag}, session=session)
session.commit()

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

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

test_postgres_queue_dagruns_where_guard_prevents_backwards_drift doesn’t currently validate the WHERE-guard behavior it describes. The test sets an existing created_at to newer_ts, then calls _postgres_queue_dagruns(), but that method always uses timezone.utcnow() for the upsert value—i.e. a timestamp newer than newer_ts—so the update is expected to succeed regardless of the WHERE (existing < excluded) guard. To actually regression-test “backwards drift prevention”, freeze/patch timezone.utcnow() (e.g. via the repo’s time_machine fixture) to return an older timestamp for the upsert, then assert created_at remains newer_ts (or unchanged).

Copilot uses AI. Check for mistakes.
Comment on lines +1492 to 1511
if dataset_events:
dag_run = dag.create_dagrun(
run_id=run_id,
run_type=DagRunType.DATASET_TRIGGERED,
execution_date=exec_date,
data_interval=data_interval,
state=DagRunState.QUEUED,
external_trigger=False,
session=session,
dag_hash=dag_hash,
creating_job_id=self.job.id,
)
Stats.incr("dataset.triggered_dagruns")
dag_run.consumed_dataset_events.extend(dataset_events)
session.execute(
delete(DatasetDagRunQueue).where(DatasetDagRunQueue.target_dag_id == dag_run.dag_id)
delete(DatasetDagRunQueue).where(
DatasetDagRunQueue.target_dag_id == dag.dag_id,
DatasetDagRunQueue.created_at <= exec_date,
)
)

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

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

dag_run is now created only when dataset_events is non-empty, but the subsequent DELETE no longer depends on dag_run. This fixes the previous unbound reference, but it introduces a more serious logic issue: when dataset_events is empty, the code still deletes from DatasetDagRunQueue for the DAG where created_at <= exec_date. That can clear readiness queue entries without creating a DagRun, potentially dropping triggers and preventing future scheduling. Consider moving the DELETE (or at least the created_at <= exec_date cleanup) inside the if dataset_events: block, or otherwise ensure the queue is only pruned when a run is actually created/consumed.

Copilot uses AI. Check for mistakes.
Comment on lines +206 to 213
values = [{"target_dag_id": dag.dag_id, "created_at": timezone.utcnow()} for dag in dags_to_queue]
stmt = insert(DatasetDagRunQueue).values(dataset_id=dataset_id)
stmt = stmt.on_conflict_do_update(
index_elements=["dataset_id", "target_dag_id"],
set_={"created_at": stmt.excluded.created_at},
where=(DatasetDagRunQueue.created_at < stmt.excluded.created_at),
)
session.execute(stmt, values)

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

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

In _postgres_queue_dagruns, values = [{..., "created_at": timezone.utcnow()} for dag in dags_to_queue] evaluates utcnow() once per DAG. That means created_at can differ slightly across rows from the same dataset event, and it adds unnecessary per-row Python work. Consider computing a single now = timezone.utcnow() once and reusing it for all rows in this batch to keep timestamps consistent and reduce overhead.

Copilot uses AI. Check for mistakes.
@potiuk

potiuk commented May 5, 2026

Copy link
Copy Markdown
Member

@joseotaviorf This draft PR has been inactive for 46 days since the last triage comment and no response from the author. Closing to keep the queue clean.

You are welcome to reopen this PR when you resume work, or to open a new one addressing the issues previously raised. There is no rush — take your time.


Note: This comment was drafted by an AI-assisted triage tool and may contain mistakes. Once you have addressed the points above, an Apache Airflow maintainer — a real person — will take the next look at your PR. We use this two-stage triage process so that our maintainers' limited time is spent where it matters most: the conversation with you.

@potiuk potiuk closed this May 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants