Skip to content

Commit a447546

Browse files
Add file-backed ring buffer with per-topic configurability (#8)
* Initial plan * Add file-based ring buffer with per-topic configurability Co-authored-by: marc-hanheide <1153084+marc-hanheide@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: marc-hanheide <1153084+marc-hanheide@users.noreply.github.com>
1 parent 856d6de commit a447546

4 files changed

Lines changed: 303 additions & 6 deletions

File tree

src/rosbag_blackbox/config/example_config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
# pattern: <glob> – match by topic NAME using the syntax above
1313
# type: <string> – match by message TYPE (exact, e.g. geometry_msgs/msg/Twist)
1414
# Both keys may be combined; in that case BOTH conditions must hold.
15+
#
16+
# Buffer type (optional, default: memory):
17+
# buffer: memory – keep messages in RAM (default, lowest latency)
18+
# buffer: file – keep messages in a temporary file (reduces RAM usage;
19+
# recommended for high-bandwidth topics like images)
1520

1621
# ── Ring-buffer settings ────────────────────────────────────────────────────
1722
buffer:
@@ -50,6 +55,7 @@ topics:
5055

5156
# ── All Camera topics at max 1 Hz ─────────────────────────────────────────
5257
- type: "sensor_msgs/msg/Image"
58+
buffer: file # use a file-backed buffer to reduce RAM usage
5359
max_frequency: 1.0 # Hz (omit or set to 0 for unlimited)
5460
qos:
5561
reliability: best_effort

src/rosbag_blackbox/rosbag_blackbox/ring_buffer.py

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,23 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
"""Thread-safe, time-bounded ring buffer for serialised ROS2 messages.
15+
"""Thread-safe, time-bounded ring buffers for serialised ROS2 messages.
16+
17+
Two implementations are provided:
18+
19+
* :class:`RingBuffer` – in-memory (default), uses a :class:`collections.deque`.
20+
* :class:`FileRingBuffer` – file-backed; only a small index is kept in RAM
21+
while message payloads are stored in a temporary file. Suitable for
22+
high-bandwidth topics such as camera images where RAM usage is a concern.
23+
24+
Both classes expose the same public API so they are interchangeable.
1625
1726
Kept in a separate module so it can be unit-tested without a ROS2
1827
installation.
1928
"""
2029

30+
import struct
31+
import tempfile
2132
import threading
2233
from collections import deque
2334
from typing import List, Tuple
@@ -71,3 +82,140 @@ def memory_bytes(self) -> int:
7182
"""Estimated total byte size of all buffered message payloads."""
7283
with self._lock:
7384
return sum(len(d) for _, _, _, d in self._buf)
85+
86+
87+
class FileRingBuffer:
88+
"""File-backed ring buffer that retains the last *duration_seconds* of data.
89+
90+
Message payloads are written to a temporary file so that RAM usage stays
91+
low regardless of payload size. Only a compact in-memory index
92+
``(timestamp_ns, file_offset, record_size)`` is kept per message.
93+
94+
The temporary file is automatically removed when the buffer is garbage-
95+
collected or when :meth:`close` is called explicitly.
96+
97+
The public API is identical to :class:`RingBuffer`, making the two
98+
classes interchangeable.
99+
"""
100+
101+
# Binary record header: timestamp_ns (int64 BE), topic_len (uint32 BE),
102+
# type_name_len (uint32 BE), data_len (uint32 BE)
103+
_HDR: struct.Struct = struct.Struct('>qIII')
104+
105+
def __init__(self, duration_seconds: float) -> None:
106+
self._duration_ns: int = int(duration_seconds * 1_000_000_000)
107+
self._lock: threading.Lock = threading.Lock()
108+
# TemporaryFile is deleted automatically on close/GC
109+
self._tmpfile = tempfile.TemporaryFile()
110+
self._write_pos: int = 0
111+
# Each entry: (timestamp_ns, file_offset, record_size_bytes)
112+
self._index: deque = deque()
113+
self._live_bytes: int = 0 # bytes occupied by non-purged records
114+
115+
# ------------------------------------------------------------------
116+
# Serialisation helpers
117+
# ------------------------------------------------------------------
118+
119+
def _pack(
120+
self,
121+
timestamp_ns: int,
122+
topic: str,
123+
type_name: str,
124+
data: bytes,
125+
) -> bytes:
126+
tb = topic.encode()
127+
tnb = type_name.encode()
128+
hdr = self._HDR.pack(timestamp_ns, len(tb), len(tnb), len(data))
129+
return hdr + tb + tnb + data
130+
131+
def _unpack_at(self, offset: int) -> Tuple[int, str, str, bytes]:
132+
self._tmpfile.seek(offset)
133+
hdr_bytes = self._tmpfile.read(self._HDR.size)
134+
ts_ns, tl, tnl, dl = self._HDR.unpack(hdr_bytes)
135+
topic = self._tmpfile.read(tl).decode()
136+
type_name = self._tmpfile.read(tnl).decode()
137+
data = self._tmpfile.read(dl)
138+
return (ts_ns, topic, type_name, data)
139+
140+
# ------------------------------------------------------------------
141+
# Core operations
142+
# ------------------------------------------------------------------
143+
144+
def add(
145+
self,
146+
timestamp_ns: int,
147+
topic: str,
148+
type_name: str,
149+
data: bytes,
150+
) -> None:
151+
"""Append a message and evict entries outside the retention window."""
152+
record = self._pack(timestamp_ns, topic, type_name, data)
153+
with self._lock:
154+
self._tmpfile.seek(self._write_pos)
155+
self._tmpfile.write(record)
156+
self._index.append((timestamp_ns, self._write_pos, len(record)))
157+
self._write_pos += len(record)
158+
self._live_bytes += len(record)
159+
self._purge(timestamp_ns)
160+
161+
def _purge(self, now_ns: int) -> None:
162+
"""Evict records older than the retention window (called under lock)."""
163+
if self._duration_ns <= 0:
164+
return
165+
cutoff = now_ns - self._duration_ns
166+
while self._index and self._index[0][0] < cutoff:
167+
_, _, size = self._index.popleft()
168+
self._live_bytes -= size
169+
# Reclaim disk space when dead bytes exceed live bytes
170+
dead = self._write_pos - self._live_bytes
171+
if self._index and dead > self._live_bytes:
172+
self._compact()
173+
174+
def _compact(self) -> None:
175+
"""Rewrite the temp file keeping only live records (called under lock)."""
176+
# Read all live raw records before modifying the file
177+
live_chunks: List[bytes] = []
178+
for _ts, offset, size in self._index:
179+
self._tmpfile.seek(offset)
180+
live_chunks.append(self._tmpfile.read(size))
181+
182+
self._tmpfile.seek(0)
183+
self._tmpfile.truncate(0)
184+
new_pos: int = 0
185+
new_index: deque = deque()
186+
for (ts_ns, _old_off, size), chunk in zip(self._index, live_chunks):
187+
self._tmpfile.write(chunk)
188+
new_index.append((ts_ns, new_pos, size))
189+
new_pos += size
190+
self._tmpfile.flush()
191+
self._write_pos = new_pos
192+
self._live_bytes = new_pos
193+
self._index = new_index
194+
195+
# ------------------------------------------------------------------
196+
def snapshot(self) -> List[Tuple[int, str, str, bytes]]:
197+
"""Return a point-in-time copy of all buffered messages."""
198+
with self._lock:
199+
return [self._unpack_at(offset) for _, offset, _ in self._index]
200+
201+
def size(self) -> int:
202+
"""Number of messages currently in the buffer."""
203+
with self._lock:
204+
return len(self._index)
205+
206+
def memory_bytes(self) -> int:
207+
"""Bytes occupied by live records in the backing file."""
208+
with self._lock:
209+
return self._live_bytes
210+
211+
# ------------------------------------------------------------------
212+
def close(self) -> None:
213+
"""Close and delete the backing temporary file."""
214+
with self._lock:
215+
self._tmpfile.close()
216+
217+
def __del__(self) -> None:
218+
try:
219+
self._tmpfile.close()
220+
except Exception: # noqa: BLE001
221+
pass

src/rosbag_blackbox/rosbag_blackbox/ringbuffer_node.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464

6565
import rosbag2_py
6666

67-
from rosbag_blackbox.ring_buffer import RingBuffer
67+
from rosbag_blackbox.ring_buffer import FileRingBuffer, RingBuffer
6868
from rosbag_blackbox.snapshot_helpers import format_snapshot_name, param_value_to_python
6969
from rosbag_blackbox.topic_filter import find_matching_config, matches_pattern
7070

@@ -201,6 +201,9 @@ def __init__(self) -> None:
201201
self._topic_types: Dict[str, str] = {} # topic -> type_name
202202
self._last_msg_ns: Dict[str, int] = {} # topic -> last timestamp (ns)
203203
self._topic_cfgs: list = self._cfg.get('topics', [])
204+
# Per-topic file-backed buffers (keyed by topic name).
205+
# Created for topics configured with ``buffer: file``.
206+
self._file_bufs: Dict[str, FileRingBuffer] = {}
204207
# Latest message for transient_local topics – never evicted so that
205208
# latched topics always appear in every snapshot.
206209
self._latched_last: Dict[str, tuple] = {} # topic -> (ts_ns, topic, type, data)
@@ -366,11 +369,28 @@ def _subscribe(
366369
qos_cfg.get('durability', 'volatile').lower() == 'transient_local'
367370
)
368371

372+
# Determine which buffer to use for this topic.
373+
# Topics configured with ``buffer: file`` get their own FileRingBuffer;
374+
# all other topics share the main in-memory RingBuffer (default).
375+
use_file: bool = (
376+
topic_cfg.get('buffer', 'memory').lower() == 'file'
377+
)
378+
if use_file:
379+
duration = float(self._cfg['buffer']['duration_seconds'])
380+
topic_buf = FileRingBuffer(duration)
381+
self._file_bufs[topic] = topic_buf
382+
self.get_logger().info(
383+
f'Using file-backed buffer for {topic} [{type_name}]'
384+
)
385+
else:
386+
topic_buf = self._buf
387+
369388
def _make_cb(
370389
t: str,
371390
tn: str,
372391
min_interval: int,
373392
latched: bool,
393+
buf,
374394
):
375395
def _cb(msg: Any) -> None:
376396
now_ns: int = self.get_clock().now().nanoseconds
@@ -384,7 +404,7 @@ def _cb(msg: Any) -> None:
384404

385405
try:
386406
data = serialize_message(msg)
387-
self._buf.add(now_ns, t, tn, data)
407+
buf.add(now_ns, t, tn, data)
388408
if latched:
389409
self._latched_last[t] = (now_ns, t, tn, data)
390410
except Exception as exc2: # noqa: BLE001
@@ -398,7 +418,7 @@ def _cb(msg: Any) -> None:
398418
sub = self.create_subscription(
399419
msg_class,
400420
topic,
401-
_make_cb(topic, type_name, min_interval_ns, is_latched),
421+
_make_cb(topic, type_name, min_interval_ns, is_latched, topic_buf),
402422
qos,
403423
)
404424
self._subs[topic] = sub
@@ -554,6 +574,13 @@ def _write_snapshot(self, trigger: str = 'manual') -> str:
554574
"""
555575
messages = self._buf.snapshot()
556576

577+
# Collect messages from per-topic file-backed buffers and merge
578+
for file_buf in self._file_bufs.values():
579+
messages.extend(file_buf.snapshot())
580+
581+
# Sort merged messages by timestamp so the bag is time-ordered
582+
messages.sort(key=lambda entry: entry[0])
583+
557584
# Always include the latest value for transient_local (latched) topics
558585
# even if they were evicted from the ring buffer.
559586
buffered_topics = {topic for _, topic, _, _ in messages}

0 commit comments

Comments
 (0)