Acta. Durable execution for .NET

Capabilities

Everything Acta does, explained.

Every capability on one page, in plain words, with the API behind it and the page that goes deeper. All of it runs on the Postgres, SQL Server, or SQLite you already have. There is no broker, sidecar, or hosted control plane to add.

Jobs and how they start

Fire-and-forget jobs

Hand work off and return at once. The job is a row in your database, so a worker runs it after a restart or on another machine, retries it when it fails, and records the outcome where you can read it.

EnqueueAsync · RunAndWaitAsync · developer guide
Delayed jobs

Run a job once, later: in ten minutes, or at an instant. The delay counts on the database clock, so a worker that starts late or a process that restarts does not move it.

ScheduleAsync · developer guide
Scheduled jobs

A schedule attribute on a job gives it a cron expression or an interval. By default the schedule fires one permanent job again and again. It sits idle between firings and holds no thread, and it keeps its durable state from one firing to the next, which is how a synchronization job remembers its watermark.

[Schedule] · ScheduledDeliveryCode.Permanent · delivery and capacity
A fresh job per occurrence

When each period is its own piece of work, a billing run or an hourly import, the definition can deliver every occurrence as a job of its own, with its own retries, result, and retention. Each carries a key made of the schedule and the instant, so an occurrence is never admitted twice. A cap bounds how many may be unfinished at once, and a full schedule either waits or drops the occurrence with an audited reason.

ScheduledDeliveryCode.PerOccurrence · MaxOutstanding · FullBehavior · delivery and capacity
Typed enqueue and handles

Every handler gets a generated call that names its job and carries its result type, and an enqueue that needs neither passes the input alone. The enqueue answers a handle: wait on it, read its typed result, cancel it, or ask it to explain itself. A wait with a timeout stops only the wait, never the job, and a job another process enqueued is followed the same way, by its ref.

JobHandle · WaitAsync · GetResultAsync · ExplainAsync · GetHandleAsync · your first job
Durable results

A handler that returns a value stores it on the job's row when the attempt succeeds. A one-shot job keeps it until retention or a restart; a permanent job keeps the last firing's result until the next firing is claimed. Returning null fails the job at once with its retry budget untouched, so a stored result is always a value, and a result over the inline cap is dropped with an audited event while the job still succeeds.

Task<TResult> · GetResultAsync<T> · TryGetResultAsync<T> · result persistence
Three ways to address a job

The public ref is a single word that copies, searches, and sorts by creation time, and it still names the job on events and alerts after the row is purged. The numeric id is for joins and debugging. The deduplication key is yours. The .NET API accepts all three; the CLI and the HTTP API take the id on reads only.

JobRef · JobLookup.ByRef · ByDeduplicationKey · ById · three identities
Deduplication keys

A key on the enqueue makes one row per business key while the job holding it exists. A retried request or a replayed message finds the existing job instead of making a second one. Keys keep their case and are scoped to the namespace; helpers compose them per definition, per hour or day, per tenant, or across definitions.

DeduplicationKey · Deduplicate(businessKey) · three identities
Clear rejections at enqueue

An enqueue that a guard refuses throws one exception with a machine-readable reason: a suspended namespace or tenant, an unknown or required tenant, a retired definition, a child trying to join its ancestor's lane. Over HTTP the same refusal is a 409 with the reason.

EnqueueRejectedException · Reason · tenant and namespace administration
Enqueue inside your own transaction

Every enqueue has an overload that takes your open database transaction, so the business write and the job commit or roll back together. Acta never commits, rolls back, or retries your transaction; you own it. The ref you get back is provisional until you commit, workers pick the job up on their next poll, and an ambient TransactionScope is refused rather than silently escalated.

EnqueueAsync(transaction, ...) · transactional enqueue
An outbox for a separate business database

When your data lives in a different database from the ledger, stage an Acta-shaped row there inside your own transaction and let a relay on a worker carry it across. The producer creates the outbox table with a script in its own migrations, or with one call from its deploy step. The relay reuses the row's key, so a redelivery is deduplicated while the target job exists, and rows staged into a lane arrive in staging order. Routing rejections back off and quarantine, and an operator requeues or discards quarantined rows from the dashboard's outbox panel.

AddToActaOutboxAsync · AddOutboxRelay · PostgresMigrations.CreateOutboxScript() · ApplyOutboxAsync · Operations.Outbox · the external outbox
Batch enqueue

Up to a thousand jobs in one call and one round trip, for a fan-out you start from outside a job or a backfill of named periods.

EnqueueJobsAsync
Enqueue from SQL

A database job, a migration, or a script can create work without any .NET code. An installed routine takes the namespace, the job name, and the input, plus a key, a priority, a delay, a tenant, a lane, or tags, and writes a correct job row.

acta.enqueue_job · SQL recipes
Enqueue from a process that runs no workers

One registration turns a host into a worker for a namespace; another makes typed enqueue available in an API that never executes handlers. A producer that does not even reference the handler assembly enqueues by namespace and job name with a JSON or bytes payload. Workers run on the generic host in a console app or inside a web app; only the dashboard needs ASP.NET Core.

Run<TManifest> · Reference<TManifest> · EnqueueJobRequest · wiring shape
Priority

Priority is part of the claim order: the highest first, then the earliest due instant. It is strict, with no aging, so bulk work that must never starve the rest belongs in a namespace of its own. An operator can change a waiting job's priority in place.

[Job(Priority = ...)] · ReprioritizeAsync · ordering and exclusion
Payloads and formats

Inputs, results, variables, and step results are JSON by default; byte arrays travel as bytes, a custom serializer registers a named format, and a job can pick a format per side. Inline values are capped at 1 MiB: an oversized write throws before it reaches storage, an oversized result is dropped while the job succeeds. Large files belong in blob storage, with the job carrying a reference, a size, and a checksum. Under Native AOT a source-generated JSON context serves every payload.

JobPayload · MaxInlinePayloadBytes · InputFormat · ResultFormat · IJobPayloadSerializer · UseJsonPayloads · large payloads
Tags, correlation keys, and notes

Tags are searchable labels on jobs, definitions, namespaces, and tenants, and an operator adds, replaces, or removes them at runtime. A correlation key groups the jobs of one request or one backfill. A handler writes a note, plain text or structured JSON, into its job's timeline, and notes are kept at every recording level. None of them carries data; the input does.

Tags · Operations.Tags · CorrelationKey · ctx.NoteAsync
Namespaces and tenants

A namespace is who runs the work: one service's workers, definitions, and schedules, and the only filter a claim applies; one process can host several. A tenant is who the work is about: a registered name set at enqueue, inherited by children unless a child overrides it on purpose, visible to the handler and to every query, and never a scheduling or security boundary. A definition can require a tenant or forbid one.

Run(jobNamespace) · TenantName · TenantRequirement · Operations.Tenants.RegisterAsync · namespace vs tenant

Inside a handler

Re-entry you can reason about and test

After a crash, a lease expiry, a retry, or a resumed wait, Acta runs the handler again from its first line. Every step it had completed returns its recorded result instead of repeating its work, and everything the handler decided is a row you can read, so a re-run is something you reason about and test rather than fear. Write a handler as if it will be called twice with the same input, and put every external side effect behind a step.

JobContext · execution semantics
Durable steps

A step wraps a piece of work under a name. Once its outcome is recorded, a replay returns the stored result instead of running the body again: after a crash the handler runs again from the top, and the steps that finished return their recorded results instead of running. A failing step fails the attempt, and the job's attempts and backoff retry it, with the finished steps replayed; a wait inside a step works, and IsLastAttempt lets a handler compensate before the last failure lands. An at-most-once step runs its body zero or one times.

RunStepAsync · IsLastAttempt · steps, variables, signals and timers
At-most-once steps

For a side effect where running twice is worse than not running, a step can record its intent before the body runs. If the worker dies in between, the step is Interrupted and the handler receives an explicit exception to reconcile against the external system, instead of a silent second charge.

AtMostOnce() · StepInterruptedException · at-most-once steps
Durable variables

Per-job values kept as rows: a synchronization watermark, a page cursor, a decision. They outlive the attempt, the retry, and the worker; a compute-once form caches a value the first execution computed, and a handler can check, require, or delete one.

SetVariableAsync · GetVariableOrDefaultAsync · GetOrSetVariableAsync · DeleteVariableAsync · steps, variables, signals and timers
Progress

A handler can report how far it is, and the dashboard and the API show it while the job runs.

SetProgressAsync
Durable sleeps and timers

A sleep parks the job on a timer row and frees the executor. The job comes back when the timer is due, on whichever worker claims it, so a wait of hours or days costs no thread and survives a deploy.

SleepAsync · SleepUntilAsync · steps, variables, signals and timers
Signals

A handler parks on a named signal until something outside raises it: a webhook, an operator, another job. The raise carries a typed value, and a raise that lands before the wait is stored and consumed without parking. A wait can take a timeout: the plain form ends the job as cancelled when it passes, and the try form returns a timed-out outcome so the handler decides. The deadline is database time, armed once and never extended by a replay.

WaitSignalAsync · TryWaitSignalAsync · RaiseSignalAsync · wait deadlines
Child jobs with lineage

A job can start jobs. Each child is an ordinary row that carries its parent's lineage and tenant, with its own retries, status, and operator visibility, and the parent waits for them without holding a worker. A named start is found again on replay instead of started twice, and a child can live in another namespace. One call starts a child and waits for its typed result, and cancelling the parent cancels every child that has not finished. The whole tree, with its steps and the wait it is parked on, reads as one lineage map.

StartChildAsync · ExecuteChildAsync · WaitChildrenAsync · TryWaitChildAsync · GetLineageMapAsync
Map, Parallel, and Join

Map fans a list out to one child per item, Parallel runs named branches, and Join waits on children you started. Each waits for every child and returns every outcome; a failed child does not cancel its siblings, and one deadline for the whole group cancels only the members still running.

MapAsync · ParallelAsync · JoinAsync · child-job groups
Handler locks

A block of handler code can run under a named lock, scoped to the namespace and renewed by the worker's heartbeat for as long as the handler holds it. Acquisition retries with backoff for about 30 seconds by default, or once, and throws a named exception when the budget is spent.

RunWithLockAsync · LockAcquisitionTimeoutException
Alerts raised from code

A handler can raise an operator-facing alert with a severity and a message, for a condition the code noticed and a person must act on. A deduplication key folds repeats into one open alert with a count, and the alert can route to a named channel. It lands in the same alert rows the failure alerts use.

ctx.AlertAsync · alerting
Fail, cancel, pause, reschedule, reset

A handler can end its job as failed or cancelled, pause it, move its next run to a later instant, and clear its own durable state as the last action of a recurring firing, so the next firing starts clean.

FailAsync · CancelAsync · PauseAsync · RescheduleAsync · ResetStateAsync
What the context knows

The context carries the job's ref, namespace, tenant, and attempt number, its deadline and the time left to it, and for a scheduled job the schedules that fired it, each with the occurrence it stands for.

JobContext · TriggeringSchedules · TimeUntilDeadline · JobContext
When the cancellation token fires

The token a handler gets belongs to the attempt. It fires on an operator's cancel, an execution timeout, a worker shutdown, or a lost lease, and Acta records which one. Honoring the token is the handler's part.

CancellationToken · cancellation semantics
Plain classes with constructor injection

A handler is a method with a job attribute on a class that dependency injection builds for each attempt, or a static method for the tiny pure case. It takes one input, several parameters the generator folds into one record, or no input at all. Inject an HTTP client, your DbContext, or anything else registered; a type you registered keeps its registered lifetime, and one Acta constructed is disposed when the attempt ends. There is no base class, no marker interface, and no registration step.

[Job("name")] · handler contract
Shared behavior around every handler

A pipeline behavior wraps every handler invocation with a cross-cutting concern: a logging scope, input validation, metrics. Register as many as you need; the first registered is the outermost.

AddPipelineBehavior<T> · IJobPipelineBehavior · pipeline behaviors

Retries, failure, and recovery

Retries with a backoff you declare

The attempt budget and the backoff are declared on the job, such as 15 attempts from one minute to one day, doubling with jitter. That default keeps a job retrying for about 4.4 days before it lands Failed, and an operator changes both without a deploy.

MaxAttempts · Backoff = "1m..1d x2 ~10%" · handler policy
Failures that should not retry

A not-implemented or not-supported exception and a step that failed for good end a job at once without spending budget, since retrying could not change the outcome; a permanent job moves on to its next occurrence instead. The handler can do the same for its own cases, and a cancellation tied to the attempt's token counts as a cancellation, not a failure.

ctx.FailAsync · exception semantics
Execution timeouts and deadlines

An execution timeout bounds one attempt, five minutes by default. A deadline bounds the whole job: under the strict behavior an overdue job is ended before its next attempt, never in the middle of a handler, and under the advisory behavior the handler reads that it is overdue and decides.

ExecutionTimeout · Deadline · DeadlineBehavior · ctx.IsOverdue
Leases and heartbeats

A claimed job carries a lease the worker renews on its heartbeat, every 45 seconds by default. The heartbeat interval is the one timing value you set, and a silent worker's jobs come back within about six minutes at the default. A write from an attempt that no longer holds its job changes nothing and ends that attempt without charging a retry, so a slow old attempt can never overwrite the new one.

HeartbeatInterval · worker options
Distributed, leaderless workers

Workers run on as many machines as you like and coordinate only through the database: every worker is a peer that claims from the same rows, renews its own leases, and runs recovery for the others, with no leader, no coordinator, and no election. Add a replica and it starts claiming; stop one and its work goes to the rest. Each process hosts one worker per namespace, several if you like, and each worker runs a fixed number of handlers at once that you size against the database.

MaxConcurrentExecutors · Run(jobNamespace) · many workers, no coordinator
Crash recovery with no leader

When a worker dies, its leases lapse. The recovery pass, which runs on any peer's heartbeat, puts its jobs back to Ready or lands them Failed when the budget is spent. It also marks the worker Dead, repairs a lane it left stranded, and re-raises any child latch the crash left pending. A dead worker's jobs are back within two leases.

what survives a crash
A slow database stops intake, never grows memory

A completion that fails to write is repeated until it lands, while the handler's result waits in the worker; a database that keeps failing stops claiming instead of piling work up in memory. When the database is slow, finalizing falls behind first and claiming waits for it.

how one worker claims, runs, and finalizes a job
Explicit deploys

A deploy step writes a build's job definitions and schedules and records its version: app jobs deploy, or app jobs migrate, which applies the schema first. A worker start writes none of it, and a worker whose version has not deployed in the namespace refuses to start, so a local build pointed at production changes nothing. An older build's deploy changes nothing unless forced, each definition keeps the version that introduced it, and every deploy leaves an event.

app jobs deploy · --force · DeploymentVersion · deployments_view · deploying jobs
Rolling deploys and rollbacks

Migrations are forward-only and keep an old and a new build working on one schema. A worker that claims a job for a handler it lacks hands the job back and stops claiming that definition. A deploy never retires a definition the new build omits, so a rollback keeps the queue.

DeploymentVersion · rolling deploys
Restart a failed job, even weeks later

A restart resets the budget and the failed steps and runs the same row again with its stored input; there is never a replacement row, and the history stays. When the data was the problem, amend the input first, then restart.

RestartAsync · UpdateInputAsync · control verbs
Explain a stuck or failed job

One call reads a job's status, lease, worker, steps and checkpoints, and latest reason in one statement and says what happened and what to do next. It is the first thing to run for a stuck, waiting, or failed job, from code, the command line, or the dashboard.

ExplainAsync · app jobs explain · start with jobs explain
A full alerting system for jobs

Acta ships alerting for jobs, with nothing to add. By default a job's failures become incidents: a warning on the first failure, an error when a threshold is reached, an error on the final failure, each resolved by the next success. Other profiles alert only on the final failure, only as information, or not at all. Each job can name its channel and a runbook link that travels with the notification, and a channel has a severity floor. Channels deliver to a log, a Slack webhook, or a transport you write, and startup checks that every alerting job routes to a channel that exists. A permanent job's incident still open reminds every 24 hours, and an operator acknowledges or resolves an alert from the dashboard or the API. The failure message travels with the alert unredacted, so keep secrets out of exception messages.

AlertProfile · AlertChannelName · RunbookUrl · AddAlertChannel · IAlertTransport · Operations.Alerts · alerting
Event recording levels

Audit records every execution's outcome as an event, and Failures records the failures, the success that recovers from them, what alerts need, a caller's controls, and a schedule's pauses and manual triggers. Choose per job, so a chatty high-volume definition costs less ledger than a payment.

EventRecording

Order, concurrency, and pacing

Lanes for strict order

A lane runs its jobs one at a time in enqueue order, like a FIFO message group. Only the head runs; the jobs behind it wait as Blocked without costing anything, and the moment the head finishes the next one is Ready. A failing head holds its lane through its retries, a restarted laned job runs right after the current head, and different lanes run in parallel.

.Lane("customer-42") · [Job(Lane = ...)] · order, concurrency, and rate
Concurrency keys

A key gives mutual exclusion across the whole cluster: while one job holds it, no other job with that key is admitted. A job that finds the key held goes back to Ready a few seconds later without spending a retry. It excludes, it does not order.

.ConcurrencyKey("rebuild:acme") · order, concurrency, and rate
Concurrency limits

A limit on the definition sizes the key: at most N attempts at once per key, from 1 to 1024. Without a key on the enqueue the definition's name is the key, so a limit alone throttles the whole definition, and definitions sharing a key share its concurrency slots.

[Job(ConcurrencyLimit = 4)]
Rate limits

A rate caps how many attempts are admitted over time, which is a different gate from how many run at once, and a job passes both. A job that arrives early is booked the next free turn: a near one is waited out in process, a farther one comes back for it without spending a retry, so a backlog drains at the rate in the order its jobs ask the meter. It paces admissions, not the gap between two starts, so it is not on its own a hard quota for an API that rejects bursts. A named meter is shared by several definitions.

[Job(RateLimit = "10/s")] · RateName
Policy changes without a deploy

Retries, backoff, timeouts, retention, alert profile, priority, concurrency limit, and rate limit are declared on the job. An operator overrides any of them at runtime through the operations API, the HTTP API, or the dashboard. Workers pick the change up on their next heartbeat, and a version guards against two people editing at once.

Definitions.UpdateOverridesAsync · PUT /definitions/{ns}/{name}/overrides · handler policy

Schedules and calendars

Cron expressions and intervals

A schedule is a cron expression with five or six fields, or an interval such as every five seconds or every hour, written plainly or in ISO 8601, and evaluated in a time zone you name. An interval steps from its first deploy and never drifts; a changed expression or zone retimes the schedule from the deploy. A schedule's name and expression are code; pausing, resuming, and triggering are operator actions.

[Schedule("nightly", "0 2 * * *")] · Cron.Hourly · TimeZoneId · declaring a schedule
Schedules with a start and an end

A schedule can be bounded to a window: its first occurrence is the first at or after the start, its last is the last before the end, and either bound may be left out. An interval with a start steps on the start's grid. An occurrence still pending at the end follows the misfire policy, and past the end the schedule reads exhausted.

StartAtUtc · EndBeforeUtc · finite schedules
Several schedules on one job

A definition can carry several schedules. On a permanent job the ones due at once are combined into one firing, and the handler sees which schedules fired and the occurrence each one stands for.

TriggeringSchedules · FiringId
Missed occurrences

A schedule's misfire strategy decides what it does with the occurrences it finds already past after downtime or a pause: skip them, run one catch-up covering all of them, run the latest, or run every one in order. An occurrence less than a minute late still counts as on time. The running workers make the decision, never a worker start, so a redeploy with an unchanged schedule changes nothing.

MisfireStrategy · Skip · CatchUpOnce · CatchUpLatest · CatchUpAll · catch-up and misfire
Expiring stale occurrences

A schedule can bound how old an occurrence may be when it is evaluated. Older ones are skipped with an audited reason, and the action says whether the schedule moves on or pauses so an operator learns it fell behind.

ExpireAfter · ExpireAction
Trigger now, pause, resume, preview

An operator can run a schedule now without moving its cursor, pause it indefinitely or until an instant, resume it, and preview its next occurrences without touching anything. A paused schedule applies the misfire policy as of its resume, and the dashboard's Run now fires an idle permanent job at once. A trigger answers the ref of the job it made ready, the permanent job or the fresh per-occurrence root; a schedule is read by its path, in any status; and its events are on that job and on its definition, each naming the schedule.

Schedules.TriggerAsync · PauseAsync · ResumeAsync · PreviewAsync · GetAsync · IJobs.RescheduleAsync · trigger, pause, resume, and preview
What a failed firing does

A firing retries under the job's budget like any attempt. When it fails for good, the definition's policy either leaves its schedules running so the next occurrence fires, or pauses them with the reason until an operator resumes.

FiringFailurePolicy · Continue · PauseScheduling
Schedules per environment

A schedule can name the environments it is deployed in, so the nightly export runs in production and nowhere else, with one build.

Environments · EnvironmentName
Calendars as data

A schedule can take its occurrences from a calendar instead of a cron: settlement days, release dates, the first business day of a month. An availability calendar is the other role: it says when a schedule may fire at all, office hours or the complement of a maintenance window, and filters any source; an occurrence in a closed window is omitted, never shifted. A schedule binds a calendar by name, and the name resolves to the published version of a family kept as data in the catalog, the only kind of calendar there is: weekly availability with date overrides and closures, business-day rules, explicit instants, daily local times, and unions, intersections, and subtractions of them, in a namespace or in a shared scope every namespace binds. Each save is an immutable version, and a preview forecasts a draft beside the current one. Publishing retimes every schedule bound to the name in one transaction, and refuses while an overdue occurrence is still undecided unless you choose to discard it. A deploy whose schedule names a family with no published version fails and writes nothing, so a calendar is published before the jobs that name it. A family nothing binds any more is archived.

Calendar = "settlement-days" · Availability = "office-hours" · Operations.Calendars · SaveDraftAsync · PreviewAsync · PublishAsync · ArchiveAsync · calendars
Backfill

Historical periods are ordinary jobs you enqueue explicitly, one per period, each with the period in its input, a deduplication key, and a correlation key for operator search. The scheduled job keeps its cadence while the backfill jobs carry the history, visible and retryable one by one.

DeduplicationKey.ForDefinition · CorrelationKey · backfill

Visibility and control

Everything is a row you can query

Jobs, attempts, leases, events, steps, variables, timers, signals, schedules, alerts, workers, and definitions are rows in your database, with curated views for the common reads. The same reads are a typed API: list jobs by namespace, status, definition, tenant, correlation, or tag, list events, read a health overview, a lineage map, or a job's checkpoints. A job that failed a month ago is still there to inspect and restart.

jobs_view · events_view · schedules_view · workers_view · Operations.Ledger.ListJobsAsync · data model
An append-only event ledger

Each attempt's outcome writes an event at the recording level the job chose, and each control a caller applies writes one at every level, with its reason: succeeded, failed with the message, retried, reclaimed after a crash, cancelled by whom, admitted by which schedule. The ledger is the audit trail and the answer to why a job is where it is.

events · JobEventsRetention · every code and reason
Job identifiers in your logs

Every log line written during an attempt, your handler's lines included, carries the job id and ref, the job name, the namespace, the execution number, the worker id, and the correlation key when there is one. A log search finds everything one job did, and Acta itself logs failures and rare events, never a line per successful job.

ActaJobId · ActaJobRef · ActaJobName · ActaCorrelationKey · logs
Built-in metrics

One .NET meter carries the numbers an operator watches, each tagged by namespace. Jobs in flight, how far behind an occurrence was when it was admitted, how late a firing started, how much lease an attempt had left when it renewed, how far the alert projector stood behind, and how many attempts were stopped by a lost lease. Any OpenTelemetry exporter picks them up.

Meter "Acta" · acta.jobs.inflight · acta.schedule.lag · acta.lease.headroom · acta.alerts.lag · release notes
The embedded dashboard

One package adds a dashboard to your own app, with the built interface inside it and nothing to install. Its pages: an overview with a health verdict, jobs and their history, permanent jobs and schedules, alerts, workers with heartbeats, events, definitions, namespaces, tenants, calendars, and the outbox, plus a page for every job that opens with its state and next action, and an enqueue form built from each job's input contract. It reads the same rows you can query. It is local-only by default, controls are off until you enable them, and exposing it without configuring authorization refuses to start.

Acta.Dashboard · app.MapActa("/acta") · EnableControls · HTTP API and dashboard
An HTTP API with an OpenAPI document

The dashboard's API is yours too, and it mounts without the interface. Enqueue a job, amend its input, raise a signal, run every control verb, read jobs by ref or by deduplication key, and page lists by cursor; a capabilities endpoint says what is enabled so a client hides the rest. A control request passes only with a confirmation header, a JSON body, or a bearer token, which no cross-site form can send, and every route is in the published OpenAPI document.

Acta.AspNetCore · /acta/api/v1/jobs/{jobRef} · MapActaApi · X-Acta-Control · openapi.json
Control verbs

Cancel, pause, resume, restart, reschedule, reprioritize, raise a signal, amend the input, and purge, from code, the API, the CLI, or the dashboard. Every verb answers applied, rejected, or not found instead of throwing, and stamps the actor and the reason into the ledger. Cancel, pause, resume, restart, reschedule, and reprioritize take the version you read, so a row that moved underneath you is not overwritten, and cancelling a parent cancels its unfinished children.

IJobs · expectedVersion · control verbs
A control CLI in every host

Your worker binary is the admin tool: it runs a verb against the database and exits, starting no worker. Apply the schema from a deploy step, explain a job, read its status, result, info, or events, pause, resume, cancel, or restart it, raise a signal, or run exactly one job in this process under your debugger with a breakpoint in the handler. A control names its job by ref or key, and a read also takes an id or the clipboard; output is text or JSON; exit codes are documented.

app jobs migrate · app jobs explain · app jobs debug --break · --json · the control CLI
A permission for every operation

Every route names the permission it needs: acta.read for every read and the dashboard, acta.enqueue for an enqueue, acta.control for acting on a job, schedule, or alert, and acta.admin for deleting data and changing configuration. Turn on RequirePermissions and each request is checked against your host's own policy of that name: a call with no credential gets 401, and a caller without the permission a 403 that names it.

RequirePermissions · ActaPermissions · permissions
Suspend a tenant or a namespace

Suspension stops new work from entering: enqueues for a suspended tenant are rejected, nothing new enters a suspended namespace and its schedules hold, and the work already admitted still runs. Resume reverses it, and the misfire policy decides the missed window. A tenant's display name and description and a namespace's owner team are edited the same way, under a version guard.

Operations.Tenants · Operations.Namespaces · tenant and namespace administration
Definitions you can see and retire

Every job definition and its schedules are catalog rows with their policy, overrides, display name, description, runbook link, and the version whose deploy introduced them. A definition a build dropped stays until an operator retires it, which cancels its queue on purpose rather than by accident.

definitions_view · Definitions.RetireAsync · DisplayName · RunbookUrl
Workers with heartbeats

Every worker is a row: host, deployment version, last heartbeat, and status, Active, Draining, Stopped, or Dead. The two ways a worker ends are two statuses, so a kill and a crash read the same, and a clean stop reads differently.

workers_view · Operations.Workers · how a worker ended
Retention and purge

A finished job stays for its definition's retention, 90 days by default, and an hourly system job deletes what is past it, along with events, alerts, and ended workers past their own windows. A completed child stays while its parent is unfinished, and a permanent job's steps, variables, and children stay as long as its schedule lives. A manual purge removes one finished job at once and leaves an audited event behind.

JobRetention · JobEventsRetention · AlertRetention · WorkerRetention · PurgeAsync · retention and purge
Faster pickup with optional Redis

Without Redis an idle worker polls at a floor you set, one second by default, so a new job waits at most that long. A Redis package lets a worker wake idle peers the moment work arrives instead. SQL stays the only durable truth; without Redis everything works, a little later.

Acta.Redis · SafetyPollInterval

Engineering quality

Three providers, one model

PostgreSQL 18 and SQL Server 2019 or later for production, embedded SQLite 3.51 or later for one process on one node and for tests, on .NET 10, with the same tables, the same operations, and the same conformance suite on all three. One provider package brings the runtime, the source generator, and the analyzers. Each provider takes a command timeout and retries a transient conflict on its own: a deadlock on PostgreSQL and SQL Server, a busy or locked database on SQLite. PostgreSQL and SQL Server also take a schema name, so several installs share one database; on SQLite, Acta's tables sit in main beside the application's.

Acta.Postgres · Acta.SqlServer · Acta.Sqlite · Schema · DeadlockRetryAttempts · compatibility
Plain database drivers, no ORM

Acta talks to the database through the three drivers themselves, Npgsql, Microsoft.Data.SqlClient, and Microsoft.Data.Sqlite, with hand-written SQL and installed routines. There is no Entity Framework in the runtime, no change tracker, and no query translation between your job and its row: what the engine sends is what the database runs.

SQL internals
A small data model, frozen from 1.0

A job is two rows: its identity and input, and the narrow live-state row a worker touches, so the hot path moves few bytes. Status and reason codes are small integers with their meanings documented, events are append-only, and every table and column is described in one generated reference. From 1.0 every schema change is an additive migration: no renames, drops, or retypes, ever.

jobs · jobs_state · events · data model · architecture decisions
Performance, measured on every release

Four PostgreSQL workers completed 14,857 fully executed, durably committed jobs per second, each job sent on its own; eight workers drained a backlog of a million jobs at 83,966 a second. The mechanism is one call that claims many due jobs and one commit for the completions that finish together, up to 128 at a time, so durability does not cost a round trip per job. One rig, 32 logical cores and NVMe: a measurement for rough sizing, not a promise for yours.

Acta Verify · benchmark report · how one worker claims, runs, and finalizes a job
Source-generated dispatch, Native AOT

A source generator reads your job attributes at build time and emits the registry of your jobs, the typed calls, and the dispatch, so there is no reflection at runtime. A worker publishes as Native AOT, with a source-generated JSON context for its payloads.

Acta.Generators · UseJsonPayloads
Build-time diagnostics

A duplicate job name, a wrong handler signature, a malformed duration, a schedule with no job, or a bad calendar binding is a build error with a help link that opens its cause, its fix, and the smallest example that clears it.

ACTA0101 to ACTA0142 · diagnostics
Startup checks that fail fast

A worker refuses to start on an invalid option, a database without its migration history, a host clock more than ten seconds from the database's, a namespace its build has not deployed in, or a payload format with no serializer, and a deploy refuses a schedule time zone the host does not know or a calendar with no published version. A changed payload contract and a job routed to an alert channel nobody configured warn by default and fail when you ask. The failure names the problem, and nothing claims a job until it is fixed.

PayloadContractDriftMode · AlertChannelValidationMode · AllowClockSkew · process behavior on failure
Fast, deterministic tests

The testing package stands the real runtime up on a throwaway schema of its own and lets a test drive it one tick at a time: enqueue, run once, assert, with any test framework and tests in parallel. Helpers make a retry, a timer, a wait timeout, or a schedule due now, expire a lease, or run one recovery pass, so the retry path and a crash are tested without a sleep. The median end-to-end test runs in 27 ms on PostgreSQL, 11 ms on SQLite, and 43 ms on SQL Server, and a suite of about a thousand of them finishes in 11 to 22 seconds per provider.

Acta.Testing · ActaTestHost · RunOnceAsync · RunRecoveryOnceAsync · ExpireExecutionLeaseAsync · testing custom jobs
One conformance suite on every provider

Every specified behavior is a test that drives a real runtime against a real database, and the same specs run on PostgreSQL, SQL Server, and SQLite on every change. The contracts document lists each guarantee with the arrange, act, and assert that proves it.

conformance contracts
Chaos-tested by the project

Before each release, the project runs its own chaos harness: a million jobs while a worker is killed every five seconds, on PostgreSQL and SQL Server. It then reconciles every job with the outcome it should have had, and checks from the database's own ledger that no step body ran twice in one attempt or again after its step had succeeded. A kill between a step's success and the note that records it leaves that check nothing to read; the conformance suite holds that case. No outside party runs it. The harness is in the repository, and the records ship with each release.

Acta Verify · release validation records · what the evidence covers
Schema migrations for a deploy step or a DBA

A deploy step applies the schema and then deploys the jobs with one command, app jobs migrate, using the app's own connection string and schema, and each provider's complete SQL is also a committed script a DBA runs, so the application principal needs no DDL rights. Applying migrations at startup exists for local work and is off by default.

app jobs migrate · PostgresMigrations.ApplyAsync · schema-pg.sql · schema-mssql.sql · schema-sqlite.sql · MigrateOnStartup
Contract evolution rules

A job enqueued before a deploy runs after it, so the job name, the input and result types, and the payload formats are durable contract fields with written rules. Keep the name for changes old payloads survive, take a versioned name when they would be misread, and keep step, variable, signal and timer names stable. A startup check warns or fails when a contract column drifts.

PayloadContractDriftMode · contract evolution
What Acta leaves out, on purpose

There is no visual designer or BPMN, no message bus, and no hosted control plane: Acta is a library in your process and tables in your database. The limits that bite in practice are written down with their reasons.

known limitations · choosing Acta
Apache-2.0, all of it

The runtime, the three providers, the dashboard, the CLI, the Redis package, and the testing package are one license, with its patent grant, and no paid tier.

LICENSE

Also: how Acta works·the developer guide·the operator guide·llms.txt, for your agent