Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/api/rest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1077,7 +1077,7 @@ Query and manage faults.
{
"type": "rosbag",
"name": "fault_recording",
"bulk_data_uri": "/apps/motor_controller/bulk-data/rosbags/550e8400-e29b-41d4-a716-446655440000",
"bulk_data_uri": "/apps/motor_controller/bulk-data/rosbags/fault_MOTOR_OVERHEAT_1738664999000",
"size_bytes": 1234567,
"duration_sec": 6.0,
"format": "mcap"
Expand Down Expand Up @@ -1371,7 +1371,7 @@ Download a specific bulk-data file.

.. code-block:: bash

curl -O -J http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags/550e8400-e29b-41d4-a716-446655440000
curl -O -J http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags/fault_MOTOR_OVERHEAT_1738664999000

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you update the listing section above as well? Line 1337 shows a UUID as id, but it is a recording id now. Line 1343 shows x-medkit.fault_code, which is now the fault_codes array. The text at lines 1352-1355 says "each fault gets its own descriptor with the full bag size", and this PR changes that to one descriptor per recording. Line 1367 still says Content-Disposition: attachment; filename="FAULT_CODE.mcap", but the filename is <recording_id>.<format> now. The tutorial page was updated, so the two pages now describe the same response in different ways.


**Response Codes:**

Expand Down Expand Up @@ -2814,7 +2814,8 @@ Other extensions beyond SOVD:
optional ``x-medkit`` SOVD payload-extension object with ``entity_type`` and ``entity_id``
fields when the gateway can resolve the fault's first reporting source back to an entity,
so consumers can hit ``/{entity_type}/{entity_id}/bulk-data/rosbags/{fault_code}`` directly
without enumerating entities. Resolution is snapshotted at event arrival; the entire
without enumerating entities - that address serves the fault's newest recording. To reach an
older one, list ``/bulk-data/rosbags`` and use the descriptor ``id``. Resolution is snapshotted at event arrival; the entire
``x-medkit`` object is omitted when no entity can be resolved.
- ``/health`` - Health check with discovery pipeline diagnostics
- ``/version-info`` - Gateway version information
Expand Down
28 changes: 26 additions & 2 deletions docs/config/fault-manager.rst
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ Capture continuous rosbag recordings around fault events.
max_buffer_mb: 256 # Ring-buffer RAM cap
max_bag_size_mb: 50 # Max size per bag file
max_total_storage_mb: 500 # Max total storage
max_bags_per_fault: 1 # Recordings kept per fault code
auto_cleanup: true # Auto-delete old bags

.. list-table::
Expand Down Expand Up @@ -324,10 +325,33 @@ Capture continuous rosbag recordings around fault events.
- Maximum total storage for all rosbags (MB). A recording shared by a
burst of faults counts once towards the total, and eviction removes a
whole burst's bag at a time (oldest first).
* - ``rosbag.max_bags_per_fault``

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a row for snapshots.retain_on_clear to this table? It is a new parameter and it decides whether acknowledging a fault keeps its snapshots. The note below about keeping the evidence consistent depends on it, but the parameter is not documented in this file or in the snapshots tutorial.

- ``1``
- How many recordings one fault code keeps. Past the cap the oldest is
unlinked, so the default reproduces the historical behaviour exactly: a
new recording replaces the previous one. ``0`` means unlimited, bounded
only by ``max_total_storage_mb``. ``3`` is a reasonable value for a fault

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you also mention snapshots.recapture_cooldown_sec here? It is 60 s by default, it gates rosbag capture as well, and the entry for a fault is removed only in handle_clear_fault. So a fault that returns faster than one minute still gets one recording, whatever this cap is set to. That is the case this sentence tells the operator to raise the cap for.

that flaps - see the note below before raising it.
* - ``rosbag.auto_cleanup``
- ``true``
- Delete a fault's bag when the fault is cleared. A recording shared by a
burst survives until the last fault referencing it clears.
- Delete a fault's bags when the fault is cleared. A recording shared by a
burst survives until the last fault referencing it clears. Leave this
``false`` when raising ``max_bags_per_fault``, or acknowledging a fault
discards the history that was just kept.

.. note::

``max_bags_per_fault`` is a **fairness** knob, not a depth knob.
``max_total_storage_mb`` is the real disk bound and eviction across it is
global and oldest-first, so a fault that flaps often enough will consume the
budget and push out every other fault's black box. Raise the per-fault cap
when you need the history of a specific intermittent fault; raise the total
budget with it if other faults still need theirs.

The cap keeps the newest recordings and evicts the oldest. It deliberately
does not match ``snapshots.max_per_fault``, which rejects new snapshots once

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This note says snapshots.max_per_fault rejects new snapshots when full, but this PR changes it to keep-newest. store_snapshots now removes whole capture sets, oldest first. The table row at line 213 still says the old rule, and the comment on set_max_rosbags_per_fault in fault_storage.hpp calls the rosbag cap "deliberately the opposite of set_max_snapshots_per_fault's reject-new". Could you update all three? As written, the note explains a difference between the two caps that no longer exists.

full: refusing a new recording would mean a technician standing next to a
machine faulting right now downloads a bag from three days ago.

.. _rosbag-recording-lifecycle:

Expand Down
26 changes: 19 additions & 7 deletions docs/tutorials/snapshots.rst
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ Snapshots are included inline in the fault response as ``environment_data``:
{
"type": "rosbag",
"name": "fault_recording",
"bulk_data_uri": "/apps/motor_controller/bulk-data/rosbags/550e8400-e29b-41d4-a716-446655440000",
"bulk_data_uri": "/apps/motor_controller/bulk-data/rosbags/fault_MOTOR_OVERHEAT_1738664999000",
"size_bytes": 1234567,
"duration_sec": 6.0,
"format": "mcap"
Expand All @@ -286,7 +286,10 @@ Snapshots are included inline in the fault response as ``environment_data``:
gateway start instead, marked ``x-medkit.capture_origin: startup``; a
plugin entity that reports its link down contributes its last known values,
marked ``connected: false`` in ``x-medkit``
- ``rosbag``: Recording file available via bulk-data endpoint (binary format)
- ``rosbag``: Recording file available via bulk-data endpoint (binary format).
One entry per recording the fault kept, newest first, each addressed by its own
``bulk_data_uri``. With the default ``max_bags_per_fault`` of ``1`` there is at
most one.

**Get snapshots from fault response using jq:**

Expand Down Expand Up @@ -700,20 +703,26 @@ Rosbag files are downloaded via SOVD bulk-data endpoints.

curl http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags

One item per **recording**, not per fault. A burst of correlated faults shares a
single recording and appears once, with every fault it covers listed in
``x-medkit.fault_codes``. A fault that confirmed several times contributes one
item per recording it kept (see ``max_bags_per_fault`` below).

**Response:**

.. code-block:: json

{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "MOTOR_OVERHEAT recording",
"id": "fault_MOTOR_OVERHEAT_1738664999000",
"name": "fault_MOTOR_OVERHEAT_1738664999000 recording 2026-02-04T10:30:00.000Z",
"mimetype": "application/x-mcap",
"size": 1234567,
"creation_date": "2026-02-04T10:30:00.000Z",
"x-medkit": {
"fault_code": "MOTOR_OVERHEAT",
"fault_codes": ["MOTOR_OVERHEAT"],
"recording_id": "fault_MOTOR_OVERHEAT_1738664999000",
"duration_sec": 6.0,
"format": "mcap"
}
Expand All @@ -723,12 +732,15 @@ Rosbag files are downloaded via SOVD bulk-data endpoints.

**2. Download a specific rosbag:**

Use the ``bulk_data_uri`` from the fault response, or construct from listing:
Use the ``bulk_data_uri`` from the fault response, or the descriptor ``id`` from
the listing. A URL carrying a bare fault code instead of a recording id still
resolves and serves that fault's newest recording, so addresses built before
recordings had their own identity keep working.

.. code-block:: bash

# Using bulk_data_uri from fault response
curl -O -J http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags/550e8400-e29b-41d4-a716-446655440000
curl -O -J http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags/fault_MOTOR_OVERHEAT_1738664999000

The ``-J`` flag uses the server-provided filename from ``Content-Disposition`` header.

Expand Down
1 change: 1 addition & 0 deletions src/ros2_medkit_fault_manager/CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Changelog for package ros2_medkit_fault_manager

Forthcoming
-----------
* Rosbag black-box recordings are no longer limited to one per fault code. A fault that re-confirms keeps a bounded history of recordings instead of overwriting the previous one, controlled by the new ``snapshots.rosbag.max_bags_per_fault`` (default ``1``, which reproduces the previous behaviour exactly; ``0`` = unlimited). Retention is keep-newest and the bag is unlinked only when no fault still references it, so a burst that shares one recording behaves as before. Internally the ``rosbag_files`` grain changed from "one row per fault" to "one row per (fault, recording) link": ``recording_id`` is now a stored, indexed column, and the legacy column-level ``UNIQUE(fault_code)`` is replaced by a ``UNIQUE INDEX`` on ``(fault_code, file_path)`` through an automatic, idempotent table rebuild on first open. Four latent defects are fixed on the way: quota eviction deleted by fault code rather than by recording, ``get_rosbag_file`` had no ``ORDER BY`` and would have served an arbitrary recording, the stale-row self-heals deleted a fault's entire history because one bag had vanished from disk, and both ``delete_rosbag_file`` / ``delete_rosbag_files`` read only the first ``file_path`` of a fault, so deleting a fault with several recordings removed every row but left all but one bag on disk - unreachable and still charged against the quota (`#620 <https://github.com/selfpatch/ros2_medkit/issues/620>`_)
* Optional append-only, hash-chained audit log of fault state transitions: each transition appends one immutable row (``record_hash = sha256(prev_hash + canonical(event))`` via OpenSSL EVP SHA-256) with a persisted chain head, a ``verify`` routine, a read API, and retention that seals a segment anchor before pruning. Time-based (PREFAILED->CONFIRMED) auto-confirmations are also audited. ``verify`` reads the chain head directly from the database, so deleting the newest row together with the head row is reported as tampering instead of silently recovering. ``BEFORE UPDATE`` / ``BEFORE DELETE`` triggers reject out-of-band edits as defense-in-depth. The chain is unkeyed and stored in a single writable file, so ``verify`` detects edits/deletions that did not recompute the chain (casual or accidental tampering); it is not a defence against an attacker who can rewrite the whole file. Off by default (`#483 <https://github.com/selfpatch/ros2_medkit/issues/483>`_)

0.6.0 (2026-06-22)
Expand Down
11 changes: 11 additions & 0 deletions src/ros2_medkit_fault_manager/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ if(BUILD_TESTING)
target_link_libraries(test_sqlite_storage fault_manager_lib)
medkit_target_dependencies(test_sqlite_storage rclcpp ros2_medkit_msgs)

# Rosbag retention parity: every assertion runs against both storage backends.
medkit_add_gtest(test_rosbag_storage_parity test/test_rosbag_storage_parity.cpp)
target_link_libraries(test_rosbag_storage_parity fault_manager_lib)
medkit_target_dependencies(test_rosbag_storage_parity rclcpp ros2_medkit_msgs)

# Fault audit log tests (hash chain, verify, rotation, reopen)
medkit_add_gtest(test_fault_audit_log test/test_fault_audit_log.cpp)
target_link_libraries(test_fault_audit_log fault_manager_lib)
Expand Down Expand Up @@ -190,6 +195,12 @@ if(BUILD_TESTING)
medkit_add_launch_test(test_rosbag_entity_scope test/test_rosbag_entity_scope.test.py TIMEOUT 120
LABELS "integration")

# The only suite running above max_bags_per_fault=1: drives confirm / clear /
# confirm on one code and asserts both recordings survive and stay separately
# addressable. Six occurrences plus their post-roll windows, hence the timeout.
medkit_add_launch_test(test_rosbag_history test/test_rosbag_history.test.py TIMEOUT 240
LABELS "integration")

# Parametrized over both storage formats (sqlite3 + mcap), so the launch
# runs twice inside one ctest invocation.
medkit_add_launch_test(test_rosbag_boundary test/test_rosbag_boundary.test.py TIMEOUT 240
Expand Down
26 changes: 24 additions & 2 deletions src/ros2_medkit_fault_manager/config/snapshots.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ default_topics:
# `default_topics` from this YAML file. Rosbag settings must be configured
# via ROS 2 parameters (--ros-args -p snapshots.rosbag.*) or launch files.

# Keep a fault's value snapshots when it is acknowledged (default: false)
# Off is the historical behaviour: clearing a fault deletes them. Turn it on
# together with rosbag.max_bags_per_fault, or acknowledging leaves the fault
# holding recordings whose matching readings are gone - evidence that no longer
# lines up. Growth stays bounded by max_per_fault either way.
retain_on_clear: false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing reads this key. load_snapshot_config_from_yaml reads only fault_specific, patterns and default_topics. The real setting is the ROS parameter snapshots.retain_on_clear. An operator who edits it here gets no effect and no warning. Could you remove it, or turn it into a comment that points to the parameter, the way the rosbag block below does?


rosbag:
# Enable/disable rosbag capture (default: false)
# When disabled, only JSON snapshots are captured
Expand Down Expand Up @@ -145,17 +152,32 @@ rosbag:

# Storage path for bag files (default: "" = system temp directory)
# Empty string uses /tmp/rosbag_snapshots/
# Bag files are named: {fault_code}_{timestamp}/
# Bag files are named: fault_{fault_code}_{timestamp}/ and that directory name is
# the recording's public id - the last segment of its bulk-data URL.
storage_path: ""

# Maximum size per bag file in MB (default: 50)
# If a bag exceeds this size, it will be closed even if still recording
max_bag_size_mb: 50

# Maximum total storage for all bag files in MB (default: 500)
# Oldest bags are deleted when this limit is exceeded
# Oldest bags are deleted when this limit is exceeded. This is the real disk bound;
# max_bags_per_fault below only decides how the budget is shared out.
max_total_storage_mb: 500

# Recordings kept per fault code (default: 1, 0 = unlimited)
# A fault that keeps re-confirming leaves a trail of black boxes instead of only
# the latest one. Past the cap the fault's OLDEST recording is dropped, and the bag
# is deleted once no fault still references it (a burst shares one recording).
#
# 1 is the historical behaviour: each re-confirmation replaces the previous bag.
# 3 is a good starting point for an intermittent fault you are chasing.
#
# Think of this as fairness rather than depth: the disk is bounded by
# max_total_storage_mb either way, and a high value lets one flapping fault consume
# the budget and evict every other fault's recording.
max_bags_per_fault: 1

# Maximum in-memory ring buffer size in MB (default: 256)
# Oldest buffered messages are dropped once the buffer exceeds this, so a broad
# subscribe set on a busy robot cannot grow memory without bound.
Expand Down
Loading
Loading