This is an automated email from the ASF dual-hosted git repository.
Lee-W pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new 02c43a1dd58 [v3-4-test] Fix duplicate pending Dag runs for the same
asset partition key (#71074) (#75382)
02c43a1dd58 is described below
commit 02c43a1dd581792ca694db7521d74b9af95d37bc
Author: Wei Lee <[email protected]>
AuthorDate: Thu Oct 22 18:15:23 2026 +0900
[v3-4-test] Fix duplicate pending Dag runs for the same asset partition key
(#71074) (#75382)
Co-authored-by: Anmol Mishra <[email protected]>
Co-authored-by: Kaxil Naik <[email protected]>
Co-authored-by: anmolxlight <[email protected]>
---
airflow-core/docs/migrations-ref.rst | 5 +-
airflow-core/src/airflow/assets/manager.py | 281 ++++++++++----------
...0148_3_4_0_add_pending_partition_key_to_apdr.py | 84 ++++++
airflow-core/src/airflow/models/asset.py | 33 ++-
airflow-core/src/airflow/utils/db.py | 2 +-
airflow-core/tests/unit/assets/test_manager.py | 283 +++++++++++++++++++--
.../test_0148_add_pending_partition_key_to_apdr.py | 187 ++++++++++++++
7 files changed, 712 insertions(+), 163 deletions(-)
diff --git a/airflow-core/docs/migrations-ref.rst
b/airflow-core/docs/migrations-ref.rst
index 44e2090370d..690ee0fc869 100644
--- a/airflow-core/docs/migrations-ref.rst
+++ b/airflow-core/docs/migrations-ref.rst
@@ -39,7 +39,10 @@ Here's the list of all the Database Migrations that are
executed via when you ru
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
| Revision ID | Revises ID | Airflow Version | Description
|
+=========================+==================+===================+==============================================================+
-| ``9cffe90fa0c0`` (head) | ``a5d7b9c13e40`` | ``3.4.0`` | Add
commit_allows_update to connection_test_request. |
+| ``ee86eed19e24`` (head) | ``9cffe90fa0c0`` | ``3.4.0`` | Add
pending_partition_key to asset_partition_dag_run and |
+| | | | enforce
single pending row per key. |
++-------------------------+------------------+-------------------+--------------------------------------------------------------+
+| ``9cffe90fa0c0`` | ``a5d7b9c13e40`` | ``3.4.0`` | Add
commit_allows_update to connection_test_request. |
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
| ``a5d7b9c13e40`` | ``c3e7a9182f64`` | ``3.4.0`` | Preserve
exact task execution attribution in asset state. |
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
diff --git a/airflow-core/src/airflow/assets/manager.py
b/airflow-core/src/airflow/assets/manager.py
index ad133881db7..eb8ea92d207 100644
--- a/airflow-core/src/airflow/assets/manager.py
+++ b/airflow-core/src/airflow/assets/manager.py
@@ -18,7 +18,6 @@
from __future__ import annotations
from collections.abc import Callable, Collection, Iterable
-from contextlib import contextmanager
from functools import partial
from typing import TYPE_CHECKING
@@ -48,7 +47,7 @@ from airflow.models.log import Log
from airflow.timetables.base import compute_rollup_fingerprint
from airflow.utils.helpers import is_container, prune_dict
from airflow.utils.log.logging_mixin import LoggingMixin
-from airflow.utils.sqlalchemy import get_dialect_name, with_row_locks
+from airflow.utils.sqlalchemy import get_dialect_name
if TYPE_CHECKING:
from datetime import datetime
@@ -66,59 +65,7 @@ if TYPE_CHECKING:
log = structlog.get_logger(__name__)
-
-@contextmanager
-def _lock_asset_model(
- *,
- session: Session,
- asset_id: int,
- max_retries: int = 10,
- retry_delay: float = 0.1,
-):
- """
- Context manager to acquire a lock for AssetPartitionDagRun creation.
-
- - SQLite: Use a no-op ORM update to trigger a write-transaction and
acquire SQLite's global writer lock.
- - Postgres/MySQL: uses row-level lock on AssetModel.
- """
- if get_dialect_name(session) == "sqlite":
- import time
-
- from sqlalchemy import update
-
- # no-op update
- # This is used to acquire SQLite's global writer lock.
- stmt = update(AssetModel).where(AssetModel.id ==
asset_id).values(id=AssetModel.id)
- for _ in range(max_retries):
- try:
- session.execute(stmt)
- session.flush()
- except exc.OperationalError as err:
- err_msg = str(err).lower()
- if "locked" in err_msg or "busy" in err_msg:
- session.rollback()
- time.sleep(retry_delay)
- continue
-
- # lock acquired
- yield
- return
-
- raise RuntimeError(f"Could not acquire SQLite AssetModel writer lock
for asset_id={asset_id}")
- else:
- # Postgres/MySQL row-level lock
- if (
- session.scalar(
- with_row_locks(
- query=select(AssetModel.id).where(AssetModel.id ==
asset_id),
- session=session,
- key_share=True,
- )
- )
- ) is None:
- raise RuntimeError(f"Asset {asset_id} does not exist – cannot
lock.")
-
- yield
+_APDR_GET_OR_CREATE_ATTEMPTS = 2
class AssetManager(LoggingMixin):
@@ -524,7 +471,12 @@ class AssetManager(LoggingMixin):
if not dags_to_queue:
return None
- partition_dags = [x for x in dags_to_queue if x.timetable_partitioned
is True]
+ # An APDR INSERT that collides with another transaction's uncommitted
row on the
+ # pending-key unique constraint waits for it. Sorting keeps two events
from inserting
+ # rows for the same pair of Dags in opposite orders and deadlocking.
+ partition_dags = sorted(
+ (x for x in dags_to_queue if x.timetable_partitioned is True),
key=lambda d: d.dag_id
+ )
cls._queue_partitioned_dags(
asset_id=asset_id,
partition_dags=partition_dags,
@@ -693,7 +645,6 @@ class AssetManager(LoggingMixin):
target_partition_date=target_partition_date,
target_dag=target_dag,
rollup_fingerprint=fingerprint,
- asset_id=asset_id,
allow_reuse=timetable.batch_asset_events,
session=session,
)
@@ -715,28 +666,143 @@ class AssetManager(LoggingMixin):
target_partition_date: datetime | None,
target_dag: DagModel,
rollup_fingerprint: dict,
- asset_id: int,
allow_reuse: bool,
session: Session,
) -> AssetPartitionDagRun:
"""
Get or create an APDR.
- If 2 processes invoke this method at the same time using the same
(target_key, target_dag) pair,
- they may both check the database and, finding no existing APDR, create
separate instances.
- This leads to the unintended outcome of having two APDRs created
instead of one.
- To resolve this, we add a mutex lock to AssetModel for PostgreSQL and
MySQL and use
- AssetPartitionDagRunMutexLock table for SQLite.
+ When ``allow_reuse=True``, an existing pending APDR for the same
+ ``(target_dag, partition_key)`` is reused — multiple events accumulate
on one
+ APDR. When ``allow_reuse=False`` (set when the timetable's
``batch_asset_events``
+ is ``False``), a new APDR is always created so each event gets its own
APDR
+ and the scheduler produces one DagRun per event.
+
+ With reuse, two processes handling events for the same (target_key,
target_dag) pair
+ may both find no pending APDR and both try to create one. The events
can come from
+ different producer assets, so nothing they share can be locked to
serialize this
+ short of the Dag row. Instead, a reusable APDR is created with
``pending_partition_key``
+ set, the unique constraint on (target_dag_id, pending_partition_key)
rejects the second
+ INSERT, and the loser reuses the winner's row.
``rollup_fingerprint`` is the serialized mapper / window definition
for all partitioned
assets in the timetable at creation time; the scheduler discards APDRs
whose stamp no
longer matches the current timetable's fingerprint (mapper / window
may have changed).
+ """
+ if allow_reuse and (
+ pending_apdr := cls._get_latest_pending_apdr(
+ target_key=target_key, target_dag_id=target_dag.dag_id,
session=session
+ )
+ ):
+ cls._reconcile_partition_date(
+ apdr=pending_apdr,
+ target_partition_date=target_partition_date,
+ target_dag_id=target_dag.dag_id,
+ target_key=target_key,
+ session=session,
+ )
+ cls.logger().debug(
+ "Existing APDR found for key %s dag_id %s",
+ target_key,
+ target_dag.dag_id,
+ exc_info=True,
+ )
+ return pending_apdr
+
+ build_apdr = partial(
+ AssetPartitionDagRun,
+ target_dag_id=target_dag.dag_id,
+ created_dag_run_id=None,
+ partition_key=target_key,
+ pending_partition_key=target_key if allow_reuse else None,
+ partition_date=target_partition_date,
+ rollup_fingerprint=rollup_fingerprint,
+ )
+ if not allow_reuse:
+ apdr = build_apdr()
+ session.add(apdr)
+ session.flush()
+ else:
+ # begin_nested() flushes the caller's pending rows before the
savepoint starts, so
+ # rolling it back on a conflict expunges only ``apdr``. The
re-lookup after a
+ # conflict sees the winner's row on MySQL only because it runs at
READ COMMITTED
+ # (settings.py), unlike its REPEATABLE READ default.
+ for attempt in range(1, _APDR_GET_OR_CREATE_ATTEMPTS + 1):
+ apdr = build_apdr()
+ try:
+ with session.begin_nested():
+ session.add(apdr)
+ except exc.IntegrityError:
+ winner = cls._get_latest_pending_apdr(
+ target_key=target_key,
target_dag_id=target_dag.dag_id, session=session
+ )
+ if winner is None:
+ # The winner was already turned into a Dag run, which
cleared its key and
+ # freed the constraint, so the INSERT can be run again.
+ if attempt == _APDR_GET_OR_CREATE_ATTEMPTS:
+ raise
+ continue
+ cls.logger().debug(
+ "Another process created the APDR for key %s dag_id %s
first; reusing it",
+ target_key,
+ target_dag.dag_id,
+ )
+ cls._reconcile_partition_date(
+ apdr=winner,
+ target_partition_date=target_partition_date,
+ target_dag_id=target_dag.dag_id,
+ target_key=target_key,
+ session=session,
+ )
+ return winner
+ break
+
+ cls.logger().debug(
+ "No existing APDR found. Create APDR for key %s dag_id %s",
+ target_key,
+ target_dag.dag_id,
+ exc_info=True,
+ )
+ return apdr
+
+ @staticmethod
+ def _get_latest_pending_apdr(
+ *, target_key: str, target_dag_id: str, session: Session
+ ) -> AssetPartitionDagRun | None:
+ """
+ Return the latest pending APDR for (target_dag_id, target_key), if any.
+
+ This also matches pending rows created while batching was disabled,
which carry no
+ ``pending_partition_key``, so a Dag that turns batching on keeps
accumulating onto
+ its most recent pending row.
+ """
+ return session.scalar(
+ select(AssetPartitionDagRun)
+ .where(
+ AssetPartitionDagRun.partition_key == target_key,
+ AssetPartitionDagRun.target_dag_id == target_dag_id,
+ AssetPartitionDagRun.created_dag_run_id.is_(None),
+ )
+ .order_by(AssetPartitionDagRun.id.desc())
+ .limit(1)
+ )
- Reconciling the carried ``partition_date`` on an existing pending APDR
is best-effort:
- a partitioned consumer's feeding assets are expected to agree on the
partition's
- datetime. The carry only matters for ``IdentityMapper`` (whose key the
scheduler
- cannot decode); temporal/composite feeds re-derive the date from the
key at run
- creation regardless of what is stored here. Within that contract:
+ @staticmethod
+ def _reconcile_partition_date(
+ *,
+ apdr: AssetPartitionDagRun,
+ target_partition_date: datetime | None,
+ target_dag_id: str,
+ target_key: str,
+ session: Session,
+ ) -> None:
+ """
+ Reconcile the carried ``partition_date`` on a reused pending APDR.
+
+ This is best-effort: a partitioned consumer's feeding assets are
expected to agree
+ on the partition's datetime. The carry only matters for
``IdentityMapper`` (whose key
+ the scheduler cannot decode); temporal/composite feeds re-derive the
date from the key
+ at run creation regardless of what is stored here. Within that
contract:
- If the APDR carries no date yet (``None`` — created by an event that
carried none),
adopt the incoming date when this event carries one. There is
nothing to conflict
@@ -745,73 +811,24 @@ class AssetManager(LoggingMixin):
one, the producing assets disagree; picking one would be
order-dependent, so the
carried date is suppressed to ``None`` (and re-adoptable by a later
event).
- Otherwise (the dates agree, or this event carries none) the existing
value is kept.
-
- When ``allow_reuse=True``, an existing pending APDR for the same
- ``(target_dag, partition_key)`` is reused — multiple events accumulate
on one
- APDR. When ``allow_reuse=False`` (set when the timetable's
``batch_asset_events``
- is ``False``), a new APDR is always created so each event gets its own
APDR
- and the scheduler produces one DagRun per event.
"""
- with _lock_asset_model(session=session, asset_id=asset_id):
- latest_apdr: AssetPartitionDagRun | None = None
- if allow_reuse:
- latest_apdr = session.scalar(
- select(AssetPartitionDagRun)
- .where(
- AssetPartitionDagRun.partition_key == target_key,
- AssetPartitionDagRun.target_dag_id ==
target_dag.dag_id,
- )
- .order_by(AssetPartitionDagRun.id.desc())
- .limit(1)
- )
- if latest_apdr and latest_apdr.created_dag_run_id is None:
- existing_partition_date = latest_apdr.partition_date
- if existing_partition_date is None:
- # No carried date yet; adopt the incoming one if present
(no conflict
- # to resolve). Keeps a later identity event's date from
being dropped.
- if target_partition_date is not None:
- latest_apdr.partition_date = target_partition_date
- session.flush()
- elif target_partition_date is not None and
existing_partition_date != target_partition_date:
- # Two contributing events carry conflicting
partition_dates for the same
- # (target_key, target_dag). Choosing one would be
order-dependent, so
- # suppress: the consumer DagRun gets partition_date=None
rather than a
- # wrong, unstable value.
- log.warning(
- "Conflicting partition_date carried for the same
target key; "
- "suppressing it so the consumer DagRun's
partition_date is None. "
- "The producing assets likely disagree on the
partition's datetime.",
- target_dag_id=target_dag.dag_id,
- target_key=target_key,
- existing_partition_date=existing_partition_date,
- incoming_partition_date=target_partition_date,
- )
- latest_apdr.partition_date = None
- session.flush()
- cls.logger().debug(
- "Existing APDR found for key %s dag_id %s",
- target_key,
- target_dag.dag_id,
- exc_info=True,
- )
- return latest_apdr
-
- apdr = AssetPartitionDagRun(
- target_dag_id=target_dag.dag_id,
- created_dag_run_id=None,
- partition_key=target_key,
- partition_date=target_partition_date,
- rollup_fingerprint=rollup_fingerprint,
+ existing_partition_date = apdr.partition_date
+ if existing_partition_date is None:
+ if target_partition_date is not None:
+ apdr.partition_date = target_partition_date
+ session.flush()
+ elif target_partition_date is not None and existing_partition_date !=
target_partition_date:
+ log.warning(
+ "Conflicting partition_date carried for the same target key; "
+ "suppressing it so the consumer DagRun's partition_date is
None. "
+ "The producing assets likely disagree on the partition's
datetime.",
+ target_dag_id=target_dag_id,
+ target_key=target_key,
+ existing_partition_date=existing_partition_date,
+ incoming_partition_date=target_partition_date,
)
- session.add(apdr)
+ apdr.partition_date = None
session.flush()
- cls.logger().debug(
- "No existing APDR found. Create APDR for key %s dag_id %s",
- target_key,
- target_dag.dag_id,
- exc_info=True,
- )
- return apdr
@classmethod
def _queue_dagruns_nonpartitioned(
diff --git
a/airflow-core/src/airflow/migrations/versions/0148_3_4_0_add_pending_partition_key_to_apdr.py
b/airflow-core/src/airflow/migrations/versions/0148_3_4_0_add_pending_partition_key_to_apdr.py
new file mode 100644
index 00000000000..0b70761ff50
--- /dev/null
+++
b/airflow-core/src/airflow/migrations/versions/0148_3_4_0_add_pending_partition_key_to_apdr.py
@@ -0,0 +1,84 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Add pending_partition_key to asset_partition_dag_run and enforce single
pending row per key.
+
+Asset events from two producer assets that map to the same downstream
partition key
+could each create a pending AssetPartitionDagRun (APDR), splitting the events
between
+two rows that each wait forever for the other's events.
``pending_partition_key``
+mirrors ``partition_key`` on a pending row, and a unique constraint on
+(target_dag_id, pending_partition_key) stops a second one from being created.
It stands
+in for a partial unique index, which MySQL does not support.
+
+Existing pending rows are neither merged nor deleted: a Dag without
``batch_asset_events``
+legitimately has several pending rows for one key, one per event. Only the
latest (highest id)
+pending row per (target_dag_id, partition_key) is marked; older duplicates
keep a NULL
+``pending_partition_key``, which the unique constraint treats as distinct, so
they do not
+conflict. A pending row split by the old race is not healed by this migration.
This is plain
+SQL, so it behaves the same in offline mode.
+
+Revision ID: ee86eed19e24
+Revises: 9cffe90fa0c0
+Create Date: 2026-08-04 00:00:00.000000
+
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+from airflow.migrations.db_types import StringID
+from airflow.migrations.utils import disable_sqlite_fkeys
+
+revision = "ee86eed19e24"
+down_revision = "9cffe90fa0c0"
+branch_labels = None
+depends_on = None
+airflow_version = "3.4.0"
+
+_TABLE = "asset_partition_dag_run"
+_UQ_NAME = "apdr_target_dag_id_pending_partition_key_uq"
+
+
+def upgrade():
+ """Add pending_partition_key to asset_partition_dag_run and enforce single
pending row per key."""
+ with disable_sqlite_fkeys(op):
+ with op.batch_alter_table(_TABLE, schema=None) as batch_op:
+ batch_op.add_column(sa.Column("pending_partition_key", StringID(),
nullable=True))
+
+ # The derived table lets MySQL read the table it updates.
+ op.execute(
+ f"UPDATE {_TABLE} SET pending_partition_key = partition_key WHERE
id IN ("
+ "SELECT latest_id FROM ("
+ f"SELECT MAX(id) AS latest_id FROM {_TABLE} WHERE
created_dag_run_id IS NULL "
+ "GROUP BY target_dag_id, partition_key"
+ ") latest_pending)"
+ )
+
+ with op.batch_alter_table(_TABLE, schema=None) as batch_op:
+ batch_op.create_unique_constraint(_UQ_NAME, ["target_dag_id",
"pending_partition_key"])
+
+
+def downgrade():
+ """Drop the pending-partition uniqueness guard and pending_partition_key
column."""
+ with disable_sqlite_fkeys(op):
+ with op.batch_alter_table(_TABLE, schema=None) as batch_op:
+ batch_op.drop_constraint(_UQ_NAME, type_="unique")
+ batch_op.drop_column("pending_partition_key")
diff --git a/airflow-core/src/airflow/models/asset.py
b/airflow-core/src/airflow/models/asset.py
index ab780be2e77..df845b53a9f 100644
--- a/airflow-core/src/airflow/models/asset.py
+++ b/airflow-core/src/airflow/models/asset.py
@@ -33,11 +33,12 @@ from sqlalchemy import (
PrimaryKeyConstraint,
String,
Table,
+ UniqueConstraint,
delete,
select,
)
from sqlalchemy.ext.associationproxy import association_proxy
-from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from airflow._shared.timezones import timezone
from airflow.configuration import conf as airflow_conf
@@ -950,16 +951,26 @@ class AssetPartitionDagRun(Base):
gets created leaves its APDR record behind.
Where created_dag_run_id is null, the Dag run has not yet been created.
- With event batching enabled, `_lock_asset_model` protects reuse of the
- latest pending row for a target_dag_id / partition_key pair. With batching
- disabled, each event creates its own row, so multiple pending rows for
- the same pair are expected.
+ With batching disabled, each event creates its own row, so multiple pending
+ rows for the same pair are expected. With event batching enabled, events
+ accumulate on one pending row per target_dag_id / partition_key, and a
+ unique constraint on (target_dag_id, pending_partition_key) keeps
+ concurrent writers from creating a second one.
+
+ pending_partition_key mirrors partition_key on a pending row created with
+ batching enabled, and is null otherwise; the `_sync_pending_partition_key`
+ validator clears it once the Dag run is created. A unique index treats null
+ as distinct from every value on all three supported backends, so it only
+ compares batched pending rows. It stands in for a partial unique index on
+ (target_dag_id, partition_key) WHERE created_dag_run_id IS NULL, which
+ MySQL does not support.
"""
id: Mapped[int] = mapped_column(Integer, primary_key=True,
autoincrement=True)
target_dag_id: Mapped[str] = mapped_column(StringID(), nullable=False)
created_dag_run_id: Mapped[int | None] = mapped_column(Integer(),
nullable=True)
partition_key: Mapped[str] = mapped_column(StringID(), nullable=False)
+ pending_partition_key: Mapped[str | None] = mapped_column(StringID(),
nullable=True)
partition_date: Mapped[datetime | None] = mapped_column(UtcDateTime,
nullable=True)
# Serialized snapshot of the rollup definition (mapper + window for every
# partitioned asset in the timetable) at the time this APDR was created.
@@ -984,8 +995,20 @@ class AssetPartitionDagRun(Base):
name="apdr_created_dag_run_id_fkey",
ondelete="CASCADE",
),
+ UniqueConstraint(
+ "target_dag_id",
+ "pending_partition_key",
+ name="apdr_target_dag_id_pending_partition_key_uq",
+ ),
)
+ @validates("created_dag_run_id")
+ def _sync_pending_partition_key(self, key: str, value: int | None) -> int
| None:
+ """Clear pending_partition_key once a Dag run is created for this
row."""
+ if value is not None:
+ self.pending_partition_key = None
+ return value
+
class PartitionedAssetKeyLog(Base):
"""
diff --git a/airflow-core/src/airflow/utils/db.py
b/airflow-core/src/airflow/utils/db.py
index 538231bf54c..b4a1638422f 100644
--- a/airflow-core/src/airflow/utils/db.py
+++ b/airflow-core/src/airflow/utils/db.py
@@ -117,7 +117,7 @@ _REVISION_HEADS_MAP: dict[str, str] = {
"3.1.8": "509b94a1042d",
"3.2.0": "1d6611b6ab7c",
"3.3.0": "d2f4e1b3c5a7",
- "3.4.0": "9cffe90fa0c0",
+ "3.4.0": "ee86eed19e24",
}
# Prefix used to identify tables holding data moved during migration.
diff --git a/airflow-core/tests/unit/assets/test_manager.py
b/airflow-core/tests/unit/assets/test_manager.py
index 049df518569..c700605c24f 100644
--- a/airflow-core/tests/unit/assets/test_manager.py
+++ b/airflow-core/tests/unit/assets/test_manager.py
@@ -20,12 +20,13 @@ from __future__ import annotations
import concurrent.futures
import itertools
import logging
+import threading
from collections import Counter
from typing import TYPE_CHECKING
from unittest import mock
import pytest
-from sqlalchemy import func, select
+from sqlalchemy import event, exc, func, insert, select
from sqlalchemy.dialects import mysql, postgresql, sqlite
from sqlalchemy.orm import Session
@@ -43,6 +44,7 @@ from airflow.models.asset import (
DagScheduleAssetNameReference,
DagScheduleAssetReference,
DagScheduleAssetUriReference,
+ PartitionedAssetKeyLog,
)
from airflow.models.dag import DAG, DagModel
from airflow.models.dagbundle import DagBundleModel
@@ -54,6 +56,7 @@ from airflow.partition_mappers.window import WeekWindow
from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.sdk.definitions.asset import Asset
from airflow.sdk.definitions.timetables.assets import PartitionedAssetTimetable
+from airflow.utils.session import create_session
from tests_common.test_utils.asserts import capture_orm_selects
from tests_common.test_utils.config import conf_vars
@@ -417,9 +420,8 @@ class TestAssetManager:
@pytest.mark.usefixtures("dag_maker", "testing_dag_bundle")
def test_get_or_create_apdr_race_condition(self, session, caplog):
- asm = AssetModel(uri="test://asset1/", name="partition_asset",
group="asset")
testing_dag = DagModel(dag_id="testing_dag", is_stale=False,
bundle_name="testing")
- session.add_all([asm, testing_dag])
+ session.add(testing_dag)
session.commit()
session.flush()
assert
session.scalar(select(func.count()).select_from(AssetPartitionDagRun)) == 0
@@ -439,7 +441,6 @@ class TestAssetManager:
target_partition_date=None,
target_dag=testing_dag,
rollup_fingerprint=rollup_fingerprint,
- asset_id=asm.id,
allow_reuse=True,
session=_session,
).id
@@ -452,14 +453,68 @@ class TestAssetManager:
with
concurrent.futures.ThreadPoolExecutor(max_workers=thread_count) as pool:
ids = pool.map(lambda _: _get_or_create_apdr(), [None] *
thread_count)
- assert Counter(r.msg for r in caplog.records) == {
- "Existing APDR found for key test_partition_key dag_id
testing_dag": thread_count - 1,
- "No existing APDR found. Create APDR for key test_partition_key
dag_id testing_dag": 1,
- }
+ # A thread either finds the row with its lookup, or loses the INSERT
to the unique
+ # constraint and reuses the winner's row; which one happens is a
timing detail.
+ msg_counts = Counter(r.msg for r in caplog.records)
+ create_msg = "No existing APDR found. Create APDR for key
test_partition_key dag_id testing_dag"
+ found_msg = "Existing APDR found for key test_partition_key dag_id
testing_dag"
+ lost_race_msg = (
+ "Another process created the APDR for key test_partition_key
dag_id testing_dag first; reusing it"
+ )
+ assert msg_counts[create_msg] == 1
+ assert msg_counts[found_msg] + msg_counts[lost_race_msg] ==
thread_count - 1
assert len(set(ids)) == 1
assert
session.scalar(select(func.count()).select_from(AssetPartitionDagRun)) == 1
+ @pytest.mark.usefixtures("testing_dag_bundle")
+ def
test_get_or_create_apdr_reuses_winner_after_losing_insert_and_keeps_unflushed_rows(self,
session):
+ """An APDR INSERT that loses to the unique constraint reuses the row
that won.
+
+ Rolling back the savepoint must not expunge rows the caller added
before and has not
+ flushed, such as the ``Log`` rows ``_queue_partitioned_dags`` adds
between APDRs.
+ """
+ testing_dag = DagModel(dag_id="testing_dag_lost_race", is_stale=False,
bundle_name="testing")
+ session.add(testing_dag)
+ session.commit()
+ log_row = Log(event="added before the savepoint")
+ session.add(log_row)
+ real_lookup = AssetManager._get_latest_pending_apdr
+
+ def lookup_misses_concurrent_insert(*, target_key, target_dag_id,
session):
+ # Another writer inserts the row right after this lookup, so the
INSERT that
+ # follows collides with it on the unique constraint.
+ lookup.side_effect = real_lookup
+ session.execute(
+ insert(AssetPartitionDagRun).values(
+ target_dag_id=target_dag_id, partition_key=target_key,
pending_partition_key=target_key
+ )
+ )
+ return None
+
+ with mock.patch.object(
+ AssetManager,
+ "_get_latest_pending_apdr",
+ autospec=True,
+ side_effect=lookup_misses_concurrent_insert,
+ ) as lookup:
+ apdr = AssetManager._get_or_create_apdr(
+ target_key="2026-05-20",
+ target_partition_date=None,
+ target_dag=testing_dag,
+ rollup_fingerprint={},
+ allow_reuse=True,
+ session=session,
+ )
+
+ assert lookup.call_count == 2
+ assert apdr.pending_partition_key == "2026-05-20"
+ assert session.scalars(
+
select(AssetPartitionDagRun.id).where(AssetPartitionDagRun.target_dag_id ==
testing_dag.dag_id)
+ ).all() == [apdr.id]
+ session.flush()
+ assert session.scalar(select(Log.event).where(Log.id == log_row.id))
== "added before the savepoint"
+
@pytest.mark.usefixtures("testing_dag_bundle")
def test_get_or_create_apdr_suppresses_conflicting_partition_date(self,
session):
"""Two events resolving the same target key to different dates →
suppress to None.
@@ -467,9 +522,8 @@ class TestAssetManager:
Rather than an order-dependent first-event-wins, conflicting carried
dates produce a
deterministic ``None`` so the consumer DagRun is not stamped with a
wrong, unstable date.
"""
- asm = AssetModel(uri="test://asset1/", name="partition_asset",
group="asset")
testing_dag = DagModel(dag_id="testing_dag_pd_conflict",
is_stale=False, bundle_name="testing")
- session.add_all([asm, testing_dag])
+ session.add(testing_dag)
session.commit()
fp = {"asset-1|test://asset1/": {"__type": "IdentityMapper", "__var":
{}}}
@@ -478,7 +532,6 @@ class TestAssetManager:
target_partition_date=timezone.parse("2026-05-20T00:00:00"),
target_dag=testing_dag,
rollup_fingerprint=fp,
- asset_id=asm.id,
allow_reuse=True,
session=session,
)
@@ -490,7 +543,6 @@ class TestAssetManager:
target_partition_date=timezone.parse("2026-05-21T00:00:00"),
target_dag=testing_dag,
rollup_fingerprint=fp,
- asset_id=asm.id,
allow_reuse=True,
session=session,
)
@@ -500,9 +552,8 @@ class TestAssetManager:
@pytest.mark.usefixtures("testing_dag_bundle")
def test_get_or_create_apdr_keeps_agreeing_partition_date(self, session):
"""A later event carrying the same (or no) date does not trip the
conflict suppression."""
- asm = AssetModel(uri="test://asset1/", name="partition_asset",
group="asset")
testing_dag = DagModel(dag_id="testing_dag_pd_agree", is_stale=False,
bundle_name="testing")
- session.add_all([asm, testing_dag])
+ session.add(testing_dag)
session.commit()
fp = {"asset-1|test://asset1/": {"__type": "IdentityMapper", "__var":
{}}}
source_date = timezone.parse("2026-05-20T00:00:00")
@@ -511,7 +562,6 @@ class TestAssetManager:
target_key="2026-05-20",
target_dag=testing_dag,
rollup_fingerprint=fp,
- asset_id=asm.id,
allow_reuse=True,
session=session,
)
@@ -529,9 +579,8 @@ class TestAssetManager:
@pytest.mark.usefixtures("testing_dag_bundle")
def test_get_or_create_apdr_adopts_date_when_existing_is_none(self,
session):
"""An APDR created with no date adopts a later event's carried date
(not dropped)."""
- asm = AssetModel(uri="test://asset1/", name="partition_asset",
group="asset")
testing_dag = DagModel(dag_id="testing_dag_pd_adopt", is_stale=False,
bundle_name="testing")
- session.add_all([asm, testing_dag])
+ session.add(testing_dag)
session.commit()
fp = {"asset-1|test://asset1/": {"__type": "IdentityMapper", "__var":
{}}}
source_date = timezone.parse("2026-05-20T00:00:00")
@@ -540,7 +589,6 @@ class TestAssetManager:
target_key="2026-05-20",
target_dag=testing_dag,
rollup_fingerprint=fp,
- asset_id=asm.id,
allow_reuse=True,
session=session,
)
@@ -555,9 +603,8 @@ class TestAssetManager:
@pytest.mark.usefixtures("testing_dag_bundle")
def test_get_or_create_apdr_recovers_after_conflict(self, session):
"""Once a conflict has suppressed the date to None, a later event
re-adopts a date."""
- asm = AssetModel(uri="test://asset1/", name="partition_asset",
group="asset")
testing_dag = DagModel(dag_id="testing_dag_pd_recover",
is_stale=False, bundle_name="testing")
- session.add_all([asm, testing_dag])
+ session.add(testing_dag)
session.commit()
fp = {"asset-1|test://asset1/": {"__type": "IdentityMapper", "__var":
{}}}
date_1 = timezone.parse("2026-05-20T00:00:00")
@@ -567,7 +614,6 @@ class TestAssetManager:
target_key="2026-05-20",
target_dag=testing_dag,
rollup_fingerprint=fp,
- asset_id=asm.id,
allow_reuse=True,
session=session,
)
@@ -628,9 +674,8 @@ class TestAssetManager:
def test_get_or_create_apdr_allow_reuse(self, session, allow_reuse):
clear_db_apdr()
clear_db_pakl()
- asset = AssetModel(uri="test://reuse/", name="reuse_asset",
group="asset")
dag = DagModel(dag_id="reuse_test_dag", is_stale=False,
bundle_name="testing")
- session.add_all([asset, dag])
+ session.add(dag)
session.flush()
with capture_orm_selects("asset_partition_dag_run") as lookups:
@@ -640,7 +685,6 @@ class TestAssetManager:
target_partition_date=None,
target_dag=dag,
rollup_fingerprint={},
- asset_id=asset.id,
allow_reuse=allow_reuse,
session=session,
)
@@ -652,6 +696,197 @@ class TestAssetManager:
assert len({apdr.id for apdr in apdrs}) == expected_count
assert
session.scalar(select(func.count()).select_from(AssetPartitionDagRun)) ==
expected_count
assert all(apdr.created_dag_run_id is None for apdr in apdrs)
+ # Only a reusable row takes part in the one-pending-row-per-key
constraint.
+ assert {apdr.pending_partition_key for apdr in apdrs} == {"key-1" if
allow_reuse else None}
+
+ @pytest.mark.backend("postgres", "mysql")
+ @pytest.mark.usefixtures("testing_dag_bundle")
+ def
test_concurrent_events_from_different_assets_share_one_pending_apdr(self,
dag_maker, session):
+ """Events from two producer assets racing on one partition key
accumulate on one APDR.
+
+ If the second writer looks the key up before the first writer commits
its APDR, it finds
+ nothing and inserts its own. Two pending APDRs holding one event each
can
+ never satisfy ``asset_a & asset_b``, and the consumer Dag never runs.
+ """
+ _clear_partition_db()
+ try:
+ asset_a = Asset(name="race-asset-a")
+ asset_b = Asset(name="race-asset-b")
+ with dag_maker(
+ dag_id="race-consumer",
+ schedule=PartitionedAssetTimetable(
+ assets=asset_a & asset_b,
default_partition_mapper=IdentityMapper()
+ ),
+ serialized=True,
+ ):
+ EmptyOperator(task_id="hi")
+ dag_maker.sync_dagbag_to_db()
+ session.commit()
+
+ test_thread = threading.get_ident()
+ second_writer_inserting_apdr = threading.Event()
+
+ def on_execute(conn, cursor, statement, parameters, context,
executemany):
+ if threading.get_ident() != test_thread and
statement.startswith(
+ "INSERT INTO asset_partition_dag_run "
+ ):
+ second_writer_inserting_apdr.set()
+
+ def run_second_writer():
+ with create_session(scoped=False) as second:
+ AssetManager.register_asset_change(asset=asset_b,
partition_key="day-1", session=second)
+
+ event.listen(settings.engine, "before_cursor_execute", on_execute)
+ try:
+ with create_session(scoped=False) as first:
+ AssetManager.register_asset_change(asset=asset_a,
partition_key="day-1", session=first)
+ first.flush()
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1)
as pool:
+ second_done = pool.submit(run_second_writer)
+ assert second_writer_inserting_apdr.wait(timeout=30)
+ first.commit()
+ second_done.result(timeout=30)
+ finally:
+ event.remove(settings.engine, "before_cursor_execute",
on_execute)
+
+ pending = session.scalars(
+
select(AssetPartitionDagRun).where(AssetPartitionDagRun.target_dag_id ==
"race-consumer")
+ ).all()
+ assert len(pending) == 1
+ logged_events = session.scalar(
+ select(func.count())
+ .select_from(PartitionedAssetKeyLog)
+ .where(PartitionedAssetKeyLog.asset_partition_dag_run_id ==
pending[0].id)
+ )
+ assert logged_events == 2
+ finally:
+ _clear_partition_db()
+
+ @pytest.mark.usefixtures("testing_dag_bundle")
+ def test_get_or_create_apdr_inserts_again_when_winner_was_consumed(self,
session, dag_maker):
+ """A winner turned into a Dag run before the loser looks it up has
freed the key, so the loser inserts anew."""
+ with dag_maker(dag_id="consumed_winner_producer", serialized=True):
+ EmptyOperator(task_id="hi")
+ dag_run = dag_maker.create_dagrun()
+ testing_dag = DagModel(dag_id="testing_dag_consumed_winner",
is_stale=False, bundle_name="testing")
+ session.add(testing_dag)
+ winner = AssetPartitionDagRun(
+ target_dag_id=testing_dag.dag_id, partition_key="k",
pending_partition_key="k"
+ )
+ session.add(winner)
+ session.commit()
+ lookups = []
+
+ def lookup_misses_then_consumes_winner(*, target_key, target_dag_id,
session):
+ if lookups:
+ winner.created_dag_run_id = dag_run.id
+ session.flush()
+ lookups.append(target_key)
+ return None
+
+ with mock.patch.object(
+ AssetManager,
+ "_get_latest_pending_apdr",
+ autospec=True,
+ side_effect=lookup_misses_then_consumes_winner,
+ ):
+ apdr = AssetManager._get_or_create_apdr(
+ target_key="k",
+ target_partition_date=None,
+ target_dag=testing_dag,
+ rollup_fingerprint={},
+ allow_reuse=True,
+ session=session,
+ )
+ session.flush()
+
+ assert apdr.id != winner.id
+ assert apdr.pending_partition_key == "k"
+ assert dict(
+ session.execute(
+ select(AssetPartitionDagRun.id,
AssetPartitionDagRun.pending_partition_key).where(
+ AssetPartitionDagRun.target_dag_id == testing_dag.dag_id
+ )
+ ).all()
+ ) == {winner.id: None, apdr.id: "k"}
+
+ @pytest.mark.usefixtures("testing_dag_bundle")
+ def test_get_or_create_apdr_reraises_when_insert_keeps_conflicting(self,
session):
+ testing_dag = DagModel(dag_id="testing_dag_no_winner", is_stale=False,
bundle_name="testing")
+ session.add(testing_dag)
+ session.commit()
+ session.add(
+ AssetPartitionDagRun(
+ target_dag_id=testing_dag.dag_id, partition_key="k",
pending_partition_key="k"
+ )
+ )
+ session.flush()
+
+ with mock.patch.object(AssetManager, "_get_latest_pending_apdr",
autospec=True, return_value=None):
+ with pytest.raises(exc.IntegrityError):
+ AssetManager._get_or_create_apdr(
+ target_key="k",
+ target_partition_date=None,
+ target_dag=testing_dag,
+ rollup_fingerprint={},
+ allow_reuse=True,
+ session=session,
+ )
+
+ assert (
+ session.scalar(
+ select(func.count())
+ .select_from(AssetPartitionDagRun)
+ .where(AssetPartitionDagRun.target_dag_id ==
testing_dag.dag_id)
+ )
+ == 1
+ )
+
+ @pytest.mark.usefixtures("testing_dag_bundle")
+ def test_queue_dagruns_creates_apdrs_in_dag_id_order(self, session,
dag_maker):
+ """Partitioned consumers get their APDRs in dag_id order whatever
order the set iterates in."""
+ _clear_partition_db()
+ try:
+ asset = Asset(name="ordered-asset")
+ for dag_id in ("z-consumer", "a-consumer"):
+ with dag_maker(
+ dag_id=dag_id,
+ schedule=PartitionedAssetTimetable(
+ assets=asset, default_partition_mapper=IdentityMapper()
+ ),
+ serialized=True,
+ ):
+ EmptyOperator(task_id="hi")
+ dag_maker.sync_dagbag_to_db()
+ session.commit()
+
+ class ReverseOrderSet(set):
+ def __iter__(self):
+ return iter(sorted(super().__iter__(), key=lambda d:
d.dag_id, reverse=True))
+
+ real_queue_dagruns = AssetManager._queue_dagruns
+
+ def queue_dagruns_in_reverse_order(*, dags_to_queue, **kwargs):
+
real_queue_dagruns(dags_to_queue=ReverseOrderSet(dags_to_queue), **kwargs)
+
+ with mock.patch.object(
+ AssetManager, "_queue_dagruns", autospec=True,
side_effect=queue_dagruns_in_reverse_order
+ ):
+ AssetManager.register_asset_change(asset=asset,
partition_key="day-1", session=session)
+ session.flush()
+
+ assert session.scalars(
+
select(AssetPartitionDagRun.target_dag_id).order_by(AssetPartitionDagRun.id)
+ ).all() == ["a-consumer", "z-consumer"]
+ finally:
+ _clear_partition_db()
+
+ def test_assigning_created_dag_run_id_clears_pending_partition_key(self):
+ apdr = AssetPartitionDagRun(target_dag_id="d", partition_key="k",
pending_partition_key="k")
+
+ apdr.created_dag_run_id = 123
+
+ assert apdr.pending_partition_key is None
@pytest.mark.need_serialized_dag
@pytest.mark.usefixtures("testing_dag_bundle")
diff --git
a/airflow-core/tests/unit/migrations/test_0148_add_pending_partition_key_to_apdr.py
b/airflow-core/tests/unit/migrations/test_0148_add_pending_partition_key_to_apdr.py
new file mode 100644
index 00000000000..064fee68e10
--- /dev/null
+++
b/airflow-core/tests/unit/migrations/test_0148_add_pending_partition_key_to_apdr.py
@@ -0,0 +1,187 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Tests for the pending partition key migration, written against the schema as
it stood at that revision.
+
+The tables are declared here instead of taken from ``airflow.models``, and
alembic moves the database
+to the revision, so later migrations and model changes cannot change what
these tests exercise.
+"""
+
+from __future__ import annotations
+
+from importlib import import_module
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+from airflow import settings
+from airflow._shared.timezones import timezone
+from airflow.utils.db import downgrade, upgradedb
+from airflow.utils.sqlalchemy import UtcDateTime
+
+pytestmark = pytest.mark.db_test
+
+_migration =
import_module("airflow.migrations.versions.0148_3_4_0_add_pending_partition_key_to_apdr")
+REVISION = _migration.revision
+PREVIOUS_REVISION = _migration.down_revision
+DAG_ID = "migration_0148"
+OTHER_DAG_ID = "migration_0148_other"
+PARTITION_DATE = timezone.datetime(2026, 5, 20)
+
+metadata = sa.MetaData()
+dag_run = sa.Table(
+ "dag_run",
+ metadata,
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("dag_id", sa.String(250)),
+ sa.Column("run_id", sa.String(250)),
+ sa.Column("run_type", sa.String(50)),
+ sa.Column("run_after", UtcDateTime),
+)
+apdr = sa.Table(
+ "asset_partition_dag_run",
+ metadata,
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("target_dag_id", sa.String(250)),
+ sa.Column("created_dag_run_id", sa.Integer),
+ sa.Column("partition_key", sa.String(250)),
+ sa.Column("partition_date", UtcDateTime),
+ sa.Column("created_at", UtcDateTime),
+ sa.Column("updated_at", UtcDateTime),
+)
+key_log = sa.Table(
+ "partitioned_asset_key_log",
+ metadata,
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("asset_id", sa.Integer),
+ sa.Column("asset_event_id", sa.Integer),
+ sa.Column("asset_partition_dag_run_id", sa.Integer),
+ sa.Column("source_partition_key", sa.String(250)),
+ sa.Column("target_dag_id", sa.String(250)),
+ sa.Column("target_partition_key", sa.String(250)),
+ sa.Column("created_at", UtcDateTime),
+)
+
+
+def _delete_rows(connection):
+ dag_ids = (DAG_ID, OTHER_DAG_ID)
+
connection.execute(key_log.delete().where(key_log.c.target_dag_id.in_(dag_ids)))
+ connection.execute(apdr.delete().where(apdr.c.target_dag_id.in_(dag_ids)))
+ connection.execute(dag_run.delete().where(dag_run.c.dag_id == DAG_ID))
+
+
[email protected]
+def database_before_revision():
+ """Move the database to the revision before the migration for one test,
then back to the current head."""
+ original_async_conn = settings.SQL_ALCHEMY_CONN_ASYNC
+ settings.SQL_ALCHEMY_CONN_ASYNC = ""
+ settings.dispose_orm(do_log=False)
+ settings.configure_orm(disable_connection_pool=True)
+ try:
+ downgrade(to_revision=PREVIOUS_REVISION)
+ with settings.engine.begin() as connection:
+ _delete_rows(connection)
+ yield settings.engine
+ finally:
+ try:
+ with settings.engine.begin() as connection:
+ _delete_rows(connection)
+ upgradedb()
+ finally:
+ settings.SQL_ALCHEMY_CONN_ASYNC = original_async_conn
+ settings.reconfigure_orm()
+
+
+def
test_upgrade_marks_only_the_latest_pending_row_per_key_and_keeps_every_row(database_before_revision):
+ now = timezone.utcnow()
+ with database_before_revision.begin() as connection:
+ dag_run_id = connection.execute(
+ dag_run.insert().values(dag_id=DAG_ID, run_id="run",
run_type="asset_triggered", run_after=now)
+ ).inserted_primary_key[0]
+
+ def add_apdr(partition_key, *, dag_id=DAG_ID, created_dag_run_id=None,
partition_date=None):
+ return connection.execute(
+ apdr.insert().values(
+ target_dag_id=dag_id,
+ partition_key=partition_key,
+ created_dag_run_id=created_dag_run_id,
+ partition_date=partition_date,
+ created_at=now,
+ updated_at=now,
+ )
+ ).inserted_primary_key[0]
+
+ completed = add_apdr("day-1", created_dag_run_id=dag_run_id)
+ older_pending = add_apdr("day-1", partition_date=PARTITION_DATE)
+ other_dag = add_apdr("day-1", dag_id=OTHER_DAG_ID)
+ latest_pending = add_apdr("day-1")
+ other_key = add_apdr("day-2")
+ apdr_ids = (completed, older_pending, other_dag, latest_pending,
other_key)
+ for asset_event_id, apdr_id in enumerate(apdr_ids):
+ connection.execute(
+ key_log.insert().values(
+ asset_id=asset_event_id,
+ asset_event_id=asset_event_id,
+ asset_partition_dag_run_id=apdr_id,
+ source_partition_key="source",
+ target_dag_id=OTHER_DAG_ID if apdr_id == other_dag else
DAG_ID,
+ target_partition_key="target",
+ created_at=now,
+ )
+ )
+
+ upgradedb(to_revision=REVISION)
+
+ upgraded_apdr = sa.Table(apdr.name, sa.MetaData(),
autoload_with=database_before_revision)
+ with database_before_revision.connect() as connection:
+ apdr_rows = {
+ row.id: (row.partition_key, row.created_dag_run_id,
row.pending_partition_key)
+ for row in connection.execute(
+
sa.select(upgraded_apdr).where(upgraded_apdr.c.target_dag_id.in_((DAG_ID,
OTHER_DAG_ID)))
+ )
+ }
+ partition_dates = dict(
+ connection.execute(
+ sa.select(apdr.c.id, apdr.c.partition_date).where(
+ apdr.c.target_dag_id.in_((DAG_ID, OTHER_DAG_ID))
+ )
+ ).all()
+ )
+ log_rows = dict(
+ connection.execute(
+ sa.select(key_log.c.asset_event_id,
key_log.c.asset_partition_dag_run_id).where(
+ key_log.c.target_dag_id.in_((DAG_ID, OTHER_DAG_ID))
+ )
+ ).all()
+ )
+
+ # A Dag without batch_asset_events has one pending row per event, so none
are merged or deleted:
+ # only the latest pending row of each key is marked, and a completed row
never is.
+ assert apdr_rows == {
+ completed: ("day-1", dag_run_id, None),
+ older_pending: ("day-1", None, None),
+ other_dag: ("day-1", None, "day-1"),
+ latest_pending: ("day-1", None, "day-1"),
+ other_key: ("day-2", None, "day-2"),
+ }
+ assert partition_dates[older_pending] == PARTITION_DATE
+ assert log_rows == dict(enumerate(apdr_ids))
+ assert _migration._UQ_NAME in {
+ constraint["name"]
+ for constraint in
inspect(database_before_revision).get_unique_constraints(apdr.name)
+ }