Add on_skipped_intervals_callback for catchup=False DAGs#68359
Conversation
|
Congratulations on your first Pull Request and welcome to the Apache Airflow community! If you have any issues or are unsure about any anything please check our Contributors' Guide
|
|
Hi @ferruzzi, please review when you get the chance. Thanks |
|
Sorry for the delay, I'll look tomorrow. The failing CI is going to have to be addressed. Run your static-checks locally for a start, then rebase main and do a force-push. We'll see what's left failing and can look at them together if you need a hand. |
d5c46ca to
97fb4d3
Compare
ferruzzi
left a comment
There was a problem hiding this comment.
Good start, left some comments and I can make another pass when you get those addresseed
0ff46f9 to
4285a9c
Compare
3e21bbe to
6dad54d
Compare
ferruzzi
left a comment
There was a problem hiding this comment.
Left a question for discussion but otherwise LGTM.
| HeadVersion(), | ||
| Version("2026-06-23", AddDagSkippedIntervalsCallbackRequest), | ||
| Version("2026-06-16"), | ||
| Version("2026-05-23"), |
There was a problem hiding this comment.
Please leave this comment open as a reminder to verify this file before we merge.
|
I feel we lack a discussion on this feature. Can someone raise a thread on the dev list so maintainers are aware of this? |
560391c to
624bff9
Compare
|
Hey @uranusjr, can you please submit a 2nd review for this PR when you have time? Thank you |
potiuk
left a comment
There was a problem hiding this comment.
Thanks for the thorough work here — the callback/listener symmetry, the serialization round-trip coverage, docs, and newsfragment are all in good shape, and the earlier review points (the O(1) redesign, moving the listener call out of the retry transaction, the ignored-keys handling) look well addressed.
I think there's one correctness issue that should be resolved before this lands.
Blocking — skip detection false-fires on every run for the default timetable family (airflow-core/src/airflow/serialization/definitions/dag.py / jobs/scheduler_job_runner.py)
summarize_skipped_intervals_between reports a skip whenever prev_interval_end < new_interval_start:
if prev_interval_end >= new_interval_start:
return None
return SkippedIntervalsSummary(skipped_range=DataInterval(prev_interval_end, new_interval_start))That's correct only for contiguous data intervals (CronDataIntervalTimetable / DeltaDataIntervalTimetable). But the default config is scheduler.create_cron_data_intervals = False and scheduler.create_delta_data_intervals = False, so a plain schedule="@daily" or schedule=timedelta(...) resolves to CronTriggerTimetable / DeltaTriggerTimetable, whose data interval defaults to zero-width (interval=timedelta() → DataInterval(next_start, next_start)).
For a zero-width-interval timetable, consecutive normal runs are never contiguous:
- run day 1 →
data_interval = [day1 00:00, day1 00:00] - run day 2 (no downtime, nothing skipped) →
data_interval = [day2 00:00, day2 00:00] prev.data_interval_end (day1) < new.data_interval_start (day2)→ a "skip" is reported, and the callback + listener + a Dag-processor callback dispatch fire on every run.
So under the default configuration the feature fires continuously even when nothing was skipped — the opposite of the documented contract ("notified when the scheduler advances past missed intervals").
The current tests don't catch this because they hand-construct full-width contiguous intervals (data_interval=(prev_start, prev_start + 1 day) and a full-width next_dagrun_data_interval) rather than the zero-width intervals a Trigger timetable actually produces at runtime.
Suggested direction: instead of the bare prev_end < new_start comparison, ask the timetable whether at least one scheduled logical time lies strictly between prev_interval_end and new_interval_start. That's a boundary check (e.g. timetable.next_dagrun_info(...) from prev_interval_end, test whether it falls before new_interval_start) — no full enumeration, so it stays cheap. Please add a regression test using the default (Trigger) timetable with no skips that asserts the callback does not fire.
Smaller observations
jobs/scheduler_job_runner.py—get_listener_manager().hook.on_intervals_skipped.get_hookimpls()is evaluated twice for the same dag in one scheduling loop (once in_collect_skipped_intervals, again in_create_dag_runs). Worth computing once.serialization/definitions/dag.py—summarize_skipped_intervals_betweencurrently uses nothing from the DAG/timetable; addressing the point above will give it a real reason to live on the DAG.dag_processing/processor.py— stray blank line right after thedef _execute_dag_skipped_intervals_callback(...)signature.
I'll leave the Cadwyn / supervisor-schema-versioning portion to @uranusjr, as @ferruzzi noted.
This review was drafted by an AI-assisted tool and confirmed by an Apache Airflow maintainer. After you've addressed the points above and pushed an update, an Apache Airflow maintainer — a real person — will take the next look at the PR. The findings cite the project's review criteria; if you think one of them is mis-applied, please reply on the PR and a maintainer will weigh in.
More on how Apache Airflow handles maintainer review: contributing-docs/05_pull_requests.rst.
Drafted-by: Claude Code (Opus 4.8); reviewed by @potiuk before posting
Oh, nice catch, I didn't realize that. Sneaky bug could have been a pain to troubleshoot, too. So the right solution is to use |
When catchup=False and the scheduler jumps past missed intervals, there was no way to observe which data intervals were skipped. Add a Dag-level on_skipped_intervals_callback (context-based, like on_success_callback) and an AIP-61 listener hook on_intervals_skipped. The scheduler detects gaps at scheduled run creation and fires the listener in-process; user callbacks are dispatched to the Dag File Processor after commit.
Both these fixes were flagged by CI in the previous commit. Issue 1: The addition of the `on_skipped_intervals_callback` in the callback types table was not correct syntactically with rst. Issue 2: The serialization tests were failing since the `has_on_skipped_intervals_callback` key was missing but not expected. Since this callback was implemented similarly to `has_on_success_callback`, it has been added to the ignored set.
When a Dag with catchup=False skips many scheduled intervals (long pause, scheduler downtime, etc.), the original design enumerated every missed interval in the scheduler via iter_dagrun_infos_between(), materialized the full list in DagSkippedIntervalsCallbackRequest, and passed it through the listener hook and dag-processor callback. That is O(n) in time and memory and can stall the scheduler or produce huge callback payloads for high-frequency schedules. Replace the full interval list with a compact SkippedIntervalsSummary (count plus skipped_range envelope) and compute the count in O(1) time from the schedule and gap boundaries. Scheduler and Dag integration: - Add SkippedIntervalsSummary (count, skipped_range) in timetables.base. - Add Timetable.count_skipped_intervals_between(); default raises NotImplementedError. - Implement O(1) counting on DeltaMixin (timedelta/relativedelta schedules) and CronMixin (uniform cron periods, plus calendar shortcuts for monthly and weekly expressions). - Add SerializedDAG.summarize_skipped_intervals_between() delegating to the Dag timetable; returns None when there is no gap or counting is unsupported. - Change SchedulerJobRunner._collect_skipped_intervals() to return SkippedIntervalsSummary | None instead of a list of interval tuples. Callback and listener API: - on_intervals_skipped listener: (dag_id, summary: SkippedIntervalsSummary) instead of (dag_id, skipped_intervals: list[DataInterval]). - on_skipped_intervals_callback context: skipped_interval_count and skipped_range instead of skipped_intervals. - DagSkippedIntervalsCallbackRequest carries skipped_interval_count and skipped_range; add from_summary() / to_summary() helpers. - Dag processor builds the callback context from the summary. Documentation and examples: - Update callbacks.rst and listeners.rst for the new context and listener signature; document iter_dagrun_infos_between() for full enumeration in the dag processor. - Update SDK Dag docstring and example event_listener plugin. Schema: - Update task-sdk supervisor schema and v2026_06_16 version migration for the revised DagSkippedIntervalsCallbackRequest fields. Tests: - Add test_skipped_intervals_summary.py with parametrized O(1) vs reference counts (delta/cron daily, hourly, monthly) and summarize helpers. - Update scheduler, callback request, dag processor, and listener tests for the summary shape. Custom timetables must implement count_skipped_intervals_between() to participate; unsupported built-in timetables silently skip notification. Cron expressions outside the supported fast paths raise AirflowTimetableInvalid from CronMixin (not swallowed by summarize).
…xt, update schema versioning The skipped-intervals callback receives a dedicated payload, not task template Context; a proper TypedDict removes the mypy ignore and matches runtime behavior. Updated the api version in the schema from 2026-06-16 -> 2026-06-23.
Co-authored-by: D. Ferruzzi <ferruzzi@amazon.com>
Calculating the skipped_interval_count would either take O(n) time in the scheduler or would require mixin methods that would be unreliable for custom timetables. Instead, this field has been removed so that the skipped intervals callback can work reliably and efficiently. Users can access the specific skipped interval data by calling other methods in their callback/listener implementations. All source, tests, and docs have been updated to reflect this change.
When the schema was bumped to 2026-06-23, version 2026-06-16 was removed from the bundle, breaking coordinator tests and any bundles compiled against that schema version. Restore the intermediate version to the bundle chain to support in-flight deployments. Also document SkippedIntervalsCallbackContext in the public API docs since it was added to __all__ but missing from the Sphinx inventory.
Originally, this listener was called within _create_dag_runs(). This meant that the hook is called repeatedly if transaction retries occur. This change moves the listener call into _do_scheduling(), after the DB commit.
…kipped interval fields Replaced field-level schema(...).didnt_exist instructions with a convert_response_to_previous_version_for(DagFileParseRequest) hook that filters callback_requests, dropping entries with type == "DagSkippedIntervalsCallbackRequest" when downgrading for older lang-SDK bundles. The original instructions did not accurately revert the schema version.
…ble runs Default Airflow 3 schedules use zero-width CronTriggerTimetable / DeltaTriggerTimetable intervals, so a bare prev_end < new_start check treated every normal schedule advance as a catchup gap. Detect skips by comparing the timetable's expected next logical date with the run being created, and compute the listener-hook presence once per scheduling loop.
Scheduler (test_scheduler_job.py) _collect_skipped_intervals: empty when catchup=True, no callback/listener, no prior run, no gap, returns 3 intervals when a gap exists _create_dag_runs: returns DagSkippedIntervalsCallbackRequest with correct payload _do_scheduling: dispatches request via executor.send_callback Dag processor (test_processor.py) TestExecuteCallbacks::test_execute_callbacks_routes_skipped_intervals_request: routing in executecallbacks TestExecuteDagSkippedIntervalsCallback: callback execution, context (dag, reason, skipped_intervals), multiple callbacks, missing callback warning, missing DAG error Listener (test_skipped_intervals_listener.py + skipped_intervals_listener.py) on_intervals_skipped fired when the scheduler creates a Dag run across a gap (listener-only, no Dag callback) Serialization and callback requests test_dag_serialization.py: has_on_skipped_intervals_callback round-trip (mirrors success/failure callback tests) test_callback_requests.py: DagSkippedIntervalsCallbackRequest JSON round-trip and CallbackRequest union validation All 15 new tests pass locally.
e5f4750 to
eaa863c
Compare
|
Thank you @potiuk and @ferruzzi for the comments. I implemented the recommended fix using I noticed that dag-run.rst didn't include any info on trigger timetables and was quite outdated. I opened PR #70167 to update the documentation on that page. Please take a look at this updated PR and #70167 when you have the chance. Would appreciate any further feedback. |
{pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.closes: #66791
When a Dag is configured with
catchup=Falseand the scheduler advances past missed intervals (for example after a restart or re-enable), the skip happens silently: no DagRun rows are created and no hook fires. Operators who need observability for skipped partitions (SLA accounting, compliance audit trails, dashboard counters) had to comparelast_automated_data_intervalagainst wall clock externally.This PR adds two symmetric extension points:
on_skipped_intervals_callback— a Dag-level callback on the SDKDAGconstructor, using the same context-based signature ason_success_callback/on_failure_callback. The context includesdag,reason("skipped_intervals"), andskipped_range(aDataIntervalfrom the previous automated run'sdata_interval_endtothe new run's
data_interval_start).on_intervals_skipped— an AIP-61 listener hookspec for pluginsthat need in-process observability without reloading the Dag file.
The listener receives a
SkippedIntervalsSummarywith the sameskipped_range.How it works
Skipped intervals are detected at scheduled DagRun creation time (timetable-agnostic):
SCHEDULEDrun for acatchup=FalseDag, it compares the new run's
data_interval_startagainst thedata_interval_endof the most recent prior scheduled run.SkippedIntervalsSummarywithskipped_rangespanning those two boundaries. The scheduler does notenumerate every skipped interval on the hot path.
not dag.catchupAND(
has_on_skipped_intervals_callbackis set OR anon_intervals_skippedlistener implementation is registered).
Listener fires synchronously in the scheduler (same pattern as
on_dag_run_runningfor Dag start events).Callback follows the existing Dag callback pipeline:
DagSkippedIntervalsCallbackRequest→DatabaseCallbackSink→ Dag File Processor reloads the Dag file and invokes the callable. The skipped gap is serialized as a(start, end)datetime pair (skipped_range) in the request because no DagRun row exists for skipped intervals and the processor cannot reliably reconstruct the gap later.To enumerate individual skipped intervals (for example to count them or log each one), call
context["dag"].iter_dagrun_infos_between(skipped_range.start, skipped_range.end)inside the callback, or the equivalent on the Dag in a listener implementation.Files changed
task-sdk/src/airflow/sdk/definitions/dag.py,task-sdk/src/airflow/sdk/definitions/context.pyairflow-core/src/airflow/timetables/base.pyserialization/definitions/dag.py,serialized_objects.pycallbacks/callback_requests.py,dag_processing/processor.pytask-sdk/src/airflow/sdk/execution_time/schema/versions/v2026_06_23.py,schema.jsonlisteners/spec/dag.py(new),listeners/listener.pyjobs/scheduler_job_runner.pyUsage (preview)
Follow-up (Will be added to this PR)
Unit and integration tests (scheduler skip detection, callback execution, listener hook, serialization roundtrip)
User documentation (on_skipped_intervals_callback in Dag reference, on_intervals_skipped in listeners docs)
Newsfragment in airflow-core/newsfragments/ (user-facing feature)
Test plan
To be completed before marking Ready for review:
Scheduler creates skip callback request when catchup=False Dag jumps past intervals and callback is configured
Listener on_intervals_skipped fires with correct intervals
Dag File Processor executes callback with skipped_intervals in context
No overhead when callback and listener are both unset
Serialization roundtrip preserves has_on_skipped_intervals_callback
prek run --from-ref main --stage pre-commit passes
Was generative AI tooling used to co-author this PR?
Generated-by: Cursor (Auto) following the guidelines