Sample project showing how to orchestrate a place-order + post-payment consignment flow across five Spring Boot services with Temporal, using a parent OrderWorkflow that blocks on a child ConsignmentWorkflow after payment succeeds.
- Stack: Spring Boot
4.0.5, Java25, Temporal Java SDK1.32.1(temporal-spring-boot-starter). - Transport between services is Temporal itself — no HTTP calls between services. Each service runs its own Temporal worker on a dedicated task queue.
- All business actions are dummy — they only emit a decorated 3-line banner to the log so you can watch the workflow progress in real time.
┌─────────────────────────┐
│ Temporal server :7233 │
│ Temporal UI :8080 │
└──────┬──────────────────┘
│
┌───────────────┬──────────────┼──────────────┬──────────────┐
│ │ │ │ │
┌────┴─────────┐ ┌───┴──────────┐ ┌─┴──────────┐ ┌─┴──────────┐ ┌─┴─────────────┐
│order-service │ │email-service │ │payment-svc │ │cms-service │ │consignment- │
│:8081 │ │:8082 │ │:8083 │ │:8084 │ │ service │
│ │ │ │ │ │ │ │ │:8085 │
│POST /place- │ │worker on │ │worker on │ │POST /trig- │ │worker on │
│ order │ │ email-tq │ │ payment-tq │ │ ger-work- │ │ consignment-tq│
│parent WF on │ │activities: │ │activities: │ │ flow │ │CHILD WF on │
│ order-tq │ │ sendOrder.. │ │ checkPay.. │ │POST /reset-│ │ consignment-tq│
│ │ │ sendPayment │ │ cancelOrd. │ │ workflow │ │activities: │
│on payment │ │ sendCancel │ │ │ │POST /cancel│ │ checkShipping │
│ success: │ │ │ │POST │ │POST /term- │ │ │
│ starts CHILD │ │ │ │ /payment │ │ inate │ │POST /shipment │
│ ConsignmentWF│ │ │ │GET /check- │ │GET /work- │ │POST /customer-│
│ on │ │ │ │ payment │ │ flows │ │ confirm │
│ consignment- │ │ │ │ │ │GET /work- │ │GET /check- │
│ tq, blocks │ │ │ │ │ │ flows/{id}│ │ shipping │
│ until return │ │ │ │ │ │ [+ /query] │ │ │
└──────────────┘ └──────────────┘ └────────────┘ └────────────┘ └───────────────┘
- Client calls
POST /place-orderon order-service with{"orderId":"..."}. - order-service starts
OrderWorkflowwith workflow idorder-{orderId}onorder-tq. - Workflow dispatches
EmailActivities.sendOrderPlacedNotificationtoemail-tq. - Workflow does
Workflow.await(Duration.ofMinutes(1), () -> paymentReceived). - Either:
- Signal arrives in time — client hit
POST /payment?orderId=...on payment-service → controller signals the workflow → workflow dispatchessendPaymentCompletedNotification. - 1-minute timeout — workflow dispatches
PaymentActivities.checkPaymenttopayment-tq. Rule:trueif the numeric order id is even,falseif odd.true→sendPaymentCompletedNotification.false→cancelOrder+sendOrderCancelledNotification.
- Signal arrives in time — client hit
- On payment success (either signal or
checkPayment=true) —OrderWorkflowstarts a childConsignmentWorkflowwith idconsignment-{orderId}onconsignment-tqand blocks on it. The parent does not finish until the child returns.
ConsignmentWorkflow owns the post-payment fulfilment:
- Banner
CONSIGNMENT STARTED, thenWorkflow.await(() -> shipmentDispatched)— indefinite wait forPOST /shipment?orderId=...on consignment-service. - Signal arrives → banner
SHIPMENT RECEIVED, thenOUT FOR DELIVERY→Workflow.sleep(Duration.ofMinutes(2))(durable timer). - After sleep, activity
ShippingActivities.checkShippingonconsignment-tq— rule: orderId ending in9→ undelivered, otherwise delivered. The activity is flaky on attempt 1 (throwsApplicationFailure("FlakySimulated")) and the stub'sRetryPolicyretries 2s later; attempt 2 returns the real result. - If delivered → banner
DELIVERED→Workflow.await(Duration.ofMinutes(1), () -> customerConfirmed)(optional customer confirmation viaPOST /customer-confirm?orderId=...; either signal or 1-minute timeout, flow proceeds either way) → bannerCONSIGNMENT COMPLETE→ child returnsConsignmentResult.DELIVERED→ parent bannerWORKFLOW COMPLETED→ parent run completes. - If NOT delivered → banner
UNDELIVERED→ child returnsConsignmentResult.UNDELIVEREDimmediately (no customer-confirm step) → parent runs compensation:PaymentActivities.refundPayment+EmailActivities.sendOrderCancelledNotification→WORKFLOW COMPLETED.
If a workflow already cancelled but reconciliation shows the customer actually paid, POST /reset-workflow on cms-service calls Temporal's ResetWorkflowExecution to rewind the workflow to an earlier WorkflowTaskCompleted event — typically event 10 (just before the 60s timer starts) — creating a new run that re-enters Workflow.await(...). A follow-up POST /payment then takes the happy branch.
spring-temporal/
├── pom.xml parent (Spring Boot 4.0.5, Java 25, Temporal 1.32.1)
├── docker-compose.yml postgres + temporal + temporal-ui + 5 services
├── common/ shared workflow/activity interfaces, DTOs, task-queue names
├── order-service/ OrderController, OrderWorkflowImpl (parent — dispatches child on payment success)
├── email-service/ EmailActivitiesImpl
├── payment-service/ PaymentController, PaymentActivitiesImpl, PaymentRules
├── cms-service/ TriggerController, ResetController, LifecycleController, ListController, DetailController, QueryController (operational gRPC)
└── consignment-service/ ConsignmentWorkflowImpl (child), ShippingActivitiesImpl, ShippingController, ShippingRules
common is a pure contracts jar — it contains no service logic, only the types both sides of every Temporal interaction must agree on.
| Class | Role |
|---|---|
id.adiputera.common.TaskQueues |
Constants for the four task queues (order-tq, email-tq, payment-tq, consignment-tq). One spelling, referenced everywhere. |
id.adiputera.common.dto.PlaceOrderRequest |
Request body for POST /place-order and input to OrderWorkflow.placeOrder. |
id.adiputera.common.workflow.OrderWorkflow |
@WorkflowInterface for the parent workflow (placeOrder + paymentReceived signal). order-service implements, payment-service signals. |
id.adiputera.common.workflow.ConsignmentWorkflow |
@WorkflowInterface for the child workflow (startConsignment + shipmentDispatched / customerConfirmed signals). consignment-service implements, order-service invokes as child, consignment-service's own HTTP endpoints signal it. |
id.adiputera.common.activity.EmailActivities |
@ActivityInterface used to build a stub in the workflow and implemented by email-service. |
id.adiputera.common.activity.PaymentActivities |
Same pattern — stub in workflow, implementation in payment-service. |
id.adiputera.common.activity.ShippingActivities |
Same pattern — stub in child workflow, implementation in consignment-service. |
Temporal requires the same interface on both sides of every stub/implementation pair. The workflow builds a stub via Workflow.newActivityStub(EmailActivities.class, …) and the worker registers an implementation of the same EmailActivities type; if each service carried its own copy, a method rename in one would diverge silently and blow up at runtime (method not found on the worker). Keeping the interface in common makes them compile against the same signatures.
It also means services depend on each other's contracts without depending on each other's code: order-service/pom.xml depends only on common, not on email-service or payment-service. You could swap out either implementation without touching order-service.
Input/output types live here too for the same reason: PlaceOrderRequest is the HTTP body Jackson deserializes in OrderController and the value Temporal serializes into the workflow's history — one class, one shape.
- Docker + Docker Compose (for the one-shot path below), or
- Java 25, Maven 3.9+, and a running Temporal server at
localhost:7233(e.g.temporal server start-dev) for local dev.
docker compose up --buildBrings up eight containers: Postgres, Temporal server, Temporal UI, and the five Spring Boot services. Services come up on:
| Service | URL |
|---|---|
| order-service | http://localhost:8081 |
| email-service | http://localhost:8082 (no public API) |
| payment-service | http://localhost:8083 |
| cms-service | http://localhost:8084 |
| consignment-service | http://localhost:8085 |
| Temporal UI | http://localhost:8080 |
| Temporal gRPC | localhost:7233 |
Stop with Ctrl+C then docker compose down (add -v to wipe the postgres volume).
Start a dev Temporal server in one terminal:
temporal server start-devBuild once, then run each service (separate terminals):
mvn -q -DskipTests package
java -jar order-service/target/order-service-0.1.0.jar
java -jar email-service/target/email-service-0.1.0.jar
java -jar payment-service/target/payment-service-0.1.0.jar
java -jar cms-service/target/cms-service-0.1.0.jar
java -jar consignment-service/target/consignment-service-0.1.0.jarEach service reads TEMPORAL_TARGET (default localhost:7233).
| Method | Path | Body / Params | Description |
|---|---|---|---|
POST |
/place-order |
{ "orderId": "1001" } |
Starts OrderWorkflow with id order-1001. |
| Method | Path | Params | Description |
|---|---|---|---|
POST |
/payment |
?orderId=1001 |
Sends the paymentReceived signal to workflow order-1001. |
GET |
/check-payment |
?orderId=42 |
Returns {"completed": true|false} — true iff the id is even. |
| Method | Path | Params | Description |
|---|---|---|---|
POST |
/shipment |
?orderId=1001 |
Sends the shipmentDispatched signal to child workflow consignment-1001. Unblocks step 1 of the child. |
POST |
/customer-confirm |
?orderId=1001 |
Sends the customerConfirmed signal. Optional — the workflow waits up to 1 minute for this, then proceeds anyway. |
GET |
/check-shipping |
?orderId=42 |
Returns {"delivered": true|false} — false iff the orderId ends in 9, otherwise true. Orthogonal to /check-payment's parity rule so an even-id-ending-in-9 (e.g. 2009) passes payment but fails shipping, demoing the saga. |
| Method | Path | Body / Params | Description |
|---|---|---|---|
POST |
/trigger-workflow |
{ "id": "order-1001", "workflowType": "OrderWorkflow", "taskQueue": "order-tq", "input": {...} } |
Generic workflow launcher — starts any registered workflow type on any task queue via an untyped stub. input is optional and accepts any JSON (object, string, number). Returns 202 with the new runId; 409 if the workflow id is already running. See the caveats section below. |
POST |
/reset-workflow |
{ "orderId": "1001", "eventId": 10, "reason": "..." } |
Resets workflow order-{orderId} to the given WorkflowTaskCompleted event id; returns the new run id. |
POST |
/cancel |
?workflowId=... |
Graceful cancel (workflow can catch CanceledFailure and compensate; in this sample it doesn't, so run ends with status CANCELED). Cascades to children via parentClosePolicy. |
POST |
/terminate |
?workflowId=...&reason=... |
Hard kill — no chance to compensate. Parent and any in-flight children end with status TERMINATED. |
GET |
/workflows |
?status=, ?workflowType=, ?workflowId=, ?query= |
Lists every workflow in the namespace with its status + most recently scheduled activity and that activity's state. Each structured param accepts a comma-separated list (or the param repeated); single value → =, multi-value → IN (...). All filters are optional and combined with AND. |
GET |
/workflows/{workflowId} |
?runId= (optional — defaults to latest run) |
Full detail for a single run: identity, status, timing, input, result/failure, signal timeline, activity timeline (per-step timing + input/result + a resetEventId you can pass to /reset-workflow to roll back to before each activity was scheduled), timer timeline, pending activities + pending children, execution timeouts, pending workflow task, and reset/continue-as-new lineage. Returns 404 if the workflow id has no run. |
GET |
/workflows/{workflowId}/query |
?name=currentStep&runId=<optional> |
Invokes a @QueryMethod handler on the workflow (synchronous, no history event emitted). name=currentStep returns a short identifier of where the workflow is right now. Works on both running and closed workflows. |
One element per run (so a reset workflow shows up twice — once per run id). lastActivity is the name of the most recently scheduled activity; lastActivityState is one of scheduled, started, completed, failed, timed_out, canceled.
[
{
"workflowId": "order-2003",
"runId": "19f82473-...",
"workflowType": "OrderWorkflow",
"status": "COMPLETED",
"startTime": "2026-04-23T07:30:15Z",
"closeTime": "2026-04-23T07:30:21Z",
"lastActivity": "SendPaymentCompletedNotification",
"lastActivityState": "completed"
},
{
"workflowId": "order-3001",
"runId": "5501e9a9-...",
"workflowType": "OrderWorkflow",
"status": "RUNNING",
"startTime": "2026-04-23T07:41:53Z",
"closeTime": null,
"lastActivity": "SendOrderPlacedNotification",
"lastActivityState": "completed"
}
]All four params are optional and any combination is AND-ed together before being sent to Temporal as a visibility query. Each structured param accepts a single value, a comma-separated list, or the param repeated — the builder emits = for a single value and IN (...) for multiple.
| Param | Example | Becomes |
|---|---|---|
status |
?status=RUNNING |
ExecutionStatus='Running' |
status |
?status=RUNNING,COMPLETED |
ExecutionStatus IN ('Running', 'Completed') |
workflowType |
?workflowType=OrderWorkflow,ApprovalWorkflow |
WorkflowType IN ('OrderWorkflow', 'ApprovalWorkflow') |
workflowId |
?workflowId=order-1001,order-2003 |
WorkflowId IN ('order-1001', 'order-2003') |
query |
?query=StartTime>'...' |
(<verbatim>) — escape hatch for ranges, OR clauses, time windows, etc. |
status is case-insensitive (RUNNING, running, Running all resolve to the canonical Running). Valid values: Running, Completed, Failed, Canceled, Terminated, ContinuedAsNew, TimedOut — see Workflow statuses for what each one means and when it occurs in this sample. Comma-split and param-repetition are equivalent — ?status=RUNNING,COMPLETED and ?status=RUNNING&status=COMPLETED produce the same query.
curl 'localhost:8084/workflows?status=RUNNING'
curl 'localhost:8084/workflows?status=RUNNING,COMPLETED' # IN clause
curl 'localhost:8084/workflows?status=RUNNING&status=COMPLETED' # same, repeated param form
curl 'localhost:8084/workflows?workflowType=OrderWorkflow'
curl 'localhost:8084/workflows?workflowId=order-1001,order-2003' # 3 runs across 2 workflow ids
curl 'localhost:8084/workflows?status=COMPLETED&workflowId=order-2003' # AND: just the completed ones
curl "localhost:8084/workflows?status=COMPLETED&query=StartTime%3E'2026-04-23T00:00:00Z'" # structured + rawCost note: the endpoint fetches the full history for every workflow returned by the list call (to find the last activity). Fine for this sample (a few dozen workflows); for a production namespace with tens of thousands, persist "last activity" into a Search Attribute instead so the list call alone can answer the question.
Assembled from two Temporal calls per request: DescribeWorkflowExecution (identity, status, timing, pending activities) + GetWorkflowExecutionHistory (signal/activity/timer timelines, input/result/failure payloads). Pass ?runId=... to pick a specific run; omit to get the latest run of the workflow id.
{
"workflowId": "order-2003",
"runId": "19f82473-...",
"workflowType": "OrderWorkflow",
"status": "COMPLETED",
"taskQueue": "order-tq",
"startTime": "2026-04-23T07:30:15.810Z",
"closeTime": "2026-04-23T07:30:21.547Z",
"historyLength": 25,
"firstRunId": "f59b1cde-...", // original (pre-reset) run id
"continuedFromRunId": null, // set on continue-as-new, null on reset
"input": ["{\"orderId\":\"2003\"}"],
"result": [],
"failure": null,
"signals": [
{ "eventId": 15, "name": "paymentReceived", "time": "...", "input": [] }
],
"activities": [
{
"scheduledEventId": 5,
"type": "SendOrderPlacedNotification",
"taskQueue": "email-tq",
"input": ["\"2003\""],
"state": "completed",
"attempt": 1,
"scheduledTime": "...",
"startedTime": "...",
"closedTime": "...",
"result": [],
"failure": null,
"resetEventId": null // scheduled by the first workflow task — nothing before it to reset to
},
{ "scheduledEventId": 19, "type": "SendPaymentCompletedNotification", "state": "completed", "resetEventId": 13, ... }
// ↑ pass this to /reset-workflow to undo this activity being scheduled
],
"timers": [
{ "eventId": 14, "timerId": "086b3a4f-...", "duration": "PT1M", "startedTime": "...", "firedTime": null, "canceledTime": null }
],
"executionTimeouts": { // from WorkflowExecutionConfig — fixed at start time
"workflowExecutionTimeout": null, // null = no limit (not set in this sample)
"workflowRunTimeout": "PT10M", // set on parent; 20 min on child via ChildWorkflowOptions
"workflowTaskTimeout": "PT10S" // Temporal default
},
"pendingActivities": [], // non-empty only while workflow is running AND blocked on an activity
"pendingChildren": [], // non-empty only while workflow is running AND blocked on a child workflow
"pendingWorkflowTask": null // populated only while a worker is actively progressing the workflow
}What Temporal exposes (that this endpoint surfaces):
| Temporal source | Fields on response |
|---|---|
DescribeWorkflowExecution → WorkflowExecutionInfo |
status, startTime, closeTime, historyLength, workflowType |
DescribeWorkflowExecution → WorkflowExecutionConfig |
taskQueue, executionTimeouts (workflowExecutionTimeout, workflowRunTimeout, workflowTaskTimeout) |
DescribeWorkflowExecution → PendingActivities |
pendingActivities[] — activities scheduled on a task queue but not yet terminal (with attempt, maxAttempts, lastFailure) |
DescribeWorkflowExecution → PendingChildren |
pendingChildren[] — child workflows started by this workflow that haven't yet completed (with their workflowId, runId, type, and parentClosePolicy) |
DescribeWorkflowExecution → PendingWorkflowTask |
pendingWorkflowTask — the decision task currently scheduled/in-progress (with state, attempt); null between tasks |
WorkflowExecutionStartedEventAttributes (event 1) |
input, firstRunId, continuedFromRunId |
WorkflowExecutionCompleted/Failed/Canceled (terminal event) |
result or failure |
WorkflowExecutionSignaledEventAttributes |
signals[] |
ActivityTaskScheduled/Started/Completed/Failed/TimedOut/Canceled |
activities[] (the builder coalesces all five event types per scheduledEventId) |
WorkflowTaskCompleted (id of the one before each ActivityTaskScheduled) |
activities[].resetEventId — pass this to /reset-workflow to roll back to before this activity was scheduled; null for activities scheduled by the very first workflow task |
TimerStarted/Fired/Canceled |
timers[] |
Not yet surfaced (but available via the same two gRPC calls if you want to extend):
- Search attributes / memo (
WorkflowExecutionInfo.searchAttributes,.memo) - Parent workflow back-reference on the child (
WorkflowExecutionInfo.parentExecution) - Per-event causation metadata (
WorkflowTaskScheduled/Started/Completed/Failed/Timeout) - Heartbeat details on pending activities (
PendingActivityInfo.heartbeatDetails)
Custom business state via @QueryMethod is surfaced as a separate GET /workflows/{id}/query endpoint (not on the detail response).
The default happy/sad paths are deterministic — signal in time → paid, timeout + odd id → cancelled. Reality adds a third case: the workflow already cancelled the order because checkPayment said false, but later reconciliation shows the customer did pay (gateway outage, webhook lost, etc.). Starting a brand-new workflow would resend the initial "order placed" email, pollute metrics with a duplicate order, and lose the original history.
Temporal's ResetWorkflowExecution solves this precisely: pick an event id from the original history (typically the WorkflowTaskCompleted just after sendOrderPlacedNotification and just before the timer fires), and Temporal replays up to that event, discards everything after, and re-enters the workflow from that state. A subsequent POST /payment on payment-service then drives the happy path. The new run id is returned so callers can follow it in the UI.
The event id is workflow-history specific. Two ways to obtain it: read it from GET /workflows/{id} — every scheduled activity in the response carries a resetEventId field that is exactly the value to pass here to undo that activity being scheduled — or browse the Temporal UI (http://localhost:8080 → pick the workflow → History tab) and pick a WorkflowTaskCompleted event id by hand. The first form is preferred for ops-from-CLI; the UI form is the fallback when you need a reset point that isn't tied to an activity (rare). The endpoint is kept on its own service because reset is an operational action (post-incident, manual audit), not a business flow — keeping it off the customer-facing services means you can lock it down separately (different network, different auth) without touching order/payment.
Choosing which activity's resetEventId to read. Pick the first activity that put the workflow on the wrong path, not the last one you wish hadn't happened. For OrderWorkflow's cancel-after-timeout scenario, that's CheckPayment (the activity that returned false and triggered the cancel branch) — its resetEventId rewinds past the timer-fired task and back into the Workflow.await(...) state. Reading CancelOrder.resetEventId instead would only rewind one task: the workflow would re-run with checkPayment's false result still in history and re-cancel.
Each owning service starts its own workflow from a typed controller (order-service → OrderWorkflow). That coupling is fine for the business path, but leaves no way to trigger a workflow from outside its owner — useful when replaying an incident, bootstrapping test data, or starting a workflow whose owner isn't up. POST /trigger-workflow on cms-service fills that gap: it's a thin pass-through to WorkflowClient.newUntypedWorkflowStub(workflowType, options).start(input), with workflowType, taskQueue, id, and input supplied verbatim by the caller.
Design constraint: adding a new workflow type to the project must not require editing cms-service. The controller therefore keeps no registry — caller supplies the Temporal workflow type name and task queue directly. Trade-off: the caller has to know those two values (both are already visible in application.yml of the owning service).
# OrderWorkflow — input is the same JSON shape that order-service's PlaceOrderRequest serialises to
curl -X POST localhost:8084/trigger-workflow \
-H 'Content-Type: application/json' \
-d '{"id":"order-9001","workflowType":"OrderWorkflow","taskQueue":"order-tq","input":{"orderId":"9001"}}'
# → 202 {"workflowId":"order-9001","runId":"…","workflowType":"OrderWorkflow"}
# ConsignmentWorkflow — a @WorkflowMethod that takes a raw String; pass the string as-is
curl -X POST localhost:8084/trigger-workflow \
-H 'Content-Type: application/json' \
-d '{"id":"consignment-9002","workflowType":"ConsignmentWorkflow","taskQueue":"consignment-tq","input":"9002"}'Caveats:
- Temporal does not reject unknown workflow types at
start(). The server has no per-task-queue registry of workflow types;start()always succeeds and returns arunId. A typo surfaces only on the worker side as repeatedWorkflowTaskFailedevents (visible inGET /workflows/{id}→pendingWorkflowTask.attemptclimbing,lastFailure.message = "Unknown workflow type: …"). Wrong task queue with no worker polling it leaves the run stuck atWorkflowExecutionStartedwith zero history progress. In both cases, clean up withPOST /terminate?workflowId=…. - Starting a child-only workflow standalone skips the parent's compensation.
ConsignmentWorkflowis normally run as a child ofOrderWorkflow, whose impl reacts toConsignmentResult.UNDELIVEREDwithrefundPayment+sendOrderCancelledNotification. TriggeringConsignmentWorkflowvia this endpoint has no parent, so onUNDELIVEREDthe workflow simply closes with that result and compensation does not run. Correct for an ops-initiated trigger, but don't use this endpoint to drive the regular business path. - 409 on duplicate
id. Temporal'sWorkflowExecutionAlreadyStartedis translated to HTTP 409. To retry, either terminate the prior run viaPOST /terminate?workflowId=…and re-trigger, or pick a freshid.
Every Temporal workflow execution has exactly one of seven status values. The /workflows list, the /workflows/{id} detail endpoint, and the Temporal UI all use the short form (the gRPC enum is WORKFLOW_EXECUTION_STATUS_<STATUS>; this sample strips that prefix everywhere).
| Status | Meaning | How to reach it in this sample |
|---|---|---|
RUNNING |
Active — at least one pending workflow task, activity, timer, or signal wait | Any in-flight workflow: parent waiting for payment signal, parent blocked on child, child sleeping 2 min for delivery |
COMPLETED |
Workflow returned normally (success or any "business finished" branch) | Happy path, checkPayment=true path, cancel-order branch, UNDELIVERED compensation branch (all return normally) |
FAILED |
Workflow threw an unhandled ApplicationFailure / runtime exception |
Not produced by this sample — activities are retried 3× and eventually succeed; no branch rethrows out of the workflow |
CANCELED |
Someone called CancelWorkflow RPC and the workflow acknowledged |
POST /cancel?workflowId=order-… on cms-service — cascades to any in-flight child |
TERMINATED |
Someone called TerminateWorkflow RPC — no rollback, just hard stop |
POST /terminate?workflowId=order-…&reason=… on cms-service — cascades to child via parentClosePolicy |
CONTINUED_AS_NEW |
Workflow called Workflow.continueAsNew(...) — a new run replaces this one |
Not used here (a typical use is trimming long histories by restarting with fresh state) |
TIMED_OUT |
Exceeded a WorkflowRunTimeout or WorkflowExecutionTimeout |
Place an order and don't signal anything — the parent's 10-min workflowRunTimeout fires, status flips to TIMED_OUT. Child has its own 20-min timeout. |
Status vs. activity state. The status on the detail endpoint is workflow-level (one of the seven above). Individual activities within a workflow track their own lifecycle in activities[].state — scheduled → started → completed / failed / timed_out / canceled. A workflow with status: RUNNING may legitimately show all of its past activities as completed; the "running" part is that the workflow is currently on a timer or await.
Status after reset. ResetWorkflowExecution leaves the original run with its terminal status (typically COMPLETED for the cancel branch) and creates a new run — a new row in /workflows with status: RUNNING sharing the same workflowId, pointing back via firstRunId to the original. Neither run moves between statuses; the reset produces a fresh execution.
Parent / child status coupling. When OrderWorkflow starts ConsignmentWorkflow as a blocking child, both runs are RUNNING simultaneously — two rows in /workflows. The parent transitions to COMPLETED only after the child hits COMPLETED. If the child fails or is terminated, the parent's child-workflow call rethrows the failure, which surfaces on the parent as FAILED (unless caught).
# All active work right now
curl 'localhost:8084/workflows?status=RUNNING'
# Everything that ran a business-cancel branch (still COMPLETED at Temporal level)
curl "localhost:8084/workflows?status=COMPLETED" \
| jq '.[] | select(.lastActivity=="SendOrderCancelledNotification")'
# Both runs of a reset workflow — the original (terminal) + the reset (current)
curl 'localhost:8084/workflows?workflowId=order-2003'After driving the Try-it recipes end-to-end against a fresh namespace, the list endpoint shows workflows across four distinct terminal states:
$ curl -s 'localhost:8084/workflows' | jq 'group_by(.status) | map({status: .[0].status, count: length})'
[
{ "status": "CANCELED", "count": 2 }, // order-9100 + consignment-9100 (cascade)
{ "status": "COMPLETED", "count": 14 }, // happy path, saga compensation, cancel-payment branch, reset runs
{ "status": "TERMINATED", "count": 4 } // order-9200/6000 + their cascaded children
]
RUNNING is ephemeral and only visible while work is in flight; TIMED_OUT is reachable via the 10-minute workflowRunTimeout by placing an order and walking away. FAILED and CONTINUED_AS_NEW are not exercised by the current code — see the Workflow-statuses table above for what it would take.
After POST /place-order + POST /payment but before POST /shipment, the parent is blocked on the child and both runs are RUNNING. Here's exactly what the two endpoints return in that state (captured live for orderId=6000).
GET /workflows?workflowId=order-6000,consignment-6000 — list view shows both rows RUNNING, parent's lastActivity is the last thing it did (send the payment email), not the child it's currently blocked on:
[
{
"workflowId": "consignment-6000",
"runId": "f741e35d-...",
"workflowType": "ConsignmentWorkflow",
"status": "RUNNING",
"closeTime": null,
"lastActivity": null, // child hasn't scheduled any activity yet — still in await-for-shipment
"lastActivityState": null
},
{
"workflowId": "order-6000",
"runId": "6b8a6f28-...",
"workflowType": "OrderWorkflow",
"status": "RUNNING",
"closeTime": null,
"lastActivity": "SendPaymentCompletedNotification", // last thing the PARENT did
"lastActivityState": "completed"
}
]The list endpoint alone doesn't tell you "parent is blocked on child" — you need the detail endpoint for that.
GET /workflows/order-6000 — parent detail, key fields only:
{
"workflowId": "order-6000",
"status": "RUNNING",
"historyLength": 26,
"signals": [ { "eventId": 12, "name": "paymentReceived", ... } ],
"activities": [ /* SendOrderPlaced (completed), SendPaymentCompleted (completed) */ ],
"pendingActivities": [], // NOT blocked on an activity
"pendingChildren": [ // blocked on a child workflow — this is the "why"
{
"workflowId": "consignment-6000",
"runId": "f741e35d-...",
"workflowType": "ConsignmentWorkflow",
"initiatedEventId": 22,
"parentClosePolicy": "terminate"
}
]
}GET /workflows/consignment-6000 — child detail, awaiting its first signal:
{
"workflowId": "consignment-6000",
"status": "RUNNING",
"historyLength": 4, // only Started + WorkflowTask Scheduled/Started/Completed — then await
"input": ["\"6000\""], // orderId passed by parent
"signals": [], // shipmentDispatched hasn't arrived
"activities": [],
"timers": [], // no timer — Workflow.await(() -> flag) is indefinite
"pendingActivities": [],
"pendingChildren": []
}Interpretation: pendingChildren on the parent is the signal that it's waiting on a child — directly analogous to how pendingActivities signals it's waiting on an activity. Neither populates a field in the list endpoint; to see them you always need the detail call. parentClosePolicy: "terminate" (the Temporal default) means: if the parent is terminated, Temporal cascades a terminate to the child — so you usually don't orphan a child by killing the parent. Other options: abandon (child keeps running independently), request_cancel (child receives a cancellation request).
Status transition at the moment the child returns:
- Child emits
CONSIGNMENT COMPLETEbanner, workflow method returns. - Child's
ChildWorkflowExecutionCompletedevent lands in the parent's history. - Parent's blocking
consignment.startConsignment(orderId)call returns in order-service's worker. - Parent logs
WORKFLOW COMPLETEDand its workflow method returns. - Both runs'
statusflip toCOMPLETEDwithin milliseconds of each other (verified end-to-end fororder-5000: child closed at08:17:39.647Z, parent at08:17:39.671Z— 24ms apart).
Full happy path — signal payment within 1 minute, then drive the child consignment workflow:
curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"1001"}'
curl -X POST 'localhost:8083/payment?orderId=1001' # parent receives signal, starts child
curl -X POST 'localhost:8085/shipment?orderId=1001' # unblocks child step 1 (waits 2 minutes)
# ... wait ~2 minutes for the out-for-delivery timer ...
curl -X POST 'localhost:8085/customer-confirm?orderId=1001' # optional; child auto-proceeds after 1 minuteMinimal flow without the child (when payment succeeds, child still starts — you must signal /shipment or the parent will block forever, timing-out only if you set a WorkflowRunTimeout, which this sample does not). To test just the order+cancel path, use an odd orderId that will go down the cancel branch and skip consignment entirely:
curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"2001"}'
# wait > 60s → timeout → checkPayment=false (odd) → cancelled, no consignmentTimeout + even id (checkPayment returns true → payment completed):
curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"2000"}'
# wait > 60sTimeout + odd id (checkPayment returns false → order cancelled):
curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"2001"}'
# wait > 60sDirect /check-payment sanity:
curl 'localhost:8083/check-payment?orderId=42' # {"completed":true}
curl 'localhost:8083/check-payment?orderId=43' # {"completed":false}Saga / compensation path — orderId ending in 9 triggers UNDELIVERED → parent refunds + cancels:
curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"2009"}'
curl -X POST 'localhost:8083/payment?orderId=2009'
curl -X POST 'localhost:8085/shipment?orderId=2009'
# wait ~2 min → checkShipping returns false → UNDELIVERED → refundPayment → sendOrderCancelledNotification → WORKFLOW COMPLETEDRead-only query for current workflow step (no history event emitted; works on both running and closed workflows):
curl 'localhost:8084/workflows/order-7000/query?name=currentStep' # e.g. PAID_WAITING_FOR_CONSIGNMENT
curl 'localhost:8084/workflows/consignment-7000/query?name=currentStep' # e.g. WAITING_FOR_SHIPMENT → OUT_FOR_DELIVERY → DELIVERED_WAITING_FOR_CUSTOMER → COMPLETEGraceful cancel (cascades to child) — workflow status CANCELED:
curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"9100"}'
curl -X POST 'localhost:8083/payment?orderId=9100'
curl -X POST 'localhost:8084/cancel?workflowId=order-9100'
# both order-9100 and consignment-9100 end up with status CANCELEDHard terminate (cascades to child) — workflow status TERMINATED:
curl -X POST 'localhost:8084/terminate?workflowId=order-9200&reason=ops+test'
# both order-9200 and consignment-9200 end up with status TERMINATEDTimeout demo — place an order and don't signal anything. After 10 minutes the parent's workflowRunTimeout fires → status TIMED_OUT:
curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"9999"}'
# wait > 10 min, do not signal /paymentReset a cancelled workflow back to wait-for-payment (e.g. after orderId=2001 went down the cancel path) — pull the reset point straight from the detail endpoint, no Temporal UI round-trip:
# 1. Read CheckPayment.resetEventId — the value that rewinds past the timer-fired task
# and back into Workflow.await(...). Picking CancelOrder.resetEventId would only
# rewind one task and re-cancel; see "Choosing which activity's resetEventId to
# read" in the reset section above.
event_id=$(curl -s localhost:8084/workflows/order-2001 \
| jq '.activities[] | select(.type=="CheckPayment") | .resetEventId')
# 2. Reset to that event, then signal.
curl -X POST localhost:8084/reset-workflow \
-H 'Content-Type: application/json' \
-d "{\"orderId\":\"2001\",\"eventId\":$event_id,\"reason\":\"gateway reconciliation\"}"
curl -X POST 'localhost:8083/payment?orderId=2001'The workflow resumes at Workflow.await(...) with a fresh run id; the POST /payment signal then completes it via the happy path.
Each event produces three log lines: a decoration line (the glyph repeated 10×), the message, another decoration line. Example:
##########
sendOrderPlacedNotification 1001
##########
Traced end-to-end for orderId=7000 (even → happy-path payment + not ending in 9 → delivered):
# t=0s — place
>>>>>>>>>> POST /place-order 7000 (order-service)
********** ORDER PLACED: 7000 (order-service)
########## sendOrderPlacedNotification 7000 (email-service)
# t=2s — signal payment + shipment, signal customer-confirm early
<<<<<<<<<< POST /payment 7000 (payment-service)
@@@@@@@@@@ PAYMENT SIGNAL RECEIVED 7000 (order-service)
++++++++++ sendPaymentCompletedNotification (email-service)
:::::::::: CONSIGNMENT STARTED 7000 (consignment-service)
{{{{{{{{{{ POST /shipment 7000 (consignment-service)
|||||||||| SHIPMENT RECEIVED 7000 (consignment-service)
////////// OUT FOR DELIVERY 7000 (consignment-service, starts 2-min timer)
[[[[[[[[[[ POST /customer-confirm 7000 (consignment-service, buffered)
# t=2m05s — 2-min timer fires; checkShipping is flaky on attempt 1
\\\\\\\\\\ checkShipping 7000 FLAKY-FAIL attempt=1 (consignment-service)
# ...2s retry backoff (visible in pendingActivities[].attempt=2 during the gap)...
(((((((((( checkShipping 7000 -> true (attempt=2) (consignment-service)
.......... DELIVERED 7000 (consignment-service)
"""""""""" CUSTOMER CONFIRMED 7000 (consignment-service, signal was already buffered)
'''''''''' CONSIGNMENT COMPLETE 7000 (consignment-service)
# child returns DELIVERED → parent resumes
$$$$$$$$$$ WORKFLOW COMPLETED 7000 (order-service)
Traced end-to-end for orderId=2009 (even → payment succeeds; ends in 9 → checkShipping returns false → UNDELIVERED → parent compensates):
# t=0–5s — place, signal payment, signal shipment (same as happy path)
...
////////// OUT FOR DELIVERY 2009 (consignment-service)
# t=2m05s
\\\\\\\\\\ checkShipping 2009 FLAKY-FAIL attempt=1 (consignment-service)
(((((((((( checkShipping 2009 -> false (attempt=2) (consignment-service)
]]]]]]]]]] UNDELIVERED 2009 (consignment-service)
# child returns UNDELIVERED → parent runs compensation
)))))))))) refundPayment 2009 (payment-service)
---------- sendOrderCancelledNotification 2009 (email-service)
$$$$$$$$$$ WORKFLOW COMPLETED 2009 (order-service)
Traced end-to-end for orderId=2003 (odd id → cancelled after timeout → reset to event 10 → signal → completed happy path):
# t=0s — place order
>>>>>>>>>> POST /place-order 2003 (order-service)
********** ORDER PLACED: 2003 (order-service)
########## sendOrderPlacedNotification 2003 (email-service)
# t=60s — 1-minute timer fires, odd id → cancel branch
!!!!!!!!!! TIMEOUT - CHECKING PAYMENT 2003 (order-service)
========== checkPayment 2003 -> false (payment-service)
~~~~~~~~~~ cancelOrder 2003 (payment-service)
---------- sendOrderCancelledNotification 2003 (email-service)
$$$$$$$$$$ WORKFLOW COMPLETED 2003 (order-service) # cancelled run
# t=95s — reconciliation: customer actually paid; reset + signal
&&&&&&&&&& POST /reset-workflow order-2003 eventId=10 (cms-service)
<<<<<<<<<< POST /payment 2003 (payment-service)
@@@@@@@@@@ PAYMENT SIGNAL RECEIVED 2003 (order-service) # on NEW run
++++++++++ sendPaymentCompletedNotification 2003 (email-service)
$$$$$$$$$$ WORKFLOW COMPLETED 2003 (order-service) # happy-path run
Note that sendOrderPlacedNotification is not re-emitted after reset — event 10 sits after that activity completed, so replay stops just short of re-scheduling it. Pick an earlier event id (e.g. 4) if you want the initial notification to re-fire.
Every action uses its own glyph so a scan of the combined logs tells you exactly where you are in the flow.
| Where | Action | Glyph |
|---|---|---|
| order-service | POST /place-order controller |
> |
| order-service | Workflow: ORDER PLACED | * |
| order-service | Workflow: PAYMENT SIGNAL RECEIVED | @ |
| order-service | Workflow: TIMEOUT — CHECKING PAYMENT | ! |
| order-service | Workflow: WORKFLOW COMPLETED | $ |
| email-service | sendOrderPlacedNotification |
# |
| email-service | sendPaymentCompletedNotification |
+ |
| email-service | sendOrderCancelledNotification |
- |
| payment-service | checkPayment activity |
= |
| payment-service | cancelOrder activity |
~ |
| payment-service | POST /payment controller |
< |
| payment-service | GET /check-payment controller |
% |
| cms-service | POST /reset-workflow controller |
& |
| cms-service | GET /workflows controller |
? |
| cms-service | GET /workflows/{id} controller |
^ |
| consignment-svc | Child WF: CONSIGNMENT STARTED | : |
| consignment-svc | Child WF: SHIPMENT RECEIVED | | |
| consignment-svc | Child WF: OUT FOR DELIVERY | / |
| consignment-svc | checkShipping activity |
( |
| consignment-svc | Child WF: DELIVERED | . |
| consignment-svc | Child WF: CUSTOMER CONFIRMED / TIMEOUT | " |
| consignment-svc | Child WF: CONSIGNMENT COMPLETE | ' |
| consignment-svc | POST /shipment controller |
{ |
| consignment-svc | POST /customer-confirm controller |
[ |
| consignment-svc | GET /check-shipping controller |
_ |
| consignment-svc | Child WF: UNDELIVERED | ] |
| payment-service | refundPayment activity (compensation) |
) |
| payment-service | checkPayment / checkShipping FLAKY-FAIL (attempt 1) |
\ |
| cms-service | POST /cancel controller |
; |
| cms-service | POST /terminate controller |
, |
| cms-service | GET /workflows/{id}/query controller |
} |
| cms-service | POST /trigger-workflow controller |
` |
common/src/main/java/id/adiputera/common/workflow/OrderWorkflow.java—@WorkflowInterfacewith thepaymentReceivedsignal method.order-service/src/main/java/id/adiputera/order/workflow/OrderWorkflowImpl.java— workflow logic; builds one activity stub per task queue viaWorkflow.newActivityStub(...)withActivityOptions.setTaskQueue(...).payment-service/src/main/java/id/adiputera/payment/api/PaymentController.java—/paymentcallsworkflowClient.newUntypedWorkflowStub("order-" + orderId).signal("paymentReceived").cms-service/src/main/java/id/adiputera/cms/api/ResetController.java—/reset-workflowcalls the low-levelWorkflowServiceStubs.blockingStub().resetWorkflowExecution(...)gRPC with aworkflowTaskFinishEventIdpointing at the desired reset point in history.cms-service/src/main/java/id/adiputera/cms/api/ListController.java—/workflowspagesListWorkflowExecutions, then for each run fetchesGetWorkflowExecutionHistoryand walks the event list to find the most recently scheduled activity plus its current state (scheduled/started/completed/failed).cms-service/src/main/java/id/adiputera/cms/api/DetailController.java—/workflows/{workflowId}combinesDescribeWorkflowExecution(identity, status, pending activities) with a full history walk to assemble input/result/failure payloads, signal/activity/timer timelines, and reset lineage.common/src/main/java/id/adiputera/common/workflow/ConsignmentWorkflow.java—@WorkflowInterfacefor the child workflow with two signal methods (shipmentDispatched,customerConfirmed).consignment-service/src/main/java/id/adiputera/consignment/workflow/ConsignmentWorkflowImpl.java— child workflow logic: indefinite await for shipment →Workflow.sleep(2m)→checkShippingactivity → 1-minute await for customer confirmation → return (parent resumes).order-service/.../OrderWorkflowImpl.java#runConsignment— parent starts the child viaWorkflow.newChildWorkflowStub(ConsignmentWorkflow.class, ChildWorkflowOptions.setTaskQueue(CONSIGNMENT_TQ))and callsstartConsignment(orderId)synchronously; the blocking call returns only after the child completes. Return value isConsignmentResult—UNDELIVEREDtriggers the compensation path (refundPayment+sendOrderCancelledNotification).cms-service/src/main/java/id/adiputera/cms/api/LifecycleController.java—POST /cancelandPOST /terminateusingWorkflowStub.cancel()/WorkflowStub.terminate(reason). Both cascade to children.cms-service/src/main/java/id/adiputera/cms/api/QueryController.java—GET /workflows/{id}/query?name=...invokes@QueryMethodhandlers viaWorkflowStub.query(name, String.class).cms-service/src/main/java/id/adiputera/cms/api/TriggerController.java—POST /trigger-workflowis a generic launcher; takes{id, workflowType, taskQueue, input}and forwards toworkflowClient.newUntypedWorkflowStub(workflowType, WorkflowOptions).start(input). No per-type code — a new workflow type added elsewhere in the project works here without edits.common/src/main/java/id/adiputera/common/dto/ConsignmentResult.java— enum{ DELIVERED, UNDELIVERED }returned by the child and inspected by the parent to branch into compensation.- Flaky activities:
PaymentActivitiesImpl.checkPaymentandShippingActivitiesImpl.checkShippingthrowApplicationFailureonActivity.getExecutionContext().getInfo().getAttempt() == 1; the workflow stubs attach aRetryOptions.setMaximumAttempts(3).setInitialInterval(2s)retry policy.
- Workflow id convention is
order-{orderId}.POST /paymentwith an unknown id returns a TemporalNotFounderror. orderIdmust be numeric (Long.parseLongis used for parity); non-numeric ids will throw.- Temporal UI at
http://localhost:8080shows full history — handy to inspect theTimerFiredvsWorkflowSignaledbranch. For/reset-workflowyou usually don't need it: every activity inGET /workflows/{id}carries aresetEventId(the previousWorkflowTaskCompletedevent id) — pass that value to roll back to before that activity was scheduled. /reset-workflowmust be given aWorkflowTaskCompletedevent id; other event types are rejected by Temporal withInvalidArgument. The detail endpoint'sresetEventIdfield is always such an event (ornullfor the very first task). In this sample,CheckPayment.resetEventIdrewinds to "about to wait for signal" without re-sending the order-placed email;SendOrderPlacedNotification.resetEventIdisnullbecause there is no preceding task close — to rewind all the way to "start" and re-send the email, look up event 4 in the UI.- Activities are local no-op logs; no email or payment gateway is actually called.