Durable workflow engine · Apache-2.0 · Open Source

Build workflows that keep running.

Durable workflows that survive failures, wait for humans, retry automatically, and scale from one embedded process to a clustered server.

Java · Go · Python · Apache-2.0 · Open Source
order-fulfilment · live instancerunning
Start Validate Payment Failure ✕ retries triggered Retry · backoff ↻ attempt 2 Success · workflow done
payment failed → retries → succeedsdurable
What Wiggle actually does

Real situations, real recovery.

Wiggle wasn't designed against a feature checklist. It was designed against three situations every distributed system meets — failure, waiting, and scale.

▸ Workers die. Humans take their time. Infrastructure grows. The workflow keeps moving.
01 · Failure ▸ Dies Recover Retry Continue
▸detects the failure
▸recovers the lease
▸retries the step
▸continues the workflow
02 · Human wait Need Wait Signal Resume ⏳ persisted
▸pauses at the gate
▸persists, no polling
▸receives the signal
▸continues the workflow
03 · Scale 1 2 3 cluster embedded standalone cluster
▸starts embedded
▸goes standalone
▸clusters
Why Wiggle

Six design decisions.
One dependable engine.

From a single JVM to a clustered server, the workflow definition and your workers never change.

state DB · rows
02 · Durable

Durable execution

Workflow state is DB-backed rows, not a call stack. Crash mid-step → resume from durable state.

Exactly-once dispatch, at-least-once execution, lease-based recovery when a worker dies mid-step. Failed instances can unwind: declared compensations run newest-first as durable tasks (sagas).

s g ✓
03 · State machine

State machine, not glue

A compiled graph, versioned by content hash. The workflow is data, not replayed code.

No determinism contract. step · gate · fork · choose · timers · signals · compensation — handlers can use clocks, randomness, any library.

server Java · Go · Py pull worker pull worker gRPC
04 · Pull-based

Pull & polyglot

Workers long-poll over gRPC. Java, Go, Python on the same server.

No inbound connectivity. No broker. Backpressure built in. Dispatch is by activity name — not language.

your JVM WiggleServer
05 · Embeddable

Lightweight

One JAR + a database. Embedded in your JVM for tests.

PostgreSQL — or in-memory for dev. No Elasticsearch, no sidecar mesh, no mandatory Kubernetes.

ops console · every step
06 · Operable

Operable from day one

Web ops console with every run step by step and per-step latency, health probes, queue-lag monitoring.

Every instance as a table of its steps; open one to see its input, output, retries and timing. Cancel, signal, schedule, search by id or correlation id. A Performance tab ranks every step by p95 and queue wait.

The ops console

See every run. Find the slow step.

One web UI, a pure gRPC client of the server. Every instance step by step — what each step got, what it returned, how often it retried — and every step timed by the handler's own clock.

The console's instance detail: an onboarding run as a table of its steps, the first expanded to its input, output, retries and timing, parked on a manager-approval signal with an inline deliver button
Every step. Click a step for its input, output, retries and timing; a signal waiting on a human, and the button that delivers it.
Architecture

One database is the whole system.

Clients and pull-based workers speak gRPC to a cluster of stateless server nodes over a single database. Any node can drive any instance; the elected leader runs timers and recovery.

CLIENTS start · signal · query · cancel your application service / microservice PULL-BASED WORKERS long-poll · no inbound ports Java Go Python one instance, steps served by three languages OPS CONSOLE web UI :8090 trace · cancel · signal a pure gRPC client, like any other ▽ gRPC :8080 wiggle server — one cluster stateless nodes · any node drives any instance · add nodes for capacity and availability node · leader engine · gRPC :8080 timers · recovery node engine · gRPC :8080 leases · shared queue more nodes… or embed in your JVM new WiggleServer(config) ▽ JDBC PostgreSQL — the durable state instances · tokens · timers · schedules · definitions
Durable state

Rows, not a call stack

Every instance is tokens on a graph, committed to the database. A node can die mid-step and another picks the work up from durable state — nothing is replayed.

Pull-based

Workers long-poll

Workers run in your services, in your language, and ask for work when they have capacity. No inbound connectivity, no broker, backpressure by construction.

One source of truth

No second system to operate

The database is the only state. No consensus protocol, no quorum to lose, no separate store to keep in sync — give it the availability story you already run.

Deploying on Kubernetes →  ·  Active/active vs active/passive →

Performance

Engineered at capacity.

Numbers from the public benchmark — measured, reproducible, and the reason this engine exists.

7.2K
durable step executions/sec · same node on an 8 vCPU Cloud SQL
900 workflow instances/sec · the database sets the ceiling
260ms
end-to-end latency, flat, at 8,000 durable step executions/sec
1,000 workflow instances/sec · no backlog
◈ Measured on GCP: one server node, separate worker and load-generator VMs, Cloud SQL PostgreSQL 16. The database sets the ceiling: on 8 vCPU its CPU saturates first, on 16 vCPU waits on instance locks do. A bigger database raises it; more server nodes on the same database do not. Full methodology →
Start simple. Grow in place.

You don't rewrite your workflows as your infrastructure grows.

The same workflow definition runs embedded in a JVM today and on a clustered server tomorrow — no rewrite, and no migration of in-flight work.

A WiggleServer inside your JVM with an in-memory store. Perfect for development, tests, and single-process applications.
devtestssingle-process apps
your JVM workflow in-memory store server :8080 workflow DB worker node 1 node 2 node 3 shared queueone database
Build workflows in code

Topology and logic stay separate.

Handlers are plain methods matched by name. No YAML, no replay, no determinism rules to memorise. The workflow is a graph — the code is just the set of steps.

// A complete, durable, parallel workflow — a compiled graph the server owns.
interface OrderSteps { Order validate(Order o); boolean inStock(Order o); /* … */ }

FlowSpec orders = FlowSpec.define("order-fulfilment", Order.class, OrderSteps.class, (f, s) -> {
    var checked  = f.thenApply(s::validate).thenFilter(s::inStock);

    // continuing `checked` twice is the fan-out; the arms run on isolated copies
    var payment  = checked.thenApply(s::authorise).thenApply(s::capture);
    var shipping = checked.thenApply(s::reserveStock).thenApply(s::printLabel);

    return Wiggle.allOf(payment, shipping)
            .combine(s::merge)   // rejoining is always explicit
            .thenApply(s::notify);
});
// A Go worker implements steps by name — it interoperates with Java & Python.
worker := wiggle.NewWorker(client, "worker-1").
    Handle("order", "validate", func(o wiggle.Context) (wiggle.Context, error) {
        o["status"] = "VALIDATED"
        return o, nil
    }).
    HandleGate("order", "in-stock", func(o wiggle.Context) (bool, error) {
        return o["quantity"].(float64) > 0, nil
    }).
    Handle("order", "charge", func(o wiggle.Context) (wiggle.Context, error) {
        o["paymentRef"] = fmt.Sprintf("auth-%v", o["orderId"])
        return o, nil
    }).
    HandleEffect("order", "notify", func(o wiggle.Context) error {
        fmt.Println("shipped", o["orderId"]); return nil
    })
worker.Start(ctx)
from wiggle import Handlers, Worker

class OrderHandlers(Handlers):
    workflow = "order-fulfilment"          # methods ARE the steps

    def validate(self, o) -> dict:      # task → returns the new context
        return {**o, "status": "VALIDATED"}

    def inStock(self, o) -> bool:       # bool → a gate
        return o["qty"] > 0

    def notify(self, o) -> None:        # None → a side effect
        print("shipped", o["orderId"])

worker = Worker(client, "orders").register_handlers(OrderHandlers())
worker.start()
One workflow definition, three languages, one server — dispatch by workflow#step, never by language.
Start Run Recover Done

Your workflows deserve to survive.

Failure happens. Processes wait. Workers disappear. Infrastructure changes. The workflow continues.