elsa-workflows / elsa-workflows/elsa-extensions
Hangfire ScheduleRecurringAsync ignores startAt and maps intervals to lossy cron (diverges from Local/Quartz)
- Dominant language
- C#
- Stars
- 49
- Forks
- 48
- Avg merge
- 21h 35m
- Merged PRs (30d)
- 20
Description
## Summary
`HangfireWorkflowScheduler.ScheduleRecurringAsync` does not implement the same semantics as core `DefaultWorkflowScheduler` / `LocalScheduler` or the Quartz provider:
1. It **ignores `startAt`**.
2. It converts `TimeSpan interval` to a **lossy cron string** via `TimeSpanExtensions.ToCronExpression`, then calls `ScheduleCronAsync`.
Local and Quartz use a real interval schedule anchored at `startAt`. Hangfire’s mapping is incorrect for many common durations (hours, multi-field intervals) and cannot express “first fire at T0, then every Δ”.
## Evidence
Hangfire (`Elsa.Scheduling.Hangfire/Services/HangfireWorkflowScheduler.cs`):
```csharp
public async ValueTask ScheduleRecurringAsync(..., DateTimeOffset startAt, TimeSpan interval, ...)
{
await ScheduleCronAsync(taskName, request, interval.ToCronExpression(), cancellationToken);
// startAt unused
}
```
`ToCronExpression` (`Extensions/TimeSpanExtensions.cs`):
```csharp
// builds "*/s */m */h */d * *" style components from TimeSpan parts
```
Quartz (`QuartzWorkflowScheduler.ScheduleRecurringAsync`) honors both:
```csharp
.StartAt(startAt)
.WithSimpleSchedule(s => s.WithInterval(interval).RepeatForever())
```
Core `DefaultWorkflowScheduler` uses `RecurringSchedule(startAt, interval)` via `LocalScheduler`.
## Why this is not a duplicate of elsa-core #4509
#4509 documents the same *class* of cron-mapping bug for **Elsa 2** `Elsa.Activities.Temporal.Hangfire` (`DurationExtensions`). This issue is the **Elsa 3** extensions implementation under `elsa-workflows/elsa-extensions` (`TimeSpanExtensions` + `HangfireWorkflowScheduler`), still open and still diverging from Local/Quartz on main.
## Proposed direction (subtractive clarity)
1. Prefer Hangfire’s native delayed + recurring primitives (or a single recurring job with an explicit first fire) so `startAt` + `interval` match Local/Quartz — **do not** keep inventing cron from `TimeSpan`.
2. If cron remains for true cron triggers only, delete or quarantine `ToCronExpression` from the recurring path.
3. Conformance: shared tests that `ScheduleRecurringAsync` first-fire and period match across Local / Hangfire / Quartz for a few intervals (e.g. 30m, 1h, 24h).
Milestone: leave unset for Triage / Crew Lead.
## Related
- elsa-core #4509 (Elsa 2 Hangfire temporal cron mapping — related history, different tree)
- elsa-extensions #121 / elsa-core #3110 (schedule identity / restart; complementary)
- elsa-core #7356 (longer-term native workflow jobs — out of scope for this surgical fix)
Contributor guide
Research direction
Start with Elsa.Scheduling.Hangfire/Services/HangfireWorkflowScheduler.cs and Extensions/TimeSpanExtensions.cs, then compare ScheduleRecurringAsync in QuartzWorkflowScheduler and the core LocalScheduler path. Check how Hangfire delayed and recurring primitives can represent the stated schedule, and add or locate shared tests covering 30-minute, 1-hour, and 24-hour intervals. Done means the first fire honors startAt and subsequent fires match the requested interval across Local, Hangfire, and Quartz.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- backend, testing
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 62/100