Build a workflow engine (Temporal / Airflow / Cadence style) (13 scenes)
Scene 04 · Activities: quarantine for side effects
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.
Previously
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
Diagram
Code is colored by where it runs: BLUE = workflow code, which is replayed and must be deterministic — it only orchestrates. RED = activity, which runs exactly once and whose result is recorded in history. On replay the engine re-runs the blue code but hands back the recorded result for any red activity instead of re-doing it — so ChargeCard, as an activity, is never re-charged. Your blue code issues a 'command' (schedule this activity); the engine writes back an 'event' (the activity completed with this result). This blue/red palette never changes for the rest of the course.
red = activity: runs once, result recorded in history
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.
Implementation
OrderWorkflow.run (blue, replayed)
where ChargeCard is written decides if it re-runs on replay
1def order_workflow(order):2 # INLINE: a real charge written in replayed code.3 paymentApi.charge(order.card, order.total) # re-runs!4 # ACTIVITY: issue a command, the engine records the result.5 result = schedule_activity(ChargeCard, order)6 schedule_activity(ReserveInventory, order)7 schedule_activity(ShipPackage, order)8 schedule_activity(SendConfirmationEmail, order)
Engine.replay
re-runs blue code, but hands back recorded activity results
1def schedule_activity(activity, args):2 # On replay, look for a recorded result first.3 if replaying and history.has_result(activity):4 return history.result_of(activity) # hand back, skip5 # No recorded result: command + run for real.6 history.append(ActivityScheduled(activity))7 result = run_activity(activity, args)8 history.append(ActivityCompleted(activity, result))9 return result
ChargeCard (red activity)
the side effect runs once; its result becomes an event
1def charge_card(order):2 # The only place the money actually moves.3 receipt = paymentApi.charge(order.card, order.total)4 # caller records ActivityCompleted(ok, $42) in history5 return {status: 'ok', amount: order.total}
Not sure what to ask? Tap a question — the staff engineer answers in the chat panel.