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 typedEnqueueAsync. 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 as5mor 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
JobReffor public references,JobIdinternally.- 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
eventsplus curatedjobs_viewandevents_viewSQL. 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.
// 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 * * * *");
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.
- Retries run for days, not hours. Hangfire's default policy retries 10 times over roughly 4.5 hours; Acta's default is 15 attempts on
min(1m x 2^(n-1), 1d)with about 10% jitter, a horizon of about 4.4 days with at most one retry per day at the tail. Both are tunable; the defaults state what each expects a retry to outlive. A job that used to be dead by Sunday evening still has attempts left on Monday morning. TuneMaxAttemptsand backoff if you want the old horizon. - Typed inputs replace serialized expressions. The input is a record you declare and version, not a captured lambda. What was enqueued is readable on the job row, and a failed job restarts with its original input.
- Curated SQL views replace a private storage schema.
jobs_view,events_view, and friends are the supported operator surface: backlog, stuck jobs, worker liveness, and history are queries, not dashboard-only knowledge. - Durable steps, sleeps, signals, and lineage are first-class. The patterns that continuations and batches approximate become recorded state: a step's outcome survives a crash, a wait holds no worker, and children stay traceable to their parent.
- Operators get verbs, not just views. Pause, resume, cancel, restart, signal, reprioritize a live job in place, amend a payload before a restart, override a schedule's expression or time zone with version checks. And the CLI ships in every host:
jobs explainsays why a job is where it is,jobs debugre-runs a persisted job under your debugger. - Dashboard verbs are off until you turn them on. Both dashboards ship embedded in your app and default to local requests only. The difference is the mutating surface: Acta's dashboard is read-only until you explicitly enable controls, so exposing it never silently exposes retry, delete, or trigger buttons.
A fair split
Keep Hangfire if it is enough.
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.