Skip to content

Fix scheduler loop permanently stopping on reversible states - #787

Merged
KrzysztofPajak merged 1 commit into
developfrom
fix/scheduler-loop-recovery
Aug 16, 2026
Merged

Fix scheduler loop permanently stopping on reversible states#787
KrzysztofPajak merged 1 commit into
developfrom
fix/scheduler-loop-recovery

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Resolves #issueNumber
Type: bugfix

Issue

BackgroundServiceTask.ExecuteAsync (the in-process scheduler loop) used break to end the loop for three states that are all reversible at runtime:

  • the scheduled task row is not yet seeded in the database
  • the task is disabled in the admin panel
  • the task is leased to a machine name that is no longer running it

Once any of these was hit, the loop exited for good — the only way to recover was a full process restart, with no error or alert surfaced. Concretely: an admin toggling a task off and back on (e.g. QueuedMessagesSendScheduleTask) silently stops order-confirmation e-mails from ever being queued again on that instance.

Separately, the outer catch (Exception) swallowed every exception (e.g. a transient DB failure in GetTaskByName) without logging, making the failure mode above undiagnosable from logs.

Solution

  • Replace both break statements with delay-then-continue, so the loop keeps polling instead of ending for good.
  • Log unhandled exceptions from the outer catch via LogError instead of swallowing them; keep shutdown (OperationCanceledException on cancellation) unlogged and distinct from a real failure.
  • Resolve the logger once from the root provider (rather than per-iteration from the disposed scope) so it's available in the outer catch too.
  • Add a protected virtual TimeSpan Delay(int minutes) seam so tests can shrink the minute-scale waits to milliseconds and observe the loop recovering without a real wait.

Cron-style scheduling and a dedicated worker host (also suggested in the review that raised this) are out of scope here — larger, separately breaking changes.

Breaking changes

None.

Testing

  1. dotnet test src/Tests/Grand.Web.Common.Tests/Grand.Web.Common.Tests.csproj --filter "FullyQualifiedName~BackgroundServiceTaskTests" — 7/7 pass, including three new tests added for this fix:
    • ExecuteAsync_TaskDisabled_ThenReEnabled_ResumesExecutionWithoutRestart
    • ExecuteAsync_TaskNotYetSeeded_ThenSeeded_ResumesExecutionWithoutRestart
    • ExecuteAsync_UnexpectedExceptionInLoop_LogsErrorAndKeepsRunning
  2. dotnet build src/Web/Grand.Web.Common/Grand.Web.Common.csproj — succeeds, 0 warnings/errors.

Copilot AI lite review requested due to automatic review settings August 16, 2026 10:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

BackgroundServiceTask.ExecuteAsync used break for states that can
change at runtime: task not yet seeded in the DB, task disabled from
the admin panel, and task leased to a machine that no longer holds
it. Once hit, the loop ended for good and required a process restart
to recover - e.g. QueuedMessagesSendScheduleTask silently stops
sending order confirmation e-mails after an admin toggles it off and
back on.

Extract the scheduling logic into a pure Decide(task, machineName,
utcNow) function with no side effects and no terminal outcome: every
branch resolves to a delay-and-retry action, so there is no 'stop
the loop' outcome to accidentally reach - the break statements are
gone from ExecuteAsync entirely, by construction rather than by
convention. Decide() is unit-tested directly without any timers, DI,
or BackgroundService lifecycle involved.

Also log unhandled exceptions from the outer catch instead of
swallowing them silently, and resolve the logger once from the root
provider so it is available there too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@KrzysztofPajak
KrzysztofPajak force-pushed the fix/scheduler-loop-recovery branch from bb559f8 to 127ea8c Compare August 16, 2026 10:59
Comment thread src/Web/Grand.Web.Common/Infrastructure/BackgroundServiceTask.cs Dismissed
@KrzysztofPajak
KrzysztofPajak merged commit 9aa8c17 into develop Aug 16, 2026
6 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the fix/scheduler-loop-recovery branch August 16, 2026 11:22
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