Skip to content

First steps towards PostgreSQL integration - #651

Open
gstavrinos wants to merge 5 commits into
selfpatch:mainfrom
aperion-robotics:postgres-fault-storage
Open

First steps towards PostgreSQL integration#651
gstavrinos wants to merge 5 commits into
selfpatch:mainfrom
aperion-robotics:postgres-fault-storage

Conversation

@gstavrinos

@gstavrinos gstavrinos commented Sep 4, 2026

Copy link
Copy Markdown

Pull Request

Summary

As discussed in #649, this is an early implementation of the PostgreSQL integration. Keep in mind that currently the code does not compile because the testing suite is not included in this PR.


Issue

Link the related issue (required):


Type

  • Bug fix
  • New feature or tests
  • Breaking change
  • Documentation only

Testing

Tests are yet to be implemented, so the code remains in an early stage, completely untested even for basic functionality


Checklist

  • Breaking changes are clearly described (and announced in docs / changelog if needed)
  • Tests were added or updated if needed
  • Docs were updated if behavior or public API changed

TODOs (based on your checklist, will tick the list as improvements come along)

  • Testing suite following the SQLite paradigm (Currently WIP, ETA next week)
  • Documentation update on how to use the new PostgreSQL fault storage
  • For now, no breaking changes have been added, and the goal is to not introduce any.

As this is still a WIP, feel free to offer suggestions, recommendations or problems you might think of.

Tests are yet to be implemented, so the code remains in an early stage, completely untested even for basic functionality
Copilot AI lite review requested due to automatic review settings September 4, 2026 11:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

There are multiple confirmed correctness/build issues (schema DDL syntax error, incorrect SELECT result handling via affected_rows(), missing test source file in CMake, and credential logging risk) that must be fixed before it can be safely validated.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Introduces an initial PostgreSQL-backed implementation of the FaultStorage backend for ros2_medkit_fault_manager, wiring it into FaultManagerNode via a new storage_type=postgres option and a database_url parameter.

Changes:

  • Add PgFaultStorage (libpqxx-based) with schema initialization and implementations for fault/events, snapshots, near-misses, and rosbag retention APIs.
  • Extend FaultManagerNode to select PostgreSQL storage via parameters.
  • Update build/package dependencies to include PostgreSQL/libpqxx and add a placeholder GTest target for PostgreSQL storage.
File summaries
File Description
src/ros2_medkit_fault_manager/src/postgres_fault_storage.cpp New PostgreSQL FaultStorage implementation and schema creation logic.
src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/postgres_fault_storage.hpp Public header for PgFaultStorage implementing the FaultStorage interface.
src/ros2_medkit_fault_manager/src/fault_manager_node.cpp Adds database_url param and selects PostgreSQL storage when storage_type=postgres.
src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/fault_manager_node.hpp Stores new database_url_ member.
src/ros2_medkit_fault_manager/CMakeLists.txt Adds PostgreSQL dependency linkage and a PostgreSQL test target (currently missing source).
src/ros2_medkit_fault_manager/package.xml Declares libpqxx dependency.
Review details

Suppressed comments (4)

src/ros2_medkit_fault_manager/src/postgres_fault_storage.cpp:547

  • get_fault() uses affected_rows() on a SELECT result; this can incorrectly return nullopt even when a fault exists. Use res.empty() to test whether the query returned rows.
    auto res = tx.exec_params(
        "SELECT fault_code, severity, description, first_occurred_ns, last_occurred_ns, occurrence_count, status, "
        "reporting_sources, last_passed_ns FROM faults WHERE fault_code = $1",
        fault_code);
    if (res.affected_rows() == 0) {

src/ros2_medkit_fault_manager/src/postgres_fault_storage.cpp:653

  • contains() uses affected_rows() on a SELECT result; that can incorrectly report false even when the row exists. For SELECT queries, check res.empty() instead.
    auto res = tx.exec_params("SELECT 1 FROM faults WHERE fault_code = $1 LIMIT 1", fault_code);
    tx.commit();
    return res.affected_rows() > 0;

src/ros2_medkit_fault_manager/src/postgres_fault_storage.cpp:748

  • The newest-capture query aliases the column as max_capture_id but then reads max_capture, and also uses affected_rows() on a SELECT. This can throw at runtime and/or disable trimming. Prefer COALESCE + res.empty() and read the correct alias.
      auto res =
          tx.exec_params("SELECT MAX(capture_id) AS max_capture_id FROM snapshots WHERE fault_code = $1", fault_code);
      int64_t newest_capture = 0;
      if (res.affected_rows() > 0) {
        newest_capture = res[0]["max_capture"].as<int64_t>();

src/ros2_medkit_fault_manager/src/postgres_fault_storage.cpp:753

  • The snapshot-trimming loop uses count_res.affected_rows() == 0 on a SELECT COUNT(*) query; for SELECTs this is not a valid emptiness check and can short-circuit trimming unexpectedly. Use count_res.empty() (or just read the first row) instead.
        auto count_res = tx.exec_params("SELECT COUNT(*) AS sz FROM snapshots WHERE fault_code = $1", fault_code);
        if (count_res.affected_rows() == 0 || count_res[0]["sz"].as<size_t>() <= max_snapshots_per_fault_) {
          break;
  • Files reviewed: 6/6 changed files
  • Comments generated: 6
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +142 to 146
# PostgreSQL storage tests
medkit_add_gtest(test_postgres_storage test/test_postgres_storage.cpp)
target_link_libraries(test_postgres_storage fault_manager_lib)
medkit_target_dependencies(test_postgres_storage rclcpp ros2_medkit_msgs)

}

if (storage_type_ == "postgres") {
RCLCPP_INFO(get_logger(), "Using PostgreSQL fault storage: %s", database_url_.c_str());
Comment on lines +99 to +100
CREATE INDEX IF NOT EXISTS idx_snapshots_fault_code ON snapshots(fault_code);
CREATE INDEX IF NOT EXISTS idx_snapshots_fault_topic ON snapshots(fault_code, topic))");
"first_occurred_ns FROM faults WHERE fault_code = $1",
fault_code);

if (res.affected_rows() > 0) {
Comment on lines +1291 to +1293
auto res = tx.exec_params("SELECT COUNT(*) FROM rosbag_files WHERE file_path = $1", file_path);
tx.commit();
return res.affected_rows();
for (const auto & r : res) {
paths.insert(r["file_path"].as<std::string>());
}
removed = res.affected_rows() > 0;
@gstavrinos

Copy link
Copy Markdown
Author

Oh, wow, Copilot came in aggressively! I will consider the LLM's remarks as I am implementing the tests.

@bburda
bburda self-requested a review September 4, 2026 17:52
@bburda

bburda commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Oh, wow, Copilot came in aggressively! I will consider the LLM's remarks as I am implementing the tests.

No worries, he is always like that. Use your own judgement, some of his comments are not worth fixing.
Also please ping me any time if you need guidance or get stuck, or once the PR is ready for review.

Happy to take a look.

@gstavrinos

Copy link
Copy Markdown
Author

Hello, I am currently finishing up the unit tests.

I plan to update this PR with the new changes tomorrow. Apart from the integration and testing code, I also want to update the documentation. Can you please point me to the required documentation file(s) that need to be updated?

Thanks!

@bburda

bburda commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Hello, I am currently finishing up the unit tests.

I plan to update this PR with the new changes tomorrow. Apart from the integration and testing code, I also want to update the documentation. Can you please point me to the required documentation file(s) that need to be updated?

Thanks!

Hi @gstavrinos, two files to update:

That should be it, but if I find anything else, I'll let you know.

Improved SELECT result handling based on best practices, fixed typos, fixed potential password leaks from printing the database url and other minor issues
@gstavrinos

Copy link
Copy Markdown
Author

I have updated the source code, added the unit tests, a docker compose file and relevant documentation points.

Some remarks:

  • I could not find an easy and non-destructive way to also integrate PostgreSQL to the audit logs. They appear to be intertwined with sqlite.
  • I have not tampered with the github actions of the repository. The provided docker container should also run while testing postgres fault storage.

I will be waiting for your feedback on the provided material and the next steps towards merging.

Thanks again!

@bburda bburda left a comment

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.

Thanks for this contribution :)

There are also two bigger things: the PostgreSQL tests do not run in CI, and the fault manager stops on the next fault report after the database restarts. We can open separate issues for them, or you can fix them in this PR. Please also make sure that CI passes.

find_package(rclcpp REQUIRED)
find_package(ros2_medkit_msgs REQUIRED)
find_package(ros2_medkit_serialization REQUIRED)
find_package(PostgreSQL REQUIRED)

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 put the PostgreSQL backend behind a CMake option that is OFF by default? Right now libpqxx is required for every build, and that breaks builds that never use PostgreSQL:

  • Humble has libpqxx 6.4. The new file does not compile there: connection::close() is protected, a std::vector<std::string> cannot be a query parameter (list_faults()), and <filesystem> is not included.
  • On Lyrical, 22 of 24 fault_manager tests fail. For example, test_capture_thread_pool passes all 12 tests and then aborts at exit with free(): double free detected in tcache 2. The cause is the libpqxx 7.10.0 package in Ubuntu 26.04. A program with only #include <pqxx/pqxx> and int main() { return 0; } aborts in the same way. If I link libpqxx and leave out the header, it exits normally.
  • The runtime stage of the Dockerfile in this repository does not install libpqxx. fault_manager_node now needs libpqxx-7.8.so, so the Docker image cannot start the fault manager.

With the option OFF, please build nothing PostgreSQL-related: no find_package, no postgres_fault_storage.cpp, no pqxx link and no test target. In such a build, create_storage() should stop with a clear error when storage_type is postgres. The #include of postgres_fault_storage.hpp in fault_manager_node.cpp needs the same guard. Please also remove <depend>libpqxx-dev</depend> from package.xml, and write the option name and the libpqxx-dev package in the README, because rosdep does not install it without the <depend>.

// The SQLite backend has to opt into a transaction for this (BEGIN IMMEDIATE)
// In the PostgreSQL implementation the lock used is a transaction lock, so no extra handling is required apart from
// updating the tables properly
pqxx::work tx(*db_conn_);

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.

I built this branch and ran the fault manager against PostgreSQL 16. I reported one fault, restarted the database container and reported a second fault. The node stopped on the second report:

terminate called after throwing an instance of 'pqxx::broken_connection'
  what():  Lost connection to the database server.

The object opens one pqxx::connection and never reconnects, and the service handlers do not catch storage exceptions. So every database restart or network drop stops the fault manager. If you want to fix this in the PR, two changes are needed:

  1. In PgFaultStorage: when a transaction cannot start because the connection is broken, open a new connection and try the call once more. Do not retry on pqxx::in_doubt_error, because the commit may already be applied.
  2. In the node: catch exceptions from storage_ in the service and timer callbacks and return an error response, so the process keeps running while the database is down.

Comment on lines +1004 to +1010
for (const auto & path : unique_paths) {
if (path_referenced(path)) {
continue;
}
std::error_code ec;
std::filesystem::remove_all(path, ec);
}

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 make sure this loop cannot throw? The rows are already committed at this point. path_referenced() starts a new transaction, so it throws when the connection is lost. RosbagCapture treats any exception from store_rosbag_files() as "nothing was stored" and deletes the new bag, and the committed rows then point to a bag that no longer exists. With the change below, the worst case is an orphaned directory, which the other comments in this file already accept.

Suggested change
for (const auto & path : unique_paths) {
if (path_referenced(path)) {
continue;
}
std::error_code ec;
std::filesystem::remove_all(path, ec);
}
for (const auto & path : unique_paths) {
try {
if (path_referenced(path)) {
continue;
}
} catch (const std::exception &) {
continue; // The rows are already committed, so keep the directory.
}
std::error_code ec;
std::filesystem::remove_all(path, ec);
}

try {
db_conn_ = std::make_unique<pqxx::connection>(base_conn_info());
} catch (const std::exception & e) {
GTEST_SKIP() << "No PostgreSQL server reachable (set ROS2_MEDKIT_TEST_PG_CONN): " << e.what();

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.

All 98 tests in this file are skipped in CI, because CI has no PostgreSQL server. The Jazzy test log shows [ SKIPPED ] for each of them. If you want to fix this in the PR, the jazzy-test job in .github/workflows/ci.yml needs:

  • a postgres service at the minimum version you support
  • libpqxx-dev installed and a build with the new option ON
  • ROS2_MEDKIT_TEST_PG_CONN set to postgresql://user:password@postgres:5432/<db>

jazzy-test runs in a container, so the service name works as the host name. After that job is in place, please change GTEST_SKIP() to FAIL() here, so a missing server is reported as a failure. With the option OFF the test target is not built, so the other jobs are not affected.

auto res = tx.exec_params(
"DELETE FROM near_misses WHERE id IN ("
"SELECT id FROM (SELECT id, ROW_NUMBER() OVER "
"(PARTITION BY fault_code ORDER BY id DESC) AS rn FROM near_misses) "

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 an alias to this subquery? PostgreSQL accepts a subquery in FROM without an alias only since version 16. I ran the fault manager from this branch against postgres:15 with default parameters. It stops at startup, because near_miss.max_per_fault defaults to 200 and the constructor runs this statement:

set_max_near_misses_per_fault PostgreSQL error: ERROR:  subquery in FROM must have an alias

With the alias below, the statement works on PostgreSQL 15. Ubuntu 22.04 still ships PostgreSQL 14. Please also write the minimum PostgreSQL version in the docs and run the tests against that version. The compose file uses postgres:18, so the tests could not catch this.

Suggested change
"(PARTITION BY fault_code ORDER BY id DESC) AS rn FROM near_misses) "
"(PARTITION BY fault_code ORDER BY id DESC) AS rn FROM near_misses) AS ranked "

Comment on lines +283 to +303
TEST_F(PgFaultStorageTest, ReopenRepairsLastOccurredInflatedByOldPassedBug) {
// Rows written by releases that advanced last_occurred_ns on PASSED events are
// inflated, and a latched CONFIRMED fault that never fails again would keep the
// wrong timestamp forever. Opening the storage must repair them from
// last_failed_ns.
// NOTE: This should not be possible in the PostgreSQL implementation since it was introduced after the fix
const rclcpp::Time failed_at(1000, 0, RCL_SYSTEM_TIME);
storage_->report_fault_event("FAULT_MIG", ReportFault::Request::EVENT_FAILED, Fault::SEVERITY_ERROR, "Test", "/node1",
failed_at, default_config());
storage_.reset();

// Simulate the old bug: a PASSED event at t=9000s re-dated the row.
exec_raw("UPDATE faults SET last_occurred_ns = 9000000000000 WHERE fault_code = 'FAULT_MIG'");

storage_ = std::make_unique<PgFaultStorage>(conn_info_);

auto fault = storage_->get_fault("FAULT_MIG");
ASSERT_TRUE(fault.has_value());
EXPECT_EQ(rclcpp::Time(fault->last_occurred).nanoseconds(), failed_at.nanoseconds());
}

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 remove this test together with the migration in initialize_schema()? Without that UPDATE this test fails.

Suggested change
TEST_F(PgFaultStorageTest, ReopenRepairsLastOccurredInflatedByOldPassedBug) {
// Rows written by releases that advanced last_occurred_ns on PASSED events are
// inflated, and a latched CONFIRMED fault that never fails again would keep the
// wrong timestamp forever. Opening the storage must repair them from
// last_failed_ns.
// NOTE: This should not be possible in the PostgreSQL implementation since it was introduced after the fix
const rclcpp::Time failed_at(1000, 0, RCL_SYSTEM_TIME);
storage_->report_fault_event("FAULT_MIG", ReportFault::Request::EVENT_FAILED, Fault::SEVERITY_ERROR, "Test", "/node1",
failed_at, default_config());
storage_.reset();
// Simulate the old bug: a PASSED event at t=9000s re-dated the row.
exec_raw("UPDATE faults SET last_occurred_ns = 9000000000000 WHERE fault_code = 'FAULT_MIG'");
storage_ = std::make_unique<PgFaultStorage>(conn_info_);
auto fault = storage_->get_fault("FAULT_MIG");
ASSERT_TRUE(fault.has_value());
EXPECT_EQ(rclcpp::Time(fault->last_occurred).nanoseconds(), failed_at.nanoseconds());
}

- ``""``
- Where the audit database lives. Empty puts it beside the fault database,
or in memory when the fault store is itself in memory or not SQLite.
or in memory when the fault store is itself in memory or unknown storage type.

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.

With storage_type: postgres, create_audit_log() writes the audit log to fault_audit.db next to database_path. By default that is /var/lib/ros2_medkit/, a path that PostgreSQL users do not use otherwise. Could you write that here, so users know the audit trail stays on the robot? The complete example at the end of this page also lists only storage_type and database_path. Please add database_url there.

Suggested change
or in memory when the fault store is itself in memory or unknown storage type.
or in memory when the fault store is itself in memory or unknown storage type.
With ``storage_type: postgres`` it is a local SQLite file ``fault_audit.db`` next to
``database_path``, so the audit trail stays on the robot.


**Memory**: Faults are stored in memory only. Useful for testing or when persistence is not required.

**PostgreSQL**: Faults are persisted to disk and survive node restarts. Needs an external PostgreSQL server.

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 Features list above too? It still says Persistent storage: SQLite backend ensures faults survive node restarts. Please also change this line so it says where the faults and the audit log are stored:

Suggested change
**PostgreSQL**: Faults are persisted to disk and survive node restarts. Needs an external PostgreSQL server.
**PostgreSQL**: Faults are stored in an external PostgreSQL server and survive node restarts. The audit log stays a local SQLite file next to `database_path`.

medkit_add_gtest(test_sqlite_storage test/test_sqlite_storage.cpp)
target_link_libraries(test_sqlite_storage fault_manager_lib)
medkit_target_dependencies(test_sqlite_storage rclcpp ros2_medkit_msgs)

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 remove the trailing whitespace on this line? lint_cmake in the format-lint job fails on it.

Suggested change

Comment on lines +1367 to +1369
{
std::ofstream(bag_dir / "payload.mcap") << "data";
}

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 format this block as clang-format expects? The format-lint job fails on it. pre-commit run --all-files from the repository root catches this and the CMake whitespace before CI does.

Suggested change
{
std::ofstream(bag_dir / "payload.mcap") << "data";
}
{ std::ofstream(bag_dir / "payload.mcap") << "data"; }

@gstavrinos

Copy link
Copy Markdown
Author

Hey Bartosz,

Thank you for your input. I will look into your suggestions and return with fixes and/or questions.

I am a little bit surprised that the tests do not pass, since I run them on my (jazzy) setup. Seems like I will have to test against humble and lyrical too since Ubuntu uses different PostgreSQL versions.

Give me some time for all that and I will be back.

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.

3 participants