Durable workflows,
cellular by design.
Describe a process as a graph. Wiggle runs it as a durable state machine that survives crashes, waits for humans, retries failures β and shards itself across isolated cells when one database is no longer enough.
Blueprint orders = Workflow.define("order-fulfilment") .step("validate") .gate("in-stock") .fork(Branch.of("payment", s -> s.step("authorise").step("capture")), Branch.of("shipping", s -> s.step("reserve-stock").step("print-label"))) .combine("merge") .step("notify") .build();
That's a complete, durable, parallel workflow. No YAML, no replay, no determinism rules to memorize β a compiled graph the server owns, and plain Java (or Go, or Python) methods that serve its steps.
Why teams pick it
Durable execution without the replay tax, and a sharding model that treats isolation as a first-class deployment primitive β not an afterthought. The full argument β
π§« Cellular by design
A namespace is a cell: its own database, its own cluster. A Raft coordinator places
instances by consistent hashing over epochs; the instance id carries its own routing
(orders.e0.s3.01Jβ¦). Grow by adding cells; resharding never migrates data.
πΎ Durable, honestly
Every instance is DB-backed rows, not a call stack. Exactly-once dispatch, at-least-once execution, lease-based recovery when a worker dies mid-step.
π§ State machine, not glue code
step, gate, fork, choose, timers, signals,
sub-workflows, doWhile, forEach β a compiled graph, versioned by content
hash. The workflow is data, not replayed code, so there is no determinism contract to get wrong.
π Pull-based & polyglot
Workers long-poll over gRPC: no inbound connectivity, no broker, backpressure built in. Java, Go, and Python workers interoperate on one server β one instance, three languages.
πͺΆ Lightweight & embeddable
One JAR plus a database (PostgreSQL, MySQL, Oracle, SQL Server β or in-memory for dev). Embed the server in your JVM for tests. The coordinator is opt-in. No Elasticsearch, no sidecar mesh, no mandatory Kubernetes.
π₯ Operable from day one
A web ops console with a live trace of every instance over the workflow diagram β cancel, deliver signals, schedules, search by instance or correlation id. A CLI for the control plane. Health probes and queue-lag monitoring built in.
How it works
Topology and logic are separate. The graph is pure structure; handlers
are plain methods matched by name. A method's signature defines its step kind β a
boolean return is a gate, void is an effect, anything else is a task whose
return replaces the context. Nothing merges implicitly, ever.
Blueprint orders = Workflow.define("order-fulfilment") .step("validate") .gate("in-stock") // false β ends cleanly .fork( Branch.of("payment", s -> s .step("authorise", RetryPolicy.exponential(5, ofMillis(100))) .step("capture")), Branch.of("shipping", s -> s .step("reserve-stock") .sleep("await-warehouse", ofMillis(300)) .step("print-label"))) .combine("merge") // isolated branches rejoin HERE .step("notify") .build();
Branches run on isolated context copies β siblings can't see each other's writes. The only way back to shared state is the explicit combine.
@Handlers("order-fulfilment") class OrderHandlers { Order validate(Order o) { return o.withStatus("VALIDATED"); } boolean inStock(Order o) { return o.quantity() > 0; } Order authorise(Order o) { return o.withPaymentRef(auth(o)); } // the combine is mandatory: fold the branches' results onto the // pre-fork base and return the COMPLETE post-join context Order merge(@Context Order base, @Arm("payment") Order pay, @Arm("shipping") Order ship) { return base.withPaymentRef(pay.paymentRef()) .withShipmentRef(ship.shipmentRef()); } }
Handlers can use clocks, randomness, any library β and be redeployed at will. No replay means no history to stay compatible with.
These building blocks compose into the shapes real processes take β browse the patterns: approvals with deadlines, sagas, fan-out over collections, cron jobs, cross-service orchestration.
Architecture
Clients and pull-based workers speak gRPC to cells. Each cell is a complete cluster with its own database; an optional Raft coordinator (embedded Ratis + RocksDB β no external store) publishes shardβcell rings as epochs. New instances follow the new ring; in-flight ones finish where they live β resharding never migrates data, and any party can compute an instance's owning cell from its id alone. How placement works β
Performance
Honest numbers from honest hardware β everything below measured on one MacBook Pro (M2 Pro, 10 cores). Full methodology β
Start embedded, end sharded
One codebase, four postures β without rewriting your workflows.
| Mode | What it is | When |
|---|---|---|
| Embedded | WiggleServer inside your JVM, in-memory store | dev, tests, single-process apps |
| Standalone | one node, gRPC :8080, in-memory or a database | small services, first deploy |
| Cluster | several nodes on one database β shared queue, leader runs timers/recovery | production, HA |
| Cellular | many cells (each its own DB + cluster) behind a coordinator | multi-tenant isolation, scale-out |
β Java
The reference client: DSL, @Handlers, embedded
server for tests. Maven Central.
πΉ Go
wiggle-go β idiomatic structs-and-interfaces workers, same wire protocol.
π Python
wiggle-python β decorator-free handler classes, contextvars step scope.
Quickstart
The fastest end-to-end is one JVM: embedded server, one worker, one instance.
implementation("io.github.hadielmougy:wiggle-client:2.1.8") implementation("io.github.hadielmougy:wiggle-server:2.1.8") // embed for tests implementation("io.github.hadielmougy:wiggle-postgres:2.1.8") // + your storage
docker run --rm -p 8080:8080 \ -e WIGGLE_JDBC_URL=jdbc:postgresql://db:5432/wiggle \ -e WIGGLE_JDBC_USER=wiggle -e WIGGLE_JDBC_PASSWORD=wiggle \ hadielmougy/wiggle:2.1.8
try (WiggleServer server = new WiggleServer(ServerConfig.fromEnvironment()).start(); WiggleClient client = new WiggleClient(server.baseUrl()); Worker worker = new Worker(client, "worker-1").register(greet).handlers(new GreetHandlers())) { worker.start(); String id = client.start(greet, Map.of("name", "ada")); InstanceView result = client.awaitCompletion(id, ofSeconds(10)); // β COMPLETED }
Then: onboarding + full configuration reference Β· the DSL cookbook (every operator, runnable) Β· the patterns library.