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.
Kill the worker. Keep the work.
The durable work ledger for .NET.
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.
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.
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.
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);
-- 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 stored input.
How it runs
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.
Install
One package. Nothing new 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.
dotnet add package Acta.Sqlite --prerelease
dotnet add package Acta.Postgres --prerelease
dotnet add package Acta.SqlServer --prerelease
Requires .NET 10. --prerelease until 1.0, and the release candidate line is 1.0.0-rc.1. 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
Work that outlives the process running it.
Different domains, one shape
Acta has no domain types and never will: no Agent, no Pipeline, no Workflow. It does not care whether a step calls a model, a payment rail, FFmpeg, or a machine. What these have in common is work measured in minutes or days, expensive to repeat, and unacceptable to lose.
AI agents and LLM pipelines
A retried agent step repeats the latency, the tokens, the tool calls, and the human approval, and a nondeterministic model does not repeat the same answer. Your agent can reason again; it should not have to do the work again.
RunStepAsync persists a completed stepWaitSignalAsync holds approval for days
GetOrSetVariableAsync is memory that survives the process
Document processing
Ingest, OCR, extract, validate, index. A thousand-page batch is a thousand chances to lose the eight hundredth page, and re-running the whole batch to recover one document is the thing you are trying to avoid.
MapAsync fans a batch out with lineageJobPayload by reference for large artifacts
ctx.NoteAsync for the per-document audit line
Media and video encoding
A render measured in hours, several renditions per source, and a publish step that must happen exactly once even though the encode around it may be attempted many times.
ParallelAsync per renditionAtMostOnce() on the publish
leases reclaimed when the encoder box dies
Manufacturing and physical processes
ERP above, machines below, and Acta coordinating the business process between them. The six-hour cure, the cooldown, the maintenance window: nobody argues those should hold a thread.
SleepAsync for a wait measured in hoursAtMostOnce() where a repeat is a physical event
never real-time or safety control
Orders, billing, and payments
The job and the business row commit together or not at all, so there is no window where the order exists and the work to fulfil it does not. Then one customer's work runs in order, without a queue per customer.
Transactional enqueue in your own commitDeduplicationKey makes a retry free
ExclusiveKey serializes per customer
Pipelines, backfills, and ETL
Scheduled work that must be explainable a month later: what ran, when, why it failed, what it produced, and whether the backfill you started on Friday is still going.
[JobSchedule] with cron or a durationretries budgeted, not unbounded
the ledger is the audit trail
How it compares
You know these tools. Here is the difference.
This table is the short version. The full fit guide covers when to keep exactly what you have. Choosing Acta
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; removing the local-only guard without configuring authorization does not degrade, it refuses to start.
Numbers & boundaries
Measured, not promised.
One rig (32 logical cores, NVMe), one warmup, median of three runs, engine 1.0.0-rc.1 on the tagged tree, for regression tracking and rough sizing, not capacity claims.
- No deterministic replay, checkpoints instead; completed slots don't re-run.
- No BPMN, no visual designer, no message bus, no hosted control plane.
- At-least-once execution, recorded step outcomes are repeat-safe; external effects still need idempotency or reconciliation.
AtMostOnce()trades possible duplication for an explicitly ambiguous outcome. At-most-once steps - Not for every job: disposable local loops belong in
BackgroundServiceor cron.
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 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 debugunder 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
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 callEnqueueAsync(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. - Enqueue and wait
RunAndWaitAsyncenqueues, waits for the terminal outcome, and hands back the typed result: request-response over durable execution, for the places where fire-and-forget isn't an answer.- 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 operators can pause a schedule (with an auto-resume time if they want one), resume it, or fire it immediately with trigger-now, all without touching the cadence.- 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 enqueue the email job twice. It deduplicates the job row, not the side effect: execution stays at-least-once, which is what durable steps and idempotency keys are for.
- Retries with typed backoff
- Retry count and backoff are explicit, typed policy on the job, not folklore in a catch block. Attempts are counted on the job row, and at the default audit level each one is recorded as start and finish events, 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
AddToActaOutboxAsyncand the built-insys.outboxrelay 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 progress
SetProgressAsyncwrites typed progress into a reserved checkpoint slot: a long import can say "43,000 of 90,000", and the dashboard, the API, and plain SQL all read the same 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. An
ExclusiveKeybounces the losing job back to the queue so no worker waits on it;RunWithLockAsyncwaits inside the running handler instead, for the small critical section that wants it. Exclusion rides the lease, and admission order is unspecified: exclusive, unordered work. - Job results
- Return a value from a handler and fetch it later by job reference: the result is durable state like everything else.
- Handler-directed outcomes
- A handler can end its own run deliberately: reschedule itself to a chosen instant, fail with a stated reason, cancel, or pause for an operator. The outcome is policy stated in code, not an exception dressed up as control flow.
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 stored 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. The restart runs whatever the row holds now, so if an operator amended the input first, the amendment is what runs.
- Job repair, beyond restart
- Reschedule a job to run now or at a chosen instant, amend its stored input (bounded metadata records that you did), or purge it outright: the repair verbs operators actually reach for, durable and audited like everything else.
- Failure alerts
- Failures raise alert rows by default: query, route, and resolve them like everything else, on-failure visibility without bolting on a separate alerting pipeline. An outage is one open incident per job and reason that re-notifies on the reminder interval (daily by default) until it resolves, not a row per failure. Per-definition profiles can quiet a job down to terminal failures only, or to nothing.
- Outbox quarantine
- The
sys.outboxrelay deduplicates as it admits staged work, and what cannot be admitted lands in a quarantine you can read, requeue, or discard: from code, the dashboard, or HTTP, with the decision recorded.
Visibility & operations
- SQL-visible state, documented
- Jobs, leases, events, schedules, checkpoints, workers, and alerts are ordinary rows, with curated operator views (
acta.jobs_viewand friends) for the common questions: backlog, stuck jobs, worker liveness, open alerts. The model is documented column by column - 15 entities with their keys, indexes, and checks - and the full provisioning SQL for all three providers ships in the repo, so you can read the schema before you adopt it. Attempt-by-attempt history rides the event ledger, as complete as the definition's audit level. - Append-only event ledger
- Every job carries an append-only timeline of what happened to it - executions started and finished, retries, suspensions, signals, operator actions, completion - as queryable rows. The audit level chooses how much of it each definition writes: the full timeline at
Audit, failures only or nothing at all where churn matters more than history. - Notes & manual alerts
ctx.NoteAsyncappends handler-authored evidence to the job's timeline even where audit is turned down;ctx.AlertAsyncopens an alert the handler decided is worth an operator's attention. Both are rows, like everything else.- Built-in metrics
- An
Actameter emits executions, durations, claims, steps, live executing jobs, lock contention, and alert activity throughSystem.Diagnostics.Metrics: OpenTelemetry picks it up with one line, no Acta-specific exporter. - Embedded dashboard & JSON API
Acta.AspNetCoreserves 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 debugclaims an eligible persisted job - one this host's manifest knows and no worker is running right now - 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. Over HTTP and the dashboard they are off by default, so remote exposure is a decision, not an accident. The in-binary CLI ships enabled - it already implies shell access to the host - and
DisableCli()turns it off where even that is too much. - 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 enqueues at the commit boundary; jobs already admitted keep running, and children they spawn inherit the tenant with them. Namespaces carry the same operator lifecycle: list, suspend, resume, and version-checked metadata updates. 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.- Execution profiles
- Buffered, Direct, and Bulk choose the throughput-durability trade explicitly, per worker. Bulk group-commits completions and is meant for safely re-runnable work; the docs state exactly what each profile relaxes.
- Source-generated dispatch, NativeAOT
- Handlers are dispatched through generated code (no reflection on the hot path). NativeAOT is exercised, not asserted: every shipped package declares AOT compatibility and the bundled lab app publishes under NativeAOT as a gated test. One SQL round-trip per state change.
- Deterministic test host
Acta.Testingdrives the real runtime one tick at a time: no sleeps, no polling, no flaky waits. Real-database job tests run in tens of milliseconds.- Contract-drift guard
- Worker startup compares each job's input and output contracts against what the ledger recorded, and warns or refuses - your choice - before a mismatched payload meets a live queue.
- Payload codecs
- JSON, text, and bytes ship built in; the serializer registry is pluggable per job, with worked MessagePack and gzip examples in the concepts.
- Redis as a bell, only
- Optional
Acta.Rediswakes 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.
- Published baselines
- The benchmark matrix and its methodology ship in the repo, with dated results on documented hardware: one worker at 32 concurrent executors on the conservative Buffered profile measured 3,855 end-to-end jobs/s on PostgreSQL and 3,087 on SQL Server (2026-07-31 baseline, 32 logical cores, NVMe). Regression evidence and rough sizing, not capacity promises: re-run it on yours.
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. - Live policy overrides
- Priority, retries, backoff, timeout, deadline, retention, audit, and alerting can be overridden per definition on a running system, guarded by expected-version checks: tune a hot job without a deploy.
- Durable named settings
- Global, namespace, and definition-scoped settings live in the ledger next to the work: compare-and-swap writes, audited changes, new names without a migration. Configuration your operators can see.
- Execution timeouts & deadlines
- Per-definition execution timeout, plus whole-job deadlines in two strengths: a Strict deadline terminates the job; an Advisory one sets
ctx.IsOverdueso the handler can degrade gracefully. - Batch enqueue
EnqueueBatchAsyncalongside 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. Tags reach past jobs: tenants, namespaces, definitions, schedules, workers, alerts, and events all take them.
- 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 (1 MiB default; oversized writes are refused, not truncated). Big artifacts belong in blob storage with the job carrying a reference - URI, checksum, size, content type - a pattern the concepts show worked end to end. The cap is Acta's; the blob store and the verification stay yours.
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.
$ 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/