Skip to content

fix(scheduler): survive SQL-unavailable startup and recover hung tasks (GenRTP incident) - #48

Merged
svtica merged 1 commit into
mainfrom
claude/litetask-genrtp-startup-race-fzadzw
Jul 15, 2026
Merged

fix(scheduler): survive SQL-unavailable startup and recover hung tasks (GenRTP incident)#48
svtica merged 1 commit into
mainfrom
claude/litetask-genrtp-startup-race-fzadzw

Conversation

@svtica

@svtica svtica commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Context

On 2026-07-10 ~16:00, all servers rebooted simultaneously for Windows Updates. LiteTask started before a SQL Server was up, launched all 6 tasks in the same second, and a process hung while holding its in-process lock. The stale detector alerted once, its cleanup could not release a lock whose process was still alive, and the process was silently skipped ~6,700 times over 5 days until a manual service restart on 2026-07-15.

This PR addresses the whole failure chain, following the six reliability goals from the incident review.

Changes

1. Dependency readiness gate (new SqlReadinessChecker)

Before dispatching a SQL-dependent task, the scheduler probes SQL Server with a lightweight 5 s connection test. While unavailable, the task is deferred to the next 30 s tick (its schedule is untouched) and probes retry with exponential backoff (15 s → 10 min cap), each attempt logged. A task is SQL-dependent when it has a SQL action or its name matches Reliability/SqlGatedTasks (default GenRTP,Extracteur, case-insensitive substring match — this generalizes the site-side Extracteur_VerifierSiPossibleExecution check into a reusable gate). Safety valve: a login/database error means the engine answered, so it counts as ready — a permissions issue can never block tasks forever.

2. Per-task execution timeout with forced kill (the key fix)

TaskAction.TimeoutMinutes was persisted since day one but never enforced. Now:

  • Executable/Batch children and sqlcmd run through RunProcessWithTimeout, which kills the entire process tree on expiry (the old no-timeout RunProcess path is removed).
  • The PowerShell in-process timeout uses the action's configured value instead of a fixed 4 h.
  • A scheduler-level backstop (action timeout + 2 min) kills the tracked child processes, releases the lock, logs an error, and lets the next schedule fire — even if the execution path itself hangs (e.g. output streams never closing).

3. Stale-lock recovery that actually kills the process

TaskRunner now tracks child processes by PID per task (task name flows via AsyncLocal, no signature changes) and exposes KillProcessesForTask. CleanupStaleTasks no longer resets the running state at 15 min — that reset is what caused the endless re-dispatch into a held lock. Instead it alerts, and past sum of action timeouts + 10 min it kills the owning process(es), force-releases the semaphore, and resets the state.

4. Escalating stale/blocked alerting

Stale alerts repeat every Reliability/StaleAlertRepeatMinutes (default 60) with an ALERT #N counter in the subject while the condition persists. A task that keeps being skipped (lock unavailable / already running) now also raises repeating alerts after 15 min of blockage, instead of thousands of silent log warnings.

5. Post-reboot startup delay + staggered launch

The service waits Reliability/StartupDelaySeconds (default 90) before the first dispatch, and tasks due in the same tick are staggered Reliability/TaskLaunchStaggerSeconds apart (default 5 s) via a pending-dispatch guard that also prevents double launches. Dispatch is offloaded to the thread pool, so the up-to-30 s lock wait no longer stalls the scheduler tick.

6. Non-interactive child execution

SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX) at service start (inherited by children) prevents Windows hard-error dialogs from blocking a child invisibly, and the PsExec -i flag is now only passed in interactive sessions. stdout/stderr were already redirected and logged; additionally ExecuteSqlTask no longer reports success when sqlcmd fails or times out.

Configuration

New optional Reliability section in settings.xml (defaults apply when absent):

<Reliability>
  <StartupDelaySeconds>90</StartupDelaySeconds>
  <TaskLaunchStaggerSeconds>5</TaskLaunchStaggerSeconds>
  <SqlReadinessEnabled>True</SqlReadinessEnabled>
  <SqlReadinessServer></SqlReadinessServer> <!-- falls back to SqlConfiguration/DefaultServer -->
  <SqlGatedTasks>GenRTP,Extracteur</SqlGatedTasks>
  <StaleAlertRepeatMinutes>60</StaleAlertRepeatMinutes>
</Reliability>

Behavior changes to review

  • PowerShell tasks previously had a 4 h cap; every task now defaults to its action's TimeoutMinutes (default 60 min). Any task that legitimately runs longer needs its TimeoutMinutes raised in the task definition.
  • SQL tasks now report failure (and trigger the failure notification) when sqlcmd exits non-zero or times out; previously they always reported success.
  • The readiness probe uses Integrated Security under the service account (same pattern as SqlTab); the login-error-counts-as-ready rule keeps this safe even if the account has no SQL login.

After the 2026-07-10 shared reboot, GenRTP hung on an unavailable SQL
Server while holding its in-process lock; the stale detector alerted once
and skipped ~6,700 runs for 5 days without recovering. This addresses the
whole failure chain:

- Enforce per-action TimeoutMinutes (persisted but never applied) on all
  execution paths: Executable/Batch/sqlcmd children run through
  RunProcessWithTimeout (kills the process tree on expiry) and the
  PowerShell timeout now uses the action value instead of a fixed 4 h.
  A scheduler-level backstop (timeout + 2 min) kills tracked child
  processes and releases the lock if the execution path itself hangs.
- Track child processes by PID per task in TaskRunner (AsyncLocal task
  context) and expose KillProcessesForTask; stale-lock recovery now kills
  the owning process and force-releases the lock instead of resetting
  state and re-dispatching into a held lock forever.
- Repeat stale/blocked alerts on a configurable cadence with an
  escalating ALERT #N counter; persistent skipped runs (lock unavailable)
  also alert instead of only logging warnings.
- Gate SQL-dependent tasks (SQL actions + Reliability/SqlGatedTasks name
  patterns, default GenRTP,Extracteur) behind a new SqlReadinessChecker:
  lightweight connection probe with exponential backoff, deferring the
  task to the next tick while SQL Server is down. Login/database errors
  count as engine-up so a permissions issue cannot block tasks forever.
- Add configurable post-reboot startup delay (default 90 s) and stagger
  simultaneous launches (default 5 s apart) with a pending-dispatch guard
  against double launch; dispatch is offloaded to the thread pool so lock
  waits no longer stall the scheduler tick.
- Suppress Windows hard-error dialogs via SetErrorMode (inherited by
  children) and only pass PsExec -i in interactive sessions, so a child
  cannot block invisibly on a dialog under the service.
- ExecuteSqlTask now reports failure when sqlcmd fails or times out.

New optional settings section: Reliability (StartupDelaySeconds,
TaskLaunchStaggerSeconds, SqlReadinessEnabled, SqlReadinessServer,
SqlGatedTasks, StaleAlertRepeatMinutes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014A3QMSjA4Lz6kB5Xh6KbTQ
@svtica
svtica marked this pull request as ready for review July 15, 2026 14:56
@svtica
svtica merged commit 3b6b418 into main Jul 15, 2026
1 check passed
@svtica
svtica deleted the claude/litetask-genrtp-startup-race-fzadzw branch July 15, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants