Activities: quarantine for side effects — commands, events, and the activity boundary
An activity is the boundary where every side effect lives, because only there can the engine record the result into history and, on replay, hand that result back instead of re-running the effect — so charging the card is replay-safe only if it's an activity.
Replay needed the side effects out of the deterministic code, so we moved each one into an activity whose result is recorded — and ChargeCard stopped re-charging on replay. But all of this runs inside some process, and a process is exactly what crashes or gets redeployed. What runs ORDER #1001's code, and how does it survive you killing every server to ship v2?
Scene 04
Activities: quarantine for side effects
- Watch
- Try it
- Predict
- Capture
Replay re-runs your code, so the one thing it must never re-run is a side effect like charging the card. The fix is to pull every side effect out of the deterministic code and into a special boundary the engine treats differently. Here is ORDER #1001's code, colored by WHERE each step runs. BLUE is workflow code — it's replayed, so it only orchestrates. ChargeCard is the red step: it does the real, money-moving work, it runs exactly once, and its result is recorded into history as an event. That red, runs-once-and-its-result-is-recorded boundary is called an activity — the one place a side effect is allowed to happen, precisely because it's the one place the result can be durably saved and skipped on replay. Watch ChargeCard run once and append 'Completed(ChargeCard, ok · $42)' to the strip. Then watch replay after the crash: instead of re-charging, the engine hands your code that recorded result back. Notice the two-way motif crossing the middle — your blue code issues a command ('schedule ChargeCard'), and the engine records the resulting event ('ChargeCard completed'); commands are requests your code makes, events are the recorded facts the engine writes down.
Highlighted lines are the ones running in the diagram right now.
def order_workflow(order):# INLINE: a real charge written in replayed code.paymentApi.charge(order.card, order.total) # re-runs!# ACTIVITY: issue a command, the engine records the result.result = schedule_activity(ChargeCard, order)schedule_activity(ReserveInventory, order)schedule_activity(ShipPackage, order)schedule_activity(SendConfirmationEmail, order)
def schedule_activity(activity, args):# On replay, look for a recorded result first.if replaying and history.has_result(activity):return history.result_of(activity) # hand back, skip# No recorded result: command + run for real.history.append(ActivityScheduled(activity))result = run_activity(activity, args)history.append(ActivityCompleted(activity, result))return result
def charge_card(order):# The only place the money actually moves.receipt = paymentApi.charge(order.card, order.total)# caller records ActivityCompleted(ok, $42) in historyreturn {status: 'ok', amount: order.total}
Where this sits in Build a workflow engine (Temporal / Airflow / Cadence style)
Scene 04 of 13, in the Replay act — Rebuild state by replay; quarantine in activities.. Replay re-runs workflow code, so every side effect must move into an activity whose result is recorded — replay hands the result back instead of charging again.
Up next. Activities and replay live inside a process you will eventually redeploy. The trick that makes 'kill every server to deploy' safe is to never push work into a worker at all — instead the engine puts tasks on a queue and stateless workers pull them. So when there's no worker for a moment, the workflow simply waits, it isn't lost.
All 13 scenes in Build a workflow engine (Temporal / Airflow / Cadence style) · Every curriculum