Skip to content

Fix scheduling startup backlog catch-up#7747

Open
sfmskywalker wants to merge 3 commits into
mainfrom
sfmskywalker-fix-scheduling-startup-backlog-main
Open

Fix scheduling startup backlog catch-up#7747
sfmskywalker wants to merge 3 commits into
mainfrom
sfmskywalker-fix-scheduling-startup-backlog-main

Conversation

@sfmskywalker

Copy link
Copy Markdown
Member

Forward-port/main PR for the scheduling startup backlog fix from #7746.

This keeps the same main-targeted change available after #7746 is retargeted to release/3.8.0.

Validation from original PR/session:

  • Elsa.Scheduling build
  • Elsa.Workflows.Runtime build
  • Elsa.Persistence.EFCore build
  • Elsa.Scheduling.UnitTests: 50 passed
  • Elsa.Http.UnitTests / HttpWorkflowsMiddlewareTests: 6 passed
  • Greptile 5/5 with zero unresolved comments

sfmskywalker and others added 2 commits June 21, 2026 01:28
Rebuild local schedules in bounded pages and stagger past-due specific-instant catch-up so orphaned scheduling bookmarks do not flood dispatch during startup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sfmskywalker

Copy link
Copy Markdown
Member Author

Main-targeted copy of #7746. #7746 has been retargeted to release/3.8.0; this PR keeps the same scheduling startup backlog fix queued for main.

@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR forward-ports the scheduling startup backlog catch-up fix from release/3.8.0 to main. It changes DefaultTriggerScheduler to schedule past-due StartAt triggers instead of silently skipping them, introduces PastDueScheduleStaggerer to spread those catch-up dispatches over a bounded window, and converts the startup trigger/bookmark loading from a single unbounded query to a configurable paged loop.

  • Paged startup loading (CreateSchedulesStartupTask): replaces a single FindManyAsync call with a while-loop that advances through PageArgs-based pages, adding a new FindManyAsync(filter, pageArgs, ct) overload to IBookmarkStore (and implementing it in EFCoreBookmarkStore and MemoryBookmarkStore).
  • Dispatch storm prevention (PastDueScheduleStaggerer): a new singleton service that distributes all past-due schedule activations across a configurable PastDueScheduleStaggerWindow using an atomic sequence counter, exposed via SchedulingOptions; ScheduledSpecificInstantTask now delegates its 0/negative-delay handling to this service.
  • Cache key fix (CachingTriggerStore): pageArgs (and order) are now included in the cache key for the paginated FindManyAsync overloads, preventing pages from colliding on the same cache entry.

Confidence Score: 4/5

Safe to merge for the paging, staggering, and cache-key fixes; the catch-up path in DefaultTriggerScheduler still re-schedules the same past-due StartAt triggers on every subsequent restart because the trigger records are never removed or marked after dispatch.

The paged startup loading, PastDueScheduleStaggerer, and CachingTriggerStore cache-key corrections are all well-implemented and well-tested. The one open concern — raised in the prior review — is that DefaultTriggerScheduler now schedules past-due StartAt triggers but does not remove or flag them afterwards, so each application restart will re-trigger the same one-shot StartAt workflows for as long as their trigger records remain in the store.

src/modules/Elsa.Scheduling/Services/DefaultTriggerScheduler.cs — the catch-up scheduling path does not clean up or mark past-due one-shot StartAt triggers after dispatch.

Important Files Changed

Filename Overview
src/modules/Elsa.Scheduling/Services/DefaultTriggerScheduler.cs Past-due StartAt triggers are now scheduled instead of skipped; trigger records are not removed/marked after catch-up, so each restart will re-schedule the same past-due triggers (flagged in prior review).
src/modules/Elsa.Scheduling/StartupTasks/CreateSchedulesStartupTask.cs Replaces single unbounded query with a paged while-loop for both triggers and bookmarks; termination logic is correct via Items.Count==0 and nextOffset>=TotalCount guards.
src/modules/Elsa.Scheduling/Services/PastDueScheduleStaggerer.cs New service that distributes past-due schedule activations over a bounded window using an atomic sequence counter; logic is correct and thread-safe.
src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledSpecificInstantTask.cs Adds a 6-arg ActivatorUtilities constructor that accepts PastDueScheduleStaggerer; the 5-arg constructor delegates to a static default instance, keeping backward compatibility for non-DI callers.
src/modules/Elsa.Scheduling/Options/SchedulingOptions.cs New options class exposing StartupSchedulePageSize, MinimumPastDueScheduleDelay, PastDueScheduleStaggerInterval, and PastDueScheduleStaggerWindow with sensible defaults.
src/modules/Elsa.Persistence.EFCore/Modules/Runtime/BookmarkStore.cs Adds paged FindManyAsync overload using CountAsync then QueryAsync with OrderBy+Paginate; standard two-phase pattern consistent with other EFCore stores in this codebase.
src/modules/Elsa.Workflows.Runtime/Stores/CachingTriggerStore.cs Fixes cache key collision by including pageArgs (and order) in the hash for both paginated FindManyAsync overloads.
src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkStore.cs Adds new paged FindManyAsync method to the interface with a doc comment reminding implementors to page at the persistence layer.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant ST as CreateSchedulesStartupTask
    participant TStore as ITriggerStore
    participant BStore as IBookmarkStore
    participant TSched as ITriggerScheduler
    participant BSched as IBookmarkScheduler
    participant DTS as DefaultTriggerScheduler
    participant Staggerer as PastDueScheduleStaggerer
    participant Task as ScheduledSpecificInstantTask

    ST->>TStore: "FindManyAsync(filter, page=0, size=N)"
    TStore-->>ST: "Page<StoredTrigger>(items, totalCount)"
    ST->>TSched: ScheduleAsync(items)
    TSched->>DTS: (delegate)
    DTS->>DTS: "executeAt < now? log catch-up, no skip"
    DTS->>Task: create ScheduledSpecificInstantTask(startAt)
    Task->>Staggerer: GetDelay(negativeDelay)
    Staggerer->>Staggerer: Interlocked.Increment(_sequence)
    Staggerer-->>Task: "minimumDelay + slot*interval"
    Task->>Task: Start timer with staggered delay
    ST->>TStore: "FindManyAsync(filter, page=N, size=N)"
    TStore-->>ST: Page (empty or next batch)
    Note over ST: Loop until totalCount exhausted
    ST->>BStore: "FindManyAsync(bookmarkFilter, page=0, size=N)"
    BStore-->>ST: "Page<StoredBookmark>"
    ST->>BSched: ScheduleAsync(items)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant ST as CreateSchedulesStartupTask
    participant TStore as ITriggerStore
    participant BStore as IBookmarkStore
    participant TSched as ITriggerScheduler
    participant BSched as IBookmarkScheduler
    participant DTS as DefaultTriggerScheduler
    participant Staggerer as PastDueScheduleStaggerer
    participant Task as ScheduledSpecificInstantTask

    ST->>TStore: "FindManyAsync(filter, page=0, size=N)"
    TStore-->>ST: "Page<StoredTrigger>(items, totalCount)"
    ST->>TSched: ScheduleAsync(items)
    TSched->>DTS: (delegate)
    DTS->>DTS: "executeAt < now? log catch-up, no skip"
    DTS->>Task: create ScheduledSpecificInstantTask(startAt)
    Task->>Staggerer: GetDelay(negativeDelay)
    Staggerer->>Staggerer: Interlocked.Increment(_sequence)
    Staggerer-->>Task: "minimumDelay + slot*interval"
    Task->>Task: Start timer with staggered delay
    ST->>TStore: "FindManyAsync(filter, page=N, size=N)"
    TStore-->>ST: Page (empty or next batch)
    Note over ST: Loop until totalCount exhausted
    ST->>BStore: "FindManyAsync(bookmarkFilter, page=0, size=N)"
    BStore-->>ST: "Page<StoredBookmark>"
    ST->>BSched: ScheduleAsync(items)
Loading

Reviews (2): Last reviewed commit: "Use stable CShells packages from NuGet" | Re-trigger Greptile

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sfmskywalker

Copy link
Copy Markdown
Member Author

Updated the package source mapping so the already-pinned CShells 0.0.28 stable packages restore from NuGet.org instead of the stale cshells Feedz source.\n\nValidation:\n- dotnet restore src/modules/Elsa.Persistence.EFCore.Common/Elsa.Persistence.EFCore.Common.csproj --ignore-failed-sources --no-cache --verbosity minimal\n- dotnet build src/modules/Elsa.Persistence.EFCore.Common/Elsa.Persistence.EFCore.Common.csproj --no-restore --configuration Release --verbosity minimal

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.

1 participant