Skip to content

Keep scheduled task runs from overlapping across instances - #914

Merged
KrzysztofPajak merged 2 commits into
developfrom
fix/schedule-task-instances
Oct 15, 2026
Merged

KrzysztofPajak merged 2 commits into
developfrom
fix/schedule-task-instances

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Resolves: no issue (follow-up to the review of #878)
Type: bugfix

Issue

The scheduler's claim (#878 and before) guarantees that one instance starts each run, but several things still went wrong with more than one instance (pods/containers):

  1. Overlapping runs. Nothing marked a run as in progress. When a run took longer than the task's interval, another instance claimed the next run and executed the task in parallel.
  2. Pinning to a machine broke in containers. LeasedByMachineName was compared with Environment.MachineName, which is the container hostname (Docker) or the pod name (Kubernetes). Both change on every deployment, so after a redeploy no instance matched and the pinned task silently stopped running.
  3. Busy loop before every run. The wait was (int)(nextRunUtc - utcNow).TotalMinutes. In the last minute before a run this is 0, so the loop re-read the task from the database without any delay until it was due.
  4. No claim ever succeeded on LiteDB outside UTC (found while testing). LiteDB returns dates as local time. The claim read-back compared LastStartUtc == runStartUtc by ticks, so on a host in another time zone (e.g. UTC+2) the instance never saw itself as the winner and tasks never ran. Decide also compared local dates with utcNow. Containers usually run in UTC, which is why this did not show up there.

Solution

  • Lease: TryClaimTaskRun also sets LeasedUntilUtc (the field already existed but was never used) and refuses the claim while a previous run's lease is still in the future. While the task executes, the instance renews the lease every minute (5-minute lease) from its own DI scope. The final write releases it (LeasedUntilUtc = null). If the instance dies, another one takes the run once the lease runs out. Decide returns Retry until the lease expires instead of trying to claim.
  • New IScheduleTaskService.RenewTaskLease: extends the lease only while the same instance still holds it, so a late renewal cannot recreate a released lease or take over someone else's.
  • Application:InstanceName: a task pinned with "Leased by machine name" also matches this configured name (case-insensitive, like the machine name now). Several instances can share it, e.g. a dedicated worker deployment, and they then compete for runs like unpinned instances. Added to the four appsettings.json files and documented in docs/installation.md.
  • Delay: ScheduleDecision carries a TimeSpan, so the loop sleeps for the exact remaining time.
  • UTC: the claim read-back and Decide compare dates via ToUniversalTime().

Breaking changes

  • IScheduleTaskService.TryClaimTaskRun takes an additional DateTime leasedUntilUtc parameter, and RenewTaskLease is a new interface member. The only implementation is in Grand.Module.ScheduledTasks. A custom replacement of the service would need to add both.
  • LeasedByMachineName is now compared case-insensitively.
  • No migration is needed: LeasedUntilUtc is null on existing tasks, which means "not leased".

Not changed: "Run now" in the admin still executes immediately, without a lease, inside the request.

Testing

  1. dotnet test src/Tests/Grand.Web.Common.Tests: 420/420. New Decide tests cover the exact delay (including the last-minute case that used to be 0), pinning by instance name, case-insensitive machine names, retry while leased, run after the lease expires, and local-time dates. A loop test checks that the claim carries a 5-minute lease and that the final write releases it.
  2. dotnet test src/Tests/Grand.Modules.Tests: 356/356. New ScheduleTaskServiceTests (MongoDB) cover claim refused while leased, claim after the lease ran out, renewal of own lease, no takeover of another instance's lease, and no recreation of a released lease. New ScheduleTaskServiceLiteDbTests cover the same filter on LiteDB. The successful-claim test failed on LiteDB before the UTC fix on a UTC+2 machine.
  3. dotnet build src/Web/Grand.Web: succeeded.

A claimed run now holds a lease (LeasedUntilUtc) that the running instance renews, so another instance cannot start the same task while it still runs. Tasks pinned with LeasedByMachineName can also match Application:InstanceName, since container hostnames change on every deployment. The loop sleeps for the exact remaining time instead of whole minutes, and dates read back from LiteDB as local time are compared in UTC.
Copilot AI balanced review requested due to automatic review settings October 14, 2026 18:41

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.

Comment on lines +240 to +243
catch (Exception exc)
{
logger.LogWarning(exc, "Could not renew the lease of task {TaskName}", Name);
}
Write only the run result and release the lease, and only while this
instance still holds it. The whole-entity write reverted admin edits made
during the run and, after a lease that ran out, overwrote the claim of the
instance that took the task over, letting a third one start.

Keep the four-argument TryClaimTaskRun as an obsolete overload so callers
built against 2.4 keep working.
IScheduleTaskService service = _service;

#pragma warning disable CS0618
var claimed = await service.TryClaimTaskRun(task.Id, task.LastStartUtc, DateTime.UtcNow, "instance-a");
@KrzysztofPajak
KrzysztofPajak merged commit dbc2dbc into develop Oct 15, 2026
6 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the fix/schedule-task-instances branch October 15, 2026 14:01
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