# ROS 2 bag recording and replay: six messages, three meanings of time

This ROS 2 Jazzy exercise records six supplied `PointStamped` messages, inspects
the saved bag, and replays it twice into fresh consumers. Both consumers receive
the same messages. The first has `use_sim_time=true` but receives no `/clock`, so
its ROS clock remains zero. The second receives the player's `/clock` and its
ROS time advances.

The messages and clock are synthetic fixtures. This exercise does not record a
robot, run SLAM, estimate motion, or measure transport performance.

## Run

Save `bag_workflow.py` and `verify.sh` together. You need Docker. The pinned
image already contains ROS, so you can run the example without installing ROS
on your host. Open a terminal in that directory:

```bash
docker pull ros:jazzy-ros-base@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca

docker run --rm --network none \
  --env ROS_DOMAIN_ID=73 \
  --env RMW_IMPLEMENTATION=rmw_fastrtps_cpp \
  --mount "type=bind,source=$(pwd),target=/lesson,readonly" \
  ros:jazzy-ros-base@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca \
  bash /lesson/verify.sh
```

The pull needs internet access if the image is not cached. The run itself uses
no network, installs nothing, mounts the source directory read-only, and removes
the temporary bag and container when finished. `verify.sh` selects domain 73
and Fast DDS, sources Jazzy, and runs the Python orchestrator. All ROS participants
run inside this one isolated container; no ROS daemon is needed.

The captured environment was Ubuntu 24.04.5 ARM64, Python 3.12.3, rosbag2 0.26.11,
and Docker Desktop on macOS ARM64. See `environment.txt` for package versions.
Other architectures have not been tested for this exercise. The run should end
with `PASS`; failures raise an exception, show subprocess logs, and exit nonzero.

## Keep the bag and inspect the evidence

Use a separate writable output mount to keep the results on your host:

```bash
mkdir -p bag-output

docker run --rm --network none \
  --env ROS_DOMAIN_ID=73 \
  --env RMW_IMPLEMENTATION=rmw_fastrtps_cpp \
  --mount "type=bind,source=$(pwd),target=/lesson,readonly" \
  --mount "type=bind,source=$(pwd)/bag-output,target=/output" \
  ros:jazzy-ros-base@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca \
  bash /lesson/verify.sh --output /output/run
```

Keep each run in a new directory. If `/output/run` already exists, the script
exits before recording; change the final argument to `/output/run-2` to preserve
both runs for comparison. Results from the first run appear in `bag-output/run`:

| File | What to inspect |
| --- | --- |
| `fixture_bag/metadata.yaml` | Topic/type, six-message count, storage, and duration |
| `fixture_bag/*.mcap` | The actual serialized recording |
| `bag-info.txt` | Full output from the real `ros2 bag info` command |
| `report.json` | Expected fixture, stored data, and both received message sequences |
| `replay-without-clock.json` | Every callback's header and zero consumer clock |
| `replay-with-clock.json` | Every callback's preserved header and advancing consumer clock |
| `record.log`, `play-*.log` | Recorder and player diagnostics |

To run `info` again on the retained bag, with no publisher or consumer running:

```bash
docker run --rm --network none \
  --mount "type=bind,source=$(pwd)/bag-output,target=/output,readonly" \
  ros:jazzy-ros-base@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca \
  ros2 bag info /output/run/fixture_bag
```

## Supplied fixture

The topic is `/bag_demo/point`, with type `geometry_msgs/msg/PointStamped`.
Every message uses frame `map`. For index `i = 0..5`, the header stamp is
`100 + i` seconds and the point is `(0.5i, -0.25i, 0)` metres.
The first point is zero; the last is `(2.5, -1.25, 0)` at 105 seconds.
No sensor produced these positions.

Only the point topic enters the bag. A separate `/clock` publisher holds each
supplied time while the recorder receives the corresponding point. Reading the
saved bag verifies that both timestamp sequences are 100 through 105 seconds.
Their equality belongs to this fixture: rosbag does not extract recording time
from `PointStamped.header.stamp`.

Initialize the clock first. The recorder waits for `/clock` before creating its
data subscription, so waiting for that subscription before publishing any clock
would stall the exercise. The source's timer uses its default system clock and
can run while simulated time is still uninitialized.

## Commands the orchestrator actually runs

The printed commands replace the temporary absolute bag path with `fixture_bag`:

```bash
ros2 bag record --storage mcap --output fixture_bag --use-sim-time \
  --topics /bag_demo/point --disable-keyboard-controls

ros2 bag info fixture_bag

ros2 bag play fixture_bag --start-paused --disable-keyboard-controls \
  --wait-for-all-acked 3000

ros2 bag play fixture_bag --start-paused --disable-keyboard-controls \
  --wait-for-all-acked 3000 --clock 20
```

The script coordinates these commands. Running them alone would omit the
source and consumers that publish the supplied clock, send each point, and
check the messages after discovery. It stops recording with SIGINT so the bag is finalized. Each
player starts paused; the script observes its publisher and resume service,
allows discovery to settle, and calls `/rosbag2_player/resume` using
`rosbag2_interfaces/srv/Resume`. In the repaired run it also waits for the paused
player to initialize the consumer's clock to 100 seconds before resuming.

Messages use reliable QoS with depth 10. The recorder acknowledges each published
sample; the player gets up to 3000 milliseconds to drain reliable acknowledgments
before exiting. Discovery waits and acknowledgments alone do not prove the
exercise's result: the saved and received sequences are checked afterward.

If a timeout depends on the simulated clock, the missing-clock case can wait
forever; this script uses monotonic elapsed time so it can report a stalled
exercise even when ROS time stays at zero. Most waits allow 15 seconds. Each
finite replay gets 20 seconds. These bounds detect a stalled exercise without
claiming a latency benchmark or a real-time guarantee.

## Three different timestamps

| Value | Owner | What the exercise verifies |
| --- | --- | --- |
| `message.header.stamp` | The source publisher | Exactly 100 through 105, unchanged in storage and both replays |
| Recording timestamp | rosbag2 recorder | Exactly 100 through 105 under this supplied simulation clock |
| `consumer.get_clock().now()` | The consumer's active ROS clock | Zero without `/clock`; starts at 100 and advances with replay clock |

`/clock` is deliberately absent from the bag. The source is destroyed before
either replay. Thus the first consumer has no time source despite receiving
all six messages. A stamped message does not update a node's ROS clock. Zero
here means uninitialized ROS time, not a message timestamp or a lost sample.

The repaired player requests 20 clock updates per second. Playback speed stays
at its default rate of one; the number after `--clock` controls publication
frequency. Its periodic clock and the point
messages travel through separate callbacks: a callback's `now()` need not equal
the message header. It can even be slightly beyond 105 seconds when the final
point arrives. `report.json` retains those values instead of rounding them into
an artificial equality. Monotonic progress is checked only within this one
uninterrupted replay; loops or seeking can move simulation time backward.

The checks cover 18 payloads: six saved messages and six received in each
replay. Every row must have the expected order, coordinates, frame, and header
stamp. The bag metadata must also agree, and the recording timestamps must
match the supplied clock values even though those timestamps live outside
the serialized point message. These checks do not establish the accuracy of a real sensor or the
completeness of a different recording.

## Try next

1. Open the retained `report.json`. Compare the last `header_ns` and
   `consumer_now_ns` in each replay. Explain why the zero clock is wrong for a
   simulation-time consumer even though every payload arrived.
2. Change `sample()` so header stamps lag the supplied clock by two seconds.
   Before rerunning, update the independent expected recording timestamps in
   `inspect()` so they still describe the clock values 100 through 105. Headers
   and recording timestamps should now differ; both replays should preserve the
   new headers. This changes the supplied data, not rosbag's behavior.
3. Use the retained bag with `ros2 bag info` and identify exactly which topic is
   available. A robot application may additionally need transforms, calibration,
   configuration, and other sensor topics; those dependencies are absent here.

## Primary references

- [rosbag2 Jazzy recording and playback documentation](https://github.com/ros2/rosbag2/tree/jazzy)
- [Jazzy recorder implementation and simulation-time behavior](https://github.com/ros2/rosbag2/blob/jazzy/rosbag2_transport/src/rosbag2_transport/recorder.cpp)
- [Jazzy playback CLI options](https://github.com/ros2/rosbag2/blob/jazzy/ros2bag/ros2bag/verb/play.py)
- [ROS 2 clock and time design](https://design.ros2.org/articles/clock_and_time.html)

The branch links can evolve. The pinned image and captured package versions
identify the implementation actually exercised here.
