A hands-on starter lab for learning Temporal with Java. You build one workflow, one small step at a time, across four labs — starting from your very first activity and ending with a determinism/versioning exercise.
Everything is debug-ready: breakpoints, a debug launch config, and a generous workflow-task timeout so you can sit on a breakpoint without anything timing out.
| Module | What it is |
|---|---|
starter/ |
Your working copy. The scaffolding is in place; you fill in the // TODO Lab N bodies. |
solution/ |
The finished, runnable reference. Peek when you're stuck, or run it to see the target behavior. |
Both modules share the same package (foundations) and the same file names, so the only
difference is the code you write.
By the end, MathWorkflow computes:
(2 × (a + b) − submittedValue)²
For example, a=3, b=4 and a submitted signal value of 5 gives
(2×(3+4) − 5)² = (14 − 5)² = 9² = 81.
The labs come in two sessions with a break in between:
Part 1 — build the pipeline (Labs 1–3): finish at 2 × (a + b) − submittedValue.
| Lab | You add | Concept |
|---|---|---|
| 1 | An add(a, b) activity + a workflow that calls it |
Your first activity & workflow, input structs |
| 2 | A doubleValue activity, chained onto the sum |
Chaining activities; each is a durable checkpoint |
| 3 | A submit(int) signal, then subtract it |
Signals & Workflow.await (parking a workflow) |
☕ Break.
Part 2 — event history & determinism (Labs 4–5): understand the durable log, then evolve the code safely.
| Lab | You do | Concept |
|---|---|---|
| 4 | Read the event history of what you built | Event history: the durable log; replay |
| 5 | Add a square activity, introduced safely |
Determinism, replay, and Workflow.getVersion |
flowchart TD
Start(["Start: MathInput a, b"]) --> Add["Activity: add(a, b)<br/>sum = a + b"]
Add --> Double["Activity: doubleValue(sum)<br/>doubled = 2 × (a + b)"]
Double --> Await{"Workflow.await<br/>submit(int) signal?"}
Signal[["Signal: submit(value)"]] -. delivers value .-> Await
Await --> Sub["result = doubled − value<br/>= 2×(a+b) − value"]
Sub --> Ver{"getVersion<br/>"add-square-step""}
Ver -->|DEFAULT_VERSION<br/>executions started before the square change| RetR([return result])
Ver -->|version 1<br/>executions started after the square change| Square["Activity: square(result)<br/>squared = result²"]
Square --> RetS(["return squared<br/>= (2×(a+b) − value)²"])
Part 1 builds everything up to
return result(2×(a+b) − value). Part 2 adds thegetVersionbranch and thesquareactivity — safely, without breaking the executions you started in Part 1.
- A JDK, version 17 or newer. Check with
java -version. On macOS:brew install openjdk@21. - A running Temporal debug server — follow
PREREQUISITES.md(한국어) once to build and start it.
Why the debug server, not
temporal server start-dev? Lab 5's determinism exercise (and holding breakpoints in workflow code) needs a server whose internal timeouts are relaxed ×100 so it won't give up on a paused worker.PREREQUISITES.mdbuilds exactly that, plustdbg— the tool thescripts/history.sh/scripts/describe.shhelpers use to decode raw history from the database. Do that setup first.
You do not need to install Gradle — the repo ships a Gradle wrapper (./gradlew)
that downloads the pinned Gradle version on first use. Always use ./gradlew, never a
system gradle.
temporal-java-foundations/
├── README.md / README_ko_kr.md # this guide (EN / KO)
├── PREREQUISITES.md / PREREQUISITES_ko_kr.md # one-time server setup (EN / KO)
├── build.gradle # shared config for both modules
├── settings.gradle # includes :starter and :solution
├── gradlew / gradle/ # Gradle wrapper (no install needed)
├── .vscode/launch.json # debug + run configs for both modules
├── scripts/ # bash helpers (run-worker, start, signal, history, ...)
├── starter/
│ └── src/main/java/foundations/
│ ├── MathInput / DoubleInput / SquareInput .java # input structs (given)
│ ├── MathActivities.java # @ActivityInterface (given)
│ ├── MathActivitiesImpl.java # ← YOU fill in the bodies
│ ├── MathWorkflow.java # @WorkflowInterface (given)
│ ├── MathWorkflowImpl.java # ← YOU build this up across the labs
│ ├── WorkerApp.java # hosts the code, starts the worker (given)
│ ├── Starter.java # starts one workflow (given)
│ └── SignalSender.java # sends the submit(int) signal (given)
└── solution/ # same files, fully implemented
Complete PREREQUISITES.md once. Afterward you should have, running
in their own terminals: the Docker database stack (make start-dependencies) and the
debug server (./temporal-server-debug ... start) on localhost:7233, with the
Web UI on http://localhost:8080. Keep the UI open — it's where you'll watch event
history, signals, and failures.
You need two processes: the worker (runs your workflow/activity code) and a starter (kicks off one workflow). Lab 3 adds a third step: sending a signal.
The scripts/ helpers default to the starter module; prefix MODULE=solution to use
the reference module instead. See Helper scripts for the full list.
# Terminal 1 — the worker (Ctrl-C to stop, restart after code changes):
./scripts/run-worker.sh
# Terminal 2 — start a workflow with a=3, b=4:
./scripts/start.sh 3 4
# Terminal 2 — (Lab 3+) send the submit signal with value=5:
./scripts/signal.sh 5
# ...then check the result:
./scripts/result.shReplace <module> with starter or solution.
./gradlew :<module>:run # Terminal 1 — worker
./gradlew :<module>:runStarter --args="3 4" # Terminal 2 — start
./gradlew :<module>:runSignal --args="5" # Terminal 2 — signal (Lab 3+)
temporal workflow result --workflow-id math-wf # resultThe repo includes .vscode/launch.json. From the Run and Debug panel:
- Run “Worker (debug) —
<module>”. It setsTEMPORAL_DEBUG=trueso breakpoints in workflow code don't trip the deadlock detector. - Run “Start —
<module>” to kick off a workflow. - (Lab 3+) Run “Signal —
<module>” to send the signal.
Set breakpoints on the >>> BREAKPOINT <<< markers in the code.
Work in the starter module. After each lab, restart the worker (it holds your
compiled code in memory) and run a workflow to check your result. Part 1 finishes with the
workflow computing 2 × (a + b) − submittedValue — then you take a break.
Goal: add(a, b) returns a + b.
- In
MathActivitiesImpl.java, implementaddto returnin.a + in.b. - In
MathWorkflowImpl.java, inrun(...), callactivities.add(in)and return it. - Restart the worker, then:
For now the workflow returns as soon as
./scripts/start.sh 3 4
runreturns — no signal yet. Check the result is7:./scripts/result.sh
Why the struct?
MathInputcarriesaandbas one JSON-serializable payload. Temporal serializes workflow/activity arguments, so they need public fields and a no-arg constructor.
Goal: double the sum → 2 × (a + b).
- In
MathActivitiesImpl.java, implementdoubleValueto returnin.value * 2. - In
run(...), feed the sum intoactivities.doubleValue(new DoubleInput(sum))and return the doubled value. - Restart the worker;
a=3, b=4should now give14.
Durable checkpoints. Each activity result is written to event history. If the worker crashes and restarts, Temporal replays the workflow and hands back the recorded results instead of re-running the activities. Watch the two
ActivityTaskCompletedevents appear in the Web UI.
Goal: wait for submit(int), then compute 2 × (a + b) − value.
- In
MathWorkflowImpl.java, add signal state (submittedflag +submittedValue) and fill in thesubmit(int)handler to record them. - In
run(...), after doubling, park until the signal arrives, then subtract:Workflow.await(Duration.ofHours(1), () -> submitted); int result = doubled - submittedValue; return result;
- Restart the worker, then:
./scripts/start.sh 3 4 # starts, then PARKS (Running in the Web UI) ./scripts/signal.sh 5 # deliver value=5 ./scripts/result.sh # -> 2×(3+4) − 5 = 9
Parking a workflow.
Workflow.awaitsuspends the workflow — durably — until its condition becomes true. The workflow uses no resources while parked; it could wait for seconds or months. Keep theDurationtimeout onawait: it records aTimerStartedevent in history, which matters in Part 2 (Lab 5).
You've built a durable workflow that runs two activities, parks on a signal, and returns
2 × (a + b) − submittedValue. Good place to stop.
Before the break, leave one workflow parked — you'll inspect it in Lab 4:
./scripts/reset.sh # clear any earlier run
./scripts/start.sh 3 4 # starts and PARKS on the signal (do NOT signal it)When you come back, keep the DB stack and debug server running (Part 2 needs the same environment).
Part 1 treated Temporal as a black box that "just runs" your code durably. Part 2 opens the box: you'll read the event history that makes it durable (Lab 4), then use that understanding to evolve the workflow safely while executions are in flight (Lab 5).
Goal: understand the durable log behind the workflow you built in Part 1. No code changes — this lab is all inspection.
Every workflow is backed by an append-only event history. The worker holds no durable state of its own; it rebuilds everything by replaying that history. We'll read it all in the Web UI. Let's look at the one you left parked before the break.
-
Open the Web UI at http://localhost:8080 → namespace
default→ workflowmath-wf. The top shows it's Running (parked on your signal). -
Open the History panel and walk the timeline. Match each event to your code:
Event Comes from WorkflowExecutionStartedstart.sh(carries theMathInput)ActivityTaskScheduled → Started → Completed(×2)add, thendoubleValueTimerStartedthe Workflow.await(Duration…)park(nothing after the timer yet) it's waiting for your signal Click the
ActivityTaskScheduled/Completedevents to expand the Input and Result payloads — you'll see{a:3, b:4}go in and7, then14, come back. -
Now signal it and watch the history grow. Send the signal, then refresh the UI:
./scripts/signal.sh 5
New events append to the log:
WorkflowExecutionSignaled(value5) and, once the worker finishes,WorkflowExecutionCompleted(result9). Toggle the UI between the timeline and JSON / Compact views to see the raw event shapes.
Why this matters. History is the source of truth; the worker is disposable. Kill the worker mid-run and a new one replays the history to rebuild identical state, then carries on. That replay only works if your code produces the same commands in the same order every time — which is exactly the constraint Lab 5 explores.
Advanced (optional) — read the same history from the terminal (CLI / tdbg)
The Web UI is the primary tool; these are terminal equivalents for advanced users.
temporal workflow show --workflow-id math-wf # event history via the Temporal CLI
./scripts/history.sh # decoded history via tdbg
./scripts/history.sh math-wf TIMER_STARTED # filter to the await's timer
./scripts/sql.sh q2 # the raw append-only history ledgerGoal: square the result → (2 × (a + b) − value)², introduced without breaking
executions that are already running.
-
In
MathActivitiesImpl.java, implementsquareto returnin.value * in.value. -
In
run(...), replacereturn result;with the versioned square step:int version = Workflow.getVersion("add-square-step", Workflow.DEFAULT_VERSION, 1); if (version == Workflow.DEFAULT_VERSION) { return result; // executions started before this change } return activities.square(new SquareInput(result)); // new executions: ^2
-
Restart the worker, then run a fresh one:
./scripts/start.sh 3 4 && ./scripts/signal.sh 5 && ./scripts/result.sh # -> 81
In the Web UI, open this new
math-wfand find theMarkerRecordedevent (marker nameVersion) thatgetVersionwrote into history — it recordsadd-square-stepwith version1, right before thesquareactivity.Advanced (optional) — see the marker from the terminal
./scripts/history.sh math-wf MARKER_RECORDED # changeId "add-square-step", version 1
This shows why getVersion exists. Workflow code is replayed on every worker
restart, so it must be deterministic: the commands your code issues must match the
commands already recorded in history. Change the code under a running workflow the wrong
way and replay fails.
-
Start a fresh workflow and do not signal it — leave it parked on
await:./scripts/reset.sh # clear any earlier run of math-wf ./scripts/start.sh 3 4 # parks
In the Web UI, confirm
math-wfis Running and its history ends withTimerStarted. -
Stop the worker (Ctrl-C in its terminal).
-
Make a breaking change: add an extra activity call before
Workflow.await(...), with nogetVersionguard — e.g.doubled = activities.doubleValue(new DoubleInput(doubled)); // the "bad" insert Workflow.await(Duration.ofHours(1), () -> submitted);
-
Restart the worker, then send the signal:
./scripts/signal.sh 5
-
Replay reaches the point where history recorded
TimerStarted, but your new code issuesScheduleActivityTask(doubleValue)instead → non-determinism error. In the Web UI,math-wfstays Running with aWorkflowTaskFailedevent whose message mentions non-determinism, and the task keeps retrying — the workflow never completes.Advanced (optional) — see the failure from the terminal
./scripts/history.sh math-wf TASK_FAILED # the WorkflowTaskFailed events ./scripts/describe.sh # status: Running, task retrying
-
Undo the bad insert (and
./scripts/reset.shto clear the wedged workflow).
Why did adding square at the end (Part A) not break, but this did?
Replay only detects a contradiction when your code issues a command that disagrees with
one already recorded. square runs after the last recorded command (the timer), so it
just appends new history — safe. The extra activity before the timer collides with the
recorded TimerStarted — unsafe. (This is also why the await keeps its timeout: a bare
await records no command, so nothing would collide and the error would hide.)
Workflow.getVersion is the general tool for any change that isn't a safe append: it
writes a version marker into history. Executions started before the change have no
marker → getVersion returns DEFAULT_VERSION → they take the old branch and replay
cleanly. Executions started after get marker 1 → they run the new code. One codebase
serves both. Compare the histories of an old vs. a new execution in the Web UI (or, for
advanced users, ./scripts/history.sh <id> MARKER_RECORDED).
All scripts live in scripts/ and read the same defaults (workflow ID math-wf, namespace
default, server localhost:7233). They pick the starter module by default — set
MODULE=solution to target the reference module.
Core (drive the workflow):
| Script | What it does |
|---|---|
run-worker.sh |
Run the worker (TEMPORAL_DEBUG=true). MODULE=solution ./scripts/run-worker.sh for the reference. |
start.sh [a] [b] [id] |
Start one MathWorkflow (default a=3 b=4 id=math-wf). |
signal.sh [value] [id] |
Send the submit(int) signal (default value=5). |
result.sh [id] |
Print the workflow's status + result. |
reset.sh [id] |
Terminate the workflow so you can start fresh. |
Advanced / optional (inspect from the terminal — the Web UI is the primary tool). These
need the tdbg binary / Docker DB from PREREQUISITES.md; set
TEMPORAL_SRC=/path/to/temporal if your checkout isn't at ~/temporal-oss/temporal.
| Script | What it does |
|---|---|
history.sh [id] [filter] |
Decode raw event history via tdbg; optional grep filter (e.g. MARKER_RECORDED, TASK_FAILED, TIMER_STARTED). |
describe.sh [id] |
Decoded mutable state (tdbg) + high-level status. |
sql.sh [q1|q2|q3|all] [id] |
Peek at the raw MySQL tables (state, history ledger, in-flight timers). |
- Workflow breakpoints need
TEMPORAL_DEBUG=true(the “Worker (debug)” configs set this). Without it, sitting on a breakpoint in workflow code trips the SDK's deadlock detector. - Activity breakpoints just work — activities are ordinary code.
- The starter/
Startersets a 15-minute workflow-task timeout, so you can hold a breakpoint in workflow code for a long time without the task timing out.
| Symptom | Fix |
|---|---|
Connection refused / UNAVAILABLE on start |
The debug server / DB stack isn't running — see PREREQUISITES.md (make start-dependencies + ./temporal-server-debug ... start). |
./gradlew: Permission denied |
chmod +x gradlew |
Advanced scripts can't find tdbg |
tdbg is optional — build it (cd $TEMPORAL_SRC && make tdbg) or set export TEMPORAL_SRC=/path/to/your/temporal. |
UnsupportedOperationException: TODO Lab N |
That step isn't implemented yet — that's expected in starter until you fill it in. |
Unsupported class file major version / Gradle won't start |
Your java -version is older than 17 — install JDK 17+. |
| Non-determinism error you didn't intend | You changed workflow code while an execution was running. Start a fresh workflow, or guard the change with Workflow.getVersion. |
WARNING: sun.misc.Unsafe... at startup |
Harmless — it's the gRPC/Netty library on newer JDKs, not your code. |
VSCode shows red squiggles but ./gradlew build works |
Command Palette → “Java: Clean Language Server Workspace”, then reload. |
- Task queue:
foundations-java· Workflow ID:math-wf· Namespace:default - Web UI: http://localhost:8080
- Build everything:
./gradlew build - Send a signal via the CLI instead of
SignalSender/signal.sh:temporal workflow signal --workflow-id math-wf --name submit --input 5
- Advanced: decode raw history via
tdbg(whathistory.shwraps):~/temporal-oss/temporal/tdbg -n default execution show --workflow-id math-wf --decode