Acta. Durable execution for .NET

How Acta works

Everything is a job, and every job is a row.

Acta is a library in your .NET process and tables in your database. Everything that has to outlive a request is a job: a row a worker claims under a lease, retries under a budget, and records in a ledger. Steps, waits, signals, and progress are durable state on that row. Children are more rows, with lineage. A schedule makes one row claimable again and again, or starts a fresh one for each occurrence. One model, one engine, one set of views, and nothing else to run.

The model in one figure

A permanent job, through one firing.

A scheduled definition owns one permanent job by default, and the figure follows it. Admission makes the job claimable when a schedule is due, a worker runs the handler for a bounded amount of work, and the job returns to Idle holding no thread. Acta's progress and the job's are two different rows, and they never touch.

Your database the job, its schedule cursor, its firing envelope, and its watermark are rows here Acta's progress: the schedule cursor and the firing envelope, moved by admission and settlement Idle no thread, no lease Admission a schedule is due: the cursor moves, the envelope is written Ready any worker may claim it Executing your handler, under a lease: one batch, then return Idle until the next firing The job's progress: the watermark, a durable variable written after each batch, where the next firing, or a recovered one, continues 120 160 moves only while the handler executes the two never touch: an empty firing moves the cursor and not the watermark; a manual trigger moves the watermark and not the cursor Your database the job, the cursor, the envelope, the watermark Idle no thread, no lease Admission a schedule is due: cursor and envelope move Ready any worker may claim it Executing your handler under a lease, one batch Idle until the next firing The job's watermark 120 → 160, a variable written after the batch; the cursor and the watermark never touch
Acta's progress

The schedule cursor says when the job is next due; the firing envelope says which schedules admitted this firing and how it settled. Admission writes both in one apply, so an occurrence is admitted once across every worker.

The job's progress

The watermark is a variable on the job, written by the handler after each batch and read first on every execution. Acta keeps it the way it keeps the job: a row in checkpoints_view, carried across firings, retries, and workers. It is where the next firing starts, and where a recovered firing continues after a crash.

Why they never touch

A firing that finds nothing to sync still moves the cursor. A manual trigger moves the watermark and not the cursor. Each kind of progress answers one question, and neither has to be reconciled with the other.

Ten questions

The model, one question at a time.

Each answer links to the section of the developer guide that states it in full, and where there are rows to look at, to the lab that prints them.

What is a job?the unit

The only thing a worker claims. A job is a row with a name, an input, a status, a lease, a retry budget, and a ledger of events. A worker claims it under the lease, runs your handler, and writes what happened. Nothing else is claimed on its own.

Definition, or instance?two things named job

A definition is the [Job] on a handler: the durable name, the retry policy, the schedules. An instance is a row made from it, enqueued by your code, admitted by a schedule, or started by a parent. One definition, any number of rows, each with its own public handle.

What are steps and checkpoints?state on the row

Durable state that belongs to a job. A step records its outcome, so a re-run returns the stored result instead of repeating the work; variables, timers, signals, and progress are checkpoints on the same job. They share the job's claim, lease, retry budget, and events, and are never claimed on their own.

What are parents and children?more rows

Ordinary jobs. A parent starts children through Map, Parallel, or Join; each child is a row with the parent's lineage, its own retries, and its own result, and the parent waits on a durable latch without holding a worker. Fan-out is more jobs, not another kind of thing.

What is a permanent job?one job, fired again and again

The default for a definition with a [Schedule]: one permanent job the schedule fires again and again. It is Idle between firings and holds no thread; admission makes it Ready when a schedule is due, a worker claims it, and the handler does a bounded amount of work and returns. Its steps and progress persist across firings, and the handler may reset them as its final action. This is how a synchronization controller runs without a loop that must never die.

What is per-occurrence delivery?a job per occurrence

The other mode for a schedule. Each occurrence becomes a fresh ordinary root job with its own state, retries, result, and retention, and MaxOutstanding bounds how many of one schedule's roots may be unsettled at once. Use it when each period is its own business event: a statement run, an import whose files must each be tracked. Backfilling past periods is application work under either mode.

Scheduler, or executor?two passes, no coordinator

Two passes over the same rows, on every worker. Admission turns a due schedule into a firing: it moves the cursor and writes the firing envelope in one apply, so an occurrence is admitted once however many workers run. Claiming takes Ready rows under a lease and runs them. A schedule never runs code; it only makes a row claimable.

What survives a crash?everything that committed

The row, its status, the lease, the firing envelope, every recorded step, every checkpoint, and every event are in your database. The dead worker's lease runs out, a heartbeat on any worker reclaims the row, and the next execution re-enters the handler from the top, where the recorded steps and your own committed progress say where to continue. The figure and the rows below are from a real kill.

Where does at-least-once end?at your side effects

A step's recorded outcome is repeat-safe, but your writes and Acta's record of them commit separately, so a handler can run again for work that already landed. An idempotency key, an upsert, or a watermark written after the work makes that harmless. AtMostOnce() trades a possible repeat for an explicitly ambiguous outcome.

Why no separate workflow engine?composition, not a runtime

Because a workflow is a composition of jobs and their durable state, not a second runtime. A parent, its children, a long wait, a permanent controller: the same claim, lease, recovery, retry budget, dashboard, CLI, and SQL views serve all of them. There is nothing new to deploy or operate, and no determinism contract on your code.

The crash, in rows

Kill the worker after a batch. The next execution reads the same watermark and continues.

The synchronization lab arms a crash after the next batch, kills its own process, and is restarted. What the rows said before and after, on embedded SQLite, from a fresh clone.

Executing execution 7, batch applied: watermark 160 Worker killed before the attempt completes Durable state kept job executing, lease unrenewed, envelope pending, watermark 160 Lease lapses a heartbeat reclaims the row: execution-orphaned, Ready any peer, or the restarted worker Execution 8, the same firing re-enters from the top, reads watermark 160, applies the next batch: same job_ref, same firing id, one more execution_number, nothing applied twice Executing execution 7, batch applied, watermark 160 Worker killed before the attempt completes Durable state kept job executing, lease unrenewed, envelope pending, watermark 160 Lease lapses a heartbeat reclaims the row: Ready Execution 8, the same firing reads watermark 160, applies the next batch: same job, same firing id, nothing applied twice any peer, or the restarted worker
SQL · what the rows said, on 2026-10-30
-- after the kill: the job the dead worker left behind, and the watermark it wrote
job_ref                               job_status  execution_number  leased_by_worker_host_name  lease_expires_at_utc
01a18cba-b966-7a5a-aaf2-83061ba0af99  executing   7                 DESKTOP-MVKQH5M             2026-10-30 12:14:08.857Z
sys.firing  pending   {"firingId":"01a18cbb-422e-7ac2-9e3…
watermark   variable  160
feed head: change 180; local orders: 40 (newest change 160)

-- after the restart: the reclaim, the same firing finished under execution 8, the next admission
19  job.execution-orphaned   7  {"reason":"lease-expired","to":"ready"}
20  job.execution-succeeded  8
21  schedule.skipped         8  {"schedule":"every-5s","from":"2026…
22  schedule.admitted        8  {"schedule":"every-5s","occurrence"…
watermark   variable  180
feed head: change 180; local orders: 40 (newest change 180)

No admission sits between the reclaim and execution 8, so execution 8 ran the firing execution 7 lost, not a new one: it read watermark 160 and applied the twenty changes the dead process never reached. The occurrences missed while no worker ran were skipped by the misfire policy, and event 22 admits the next firing with an id of its own. The destination holds forty orders at change 180, one row per order. The lab README has every row in full, and its tests prove each claim on the real runtime without a sleep.

Also: how one worker runs a job·order, concurrency, and rate·many workers, no coordinator