Running Composer — The Full Stack, a First Workflow and the Test Suites
Building a Distributed Workflow Engine·Part 11 of 11·6 min read

Running Composer — The Full Stack, a First Workflow and the Test Suites

On this page · 7 sections
  1. Step 1: Start the stack
  2. Step 2: Log in
  3. Step 3: Register, deploy and run a two-step workflow
  4. Step 4: Try signals, retries and cancels
  5. Step 5: Break things on purpose
  6. Step 6: Run the test suites
  7. What you built

Everything is built. Now run it and push a real workflow through every layer you wrote.

Step 1: Start the stack

You need Docker with Compose:

make docker-up    # PostgreSQL, Redis, coordinator, 2 workers, language servers, web UI, observability
Service Address
Web UI http://localhost:3000 (sign in as admin / admin123, change it after first login)
Coordinator HTTP API http://localhost:8080
Coordinator gRPC (workers) localhost:50051
Jaeger (traces) http://localhost:16686
Grafana (dashboards) http://localhost:3001
Prometheus http://localhost:9090
PostgreSQL / Redis localhost:5432 / localhost:6379

make docker-logs follows the logs; make docker-down stops everything.

Or run the pieces yourself

psql "$DATABASE_URL" -f migrations/V001__init.sql      # schema, partitions, seed data

./bin/coordinator --store.url "$DATABASE_URL" --redis.addrs localhost:6379

(cd cmd/language-server-py && PORT=50053 uv run python main.py)
(cd cmd/language-server-js && PORT=50052 npm start)

LANG_WORKER_ADDRS=python:localhost:50053,js:localhost:50052 ./bin/worker

(cd web && npm run dev)                                 # http://localhost:3000

Coordinator flags (or COMPOSER_-prefixed environment variables, e.g. COMPOSER_STORE_URL):

Flag Default Meaning
--store.url postgres://postgres:password@localhost:5432/composer PostgreSQL URL
--redis.addrs localhost:6379 Redis address
--api.http_port / --api.grpc_port 8080 / 50051 API ports
--stream_shards 4 streams per task queue (published to Redis for workers)
--retention_days 0 drop run history for months that ended more than N days ago (0 keeps everything)
--outbox.poll_interval 1s outbox fallback poll
--outbox.batch_size / --outbox.concurrency 50 / 2 outbox publishing

Step 2: Log in

TOKEN=$(curl -s localhost:8080/api/v1/auth/login \
  -d '{"username":"admin","password":"admin123"}' | jq -r .token)

Every other endpoint needs Authorization: Bearer $TOKEN.

Step 3: Register, deploy and run a two-step workflow

Register version 1 of a workflow that fetches JSON over HTTP and then upper-cases a field in Python. The spec is YAML inside the JSON body:

curl -s localhost:8080/api/v1/workflows -H "Authorization: Bearer $TOKEN" -d @- <<'JSON'
{
  "workflow_id": "hello",
  "name": "Hello",
  "version": 1,
  "spec": "nodes:\n  - {node_id: fetch, name: Fetch, type: HTTP, spec: {url: 'https://httpbin.org/json', method: GET}}\n  - node_id: shout\n    name: Shout\n    type: CODE\n    runtime: python\n    spec:\n      code: |\n        def handler(input):\n            return {'title': input['title'].upper()}\n      title: '{{tasks.fetch.output.slideshow.title}}'\nedges:\n  - {from: fetch, to: shout}\n"
}
JSON

Deploy it and start a run:

curl -s -X POST "localhost:8080/api/v1/workflows/hello/deploy?version=1" -H "Authorization: Bearer $TOKEN"
RUN=$(curl -s localhost:8080/api/v1/workflows/hello/execute -H "Authorization: Bearer $TOKEN" \
  -d '{"input":{}}' | jq -r .run_id)

Inspect it:

curl -s localhost:8080/api/v1/executions/$RUN -H "Authorization: Bearer $TOKEN" | jq

What just happened, layer by layer

The hello run layer by layer: REST call, state machine, storage, outbox, worker, template resolution, the Python step and finishing the run POST /execute → StartWorkflow1coordinatorRunStarted → CreateTask(fetch) + DispatchTask2state machineINSERT task + outbox · COMMIT3storageNOTIFY → enqueuer XADD → http shard4outbox & streamsworker: lease → HTTP GET → report → ack5workers & leasesNodeCompleted(fetch) → resolve {{tasks.fetch.output…}}6workflow modelCreateTask(shout) → python shard → language server7workers & leasesNodeCompleted(shout) → FinishRun(SUCCEEDED)8state machine
Which page built each step of the run

Look at the run's tasks in the database to see your schema at work:

psql "$DATABASE_URL" -c "SELECT node_id, state, attempt, lease_owner FROM task_instances WHERE run_id = '$RUN'"

Step 4: Try signals, retries and cancels

With the order workflow from the model page (WAIT_FOR_EVENT on approve-{{input.order_id}}), start a large order, then approve it:

curl -s localhost:8080/api/v1/executions/$RUN/events -H "Authorization: Bearer $TOKEN" \
  -d '{"event": "approve-42", "payload": {"approved_by": "manager"}}'
# → {"delivered": true}   a waiting node consumed it
# → {"delivered": false}  buffered: the run has not reached the wait yet

Restart a finished run from a node, or cancel a running one:

curl -s localhost:8080/api/v1/executions/$RUN/retry  -H "Authorization: Bearer $TOKEN" -d '{"node_id": "ship"}'
curl -s -X POST localhost:8080/api/v1/executions/$RUN/cancel -H "Authorization: Bearer $TOKEN"

The full REST surface:

Method Path Purpose
POST /auth/login Exchange username/password for a JWT
POST /workflows Register a definition version (spec is YAML)
GET /workflows, /workflows/{id} List / get definitions
POST /workflows/{id}/deploy?version=N, /workflows/{id}/rollback Choose the deployed version
POST /workflows/{id}/execute Start a run: {"input": {…}, "execution_options": {…}} → {"run_id"}
GET /executions, /executions/{id} List runs / run details with every task
POST /executions/{id}/events Signal a run → {"delivered": bool}
POST /executions/{id}/retry Restart a finished run from a node
POST /executions/{id}/cancel Cancel a run

Errors map straight from the state machine's error values: 400 invalid request, 404 not found, 409 conflict (retrying a run with tasks in flight, signalling a finished run).

Step 5: Break things on purpose

The best way to trust the engine is to watch it heal:

cd infra/docker

# Kill one of the two worker replicas in the middle of a slow step.
docker kill $(docker compose ps -q worker | head -1)
#   → within ~90 s the lease expires; the other worker reclaims and finishes the task.

# Restart Redis while runs are in flight.
docker compose restart redis
#   → publishes fail and back off; dispatch resumes when Redis is back.

# Restart the coordinator.
docker compose restart coordinator
#   → in-flight reports fail and are retried by workers; nothing is half-applied.

Watch the recovery in Grafana (outbox backlog, stream depth) and follow one run end to end in Jaeger.

Step 6: Run the test suites

go test -short ./...                         # unit tests only (no Docker needed)
make test                                    # everything, incl. container-backed tests (needs Docker)
go test ./tests/ -run TestEngine -v          # engine scenarios end to end

(cd cmd/language-server-py && uv run python -m unittest test_sandbox -v)   # Python sandbox
(cd cmd/language-server-js && npm test)                                    # JS sandbox (Node 22)
Suite What it covers
pkg/statemachine every engine rule, event by event, against an in-memory snapshot
pkg/model, pkg/template, pkg/stream DAG index, template references, CEL resolution, queue sharding
pkg/coord, pkg/api/..., pkg/outbox, pkg/auth query building, run ID validation, error mapping, request validation, outbox backoff, JWT
worker the worker pipeline against an in-memory stream and a fake coordinator
tests/ full system: fork/join, switches, retries, waits, signals, queues, lease recovery, NOTIFY latency, partition retention
sandbox suites known escape techniques are blocked; ordinary code keeps working

What you built

Layer What it does
pkg/model + pkg/template a workflow is an indexed DAG + CEL
pkg/statemachine pure rules: events in, actions out
migrations + repository partitioned, fenced, minimal indexes
pkg/coord lock → decide → persist → commit
pkg/outbox + pkg/stream commit-time NOTIFY, SKIP LOCKED, shards
pkg/lease + worker/ one executor per task, renew or abandon
recovery loops expired leases, lost messages, timers

A handful of ideas carry the whole design: keep the decision logic pure, commit decisions and their effects together, serialize per run with a row lock, fence every result with an attempt number, and make every background job safe to run everywhere at once. With those in place, adding a new node type is a few lines in the state machine and a new executor on the worker.