Skip to content
Draft
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
30 changes: 30 additions & 0 deletions docs/api/rest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,33 @@ recording therefore reports its size once. One fault code can appear on several
descriptors, one per occurrence it kept, told apart by ``creation_date``, which
is the time that recording was made.

``size`` is the number of bytes the download route below puts on the wire for
that descriptor, so a client can size a buffer or a progress bar from the
listing. For a rosbag held in a single storage file, which is the normal case,
that is the file (``.mcap`` or ``.sqlite3``) and it is the only file the
download serves. The bag directory also holds ``metadata.yaml``, and those bytes
are not part of the transfer.

.. _rest-recording-size-rule:

**One recording, one size.** The descriptor ``size`` here, the
``environment_data.snapshots[].size_bytes`` a fault reports for the same
recording, and the ``Content-Length`` of its download are the same number, and
that number is the storage file. A recording also has a footprint on the
gateway host, which is larger because the directory holds ``metadata.yaml`` as
well. That figure is what the recording spends against its storage quota and is
not reported by the API. The one case where the two coincide is a recording
split across several storage files, past the configured maximum bag size: the
download can hand over only one of them, no single file describes the transfer,
and the API reports the recording's total instead.

For that split case the three numbers stop agreeing, and deliberately so. The
descriptor ``size`` and the nested ``size_bytes`` report the recording's total
while the download's ``Content-Length`` is the one storage file it hands over,
so ``size`` exceeds ``Content-Length``. That gap is the signal: a client that
compares the two can tell the transfer it just made is a part of the recording
rather than the whole of it, which no single reported number could express.

Download Bulk Data
~~~~~~~~~~~~~~~~~~

Expand All @@ -1787,6 +1814,9 @@ Download a specific bulk-data file.
a pre-#620 fault-code URL is not the segment the client sent, and the format
is the one persisted at capture time (``mcap`` or ``sqlite3``). For every
other category it is the stored item's own name, e.g. ``report.zip``.
- ``Content-Length``: the served file's length. For how it relates to the
descriptor ``size`` of the same recording, see
:ref:`One recording, one size <rest-recording-size-rule>`
- ``Accept-Ranges``: ``bytes`` - the download is served by a range-aware
provider, so a client may fetch part of the file
- ``Access-Control-Expose-Headers``: ``Content-Disposition``
Expand Down
6 changes: 4 additions & 2 deletions docs/tutorials/snapshots.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@ Quick Start

ros2 launch ros2_medkit_gateway gateway.launch.py

3. **When a fault is confirmed, query its snapshots:**
3. **When a fault is confirmed, read its snapshots from the fault itself:**

They are returned inline, under ``environment_data.snapshots``.

.. code-block:: bash

curl http://localhost:8080/api/v1/faults/MOTOR_OVERHEAT/snapshots
curl http://localhost:8080/api/v1/apps/motor_controller/faults/MOTOR_OVERHEAT

Configuration Options
---------------------
Expand Down
68 changes: 0 additions & 68 deletions postman/collections/ros2-medkit-gateway.postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -1355,74 +1355,6 @@
"description": "List faults with cluster details. Clusters are groups of similar faults that occurred within a time window.\n\nEach cluster includes:\n- `cluster_id`: Unique cluster identifier\n- `rule_id`, `rule_name`: The auto-cluster rule that created this cluster\n- `representative_code`: The fault shown as the cluster representative (based on rule config: first, most_recent, or highest_severity)\n- `representative_severity`: Severity of the representative fault\n- `fault_codes[]`: All fault codes in the cluster\n- `first_at`, `last_at`: Timestamps of first and last faults in cluster"
},
"response": []
},
{
"name": "GET Fault Snapshots (System-wide)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/faults/SENSOR_OVERTEMP/snapshots",
"host": [
"{{base_url}}"
],
"path": [
"faults",
"SENSOR_OVERTEMP",
"snapshots"
]
},
"description": "Get topic snapshots captured when a fault transitioned to CONFIRMED status. Snapshots provide system state at the moment of fault confirmation for post-mortem debugging. Returns object with fault_code, captured_at timestamp, and topics object keyed by topic name containing message_type and parsed data."
},
"response": []
},
{
"name": "GET Fault Snapshots (Filtered by Topic)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/faults/SENSOR_OVERTEMP/snapshots?topic=/joint_states",
"host": [
"{{base_url}}"
],
"path": [
"faults",
"SENSOR_OVERTEMP",
"snapshots"
],
"query": [
{
"key": "topic",
"value": "/joint_states"
}
]
},
"description": "Get snapshots filtered by specific topic name. Use this when you only need data from a particular topic."
},
"response": []
},
{
"name": "GET Component Fault Snapshots",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/components/temp_sensor/faults/SENSOR_OVERTEMP/snapshots",
"host": [
"{{base_url}}"
],
"path": [
"components",
"temp_sensor",
"faults",
"SENSOR_OVERTEMP",
"snapshots"
]
},
"description": "Get topic snapshots for a specific component's fault. Same as the system-wide endpoint but scoped to a component context. Useful when working within a component-centric workflow."
},
"response": []
}
]
},
Expand Down
5 changes: 5 additions & 0 deletions src/ros2_medkit_fault_manager/CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
Changelog for package ros2_medkit_fault_manager
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Forthcoming
-----------
* **Breaking (service payload):** the ``GetSnapshots`` response no longer carries ``rosbag.download_url``. It named ``/api/v1/faults/{code}/snapshots/bag``, a route that has not existed since ``0.2.0``, so the field described a download that answers 404. It is dropped rather than repointed: a recording is addressed under its entity as ``/api/v1/{entity-type}/{id}/bulk-data/rosbags/{recording_id}``, and the entity type is part of the gateway's discovery model rather than anything the fault manager holds. The gateway already builds that URI itself, from the recording id it receives on the ``GetFault`` snapshot entries.
* The size a recording reports through ``GetFault``, ``GetSnapshots``, ``GetRosbag`` and ``ListRosbags`` is the storage file a download transfers, read from the bag's own ``metadata.yaml``, instead of the bag directory's total. The two differ by ``metadata.yaml``, which is not served, so every listing used to overstate its own download. The stored ``size_bytes`` and the ``snapshots.rosbag.max_total_storage_mb`` quota are unchanged and still count the whole directory, which is what eviction frees. A recording whose metadata cannot be read, and one split across several storage files past ``snapshots.rosbag.max_bag_size_mb``, both report the directory total as before.

0.7.0 (2026-08-27)
------------------
* 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 (`#623 <https://github.com/selfpatch/ros2_medkit/pull/623>`_, `#620 <https://github.com/selfpatch/ros2_medkit/issues/620>`_)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -487,4 +487,36 @@ class RosbagCapture {
bool dynamic_discovery_{false};
};

/// Bytes a client receives when it downloads the recording at @p bag_path.
///
/// A recording occupies a directory and is served as a single file. Those are two
/// different quantities and the fault manager needs both. ``RosbagFileInfo::size_bytes``
/// is the directory total, because that is what the recording costs against
/// ``max_total_storage_mb`` and what eviction frees. This is the other one: the storage
/// file the download hands over, which is what a caller sizing a buffer or a progress
/// bar needs. Reporting the total in its place overstated every download by
/// ``metadata.yaml``, and on a short recording that is around a tenth of the transfer.
///
/// The file is the one ``metadata.yaml`` names in ``relative_file_paths``, read through
/// the same library that wrote it, so this answers with the bag's own record of its
/// contents rather than by guessing from a file extension.
///
/// Falls back to @p stored_total_bytes, never to zero, when no single served file can
/// be named:
/// - no ``metadata.yaml``, or one that cannot be read or parsed.
/// - ``relative_file_paths`` naming other than exactly one file. Past
/// ``max_bag_size_mb`` rosbag2 splits a recording across several storage files, and
/// then no single number describes the download at all.
/// - a named file that cannot be stat'd.
///
/// None of those is an error worth logging. A pre-metadata bag and a split bag are
/// both normal, this runs once per reported row on every request, and the fallback is
/// a real measurement of the recording rather than a failure sentinel. A zero would
/// not be: it would describe the recording as empty.
///
/// @param bag_path Bag directory as stored in ``RosbagFileInfo::file_path``
/// @param stored_total_bytes The stored directory total, used as the fallback
/// @return Size of the served storage file, or @p stored_total_bytes
size_t rosbag_served_bytes(const std::string & bag_path, size_t stored_total_bytes);

} // namespace ros2_medkit_fault_manager
23 changes: 17 additions & 6 deletions src/ros2_medkit_fault_manager/src/fault_manager_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1158,7 +1158,9 @@ void FaultManagerNode::handle_get_fault(const std::shared_ptr<ros2_medkit_msgs::
// The RECORDING, not the fault code: several recordings of one fault would
// otherwise all carry the same id and collapse into one download.
rosbag_snapshot.bulk_data_id = rosbag_info.recording_id;
rosbag_snapshot.size_bytes = rosbag_info.size_bytes;
// What a download of this recording transfers, not what it occupies on disk.
// The stored figure is the directory total and stays the quota's business.
rosbag_snapshot.size_bytes = rosbag_served_bytes(rosbag_info.file_path, rosbag_info.size_bytes);
rosbag_snapshot.duration_sec = rosbag_info.duration_sec;
rosbag_snapshot.format = rosbag_info.format;
rosbag_snapshot.captured_at_ns = rosbag_info.created_at_ns;
Expand Down Expand Up @@ -1491,15 +1493,24 @@ void FaultManagerNode::handle_get_snapshots(
}
result["topics"] = topics_json;

// Include rosbag info if available
// Include rosbag info if available.
//
// No download URL. This payload used to carry one built as
// /api/v1/faults/{code}/snapshots/bag, a route that no longer exists and answers
// 404, so the field described a download nobody could perform. It is not replaced
// here either: a recording is addressed under its entity
// (/api/v1/{entity-type}/{id}/bulk-data/rosbags/{recording_id}), and the entity
// type is a fact of the gateway's discovery model that the fault manager does not
// hold. Any URL built here would be a guess at one of four prefixes. The gateway
// resolves the entity itself and builds that URI from the recording id it gets on
// the GetFault snapshot entries, which is the one place the mapping is known.
auto rosbag_info = storage_->get_rosbag_file(request->fault_code);
if (rosbag_info) {
nlohmann::json rosbag_json;
rosbag_json["available"] = true;
rosbag_json["duration_sec"] = rosbag_info->duration_sec;
rosbag_json["size_bytes"] = rosbag_info->size_bytes;
rosbag_json["size_bytes"] = rosbag_served_bytes(rosbag_info->file_path, rosbag_info->size_bytes);
rosbag_json["format"] = rosbag_info->format;
rosbag_json["download_url"] = "/api/v1/faults/" + request->fault_code + "/snapshots/bag";
result["rosbag"] = rosbag_json;
} else {
result["rosbag"] = {{"available", false}};
Expand Down Expand Up @@ -1598,7 +1609,7 @@ void FaultManagerNode::handle_get_rosbag(const std::shared_ptr<ros2_medkit_msgs:
response->fault_codes = attached_codes;
response->format = rosbag_info->format;
response->duration_sec = rosbag_info->duration_sec;
response->size_bytes = rosbag_info->size_bytes;
response->size_bytes = rosbag_served_bytes(rosbag_info->file_path, rosbag_info->size_bytes);

RCLCPP_DEBUG(get_logger(), "GetRosbag returned file '%s' for %s", rosbag_info->file_path.c_str(), subject.c_str());
}
Expand Down Expand Up @@ -1635,7 +1646,7 @@ void FaultManagerNode::handle_list_rosbags(
response->file_paths.push_back(info.file_path);
response->formats.push_back(info.format);
response->durations_sec.push_back(info.duration_sec);
response->sizes_bytes.push_back(info.size_bytes);
response->sizes_bytes.push_back(rosbag_served_bytes(info.file_path, info.size_bytes));
response->created_at_ns.push_back(info.created_at_ns);
}

Expand Down
48 changes: 48 additions & 0 deletions src/ros2_medkit_fault_manager/src/rosbag_capture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
#include <mutex>
#include <optional>
#include <rosbag2_cpp/writer.hpp>
#include <rosbag2_storage/bag_metadata.hpp>
#include <rosbag2_storage/metadata_io.hpp>
#include <rosbag2_storage/storage_options.hpp>
#include <set>
#include <sstream>
Expand Down Expand Up @@ -1325,6 +1327,12 @@ std::string RosbagCapture::generate_bag_path(const std::string & fault_code) con
return base_path + "/" + bag_directory_name(fault_code, timestamp);
}

// The recording's footprint, and the figure stored on its rows. It is what the
// recording costs against max_total_storage_mb and what evicting it frees, so it
// counts everything in the directory including metadata.yaml and every file of a
// split. rosbag_served_bytes() below is the other measurement of the same
// recording, the one the API reports, and the two are deliberately different
// numbers - see its comment for which question each answers.
size_t RosbagCapture::calculate_bag_size(const std::string & bag_path) const {
size_t total_size = 0;

Expand All @@ -1345,6 +1353,46 @@ size_t RosbagCapture::calculate_bag_size(const std::string & bag_path) const {
return total_size;
}

// The bytes a download of this recording actually transfers, which is the one
// storage file the bulk-data route hands over. calculate_bag_size() above answers
// the storage question (what the recording costs on disk) and this one answers the
// client's question (what is about to arrive). Reporting the footprint in place of
// the transfer is what made every listing overstate its own download by
// metadata.yaml. Keeping them separate is what lets the quota stay honest while the
// API does.
//
// The served file is read out of the bag's own metadata.yaml rather than guessed
// from a file extension, so a bag that names something unexpected is still described
// by its own record. See the header for every fallback and why none of them logs.
size_t rosbag_served_bytes(const std::string & bag_path, size_t stored_total_bytes) {
try {
rosbag2_storage::MetadataIo metadata_io;
if (!metadata_io.metadata_file_exists(bag_path)) {
return stored_total_bytes;
}

const rosbag2_storage::BagMetadata metadata = metadata_io.read_metadata(bag_path);
// Exactly one, or there is no single served file to measure. Zero means a bag
// that recorded nothing addressable. More than one means a split, where the
// download hands over one segment and the rest are unreachable through it - a
// defect of the download route, not something a size can paper over.
if (metadata.relative_file_paths.size() != 1) {
return stored_total_bytes;
}

const std::filesystem::path storage_file = std::filesystem::path(bag_path) / metadata.relative_file_paths.front();
std::error_code ec;
const auto served = std::filesystem::file_size(storage_file, ec);
if (ec) {
return stored_total_bytes;
}
return static_cast<size_t>(served);
} catch (const std::exception &) {
// read_metadata throws on a metadata.yaml that cannot be read or parsed.
return stored_total_bytes;
}
}

std::vector<std::string> RosbagCapture::evict_bags_over_quota(FaultStorage * storage, size_t max_bytes) {
size_t current_bytes = storage->get_total_rosbag_storage_bytes();
if (current_bytes <= max_bytes) {
Expand Down
Loading
Loading