Durable workflows that survive failures, wait for humans, retry automatically, and scale from one embedded process to a clustered server.
Wiggle wasn't designed against a feature checklist. It was designed against three situations every distributed system meets — failure, waiting, and scale.
From a single JVM to a clustered server, the workflow definition and your workers never change.
A definition is a compiled graph the server walks — not code it re-executes. So there is no determinism contract to break and no replay tax to pay.
Handlers are ordinary methods bound by name, so you can change them, redeploy them, and write them in another language. Versioning is a content hash of the graph — in-flight instances finish on the graph they started with.
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).
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.
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.
One JAR + a database. Embedded in your JVM for tests.
PostgreSQL — or in-memory for dev. No Elasticsearch, no sidecar mesh, no mandatory Kubernetes.
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.
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.
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.
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.
Workers run in your services, in your language, and ask for work when they have capacity. No inbound connectivity, no broker, backpressure by construction.
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 →
Numbers from the public benchmark — measured, reproducible, and the reason this engine exists.
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.
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()
Failure happens. Processes wait. Workers disappear. Infrastructure changes. The workflow continues.