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

from hangfire · migration notes

From Hangfire to Acta.

If Hangfire makes your background work boring and visible, keep it. Use Acta when a background job stops being a task and becomes application state: durable steps, signals, child jobs, long waits, and operators who need to ask a job what happened. Acta records all of that as SQL rows in the Postgres, SQL Server, or SQLite you already run, so you can kill the worker, keep the work.

The concept map

Every Hangfire concept has a place to land.

The model translates cleanly. What changes is where the state lives, and how much of it you can read afterwards.

Background job
[Job("name")] on a handler method, plus typed EnqueueAsync. The name is the durable, operator-facing contract used in SQL, the dashboard, the CLI, and alerts.
Recurring job
[JobSchedule] on the same [Job] method: an interval such as 5m or a cron expression, backed by persistent schedule cursors with explicit misfire policy.
Dashboard
MapActa() serves the embedded dashboard under /acta: local-only by default, every mutating verb off until you enable it.
Retry policy
[Job(MaxAttempts = ...)] and typed backoff settings: policy on the job, not folklore in a catch block.
Queue
A job namespace plus worker registration: one owning service per namespace, and its replicas are the peer workers that drain it.
Job id
JobRef for public references, JobId internally.
Job filters
AddPipelineBehavior<T>: one interface wraps every attempt, for cross-cutting logging, metrics, or guards.
Continuations / batches
Child jobs with fan-out / fan-in: parent lineage is recorded, and the parent can wait on the children's results.
Job parameters
Typed input contracts: a record you declare, not a serialized method-call expression.
Job history
Durable events plus curated jobs_view and events_view SQL. The event timeline is the job log: no private storage schema to decode.

The code, side by side

A serialized expression becomes a typed contract.

Hangfire · enqueue by expression
// fire-and-forget: a serialized method-call expression
BackgroundJob.Enqueue<IWebhookSender>(x =>
    x.Send(deliveryId, endpoint, payload));

// recurring, by cron string
RecurringJob.AddOrUpdate<ICleanupService>(
    "cleanup-expired-sessions",
    x => x.Cleanup(),
    "*/5 * * * *");
Acta · the whole contract
public sealed record DeliverWebhook(Guid DeliveryId, Uri Endpoint, string Payload);

public sealed class WebhookJob(IWebhookSender sender)
{
    [Job("deliver-webhook")]
    public Task Handle(DeliverWebhook input, CancellationToken ct) =>
        sender.SendAsync(input.Endpoint, input.Payload,
            idempotencyKey: input.DeliveryId.ToString(), ct);
}

// enqueue from anywhere in the host
await jobs.EnqueueAsync(new DeliverWebhook(
    Guid.CreateVersion7(),
    new Uri("https://partner.example.com/hooks/orders"),
    """{"orderId":"ORD-1042","status":"shipped"}"""), ct: ct);

// wiring, once at startup
builder.Services.UseActa(j =>
{
    j.UsePostgres(...);   // or UseSqlServer / UseSqlite
    j.Run<AppJobs>("webhooks");
});

Enqueue takes a typed record, not an expression tree, and dispatch is source-generated: no reflection on the hot path. The [Job] name is what operators see everywhere, and the state it creates is rows you SELECT.

What changes

The differences you will feel in week one.

A fair split

Keep Hangfire if it is enough.

Keep Hangfirewhen
You want a familiar persistent job runner with recurring jobs, retries, and a dashboard, and nobody asks your jobs harder questions than whether they ran. That is a solved problem, and Hangfire solves it well.
Consider Actawhen
Job state should be first-class application data in SQL: typed contracts, durable steps, signals, child jobs, and SQL-first inspection. The trigger is not cron; it is the day your continuations and batches need diagnosing.

The migration

Migrate one job, coexist with the rest.

There is no importer for Hangfire's tables and no IBackgroundJobClient-compatible shim, by design. Move one job that has outgrown being a task, run both systems side by side, and let the next migration argue for itself.

Also: from Quartz·from TickerQ·how Acta compares