Wiggle — Onboarding & Configuration Reference§

Everything a new contributor or operator needs: what Wiggle is, how to get it running, how to author workflows, and every configuration option in one place.


1. What Wiggle is§

A durable state-machine platform. You describe a process as a graph with a java.util.stream-style DSL; a server runs it as a durable state machine (tokens over the graph) that survives crashes and resumes where it left off; workers pull work when they have capacity and run the step logic. See README.md for the elevator pitch and docs/local-execution.md for the execution-mode deep dive.

Core properties: durable (survives restarts), exactly-once dispatch and at-least-once execution, pull-based workers (no inbound connectivity), immutable published definitions, and multi-node clustering over a shared database.


2. Getting the code§

git clone https://github.com/hadielmougy/wiggle.git
cd wiggle
./gradlew build          # compiles everything and runs the test suite

Prerequisites:

  • JDK 21+ (the Gradle toolchain pins language level 21).
  • The Gradle wrapper is committed (./gradlew); no local Gradle needed. If it's ever missing, regenerate once with gradle wrapper --gradle-version 8.10.
  • Docker only for the Postgres cluster demos (§4.3); nothing else needs it.

Dev loop:

./gradlew build                        # full build + tests
./gradlew check                        # tests only (JUnit)
./gradlew :tests:run                   # the conformance scenarios, framework-free, no network
./gradlew :module:test --tests "Foo"   # a single test class

Branch off main, keep changes focused, and run ./gradlew check before pushing. Schema changes go through the migration runner (§7.4), never by editing an already-released migration.


3. Modules§

Module What's in it Published artifact
core JSON, the compiled graph model, retry policy, execution mode, wire records wiggle-core
proto the WiggleControlPlane gRPC service + generated stubs wiggle-proto
client the workflow DSL, WiggleClient, the pulling Worker wiggle-client
server engine, cluster manager, housekeeper, queue-lag monitor, gRPC API, /healthz probe, in-memory store, injected StorageFactory wiggle-server
jdbc shared dialect-aware, HikariCP-pooled JDBC store wiggle-jdbc
postgres PostgreSQL dialect, plus H2 for tests and local runs, and the PostgresStorageFactory that maps a URL scheme to one of them wiggle-postgres
election leader election by announce-and-heartbeat, used for intra-cluster leadership (not published)
dist runnable standalone server (what the Docker image runs); its WiggleStorageFactory is PostgresStorageFactory under the published name (not published)
example order-fulfilment demo, standalone worker/submitter, benchmark (not published)
tests conformance scenarios + JUnit wrapper (not published)

Published under group sh.wiggle, version 0.0.10 (the runnable dist module is not published). The server core is database-agnostic; it builds its store from an injected StorageFactory and the backend is selected from the URL scheme (§7.2).

Two extras make wiggle painless behind a locked-down internal Artifactory: wiggle-bom (import it as a platform and every module — plus the shared gRPC/protobuf stack — is version-aligned with no per-artifact pins), and wiggle-client-all (the client shaded into one jar with gRPC, protobuf and Guava relocated under com.wiggle.shaded, so it has zero transitive dependencies and can't clash with anything already on the classpath). See the README's install section for snippets.


4. Running it§

4.1 Single node (in-memory) — dev default§

./gradlew :dist:run                          # gRPC on :8080, in-memory store
./gradlew :example:run                          # embedded server + worker + a few orders, one JVM

4.2 Server + workers as separate processes§

./gradlew :dist:run                                  # terminal 1
./gradlew :example:runWorker                            # terminal 2 (WIGGLE_URL=localhost:8080)
./gradlew :example:submitOrders -Pcount=20             # terminal 3

4.3 A cluster on Postgres§

docker compose up -d postgres        # Postgres on :5433 (see docker-compose.yml)
scripts/cluster.sh 20                 # three server nodes, two workers, one Postgres

# or on Kubernetes (kind):
scripts/kind-up.sh 3                   # 3 server pods + Postgres at localhost:30080
scripts/run-workers.sh 5 20            # 5 local workers, then submit 20 orders
scripts/kind-down.sh                   # tear down

4.4 As a container (Docker)§

The Dockerfile builds one image: the server, with the portal on WIGGLE_PORTAL_PORT (8070) and every storage backend bundled, picked from the URL scheme; it reads the same env vars as the JAR (§6). TLS is set the same way — WIGGLE_TLS_KEYSTORE + a mounted keystore. The signed, multi-arch image is published to both hadielmougy/wiggle (Docker Hub) and ghcr.io/hadielmougy/wiggle (GHCR) — the two are the same image; use whichever you prefer.

# run the released image: an in-memory server (gRPC :8080, /healthz probe optional)
docker run --rm -p 8080:8080 hadielmougy/wiggle:0.0.10            # Docker Hub
# docker run --rm -p 8080:8080 ghcr.io/hadielmougy/wiggle:0.0.10  # …or GHCR

# the same, serving the portal → http://localhost:8070
docker run --rm -p 8080:8080 -p 8070:8070 \
  -e WIGGLE_DASHBOARD_PASSWORD=change-me hadielmougy/wiggle:0.0.10

# a complete stack: server with the portal + Postgres, login, durable volume, no TLS
docker compose -f docker-compose.full.yml up -d      # → http://localhost:8070 (admin / change-me)

# build locally / publish multi-arch
docker build -t wiggle .
scripts/docker-release.sh                             # buildx amd64+arm64, pushes to a registry

The image is the control plane (+ the optional portal) only; run workers as separate processes against :8080 (your app on wiggle-client, or ./gradlew :example:runWorker).

4.5 Handy scripts§

Script Purpose
scripts/build.sh javac over the whole monorepo, no Gradle
scripts/verify.sh run the conformance suite
scripts/demo.sh run the single-JVM demo
scripts/cluster.sh [count] 3 nodes + 2 workers on local Postgres
scripts/kind-up.sh [nodes] / kind-down.sh Postgres cluster on kind
scripts/run-workers.sh <workers> [submit] run N worker JVMs against WIGGLE_URL
scripts/bump-version.sh <new-version> rewrite the version everywhere (jars + Docker tag + docs) from the current one
scripts/docker-release.sh build & push the multi-arch server image

4.6 Gradle tasks§

Task Runs
:dist:run standalone server (com.wiggle.dist.Main, every backend bundled)
:dist:installDist server distribution (bundles every storage backend) — used by the Docker image
:example:run Demo (embedded end-to-end)
:example:runWorker WorkerMain
:example:submitOrders -Pcount=N SubmitOrders
:example:bench Benchmark (throughput micro-benchmark, §7.6)
:tests:run Scenarios (framework-free conformance)

5. Authoring workflows§

A definition compiles to pure topology — named nodes and their wiring. What reaches the server is a FlowSpec: the graph, and nothing else. There are two ways to write one, and they differ only in where the step names come from.

FlowSpec.define — when the steps can be declared as a contract. Declare them as an interface and name them through it, and the compiler checks that every step consumes what the one before it produced, while a rename carries the step name with it:

interface OrderSteps {
    Order   validate(Order o);
    boolean inStock(Order o);
    Order   authorise(Order o);
    Order   merge(Order payment, Order shipping);
        ...
}

FlowSpec orders = FlowSpec.define("order-fulfilment", 1, Order.class, OrderSteps.class, (f, s) -> {
    var validated = f.thenApply(s::validate).thenFilter(s::inStock);

    var payment  = validated.thenApply(s::authorise, RetryPolicy.exponential(5, ofMillis(100)))
                            .thenApply(s::capture);
    var shipping = validated.thenApply(s::reserve)
                            .thenSleep("await", ofMillis(300))
                            .thenApply(s::label);

    return Wiggle.allOf(payment, shipping)   // continuing `validated` twice is the fan-out
            .combine(s::merge)    // arms are isolated, so rejoining is always explicit
            .thenApply(s::notify);
});

s is an inert stand-in: the body only names steps through it, and calling a method on it throws. That is the point — a spec never runs a step. It records the step's name, and a worker supplies the code by matching that name. A reference to a concrete class would name code the spec will never call, and go quietly wrong when the worker binds some other object; naming an interface method cannot mislead that way. On the worker, a handler should implements OrderSteps, which makes the compiler check both halves against one contract — but it is not required, since binding is by name.

Nothing executes while the workflow is defined — the chain is walked once and recorded. There is no get() or join() on a handle, because there is nothing to wait for: the server drives the graph one node at a time. An ordinary for loop in the body therefore unrolls into nodes; anything that depends on a step's result uses Wiggle.oneOf or repeatWhile, which the engine evaluates at run time.

Steps are named by reference, always. A spec records a step's name and nothing else — it never holds a handler — so the interface it names them through is a declaration, not an implementation. That is what lets the same workflow be served by workers in Java, Go or Python: they each implement the steps in their own language and bind by name.

A topology written where its handlers are not — registered by an author with no handler classes on its classpath, or served by several independent workers that each bind a subset — declares the interface and stops there:

interface OrderSteps {
    Order   validate(Order o);
    boolean inStock(Order o);
    Order   authorise(Order o);
    Order   merge(Order payment, Order shipping);
        ...
}

// the author registers this without implementing a single step
FlowSpec orders = FlowSpec.define("order-fulfilment", 1, Order.class, OrderSteps.class,
                (f, s) -> { … });

The step logic is a separate class annotated @ForFlow("<workflow-name>"), bound on a worker by name. Each method whose name matches a step (case/style-insensitive, so inStock serves in-stock) is a handler; its signature defines the step — one parameter is the input (decoded from JSON), a boolean return is a gate, void is an effect, any other return is a task whose value becomes the next context (types may change from step to step, like Stream.map):

@ForFlow("order-fulfilment")
class OrderHandlers implements OrderSteps {
    public Order   validate(Order o)  { return o.withStatus("VALIDATED"); }
    public boolean inStock(Order o)   { return o.quantity() > 0; }        // gate
    public Order   authorise(Order o) { return o.withPaymentRef(auth(o)); }
    public Order   capture(Order o)   { return o.log("captured"); }
    public Order   reserve(Order o)   { return o.withShipmentRef(reserveRef(o)); }
    public Order   label(Order o)     { return o.withTrackingLabel(print(o)); }
    public Order   notify(Order o)    { return o.withStatus("FULFILLED"); }

    // found by type: the arms take the Order parameters in fork order; the pre-fork order is Step.base()
    public Order merge(Order payment, Order shipping) {
        return Step.base(Order.class).withPaymentRef(payment.paymentRef())
                   .withShipmentRef(shipping.shipmentRef());
    }
}

Publish it with client.register(orders), and bind the steps on a worker with new Worker(client, "w").registerHandler(new OrderHandlers()) — the worker fetches the graph and matches against it; it is never given the topology. A combine node (merge) must have an explicit handler, whose return is the COMPLETE post-join context. Its parameters are found by type, in any order: each takes the arm whose last step produces its type, and a parameter whose type no arm produces receives the pre-fork context. Arms of the same type go to the parameters of that type in fork order, matched from the last parameter back; an extra parameter of an arm's type is refused when the worker starts. In merge(Order payment, Order shipping) every arm is an Order, so the pre-fork order is read through Step.base(Order.class). A combine need not take every arm. There is no implicit fold: a combine served by no worker fails its task, and keys the handler does not return do not survive the join.

5.1 Operations§

Every operation is topology only — it names a node; the matching @ForFlow method supplies its logic.

Operation Meaning
thenApply(s::step) run the step's handler on a worker; its result becomes the new context
thenAccept(s::step) the handler runs for a side effect (a void method); context unchanged
thenFilter(s::guard) continue only while the guard returns true; false ends the instance as gated:<name>
Wiggle.oneOf(arms…) + when / otherwise switch/case: the first arm whose guard holds runs. Every arm opens with f.when(s::guard) or f.otherwise(); a single arm is legal and reads as "run this, or skip past it"
Wiggle.allOf(arms…).combine(s::merge) run arms in parallel on isolated context copies, then rejoin at the mandatory combine. The combine's parameters are found by type, in any order; one whose type no arm produces is the pre-fork context, else read it with Step.base()
thenForEach(Ctx::items, body).combine(s::collect) runtime fan-out: one isolated branch per element of the list (or map) at itemsKey. The element IS the item's context — body handlers take the item's value (scalars included) and their return replaces it; the frozen base is Step.base() (the position/source key at Step.itemIndex()/Step.itemMapKey()); a combine can also take it as a parameter that is not a collection. The mandatory combine receives the collected final values (List/Set for a list input, Map keyed like a map input) and returns the complete post-join context. thenForEach(name, Ctx::items, …) names the node explicitly. The accessor is a reference to the context's own component — it gives both the key and the element type, so no Class<E> is needed and a renamed component carries the key with it. Lists, maps and arrays all work. Use the string form thenForEach("items", Item.class, body) when the context is a Map<String, Object>, which has no accessor to reference
repeatWhile(s::guard, body) run body, then repeat while the guard holds (at least once). repeatWhile(guard, maxIterations, body) caps it; a trailing "queue" pins the condition
thenSleep(duration) / thenSleep(name, duration) server-side timer; holds no worker
thenAwait(name[, timeout[, escalation]]) wait for a named external signal; optional deadline escalates or fails
thenSubFlow(node, workflow, Result.class) run another workflow as a child; its result merges back, failure propagates
a trailing "queue" argument / defaultQueue(q) route one node (or every following step) to a dedicated worker pool
executeInServer() / executeInLocalSync() / executeInLocalAsync() set the execution mode (§6.4)
checkpoint() (LOCAL_ASYNC) flush this step to the server before the next runs

Retry and queue are trailing arguments on the call that creates the node, in either order, so they travel with the step they configure — there is no separate call to forget. A node you named with a handler takes both (thenApply, thenAccept, thenFilter, thenApplyCompensable, when, and the combine of an allOf or a thenForEach). A node a construct creates for you takes only the queue: repeatWhile's condition, whose retry stays the workflow default. Omitting a retry never leaves a node bare — it inherits the default given to FlowSpec.define. The context type is not fixed by the definition — each handler picks the type it works in by its signature (a typed record, or a Map<String, Object> for raw JSON), and a method may return a different type than it takes.

Evolving a record's schema. A handler decodes the persisted JSON into its parameter type reflectively, so adding, renaming, or retyping a field silently defaults/loses data — or fails to decode — for instances already in flight (the workflow version hashes only the graph topology, not the context type). Opt into custom decoding with a @Decode method in the handler class: it takes the raw JSON (Map<String, Object>) and returns the current type, running instead of the default mapping wherever a step or combine parameter of that type is bound. It's the seam for schema-version upcasts or a bespoke codec:

@ForFlow("order-fulfilment")
class OrderHandlers {
    @Decode
    public Order load(Map<String, Object> raw) {     // upcast an older shape to the current Order
        raw.putIfAbsent("currency", "USD");           // e.g. default a field added in a later version
        return (Order) RecordMapper.fromJson(raw, Order.class);
    }
        // ... step methods, which now receive the upcast Order ...
}

5.2 Running instances§

try (WiggleClient client = new WiggleClient("localhost:8080")) {
    String id = client.start(orders, Order.of("A-1001"));                  // same-JVM convenience: by FlowSpec
    InstanceView v = client.awaitCompletion(id, Duration.ofSeconds(30));   // COMPLETED | FAILED | CANCELLED
    client.cancel(id, "reason");
}

Integrating as a separate team — start by name, no jar. A submitting service does not need the FlowSpec or any shared artifact: the graph is data the server owns, so the submitter's whole contract is the workflow name plus the agreed context shape (document it like any API schema).

String id = client.start("order-fulfilment", Map.of("orderId", "A-1001", "quantity", 3L));

// version pinning: unpinned = latest registered; pin to be immune to mid-deploy changes.
String id2 = client.start("order-fulfilment", ctx, 302800684, "corr-42");

Registration belongs with whoever owns the definition — the author, via client.register(spec). A worker never publishes a topology: it binds handlers by name and fetches the graph to validate their signatures against on startup (WorkerOptions.withAwaitRegistration gives it a window to wait if it starts before the author). That split is why the same flow can be served by workers in Java, Go and Python without any of them redefining it. Content-hash versioning makes this safe for everyone else: re-registering an identical graph is a no-op, a changed graph is a NEW version that redirects nothing, in-flight instances stay pinned to the version they started on, and by-name submitters pick the new version up only for new starts — or never, if they pin.

Serving one version rather than all of them. By default a worker's handlers serve every version of the workflow they bind, which is almost always what you want: step names are stable across versions, so one implementation covers them all. Pass a version to narrow that:

new Worker(client, "service-a").registerHandler(new OrderHandlers(), v1.version());  // claims only v1
new Worker(client, "service-b").registerHandler(new OrderHandlers(), v2.version());  // claims only v2

A scoped worker filters its claim by (workflow, version), so it will not pick up another version's tasks. That is what makes it possible to move a capability between services: service A keeps serving v1 while service B takes v2, A drains its in-flight instances and retires — no shared deploy, no cutover. Without the scoping, A would keep claiming v2's tasks and running them with v1's code, because the activity a handler binds is workflow#step and carries no version, so the names match and nothing notices. Binding a version that was never registered fails at start(), where a deploy can fail, rather than as a decode error on the first task.

The cost of scoping is that a version nobody registers has no worker at all, and its tasks sit dispatchable forever — see §7.5.


6. Configuration reference§

6.1 How settings are supplied§

The server reads each setting from a system property first, then an environment variable, falling back to a default (ServerConfig.fromEnvironment()). So -Dwiggle.port=9090 and WIGGLE_PORT=9090 are equivalent. For the application distribution, pass JVM flags via the WIGGLE_OPTS (or JAVA_OPTS) environment variable that bin/wiggle honours.

The worker is configured programmatically via WorkerOptions (§6.5); the WIGGLE_* worker variables in §6.7 are conventions of the example WorkerMain, not the client library.

6.2 Server — core & storage§

Env var System property Default Meaning
WIGGLE_ROLE — server which process this image runs: only server. cell is the old name for it and still works; console and coordinator were removed and are refused, as is any unrecognised value
WIGGLE_PORT wiggle.port 8080 gRPC port (0 = pick a free one)
WIGGLE_NODE_NAME wiggle.node.name hostname name shown in cluster membership
WIGGLE_JDBC_URL wiggle.jdbc.url (unset) unset = in-memory, single node; set to cluster on a database
WIGGLE_JDBC_USER wiggle.jdbc.user (unset) database user
WIGGLE_JDBC_PASSWORD wiggle.jdbc.password (unset) database password
WIGGLE_JDBC_POOL_SIZE wiggle.jdbc.poolSize 10 JDBC connection pool size
WIGGLE_JDBC_TX_ATTEMPTS wiggle.jdbc.txAttempts 3 replays for a transaction the database could not serve momentarily
WIGGLE_PG_RANDOM_PAGE_COST wiggle.pg.randomPageCost 1.1 PostgreSQL random_page_cost set on each connection; server keeps the server's

6.3 Server — engine, cluster & housekeeping§

Env var System property Default Meaning
WIGGLE_LEASE_MILLIS wiggle.lease.millis 30000 default task lease before a stalled step is reclaimed
WIGGLE_RECORD_STEP_IO wiggle.stepIo.record true record each step's input and output on its token, for the portal
WIGGLE_STEP_IO_MAX_CHARS wiggle.stepIo.maxChars 65536 cap per recorded input/output; a longer one keeps its first 4096 characters and its length
WIGGLE_LONGPOLL_MAX_MILLIS wiggle.longpoll.maxMillis 20000 server-side cap on how long a PollTasks may block
WIGGLE_POLL_INTERVAL_MILLIS wiggle.poll.intervalMillis 1000 housekeeping tick cadence (timers, lease reclaim, deadlines)
WIGGLE_HEARTBEAT_INTERVAL_MILLIS wiggle.heartbeat.intervalMillis 5000 cluster heartbeat/election interval
WIGGLE_MISSED_HEARTBEATS wiggle.heartbeat.missedBeforeDead 3 missed beats before a node is considered dead
WIGGLE_RETENTION_MILLIS wiggle.retention.millis 86400000 how long finished instances are kept before purge
WIGGLE_EVENTS_RETENTION_MILLIS wiggle.events.retentionMillis 604800000 how long an acknowledged event-log entry is kept
WIGGLE_EVENTS_VISIBILITY_MILLIS wiggle.events.visibilityMillis 50 how long an appended event is held back from the feed, covering appends still in flight
WIGGLE_HOUSEKEEPING_BATCH wiggle.housekeeping.batch 100 max items a housekeeping sweep processes per tick
WIGGLE_ADAPTIVE_HOUSEKEEPING wiggle.adaptive.housekeeping true a sweep that fills its batch runs again immediately (drain mode) — removes the batch÷tick promotion ceiling under backlog (measured: 100 → ~1,700 timers/sec at defaults); idle cost unchanged. false restores one batch per tick
WIGGLE_SWEEP_PARALLELISM wiggle.sweep.parallelism 4 how many of a housekeeping sweep's due items (timers, retries, expired leases, signal deadlines, schedules) run at once, each in its own transaction; each holds a pooled connection while it runs. 1 runs them one at a time
WIGGLE_RETRY_TIMER_MIN_MILLIS wiggle.retry.timerMinMillis 1000 a retry backing off at least this long waits off the dispatch queue (WAITING) and is made dispatchable by the housekeeper once due, so it can land up to one housekeeping tick late; a shorter backoff waits READY. Keep it at or above WIGGLE_POLL_INTERVAL_MILLIS
WIGGLE_ADAPTIVE_FALLBACK_POLL wiggle.adaptive.fallback false freshly-parked long-polls re-claim quickly (fallback÷4) and decay to the configured interval — cuts cross-node dispatch latency in a multi-node cluster (measured: p50 105 → 30 ms); idle DB cost bounded
WIGGLE_LOOP_MAX_ITERATIONS wiggle.loop.max.iterations 10000 default repeatWhile budget — a loop guard may evaluate true at most this many times before the instance FAILS with a clear error; per-loop override via repeatWhile(guard, maxIterations, body)
WIGGLE_QUEUE_LAG_CHECK_INTERVAL_MILLIS wiggle.queueLag.checkIntervalMillis 5000 how often the leader checks the backlog (§7.6)
WIGGLE_QUEUE_LAG_WARN_MILLIS wiggle.queueLag.warnThresholdMillis 10000 WARN once the backlog isn't draining within this budget

6.4 Execution modes§

Set per workflow: f.executeInLocalSync() (or executeInServer() / executeInLocalAsync()) in the define body; a spec that names none defers to the server's default. The mode is part of the definition's fingerprint, so an in-flight instance keeps the mode it started on.

Mode Behaviour Crash blast radius Use for
SERVER (default) server advances one node per claim one step anything non-idempotent
LOCAL_SYNC worker chains steps, commits each before the next one step (same as SERVER) most workflows — safe speedup
LOCAL_ASYNC worker buffers up to localBatchSize steps, flushes in one call at handback whole batch re-runs idempotent, throughput-critical
DEFAULT defer to the server default (currently resolves to SERVER) — leave the choice to deployment

.checkpoint() after a step forces LOCAL_ASYNC to commit it before continuing. Details and benchmark numbers: docs/local-execution.md.

6.5 Worker — WorkerOptions (programmatic)§

new Worker(client, "worker-1", WorkerOptions.defaults()
        .withConcurrency(16)
        .withLease(Duration.ofSeconds(30))
        .withLongPollWait(Duration.ofSeconds(10))
        .withLocalBatchSize(64));
Field Default Meaning
concurrency CPU count max steps in flight at once
lease 30s lease requested per task (renewed by heartbeats)
longPollWait 10s how long the worker lets a poll block server-side
idleBackoff 200ms pause when a poll returns nothing
errorBackoff 2s pause after a poll error
localBatchSize 64 LOCAL_ASYNC steps buffered before a flush (ignored by SERVER/LOCAL_SYNC)

RPC retry (client + worker). Every WiggleClient call — and therefore every worker RPC (poll, complete, fail, heartbeat) — retries on UNAVAILABLE, so an operation issued while a node is momentarily gone (a restart, or an active/passive failover taking over the address) rides out the outage instead of failing. Only UNAVAILABLE is retried (the RPC almost certainly never ran, so it's safe even for non-idempotent calls); permanent errors and DEADLINE_EXCEEDED are not. Tune per JVM: -Dwiggle.rpc.maxAttempts / WIGGLE_RPC_MAX_ATTEMPTS (default 5; 1 disables retry) and -Dwiggle.rpc.retryDelayMillis / WIGGLE_RPC_RETRY_DELAY_MILLIS (default 200, exponential backoff). For exactly-once starts across a retried failover, pass a correlationId to start.

6.6 Logging§

Wiggle logs through the JDK's System.Logger (routes to java.util.logging), so there's no logging dependency. See docs/ / deploy/logging.properties and:

Env var Default Meaning
WIGGLE_LOG_FILE (unset) set a path to also log to a rotating file (5 × 10 MB)
WIGGLE_LOG_LEVEL INFO file level in System.Logger names: INFO, DEBUG, WARNING, ERROR
(JVM flag) -Djava.util.logging.config.file=… — full control via a logging.properties; overrides the env shortcut

Level mapping to java.util.logging: DEBUG→FINE, TRACE→FINER, INFO→INFO, WARNING→WARNING, ERROR→SEVERE. INFO is a clean lifecycle narrative (start/stop, membership, leader, registrations, failures, purges, lag warnings); DEBUG adds per-token/step/RPC detail.

6.7 Example worker & benchmark variables§

Conventions of the example module's WorkerMain / Benchmark (not the library):

Env var Default Read by Meaning
WIGGLE_URL localhost:8080 WorkerMain, SubmitOrders server gRPC target (a leading http(s):// is stripped)
WIGGLE_WORKER_ID worker-<pid> WorkerMain worker identity
WIGGLE_WORKER_CONCURRENCY 8 WorkerMain worker concurrency
WIGGLE_LOCAL_BATCH_SIZE 64 WorkerMain, Benchmark LOCAL_ASYNC batch size
WIGGLE_EXECUTION_MODE SERVER Benchmark execution mode for the benchmark workflow
WIGGLE_BENCH_STEPS 20 Benchmark steps in the linear pipeline
WIGGLE_BENCH_COUNT 2000 Benchmark instances to run
WIGGLE_BENCH_WORKERS 4 Benchmark worker JVM-internal instances
WIGGLE_JDBC_URL / _USER / _PASSWORD (unset) Benchmark run the benchmark against a real DB

The example workflows set their mode in code via .executeIn...(). To sweep modes without editing, change OrderFulfilment.flow spec() to call the provided mode() helper (reads WIGGLE_EXECUTION_MODE); the benchmark already reads it.


7. Operations§

7.1 The portal (web UI)§

The web UI is the portal, served by the server process itself on WIGGLE_PORTAL_PORT (default 8070; 0 turns it off), separate from the gRPC port (the console module, embedded Tomcat + servlets). It reads and acts through the engine in process, so any node with the portal on serves the whole cluster, and nothing it does goes over gRPC. A node's WIGGLE_DASHBOARD_PORT (default 0 = off) still serves only the /healthz probe for liveness/readiness checks.

Several server nodes on one host each need their own WIGGLE_PORTAL_PORT (or 0). To keep heavy portal use off the claim path, run a node or two with the portal on and route no workers to them; they are ordinary server nodes.

# something to look at: a seeded server on :8080 with the portal on :8070 (a completed run,
# two runs parked on a signal, two schedules)
./gradlew :example:seedDashboard                         # → http://localhost:8070

# or on any server node, e.g. the Docker image
docker run -p 8080:8080 -p 8070:8070 hadielmougy/wiggle          # first visit sets the admin password

Accounts and sessions live on the auth shard, so any portal node serves any signed-in request; a load balancer in front of several needs no sticky sessions.

The SPA (ClojureScript + Reagent, source in dashboard-ui/, compiled into the console jar) has seven tabs: Instances (filter, search by instance id or correlation id, each instance's steps as a table — click one to expand its input, output, retries and timing — cancel, inline signal delivery), Workflows (each compiled graph's steps, kinds, queues and retry policies), Schedules (create/delete interval and cron schedules), Signals, Backlog (dispatchable work no running worker can claim — §7.5), Performance (per-step p50/p95 by the handler's own clock and queue wait for every execution mode, slowest first), and Users (§7.1a, for accounts holding user.manage). ./gradlew :console:build compiles the bundle automatically (needs Node; -PskipDashboard or a missing Node toolchain skips it). Dev loop: cd dashboard-ui && npx shadow-cljs watch app (hot reload on :8280, proxying /api to a portal on :8070).

The portal's instance detail: an onboarding run as a table of its steps, the first expanded to its input, output, retries and timing, with an inline signal form.

Auth. A role is a named set of permissions. Two are built in: admin (*, everything) and viewer (read, sees everything and is refused every write). The actions are read, instance.cancel, instance.signal, schedule.write and user.manage (plus instance.start and task.poll, reserved for gRPC authorization); the instance and schedule ones take a workflow scope, so instance.cancel:orders cancels only orders instances. Browsers get a /login form that sets an HttpOnly session cookie; programmatic clients can use HTTP Basic auth. Credentials travel cleartext over plain HTTP, so serve over TLS for anything exposed.

Accounts come from two places. Built-in accounts are configured in the environment of the nodes serving the portal: WIGGLE_DASHBOARD_PASSWORD for the admin (WIGGLE_DASHBOARD_USER, default admin) and optionally WIGGLE_DASHBOARD_VIEWER_PASSWORD for a viewer (WIGGLE_DASHBOARD_VIEWER_USER, default viewer). Nothing in the running portal can change them. Managed accounts are the ones an admin creates in the portal itself (§7.1a).

With no WIGGLE_DASHBOARD_PASSWORD and no account in the database yet, the portal starts in first-run setup: every page leads to a screen that sets the password of the admin account (WIGGLE_DASHBOARD_USER, default admin), stores it hashed in the database (the auth shard), and signs you in. After that the setup screen is gone for good, on every node, and the admin signs in like any other account. Until it is done, anyone who can reach the portal port can do it, so set it straight after the first start, or set WIGGLE_DASHBOARD_PASSWORD to skip setup. On the in-memory store the password goes with the process.

7.1a Users an admin manages§

An account holding user.manage gets a Users tab: create an account with a name, a password and a role, change its roles, set its password, disable or enable it, or delete it; define roles from permissions; and read the audit of every change to accounts, roles and sessions. Everyone who signs in with a managed account can change their own password from the header, proving their current one first. A viewer may do that too — it is the one write a viewer is allowed, since it changes nothing but their own account.

Accounts, roles, sessions and the audit live on the auth shard: the database the storage topology gives the auth role, or the one database of a single-database deployment. Every node caches what it has looked up for at most WIGGLE_AUTH_CACHE_MILLIS (30 s) and polls the audit every second, so a password change, a role change, a disable or a deletion takes effect on every node within about a second. With the auth shard down, people already signed in keep working, nobody new signs in (503), and the built-in admin still can. No gRPC RPC reads or writes accounts.

A console users file from before (WIGGLE_CONSOLE_USERS_FILE, default wiggle-users.json) is imported once by the first node to start with it, hashes as they are; after that it is no longer read, and every node says so at startup.

Passwords are stored as PBKDF2-HMAC-SHA256 hashes over a per-account random salt, and sessions only as a hash of their token: nothing in the database signs anyone in.

Three rules keep a portal reachable:

  • A managed account cannot take a built-in account's name, and built-in accounts cannot be deleted or re-passworded from the portal — they are set in the environment.
  • With no built-in admin to fall back on, no change may leave no enabled account that can manage users — not deleting, disabling or demoting the last one, not changing the role it relies on, and not creating a first account that could not manage users.
  • Creating the first managed account turns authentication on, even if no password was configured. Sign in with the account you just made.

Changing or resetting a password signs out that account's other sessions; the one doing the changing stays signed in. Deleting an account signs it out everywhere.

Env var Default Meaning
WIGGLE_PORTAL_PORT 8070 portal HTTP port; 0 turns it off (and give each node on one host its own)
WIGGLE_DASHBOARD_USER / WIGGLE_DASHBOARD_PASSWORD admin / (unset) admin login from the environment; unset = the first visit sets the admin password, kept in the database
WIGGLE_DASHBOARD_VIEWER_USER / WIGGLE_DASHBOARD_VIEWER_PASSWORD viewer / (unset) optional read-only account
WIGGLE_AUTH_CACHE_MILLIS 30000 how long a node serves a cached account or session before reading it again
WIGGLE_CONSOLE_USERS_FILE wiggle-users.json an old console users file, imported once (§7.1a)
WIGGLE_TLS_* (unset) the server's keystore serves the portal over HTTPS too; with a truststore, the portal requires client certificates as gRPC does

7.1b Authorizing gRPC calls§

Off by default: any peer that can reach the gRPC port may call any RPC. To decide per call:

  1. In the portal's Users tab, define a role for each kind of caller, then create an API credential holding it: an API key (shown once, starting wgk_) or a client certificate subject such as CN=orders-worker,O=Example for an mTLS deployment.
  2. Give each worker and service its key as WIGGLE_API_KEY (or -Dwiggle.api.key; the Go and Python clients send it as authorization: Bearer <key> metadata). Over plaintext the key is readable on the wire, so use TLS.
  3. Start the servers with WIGGLE_GRPC_AUTH=log and watch for gRPC auth (log mode) warnings: each is a call enforce would refuse. When there are none, switch to WIGGLE_GRPC_AUTH=enforce.

Typical roles:

Caller Permissions
a worker for orders task.poll (or task.poll:<queue> per queue it serves) and read:orders (it reads the graph)
a service starting orders instance.start:orders, read:orders, plus workflow.register:orders if it publishes the graph
an event consumer event.read
operations tooling read and whatever it changes

Every RPC's permission is listed in chapter 70 §11 of the spec. A call without a known credential fails UNAUTHENTICATED (401 in the Java client), one its role does not allow PERMISSION_DENIED (403). HealthCheck stays open for probes. Deleting a credential, or changing its role, takes effect on every node within about a second, for the next call: a worker's open long poll ends as it began, at most WIGGLE_LONGPOLL_MAX_MILLIS later.

Env var Default Meaning
WIGGLE_GRPC_AUTH off off, log (check and log, serve anyway) or enforce
WIGGLE_API_KEY (unset) on a client or worker: the key it presents

7.1a Transport security (TLS / mTLS)§

Opt-in and shared by the gRPC API and the portal's HTTP. A keystore turns TLS on for both; a truststore additionally requires client certificates (mTLS on the server) and presents a client certificate (on a worker/client). Unset ⇒ plaintext for both. Stores are PKCS12 by default; a .jks path is loaded as JKS. Clients/workers read the same variables. TLS secures the channel and (with mTLS) authenticates the peer, but it is not authorization: what a peer may call is decided by per-RPC authorization (§7.1b), off unless you turn it on.

Env var System property Default Meaning
WIGGLE_TLS_KEYSTORE wiggle.tls.keystore (unset) keystore path; unset = plaintext
WIGGLE_TLS_KEYSTORE_PASSWORD wiggle.tls.keystore.password (unset) keystore password
WIGGLE_TLS_TRUSTSTORE wiggle.tls.truststore (unset) truststore path; server ⇒ require client certs (mTLS)
WIGGLE_TLS_TRUSTSTORE_PASSWORD wiggle.tls.truststore.password (unset) truststore password

Find instances by what they say: the words in their context, correlation id, termination reason or error. Off by default. Turn it on with a search shard in the storage topology (its own database, so indexing never costs the instance shards write capacity), or WIGGLE_SEARCH_ENABLED=true on a single database (the server warns that indexing then shares its capacity).

The leader indexes from the event log, a second or so behind: an instance is indexed when it starts and each time its status changes, as it is at that moment. Every word you search for must occur; words are matched as written (no stemming), and results come best first. A document outlives its instance (WIGGLE_SEARCH_RETENTION_MILLIS, 30 days), so a hit can be shown purged.

WiggleClient.SearchResult r = client.search("ada lovelace", "order-fulfilment", null, null, null, 20, false);
for (WiggleClient.SearchHit hit : r.hits()) {
    System.out.println(hit.instanceId() + " " + hit.status() + (hit.purged() ? " (purged)" : ""));
}

By meaning. With an embedder, a search can rank by closeness in meaning rather than by the words: client.search(text, …, semantic = true) (the overload's last argument), or by meaning in the portal. Set WIGGLE_EMBEDDER=http with WIGGLE_EMBEDDER_URL (an OpenAI-compatible API root such as https://api.openai.com/v1 or Ollama's http://localhost:11434/v1), _MODEL, _DIMENSION and _API_KEY; WIGGLE_EMBEDDER=hashing tries the path with no model at all. Install pgvector on the search database for an HNSW index; without it vectors are compared in Java, exactly and by scan, which suits thousands of documents rather than millions. Semantic search starts once every existing document has a vector. Changing the model builds a new index beside the old one, and queries switch when it is complete, as long as the old model stays configured as WIGGLE_EMBEDDER_PREVIOUS_MODEL until then.

In the portal, pick full text next to the Instances search box. Searches see only the workflows the caller may read. With several search shards, documents spread by instance id; add or drain one and the leader moves documents in the background, a batch a minute.

Env var Default Meaning
WIGGLE_SEARCH_ENABLED false search on the one database
WIGGLE_SEARCH_RETENTION_MILLIS 30 days how long a document outlives its instance's last change
WIGGLE_SEARCH_WORKFLOWS (all) comma-separated workflows to index
WIGGLE_EMBEDDER none none, hashing or http
WIGGLE_EMBEDDER_URL / _MODEL / _DIMENSION / _API_KEY (unset) the embedding API, model, vector size and key
WIGGLE_EMBEDDER_PREVIOUS_MODEL / _DIMENSION (unset) the model being replaced, while the new index builds

7.2 Storage backends§

No URL → in-memory (single node, dev/test). With one, the server builds its store from an injected StorageFactory — no ServiceLoader: PostgresStorageFactory maps the URL scheme to a backend at runtime (the distribution's WiggleStorageFactory is that class under the image's own name). Both dialects live in wiggle-postgres over one HikariCP-pooled store (wiggle-jdbc): PostgreSQL is what you deploy on, and H2 (in PostgreSQL mode) is for tests and local runs — it takes the same schema but has no SKIP LOCKED, so it claims tasks by compare-and-set rather than in a single statement, and is not a deployment target. So the image serves jdbc:postgresql: and jdbc:h2:.

Embedding the server in your own JVM? new WiggleServer(config, new PostgresStorageFactory()) — it ships in wiggle-postgres, which brings wiggle-jdbc and wiggle-server with it, so that one dependency is the whole requirement. StorageFactory is a functional interface, so an application that wants a different mapping (a dialect of its own, a wrapper that instruments the store) passes its own lambda instead.

Leader election inside a cluster. Several nodes over one database elect a single leader — the reconcile and housekeeping loops run only there — using an announce-and-heartbeat scheme (the election module): every process announces itself and heartbeats, the longest-running live process leads with ties broken by id, and a process whose own heartbeat has gone stale stands down before doing leader work. No consensus protocol, because there is nothing to agree on — the shared table is the only source of truth and the rule over it is a pure function every process evaluates identically. What makes that safe is that the leader's duties are idempotent and re-entrant: a brief overlap during failover duplicates work but cannot corrupt state, and leader-guarded writes are compare-and-set, so a stale ex-leader's write matches zero rows and loses.

7.3 Signals, sub-workflows and schedules§

thenAwait(name) parks an instance until the named signal arrives; no worker is held. Deliver via client.signal(instanceId, name, payload) (gRPC), the portal's Signals tab, or POST /api/instances/{id}/signal/{name} (JSON body merges into the context). Optional deadline: thenAwait(name, timeout) fails the instance on timeout; the three-arg form runs an escalation branch instead. Signals are not buffered -- an early delivery is a retryable conflict.

thenSubFlow(node, workflow, Result.class) runs a registered workflow as a child with the parent's context; its final context merges back, its failure/cancellation fails the parent, and cancelling the parent cascades to children.

Schedules fire a workflow on a fixed interval or a five-field cron expression (UTC), leader-driven and exactly-once per fire. From the client: client.createSchedule(workflow, Duration, context) / createCronSchedule(workflow, "0 3 * * *", context) / schedules() / deleteSchedule(id) (gRPC). Over HTTP: POST /api/schedules {"workflow", "everyMillis"|"cron", "context"?}, GET /api/schedules, DELETE /api/schedules/{id}. Creation is an upsert keyed on workflow name -- a workflow has at most one schedule, so calling create again from any number of client instances updates it in place rather than creating duplicates.

7.4 Schema migrations§

JdbcStorage runs a versioned, forward-only migration list on startup, tracked in wf_schema_version, under a cross-node advisory lock, atomic on Postgres. To evolve the schema, append a Migration(n, "name", sql) — never edit a released one; keep changes backward-compatible for rolling deploys. Tables: wf_definition, wf_graph_node, wf_graph_edge, wf_instance, wf_token, wf_node, wf_schema_version.

Each applied migration records a SHA-256 checksum of its source in wf_schema_version; if an already-applied migration's definition later differs from the code (someone edited a released migration), startup fails loudly with a drift error rather than silently diverging.

For deployments where a DBA or CI pipeline — not the application — owns DDL:

  • WIGGLE_MIGRATE_ONLY=true — a one-shot job that applies pending migrations and exits (run it as an init container or a CI step; it forces apply even if the app env pins verify).
  • WIGGLE_SCHEMA_MODE=verify — the app applies nothing; it checks the schema is current and un-drifted and fails fast if it's behind (telling you to run the migrate job first). Run the migrate-only job, then run the app in verify mode with only SELECT/INSERT/… grants.

7.5 Backlog coverage: work nothing can claim§

A token whose queue nobody polls — or whose version every worker has scoped itself out of (§5) — sits READY forever. It is not failed, not retried, not late in any way the engine can see: the instance reads RUNNING and the token reads READY, which is exactly what a healthy system looks like a moment before a worker picks the work up. No other view can tell you about it, which is why there is one for it.

Each node remembers which queues and (workflow, version) pairs its current pollers serve, and the Backlog tab lists the dispatchable backlog grouped by (workflow, version, queue), each slice flagged covered or not — uncovered first, with how many tasks are stranded on it and how long the oldest has waited. A slice is covered when some live worker polls that queue and either is unscoped or named that version, which is the same test the claim applies, so it reports what the dispatcher would actually do.

Also on the wire as GetBacklogCoverage, and over HTTP at /api/backlog:

{"slices": [{"workflow": "orders", "version": 302800684, "queue": "gpu",
             "readyCount": 41, "oldestAvailableAt": 1757800000000, "covered": false}],
 "uncoveredSlices": 1, "strandedTasks": 41, "livePollers": 3}

The registry behind it is in memory and deliberately not durable — it is written on the poll path and must cost a map write — so it knows only its own node's pollers, and coverage is therefore reported per node. A worker that stops polling stops counting as cover once its entry times out.

7.6 Queue-lag monitoring§

The leader watches whether the dispatchable backlog is draining fast enough (backlog vs cluster-wide completion rate) and logs a WARNING when it isn't — a sign of too few workers, a stuck worker pool, or a slow step. Tune with the two WIGGLE_QUEUE_LAG_* knobs (§6.3).

7.7 Benchmarking§

# in-memory (async ≈ sync, commits are free):
WIGGLE_EXECUTION_MODE=LOCAL_ASYNC ./gradlew :example:bench

# against Postgres (async wins — far fewer WAL fsyncs):
docker compose up -d postgres
WIGGLE_EXECUTION_MODE=LOCAL_ASYNC WIGGLE_JDBC_URL=jdbc:postgresql://localhost:5433/wiggle \
  WIGGLE_JDBC_USER=wiggle WIGGLE_JDBC_PASSWORD=wiggle ./gradlew :example:bench

8. Where to go deeper§

  • README.md — overview, quick start, DSL walkthrough.
  • docs/local-execution.md — execution modes, the wire protocol, the shared traversal seam, and the crash-replay contract per mode.
  • RELEASING.md — publishing to Maven Central.
  • proto/src/main/proto/wiggle.proto — the control-plane wire contract.