Durable state machines · Cellular · Apache-2.0

Wiggle

A durable state-machine platform — and the control plane to shard it. Describe a flow with a stream-style DSL, run it as a durable state machine embedded or as a cluster, let pull-based workers process the steps, and shard across isolated cells when one cluster isn't enough.

Durable · survives restarts At-least-once · lease recovery Cellular · shard by namespace No runtime deps · JDK only
The problem

Long-running work outlives a single request.

Payments, fulfilment, approvals, ETL — real business processes span retries, timers, human input, and parallel branches. Bolting that onto request threads and cron jobs leaks state, loses work on crashes, and hides the flow across a dozen services.

Without an engine

Scattered state

Retry counters, timers, and "where was I" live in ad-hoc tables and queues.

Without an engine

Lost on crash

A dead process forgets in-flight work; nothing reclaims or resumes it.

Wiggle

One durable flow

The workflow is the source of truth — persisted, resumable, observable.

Define

A workflow is a chain of steps.

Nothing runs while you build it. build() produces an immutable Blueprint you register and start. The context is one type from first step to last — typed records or JSON maps.

step transform gate filter fork parallel sleep timer
Blueprint<Order> orders = Workflow.define("order-fulfilment",
        ContextCodec.records(Order.class))

    .step("validate", o -> o.withStatus("VALIDATED"))

    // false ends the instance successfully
    .gate("in-stock", o -> o.quantity() > 0)

    .fork(
        Branch.of("payment", s -> s
            .step("authorise", Payments::authorise)
            .step("capture",   Payments::capture)),
        Branch.of("shipping", s -> s
            .step("reserve", Stock::reserve)
            .sleep("warehouse", ofMillis(300))
            .step("label",   Labels::print)))

    .step("notify", Notifier::send)
    .build();
The DSL

Everything a real process needs.

step / effectrun on a worker; return value becomes the new context
gatecontinue only while a predicate holds
chooseswitch/case — the first matching branch runs
fork / joinrun branches in parallel, wait for all
forkEachruntime fan-out: one branch per list element
sleepserver-side timer — no worker held while waiting
awaitSignalpause for external input; optional deadline escalates
subWorkflowrun another workflow as a child; result merges back
doWhilerun a body, repeat while a condition holds
step(…, queue)route a step to a dedicated worker pool

Any of step, effect, and gate take an optional RetryPolicy — exponential backoff with jitter by default.

Architecture

Server holds the truth. Workers pull the work.

The server never pushes. Workers ask for work when they have capacity, so backpressure is built in and workers need no inbound connectivity. A step that stalls has its lease expire and is redelivered — at-least-once, always.

Server

Durable orchestration

Persists instances & tokens, drives timers, elects a leader for clock-driven duties. In-memory for dev, Postgres for a cluster.

Workers

Pull-based execution

Register blueprints, long-poll for steps, run them with configurable concurrency & leases. Add processes to scale — they share load automatically.

Storage

Pluggable

The core knows no database. It builds a store from an injected StorageFactory picked from the URL scheme — PostgreSQL/H2, MySQL, Oracle, SQL Server, Cassandra. Another DB is a new module, not an engine change.

Execution modes

Trade round-trips for throughput — per workflow.

By default the server drives one step at a time. For step-heavy linear flows, let a worker chain consecutive same-queue steps locally. The mode is part of the definition's content hash, so an in-flight instance keeps the mode it started on.

Default

SERVER

Server-driven: every step is a poll → execute → complete round-trip. Maximum control and visibility.

most durable most round-trips
Balanced

LOCAL_SYNC

Worker runs consecutive same-queue steps back-to-back, committing each before the next. As durable as SERVER — a crash re-runs at most one step.

durable fewer hops
Throughput

LOCAL_ASYNC

Buffers up to localBatchSize steps, reports in one call. Fewest commits — but a killed worker re-runs the batch, so steps must be idempotent.

fastest wider blast radius

Every mode hands control back to the server at a boundary — a sleep, fork, join, awaitSignal, a sub-workflow, a different queue, or a retry — so those always coordinate centrally. .checkpoint() forces an async step to commit early; a graceful close() drains the buffer, losing nothing.

Resilience & humans

Retries, timers, and days-long waits.

  • Automatic retries

    Per-step policy; throw PermanentActivityException to skip them.

  • Server-side timers

    sleep parks the instance — no worker thread is held.

  • Signals

    Wait for a human to approve or a system to report back. Deadlines escalate or fail.

  • Lease recovery

    A worker dies mid-step → its lease expires → another picks it up.

Workflow.defineJson("expense")
    .step("submit", Expenses::record)

    // wait up to 48h for a human;
    // escalate if nobody acts
    .awaitSignal("manager-approval",
        ofHours(48),
        b -> b.step("escalate",
                 Escalations::toDirector))

    .step("pay-out", Expenses::disburse)
    .build();

// deliver from anywhere:
client.signal(id, "manager-approval",
              Map.of("decision", "approved"));
Evolution

Contexts that change shape, safely.

  • Versioned envelope

    Wrap the record in {_schema, _v, data} so its schema can move.

  • Upcast to current

    Old data is migrated forward on read; every step sees one shape.

  • No flag day

    In-flight (pre-envelope) instances read as v1 and upgrade on the next write.

  • #
    Condition on the version

    ContextVersion.current() lets a handler grandfather older instances.

// the Order record grew over time
var codec = VersionedContextCodec.builder(Order.class, 3)
    .schema("order")
    // v1 → v2: default a new field
    .upcast(1, m -> { m.put("currency", "USD"); return m; })
    // v2 → v3: rename amount → total
    .upcast(2, m -> { m.put("total", m.remove("amount")); return m; })
    .build();

Workflow.define("order", codec)
    .step("price", o -> o.withTotal(...));
// a v1 instance is upcast and finished by a v3 worker
Observability

A live dashboard, in the box.

A ClojureScript + Reagent single-page app ships inside the server jar and is served straight from its classpath — no proxy, no CDN. Every node runs its own; each shows the whole system.

Instances

Live trace

The diagram with every node ringed by its token status; context, tokens, cancel.

Workflows

Graph view

Compiled graph as a diagram — gates, fork/join, signals, loop back-edges.

Schedules

Interval & cron

Create and delete timers with a seed context.

Signals

Deliver inline

Every instance parked on a signal, with a form to send it.

Instance · order-fulfilment
validate gate fork
payment ✓ shipping ● notify ⏸

GET /api/instances/{id} · JSON, same API the SPA uses

Deploy

One JVM to a Postgres cluster — same code.

Point several nodes at one Postgres and they form a cluster: every node serves the API and hands out work; exactly one leader runs clock-driven duties. Kill any node — the rest carry on.

0runtime dependencies (JDK only)
1 → Nnodes, set by a JDBC URL
≥1×at-least-once execution
# single node, in-memory — dev & tests
./gradlew :dist:run

# cluster on Postgres — just add a JDBC URL
WIGGLE_JDBC_URL=jdbc:postgresql://localhost:5432/wiggle \
WIGGLE_DASHBOARD_PORT=8090 ./gradlew :dist:run
Quick start

End to end in one JVM.

An embedded server, one worker, and a running instance — the whole loop in a single try-with-resources block.

io.github.hadielmougy:wiggle-client:2.1.2
Blueprint<Map<String,Object>> greet =
    Workflow.defineJson("greet")
        .step("hello", ctx ->
            Map.of("greeting",
                   "hello, " + ctx.get("name")))
        .build();

try (var server = new WiggleServer(cfg).start();
     var client = new WiggleClient(server.baseUrl());
     var worker = new Worker(client, "w-1")
                      .register(greet)) {
    worker.start();

    String id = client.start(greet,
                      Map.of("name", "ada"));
    var r = client.awaitCompletion(id, ofSeconds(10));

    System.out.println(r.status());  // COMPLETED
}
Wrap-up

Durable flows,
ordinary Java.

./gradlew :example:run  ·  docs/onboarding.md  ·  Apache-2.0

01 / 11
or space to navigate