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

open source · postgres · sql server · sqlite

Acta: durable background jobs for .NET

acta, n. pl. · Latin: the things that have been done; the official record of proceedings.


Durable background jobs for .NET.

Kill the worker. Keep the work.

What

Enqueue, schedule, retry, and wait on work that survives crashes and deploys. Every job is a row in the Postgres, SQL Server, or SQLite you already run, with fan-out, recovery, a dashboard, and a CLI built in.

Why

Because production happens: workers die holding work, retries repeat side effects, jobs get "stuck" and nobody can say why. Acta makes every job, attempt, lease, and retry durable SQL state you can see, query, and act on: no broker, no sidecar, no workflow SaaS.

3SQL providers: pg · mssql · sqlite
1SQL round-trip per state change
88runnable concepts & labs
5 minempty folder → durable job + dashboard · .NET 10 only · zero Docker

The code

A job that survives crashes, in a dozen lines of C#.

The [Job] name is the durable, operator-facing contract; enqueue is typed, dispatch is source-generated. And the state it creates isn't hidden in a broker or a SaaS console: it's rows you SELECT.

C# · 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);
SQL · the whole truth
-- In-flight work and who holds it.
select job_id, job_name, status,
       leased_by_worker_host,
       lease_expires_at_utc
from acta.jobs_view
where status = 'executing';

Jobs, attempts, leases, events, schedules, checkpoints, workers, alerts: all rows, with curated operator views. A job that failed a month ago is still there to inspect, and to restart with its stored input.

How it runs

How one worker claims, runs, and finalizes a job.

Your database every job your application enqueued is a row here claims due rows commits up to 128 completions at once renews what it holds Claim due rows, in one call Run your handlers, in parallel Finalize what waits goes out together Heartbeat every 45 s database slow → finalizing falls behind → claiming waits Your database every job is a row here claims due rows Claim due rows, in one call Run your handlers, in parallel Finalize what waits goes out together commits up to 128 completions at once database slow: finalizing falls behind, claiming waits Heartbeat every 45 s, renews what it holds
Done means committed

A job is done when its completion is a committed row, not when the handler returns. A crash before that commit runs the job again; a crash after it never loses it.

Durable, and still fast

Completions that arrive while the finalizer is writing go out in the next commit, up to 128 at a time, so durability does not cost a commit per job. One box drained 66,405 jobs a second on PostgreSQL. Benchmark round

Bounded, and it heals

A slow database stops intake instead of filling memory. A worker renews only the rows it holds, so when one dies its rows run out their lease and any peer takes them.

Optional Redis only rings the bell so idle workers wake sooner: SQL remains the only durable truth.

Every cap, inside one worker

Walkthroughs

Step through how jobs are ordered, limited, and shared across workers.

Three interactive walkthroughs, each on its own page: a handful of jobs and workers, moved one step at a time, with what the database records at every step.

How Acta decides which job runs next.

Only the head of each lane can be claimed, jobs without a lane run in parallel, and any worker can take the next one.

Step through it

Choose which jobs run in order, how many run at once, and how often they start.

Four independent gates. A job passes every gate it carries, and a job that carries none is never slowed by them.

Step through it

Add as many workers as you like. They share the work through your database.

Every worker is a peer on the same rows. Add one and it starts claiming; kill one and the work it held comes back to the others.

Step through it

Install

Install one package for your database. Nothing else to run.

The provider package carries the runtime, the source generator, the analyzers, and the operator CLI. No broker to stand up, no sidecar, no service to operate. The embedded dashboard is one more package, Acta.AspNetCore, when you want it. Acta is still in release candidates, so the commands carry --prerelease.

Zero setupembedded SQLite, single node
dotnet add package Acta.Sqlite --prerelease
Postgresproduction
dotnet add package Acta.Postgres --prerelease
SQL Serverproduction
dotnet add package Acta.SqlServer --prerelease

Requires .NET 10. The schema is a precondition, not a side effect: apply it with the committed provisioning script, or set ApplyMigrationsOnStartup for local work. Use Acta when the work must outlive the process that started it.

What you can build

Long-running work that survives crashes and deploys.

Different domains, one shape

No domain types: a step may call a model, a payment rail, FFmpeg, or a machine. What these share is work measured in minutes or days, expensive to repeat, and unacceptable to lose.

AI agents and LLM pipelines

RunStepAsync persists a completed step
WaitSignalAsync holds approval for days
GetOrSetVariableAsync is memory that survives the process

Document processing

MapAsync fans a batch out with lineage
JobPayload by reference for large artifacts
ctx.NoteAsync for the per-document audit line

Media and video encoding

MapAsync fans out one encode per rendition
AtMostOnce() on the publish
leases reclaimed when the encoder box dies

Manufacturing and physical processes

SleepAsync for a wait measured in hours
AtMostOnce() where a repeat is a physical event
never real-time or safety control

Orders, billing, and payments

Transactional enqueue in your own commit
DeduplicationKey makes a retry free
Lane one at a time per customer, in order

Pipelines, backfills, and ETL

[JobSchedule] with cron or a duration
retries budgeted, not unbounded
the ledger is the audit trail

Engineering labs

Every design decision, as a project you can run.

Nine questions, answered in rows

Each lab is a small project whose README asks the question and prints the rows that answer it. The field guide orders them into a 45-minute tour.

Can recurring work run forever on one row?

One job row per recurring slot, re-armed on every fire, with coincident schedules coalesced and missed windows skipped or caught up once.

103-multiple-schedules
106-schedule-misfire

Can real-database tests take milliseconds and run in parallel?

Acta.Testing drives the real runtime one tick at a time, each test in its own schema, in tens of milliseconds.

802-testing-durable-jobs
docs/guide.md

Can SQL replace a job queue, without a leader?

Claim, lease, and recovery are SQL, and recovery rides every worker’s heartbeat: the lab kills a worker mid-attempt and another recovers the job.

705-worker-crash-recovery
PostgreSQL or SQL Server

Can the worker binary be the admin tool, and can a job explain itself?

app jobs explain reads a job’s durable state and says what to do; jobs debug runs one job under your debugger.

021-jobs-cli

Can hot keys stop blocking workers?

A concurrency-key loser re-arms instead of waiting on a lock, and a rate limit books each job its turn.

209-concurrency-key

Can a job refuse unsafe replay?

AtMostOnce() never runs a step body twice: an interrupted step throws, and the handler reconciles.

220-at-most-once-step

Can fan-out be ordinary jobs?

Children are normal jobs, and the parent waits on a durable latch without holding a lease.

211-child-jobs

Can the schema be the runtime?

The worker runs from a handful of rows (steps, variables, signals, timers, results, events) that operators read through views.

201-durable-checkout

Can the data model stay this small?

Codes are small integers, the hot row stays narrow, and the model is frozen at 1.0: only additive migrations from here.

docs/reference/data-model.md
docs/design.md

How it compares

How Acta compares with Hangfire, Quartz.NET, TickerQ, MassTransit, and Temporal.

This table is the short version. The full fit guide covers when to keep exactly what you have. Choosing Acta

Hangfirebackground jobs
Keep Hangfire when its familiar persistent job runner is enough. Consider Acta when job state should become first-class application data, with durable steps, signals, lineage, and curated SQL inspection. Migration notes
TickerQsource-generated scheduler
Keep TickerQ when you want a source-generated scheduler with persistence, dashboard tooling, chaining, priorities, and concurrency controls. Consider Acta when SQL must serve as the durable operational ledger for resumable work: checkpoints, signals, recovery evidence, and operator intervention. Migration notes
Quartzscheduling
Keep Quartz when calendar sophistication, cron behavior, and misfire handling are the main problem. Consider Acta when the execution lifecycle matters more than the calendar: durable steps, leases, waits, recovery, lineage, and operator controls. Migration notes
Temporal / Dapr Workflow / Durable Taskreplay engines
Keep a replay engine when deterministic event-history replay and orchestration semantics are requirements. Consider Acta when you want ordinary re-entrant .NET handlers with no determinism contract, checkpoints instead of replayed history, and application-owned SQL state. The longer answer
A message busMassTransit, NServiceBus, …
Keep a message bus when you need distributed messaging, pub/sub, transport abstraction, or endpoint contracts. Consider Acta when the problem is recording and operating the lifecycle of application-owned work rather than moving messages between systems.
DIY queue table+ hosted service
Keep a DIY queue table when enqueue, claim, and complete are genuinely enough, and you are prepared to own its failure modes. Consider Acta before adding leases, retries, schedules, recovery, operator controls, a dashboard, and deterministic testing yourself.

The dashboard

A built-in dashboard shows what every job is doing.

The embedded dashboard reads the same durable rows you can query yourself (backlog, failures, dead workers with heartbeats, next schedules) and says what needs action. It ships inside your app as a library, local-only by default (browse to it as localhost or 127.0.0.1), with mutating verbs off until you enable them; removing the local-only guard without configuring authorization does not degrade, it refuses to start.

Acta operator dashboard, dark theme: Overview page with health verdict, job counts, failed jobs, alerts, outbox relays, and workers with heartbeats, captured mid-run
Field record · Anvil steady run Captured mid-run: 10,000 jobs draining through two workers. The ledger shows the backlog, the oldest ready job, live heartbeats, and the next schedule firings. Operator guide

Numbers & boundaries

Benchmarks, tests, and what Acta does not do.

66,405jobs/s drain · PostgreSQL · 16 workers, completions group-committed, every job durable
238,839jobs/s batch enqueue · PostgreSQL · 200k-job fleet insert
3.7 msp50 pickup latency · SQL Server · one job at a time
1,000,000 jobsnone lost, no step run twice, while a worker was killed every 5 s · on Postgres and SQL Server
0 Dockerone package reference → a durable job on embedded SQLite

One rig (32 logical cores, NVMe), the release preset (one warmup, one measured run), for regression tracking and rough sizing, not capacity claims.

5,053 tests on every run. The median end-to-end test takes 27 ms on PostgreSQL.

Each of about 1,050 conformance specs drives a real Acta runtime, claiming, executing, settling, and recovering, against a real database, and the same spec runs on all three providers: 3,126 end-to-end runs plus 1,927 unit and contract tests. Test speed is a feature, so the suites are measured, too.

Test suites: number of tests, wall time for the whole suite in parallel, and each test's own duration percentiles measured one test at a time
SuiteTestsWallp50p90p95p99
Unit and contractno database1,92715 s0.4 ms54 ms79 ms434 ms
End to endPostgreSQL1,04711 s27 ms81 ms138 ms1.73 s
End to endSQLite1,02915 provider-skipped22 s11 ms29 ms70 ms1.07 s
End to endSQL Server1,05021 s43 ms135 ms242 ms2.11 s

Wall is the whole suite as it normally runs, every test in parallel against one database, each suite on its own. The percentiles are each test's own time, from the same suite run one test at a time, so they leave out waiting for other tests. The p99 tail is the chaos and timing specs that wait out a lease or a poll on purpose. Measured 2026-10-17 on the release candidate against fresh databases, one rig: 32 logical cores, NVMe, Docker PostgreSQL 18 and SQL Server 2022. Beyond the suites, every release also runs a package smoke and the chaos certification. Certification records

Capabilities · quick reference

Everything Acta includes, at a glance.

The one-line version of everything Acta does. Each is explained, with the API behind it, in the guide.

Jobs & scheduling

  • Fire-and-forget · delayed · recurring under one model
  • Durable retries with typed backoff: policy, not folklore
  • Persistent schedule cursors: missed windows explicit; one stable job row
  • Deduplication keys stop blind repeats at enqueue
  • Atomic enqueue with your data: one transaction, or the external outbox

Execution primitives

  • Named durable steps: recorded outcomes return on re-entry
  • AtMostOnce(): record intent before invocation; reconcile an interruption
  • Durable sleeps & signals: wait days, hold no thread
  • Checkpoint slots: resume with intermediate results
  • Child jobs: fan-out / fan-in, lineage
  • Lanes: strict order per key; only the head is claimable
  • Concurrency keys, limits & locks
  • Rate limits: 600/m per definition, one meter per key

Failure & recovery

  • Leases with automatic lapse: leaderless reclaim & retry
  • Survives crashes, rolling deploys, restarts: two builds share a namespace, a rollback just works
  • Explain: a job tells you why, and what next
  • Restart month-old failures with stored input
  • Failure alerts: queryable rows

Visibility & ops

  • Everything SQL-visible: with curated operator views
  • Append-only event ledger per job
  • Embedded dashboard: an operational tool
  • CLI in every host: incl. jobs debug under a breakpoint
  • Opt-in controls: pause, cancel, restart, signal
  • Namespaces & tenants: who runs the work, who it is about

Engineering quality

  • pg · mssql · sqlite: one operational model
  • Generated dispatch, NativeAOT, no reflection
  • 1 SQL round-trip per state change
  • Deterministic test host: real-DB tests in tens of ms
  • Typed contracts: [Job] is the durable name
  • Redis as a bell only; pluggable payload serializers
  • Anvil lab: a million jobs, kill workers, watch recovery

Run it

Run your first durable job in five minutes.

Fresh clone to a running durable job on embedded SQLite: then point the same code at Postgres or SQL Server when it matters. Apache-2.0: the runtime, dashboard, CLI, and all SQL providers are free.

 fresh clone → running durable job
$ git clone https://github.com/acta-dotnet/acta && cd acta
$ dotnet run --project concepts/000-fundamentals/001-hello-acta
Enqueued. The worker is running - press Ctrl+C to stop.
Hello, World!
# press Ctrl+C, then:
$ dotnet run --project anvil/Anvil
Anvil         : http://127.0.0.1:5059/