Skip to content
durable-ossPublic

About

A single-server durable execution engine driven by the Trongoscript workflow language

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Gearwork

A single-server durable execution engine. Workflows are written in Trongoscript, a small LISP dialect, and run inside one Rust binary with embedded storage — no external database, no service mesh, no separate worker fleet to operate.

Temporal and Cadence are built for horizontal scale, and they charge for it up front: a Cassandra or PostgreSQL cluster, gRPC services, and worker pools, all running before your first workflow does. Gearwork is for the other case — the large class of problems where one reliable server is enough and operational simplicity is worth more than multi-node throughput. It collapses the execution engine, the state store, and the workflow runtime into a single process. You still get the durable-execution guarantees: activities run once, results survive a crash, timers fire after a restart, retries back off, and a workflow resumes exactly where it stopped.

The mechanism underneath is partial evaluation. A workflow is an expression tree. The engine reduces the pure parts eagerly and suspends the side-effecting parts, persisting the residual tree between steps. Resuming a workflow is just continuing to reduce the tree it left behind. There is no separate "replay" code path — replay is evaluation.


How it works

A workflow is a Trongoscript program. Most of it is ordinary functional code; the durable boundary is a single special form, run-activity, that marks a side effect.

(defworkflow process-order [order-id]
  (let [order   (run-activity :name "fetch-order"  :args {:id order-id})
        payment (run-activity :name "charge-card"  :args {:amount (get order :total)})]
    (run-activity :name "send-receipt" :args {:to (get order :email)
                                              :charge (get payment :id)})))

When the engine evaluates this, fetch-order is the only activity it can run first — charge-card depends on order, and send-receipt depends on both. The engine dispatches fetch-order, suspends, and persists. When the result arrives it substitutes the value back into the tree, reduces again, and now charge-card is ready. Ordering falls out of the data dependencies; there is no explicit await.

Two activities that don't depend on each other become dispatchable in the same reduction pass, so they run together. Parallelism is implicit — it is a property of the expression, not an annotation.

If the process dies between any two steps, nothing is lost. Every decision (an activity scheduled, a result received, a timer started) is an event appended to a durable log before it takes effect. On restart the engine rebuilds its state — including pending timers — by reading that log.


Quick start

Build the binary:

$ cargo build --release

Write a config file, gearwork.toml:

data_dir  = "./gearwork-data"
bind_addr = "127.0.0.1:8080"

[cache]
default_ttl_seconds = 3600

[cache.backend]
type = "memory"        # "memory", "filesystem", or "s3"

Run the server:

$ ./target/release/gearwork --config gearwork.toml

Register a workflow over HTTP:

$ curl -X POST localhost:8080/workflows \
    -H 'content-type: application/json' \
    -d '{"source": "(defworkflow greet [who] (run-activity :name \"gw/echo\" :args {:hello who}))"}'
{"registered":["greet"]}

Start a run, passing arguments:

$ curl -X POST localhost:8080/workflows/greet/runs \
    -H 'content-type: application/json' \
    -d '{"args": {"who": "ada"}}'
{"execution_id":"e1536d75-e258-411e-9f2e-7fc4a60eaf5f"}

Poll for the result:

$ curl localhost:8080/executions/e1536d75-e258-411e-9f2e-7fc4a60eaf5f
{"execution_id":"e1536d75-...","workflow_id":"greet","status":"completed","result":{"hello":"ada"}}

The gw/echo activity is a built-in that returns its arguments — useful for a first smoke test. Real workflows call activities you define in config — see Defining activities.


Workflow language

Workflows are Trongoscript: a homoiconic LISP with let, if, do, when, first-class functions, maps, vectors, and a standard library. Because a workflow is an AST that can be walked, rewritten, and serialized, the engine can pause it mid-flight by persisting the partially-reduced tree.

Defining a workflow

(defworkflow name [param ...] body ...)
(defworkflow name [param ...] :deadline-seconds 3600 body ...)

defworkflow registers a named, parameterized workflow. Parameters are bound from the argument map supplied when a run starts. One source file may define several workflows. The optional :deadline-seconds bounds each run end-to-end: a run still in flight when the deadline passes fails with a deadline-exceeded error.

Activities

(run-activity
  :name "SendEmail"
  :args {:to "user@example.com" :subject "Hello"}
  :cache-key "email-to-user"          ; optional — memoize the result
  :ttl-seconds 3600                    ; optional — cache lifetime
  :retry-policy {:initial-interval 1
                 :backoff-coefficient 2.0
                 :max-attempts 5
                 :non-retryable ["bad-request"]}
  :timeout-seconds 30                  ; optional — per attempt
  :lease-seconds 60                    ; optional — queue tasks: worker lease length
  :schedule-timeout-seconds 600)       ; optional — queue tasks: max unleased wait

run-activity is the durable side-effect boundary. Every invocation gets a stable id, is dispatched once, and has its result recorded in the event log. On replay the recorded result is substituted back in rather than re-running the side effect.

Timers

(sleep-until :seconds 30)
(sleep-until :timestamp "2026-07-01T00:00:00Z")

A sleep suspends the workflow and schedules a timer. When it fires the workflow resumes. Timers are recorded in the event log, so a sleep that should have elapsed while the server was down fires immediately on restart.

Error handling

When an activity fails permanently — a non-retryable error, or retries exhausted — the engine substitutes an error literal in place of its result:

{:error true :error-type "card-declined" :message "..."}

The workflow tests for it and routes accordingly:

(let [result (run-activity :name "charge-card" :args {:amount 4200}
                           :retry-policy {:max-attempts 5
                                          :non-retryable ["card-declined"]})]
  (if (get result :error)
    (run-activity :name "record-failed-payment" :args {:reason (get result :error-type)})
    (run-activity :name "fulfil-order"          :args {:charge-id (get result :id)})))

See examples/ for sequential, parallel fan-out, and error-handling workflows.


Defining activities

A workflow calls activities by name; the [activities] section of the config defines what each name does. Every definition presents the same contract to the engine — take a task, return a result or a typed error — over one of four execution strategies:

[activities.resize-image]
type = "subprocess"                     # a fixed command template
cmd  = "/usr/local/bin/resize"
args = ["--quality", "80"]
timeout_seconds = 60                    # default when the form sets none

[activities.resize-image.retry_policy]  # default when the form sets none;
maximum_attempts = 4                    # unset fields take the global defaults

[activities.fetch-weather]
type   = "http"                         # a fixed HTTP request
method = "GET"                          # GET/HEAD/DELETE send :args as the query string;
url    = "https://api.example.com/wx"   # other methods send :args as the JSON body
# headers = { Authorization = "Bearer ..." }

[activities.send-email]
type  = "queue"                         # no in-process execution at all
queue = "email"                         # external consumers poll this queue

[activities."gw/echo"]
type = "builtin"                        # implemented in Rust, registered in code

Subprocess. The command and argv are fixed by the definition; the workflow's :args value arrives on the child's stdin as JSON, so workflow data never lands in argv. The child writes its JSON result to stdout and exits 0. A timeout kills the child.

HTTP. The result is {:status <code> :body <value>}, with the body parsed as JSON when the response is JSON. A 4xx response (other than 408 and 429) fails as http-client-error and is never retried — re-sending a rejected request cannot succeed. Connection failures, 5xx, 408, and 429 retry under the activity's policy.

Queue. The task is enqueued and the workflow suspends. A consumer — in any language, possibly on another machine — long-polls GET /queues/:queue/poll, does the work, and posts the result back to POST /activities/:id/complete (or /fail). The engine reacts and resumes the workflow.

Polling grants a lease, not ownership. The poll response carries lease_seconds (default 60, configurable per definition or per form via :lease-seconds) and lease_expires_at. A task that finishes within the lease never thinks about it; a longer-running worker extends its lease with POST /activities/:id/heartbeat. If the lease lapses, the worker is presumed dead: the engine reclaims the task, records a worker-lost failure, and the normal retry policy decides whether it is re-enqueued for another worker or fails permanently (in which case the task is preserved on gw-errors). A late complete/fail from a presumed-dead worker is rejected with 409 Conflict. Leases are durable — a server restart re-arms every outstanding expiry from storage.

A queue definition (or form) may also set schedule_timeout_seconds (:schedule-timeout-seconds): the longest a task may wait in its queue unleased. A task nobody polls in that window fails fast with a non-retryable schedule-timeout error instead of hanging the workflow — an unattended queue is usually a misconfiguration.

Builtin. A Rust function registered on the dispatcher, run synchronously in the engine with nanoseconds of overhead. The binary pre-registers a few (gw/echo, gw/invalidate-cache); embedders add their own in code, and the TOML entry just declares the name.

A definition may carry default timeout_seconds, ttl_seconds, lease_seconds, schedule_timeout_seconds, and a retry_policy table (initial_interval_seconds, backoff_coefficient, maximum_interval_seconds, maximum_attempts, non_retryable_error_types). Defaults apply when the run-activity form doesn't set its own; the form always wins when it does.

Inline subprocesses remain available without a definition: a (run-subprocess :cmd "..." :args [...]) form runs the command the workflow names directly. Use a definition when the command is fixed and only the data varies; use the inline form as the escape hatch.

Undefined names

Calling an activity with no definition (and no registered builtin) is an error, not a silent enqueue. The activity fails immediately with a non-retryable unknown-activity error — the workflow sees the standard error literal and can route around it — and the task is preserved on the reserved gw-errors dead-letter queue, where the non-consuming peek GET /queues/gw-errors shows exactly what was dropped and why (and POST /queues/gw-errors/:task_id/requeue recovers it once fixed). A typo in an activity name therefore surfaces on the first dispatch instead of hanging a workflow forever. Queue names starting with gw- are reserved for the engine.


HTTP API

Method & path Purpose
POST /workflows Register defworkflow definitions, or run raw source once (optional deadline_seconds).
POST /workflows/:name/runs Start a run of a registered workflow with {"args": {...}}; deadline_seconds overrides the definition's.
GET /executions List executions: ?status=failed&workflow=greet&limit=50&cursor=..., cursor-paginated.
GET /executions/:id Current status and, once complete, the result.
POST /executions/:id/cancel Cancel a run ({"reason": "..."} optional). 202 queues it; already-finished runs answer 409.
GET /queues/:queue/poll Worker long-poll: returns a task (with a lease) or 204.
POST /activities/:id/heartbeat Worker extends its lease; {"cancelled": true} tells it to halt.
POST /activities/:id/complete Worker reports a result: {"result": ...}. 409 if the lease lapsed.
POST /activities/:id/fail Worker reports a failure: {"error": {"type": "...", "message": "..."}}. 409 if the lease lapsed.
POST /crons Register a cron schedule (name, workflow_name, schedule, optional args, enabled, deadline_seconds, timezone).
GET /crons, GET /crons/:name, DELETE /crons/:name List, read, and remove schedules.
POST /crons/:name/trigger Fire a schedule immediately, starting one execution.
GET /healthz Liveness: 200 when the process is up.
GET /metrics Prometheus text format: executions by status, dispatch/completion/failure counters, activity latency, queue depth, outstanding leases, timer wheel size, engine channel depth.
GET /stats Time-bucketed execution counts over a trailing window: ?window=24h&bucket=1h. Each bucket reports started, completed, failed, cancelled.
GET /audit Control-plane audit log, newest first, cursor-paginated: ?limit=50&cursor=....

Schedules and timezones

A cron schedule is a standard 5-field expression evaluated in UTC by default. Set timezone to an IANA name (e.g. "America/New_York") to pin the expression to local wall-clock time — a "0 9 * * *" schedule then fires at 9am local across daylight-saving transitions. The timer wheel still stores the resulting instant in UTC; only the interpretation of the expression changes. An unknown timezone is rejected at registration rather than silently never firing.

Statistics and audit

GET /stats answers "how did things go over the last day?" without an external time-series database: it scans the executions partition and buckets by time — started counts by creation time, the terminal counts by when the status was set. Buckets are aligned to the bucket width so the same instant always lands in the same bucket.

GET /audit records control-plane mutations that leave no trace in workflow history: cron create/delete/trigger, workflow registration, execution cancellation, and dead-letter requeue. Each entry carries a timestamp, an action verb, the target, the request's source address (when known), and an optional detail. Identity is only as strong as the transport — a single shared bearer token carries no user — so the source address is the best available actor today.

Cancellation and deadlines

POST /executions/:id/cancel stops a run: a WorkflowCancelled event is recorded and the residual simply stops being advanced. Cleanup is lazy — pending timers fire and find a finished run, queued tasks are dropped at the next poll, and a worker holding a lease learns from its heartbeat response (or the 409 on its result). In-flight synchronous activities (subprocess, HTTP) run to completion; their results are discarded.

A workflow may bound itself end-to-end with :deadline-seconds on defworkflow (or deadline_seconds on the run request, which wins). When the deadline fires with the run still in flight, it fails with a deadline-exceeded error. Together with the per-attempt execution timeout and the queue-wait schedule timeout, every phase of a workflow's life is boundable: time waiting in queue, time executing one attempt, and time end-to-end. Deadlines survive restarts.


Storage

Persistence is Fjall, a pure-Rust LSM-tree engine, embedded in-process. No database to provision or back up separately. State is split across column families: an append-only history event log, mutable executions records holding each workflow's residual AST, a task_queues partition for pending worker tasks, a memoization index, and a workflows registry of definitions.

The event log is the source of truth. The mutable records are a cache of state derivable from it, kept so the engine does not replay from the beginning on every step.


Timers

The timer system is a hierarchical, non-cascading wheel: three levels of increasing granularity (10 ms slots up through ~10-minute slots) plus an overflow map for the far future. Insertion and per-tick cost are O(1); a timer cascades down at most once per level as its deadline approaches. The wheel is an in-memory cache — durability comes from the event log, and the wheel is rebuilt from it at startup. A load test drives 100,000 timers through it and confirms each fires exactly once.


Memoization

Activity results can be cached through Apache OpenDAL, which fronts in-memory, filesystem, and S3 storage behind one interface. Set a :cache-key on an activity and its result is stored; a later run with the same key returns the cached value instead of re-executing. TTL is embedded in the stored payload, so it works uniformly across backends. The caching backend is chosen in config and the workflow code never changes when it does.


Configuration

data_dir  = "./gearwork-data"          # required: Fjall keyspace location
bind_addr = "127.0.0.1:8080"           # default shown
# Or a Unix socket, whose file permissions are the access control:
# bind_addr        = "unix:/run/gearwork.sock"
# unix_socket_mode = "0660"            # default "0600" (owner only)

# Optional: bound how many activity records are kept. Unset keeps everything.
# [retention]
# activities_for = "30d"
# max_activities = 100000

[cache]
default_ttl_seconds = 3600

# One of:
[cache.backend]
type = "memory"

# [cache.backend]
# type = "filesystem"
# root = "/var/cache/gearwork"

# [cache.backend]
# type   = "s3"
# bucket = "my-cache-bucket"
# region = "us-east-1"
# prefix = "gearwork/"                  # optional

# Activity definitions — see "Defining activities" above.
# [activities.send-email]
# type  = "queue"
# queue = "email"

Config is validated at startup — an empty data directory or a malformed S3 bucket name fails fast with a clear message rather than panicking minutes in. Logging is controlled by RUST_LOG (e.g. RUST_LOG=gearwork=debug).


Workspace layout

gearwork              binary: config, startup, signal handling
gearwork-api          HTTP server (axum): submission, status, worker polling
gearwork-engine       partial evaluator, dispatch, timers, retries, runtime
gearwork-storage      Fjall-backed event-sourced storage

The workflow language (parser, evaluator, stdlib) lives in a separate repository, durable-oss/trongoscript, and is pulled in as a pinned git dependency.


Trade-offs and limitations

Gearwork is a single-server design. There is no built-in replication or failover; durability is local-disk durability via Fjall's write-ahead log. If you need to survive the loss of the machine, put the data directory on replicated storage or run a standby.

Upper-level timer-wheel granularity is coarse by design: a multi-hour sleep fires within a window near its deadline, not to the millisecond. Workflow scheduling does not need sub-second precision at day scale, and the coarser slots are what keep the wheel O(1).


License

Licensed under either of MIT or Apache-2.0 at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

Copyright (c) 2026 Durable Programming, LLC.

About

A single-server durable execution engine driven by the Trongoscript workflow language

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages