Skip to content

Conversation

@Tiryoh
Copy link

@Tiryoh Tiryoh commented Dec 17, 2025

Description

This PR changes the automatically generated bagfile (split) filenames produced by the sequential writer from the current "{prefix}_{counter}" pattern to a new pattern:

{counter}_{prefix}_{timestamp}
  • counter: split index (integer starting from 0, not zero-padded)
  • prefix: derived from the bag directory name, with any default timestamp suffix removed
  • timestamp: local time at file creation, formatted as YYYY_MM_DD-HH_MM_SS

This change was discussed by @fujitatomoya, @MichaelOrlov, and me in [rosbag2#2198](#2198).
It makes each split file self-descriptive and chronologically traceable, improving usability for both human inspection and external tooling.

Key points / possible concerns

  • User-visible filename change for new recordings only

  • The reader behavior remains unchanged (it uses metadata)

  • Backward compatibility is preserved: existing bags can still be replayed and inspected

  • Custom scripts that assume the old pattern (e.g., mybag_0.mcap, mybag_1.mcap) may need to adjust globs or regexes

  • Timestamp is generated in local time for human readability (UTC could be added later if desired)

  • The reindexer was updated to recognize the new filename pattern (including optional double extensions), and corresponding tests/fixtures were adjusted

    • Note: The reindexer still does not decompress file-compressed bags; compressed files must be accessible or decompressed beforehand

Is this user-facing behavior change?

Yes.

When a custom name is specified (e.g., ros2 bag record -o mybag -a):

Before After
mybag/mybag_0.mcap mybag/0_mybag_2025_11_04-12_30_20.mcap
mybag/mybag_1.mcap mybag/1_mybag_2025_11_04-12_45_20.mcap

This represents the case where the user explicitly specified the output name with -o.

When no name is specified (default timestamped directory):

Before After
rosbag2_2025_09_30-09_11_30/rosbag2_2025_09_30-09_11_30_0.mcap rosbag2_2025_09_30-09_11_30/0_rosbag2_2025_09_30-09_11_30.mcap
rosbag2_2025_09_30-09_11_30/rosbag2_2025_09_30-09_11_30_1.mcap rosbag2_2025_09_30-09_11_30/1_rosbag2_2025_09_30-09_12_30.mcap

This represents the default behavior when no -o argument is provided, and the bag directory name is automatically timestamped.

Impact:

  • Easier manual identification of file order and creation time
  • External tooling and scripts relying on filename patterns may require minor adjustments
  • Reading and playing existing recordings remains unaffected

Did you use Generative AI?

Yes.

  • ChatGPT (GPT-5) was used for drafting this PR description text
  • Cursor (Composer 1) was used for writing/updating the test code

Additional Information

  • Timestamp format: YYYY_MM_DD-HH_MM_SS (local time)

  • Implementation modifies format_storage_uri within the sequential writer, updates reindexer filename parsing, and refreshes related unit/integration tests

  • No metadata or storage layout changes; behavior is limited to filename generation/recognition

  • Optionally, UTC timestamps or configurable formats could be considered in a future PR

  • Test resources in rosbag2_tests/resources/*/cdr_test/metadata.yaml were intentionally not updated

    • The test logic now uses get_actual_cdr_test_filename() to retrieve actual filenames from metadata at runtime, eliminating the need to regenerate test bags for the new naming convention

Manual verification

Record behavior with split service

While recording with ros2 bag record -a, the split service was called during recording and the generated filenames were verified.

Before (legacy filename pattern):

$ ls rosbag2_2025_12_17-20_37_09/
metadata.yaml  rosbag2_2025_12_17-20_37_09_0.mcap  rosbag2_2025_12_17-20_37_09_1.mcap  rosbag2_2025_12_17-20_37_09_2.mcap

$ ls mybag/
metadata.yaml  mybag_0.mcap  mybag_1.mcap  mybag_2.mcap  mybag_3.mcap

After (new filename pattern):

$ ls rosbag2_2025_12_17-20_55_50/
0_rosbag2_2025_12_17-20_55_50.mcap  1_rosbag2_2025_12_17-20_56_06.mcap  2_rosbag2_2025_12_17-20_56_16.mcap  metadata.yaml

$ ls mybag_new/
0_mybag_new_2025_12_17-20_57_22.mcap  1_mybag_new_2025_12_17-20_57_31.mcap  2_mybag_new_2025_12_17-20_57_40.mcap  metadata.yaml

This confirms that split files created during an active recording session follow the new naming convention for both default timestamped directories and explicitly named bags.


Reindex behavior

Reindexing was tested using the updated ros2 bag command on both legacy and new filename patterns.

Legacy filename pattern:

$ ls mybag
metadata.yaml  mybag_0.mcap  mybag_1.mcap  mybag_2.mcap  mybag_3.mcap
$ ros2 bag reindex mybag
[INFO] [1765972695.600521161] [rosbag2_cpp]: Beginning reindexing bag in directory: "mybag"

closing.

closing.

closing.

closing.
[INFO] [1765972695.620651394] [rosbag2_cpp]: Reindexing complete.

New filename pattern:

$ ls mybag_new/
0_mybag_new_2025_12_17-20_57_22.mcap  1_mybag_new_2025_12_17-20_57_31.mcap  2_mybag_new_2025_12_17-20_57_40.mcap  metadata.yaml
$ ros2 bag reindex mybag_new
[INFO] [1765972868.127763161] [rosbag2_cpp]: Beginning reindexing bag in directory: "mybag_new"

closing.

closing.

closing.
[INFO] [1765972868.152821525] [rosbag2_cpp]: Reindexing complete.

This confirms that ros2 bag reindex works correctly with both legacy and new split filename patterns.

Change rosbag2 bag filename format to {counter}_{prefix}_{timestamp}.
Extract prefix from directory name by removing timestamp pattern,
and use file creation timestamp instead of directory timestamp
to avoid duplicate timestamps in filenames.

- Update sequential writer and reindexer to generate filenames with index+name+timestamp format
- Update and refactor filename testing

Signed-off-by: Daisuke Sato <[email protected]>
Copy link
Contributor

@MichaelOrlov MichaelOrlov left a comment

Choose a reason for hiding this comment

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

@Tiryoh Thank you for your contribution.
While the changes in the code itself are pretty much minor, I would say. However, the changes in the tests are trickier and needs to be adjusted and cleaned up.

Can you please rename and redesign the std::filesystem::path RecordFixture::get_actual_bag_file_path(int split_index = 0) const method to be get_bag_file_path_from_metadata(BagMetadata metadata, int split_index = 0). i.e. we can wait for metadata explicitly with another helper functions in tests and this API will be more clear to understand how are we getting a filename.
Also, please move it to the rosbag2_test_common in a new bag_files_helpers.hpp file as well as other bag file helper functions such as

  • wait_for_metadata(std::chrono::duration<float> timeout = std::chrono::seconds(5)) const
  • std::filesystem::path get_relative_bag_file_path(int split_index)
  • std::filesystem::path get_compressed_bag_file_path(int split_index)
  • void wait_for_storage_file(const std::filesystem::path & root_bag_path, std::chrono::duration<float> timeout)
  • etc...

Copy link
Contributor

Choose a reason for hiding this comment

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

Please declare the following helper functions as public methods of the SequentialCompressionWriterTest test fixture to check the path in multiple places, the same way, and avoid code duplication.

  bool path_match_expected_regex(const std::string & path) const
  {
    // New filename format: {counter}_{bag_base_dir}_{timestamp}.{compressor}
    // Timestamp is generated at runtime, so validate using regex
    // Use static to avoid recompiling regex on each test run
    static const std::string file_pattern_str =
      R"(\d+_)" + bag_base_dir_ + R"(_)" +
      std::string(rosbag2_cpp::writers::TIMESTAMP_PATTERN) +
      R"(\.)" + std::string(DefaultTestCompressor);
    static const std::regex file_pattern(file_pattern_str);
    return std::regex_match(path, file_pattern);
    // "Path '" << path << "' does not match expected pattern for file " << counter;
  }

  ::testing::AssertionResult file_counter_at_start(const std::string & path, size_t counter) const
  {
    std::stringstream expected_prefix;
    expected_prefix << counter << "_" << bag_base_dir_ << "_";
    if (path.find(expected_prefix.str()) == 0) {
      return ::testing::AssertionSuccess();
    }
    return ::testing::AssertionFailure() << "Path '" << path <<
           "' does not start with expected prefix '" << expected_prefix.str() << "'";
  }

Expected usage:

  size_t counter = 0;
  for (const auto & path : intercepted_write_metadata_.relative_file_paths) {
    // Verify that filename matches expected format
    EXPECT_TRUE(path_match_expected_regex(path)) <<
      "Path '" << path << "' does not match expected pattern for file " << counter;

    // Verify that counter is at the correct position
    EXPECT_TRUE(file_counter_at_start(path, counter));
    counter++;
  }

Comment on lines 295 to 297
// Right now `base_folder_` is always just the folder name for where to install the bagfile.
// The name of the folder needs to be queried in case
// SequentialWriter is opened with a relative path.
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can delete the original comment it has become irrelevant and outdated

Suggested change
// Right now `base_folder_` is always just the folder name for where to install the bagfile.
// The name of the folder needs to be queried in case
// SequentialWriter is opened with a relative path.

Comment on lines +301 to +337

// Extract prefix from directory name by removing timestamp pattern if present
// This handles the case when --output is not specified and default timestamped directory is used
// Currently, the default timestamp format is `YYYY_MM_DD-HH_MM_SS`
std::string dir_name = fs::path(base_folder).filename().generic_string();
// Handle edge case where filename() returns empty (e.g., base_folder is "/" or ".")
// This should not happen in practice since base_folder is validated in open(), but
// we add this check for defensive programming.
if (dir_name.empty()) {
dir_name = "rosbag2"; // Use default prefix
}
static std::regex timestamp_pattern("_" + std::string(TIMESTAMP_PATTERN) + "$");
std::string prefix = std::regex_replace(dir_name, timestamp_pattern, "");

// Generate timestamp at file creation time
// Timestamp is generated in local time.
// During DST switches the same string may occur twice.
// The sequence counter is part of the filename, so duplicates
// still remain distinguishable.
auto now = std::chrono::system_clock::now();
auto time_t = std::chrono::system_clock::to_time_t(now);
std::tm tm_buf;
#ifdef _WIN32
localtime_s(&tm_buf, &time_t);
#else
localtime_r(&time_t, &tm_buf);
#endif

std::stringstream timestamp_stream;
timestamp_stream << std::put_time(&tm_buf, "%Y_%m_%d-%H_%M_%S");
std::string timestamp = timestamp_stream.str();

// Generate filename in format {storage_count}_{prefix}_{timestamp}
// Note: Underscores are used as separators. If the prefix contains underscores,
// this creates theoretical ambiguity when parsing filenames. However, parsing is
// typically done by matching the timestamp pattern from the end, which avoids
// ambiguity in practice.
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitpick: to shorten the body and rephrase a bit comments.

Suggested change
// Extract prefix from directory name by removing timestamp pattern if present
// This handles the case when --output is not specified and default timestamped directory is used
// Currently, the default timestamp format is `YYYY_MM_DD-HH_MM_SS`
std::string dir_name = fs::path(base_folder).filename().generic_string();
// Handle edge case where filename() returns empty (e.g., base_folder is "/" or ".")
// This should not happen in practice since base_folder is validated in open(), but
// we add this check for defensive programming.
if (dir_name.empty()) {
dir_name = "rosbag2"; // Use default prefix
}
static std::regex timestamp_pattern("_" + std::string(TIMESTAMP_PATTERN) + "$");
std::string prefix = std::regex_replace(dir_name, timestamp_pattern, "");
// Generate timestamp at file creation time
// Timestamp is generated in local time.
// During DST switches the same string may occur twice.
// The sequence counter is part of the filename, so duplicates
// still remain distinguishable.
auto now = std::chrono::system_clock::now();
auto time_t = std::chrono::system_clock::to_time_t(now);
std::tm tm_buf;
#ifdef _WIN32
localtime_s(&tm_buf, &time_t);
#else
localtime_r(&time_t, &tm_buf);
#endif
std::stringstream timestamp_stream;
timestamp_stream << std::put_time(&tm_buf, "%Y_%m_%d-%H_%M_%S");
std::string timestamp = timestamp_stream.str();
// Generate filename in format {storage_count}_{prefix}_{timestamp}
// Note: Underscores are used as separators. If the prefix contains underscores,
// this creates theoretical ambiguity when parsing filenames. However, parsing is
// typically done by matching the timestamp pattern from the end, which avoids
// ambiguity in practice.
// Extract prefix from directory name by removing timestamp pattern if present
// This handles the case when --output is not specified and default timestamped directory is used
// Currently, the default timestamp format is `YYYY_MM_DD-HH_MM_SS`
std::string dir_name = fs::path(base_folder).filename().generic_string();
// Handle edge case where filename() returns empty (e.g., base_folder is "/" or ".")
// This should not happen in practice since base_folder is validated in open(), but
// we add this check for defensive programming.
if (dir_name.empty()) {
dir_name = "rosbag2"; // Use default prefix
}
static std::regex timestamp_pattern("_" + std::string(TIMESTAMP_PATTERN) + "$");
std::string prefix = std::regex_replace(dir_name, timestamp_pattern, "");
// Generate timestamp in local time.
// Note: During DST switches the same string may occur twice. However, we're also adding the
// sequence counter as part of the filename, so duplicates still remain distinguishable.
auto time_t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
std::tm timestamp{};
#ifdef _WIN32
localtime_s(&timestamp, &time_t);
#else
localtime_r(&time_t, &timestamp);
#endif
// Generate filename in format {storage_count}_{prefix}_{timestamp}
// Note: Underscores are used as separators. If the prefix contains underscores, this creates
// theoretical ambiguity when parsing filenames. However, parsing is typically done by matching
// the timestamp pattern from the end, which avoids ambiguity in practice.
std::stringstream storage_file_name;
storage_file_name << storage_count << "_" << prefix << "_" <<
std::put_time(&timestamp, "%Y_%m_%d-%H_%M_%S");
return (fs::path(base_folder) / storage_file_name.str()).generic_string();

Comment on lines +92 to +93
std::string new_format_regex_;
std::string old_format_regex_;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitpick: Please rename to the new{old}_file_format_regex_str_
I would also make them as a const and init right away here using rosbag2_cpp::writers::TIMESTAMP_PATTERN.

  const std::string new_file_format_regex_str_ =
    R"((\d+)_(.*)_)" + std::string(rosbag2_cpp::writers::TIMESTAMP_PATTERN) +
    R"(\.[a-zA-Z0-9]+){1,2})";
  const std::string old_file_format_regex_str_ = R"((.*)_(\d+)(\.[a-zA-Z0-9]+){1,2})";

Comment on lines +53 to +58
bool matches_filename_pattern(
const std::string & filename, const std::string & prefix,
uint64_t expected_counter)
{
// Expected format: {counter}_{prefix}_{timestamp}
std::string expected_prefix = std::to_string(expected_counter) + "_" + prefix + "_";
Copy link
Contributor

Choose a reason for hiding this comment

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

Please rewrite matches_filename_pattern(const std::string & path, const std::string & prefix,, size_t expected_counter) to include stripping file extension to reduce boilerplate code in tests.
i.e.

   // Extract filename from path (remove directory part if present)
    std::string filename = fs::path(path).filename().generic_string();
    // Remove extension if present (e.g., .mcap)
    std::string filename_no_ext = fs::path(filename).stem().generic_string();
  • Also suggest using the size_t type for the counter to avoid type casts.
  • Also, please move helper functions inside the test fixture class. To have the ability to use ASSERT_TRUE inside helper functions if needed.

R"(\.)" + std::string(DefaultTestCompressor);
static const std::regex closed_file_pattern(closed_file_pattern_str);
static const std::string opened_file_pattern_str =
R"(\d+_)" + std::string("test_bag") + R"(_)" +
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
R"(\d+_)" + std::string("test_bag") + R"(_)" +
R"(\d+_)" + bag_base_dir_ + R"(_)" +

Comment on lines +147 to +154
// Wait for metadata file to exist
const auto start_time = std::chrono::steady_clock::now();
while (std::chrono::steady_clock::now() - start_time < std::chrono::seconds(10)) {
if (metadata_io.metadata_file_exists(bag_path)) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// Wait for metadata file to exist
const auto start_time = std::chrono::steady_clock::now();
while (std::chrono::steady_clock::now() - start_time < std::chrono::seconds(10)) {
if (metadata_io.metadata_file_exists(bag_path)) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
wait_for_metadata(std::chrono::seconds(10));

Comment on lines 41 to 59
std::string get_bag_file_name(int split_index) const
{
std::stringstream bag_file_name;
bag_file_name << get_test_name() << "_" << GetParam() << "_" << split_index;

return bag_file_name.str();
}

fs::path get_bag_file_path(int split_index)
{
return root_bag_path_ / get_relative_bag_file_path(split_index);
}

fs::path get_relative_bag_file_path(int split_index) const
{
const auto storage_id = GetParam();
return fs::path(
rosbag2_test_common::bag_filename_for_storage_id(get_bag_file_name(split_index), storage_id));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

These helper functions are no longer needed and can be deleted to prevent incorrect usage in the future.

<< "Could not find metadata file.";
}

std::filesystem::path get_actual_bag_file_path(int split_index = 0) const
Copy link
Contributor

Choose a reason for hiding this comment

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

Please rename and redesign this method to be get_bag_file_path_from_metadata(BagMetadata metadata, int split_index = 0).
Also, please move it to the rosbag2_test_common in a new bag_files_helpers.hpp file as well as other bag file helper functions such as

  • wait_for_metadata(std::chrono::duration<float> timeout = std::chrono::seconds(5)) const
  • std::filesystem::path get_relative_bag_file_path(int split_index)
  • std::filesystem::path get_compressed_bag_file_path(int split_index)
  • void wait_for_storage_file(const std::filesystem::path & root_bag_path, std::chrono::duration<float> timeout)
  • etc...

return rosbag2_test_common::bag_filename_for_storage_id(bag_file_name.str(), storage_id);
}

std::filesystem::path get_actual_bag_file_path(int split_index = 0) const
Copy link
Contributor

Choose a reason for hiding this comment

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

Please use previously suggested get_bag_file_path_from_metadata(BagMetadata metadata, int split_index = 0) instead of this duplicating function, which is going to be in the new bag_files_helpers.hpp in the rosbag2_test_common package.

@Tiryoh
Copy link
Author

Tiryoh commented Jan 5, 2026

@MichaelOrlov Thank you for the code review. I will go through the comments one by one and address them accordingly.

@fujitatomoya fujitatomoya self-requested a review January 5, 2026 11:47
Copy link
Contributor

@fujitatomoya fujitatomoya left a comment

Choose a reason for hiding this comment

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

lgtm with green CI and @MichaelOrlov 's comment resolved.

most of the changes are adjustment for tests, implementation looks good to me.
the only concern is that, breaking change for scripts. any external tooling assuming the old pattern (mybag_0.mcap) will need updates as acknowledged in the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make timestamp-indexed naming the default for split bagfiles

3 participants