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.

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

  1. Watch
  2. Try it
  3. Predict
  4. Capture
ORDER #1001 — where does each step RUN?ChargeCard: an ACTIVITY (red)WORKFLOW CODE · fulfill(order)ChargeCard $42activity · runs once, result recordedReserveInventory 1 x Widgetworkflow · replayed, deterministicShipPackageworkflow · replayed, deterministicSendConfirmationE…workflow · replayed, deterministiccommand: scheduleevent: completedEVENT HISTORY (append-only) →WorkflowStart…Scheduled(Cha…Completed(Cha…ok · $42Scheduled(Res…CUSTOMER STATEMENT$42charged exactly onceblue = workflow code (replayed, deterministic)red = activity (runs once, recorded)ChargeCard as a red activity: replay hands back the recorded result instead of charging again.
red = activity: runs once, result recorded in history
What to watch for

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.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

OrderWorkflow.run (blue, replayed)
where ChargeCard is written decides if it re-runs on replay
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)
Engine.replay
re-runs blue code, but hands back recorded activity results
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
ChargeCard (red activity)
the side effect runs once; its result becomes an event
def charge_card(order):
# The only place the money actually moves.
receipt = paymentApi.charge(order.card, order.total)
# caller records ActivityCompleted(ok, $42) in history
return {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

Built with Arqly
Every scene in Build a workflow engine (Temporal / Airflow / Cadence style) builds on the one before it.All 13 Build a workflow engine (Temporal / Airflow / Cadence style) scenes