Skip to content

Commit ae0e674

Browse files
feat: Add save_snapshot_delayed service with optional delay before snapshot
- Add rosbag_blackbox_interfaces package with SaveSnapshotDelayed.srv service definition (float64 delay request, bool success + string message response) - Update rosbag_blackbox package.xml to depend on rosbag_blackbox_interfaces - Add ~/save_snapshot_delayed service to RingBufferNode that returns immediately and runs the snapshot in a background ThreadPoolExecutor after the specified delay - Add 8 pure-Python tests for the delayed snapshot logic Co-authored-by: marc-hanheide <1153084+marc-hanheide@users.noreply.github.com>
1 parent 4e93a41 commit ae0e674

6 files changed

Lines changed: 360 additions & 0 deletions

File tree

src/rosbag_blackbox/package.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<exec_depend>rosbag2_storage_sqlite3</exec_depend>
1818
<exec_depend>rosidl_runtime_py</exec_depend>
1919
<exec_depend>std_srvs</exec_depend>
20+
<exec_depend>rosbag_blackbox_interfaces</exec_depend>
2021
<exec_depend>sensor_msgs</exec_depend>
2122
<exec_depend>python3-yaml</exec_depend>
2223

src/rosbag_blackbox/rosbag_blackbox/ringbuffer_node.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
A ``std_srvs/srv/Trigger`` service triggers a snapshot: the buffered
2020
messages are written to a rosbag file.
2121
22+
A second service (``~/save_snapshot_delayed``) accepts an optional
23+
``delay`` (seconds). It returns immediately and performs the snapshot
24+
in a background thread once the delay has elapsed.
25+
2226
Topics are **dynamically discovered** at runtime, mirroring the
2327
behaviour of the ``ros2 bag record`` CLI. Pattern matching follows
2428
the zenoh-selector convention (``*`` = one segment, ``**`` = any depth).
@@ -44,7 +48,9 @@
4448
is written into each snapshot.
4549
"""
4650

51+
import concurrent.futures
4752
import os
53+
import time
4854
from datetime import datetime
4955
from typing import Any, Dict, List, Optional, Set
5056

@@ -59,6 +65,7 @@
5965
ReliabilityPolicy,
6066
)
6167
from rclpy.serialization import serialize_message
68+
from rosbag_blackbox_interfaces.srv import SaveSnapshotDelayed
6269
from rosidl_runtime_py.utilities import get_message
6370
from std_srvs.srv import Trigger
6471

@@ -224,6 +231,18 @@ def __init__(self) -> None:
224231
self._on_save_snapshot,
225232
)
226233

234+
# Background executor for delayed snapshots ---------------------------
235+
self._snapshot_executor = concurrent.futures.ThreadPoolExecutor(
236+
max_workers=4, thread_name_prefix='snapshot_bg'
237+
)
238+
239+
# Service to trigger a delayed snapshot (returns immediately) ----------
240+
self._delayed_snapshot_srv = self.create_service(
241+
SaveSnapshotDelayed,
242+
'~/save_snapshot_delayed',
243+
self._on_save_snapshot_delayed,
244+
)
245+
227246
# Periodic topic discovery --------------------------------------------
228247
self._discovery_timer = self.create_timer(2.0, self._discover_topics)
229248

@@ -564,6 +583,51 @@ def _on_auto_snapshot(self) -> None:
564583
except Exception as exc: # noqa: BLE001
565584
self.get_logger().warning(f'Auto-snapshot skipped: {exc}')
566585

586+
def _on_save_snapshot_delayed(
587+
self,
588+
request: SaveSnapshotDelayed.Request,
589+
response: SaveSnapshotDelayed.Response,
590+
) -> SaveSnapshotDelayed.Response:
591+
"""Schedule a snapshot after an optional delay, returning immediately.
592+
593+
The caller receives an acknowledgement right away. The actual
594+
snapshot is written by the background executor once *delay*
595+
seconds have elapsed.
596+
597+
:param request: Service request containing ``delay`` (float64, seconds).
598+
:param response: Service response with ``success`` and ``message``.
599+
:returns: Populated response indicating the snapshot was scheduled.
600+
"""
601+
delay = request.delay
602+
if delay < 0.0:
603+
response.success = False
604+
response.message = f'Invalid delay {delay}s: must be >= 0.'
605+
self.get_logger().error(response.message)
606+
return response
607+
608+
self._snapshot_executor.submit(self._write_snapshot_background, delay=delay)
609+
610+
if delay > 0.0:
611+
response.message = f'Snapshot scheduled in {delay:.3g}s'
612+
else:
613+
response.message = 'Snapshot scheduled immediately'
614+
response.success = True
615+
self.get_logger().info(response.message)
616+
return response
617+
618+
def _write_snapshot_background(self, delay: float = 0.0) -> None:
619+
"""Write a snapshot in a background thread, optionally after a delay.
620+
621+
:param delay: Seconds to wait before taking the snapshot.
622+
"""
623+
if delay > 0.0:
624+
time.sleep(delay)
625+
try:
626+
path = self._write_snapshot(trigger='manual')
627+
self.get_logger().info(f'Delayed snapshot saved: {path}')
628+
except Exception as exc: # noqa: BLE001
629+
self.get_logger().error(f'Delayed snapshot failed: {exc}')
630+
567631
def _write_snapshot(self, trigger: str = 'manual') -> str:
568632
"""Serialise the ring buffer to a new rosbag directory.
569633
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
# Copyright 2024 Maintainer
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for the delayed-snapshot helpers – no ROS2 runtime needed.
16+
17+
The methods under test are extracted from RingBufferNode and re-bound onto a
18+
lightweight fake node so we can exercise the logic without a live ROS2
19+
installation or compiled interface packages.
20+
"""
21+
22+
import concurrent.futures
23+
import threading
24+
import time
25+
from types import SimpleNamespace
26+
from unittest.mock import MagicMock, patch
27+
28+
29+
# ---------------------------------------------------------------------------
30+
# Helpers
31+
# ---------------------------------------------------------------------------
32+
33+
def _make_request(delay: float) -> SimpleNamespace:
34+
return SimpleNamespace(delay=delay)
35+
36+
37+
def _make_response() -> SimpleNamespace:
38+
return SimpleNamespace(success=None, message='')
39+
40+
41+
# ---------------------------------------------------------------------------
42+
# The two methods under test, kept in sync with RingBufferNode so the
43+
# tests remain independent of the ROS2 interface package.
44+
# ---------------------------------------------------------------------------
45+
46+
def _on_save_snapshot_delayed(self, request, response):
47+
"""Schedule a snapshot after an optional delay, returning immediately."""
48+
delay = request.delay
49+
if delay < 0.0:
50+
response.success = False
51+
response.message = f'Invalid delay {delay}s: must be >= 0.'
52+
self.get_logger().error(response.message)
53+
return response
54+
55+
self._snapshot_executor.submit(self._write_snapshot_background, delay=delay)
56+
57+
if delay > 0.0:
58+
response.message = f'Snapshot scheduled in {delay:.3g}s'
59+
else:
60+
response.message = 'Snapshot scheduled immediately'
61+
response.success = True
62+
self.get_logger().info(response.message)
63+
return response
64+
65+
66+
def _write_snapshot_background(self, delay: float = 0.0) -> None:
67+
"""Write a snapshot in a background thread, optionally after a delay."""
68+
if delay > 0.0:
69+
time.sleep(delay)
70+
try:
71+
path = self._write_snapshot(trigger='manual')
72+
self.get_logger().info(f'Delayed snapshot saved: {path}')
73+
except Exception as exc: # noqa: BLE001
74+
self.get_logger().error(f'Delayed snapshot failed: {exc}')
75+
76+
77+
# ---------------------------------------------------------------------------
78+
# Fake node that hosts the methods above
79+
# ---------------------------------------------------------------------------
80+
81+
class _FakeNode:
82+
"""Minimal stand-in for RingBufferNode."""
83+
84+
def __init__(self, write_result='/tmp/snap', write_raises=None):
85+
self._write_result = write_result
86+
self._write_raises = write_raises
87+
self._log_messages = []
88+
self._snapshot_executor = concurrent.futures.ThreadPoolExecutor(
89+
max_workers=4, thread_name_prefix='snapshot_bg'
90+
)
91+
92+
def get_logger(self):
93+
node = self
94+
95+
class _Logger:
96+
def info(self, msg):
97+
node._log_messages.append(('info', msg))
98+
99+
def error(self, msg):
100+
node._log_messages.append(('error', msg))
101+
102+
def warning(self, msg):
103+
node._log_messages.append(('warning', msg))
104+
105+
return _Logger()
106+
107+
def _write_snapshot(self, trigger: str = 'manual') -> str:
108+
if self._write_raises:
109+
raise self._write_raises
110+
return self._write_result
111+
112+
_on_save_snapshot_delayed = _on_save_snapshot_delayed
113+
_write_snapshot_background = _write_snapshot_background
114+
115+
116+
# ---------------------------------------------------------------------------
117+
# Tests for _on_save_snapshot_delayed
118+
# ---------------------------------------------------------------------------
119+
120+
class TestOnSaveSnapshotDelayed:
121+
122+
def test_zero_delay_returns_immediately(self):
123+
node = _FakeNode()
124+
req = _make_request(delay=0.0)
125+
resp = _make_response()
126+
127+
with patch.object(node._snapshot_executor, 'submit') as mock_submit:
128+
result = node._on_save_snapshot_delayed(req, resp)
129+
130+
assert result.success is True
131+
assert 'immediately' in result.message.lower()
132+
mock_submit.assert_called_once()
133+
134+
def test_positive_delay_schedules_background_task(self):
135+
node = _FakeNode()
136+
req = _make_request(delay=5.0)
137+
resp = _make_response()
138+
139+
with patch.object(node._snapshot_executor, 'submit') as mock_submit:
140+
result = node._on_save_snapshot_delayed(req, resp)
141+
142+
assert result.success is True
143+
assert '5' in result.message
144+
mock_submit.assert_called_once_with(
145+
node._write_snapshot_background, delay=5.0
146+
)
147+
148+
def test_negative_delay_returns_error(self):
149+
node = _FakeNode()
150+
req = _make_request(delay=-1.0)
151+
resp = _make_response()
152+
153+
result = node._on_save_snapshot_delayed(req, resp)
154+
155+
assert result.success is False
156+
assert 'invalid' in result.message.lower() or '-1' in result.message
157+
158+
def test_returns_immediately_before_snapshot_completes(self):
159+
"""The response must arrive before the snapshot is written."""
160+
write_event = threading.Event()
161+
162+
class _SlowNode(_FakeNode):
163+
def _write_snapshot(self, trigger='manual'):
164+
time.sleep(0.05)
165+
write_event.set()
166+
return '/tmp/snap'
167+
168+
node = _SlowNode()
169+
req = _make_request(delay=0.0)
170+
resp = _make_response()
171+
172+
start = time.monotonic()
173+
result = node._on_save_snapshot_delayed(req, resp)
174+
elapsed = time.monotonic() - start
175+
176+
# Wait for the background task to finish
177+
write_event.wait(timeout=1.0)
178+
179+
# The response should arrive well before the 50 ms sleep in _write_snapshot
180+
assert elapsed < 0.04, f'Response took {elapsed:.3f}s – not immediate'
181+
assert result.success is True
182+
183+
184+
# ---------------------------------------------------------------------------
185+
# Tests for _write_snapshot_background
186+
# ---------------------------------------------------------------------------
187+
188+
class TestWriteSnapshotBackground:
189+
190+
def test_calls_write_snapshot_immediately_when_no_delay(self):
191+
node = _FakeNode(write_result='/tmp/snap')
192+
calls = []
193+
original = node._write_snapshot
194+
195+
def _capture(**kw):
196+
calls.append(kw)
197+
return original(**kw)
198+
199+
node._write_snapshot = _capture
200+
node._write_snapshot_background(delay=0.0)
201+
202+
assert len(calls) == 1
203+
204+
def test_calls_write_snapshot_after_delay(self):
205+
node = _FakeNode(write_result='/tmp/snap')
206+
calls = []
207+
original = node._write_snapshot
208+
209+
def _capture(**kw):
210+
calls.append(time.monotonic())
211+
return original(**kw)
212+
213+
node._write_snapshot = _capture
214+
start = time.monotonic()
215+
node._write_snapshot_background(delay=0.05)
216+
elapsed = time.monotonic() - start
217+
218+
assert len(calls) == 1
219+
assert elapsed >= 0.05, f'Did not wait: elapsed={elapsed:.4f}s'
220+
221+
def test_logs_error_when_write_snapshot_raises(self):
222+
node = _FakeNode(write_raises=ValueError('buffer empty'))
223+
node._write_snapshot_background(delay=0.0)
224+
225+
error_logs = [msg for level, msg in node._log_messages if level == 'error']
226+
assert any('failed' in m.lower() for m in error_logs)
227+
228+
def test_executor_manages_background_tasks(self):
229+
"""Verify that tasks are submitted to the executor, not raw threads."""
230+
node = _FakeNode()
231+
req = _make_request(delay=0.0)
232+
resp = _make_response()
233+
234+
submitted = []
235+
236+
original_submit = node._snapshot_executor.submit
237+
238+
def _capture_submit(fn, **kwargs):
239+
submitted.append((fn, kwargs))
240+
return original_submit(fn, **kwargs)
241+
242+
node._snapshot_executor.submit = _capture_submit
243+
node._on_save_snapshot_delayed(req, resp)
244+
245+
# Wait for background work to finish
246+
node._snapshot_executor.shutdown(wait=True)
247+
248+
assert len(submitted) == 1
249+
assert submitted[0][1] == {'delay': 0.0}
250+
251+
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
cmake_minimum_required(VERSION 3.8)
2+
project(rosbag_blackbox_interfaces)
3+
4+
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
5+
add_compile_options(-Wall -Wextra -Wpedantic)
6+
endif()
7+
8+
find_package(ament_cmake REQUIRED)
9+
find_package(rosidl_default_generators REQUIRED)
10+
11+
rosidl_generate_interfaces(${PROJECT_NAME}
12+
"srv/SaveSnapshotDelayed.srv"
13+
)
14+
15+
ament_export_dependencies(rosidl_default_runtime)
16+
17+
ament_package()
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0"?>
2+
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
3+
<package format="3">
4+
<name>rosbag_blackbox_interfaces</name>
5+
<version>0.1.0</version>
6+
<description>ROS2 service/message interfaces for rosbag_blackbox.</description>
7+
<maintainer email="maintainer@example.com">Maintainer</maintainer>
8+
<license>Apache-2.0</license>
9+
10+
<buildtool_depend>ament_cmake</buildtool_depend>
11+
<buildtool_depend>rosidl_default_generators</buildtool_depend>
12+
13+
<exec_depend>rosidl_default_runtime</exec_depend>
14+
15+
<member_of_group>rosidl_interface_packages</member_of_group>
16+
17+
<export>
18+
<build_type>ament_cmake</build_type>
19+
</export>
20+
</package>

0 commit comments

Comments
 (0)