Skip to content

Avoid scheduler crash when periodic maintenance actions fail#69487

Open
hanxdatadog wants to merge 5 commits into
apache:mainfrom
hanxdatadog:hanx/fix-scheduler-crash-on-periodic-task-exception
Open

Avoid scheduler crash when periodic maintenance actions fail#69487
hanxdatadog wants to merge 5 commits into
apache:mainfrom
hanxdatadog:hanx/fix-scheduler-crash-on-periodic-task-exception

Conversation

@hanxdatadog

@hanxdatadog hanxdatadog commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

  • Adds an opt-in non_fatal: bool = False parameter to EventScheduler.call_regular_interval. When True, an exception raised by the periodic action is logged (self.log.warning(..., exc_info=True)) and swallowed instead of propagating; the next cycle is still scheduled either way. Default is False, preserving current behavior for existing callers.
  • Enables non_fatal=True for the two SchedulerJobRunner periodic maintenance callbacks registered via call_regular_interval that had no exception handling at all: _remove_unreferenced_triggers and _reap_stale_connection_tests.
  • Logs Failed to run periodic action <name> due to <exception>; will retry on the next cycle on the caught-exception path — matches the existing Failed to <thing> due to %s house style already used by _update_dag_run_state_for_paused_dags, and states why a single occurrence is safe to ignore. Logged at WARNING (with exc_info=True to still capture the full traceback) rather than ERROR, so a persistent (rather than transient) failure repeating every cycle doesn't generate steady-state ERROR-level noise.

Motivation:
EventScheduler.call_regular_interval's inner repeat() closure calls the registered action with no exception handling, so a single transient failure (e.g. a DB statement_timeout during _remove_unreferenced_triggers's DELETE) propagates all the way up and crashes the whole SchedulerJob. In practice this means: the scheduler process dies with a critical-level log, and every task instance it was tracking gets swept into the orphaned-task-adoption path on the next scheduler startup — a heavyweight, disruptive recovery flow triggered by what should have been a harmless, skippable cycle of a periodic cleanup job. This is inconsistent with the existing pattern elsewhere in SchedulerJobRunner_update_dag_run_state_for_paused_dags already wraps its own body in except Exception as e: # should not fail the scheduler, and the same crash-avoidance philosophy has precedent in merged PRs like #62893 (isolating per-DagRun failures so one bad DagRun can't crash the scheduler). EventScheduler is documented as a "general purpose" utility, so rather than making it always swallow exceptions (which would silently change behavior for any future caller that wants failures to propagate), the fix is an explicit opt-in.

Testing:

  • Unit tests: 2 new tests in test_event_scheduler.py — one confirms the default (no flag) still propagates the action's exception, one confirms non_fatal=True swallows it and still schedules the next cycle.

Notes:

  • Named non_fatal rather than catch_exceptions to be outcome-focused (this failure isn't fatal to the scheduler) rather than mechanism-focused, matching existing "non-fatal" terminology already used elsewhere in the codebase (dag_processing/importers/base.py).
  • Log level was changed from ERROR (self.log.exception) to WARNING (self.log.warning(..., exc_info=True)) in response to review feedback — addresses steady-state log noise for a persistent failure while keeping the traceback for debuggability.

Was generative AI tooling used to co-author this PR?
  • Yes (Claude Code)

@boring-cyborg boring-cyborg Bot added the area:Scheduler including HA (high availability) scheduler label Jul 6, 2026
@hanxdatadog hanxdatadog changed the title Add catch_exceptions option to EventScheduler.call_regular_interval Avoid scheduler crash when periodic maintenance actions fail Jul 6, 2026
@hanxdatadog
hanxdatadog marked this pull request as ready for review July 7, 2026 20:20
@hanxdatadog
hanxdatadog requested review from XD-DENG and ashb as code owners July 7, 2026 20:20
@Vamsi-klu

Copy link
Copy Markdown
Contributor

Confirmed the root cause: repeat() in call_regular_interval calls action() with no exception handling, and both maintenance callbacks it guards -- _remove_unreferenced_triggers (a raw DELETE with synchronize_session=fetch) and _reap_stale_connection_tests (a row-locked SELECT plus UPDATE loop and flush) -- have no internal error handling. timers.run(blocking=False) is called inside the while loop of _run_scheduler_loop with no try/except, so a transient DB error (statement_timeout, deadlock, dropped connection) propagates out and takes down the SchedulerJob for what should be a skippable cleanup cycle. The opt-in flag is the right call since EventScheduler is general-purpose and default=False keeps every existing caller identical. except Exception (not bare except) correctly still lets KeyboardInterrupt/SystemExit through, and self.enter() is the last line of repeat() outside the try, so the next cycle is always rescheduled. Worth noting: both callbacks are @provide_session-decorated, so the session is already rolled back and closed by the time the exception reaches the except -- swallowing doesn't leave a half-open transaction. This matches the existing precedent in _update_dag_run_state_for_paused_dags. Regression coverage is meaningful: the non_fatal test fails without the guard (asserts no raise plus queue growth 1 to 2), and the default-path test pins that the unguarded behavior still propagates.

One non-blocking question: self.log.exception logs a full traceback at ERROR every cycle, so a persistent (rather than transient) failure will repeat the traceback each interval and the cleanup will silently never succeed. Given the coarse interval that's probably acceptable, but did you consider log.warning without the traceback, or logging once, to avoid steady-state log noise?

@potiuk potiuk added the ready for maintainer review Set after triaging when all criteria pass. label Jul 8, 2026
_remove_unreferenced_triggers and _reap_stale_connection_tests are
registered as periodic scheduler maintenance callbacks via
EventScheduler.call_regular_interval, which has no exception handling
around the callback. A single transient failure (e.g. a DB
statement_timeout) propagates and crashes the whole SchedulerJob.

Add an opt-in catch_exceptions flag (default False, preserving current
behavior) that logs and swallows an exception from the periodic action
instead of letting it propagate, so one bad cycle doesn't take down the
scheduler; the next cycle is still scheduled either way. Enable it for
the two maintenance callbacks above.
non_fatal reads better at call sites (e.g. non_fatal=True) than the
mechanism-focused catch_exceptions, and matches existing "non-fatal"
terminology already used elsewhere in the codebase for the same
crash-avoidance concept (see dag_processing/importers/base.py).
…pt to investigate

Log "Failed to run periodic action <name> due to <exception>. Please
investigate if this is persistent." instead of a bare "Exception
raised in periodic action <repr>", making the log actionable on its
own without needing to open the traceback to identify what failed.
_update_dag_run_state_for_paused_dags already uses "Failed to <thing>
due to %s" (scheduler_job_runner.py); match that instead of inventing
new phrasing. Drop "please investigate if this is persistent" -- it's
not actionable (nothing here can tell whether a single occurrence is
persistent) and no other log line in this codebase phrases itself as
a directive to the reader. Add "will retry on the next cycle" instead,
since that's genuinely new information explaining why the exception
is safe to swallow.
self.log.exception logs at ERROR with a full traceback. For a
persistent (rather than transient) failure this repeats every cycle,
creating steady-state log noise at ERROR severity. Log at WARNING
instead, keeping exc_info=True so the traceback is still captured --
addresses review feedback without losing debuggability.
@hanxdatadog
hanxdatadog force-pushed the hanx/fix-scheduler-crash-on-periodic-task-exception branch from 2cc8cd8 to 4739d6e Compare July 14, 2026 18:48
@hanxdatadog

Copy link
Copy Markdown
Contributor Author

Confirmed the root cause: repeat() in call_regular_interval calls action() with no exception handling, and both maintenance callbacks it guards -- _remove_unreferenced_triggers (a raw DELETE with synchronize_session=fetch) and _reap_stale_connection_tests (a row-locked SELECT plus UPDATE loop and flush) -- have no internal error handling. timers.run(blocking=False) is called inside the while loop of _run_scheduler_loop with no try/except, so a transient DB error (statement_timeout, deadlock, dropped connection) propagates out and takes down the SchedulerJob for what should be a skippable cleanup cycle. The opt-in flag is the right call since EventScheduler is general-purpose and default=False keeps every existing caller identical. except Exception (not bare except) correctly still lets KeyboardInterrupt/SystemExit through, and self.enter() is the last line of repeat() outside the try, so the next cycle is always rescheduled. Worth noting: both callbacks are @provide_session-decorated, so the session is already rolled back and closed by the time the exception reaches the except -- swallowing doesn't leave a half-open transaction. This matches the existing precedent in _update_dag_run_state_for_paused_dags. Regression coverage is meaningful: the non_fatal test fails without the guard (asserts no raise plus queue growth 1 to 2), and the default-path test pins that the unguarded behavior still propagates.

One non-blocking question: self.log.exception logs a full traceback at ERROR every cycle, so a persistent (rather than transient) failure will repeat the traceback each interval and the cleanup will silently never succeed. Given the coarse interval that's probably acceptable, but did you consider log.warning without the traceback, or logging once, to avoid steady-state log noise?

Hi @Vamsi-klu Thank you for the comment. I just made the log at the warning level. For the change's motivation, exactly as you described, we hit some crashes in some supposably non-fatal cleaning up schedule actions. here is to provide some general option for these kind of non-fatal scheduled actions. With the flag, the goal is to not let scheduler crash (making some tasks enter orphan adoption/drop path unnecessarily) meanwhile giving the maintainers enough attentions to take actions to address them shortly (warning level logs with stacktrace)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:Scheduler including HA (high availability) scheduler ready for maintainer review Set after triaging when all criteria pass.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants