Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

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.

One moment in two histories The order stream gets version 1 with 12 Elm St, then version 2 with 48 Oak Ave. The shipping run reads the order at T1, while it is still version 1. It waits for a two-minute pickup window, then prints the label at T2. At T2 the order is version 2, but the label uses the Elm St address the run read at T1. Order streamShipping runv1 placedv2 address changedT1T2read-orderprint-labelpickup-window, 2 minutes 12 Elm St48 Oak Aveorder is v2saw v1, Elm Stships to Elm St

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:

Terminal window
mkdir order-rewind
cd order-rewind
ironflow version
ironflow serve --dev --host 127.0.0.1

Keep 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:

Terminal window
cd order-rewind
python3 -m venv .venv
source .venv/bin/activate.fish
python -m pip install ironflow-py==0.43.1
set -gx IRONFLOW_SERVER_URL http://localhost:9123

The distribution is ironflow-py. The import is ironflow.

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_cursor

to_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.

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 asyncio
import os
from ironflow import IronflowRPC
from 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()
Terminal window
python worker.py

recording=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.

Open a third terminal in that same starting directory, and point it at the engine:

Terminal window
cd order-rewind
source .venv/bin/activate.fish
set -gx IRONFLOW_SERVER_URL http://localhost:9123
ironflow function list

Wait until ship-order appears in the list. Then place the order:

Terminal window
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:

Terminal window
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_id

In the second terminal, the worker prints:

Read ord_1042 at version 1

Wait for that line. Then, within two minutes, be the customer and change the address:

Terminal window
ironflow stream append ord_1042 --type Order --event order.address_changed \
--data '{"address": "48 Oak Ave, Shelbyville"}' --expected-version 1

The CLI reports version 2. When the pickup window ends, the worker prints:

Label printed for 12 Elm St, Springfield

That 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:

Terminal window
ironflow run get $run_id

Its status is completed.

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 json
import os
import sys
from ironflow import IronflowRPC
from 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']}")
Terminal window
python rewind.py $run_id ord_1042

Your 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, Springfield
2026-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, Shelbyville

get_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.

The audit events behind the rewind are also visible without any code:

Terminal window
ironflow audit trail $run_id

The 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:

Terminal window
set at REPLACE_WITH_A_TIME_BETWEEN_YOUR_TWO_TIMES
ironflow inspect $run_id --at $at

The 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.

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.

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.