A Go client and worker for Wiggle, the durable state-machine
platform. It speaks the same gRPC control plane as the Java and Python clients, so a Go worker
interoperates with them on one server — dispatch is by activity name ("<workflow>#<step>"), not
by language. In a coordinator-sharded deployment the Go client resolves the owning cell per instance.
go get github.com/hadielmougy/wiggle-goRequires Go 1.25+ (the floor set by the gRPC/protobuf dependencies) and a running Wiggle server
(docker run … hadielmougy/wiggle, from the engine repo).
package main
import (
"context"
"fmt"
"log"
"time"
wiggle "github.com/hadielmougy/wiggle-go"
)
func main() {
client, err := wiggle.Dial("localhost:8080")
if err != nil {
log.Fatal(err)
}
defer client.Close()
ctx := context.Background()
// The topology is declarative data -- a Graph that mirrors the YAML/graph schema. Compile it to a
// *Blueprint you register. Handlers are bound separately, by name (below).
wf := wiggle.Graph{
Name: "order",
Steps: []wiggle.Node{
wiggle.Step{Name: "validate"},
wiggle.Gate{Name: "in-stock"},
wiggle.Step{Name: "charge", Queue: "payments", Retry: wiggle.RetryExponential(5, 100*time.Millisecond)},
wiggle.Effect{Name: "notify"},
},
}.MustCompile()
client.Register(ctx, wf)
// A worker implements steps by name. The context is a map[string]any; a task returns the whole
// context and the engine merges only what changed. Whole numbers arrive as float64 (Go's JSON convention).
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)
defer worker.Stop()
id, _ := client.Start(ctx, wf, wiggle.Context{"orderId": "A-1", "quantity": 3.0})
view, _ := client.AwaitCompletion(ctx, id, 30*time.Second)
fmt.Println(view.Status, view.Context) // COMPLETED map[...]
}Run the bundled example against a server on :8080:
WIGGLE_URL=localhost:8080 go run ./examples/orderA workflow's shape is declarative Go data — a Graph whose Steps is a slice of Node values that
mirror the YAML/graph schema. Compile() turns it into a *Blueprint you register (it validates the
shape and returns an error); MustCompile() panics instead, for a literal known-good at build time.
Handlers are not part of the topology — a worker binds them separately, by name (see below).
wf := wiggle.Graph{
Name: "order",
// DefaultQueue: "order", // queue for steps that don't set their own (defaults to the name)
Steps: []wiggle.Node{
wiggle.Step{Name: "validate"},
wiggle.Gate{Name: "in-stock"},
wiggle.Fork{Branches: []wiggle.Branch{
{Name: "payment", Steps: []wiggle.Node{wiggle.Step{Name: "charge", Queue: "payments"}}},
{Name: "shipping", Steps: []wiggle.Node{wiggle.Step{Name: "reserve"}, wiggle.Step{Name: "label"}}},
}},
wiggle.Choose{Cases: []wiggle.Case{
{When: "vip", Then: []wiggle.Node{wiggle.Step{Name: "concierge"}}},
{Then: []wiggle.Node{wiggle.Step{Name: "thanks"}}}, // no When -> the otherwise case (must be last)
}},
wiggle.Effect{Name: "notify"},
},
}.MustCompile()| Node | Meaning |
|---|---|
Step{Name, Queue, Retry} |
a task run on a worker (Handle); only changed context keys are merged back |
Effect{Name, Queue, Retry} |
a side-effect step (HandleEffect); context unchanged |
Gate{Name, Queue, Retry} |
a predicate (HandleGate); false ends the instance as gated:<name> |
Fork{Branches} |
run branches in parallel, then wait for all (join) |
ForkEach{Name, Over, As, Body} |
runtime fan-out: one branch per element of the list at Over (bound to As) |
Choose{Cases} |
exclusive choice: the first Case whose When guard holds runs; a Case with no When is the otherwise (last) |
DoWhile{While, Body} |
run Body, then repeat while the While predicate holds (body runs at least once) |
SubWorkflow{Name, Workflow} |
run another workflow as a child; its result merges back |
Sleep{Name, For} |
server-side timer (For is a time.Duration); no worker is held |
AwaitSignal{Name, Timeout, Escalation} |
wait for a signal (Client.Signal); on Timeout, fail — or run Escalation and rejoin |
Per-step: Queue (defaults to DefaultQueue, else the workflow name) and Retry
(RetryExponential/RetryFixed/RetryNone/RetryForever). Branch{Name, Steps} and Case{When, Then}
are themselves declarative slices of Node, so branches and bodies nest arbitrarily.
You don't have to author a workflow in Go to run its steps in Go. If a workflow is already registered (by any client), bind handlers by name — no topology re-declaration:
worker := wiggle.NewWorker(client, "payments-worker").
Handle("order-fulfilment", "charge", func(o wiggle.Context) (wiggle.Context, error) {
o["paymentRef"] = "auth-" + o["orderId"].(string)
return o, nil
})
worker.Start(ctx) // reconciles against the registered graph, discovering the queue `charge` pollsHandle (task), HandleGate (predicate), and HandleEffect (side effect) bind by name. On Start
the worker reconciles: it verifies each step exists and is the right kind (a typo fails fast with the
available step names) and discovers which queue each step polls. Pass AwaitRegistration(d) to ride
out a registration race.
Instead of one Handle(...) call per step, hand the worker a struct whose methods are the steps —
matched by name, with the Go signature picking the kind:
type OrderHandlers struct{}
func (OrderHandlers) Validate(o wiggle.Context) (wiggle.Context, error) { // -> a task
o["status"] = "VALIDATED"
return o, nil
}
func (OrderHandlers) InStock(o wiggle.Context) (bool, error) { // -> a gate; matches step "in-stock"
return o["quantity"].(float64) > 0, nil
}
func (OrderHandlers) Notify(o wiggle.Context) error { // -> a side effect
fmt.Println("shipped", o["orderId"]); return nil
}
worker := wiggle.NewWorker(client, "orders").RegisterHandlers("order", OrderHandlers{})
worker.Start(ctx)Each exported method shaped like a handler — func(Context)(Context,error) (task),
func(Context)(bool,error) (gate), or func(Context)error (effect) — is matched to a step of the
named workflow by case-insensitive name (InStock ↔ in-stock) on Start. The graph confirms
the exact name and the kind, so a signature that contradicts it (a task method for a gate step) fails
fast. Two method names that collide under case-folding panic at RegisterHandlers; methods of any
other shape are ignored, so helpers can live on the struct. Pass a pointer (&OrderHandlers{}) if your
methods use pointer receivers.
Dial(target, opts…) (opts are grpc.DialOption; pass credentials for TLS). Methods take a
context.Context:
Register(ctx, *Blueprint) (int, error)
GetWorkflow(ctx, name) (map[string]any, error)
Start(ctx, workflowOrName, context, opts…) (id, error) // WithVersion, WithCorrelationID
Instance(ctx, id) (*InstanceView, error)
AwaitCompletion(ctx, id, timeout) (*InstanceView, error)
ListInstances(ctx, workflow, status, limit)
Cancel(ctx, id, reason) / Signal(ctx, id, name, payload)
Health(ctx)
// worker RPCs: Poll / Complete / Fail / Heartbeat
go test ./... # offline: topology graph shapes + conversions
WIGGLE_TEST_URL=localhost:8080 go test -run Integration ./... # end-to-end against a server- Numbers arrive as
float64(Go'sencoding/jsonconvention), so asserto["qty"].(float64). - Execution mode is SERVER only (no local step chaining); interop with Java/Python workers is by activity name, so the Go content-hash version need not equal theirs.
- Regenerating stubs:
./codegen.sh(needsprotoc+protoc-gen-go+protoc-gen-go-grpc).