Fix TimeSensor dag_version churn via TimeOfDayTrigger - #69746
Conversation
|
Reviewers: @ashb (core serialization / sensors) + @potiuk @kaxil for standard provider. This fixes dag_version churn by moving target_datetime computation to execution time. Fixes #69543 Drafted-by: Muse Spark 1.1; reviewed by @Vamsi-klu before posting |
0b7813f to
98eeb77
Compare
potiuk
left a comment
There was a problem hiding this comment.
Thanks — the underlying diagnosis is right and worth fixing: baking target_datetime into start_trigger_args at parse time means the serialized Dag hash changes every day, and the hash-stability test you added is a good way to pin that.
But the fix is to remove start-from-trigger support from TimeSensor, and that's a bigger call than the PR framing suggests. Anyone using TimeSensor(start_from_trigger=True) today has their sensor start directly in the Triggerer; after this it warns, is ignored, and the task instead occupies a worker slot to poke or defer. That's a functional regression for those users, not just churn cleanup.
Concretely that needs: a major version bump on apache-airflow-providers-standard (currently 1.16.0) and an entry under the Changelog header in providers/standard/docs/changelog.rst spelling out the required user action. Neither is in the diff. (No newsfragment — providers don't use those.)
The design question I'd like your view on: was removal the only option? Computing moment lazily — so start_trigger_args serializes a stable placeholder and the concrete datetime is resolved when the trigger actually starts — would fix the hash churn while keeping the feature. If that was tried and doesn't work, saying why in the description would help; if it wasn't, it seems worth a look before dropping a documented capability.
One correction to something I'd have flagged: trigger_kwargs is already accepted-and-ignored on main (it's declared in __init__ but never read), so dropping it here changes nothing. Not an issue with this PR.
To be clear about where this stands: the points above are mostly design questions rather than defects, and I'd rather you formed your own view on them than took mine as settled. My review here was AI-assisted, and I'd guess parts of this PR were too — that's fine on both sides, but it means neither of us should treat the output as authoritative. Please push back where you think the reasoning is wrong, and say what you think the right trade-off is between fixing the hash churn and keeping the feature.
I'd also like other maintainers to weigh in before this is decided. Removing a documented capability from a released provider isn't a call I want to make on my own, and folks closer to the start-from-trigger design will have better context here than I do.
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
a6b7902 to
0d07ffb
Compare
|
@potiuk Thanks for the careful review — you're right that removing start_from_trigger was the wrong trade-off. I've reworked this to keep the feature. Two defects (not one)Worth leading with the shared-class bug, because it is independent of the API discussion:
Design: TimeOfDayTrigger (API preserved, no major bump)I took the lazy-moment path you suggested rather than dropping the feature.
No major bump: public API is preserved, so this is a minor bugfix. Providers don't use newsfragments; no breaking-change Changelog entry under the Changelog header. Inline notes
DST
Tests (39 passed)Headline: serialize the same Dag twice with datetime.now() mocked to two different values → LazyDeserializedDAG hash and to_dict() identical. Also: already-passed today, midnight cache, DST spring/fall, no-Dag UTC fallback, TimeSensorAsync, FixedTimezone + named tz round-trip, two sensors don't share start_trigger_args, end_from_trigger on both start_from_trigger and defer paths, start_from_trigger still in inspect.signature. Happy to adjust further if other maintainers want a different DST policy or want TimeOfDayTrigger to live elsewhere. |
0d07ffb to
962e1b4
Compare
|
You were right that removal wasn't the only option, and I ended up building the lazy version you suggested. start_from_trigger stays, so no major bump: there's a new TimeOfDayTrigger that stores the wall-clock time plus timezone and only resolves the concrete UTC moment when the trigger actually starts, then hands off to DateTimeTrigger's existing wait loop. start_trigger_args is built per instance now, which also kills the shared-class mutation bug. There are tests that serialize the same Dag under two different frozen clocks and expect byte-identical payloads, so the hash churn can't quietly come back. The earlier inline comments (the kwargs.pop signature, recomputed target_datetime, the getattr UTC fallback) were all about the removal version. That code doesn't exist anymore. The latest push also fixes the three CI failures that were mine: an RST error in the new trigger docstring that broke the docs build, the pendulum in_timezone typing, and the serialization tests now pick whichever API exists on the running Airflow (2.11 through main) instead of assuming LazyDeserializedDAG.from_dag. On DST, the semantics I went with: a target time inside the spring-forward gap shifts to the next valid wall time, an ambiguous fall-back time takes the first occurrence, and "already passed today" still fires today rather than rolling to tomorrow. All documented and tested, but if maintainers want different behavior I'm fine changing it. A second pair of eyes on the trigger serialization would be welcome. Drafted-by: Claude Code (Fable 5); reviewed by @Vamsi-klu before posting |
962e1b4 to
fc509fb
Compare
fc509fb to
8b332c5
Compare
TimeSensor(start_from_trigger=True) had two defects: 1. __init__ mutated the class-level StartTriggerArgs.trigger_kwargs, so every TimeSensor shared the last-constructed sensor's moment. 2. That moment was an absolute datetime from datetime.now() at parse time, so each Dag-processor parse produced a different serialized blob and a new DagVersion. Preserve start_from_trigger by introducing TimeOfDayTrigger, which stores only parse-stable target_time + timezone and resolves the concrete moment when the trigger starts. TimeSensor builds a per-instance StartTriggerArgs (never mutates the class attribute). No major version bump: the public API is preserved. Fixes: apache#69543
The temporal trigger docstring broke the generated API docs page (bullet list without a preceding blank line). Pendulum's in_timezone rejects a generic tzinfo in its type stubs, so normalize to a pendulum zone at the boundary. The serialization-stability tests used LazyDeserializedDAG.from_dag, which does not exist on Airflow 2.11/3.0-3.2 - pick the serialization API available on the running version instead.
A class-level TimeOfDayTrigger template leaked dummy kwargs into every TimeSensor serialization, and start_from_trigger silently wrote UTC when no Dag was attached, hiding a missing timezone. The trigger parameter is named tz so it does not shadow the SDK timezone module.
01d6a74 to
46d316c
Compare
Delegating to a fresh DateTimeTrigger and omitting the resolved moment from serialize() meant a reconstruct after midnight could wait another day. Resolve at construction and persist moment so the wait target does not move. Co-authored-by: Cursor <cursoragent@cursor.com>
Problem
TimeSensor(start_from_trigger=True)caused unnecessary DagVersion churn. There are two defects, not one:start_trigger_argsis a class-levelStartTriggerArgs.__init__didself.start_trigger_args.trigger_kwargs = {...}, which mutates the shared class object — every TimeSensor got the last-constructed sensor's moment.trigger_kwargs["moment"]was an absolute datetime fromdatetime.now()at parse time, so every Dag-processor parse produced a different serialized blob → new DagVersion every parse.What Changed
Preserves the
start_from_triggerAPI (no major bump) by adding a lazy-resolution trigger:TimeOfDayTrigger— stores parse-stabletarget_time(ISO string) +timezone(IANA name or fixed offset seconds) +end_from_trigger. Resolves the concrete UTC moment when the trigger starts, then delegates toDateTimeTrigger's wait loop.TimeSensor.__init__— keepsstart_from_triggerin the signature; builds a per-instanceStartTriggerArgs(never mutates the class attribute).execute()/poke()— resolve moment once per attempt and cache (no midnight recompute drift).fold=0(first occurrence).Why not remove start_from_trigger?
Removing it would be a functional regression for users who start the sensor directly on the triggerer (worker slot avoided). That would need a major bump on
apache-airflow-providers-standardand a changelog breaking-change note. Lazy moment calculation viaTimeOfDayTriggerfixes the hash churn while keeping the feature, so no major bump is required.trigger_kwargsremains accepted-and-ignored (same as main).Impact
TimeSensor(start_from_trigger=True).Fixes: #69543
Testing
Coverage includes: already-passed today, midnight cache, DST spring/fall, no-Dag UTC fallback, TimeSensorAsync, FixedTimezone + named tz, shared-class isolation, serialization stability across mocked now(), end_from_trigger propagation.