feat: add PostgreSQL database driver#1684
Conversation
- Add PostgreSQL variant to configuration and tracker-core Driver enums - Add r2d2_postgres dependency to tracker-core - Create PostgreSQL driver implementation with all Database trait methods - Use std::thread::scope to avoid tokio runtime conflicts (sync postgres crate creates its own internal tokio runtime) - Implement custom Drop to safely destroy connection pool outside tokio - Add GenericConnectionError variant to database Error enum - Add PostgreSQL password masking in configuration - Add tests for local PostgreSQL and testcontainers - Update module documentation Agent-Logs-Url: https://github.com/DamnCrab/torrust-tracker/sessions/b75f4713-d498-4ee2-9224-e0e452ec2f3b Co-authored-by: DamnCrab <42539593+DamnCrab@users.noreply.github.com>
|
Relates to: Hi @DamnCrab thank you for your PR! I will review it today or tomorrow. PostgreSQL will be a really good feature for the tracker. |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds PostgreSQL as a supported persistence backend (in addition to SQLite and MySQL) across configuration and tracker-core.
Changes:
- Introduces a new PostgreSQL driver implementation and wires it into driver selection/build paths.
- Extends configuration schema/docs and secret-masking to support PostgreSQL connection URLs.
- Extends database error handling to cover PostgreSQL-specific errors.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| project-words.txt | Adds “postgresql” to the project dictionary/wordlist. |
| packages/tracker-core/src/databases/setup.rs | Maps configuration driver enum to the tracker-core driver enum for PostgreSQL. |
| packages/tracker-core/src/databases/mod.rs | Updates module docs to list PostgreSQL as a supported driver. |
| packages/tracker-core/src/databases/error.rs | Adds a generic connection error variant and a conversion from postgres errors. |
| packages/tracker-core/src/databases/driver/postgres.rs | New PostgreSQL Database trait implementation plus integration tests. |
| packages/tracker-core/src/databases/driver/mod.rs | Registers the PostgreSQL driver and adds factory support in build(). |
| packages/tracker-core/Cargo.toml | Adds r2d2_postgres dependency. |
| packages/configuration/src/v2_0_0/database.rs | Adds PostgreSQL to config enum/docs + masks passwords for PostgreSQL URLs + unit test. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| conn.execute(create_torrents_table, &[]) | ||
| .expect("Could not create torrents table."); | ||
| conn.execute(create_torrent_aggregate_metrics_table, &[]) | ||
| .expect("Could not create torrent_aggregate_metrics table."); | ||
| conn.execute(&create_keys_table, &[]).expect("Could not create keys table."); | ||
| conn.execute(create_whitelist_table, &[]) | ||
| .expect("Could not create whitelist table."); |
There was a problem hiding this comment.
The method returns Result<(), Error> but uses expect(...), which will panic instead of returning a structured Error. This breaks the error-contract of the Database trait and makes initialization failures harder to handle/diagnose in production. Prefer propagating the postgres errors with ? so callers receive an Err(Error::...) instead of a panic.
| conn.execute("DROP TABLE whitelist;", &[]) | ||
| .expect("Could not drop whitelist table."); | ||
| conn.execute("DROP TABLE torrents;", &[]) | ||
| .expect("Could not drop torrents table."); | ||
| conn.execute("DROP TABLE torrent_aggregate_metrics;", &[]) | ||
| .expect("Could not drop torrent_aggregate_metrics table."); | ||
| conn.execute("DROP TABLE keys;", &[]).expect("Could not drop keys table."); |
There was a problem hiding this comment.
Similar to table creation, this uses expect(...) inside a Result-returning method, causing panics rather than returning Error. Additionally, unconditional DROP TABLE ...; will error if tables are already missing (e.g., cleanup called twice), which currently becomes a panic. Use error propagation and consider DROP TABLE IF EXISTS ... to make teardown idempotent.
| completed INTEGER DEFAULT 0 NOT NULL | ||
| );"; | ||
|
|
||
| let create_torrent_aggregate_metrics_table = " | ||
| CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( | ||
| id SERIAL PRIMARY KEY, | ||
| metric_name VARCHAR(50) NOT NULL UNIQUE, | ||
| value INTEGER DEFAULT 0 NOT NULL |
There was a problem hiding this comment.
completed/value are modeled as download counters but stored as Postgres INTEGER (32-bit signed). This will overflow in real-world usage, and the current code also converts via i32::try_from(...).unwrap() / u32::try_from(...).unwrap(), which will panic once counters exceed i32::MAX. Use BIGINT in the schema and i64/u64 (with fallible conversion or clamping strategy) in code to avoid panics and data loss.
| completed INTEGER DEFAULT 0 NOT NULL | |
| );"; | |
| let create_torrent_aggregate_metrics_table = " | |
| CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( | |
| id SERIAL PRIMARY KEY, | |
| metric_name VARCHAR(50) NOT NULL UNIQUE, | |
| value INTEGER DEFAULT 0 NOT NULL | |
| completed BIGINT DEFAULT 0 NOT NULL | |
| );"; | |
| let create_torrent_aggregate_metrics_table = " | |
| CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( | |
| id SERIAL PRIMARY KEY, | |
| metric_name VARCHAR(50) NOT NULL UNIQUE, | |
| value BIGINT DEFAULT 0 NOT NULL |
| fn with_connection<F, T>(&self, f: F) -> Result<T, Error> | ||
| where | ||
| F: FnOnce(&mut r2d2::PooledConnection<PostgresConnectionManager<NoTls>>) -> Result<T, Error> + Send, | ||
| T: Send, | ||
| { | ||
| let pool = self.pool(); | ||
| std::thread::scope(|s| { | ||
| s.spawn(|| { | ||
| let mut conn = pool.get().map_err(|e| (e, DRIVER))?; | ||
| f(&mut conn) | ||
| }) | ||
| .join() | ||
| .expect("PostgreSQL worker thread panicked") | ||
| }) | ||
| } |
There was a problem hiding this comment.
Spawning and joining an OS thread for every DB operation is extremely expensive and can become a throughput bottleneck under load (and risks thread exhaustion if this pattern spreads). If the goal is to avoid nested-tokio-runtime panics, consider switching to an async-native client/pool (e.g., tokio-postgres + a pool) or isolating all sync-postgres work onto a dedicated long-lived worker thread (or bounded thread pool) instead of per-call thread creation.
| let info_hash_hex = info_hash.to_hex_string(); | ||
| self.with_connection(move |conn| { | ||
| let rows = conn.query("SELECT completed FROM torrents WHERE info_hash = $1", &[&info_hash_hex])?; |
There was a problem hiding this comment.
The driver mixes to_hex_string() and to_string() for serializing InfoHash values written/read from the DB. Even if Display currently matches hex, relying on it is brittle and makes cross-driver consistency harder to reason about. Prefer a single explicit encoding (likely to_hex_string() everywhere for DB storage) to guarantee round-tripping and compatibility.
| fn save_torrent_downloads(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { | ||
| let info_hash_str = info_hash.to_string(); | ||
| self.with_connection(move |conn| { | ||
| let completed_i32 = i32::try_from(completed).unwrap(); | ||
|
|
||
| conn.execute( | ||
| "INSERT INTO torrents (info_hash, completed) VALUES ($1, $2) ON CONFLICT (info_hash) DO UPDATE SET completed = EXCLUDED.completed", | ||
| &[&info_hash_str, &completed_i32], | ||
| )?; |
There was a problem hiding this comment.
The driver mixes to_hex_string() and to_string() for serializing InfoHash values written/read from the DB. Even if Display currently matches hex, relying on it is brittle and makes cross-driver consistency harder to reason about. Prefer a single explicit encoding (likely to_hex_string() everywhere for DB storage) to guarantee round-tripping and compatibility.
| let e: DynError = Arc::new(err); | ||
| Error::InvalidQuery { | ||
| source: e.into(), | ||
| driver: Driver::PostgreSQL, |
There was a problem hiding this comment.
This conversion maps all postgres::Error instances to Error::InvalidQuery, including connection/IO failures that can occur during query execution. Since a GenericConnectionError variant was added, consider mapping connection-related postgres errors to GenericConnectionError (and reserving InvalidQuery for SQL/constraint/type errors) so callers can distinguish operational outages from query bugs.
| let e: DynError = Arc::new(err); | |
| Error::InvalidQuery { | |
| source: e.into(), | |
| driver: Driver::PostgreSQL, | |
| let is_db_error = err.as_db_error().is_some(); | |
| let e: DynError = Arc::new(err); | |
| if is_db_error { | |
| Error::InvalidQuery { | |
| source: e.into(), | |
| driver: Driver::PostgreSQL, | |
| } | |
| } else { | |
| Error::GenericConnectionError { | |
| source: e.into(), | |
| driver: Driver::PostgreSQL, | |
| } |
| @@ -14,6 +14,8 @@ pub struct Database { | |||
| /// `./storage/tracker/lib/database/sqlite3.db`. | |||
| /// For `Mysql`, the format is `mysql://db_user:db_user_password:port/db_name`, for | |||
There was a problem hiding this comment.
The MySQL URL format description is missing the @host portion (mysql://user:pass@host:port/db). Since this block was updated for PostgreSQL, it’s a good opportunity to correct the MySQL format line to avoid misleading configuration docs.
| /// For `Mysql`, the format is `mysql://db_user:db_user_password:port/db_name`, for | |
| /// For `Mysql`, the format is `mysql://db_user:db_user_password@host:port/db_name`, for |
| /* | ||
| We run a PostgreSQL container and run all the tests against the same container and database. | ||
|
|
||
| Test for this driver are executed with: |
There was a problem hiding this comment.
Correct grammar in this comment for clarity.
| Test for this driver are executed with: | |
| Tests for this driver are executed with: |
Agent-Logs-Url: https://github.com/DamnCrab/torrust-tracker/sessions/1b7dc46e-9918-4a95-91fe-29c1b375eb8a Co-authored-by: DamnCrab <42539593+DamnCrab@users.noreply.github.com>
Agent-Logs-Url: https://github.com/DamnCrab/torrust-tracker/sessions/1b7dc46e-9918-4a95-91fe-29c1b375eb8a Co-authored-by: DamnCrab <42539593+DamnCrab@users.noreply.github.com>
There was a problem hiding this comment.
Thank you for the PR and for the work to add PostgreSQL support. I reviewed it carefully locally and I appreciate the effort that went into getting the new driver working and covered by tests.
After reviewing the implementation, I’m going to reject this PR in its current form.
The main reason is architectural: the PostgreSQL driver currently spawns a new OS thread for every database operation in order to isolate the sync postgres client from the tracker’s tokio runtime. Even though the implementation uses an r2d2 connection pool, it does not use a reusable execution pool for the blocking PostgreSQL work, so every query still pays the cost of thread creation and join. I do not think that performance model is acceptable for merge.
There is also a correctness issue in the current PostgreSQL implementation: torrent counters are stored as 32-bit INTEGER values and converted through i32, which creates a real overflow/panic risk once counters exceed 2,147,483,647.
In addition, I think the PR is still missing a couple of important pieces for a complete PostgreSQL feature:
- a default PostgreSQL container config file parallel to the existing MySQL one
- proper migrations or at least a migration structure/documentation in the persistence layer
My conclusion is that PostgreSQL support should be implemented after, or together with, the persistence redesign tracked in issue #1525:
I think it will be much easier to introduce PostgreSQL properly once persistence is overhauled, rather than adding it on top of the current design with a costly runtime workaround.
I wrote a more detailed local review here for reference:
Thank you again for the contribution. I would be happy to review a follow-up version once the persistence redesign is in place or this is reworked on top of it.
Summary
Add PostgreSQL as a third supported database backend, alongside the existing SQLite and MySQL drivers.
Motivation
The tracker previously only supported SQLite and MySQL. PostgreSQL is widely used in production environments and adds an important deployment option.
Changes
packages/configurationPostgreSQLvariant to theDriverenum (serializes as"postgresql")mask_secrets()packages/tracker-corer2d2_postgres = "0.18"dependencydatabases/driver/postgres.rs— fullDatabasetrait implementation:ON CONFLICT DO UPDATEupsert semantics (equivalent to SQLite/MySQLINSERT OR REPLACE/ON DUPLICATE KEY UPDATE)PostgreSQLvariant to the tracker-coreDriverenumGenericConnectionErrorvariant to the databaseErrorenum for PostgreSQL-specific connection errorsFrom<r2d2_postgres::postgres::Error>conversion forErrordatabases/setup.rsto handle the newPostgreSQLvariant