Skip to content

Commit 953aa79

Browse files
authored
Add FanOutMapper for one-to-many partition fan-out (apache#66030)
1 parent ccf34ff commit 953aa79

16 files changed

Lines changed: 865 additions & 8 deletions

File tree

.pre-commit-config.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,16 @@ repos:
248248
files: ^\.github/workflows/ci-(arm|amd)\.yml$
249249
pass_filenames: false
250250
require_serial: true
251+
- id: check-partition-mapper-defaults-in-sync
252+
name: Check FanOutMapper default mapper table stays in sync (core/SDK)
253+
entry: ./scripts/ci/prek/check_partition_mapper_defaults_in_sync.py
254+
language: python
255+
files: >
256+
(?x)
257+
^airflow-core/src/airflow/partition_mappers/temporal\.py$|
258+
^task-sdk/src/airflow/sdk/definitions/partition_mappers/temporal\.py$
259+
pass_filenames: false
260+
require_serial: true
251261
- id: sync-uv-min-version-markers
252262
name: Sync `# sync-uv-min-version` markers with [tool.uv] required-version
253263
entry: ./scripts/ci/prek/sync_uv_min_version_markers.py
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add ``FanOutMapper`` for one-to-many partition mapping (e.g. one weekly upstream key to seven daily downstream Dag runs). It composes ``upstream_mapper + window + downstream_mapper``, mirroring the shape of ``RollupMapper`` and reusing the existing ``Window`` classes (``DayWindow``, ``WeekWindow``, ``MonthWindow``, ``QuarterWindow``, ``YearWindow``). A new ``[scheduler] partition_mapper_max_downstream_keys`` config caps the number of downstream keys produced per upstream event by any ``PartitionMapper`` (built-in or custom). When the cap is exceeded, no Dag runs are queued for that upstream event and a ``Log`` row with ``event="partition fan-out exceeded"`` is written against the source task instance, recording the asset, target Dag, key count, and cap value.

airflow-core/src/airflow/assets/manager.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,8 @@ def _queue_partitioned_dags(
552552
)
553553
return
554554

555+
max_downstream_keys = conf.getint("scheduler", "partition_mapper_max_downstream_keys")
556+
555557
for target_dag in partition_dags:
556558
if TYPE_CHECKING:
557559
assert partition_key is not None
@@ -600,15 +602,36 @@ def _queue_partitioned_dags(
600602
continue
601603

602604
if is_container(target_key):
603-
# TODO (AIP-76): This never happens now. When we implement
604-
# one-to-many partition key mapping, this should also add a
605-
# config to cap the iterable size so the scheduler does not
606-
# blow up with an incorrectly implemented PartitionMapper.
607-
target_keys: Iterable[str] = target_key
605+
target_keys: list[str] = list(target_key)
608606
else:
609607
target_keys = [target_key]
610608
del target_key
611609

610+
if len(target_keys) > max_downstream_keys:
611+
log.error(
612+
"Partition mapper produced more downstream keys than allowed; skipping queue.",
613+
asset_id=asset_id,
614+
source_partition_key=partition_key,
615+
target_dag=target_dag.dag_id,
616+
produced_keys=len(target_keys),
617+
max_downstream_keys=max_downstream_keys,
618+
)
619+
session.add(
620+
Log(
621+
event="partition fan-out exceeded",
622+
extra=(
623+
f"Partition mapper for asset (name='{asset_model.name}', "
624+
f"uri='{asset_model.uri}') in target Dag '{target_dag.dag_id}' "
625+
f"produced {len(target_keys)} downstream keys from "
626+
f"partition_key='{partition_key}', exceeding "
627+
f"[scheduler] partition_mapper_max_downstream_keys={max_downstream_keys}. "
628+
f"No Dag runs were queued for this event."
629+
),
630+
task_instance=task_instance,
631+
)
632+
)
633+
continue
634+
612635
for target_key in target_keys:
613636
apdr = cls._get_or_create_apdr(
614637
target_key=target_key,

airflow-core/src/airflow/config_templates/config.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2688,6 +2688,19 @@ scheduler:
26882688
type: integer
26892689
default: "20"
26902690
see_also: ":ref:`scheduler:ha:tunables`"
2691+
partition_mapper_max_downstream_keys:
2692+
description: |
2693+
Maximum number of downstream partition keys a single ``PartitionMapper``
2694+
invocation may produce. When any partition mapper (built-in or custom)
2695+
expands one upstream key into more keys than this limit, the scheduler
2696+
skips queuing the runs for that asset event and logs an error against
2697+
the source task instance. This guards against a misconfigured
2698+
``PartitionMapper`` from queuing an unbounded number of Dag runs per
2699+
upstream event.
2700+
version_added: 3.3.0
2701+
type: integer
2702+
example: ~
2703+
default: "1000"
26912704
use_job_schedule:
26922705
description: |
26932706
Turn off scheduler use of cron intervals by setting this to ``False``.

airflow-core/src/airflow/example_dags/example_asset_partition.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
Asset,
2626
CronPartitionTimetable,
2727
DayWindow,
28+
FanOutMapper,
2829
IdentityMapper,
2930
MonthWindow,
3031
PartitionAtRuntime,
@@ -34,7 +35,9 @@
3435
StartOfDayMapper,
3536
StartOfHourMapper,
3637
StartOfMonthMapper,
38+
StartOfWeekMapper,
3739
StartOfYearMapper,
40+
WeekWindow,
3841
asset,
3942
task,
4043
)
@@ -357,3 +360,52 @@ def summarise_team_a_month(dag_run=None):
357360
print(f"All daily partitions received. Month: {dag_run.partition_key}")
358361

359362
summarise_team_a_month()
363+
364+
365+
# --- Fan-out: one weekly upstream → seven daily downstream Dag runs ----------
366+
367+
weekly_model_artifact = Asset(uri="file://artifacts/models/weekly.bin", name="weekly_model_artifact")
368+
369+
370+
with DAG(
371+
dag_id="train_weekly_model",
372+
schedule=CronPartitionTimetable("0 0 * * 1", timezone="UTC"),
373+
catchup=False,
374+
tags=["example", "model", "training"],
375+
):
376+
"""Train a weekly model artifact every Monday at 00:00 UTC."""
377+
378+
@task(outlets=[weekly_model_artifact])
379+
def train_model():
380+
"""Materialize the model artifact for the current weekly partition."""
381+
pass
382+
383+
train_model()
384+
385+
386+
with DAG(
387+
dag_id="daily_inference",
388+
schedule=PartitionedAssetTimetable(
389+
assets=weekly_model_artifact,
390+
# FanOutMapper composes upstream_mapper + window + (optional) downstream_mapper.
391+
# WeekWindow.to_upstream() yields seven daily datetimes inside one week,
392+
# and the default downstream_mapper for WeekWindow is StartOfDayMapper, so
393+
# a weekly upstream key fans out to seven ``%Y-%m-%d`` downstream keys.
394+
default_partition_mapper=FanOutMapper(
395+
upstream_mapper=StartOfWeekMapper(),
396+
window=WeekWindow(),
397+
),
398+
),
399+
catchup=False,
400+
tags=["example", "model", "inference"],
401+
):
402+
"""Run daily inference, fanning the weekly model artifact out to one Dag run per day."""
403+
404+
@task
405+
def run_inference(dag_run=None):
406+
"""Run inference for one daily partition derived from the weekly model."""
407+
if TYPE_CHECKING:
408+
assert dag_run
409+
print(dag_run.partition_key)
410+
411+
run_inference()

airflow-core/src/airflow/partition_mappers/temporal.py

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@
1818

1919
import re
2020
from abc import abstractmethod
21+
from collections.abc import Iterable
2122
from datetime import datetime, timedelta
22-
from typing import TYPE_CHECKING, Any
23+
from typing import TYPE_CHECKING, Any, ClassVar
2324

2425
from airflow._shared.timezones.timezone import make_aware, parse_timezone
2526
from airflow.partition_mappers.base import PartitionMapper
2627

2728
if TYPE_CHECKING:
2829
from pendulum import FixedTimezone, Timezone
2930

31+
from airflow.partition_mappers.window import Window
32+
3033

3134
_STRPTIME_PATTERNS: dict[str, str] = {
3235
"%Y": r"\d{4}",
@@ -419,3 +422,132 @@ def normalize(self, dt: datetime) -> datetime:
419422
second=0,
420423
microsecond=0,
421424
)
425+
426+
427+
class FanOutMapper(PartitionMapper):
428+
"""
429+
Partition mapper that fans one upstream key out into multiple downstream keys.
430+
431+
Compose an ``upstream_mapper`` (parses the coarse upstream key and
432+
normalizes it to its period start) with a ``window`` (enumerates the
433+
members of that period). ``downstream_mapper`` formats each member into a
434+
downstream key string; if omitted, a default is chosen from the window
435+
class.
436+
437+
``downstream_mapper`` must be passed explicitly for any window without an
438+
entry in the default table — currently ``HourWindow`` and any custom
439+
``Window`` subclass. Constructing a ``FanOutMapper`` for those windows
440+
without a ``downstream_mapper`` raises ``ValueError`` at Dag-parse time.
441+
442+
Symmetric to :class:`~airflow.partition_mappers.base.RollupMapper`: rollup
443+
is N→1 (downstream waits until all members arrive), fan-out is 1→N (one
444+
upstream event creates one downstream Dag run per member).
445+
446+
.. code-block:: python
447+
448+
# Weekly upstream → 7 daily downstream Dag runs
449+
FanOutMapper(upstream_mapper=StartOfWeekMapper(), window=WeekWindow())
450+
"""
451+
452+
# Keep ``FanOutMapper.default_downstream_mapper_by_window_name`` in sync with
453+
# the SDK copy in
454+
# ``task-sdk/src/airflow/sdk/definitions/partition_mappers/temporal.py`` —
455+
# the SDK and core class hierarchies are independent (the SDK cannot import
456+
# core), so both sides carry the same defaults and the lookup is by class
457+
# name. When adding a new ``Window`` subclass, extend the table on both
458+
# sides; a missing entry raises ``ValueError`` at ``FanOutMapper.__init__``
459+
# (see ``FanOutMapper._resolve_default_downstream_mapper``). The
460+
# ``check-partition-mapper-defaults-in-sync`` prek hook enforces that the
461+
# two tables stay identical.
462+
default_downstream_mapper_by_window_name: ClassVar[dict[str, type[_BaseTemporalMapper]]] = {
463+
"DayWindow": StartOfHourMapper,
464+
"WeekWindow": StartOfDayMapper,
465+
"MonthWindow": StartOfDayMapper,
466+
"QuarterWindow": StartOfMonthMapper,
467+
"YearWindow": StartOfMonthMapper,
468+
}
469+
470+
@classmethod
471+
def _resolve_default_downstream_mapper(cls, window: Window) -> PartitionMapper:
472+
"""
473+
Return the conventional downstream mapper for *window*.
474+
475+
Looked up by the window's class **name** rather than identity so that
476+
the SDK ``Window`` classes (used in Dag-author code) and the core
477+
``Window`` classes (used after deserialization) both resolve to the
478+
same default. Subclasses can extend or override the defaults by
479+
setting :attr:`default_downstream_mapper_by_window_name` on the
480+
subclass.
481+
"""
482+
mapper_cls = cls.default_downstream_mapper_by_window_name.get(type(window).__name__)
483+
if mapper_cls is None:
484+
raise ValueError(
485+
f"{cls.__name__} has no default downstream_mapper for window type "
486+
f"{type(window).__name__}; pass downstream_mapper explicitly."
487+
)
488+
return mapper_cls()
489+
490+
def __init__(
491+
self,
492+
*,
493+
upstream_mapper: PartitionMapper,
494+
window: Window,
495+
downstream_mapper: PartitionMapper | None = None,
496+
) -> None:
497+
self.upstream_mapper = upstream_mapper
498+
self.window = window
499+
self.downstream_mapper = downstream_mapper or self._resolve_default_downstream_mapper(window)
500+
501+
def to_downstream(self, key: str) -> Iterable[str]:
502+
# Round-trip the upstream key through its mapper to obtain the
503+
# period-start datetime (decoded form). This keeps the upstream_mapper
504+
# opaque — we don't need to know whether it's temporal or segment.
505+
formatted = self.upstream_mapper.to_downstream(key)
506+
if not isinstance(formatted, str):
507+
raise TypeError(
508+
"FanOutMapper.upstream_mapper must produce a single key from "
509+
"to_downstream; chained fan-out (mapper that itself returns multiple keys) "
510+
"is not supported."
511+
)
512+
coarse = self.upstream_mapper.decode_downstream(formatted)
513+
return [_format_with(self.downstream_mapper, item) for item in self.window.to_upstream(coarse)]
514+
515+
def serialize(self) -> dict[str, Any]:
516+
from airflow.serialization.encoders import encode_partition_mapper, encode_window
517+
518+
return {
519+
"upstream_mapper": encode_partition_mapper(self.upstream_mapper),
520+
"window": encode_window(self.window),
521+
"downstream_mapper": encode_partition_mapper(self.downstream_mapper),
522+
}
523+
524+
@classmethod
525+
def deserialize(cls, data: dict[str, Any]) -> PartitionMapper:
526+
from airflow.serialization.decoders import decode_partition_mapper, decode_window
527+
528+
return cls(
529+
upstream_mapper=decode_partition_mapper(data["upstream_mapper"]),
530+
window=decode_window(data["window"]),
531+
downstream_mapper=decode_partition_mapper(data["downstream_mapper"]),
532+
)
533+
534+
535+
def _format_with(mapper: PartitionMapper, decoded: Any) -> str:
536+
"""
537+
Format *decoded* using *mapper*'s downstream format.
538+
539+
Three-layer fallback, in order:
540+
541+
1. *mapper* exposes a callable ``format`` attribute (all temporal mappers do)
542+
— delegates to ``mapper.format(decoded)``, which uses ``output_format``.
543+
2. *decoded* is a ``datetime`` instance — returns ``decoded.isoformat()``,
544+
producing a stable ISO-8601 string that round-trips via
545+
``datetime.fromisoformat``.
546+
3. Anything else — ``str(decoded)`` as a last-resort fallback.
547+
"""
548+
formatter = getattr(mapper, "format", None)
549+
if callable(formatter):
550+
return formatter(decoded)
551+
if isinstance(decoded, datetime):
552+
return decoded.isoformat()
553+
return str(decoded)

airflow-core/src/airflow/serialization/encoders.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
DeltaDataIntervalTimetable,
4343
DeltaTriggerTimetable,
4444
EventsTimetable,
45+
FanOutMapper,
4546
HourWindow,
4647
IdentityMapper,
4748
MonthWindow,
@@ -433,6 +434,7 @@ def _(self, timetable: PartitionedAssetTimetable) -> dict[str, Any]:
433434
BUILTIN_PARTITION_MAPPERS: dict[type, str] = {
434435
AllowedKeyMapper: "airflow.partition_mappers.allowed_key.AllowedKeyMapper",
435436
ChainMapper: "airflow.partition_mappers.chain.ChainMapper",
437+
FanOutMapper: "airflow.partition_mappers.temporal.FanOutMapper",
436438
IdentityMapper: "airflow.partition_mappers.identity.IdentityMapper",
437439
ProductMapper: "airflow.partition_mappers.product.ProductMapper",
438440
RollupMapper: "airflow.partition_mappers.base.RollupMapper",
@@ -499,6 +501,14 @@ def _(self, partition_mapper: RollupMapper) -> dict[str, Any]:
499501
"window": encode_window(partition_mapper.window),
500502
}
501503

504+
@serialize_partition_mapper.register
505+
def _(self, partition_mapper: FanOutMapper) -> dict[str, Any]:
506+
return {
507+
"upstream_mapper": encode_partition_mapper(partition_mapper.upstream_mapper),
508+
"window": encode_window(partition_mapper.window),
509+
"downstream_mapper": encode_partition_mapper(partition_mapper.downstream_mapper),
510+
}
511+
502512
BUILTIN_WINDOWS: dict[type, str] = {
503513
HourWindow: "airflow.partition_mappers.window.HourWindow",
504514
DayWindow: "airflow.partition_mappers.window.DayWindow",

0 commit comments

Comments
 (0)