Acta. apache-2.0 · .net 10 · pg / mssql / sqlite

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 typed EnqueueAsync for on-demand work. The name is the durable, operator-facing contract.
Trigger, cron or interval
[JobSchedule] on the same [Job] method: an interval such as 5m or a cron expression, with Cron constants 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, plus JobRef for public references and JobId internally.
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 events plus curated jobs_view and events_view SQL: the append-only timeline is the job log.

The code, side by side

Builder wiring becomes one declaration.

Quartz · job, trigger, schedule
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);
Acta · the same schedule
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.

The entire Quartz job body
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.

A fair split

Keep Quartz if the calendar is the hard part.

Keep Quartzwhen
Calendar sophistication, cron behavior, and misfire handling are the main problem. Quartz's scheduling semantics are mature and deep, and Acta does not try to out-calendar it.
Consider Actawhen
The execution lifecycle matters more than the calendar: durable steps, leases, waits, recovery, lineage, and operator controls. Or keep both, with Quartz as the clock and Acta as the ledger.

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.

Also: from Hangfire·from TickerQ·how Acta compares