elsa-workflows / elsa-workflows/elsa-extensions

Hangfire ScheduleRecurringAsync ignores startAt and maps intervals to lossy cron (diverges from Local/Quartz)

Closed
#198 0 comments 0 reactions 0 assignees View on GitHub
bug triaged
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.