- Posts
- What did this order look like last Tuesday?
What did this order look like last Tuesday?
A customer writes in. Their parcel went to their old address. They say they changed it before the label printed. Support looks at the order and sees the new address. The warehouse says it printed what the system gave it.
Both can be right, and a row that holds only the current address cannot tell you how. You need two answers about one moment in the past. What did the order say when the label printed? And what did the shipping run know at that time?
In Ironflow, an order kept as an entity stream answers the first question. A recorded run answers the second. This walkthrough replays a small version of the incident on a local engine, then rewinds both histories with the Python SDK. It takes about five minutes, most of it a two-minute wait. “Last Tuesday” is only a timestamp. The same calls take any time you give them.
Start the engine and install the SDK
Section titled “Start the engine and install the SDK”Use Ironflow v0.43.1 and Python 3.10 or newer. The installation guide has the CLI downloads. The commands below use fish. In your first terminal, create a directory and start the local engine:
mkdir order-rewindcd order-rewindironflow versionironflow serve --dev --host 127.0.0.1Keep the engine running for the whole walkthrough. It keeps its data in
.ironflow inside this directory and serves at http://localhost:9123. --dev bypasses
authentication, and the loopback host keeps this instance local.
Open a second terminal in the directory where you ran mkdir. Enter the
project and install the matching SDK:
cd order-rewindpython3 -m venv .venvsource .venv/bin/activate.fishpython -m pip install ironflow-py==0.43.1set -gx IRONFLOW_SERVER_URL http://localhost:9123The distribution is ironflow-py. The import is ironflow.
Fold an order’s history
Section titled “Fold an order’s history”An entity stream keeps every event for one order, in order, with a version number on each. The order’s state is not stored anywhere as a row. You get it by folding the events from the first one forward. To see the order at an earlier time, fold only the events up to that time.
Save this as history.py:
from ironflow.rpc import v1
def order_at(rpc, order_id, at=None): """Fold an order's events into its state, using only events up to `at`.""" state, version, cursor = {}, 0, 0 while True: page = rpc.streams.get_history( v1.GetEntityHistoryRequest( entity_id=order_id, from_version=cursor, to_timestamp=at ) ) for entry in page.entries: state.update(entry.event_data.to_python()) version = entry.entity_version if not page.next_cursor: return {**state, "version": version} cursor = page.next_cursorto_timestamp tells the engine to stop at at. With no at, you get the
order as it is now. The engine returns history a page at a time, and
next_cursor is the version where the next page starts. The fold here is a
plain dict.update, because each event in this demo carries the fields it
changes. A real order would use a reducer with one branch for each event name.
Ship the order with a recorded run
Section titled “Ship the order with a recorded run”The shipping function reads the order, waits for the courier’s pickup window, then prints the label. Labels print in batches, so a gap between the read and the print is normal. That gap is where this incident happens.
Save this as worker.py:
import asyncioimport os
from ironflow import IronflowRPCfrom ironflow.worker import Worker, function
from history import order_at
def read_order(order_id): with IronflowRPC(server_url=os.environ["IRONFLOW_SERVER_URL"]) as rpc: return order_at(rpc, order_id)
@function( id="ship-order", triggers=[{"event": "order.ready_to_ship"}], recording=True, recording_profile="all",)async def ship_order(ctx): order_id = ctx.event.data["order_id"]
async def read(): order = await asyncio.to_thread(read_order, order_id) print(f"Read {order_id} at version {order['version']}", flush=True) return order
order = await ctx.step.run("read-order", read) await ctx.step.sleep("pickup-window", "2m")
async def print_label(): print(f"Label printed for {order['address']}", flush=True) return {"ship_to": order["address"], "read_at_version": order["version"]}
return await ctx.step.run("print-label", print_label)
if __name__ == "__main__": Worker(functions=[ship_order]).run()python worker.pyrecording=True is the line that makes the second question answerable.
Recording is off by default. Without it, Ironflow still saves each step’s
result so a resumed run can skip finished steps, but it writes no audit events.
Time travel rebuilds a run from those audit events, so an unrecorded run has
nothing to rewind. recording_profile="all" is what recording=True selects
when you leave the profile out. It is written here because time travel refuses
a narrower profile such as "steps" with this error:
recording_profile=all is required for function "ship-order".
read_order uses the synchronous client, so asyncio.to_thread keeps it off
the worker’s event loop. ctx.step.sleep hands the run back to the engine for
two minutes. The worker does not hold the run while it waits.
Replay the incident
Section titled “Replay the incident”Open a third terminal in that same starting directory, and point it at the engine:
cd order-rewindsource .venv/bin/activate.fishset -gx IRONFLOW_SERVER_URL http://localhost:9123ironflow function listWait until ship-order appears in the list. Then place the order:
ironflow stream append ord_1042 --type Order --event order.placed \ --data '{"customer": "Dana", "address": "12 Elm St, Springfield"}' \ --expected-version 0--expected-version 0 means “this stream must be new”. The CLI reports
version 1. Now send the order to shipping, and keep the run ID:
set run_id (ironflow emit order.ready_to_ship --data '{"order_id": "ord_1042"}' --json | python -c "import json, sys; print(json.load(sys.stdin)['runs'][0]['run_id'])")echo $run_idIn the second terminal, the worker prints:
Read ord_1042 at version 1Wait for that line. Then, within two minutes, be the customer and change the address:
ironflow stream append ord_1042 --type Order --event order.address_changed \ --data '{"address": "48 Oak Ave, Shelbyville"}' --expected-version 1The CLI reports version 2. When the pickup window ends, the worker prints:
Label printed for 12 Elm St, SpringfieldThat is the incident. The customer changed the address before the label printed, and the label still went to Elm St. Check that the run finished:
ironflow run get $run_idIts status is completed.
Rewind both histories
Section titled “Rewind both histories”The run’s own timeline gives you the two moments that matter: when it read the order, and when it printed the label. This script finds both, then asks each history what it said at that time.
Save this as rewind.py:
import jsonimport osimport sys
from ironflow import IronflowRPCfrom ironflow.rpc import v1
from history import order_at
run_id, order_id = sys.argv[1], sys.argv[2]
def step_name(step_id): # Step IDs have the form "<run id>:<step name>:<occurrence>". return step_id.split(":")[-2]
with IronflowRPC(server_url=os.environ["IRONFLOW_SERVER_URL"]) as rpc: timeline = rpc.time_travel.get_run_timeline(v1.GetRunTimelineRequest(run_id=run_id)) completed = { step_name(event.step_id): event.timestamp for event in timeline.events if event.event_type == "step.completed" }
for name in ("read-order", "print-label"): at = completed[name] snapshot = rpc.time_travel.get_run_state_at( v1.GetRunStateAtRequest(run_id=run_id, timestamp=at) ).snapshot steps = {step_name(step.step_id): step for step in snapshot.steps} order = order_at(rpc, order_id, at) print(f"{at.to_datetime():%Y-%m-%dT%H:%M:%SZ} {name} completed") print(f" run had: {json.loads(steps[name].output)}") print(f" order was: version {order['version']}, {order['address']}")python rewind.py $run_id ord_1042Your times will differ. The rest matches:
2026-10-12T20:38:43Z read-order completed run had: {'address': '12 Elm St, Springfield', 'customer': 'Dana', 'version': 1} order was: version 1, 12 Elm St, Springfield2026-10-12T20:40:43Z print-label completed run had: {'read_at_version': 1, 'ship_to': '12 Elm St, Springfield'} order was: version 2, 48 Oak Ave, Shelbyvilleget_run_state_at rebuilds the run as it was at a timestamp, step outputs
included. order_at folds the order up to the same timestamp. Put side by
side, the two histories settle the ticket.
At the first moment they agree. The run read version 1, and version 1 was the
order. At the second moment they disagree. The order was at version 2 with the
new address, and the label used the version 1 read from two minutes earlier.
read_at_version: 1 in the label’s output says so directly. The customer is
right, and so is the warehouse. The defect is the gap between the read and the
print.
The time travel calls do not depend on this run being recent. To ask about
last Tuesday, pass that time as a Timestamp instead of a time from the run’s
timeline. Timestamp.from_datetime from protobuf.wkt builds one from a
timezone-aware datetime. Give it the zone. A naive datetime is read as
local time without any warning.
Look at the same run from the CLI
Section titled “Look at the same run from the CLI”The audit events behind the rewind are also visible without any code:
ironflow audit trail $run_idThe trail lists run.created, a step.started and step.completed pair for
each invoke step, a step.started for pickup-window, and
run.status_changed when the run went to waiting for the pickup window and
again when it completed. For each completed step,
the PAYLOAD column holds its output, cut short to fit the terminal.
For an interactive view, open the run at a moment inside the pickup window.
Take the two times rewind.py printed, and set at to a time between them.
For the output above, 2026-10-12T20:39:43Z would do:
set at REPLACE_WITH_A_TIME_BETWEEN_YOUR_TWO_TIMESironflow inspect $run_id --at $atThe terminal UI marks read-order as completed, pickup-window as running,
and print-label as not started yet. The step list shows step IDs, which
start with the run ID, so use j and k to move through the steps and read
each full name and output in the details panel. Press q to quit.
When you are done, stop the worker and the engine with Ctrl+C.
What is kept, and for how long
Section titled “What is kept, and for how long”The two histories come from different places, and they do not follow the same rules.
The order’s events are the order. An entity stream stores every event you append, and that is how the order has a state at all. Nothing needs to be switched on, and scheduled retention does not remove a live stream’s history. Purging a stream does delete its events. What does “delete” mean in an event-sourced system? covers what each delete option keeps.
The run’s history exists only because ship-order opted into recording. Runs
of a function that does not record cannot be rewound, and neither can runs
that finished before you turned recording on. Audit events are kept for 90
days by default. IRONFLOW_AUDIT_RETENTION_DAYS changes that. Last Tuesday is
well inside the default. Last year is not.
Fix the stale read
Section titled “Fix the stale read”The rewind points at the line to change. print-label trusts a read that is
two minutes old. There are two direct fixes.
Read the order again inside the step that prints, so the label uses the order as it is at print time.
Or make the print conditional on the version the run read. Append an
order.label_printed event to the order’s stream with expected_version set
to read_at_version. If the order changed in between, the engine rejects the
append with a version conflict, and the run can read the order again before
it prints. That is the same check --expected-version 1 made when you changed the
address.
Either way, the next ticket like this one takes one script to answer.
The time-travel guide covers the dashboard’s timeline scrubber and the Node and Go clients. Event sourcing covers snapshots, upcasters, and projections built from the same streams. The Python SDK reference lists the worker options used here.