from quartz · migration notes
From Quartz.NET to Acta. Or beside it.
A scheduler decides when work should start. Acta is for what happened after it started. Keep Quartz if the calendar is the hard part: cron sophistication, calendars, misfire instructions. Use Acta when a background job stops being a task and becomes application state, recorded as SQL rows in the database you already run: kill the worker, keep the work.
The concept map
Where each Quartz concept lands.
The scheduling half translates directly. The execution half, everything after the trigger fires, is where Acta adds what Quartz never claimed to own.
- IJob and its Execute method
[Job("name")]on an ordinary handler method, plus typedEnqueueAsyncfor on-demand work. The name is the durable, operator-facing contract.- Trigger, cron or interval
[JobSchedule]on the same[Job]method: an interval such as5mor a cron expression, withCronconstants for the common cases.- JobDataMap
- Typed input contracts: a record you declare, validated at compile time, readable on the job row afterwards.
- Job key
- The
[Job]name, plusJobReffor public references andJobIdinternally. - Retry, refire, listeners
[Job(MaxAttempts = ...)]and typed backoff settings: every attempt recorded, so how many times a job ran and when is a query.- Clustered job store
- Worker leases with automatic lapse: a dead worker's jobs are reclaimed by any peer, leaderless, with no clustering configuration.
- Execution history
- Durable
eventsplus curatedjobs_viewandevents_viewSQL: the append-only timeline is the job log.
The code, side by side
Builder wiring becomes one declaration.
public sealed class CleanupJob : IJob
{
public Task Execute(IJobExecutionContext context)
=> Task.CompletedTask;
}
var job = JobBuilder.Create<CleanupJob>()
.WithIdentity("cleanup-expired-sessions")
.Build();
var trigger = TriggerBuilder.Create()
.WithCronSchedule("0 0/5 * * * ?")
.Build();
await scheduler.ScheduleJob(job, trigger);
public sealed class CleanupJobs
{
[Job("cleanup-expired-sessions")]
[JobSchedule("every-5-minutes", "*/5 * * * *")]
// The cron you already write, or an interval such as "5m", or a Cron.* constant.
public Task CleanupExpiredSessions(CancellationToken ct)
{
return Task.CompletedTask;
}
}
That one declaration creates a durable definition, a recurring schedule row, a slot job, retries, events, dashboard and API visibility, and schedule controls. For common expressions, use the Cron constants such as Cron.Every5Minutes, Cron.DailyAt5, or Cron.Weekdays.
Coexistence
Quartz stays the clock. Acta owns execution.
If Quartz is already your standard clock, keep it. Let the Quartz job body do exactly one thing: enqueue an Acta job. Quartz decides when to poke the app; Acta owns idempotency, retries, execution history, recovery, and operator controls. This removes the migration decision from day one.
public sealed record ReconcileInvoices(DateOnly BusinessDate);
// the trigger only enqueues; the deduplication key makes a double fire harmless
await jobs.EnqueueAsync(
new ReconcileInvoices(DateOnly.FromDateTime(DateTime.UtcNow)),
o => o.DeduplicationKey(DeduplicationKey.PerDay("reconcile-invoices", "billing")),
ct);
Do not write directly into Acta's tables from the scheduler. Call application code, an internal endpoint, a small console host, or another supported enqueue surface, so payload formats, idempotency rules, validation, and catalog lookup all stay in one place.
What changes
The differences you will feel in week one.
- JobDataMap becomes a typed input. The payload is a record with a compiler behind it, not a string-keyed map, and it is readable on the job row after the fact.
- Misfires become explicit policy on persistent cursors. A schedule carries a durable cursor; missed windows are visible and handled by declared misfire policy, not by per-trigger misfire instructions.
- Clustering configuration becomes leases. No clustered job store to configure: a claimed job carries a lease held by a heartbeating worker, and when the worker dies the lease lapses and any peer reclaims the job, leaderless.
- Logs become a ledger. Every job carries an append-only event timeline as queryable rows, and
jobs explainanswers why a job is in its current state from those rows, not from log archaeology. - Time zones stay first-class. Recurring schedules run in a named time zone, operators preview upcoming instants, and expression or zone overrides carry expected-version checks.
A fair split
Keep Quartz if the calendar is the hard part.
The migration
Migrate one job, coexist with the rest.
There is no importer for Quartz's job store and no IScheduler-compatible shim, by design. Coexistence is a supported end state, not a transition: keep Quartz as the clock for as long as the calendar earns it, and let Acta own what happens after the trigger fires.