Let partitioned Dag runs fire on a partial upstream window with wait_policy by Lee-W · Pull Request #66848 · apache/airflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions airflow-core/newsfragments/66848.feature.rst
63 changes: 47 additions & 16 deletions airflow-core/src/airflow/jobs/scheduler_job_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@
from airflow.executors.base_executor import BaseExecutor
from airflow.executors.executor_utils import ExecutorName
from airflow.executors.workloads.types import SchedulerWorkload
from airflow.partition_mappers.base import RollupMapper
from airflow.serialization.definitions.dag import SerializedDAG
from airflow.utils.sqlalchemy import CommitProhibitorGuard

Expand Down Expand Up @@ -350,6 +349,13 @@ def __init__(

self.scheduler_dag_bag = DBDagBag(load_op_links=False)

# Set of (dag_id, asset_name, asset_uri) tuples for trigger policies that
# are permanently unreachable for the rollup window's cardinality — the
# Dag run can never fire, and we warn once per process lifetime so an
# unreachable APDR is visible in scheduler logs without spamming every
# tick.
self._partition_unreachable_seen: set[tuple[str, str, str]] = set()

@provide_session
def heartbeat_callback(self, *, session: Session = NEW_SESSION) -> None:
stats.incr("scheduler_heartbeat", 1, 1)
Expand Down Expand Up @@ -1924,16 +1930,31 @@ def _do_scheduling(self, session: Session) -> int:

return num_queued_tis

def _check_rollup_asset_status(
def _warn_unreachable_asset_partition(
self,
*,
asset_id: int,
apdr: AssetPartitionDagRun,
mapper: RollupMapper,
actual_by_asset: dict[int, set[str]],
) -> bool:
expected = mapper.to_upstream(apdr.partition_key)
return expected.issubset(actual_by_asset.get(asset_id, set()))
name: str,
uri: str,
reason: str | None,
) -> None:
"""
Emit a warning that a rollup asset partition can never satisfy its wait policy.

The warning is deduplicated per ``(target_dag_id, name, uri)`` so a stuck APDR
is surfaced once rather than on every scheduler tick.
"""
unreachable_key = (apdr.target_dag_id, name, uri)
if unreachable_key in self._partition_unreachable_seen:
return
self.log.warning(
"Rollup asset (name=%r, uri=%r) on Dag %r is permanently unreachable: %s",
name,
uri,
apdr.target_dag_id,
reason,
)
self._partition_unreachable_seen.add(unreachable_key)

def _resolve_asset_partition_status(
self,
Expand All @@ -1952,8 +1973,9 @@ def _resolve_asset_partition_status(
Non-rollup assets resolve to ``True`` because the caller only invokes
this for assets that already have at least one logged event for *APDR*
(see :class:`~airflow.models.asset.PartitionedAssetKeyLog`), which is
the non-rollup contract for "received". Rollup assets defer to
:meth:`_check_rollup_asset_status` for the upstream-window check.
the non-rollup contract for "received". Rollup assets delegate to
:meth:`~airflow.partition_mappers.wait_policy.WaitPolicy.is_satisfied_by_keys`
for the upstream-window check.

A misconfigured mapper that raises returns ``False`` (treated as
not-yet-satisfied); the exception is logged at ``ERROR`` level in the
Expand All @@ -1963,12 +1985,21 @@ def _resolve_asset_partition_status(
mapper = timetable.get_partition_mapper(name=name, uri=uri)
if not is_rollup(mapper):
return True
return self._check_rollup_asset_status(
asset_id=asset_id,
apdr=apdr,
mapper=mapper,
actual_by_asset=actual_by_asset,
)
if TYPE_CHECKING:
assert apdr.partition_key is not None
expected = mapper.to_upstream(apdr.partition_key)
actual = actual_by_asset.get(asset_id, set())

# The policy returns both the satisfaction result and, when permanently
# unreachable, a ready-made reason string. Dedup and forwarding are the
# scheduler's responsibility; the policy owns the message content.
result = mapper.wait_policy.is_satisfied_by_keys(matched=actual, expected=expected)
if result.unreachable:
self._warn_unreachable_asset_partition(
apdr=apdr, name=name, uri=uri, reason=result.unreachable_reason
)
return False
return result.satisfied
except Exception:
self.log.exception(
"Failed to evaluate rollup status for asset; treating as not-yet-satisfied. "
Expand Down
38 changes: 32 additions & 6 deletions airflow-core/src/airflow/partition_mappers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, ClassVar, TypeGuard

from airflow.partition_mappers.wait_policy import WaitForAll, WaitPolicy

if TYPE_CHECKING:
from collections.abc import Iterable
from datetime import datetime
Expand Down Expand Up @@ -129,16 +131,23 @@ class RollupMapper(PartitionMapper):
"""
Partition mapper that rolls up many upstream keys into one downstream key.

Compose a ``upstream_mapper`` (which normalizes each upstream key to the
Compose an ``upstream_mapper`` (which normalizes each upstream key to the
downstream granularity) with a ``window`` that declares the full set of
upstream keys required for a given downstream key. The scheduler holds
the Dag run until every upstream key in the window has arrived.
upstream keys required for a given downstream key, and a
``wait_policy`` that decides when the downstream Dag run fires given
the expected window and the upstream keys that have actually arrived.
The default policy waits for every expected upstream key.
"""

is_rollup: ClassVar[bool] = True

def __init__(
self, *, upstream_mapper: PartitionMapper, window: Window, max_downstream_keys: int | None = None
self,
*,
upstream_mapper: PartitionMapper,
window: Window,
wait_policy: WaitPolicy | None = None,
Comment thread
Lee-W marked this conversation as resolved.
max_downstream_keys: int | None = None,
) -> None:
decode_overridden = type(upstream_mapper).decode_downstream is not PartitionMapper.decode_downstream
if not decode_overridden and window.expected_decoded_type is not str:
Expand All @@ -151,9 +160,12 @@ def __init__(
f"{window.expected_decoded_type.__name__}, or use a window whose "
f"'expected_decoded_type' accepts str."
)
if wait_policy is None:
wait_policy = WaitForAll()
super().__init__(max_downstream_keys=max_downstream_keys)
self.upstream_mapper = upstream_mapper
self.window = window
self.wait_policy = wait_policy

def to_downstream(self, key: str) -> str | Iterable[str]:
return self.upstream_mapper.to_downstream(key)
Expand All @@ -172,23 +184,37 @@ def to_partition_date(self, downstream_key: str) -> datetime | None:
return self.upstream_mapper.to_partition_date(downstream_key)

def serialize(self) -> dict[str, Any]:
from airflow.serialization.encoders import encode_partition_mapper, encode_window
# Builtin RollupMappers serialize through ``encode_partition_mapper``
# (encoders.py), not this method. Keep the two in sync: a new field must
# be added there (and to ``encode_wait_policy``) too, or it is silently
# dropped for builtin instances.
from airflow.serialization.encoders import (
encode_partition_mapper,
encode_wait_policy,
encode_window,
)

data: dict[str, Any] = {
"upstream_mapper": encode_partition_mapper(self.upstream_mapper),
"window": encode_window(self.window),
"wait_policy": encode_wait_policy(self.wait_policy),
}
if self.max_downstream_keys is not None:
data["max_downstream_keys"] = self.max_downstream_keys
return data

@classmethod
def deserialize(cls, data: dict[str, Any]) -> PartitionMapper:
from airflow.serialization.decoders import decode_partition_mapper, decode_window
from airflow.serialization.decoders import (
decode_partition_mapper,
decode_wait_policy,
decode_window,
)

return cls(
upstream_mapper=decode_partition_mapper(data["upstream_mapper"]),
window=decode_window(data["window"]),
wait_policy=decode_wait_policy(data["wait_policy"]),
Comment thread
Lee-W marked this conversation as resolved.
max_downstream_keys=data.get("max_downstream_keys"),
)

Expand Down
174 changes: 174 additions & 0 deletions airflow-core/src/airflow/partition_mappers/wait_policy.py
Loading