This directory is a self-contained walkthrough of everything protoc-gen-go-tmprl
generates, built around a small e-commerce style Orders service:
example/
proto/example/v1/example.proto the annotated service definition
orders/service.go implements every workflow and activity
worker/main.go wires the service to a Temporal server
client/main.go a narrated, step by step tour
e2e/e2e_test.go build-tagged tests against a real server
The generated code lives in gen/example/v1/example_tmprl.pb.go,
with markdown docs in gen/example/v1/example_tmprl_doc.md.
ProcessOrder drives an order through its lifecycle, and every plugin feature
has a natural place in it:
ChargePayment PackItems ShipOrder (child workflow)
PENDING -----------------> PAID -----------------> PACKED -----------------> SHIPPED
activity with activity with | runs the DispatchCourier
retry policy + heartbeats | activity (custom name)
non-retryable |
"CardDeclined" pickup window:
cancellable (signal)
re-routable (update)
| Feature | Proto annotation | Where to look |
|---|---|---|
| Workflow | (temporal.v1.workflow) on ProcessOrder |
orders: ProcessOrder |
| Child workflow | plain workflow, executed via ExecuteChildShipOrderSync |
orders: ShipOrder |
| Activity retry policy | retry_policy on ChargePayment |
orders: ChargePayment, the tok-flaky card |
| Non-retryable errors | non_retryable_error_types: ["CardDeclined"] |
orders: ChargePayment, the tok-declined card |
| Activity heartbeats | heartbeat_timeout on PackItems |
orders: PackItems |
| Custom registered name | name: "courier.Dispatch" on DispatchCourier |
proto + Temporal UI |
| Signal | (temporal.v1.signal) on CancelOrder |
client step 3 |
| Query | (temporal.v1.query) on GetOrderStatus |
client step 1 |
| Update (+ validator) | (temporal.v1.update) on ChangeShippingAddress |
client steps 1 and 2 |
| Schedules | generated for every workflow, no annotation | client step 6 |
| Blocking updates + continue-as-new | (temporal.v1.update) on Reserve, entity workflow TrackInventory |
client step 7, e2e |
You need docker (for the Temporal dev stack) and Go.
# 1. bring up Temporal + its UI. Every image version has a sane default baked
# into the compose file; .envrc pins them explicitly if you use direnv
docker compose up -d --wait
# 2. start the worker, and keep this terminal visible: payment retries and
# packing heartbeats are logged here
go run ./example/worker
# 3. in another terminal, run the walkthrough
go run ./example/clientThe stack follows the temporalio/samples-server
compose layout: a plain temporalio/server container plus one-shot
admin-tools jobs that create the databases and the default namespace
(scripts/setup-postgres.sh, scripts/create-namespace.sh). --wait blocks
until the server is healthy and the namespace exists.
If the default host ports are taken on your machine (another temporal stack, for example), override them and point the binaries at the right address:
TEMPORAL_HOST_PORT=27233 TEMPORAL_UI_HOST_PORT=28080 POSTGRES_HOST_PORT=25433 docker compose up -d --wait
TEMPORAL_ADDRESS=localhost:27233 go run ./example/worker
TEMPORAL_ADDRESS=localhost:27233 go run ./example/clientThe client narrates six steps:
-
The happy path. Starts
ProcessOrderasynchronously, polls theGetOrderStatusquery in the background (you see the status transitions as they happen), then re-routes the order mid-flight with theChangeShippingAddressupdate. The update call blocks until the workflow's handler answers and returns a typed response containing the previous address: synchronous request/response against a running workflow. -
A rejected update. Sends an empty address. The validator the worker registered rejects it before it is ever recorded in history, and the error comes back to the caller. The workflow then ships and the client collects the typed result, plus one last query to show queries still answer after completion.
-
Cancellation with a signal. Starts a second order and sends the
CancelOrdersignal during the pickup window. Signals are fire and forget; the workflow ends with statusCANCELLEDand the reason in its result. -
Transient failures. The
tok-flakycard makesChargePaymentfail twice before succeeding. The retry policy from the proto (1s initial interval, backoff 2.0) retries transparently; watch the worker logs to see the failed attempts. -
Non-retryable failures. The
tok-declinedcard makesChargePaymentreturn an application error of typeCardDeclined, which the retry policy lists as non-retryable. The workflow fails immediately and the client unwraps the typed error witherrors.As. -
Schedules. Upserts a schedule running
DailySalesReportevery minute, lists, pauses and unpauses it (both idempotent), then deletes it. Comment out the delete in the client if you want to watch it fire. -
Blocking updates and continue-as-new.
TrackInventoryis a long-lived entity workflow that rolls over with continue-as-new after every restock. The client sendsReserve(3)to an empty inventory: the update handler parks onworkflow.Await(stock >= 3)inside the workflow, and the client call blocks with it — no polling anywhere, the workflow is simply idle. ARestock(5)signal then satisfies the reservation AND triggers the rollover; because the workflow drains its handlers (workflow.AllHandlersFinished) before continuing-as-new, the parked caller gets its answer (remaining stock 2) from the old run first, and the new run starts with that stock carried over. Same workflow ID, two runs, and the caller never noticed.Two hard-earned caveats are baked into the workflow (both were found by adversarial testing, both are pinned by regression tests): the drain must keep consuming the signals that can unblock its handlers (a bare
Await(AllHandlersFinished)deadlocks the run), and the workflow must sweep its signal channel right before rolling over — signals still queued when a run ends do NOT carry over to the continue-as-new successor and are silently lost. Also note the drain's trade-off: it converts "abandoned update" into "rollover waits, possibly forever" — which is whyReserveaccepts a caller-suppliedtimeout_seconds: the handler bounds its wait withworkflow.AwaitWithTimeout(a durable workflow timer) and fails the update cleanly on expiry, which also releases the drain.
Everything is also visible in the Temporal UI at
http://localhost:8080 — the child ShipOrder
executions, the update in the order-happy history, the retry attempts on the
flaky payment, and the failed order-declined workflow.
The example is tested at two layers:
- Unit (
make test-unit, runs everywhere):example/ordersexercises the real service against the SDK's in-memory test suite — the mock clock means a two hour pickup window costs no wall time — andgen/example/v1pins the generated helpers themselves. - e2e (
make test-e2e, needs docker): brings up the compose stack and runsexample/e2eagainst the real server with an in-process worker, driving the generated client-side API end to end. Build-taggede2eso plaingo test ./...skips it. CI runs both layers on every PR.
The tests are narrated with t.Log, so go test -v (and the CI output) reads
as a story. What each one demonstrates:
TestHappyPathWithClientSideUpdates— the core promise of updates. An order is started and reaches PACKED, where it sits in its pickup window. The test then calls the generated update method: the call blocks until code inside the running workflow processes the request, and comes back with a typed response (the address that was replaced). It does it twice (once via the client, once via the workflow object) to show both flavours chain correctly, then sends an empty address: the validator rejects it before it is ever written to workflow history — the caller gets the error, the workflow never even sees the request. Finally the window expires and the order ships to the last address set, proving the updates actually mutated live workflow state. A last query after completion shows queries read history and keep working once the workflow is done.TestCancelSignal— signals vs updates. A parked order gets aCancelOrdersignal: fire-and-forget, no response, but the workflow's signal handler flips a flag the pickup wait is watching, so it ends CANCELLED immediately with the reason in its result.TestFlakyPaymentRetries— retry policies do their job server-side. Thetok-flakycard fails the payment activity twice; the retry policy declared in the proto retries it with backoff. The caller sees no failure at all, just a slower success.TestCardDeclinedFailsFast— failures you should NOT retry. Thetok-declinedcard raises an application error whose type is listed innon_retryable_error_types: no retries, the workflow fails in one attempt, and the caller recovers the typed error witherrors.Asto react to the business reason (CardDeclined).TestScheduleCRUD— the whole generated schedule lifecycle against a real server: upsert, list (polling, because listings go through the eventually-consistent visibility store), idempotent pause/unpause, delete.TestBlockedUpdateSurvivesContinueAsNew— a workflow ID is really a chain of runs, and continue-as-new ends one run to start the next with only the input it is handed. An in-flight update is a conversation with ONE run. This test parksReserve(3)on an empty inventory, then sends theRestock(5)that both satisfies it and triggers the rollover: the parked caller is answered by the OLD run (that is the handler drain), and the new run comes up with the post-reservation stock carried over. The caller never notices a rollover happened.TestBlockedUpdateAbortedWithoutHandlerDrain— the failure mode the drain prevents, pinned deliberately.Reserve(10)stays unsatisfiable whenRestock(5)triggers the rollover, and the workflow (configured with theskip_handler_drainanti-pattern flag) rolls over anyway: the parked update dies with the old run and its caller getsAcceptedUpdateCompletedWorkflowinstead of an answer, while the new run's stock proves the reservation never happened. Nuance the server taught us: an update the triggering event CAN satisfy completes even without the drain — the dispatcher finishes runnable handlers before shipping the rollover; only still-parked handlers are abandoned. The anti-pattern path also drops queued signals (it deliberately gets no pre-rollover sweep — see the next test for why).TestReserveTimeoutFailsCleanly— the caller-supplied reservation timeout.Reserve(10, timeout_seconds: 2)on insufficient stock fails after ~2s with the handler's own error as the update outcome — enforced by a durable timer inside the workflow, not by the client's context — and leaves no trace: stock untouched, workflow healthy, a later satisfiable reservation fills normally. This is also what bounds the pre-rollover drain: a parked reservation can only delay continue-as-new until its deadline.TestSkipDrainNeverDoubleCounts— pins a subtle ordering rule found by adversarial testing: once the workflow has built its continue-as-new arguments, it can no longer observe handler effects. Sweeping queued signals while aReserveis still parked can make that reservation satisfiable after the carried-over stock was snapshotted: the dispatcher completes the handler in the closing task, the caller is told "reserved", and the successor starts with the un-decremented stock — units exist twice. The invariant asserted here is race-proof: a caller must never receive success unless the successor's stock reflects the decrement. Hence the rule the workflow now follows: sweep only after the drain proved no handler is in flight; on the skip path, don't sweep at all.
The stack keeps running until you docker compose down. Some ideas:
- Kill the worker while an order is packing and restart it: the workflow picks up where it left off. That is Temporal doing its job, the generated code just wires your service into it.
- Run the client twice concurrently. Workflow IDs are generated per run
(
gen-workflow-prefix=true), so the orders don't collide. - Send an update after the order shipped (e.g. from a quick Go snippet or
temporal workflow update): the handler rejects it with "too late to change the address" — a handler error fails the update, not the workflow.