← vikramnag.com RosBag Resurrector

Challenges, decisions, learnings

The transparent build log: what broke on the way from first commit to v0.8, the decision each incident forced, and the lessons worth keeping. The architecture notes describe how the system is built — this page is what it cost to learn. An eight-minute read.

▶ 2-minute tour GitHub Full changelog

The arc

Feb 2026v0.1 — internal preview: MCAP parsing, BagFrame prototype
Mar 2026v0.2.0 — first public release, 164 tests
Apr 2026v0.2.1 → v0.4.2 — the reliability & honesty arc
May 2026v0.5 → v0.6.2 — 3D scene, custom types, the e2e harness
Jun 2026v0.7.x — six "10x" features, two of them demand-validated
Jul 2026v0.8.x — the notebook UI rebuild
Oct 2026v0.8.4 → v0.8.7 — four releases in nine days, mostly export correctness
Today19 releases · 1,799 backend + 101 unit + 63 e2e tests

Twelve stories

1. Picking a lane meant naming who's better

The first scoping decision was about what not to build. Foxglove is excellent at 3D visualization; PlotJuggler is excellent at fast plotting; competing there would have meant losing slowly. The lane that was actually empty: treating a recorded bag as a queryable dataset — health checks, sync, ML export, search — with no ROS install. Even so, early comparison claims overreached, an internal review flagged them as expectation risk, and the README's comparison table got tightened release by release.

Lesson: respect for competitors is a positioning feature. Narrow claims survive contact with experts; broad ones cost the credibility the honest claims earned.

2. The "lazy" that wasn't and the "sha256" that wasn't

Two early names lied. to_lazy_polars() materialized every chunk before returning — fixed once to be genuinely lazy, then removed entirely in v0.4.0 when it turned out to leak a temp file per call (the replacement, materialize_ipc_cache(), has an explicit lifecycle — the project's only breaking change to date). And _compute_sha256 hashed only the first megabyte: fine as a change-detection fingerprint, dishonest as a SHA. The rename to fingerprint (with opt-in --full-hash for a real digest) required renaming a database column on users' machines — which forced building the forward-only migration framework the index still runs on today.

Lesson: a misnamed abstraction is borrowed credibility. Repaying it cost a breaking change and a migration framework — still cheaper than a user discovering the lie first.

3. The wheel that worked on my machine

v0.3.2, the project's first true shipped-broken release: resurrector demo crashed on every PyPI install with ModuleNotFoundError: No module named 'tests'. The demo-bag generator lived under tests/fixtures/, which doesn't ship in the wheel — so every from-source check passed while every real user's first command failed. The fix moved the generator into the package; the real fix was a new CI job that builds the wheel, installs it into a fresh venv, and runs the smoke suite against that.

Lesson: test the artifact you ship, not the checkout you develop in. The gap between them is exactly where first-impression bugs live.

4. The flagship feature was broken on every install

v0.4.1: semantic frame search — the headline demo — raised a DuckDB type error on every query, on every install, because two parameters were passed in the wrong order. A test existed that would have caught it. It never ran: it was gated behind importorskip("sentence_transformers") and CI never installed the [vision] extra (avoiding a 2 GB model download), so the test silently skipped for weeks. The fix added SQL-level tests with synthetic embeddings, plus an extras-test CI matrix that installs every optional extra and runs the suite under each.

Lesson: a permanently-skipped test is indistinguishable from no test — worse, it reads as coverage. CI has to install what users install.

5. The OOM reckoning

Real bags are bigger than RAM, and the early code paths quietly assumed they weren't. v0.4.0 was a release with zero new features: health checks, density, plotting, and sync were all rewritten from accumulate-then-compute into running state — Welford accumulators, bin counters, bounded lookahead buffers. The contract ("memory is bounded by chunk size, not bag size") went into the README, and a memory-regression suite now measures peak RSS in CI and fails if any hot path regresses. The two places the rule genuinely can't hold got typed errors that name the limit and the alternative instead of a silent warning nobody reads.

Lesson: a performance promise without a test is marketing. Writing the contract down — with its enforcement and its honest exceptions — is what made "works on the demo bag" and "works on the 100GB bag" the same claim.

6. Two sync engines, one truth

The streaming rewrite left sync with two implementations behind one API: eager (vectorized, fastest under 1M messages) and streaming (bounded buffers, unbounded bags). Two engines invite silent drift, so the test suite runs both against nine timing-pathology fixture bags and asserts identical output — which surfaced real subtleties, like tie-breaking on equidistant samples. Best gotcha: MCAP readers return messages time-sorted even when written out of order, so the out-of-order policies couldn't be tested through a fixture bag at all — they're tested at the row-iterator level with a fake view that regresses timestamps on purpose.

Lesson: a second implementation is a correctness risk until you make the two verify each other. And sometimes the test harness, not the feature, is the design problem.

7. 717 green tests and a broken product

By v0.6.0 the backend suite was over 700 tests and users were still hitting a toast that said play: [object Object], a playback engine that sprinted through messages after resume, and an opaque 500 on the search page. None of those live where backend tests look. v0.6.1 added a Playwright harness driving a real browser against a hermetic dashboard — and every e2e spec is annotated with the bug it would have caught. One trap worth passing on: --update-snapshots only rewrites failing visual baselines, so a stale-but-within-tolerance baseline survives "regenerate everything" — delete the file first.

Lesson: backend-green is not product-works. Every user-visible bug gets its regression test in the layer the user actually touched, or the class of bug returns.

8. The error message is the product

The heavy dependencies — CLIP, TensorFlow, rclpy — live behind pip extras, which means the most common failure mode is "feature not installed." The v0.6.1 answer was a capability registry: one endpoint probes what's importable, and every surface renders the same honest state — available, or an install banner with a copy-paste command. Then reality kept teaching: the printed command failed on zsh (macOS's default shell) until every install string was quoted, and v0.8.3 moved the search cell's check before the input, because typing a query just to learn the extra is missing is its own small betrayal.

Lesson: for a first-run user, the failure surface is the product. "The tool told me exactly what to install" and "the tool feels broken" are the same 30 seconds, decided by an error message.

9. Strangers set the roadmap

The best features weren't planned. A ROS Discourse question about custom message types became v0.6's pluggable decoder registry, and a promise made in that public reply ("if that's a common pattern I'll promote it to first-class") became concatenate_bags(). Outreach replies asking "can it hand me an incident report?" and "can it gate CI?" became v0.7.1's report generator and bag contracts — both labeled demand-validated in the changelog because they came from users, not the roadmap.

Lesson: a public reply is a commitment with a deadline. Building what a real person asked for, while the ask is warm, beats building what the roadmap imagined.

10. Rebuilding the front door without closing the old one

An internal usability review in April was blunt: capability 8.5/10, UI maturity 5/10, discoverability 5.5/10. The dashboard was a page per feature, and features users didn't find might as well not exist. The v0.8 answer was a rebuild around one rule — every capability is a cell in a notebook feed, sharing a command palette, a linked time cursor, and inline guides. The rebuild shipped as slices: the classic UI stayed fully reachable at /classic, cells landed one release at a time, and the default route flipped only when the notebook covered everything. The e2e suite tripled in the same release the routes swapped.

Lesson: big rewrites land as a sequence of shippable slices with a reversible switch. The old door stays open until the new one provably covers it.

11. The demo recording that kept dying at 20 seconds

The v0.8 demo videos are recorded by a Playwright harness driving the real app. One night every scene started truncating at exactly ~20 seconds — because macOS's temp cleaner had deleted the demo environment under the running server, the bag list came back empty, and every scene timed out on its first selector. The failure was invisible for an hour because the recorder's output was piped through tail -1. Recording demos also found real product bugs — v0.8.1 exists because the camera never lies about a cell silently appearing below the fold.

Lesson: a demo recording is a usability test that pays twice. Also: nothing durable lives in /tmp, and never pipe away the stderr of your own tooling.

12. Solo, with guardrails

This is a nights-and-weekends project built solo, pairing heavily with AI coding agents — which changed what discipline means. Branch protection is turned on against myself; every release runs the same gate (full backend, unit, and e2e suites, then a clean-venv install smoke); the visual baselines have a human gate before every tag. The repo's CLAUDE.md conventions file became the standing brief that keeps every working session — human or machine — honest about the memory contract, the migration rules, and what never gets committed. The deliberately-not-done list is written down too, so deferring stays a decision instead of a drift.

Lesson: you-at-11pm is a new contributor. Write the contracts where every future session reads them, and let CI — not memory — hold the line.

If I keep only five