Avoid scheduler crash when periodic maintenance actions fail#69487
Avoid scheduler crash when periodic maintenance actions fail#69487hanxdatadog wants to merge 5 commits into
Conversation
|
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? |
_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.
2cc8cd8 to
4739d6e
Compare
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) |
What does this PR do?
non_fatal: bool = Falseparameter toEventScheduler.call_regular_interval. WhenTrue, an exception raised by the periodicactionis logged (self.log.warning(..., exc_info=True)) and swallowed instead of propagating; the next cycle is still scheduled either way. Default isFalse, preserving current behavior for existing callers.non_fatal=Truefor the twoSchedulerJobRunnerperiodic maintenance callbacks registered viacall_regular_intervalthat had no exception handling at all:_remove_unreferenced_triggersand_reap_stale_connection_tests.Failed to run periodic action <name> due to <exception>; will retry on the next cycleon the caught-exception path — matches the existingFailed to <thing> due to %shouse style already used by_update_dag_run_state_for_paused_dags, and states why a single occurrence is safe to ignore. Logged atWARNING(withexc_info=Trueto still capture the full traceback) rather thanERROR, so a persistent (rather than transient) failure repeating every cycle doesn't generate steady-state ERROR-level noise.Motivation:
EventScheduler.call_regular_interval's innerrepeat()closure calls the registeredactionwith no exception handling, so a single transient failure (e.g. a DBstatement_timeoutduring_remove_unreferenced_triggers's DELETE) propagates all the way up and crashes the wholeSchedulerJob. 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 inSchedulerJobRunner—_update_dag_run_state_for_paused_dagsalready wraps its own body inexcept 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).EventScheduleris 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:
test_event_scheduler.py— one confirms the default (no flag) still propagates the action's exception, one confirmsnon_fatal=Trueswallows it and still schedules the next cycle.Notes:
non_fatalrather thancatch_exceptionsto 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).ERROR(self.log.exception) toWARNING(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?