Acta. early preview · apache-2.0 · .net 10

open source · postgres · sql server · sqlite

Acta.: the durable work ledger for .NET

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


The durable work ledger for .NET.

What

A background-jobs library where every job lives in the Postgres, SQL Server, or SQLite you already run: enqueue, schedule, retry, durable waits, fan-out, with 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 minfresh clone → dashboard, zero Docker

The code

A durable job in a dozen lines.

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 in ('dispatched', '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 original input.

How it runs

your applicationenqueueone SQL ledgerclaim + leasepeer workersrecord · checkpoint · completethe same ledger

A dead worker's lease expires; any peer reclaims the job and continues from recorded state. Optional Redis only rings the bell so idle workers wake sooner: SQL remains the only durable truth.

Run it

Acta est. Now run it yourself.

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/

Capabilities · quick reference

The core inventory, in five columns.

The one-line version of everything Acta does. Each item is expanded, with the API that does it, in the full reference below.

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
  • Exclusive keys & locks

Failure & recovery

  • Leases with automatic lapse: leaderless reclaim & retry
  • Survives crashes, deployments, restarts
  • Explain: a job tells you why, and what next
  • Restart month-old failures with original 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; JSON / MessagePack / gzip codecs
  • Anvil lab: a million jobs, kill workers, watch recovery

How it compares

You know these tools. Here is the difference.

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.
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.
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.
Temporal / 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, checkpoints instead of replay, application-owned SQL state, and no separate orchestration service.
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

See exactly what your jobs are 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, with mutating verbs off until you enable them.

Acta operator dashboard, dark theme: Overview page with health verdict, job counts, workers with heartbeats, and next schedules
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

Measured, not promised.

38,664jobs/s peak drain · Postgres · Bulk · 16 workers
180,657jobs/s batch enqueue · Postgres
6.8 msp50 end-to-end latency · Direct
0 Dockerfresh clone → running dashboard on embedded SQLite

One rig (32 logical cores, NVMe), one warmup, median of three runs, engine 0.1.1-preview, for regression tracking and rough sizing, not capacity claims.

Capabilities · in full

Every capability, in plain terms.

The quick reference above, expanded: what each piece does, how it behaves when things fail, and the API that does it.

Jobs & scheduling

Fire-and-forget jobs
Mark a method with [Job("name")] and call EnqueueAsync(input). Enqueue is typed, dispatch is source-generated from your project manifest, and the job name is the durable, operator-facing contract. The host that enqueues can also execute: no separate worker process to deploy unless you want one.
Delayed jobs
Enqueue with a not-before time. The job is durable, SQL-visible state from the moment of enqueue and is dispatched when due: surviving any restarts in between.
Recurring schedules
[JobSchedule] with an interval (5m) or cron expression. One stable slot job carries moving schedule cursors instead of creating a job row per firing. Missed windows are visible and handled by explicit misfire policy, and schedules can be paused and resumed by operators.
Deduplication keys
Enqueue with a caller-supplied key and a duplicate enqueue is refused instead of creating a second job: resubmitting the same form doesn't send the same email twice.
Retries with typed backoff
Retry count and backoff are explicit, typed policy on the job, not folklore in a catch block. Every attempt is recorded, so "how many times did this run and when" is a query.
Atomic enqueue with your data
Enqueue on the transaction you already opened, so the job and the business write commit or roll back together in the same database. When the job belongs to a different database, stage the handoff in your own transaction with AddToActaOutboxAsync and the built-in sys.outbox relay moves it into the ledger, deduplicating and quarantining as it goes. Neither is a universal exactly-once guarantee: the guide states exactly what each one buys.

Durable execution

Durable steps
Wrap work in a named durable step. The outcome is recorded in the database; when the handler re-enters after a crash, retry, or suspend, a completed step returns its stored result. External side effects still need idempotency because a process can die after the effect but before its outcome is recorded. Checkpoints, not replay: no determinism rules on your code.
At-most-once steps · AtMostOnce()
For side effects where a duplicate is worse than an ambiguous interruption. If the process dies after Acta records the start, the body is not invoked again; it may have run zero or one times, and the handler must reconcile deliberately.
Checkpoint slots
Save intermediate values mid-handler and read them back after a crash or restart: resume long work where it left off, with the evidence in a row.
Durable sleep
Sleep for minutes or days without occupying a worker thread. The job leaves the worker, the timer is durable state, and the job is re-dispatched when it fires: surviving deploys in between.
Signals
A job can suspend until a named signal arrives: a webhook, an approval, another job finishing. Deliver the signal from anywhere: code, CLI, or dashboard. No worker is held while waiting.
Child jobs, fan-out / fan-in
Spawn jobs from a handler; parent lineage is recorded, so a batch that fans out into a thousand items stays traceable, and the parent can wait on the children's results.
Exclusive keys & locks
Serialize work that shares a key (one settlement run per account at a time) without blocking workers on unrelated jobs.
Job results
Return a value from a handler and fetch it later by job reference: the result is durable state like everything else.

Failure & recovery

Worker leases & heartbeats
A claimed job carries a lease held by a live, heartbeating worker. Kill the process: the lease lapses on its own. You can watch it happen in the workers table.
Leaderless recovery
Any surviving worker reclaims lapsed jobs and retries them. There is no coordinator, no leader election, no recovery service to run: recovery is itself ordinary durable work.
Explain
Ask any job why it is in its current state and what happens next. The answer comes from its recorded rows, not from log archaeology.
Restart with original input
A job that failed a month ago is still a row, with its input. Restart it as-is: no re-constructing the payload from logs.
Failure alerts
Failures raise alert rows you can query, route, and resolve like everything else: on-failure visibility without bolting on a separate alerting pipeline.

Visibility & operations

SQL-visible state
Jobs, attempts, leases, events, schedules, checkpoints, workers, and alerts are ordinary rows, with curated operator views (acta.jobs_view and friends) for the common questions: backlog, stuck jobs, worker liveness, pending alerts.
Append-only event ledger
Every job carries a complete, append-only timeline of what happened to it (enqueued, claimed, retried, signaled, completed) as queryable rows.
Embedded dashboard & JSON API
Acta.AspNetCore serves an operational dashboard and query API from inside your app. Local-only by default, no login system to configure, and every mutating verb is disabled until you explicitly enable it.
Embedded CLI · including jobs debug
Every host binary is also the admin tool. jobs debug claims any persisted job and steps through its real handler under your debugger: reproduce a production failure with a breakpoint, not printf.
Operator verbs, opt-in
Pause, resume, cancel, restart, signal: explicit controls for humans running the system, off by default so exposure is a decision, not an accident.
Namespaces & tenants
Two questions, one job row. A namespace answers who owns and runs the work: routing, team-ownership metadata, and the peer workers that drain it. A tenant answers who the work is about: registered once by an opaque business key, resolved at enqueue, inherited by child jobs, carried into the event timeline, and readable beside every job in acta.jobs_view. Definitions can require or forbid tenant scope, and suspending a tenant rejects new work at the enqueue commit boundary without touching jobs already admitted. A tenant is an audit, query, and runtime dimension, not a security or database-isolation boundary.

Engineering

Three SQL providers
Acta.Postgres, Acta.SqlServer, Acta.Sqlite: one reference is enough, and all three share the same schema shape and operational model. Develop against an embedded SQLite file; ship the same code against your server.
Source-generated dispatch, NativeAOT
Handlers are dispatched through generated code (no reflection on the hot path) and the runtime supports NativeAOT. One SQL round-trip per state change.
Deterministic test host
Acta.Testing drives the real runtime one tick at a time: no sleeps, no polling, no flaky waits. Real-database job tests run in tens of milliseconds.
Payload codecs
JSON by default; MessagePack, gzip, or scalar payloads per job when size or speed matters.
Redis as a bell, only
Optional Acta.Redis wakes idle workers faster. It is never a source of truth: lose Redis and you degrade to polling, not to data loss.
Anvil · the failure lab
The bundled load-and-failure laboratory: enqueue a million jobs, kill real worker processes mid-flight, and watch leases lapse and recovery reclaim the work, on your machine.

Policy & operations

Priorities & reprioritization
Claim priority is per-definition policy, and operators can change it in place on a live job with ReprioritizeAsync, no cancel-and-re-enqueue dance.
Execution timeouts & deadlines
Per-definition execution timeout, plus whole-job deadlines in two honesties: a Strict deadline terminates the job; an Advisory one sets ctx.IsOverdue so the handler can degrade gracefully.
Batch enqueue
EnqueueBatch alongside single enqueue for high-volume producers, one round-trip for many jobs.
Tags & correlation keys
Attach searchable tags and a caller-supplied correlation key to any job, so operators can find work by what it means to the business, not just by id.
Time-zone schedules
Recurring schedules run in a named time zone. Operators preview upcoming instants and override expression or zone with expected-version checks.
Alert routing
Definitions route alerts to named channels; startup validation of that routing is a policy you choose. Off, Warn, or Fail.
Audit levels & retention
Audit level and retention are per-definition contract values; terminal job rows purge on schedule while events remain the audit ledger.
Large payloads, by reference
Inline payloads are capped (256 KB default; oversized writes are refused, not truncated). Big artifacts live in blob storage; the job carries a verified reference. URI, checksum, size, content type.

Possible reference application

One ledger. A web of peer workers.

AI incident-response system powered by Acta

One incident becomes a durable root job. Service-owned namespaces fan out evidence collection and AI diagnostics, pause at deterministic policy and human approval gates, run bounded remediation, wait to verify recovery, and publish an auditable report: even when workers restart halfway through.

Detect Collect Diagnose Approve Remediate Verify Report
Eight service-owned namespaces, each with its kebab-case jobs, arranged in a ring around one shared Acta SQL work ledger
Possible reference application An architecture example, not a bundled product or a roadmap commitment. Read the proposed design →
Service-owned boundaries. Each deployable service owns one namespace; its replicas are peer workers that claim only that namespace's jobs.
One root, parallel evidence. A durable incident job starts cross-namespace children for logs, metrics, traces, deployments, and specialist analysis, then joins their results.
Structured, resumable diagnosis. Checkpoints preserve evidence references, hypotheses, confidence, and proposed actions instead of rebuilding an ever-growing chat transcript.
Humans remain a durable gate. The root suspends on an approval signal without holding a worker; deterministic policy decides which actions may proceed automatically.
Remediation stays bounded. Exclusive service keys serialize actions, while idempotency keys and reconciliation protect external effects under at-least-once execution.
Verify, recover, and explain. Durable timers delay verification; expired leases are reclaimed by peers; events preserve the incident timeline through resolution or escalation.

Acta est.