potiuk commented on code in PR #72475:
URL: https://github.com/apache/airflow/pull/72475#discussion_r4072545001


##########
airflow-core/src/airflow/timetables/_cron.py:
##########
@@ -61,15 +62,54 @@ def _covers_every_hour(cron: croniter) -> bool:
 
 
 class CronMixin:
-    """Mixin to provide interface to work with croniter."""
+    """
+    Mixin to provide interface to work with croniter.
+
+    Optionally applies a deterministic, per-DAG jitter to every scheduled time.
+    When ``max_jitter`` is set, each cron boundary is shifted by a fixed offset
+    derived from ``seed`` and spread across ``[0, max_jitter)``. This spreads 
out
+    DAGs that share a cron expression (e.g. every ``@daily`` DAG firing at
+    midnight) so they no longer all fire at the same instant. The offset is 
stable
+    for a given seed, so runs stay predictable across scheduler restarts and
+    serialization.
+
+    The offset shifts every cron-derived time uniformly. For data-interval
+    timetables this means the whole interval moves by the offset (the window 
keeps
+    its length and consecutive intervals stay contiguous), not just the fire 
time.
+
+    :param cron: cron expression (or a preset such as ``@daily``) defining the 
schedule.
+    :param timezone: timezone used to interpret the cron expression.
+    :param seed: stable, unique-per-DAG string the offset is derived from; the 
DAG id
+        is a natural choice. Must be non-empty whenever ``max_jitter`` is set.
+    :param max_jitter: upper bound of the jitter window; the offset falls in
+        ``[0, max_jitter)``. Defaults to zero, i.e. no jitter. Keep it small 
relative
+        to the gap between cron boundaries.
+    """
 
-    def __init__(self, cron: str, timezone: str | Timezone | FixedTimezone) -> 
None:
+    def __init__(
+        self,
+        cron: str,
+        timezone: str | Timezone | FixedTimezone,
+        *,
+        seed: str = "",
+        max_jitter: datetime.timedelta = datetime.timedelta(),
+    ) -> None:
         self._expression = cron_presets.get(cron, cron)
 
         if isinstance(timezone, str):
             timezone = parse_timezone(timezone)
         self._timezone = timezone
 
+        if max_jitter > datetime.timedelta(0) and not seed:
+            raise ValueError("seed must be a non-empty, unique-per-DAG string 
when max_jitter > 0")

Review Comment:
   `minor` — a negative `max_jitter` slips through both guards: this check is 
`max_jitter > timedelta(0)`, and the offset branch below is `if max_jitter_us > 
0`, so the offset ends up zero. But `if self._max_jitter:` in `serialize()` is 
truthy for a negative timedelta, so the payload still gets `"max_jitter": 
-3600.0` and `"seed": ""`. Silently doing nothing while persisting a 
nonsensical value — worth rejecting `max_jitter < 0` outright here.



##########
airflow-core/src/airflow/timetables/_cron.py:
##########
@@ -61,15 +62,54 @@ def _covers_every_hour(cron: croniter) -> bool:
 
 
 class CronMixin:
-    """Mixin to provide interface to work with croniter."""
+    """
+    Mixin to provide interface to work with croniter.
+
+    Optionally applies a deterministic, per-DAG jitter to every scheduled time.
+    When ``max_jitter`` is set, each cron boundary is shifted by a fixed offset
+    derived from ``seed`` and spread across ``[0, max_jitter)``. This spreads 
out
+    DAGs that share a cron expression (e.g. every ``@daily`` DAG firing at
+    midnight) so they no longer all fire at the same instant. The offset is 
stable
+    for a given seed, so runs stay predictable across scheduler restarts and
+    serialization.
+
+    The offset shifts every cron-derived time uniformly. For data-interval
+    timetables this means the whole interval moves by the offset (the window 
keeps
+    its length and consecutive intervals stay contiguous), not just the fire 
time.
+
+    :param cron: cron expression (or a preset such as ``@daily``) defining the 
schedule.
+    :param timezone: timezone used to interpret the cron expression.
+    :param seed: stable, unique-per-DAG string the offset is derived from; the 
DAG id
+        is a natural choice. Must be non-empty whenever ``max_jitter`` is set.
+    :param max_jitter: upper bound of the jitter window; the offset falls in
+        ``[0, max_jitter)``. Defaults to zero, i.e. no jitter. Keep it small 
relative
+        to the gap between cron boundaries.
+    """
 
-    def __init__(self, cron: str, timezone: str | Timezone | FixedTimezone) -> 
None:
+    def __init__(
+        self,
+        cron: str,
+        timezone: str | Timezone | FixedTimezone,
+        *,
+        seed: str = "",
+        max_jitter: datetime.timedelta = datetime.timedelta(),
+    ) -> None:
         self._expression = cron_presets.get(cron, cron)
 
         if isinstance(timezone, str):
             timezone = parse_timezone(timezone)
         self._timezone = timezone
 
+        if max_jitter > datetime.timedelta(0) and not seed:
+            raise ValueError("seed must be a non-empty, unique-per-DAG string 
when max_jitter > 0")
+        h = int(md5(seed.encode()).hexdigest(), 16)

Review Comment:
   `nit` — this runs on every `CronMixin` construction, including the default 
no-jitter path, i.e. for every cron Dag on every deserialization in the 
scheduler. It is cheap, but it only matters when `max_jitter_us > 0`, so it 
could move inside the branch below.



##########
airflow-core/src/airflow/timetables/trigger.py:
##########
@@ -406,8 +441,12 @@ def __init__(
         run_offset: int | datetime.timedelta | relativedelta | None = None,
         run_immediately: bool | datetime.timedelta = False,
         key_format: str = r"%Y-%m-%dT%H:%M:%S",
+        seed: str = "",
+        max_jitter: datetime.timedelta = datetime.timedelta(),

Review Comment:
   **Partition keys absorb the jitter here.**
   
   `CronPartitionTimetable` inherits the shifted `_get_next`/`_align_to_next`, 
and `_get_partition_date()` returns `run_date` unchanged for `run_offset == 0`, 
so `_format_key()` formats the *jittered* instant. Using this PR's own offset 
function:
   
   ```text
   seed="my_dag", max_jitter=1h  -> offset 0:58:51.663322
     cron "0 0 * * *"            -> key 2026-03-06T00:58:51  (was 
2026-03-06T00:00:00)
   
   seed="dag_4",  max_jitter=2h  -> offset 1:03:38.886020
     cron "0 23 * * *", key_format="%Y-%m-%d"
                                 -> key 2026-03-07           (was 2026-03-06)
   ```
   
   Jitter should move *when* a run executes, not *which period it is for*. The 
partition key is the run's identity — `iter_partition_dagrun_infos` dedups 
backfills on it and it usually maps to a storage path — so switching jitter on 
for an existing Dag renumbers every partition and stops it deduping against the 
old ones. The second case shows the calendar date itself flipping once 
`key_format` is coarser than the offset.
   
   Suggest stripping the offset before the partition date is derived, so the 
key stays on the boundary while the run still fires late. If the shifted key is 
intended, it needs a line in the new docs section and a test — the 
`CronPartitionTimetable` tests currently only assert the serialize round-trip.



##########
airflow-core/src/airflow/timetables/_cron.py:
##########
@@ -120,18 +168,26 @@ def _describe_with_dom_dow_fix(self, expression: str) -> 
str:
 
     def __eq__(self, other: object) -> bool:
         """
-        Both expression and timezone should match.
+        Expression, timezone and jitter settings (``seed`` and ``max_jitter``) 
should all match.
+
+        Two timetables that share a cron expression and timezone but differ in 
jitter
+        produce different schedules, so they are not considered equal.
 
         This is only for testing purposes and should not be relied on 
otherwise.
         """
         from airflow.serialization.encoders import coerce_to_core_timetable
 
         if not isinstance(other := coerce_to_core_timetable(other), 
type(self)):
             return NotImplemented
-        return self._expression == other._expression and self._timezone == 
other._timezone
+        return (
+            self._expression == other._expression
+            and self._timezone == other._timezone
+            and self._seed == other._seed
+            and self._max_jitter == other._max_jitter
+        )
 
     def __hash__(self):
-        return hash((self._expression, self._timezone))
+        return hash((self._expression, str(self._timezone), self._seed, 
self._max_jitter))

Review Comment:
   Confirmed this is a real latent bug, not a theoretical one — on current 
`main`, `hash(CronTriggerTimetable("0 0 * * *", timezone="UTC"))` raises 
`TypeError: unhashable type: 'Timezone'`. Good catch.
   
   Since it is independent of the jitter feature and backportable on its own, 
consider pulling it into a separate PR so it can land without waiting on this 
one.



##########
task-sdk/src/airflow/sdk/definitions/timetables/_cron.py:
##########
@@ -41,16 +42,33 @@
 
 @attrs.define
 class CronMixin:
-    """Mixin to provide interface to work with croniter."""
+    """
+    Mixin to provide interface to work with croniter.
+
+    Optionally applies a deterministic, per-DAG jitter: when ``max_jitter`` is 
set, every
+    cron boundary is shifted by a fixed offset derived from ``seed`` and 
spread across
+    ``[0, max_jitter)``, so DAGs sharing a cron expression no longer all fire 
at the same
+    instant. The offset is computed scheduler-side; this class only carries 
and validates
+    the settings.
+
+    :param seed: stable, unique-per-DAG string the offset is derived from (the 
DAG id is a
+        natural choice). Must be non-empty whenever ``max_jitter`` is set.
+    :param max_jitter: upper bound of the jitter window; the offset falls in
+        ``[0, max_jitter)``. Defaults to zero, i.e. no jitter.
+    """
 
     expression: str
     timezone: str | Timezone | FixedTimezone
+    seed: str = attrs.field(kw_only=True, default="")
+    max_jitter: datetime.timedelta = attrs.field(kw_only=True, 
default=datetime.timedelta())
 
     def __attrs_post_init__(self) -> None:
         # Resolve preset aliases (e.g. "@quarterly") to their cron expressions
         # in-place. After this point the original preset string is lost;
         # attrs.evolve, equality, and serialisation all see the resolved form.
         self.expression = CRON_PRESETS.get(self.expression, self.expression)
+        if self.max_jitter > datetime.timedelta(0) and not self.seed:
+            raise ValueError("seed must be a non-empty, unique-per-DAG string 
when max_jitter > 0")

Review Comment:
   `minor` — this guard is only exercised by 
`test_empty_seed_requires_zero_jitter` in 
`airflow-core/tests/unit/timetables/`, so `breeze testing task-sdk-tests` — 
which runs the Task SDK distribution standalone — does not cover it. 
`task-sdk/tests/task_sdk/definitions/timetables/test__cron.py` already exists 
and is the natural home for it.



##########
airflow-core/src/airflow/timetables/_cron.py:
##########
@@ -61,15 +62,54 @@ def _covers_every_hour(cron: croniter) -> bool:
 
 
 class CronMixin:
-    """Mixin to provide interface to work with croniter."""
+    """
+    Mixin to provide interface to work with croniter.
+
+    Optionally applies a deterministic, per-DAG jitter to every scheduled time.

Review Comment:
   `minor` — `AGENTS.md` asks for `Dag` in prose:
   
   > Write **Dag** (title case) in all prose. Keep the all-caps or lowercase 
spelling only when reproducing a literal code token
   
   This docstring has "per-DAG jitter", "every `@daily` DAG", "the DAG id is a 
natural choice"; the same applies to the `:param seed:` docs in `trigger.py`, 
both task-sdk files, the test docstrings, and the `ValueError` message 
("unique-per-DAG string"). The `.rst` section added in this PR already uses 
`Dag`, so it is just inconsistent within the PR.



-- 
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