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.
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.
Retry counters, timers, and "where was I" live in ad-hoc tables and queues.
A dead process forgets in-flight work; nothing reclaims or resumes it.
The workflow is the source of truth — persisted, resumable, observable.
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.
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();
| step / effect | run on a worker; return value becomes the new context |
| gate | continue only while a predicate holds |
| choose | switch/case — the first matching branch runs |
| fork / join | run branches in parallel, wait for all |
| forkEach | runtime fan-out: one branch per list element |
| sleep | server-side timer — no worker held while waiting |
| awaitSignal | pause for external input; optional deadline escalates |
| subWorkflow | run another workflow as a child; result merges back |
| doWhile | run 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.
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.
Persists instances & tokens, drives timers, elects a leader for clock-driven duties. In-memory for dev, Postgres for a cluster.
Register blueprints, long-poll for steps, run them with configurable concurrency & leases. Add processes to scale — they share load automatically.
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.
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.
Server-driven: every step is a poll → execute → complete round-trip. Maximum control and visibility.
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.
Buffers up to localBatchSize steps, reports in one call. Fewest commits — but a killed worker re-runs the batch, so steps must be idempotent.
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.
Per-step policy; throw PermanentActivityException to skip them.
sleep parks the instance — no worker thread is held.
Wait for a human to approve or a system to report back. Deadlines escalate or fail.
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"));
Wrap the record in {_schema, _v, data} so its schema can move.
Old data is migrated forward on read; every step sees one shape.
In-flight (pre-envelope) instances read as v1 and upgrade on the next write.
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
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.
The diagram with every node ringed by its token status; context, tokens, cancel.
Compiled graph as a diagram — gates, fork/join, signals, loop back-edges.
Create and delete timers with a seed context.
Every instance parked on a signal, with a form to send it.
GET /api/instances/{id} · JSON, same API the SPA uses
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.
# 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
An embedded server, one worker, and a running instance — the whole loop in a single try-with-resources block.
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
}
./gradlew :example:run · docs/onboarding.md · Apache-2.0