From 2ff3b6bb068da0a3f4fc740fdd97881958dd4050 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 29 Apr 2026 11:12:32 +0100 Subject: [PATCH 1/9] docs(github): add GitHub operator agent and subissue skill --- .github/agents/github-operator.agent.md | 72 ++++++++++ .../link-subissue-to-parent-issue/SKILL.md | 126 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 .github/agents/github-operator.agent.md create mode 100644 .github/skills/dev/github/link-subissue-to-parent-issue/SKILL.md diff --git a/.github/agents/github-operator.agent.md b/.github/agents/github-operator.agent.md new file mode 100644 index 000000000..cb06dcb91 --- /dev/null +++ b/.github/agents/github-operator.agent.md @@ -0,0 +1,72 @@ +--- +name: GitHub Operator +description: GitHub workflow specialist for repository tasks that should stay out of the main implementation context. Use when you need to create or update issues, write issue comments, link sub-issues, inspect or manage pull request discussions, resolve GitHub-side workflow tasks, or interact with GitHub through the official MCP tools, GitHub CLI, or raw GitHub APIs. +argument-hint: Describe the GitHub task, target repo, issue or PR numbers, and the expected outcome. Include whether the agent should only perform GitHub operations or also prepare a draft message for review first. +tools: [execute, read, search, todo] +user-invocable: true +disable-model-invocation: false +--- + +You are the repository's GitHub workflow specialist. Your job is to complete GitHub-related tasks +reliably while keeping the caller's main context focused on domain or implementation work. + +You handle GitHub operations, not general feature implementation. + +## Primary Use Cases + +Use this agent for tasks such as: + +- Creating new issues from approved specifications +- Updating issue titles, labels, bodies, assignees, or comments +- Linking sub-issues to parent issues +- Fetching, summarizing, replying to, or resolving pull request review threads +- Handling GitHub metadata or workflow tasks that would otherwise pollute the main agent context + +## Tool Preference Order + +Always prefer the most structured interface first: + +1. **Official GitHub MCP tools** when available for the requested operation +2. **GitHub CLI** (`gh issue`, `gh pr`, `gh api`) when MCP coverage is missing or limited +3. **Raw GitHub REST or GraphQL API calls** via `gh api` only when needed + +Do not jump directly to raw API calls if a dedicated MCP or CLI command covers the task clearly. + +## Required Workflow + +1. Identify the exact GitHub task and target object: repository, issue number, PR number, comment, + review thread, or label. +2. Read any local specification or context file needed to perform the task correctly. +3. Load the relevant repository skill when one exists. +4. Choose the highest-level GitHub interface that can perform the task safely. +5. Execute the operation with the minimum number of calls needed. +6. Verify the result by reading the updated GitHub object or returned URL. +7. Report only the outcome and key identifiers back to the caller. + +## Repository Guidance + +- Follow `AGENTS.md` for repository-wide standards. +- Prefer these skills when relevant: + - `.github/skills/dev/planning/create-issue/SKILL.md` for issue creation workflow + - `.github/skills/dev/github/link-subissue-to-parent-issue/SKILL.md` for parent/sub-issue linking + - `.github/skills/dev/pr-reviews/fetch-review-threads/SKILL.md` for review thread retrieval + - `.github/skills/dev/pr-reviews/resolve-review-threads/SKILL.md` for closing review threads + +## Important Rules + +- Do not guess repository names, labels, issue numbers, PR numbers, or comment IDs. +- Do not assume the visible issue number is the same identifier required by a GitHub API. +- For sub-issue linking, remember that the REST API expects the child issue's internal GitHub ID, + not its visible issue number. +- Do not mix GitHub task execution with unrelated code changes. +- If a PR review comment requires code changes, stop after identifying the actionable request and + hand control back to the caller or a code-focused agent. +- Keep the workflow deterministic: inspect, act, verify. + +## Output Expectations + +When finishing a task, return: + +1. What was changed or verified +2. The key GitHub identifiers or URLs +3. Any blockers, permissions issues, or follow-up needed diff --git a/.github/skills/dev/github/link-subissue-to-parent-issue/SKILL.md b/.github/skills/dev/github/link-subissue-to-parent-issue/SKILL.md new file mode 100644 index 000000000..891196ea1 --- /dev/null +++ b/.github/skills/dev/github/link-subissue-to-parent-issue/SKILL.md @@ -0,0 +1,126 @@ +--- +name: link-subissue-to-parent-issue +description: Guide for linking an existing GitHub issue as a sub-issue of a parent issue in the torrust-tracker project. Covers the GitHub REST API flow, the required internal issue ID for the child issue, verification, and common failure modes. Use when setting a parent issue for a sub-issue, attaching a child issue to an epic, or linking an existing issue under another issue. Triggers on "set parent issue", "link subissue", "add sub-issue", "attach child issue", or "make issue a subissue". +metadata: + author: torrust + version: "1.0" +--- + +# Linking a Sub-Issue to a Parent Issue + +This skill covers the workflow for linking an existing GitHub issue under a parent issue. + +## When to Use + +Use this when: + +- A child issue already exists and needs to be attached to an epic or parent issue +- You need to set or fix the parent issue of an existing sub-issue +- You want to verify that a sub-issue link was created correctly + +## Important Detail + +The GitHub sub-issues REST API expects the **internal GitHub issue ID** for the child issue, +not the visible issue number. + +- Issue number example: `1715` +- Internal issue ID example: `4349463336` + +If you send the issue number as `sub_issue_id`, GitHub returns a `422` validation error. + +## Standard Workflow + +### 1. Confirm the parent and child issue numbers + +Decide which issue is the parent and which is the child. + +- Parent issue number: the epic or container issue +- Child issue number: the issue to attach under the parent + +### 2. Get the internal ID for the child issue + +```bash +gh api /repos/torrust/torrust-tracker/issues/{child-issue-number} --jq '.id' +``` + +Example: + +```bash +gh api /repos/torrust/torrust-tracker/issues/1715 --jq '.id' +``` + +### 3. Link the child issue to the parent issue + +```bash +gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/torrust/torrust-tracker/issues/{parent-issue-number}/sub_issues \ + --input - <<'EOF' +{"sub_issue_id": {child-internal-id}} +EOF +``` + +Example: + +```bash +gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/torrust/torrust-tracker/issues/1525/sub_issues \ + --input - <<'EOF' +{"sub_issue_id": 4349463336} +EOF +``` + +### 4. Verify the link + +Check the child issue's `parent_issue_url`: + +```bash +gh api /repos/torrust/torrust-tracker/issues/{child-issue-number} --jq '.parent_issue_url' +``` + +Example: + +```bash +gh api /repos/torrust/torrust-tracker/issues/1715 --jq '.parent_issue_url' +``` + +Expected result: + +```text +https://api.github.com/repos/torrust/torrust-tracker/issues/1525 +``` + +## Common Failure Modes + +### `422` Invalid property `/sub_issue_id` + +Cause: you passed the child issue number instead of the child's internal issue ID. + +Fix: fetch the child issue with `gh api ... --jq '.id'` and use that value. + +### `404 Not Found` + +Possible causes: + +- Wrong repository path +- Wrong parent issue number +- Missing permissions for sub-issue management +- The repository or issue does not support the operation in the current context + +Fix: verify the repo, the parent issue number, and your GitHub permissions. + +## Optional MCP Alternative + +If GitHub MCP tools are available, prefer the dedicated sub-issue tool over raw API calls. +Still make sure you pass the **internal issue ID** for the child issue, not the issue number. + +## Notes for Torrust Tracker + +- Parent issues are often EPICs in `docs/issues/` +- Child issues usually have their own spec file and implementation branch +- After creating and linking a new issue, rename the local spec file to include the assigned issue number From 86fc930a0452ef985328e4aa1d768ec54dbc9968 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 29 Apr 2026 11:51:33 +0100 Subject: [PATCH 2/9] docs(issues): rename spec and link to GitHub issue #1715 --- .../20260429000000_keep_database_as_aggregate_supertrait.md | 2 +- docs/issues/1525-overhaul-persistence.md | 2 +- ...s.md => 1715-1525-04b-migrate-consumers-to-narrow-traits.md} | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) rename docs/issues/{1525-04b-migrate-consumers-to-narrow-traits.md => 1715-1525-04b-migrate-consumers-to-narrow-traits.md} (99%) diff --git a/docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md b/docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md index 415c45930..f0b169bb3 100644 --- a/docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md +++ b/docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md @@ -76,7 +76,7 @@ between two trait objects would be a different story, but is not needed here. about — but it is a mechanical change across test files. - `Database` will persist as long as `Arc>` wiring exists. That wiring will be replaced in subissue #1525-04b - ([docs/issues/1525-04b-migrate-consumers-to-narrow-traits.md](../issues/1525-04b-migrate-consumers-to-narrow-traits.md)) + ([docs/issues/1715-1525-04b-migrate-consumers-to-narrow-traits.md](../issues/1715-1525-04b-migrate-consumers-to-narrow-traits.md)) by a plain `DatabaseStores` struct (one `Arc` field per context). `TrackerCoreContainer` will hold `DatabaseStores` instead of `Arc>`; each service is wired at construction time by diff --git a/docs/issues/1525-overhaul-persistence.md b/docs/issues/1525-overhaul-persistence.md index fd7f26f63..b114573da 100644 --- a/docs/issues/1525-overhaul-persistence.md +++ b/docs/issues/1525-overhaul-persistence.md @@ -108,7 +108,7 @@ You can then browse or search it while working in the main repository. ### 4b) Migrate consumers to narrow persistence traits -- Spec file: `docs/issues/1525-04b-migrate-consumers-to-narrow-traits.md` +- Spec file: `docs/issues/1715-1525-04b-migrate-consumers-to-narrow-traits.md` - Outcome: every consumer holds only the narrow trait(s) it uses; `Database` becomes a private compile-time guard inside `databases/` diff --git a/docs/issues/1525-04b-migrate-consumers-to-narrow-traits.md b/docs/issues/1715-1525-04b-migrate-consumers-to-narrow-traits.md similarity index 99% rename from docs/issues/1525-04b-migrate-consumers-to-narrow-traits.md rename to docs/issues/1715-1525-04b-migrate-consumers-to-narrow-traits.md index 2dc5b4926..d1ed29a07 100644 --- a/docs/issues/1525-04b-migrate-consumers-to-narrow-traits.md +++ b/docs/issues/1715-1525-04b-migrate-consumers-to-narrow-traits.md @@ -165,6 +165,7 @@ re-exporting it from `databases/mod.rs`. Keep it accessible inside ## References - EPIC: #1525 +- GitHub Issue: #1715 - Predecessor: [docs/issues/1713-1525-04-split-persistence-traits.md](1713-1525-04-split-persistence-traits.md) - ADR: [docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md](../adrs/20260429000000_keep_database_as_aggregate_supertrait.md) - Successor: [docs/issues/1525-05-migrate-sqlite-and-mysql-to-sqlx.md](1525-05-migrate-sqlite-and-mysql-to-sqlx.md) From f6283770f0947d763fe98136d3cdf46acc02b5c1 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 29 Apr 2026 11:51:53 +0100 Subject: [PATCH 3/9] refactor(tracker-core): migrate consumers to narrow persistence traits Replace Arc> with the four narrow traits introduced in #1714: AuthKeyStore, TorrentMetricsStore, WhitelistStore, SchemaMigrator. - Introduce DatabaseStores bundle in databases::setup; initialize_database now returns DatabaseStores instead of Arc> - TrackerCoreContainer wired through DatabaseStores fields - DatabaseKeyRepository accepts Arc - DatabaseDownloadsMetricRepository accepts Arc - WhitelistRepository / setup accept Arc - authentication::handler tests use MockAuthKeyStore directly - REST API server force_database_error uses narrow trait mocks - Benchmark binary operations use narrow traits - Database re-export removed from databases::mod (now private to driver) - Fix consumers in http-tracker-core, udp-tracker-server, axum-http-tracker-server that were missed by the Implementer agent Closes #1715 --- .../src/v1/handlers/announce.rs | 2 +- .../tests/server/mod.rs | 6 +- .../server/v1/contract/context/auth_key.rs | 8 +- .../server/v1/contract/context/whitelist.rs | 6 +- .../http-tracker-core/benches/helpers/util.rs | 2 +- .../src/services/announce.rs | 2 +- .../http-tracker-core/src/services/scrape.rs | 2 +- .../src/authentication/handler.rs | 173 +++--------------- .../key/repository/persisted.rs | 22 +-- .../tracker-core/src/authentication/mod.rs | 4 +- .../driver_bench/database/mod.rs | 18 +- .../persistence_benchmark/driver_bench/mod.rs | 10 +- .../driver_bench/operations/keys.rs | 4 +- .../driver_bench/operations/mod.rs | 8 +- .../driver_bench/operations/torrent.rs | 4 +- .../driver_bench/operations/whitelist.rs | 4 +- packages/tracker-core/src/container.rs | 15 +- .../tracker-core/src/databases/driver/mod.rs | 69 +------ .../src/databases/driver/mysql.rs | 4 +- .../src/databases/driver/sqlite.rs | 2 +- packages/tracker-core/src/databases/mod.rs | 2 +- packages/tracker-core/src/databases/setup.rs | 66 +++++-- .../src/statistics/persisted/downloads.rs | 29 ++- packages/tracker-core/src/test_helpers.rs | 4 +- packages/tracker-core/src/torrent/manager.rs | 3 +- .../tracker-core/src/whitelist/manager.rs | 7 +- .../src/whitelist/repository/persisted.rs | 13 +- packages/tracker-core/src/whitelist/setup.rs | 18 +- .../src/whitelist/test_helpers.rs | 4 +- .../src/handlers/announce.rs | 2 +- .../udp-tracker-server/src/handlers/mod.rs | 2 +- 31 files changed, 186 insertions(+), 329 deletions(-) diff --git a/packages/axum-http-tracker-server/src/v1/handlers/announce.rs b/packages/axum-http-tracker-server/src/v1/handlers/announce.rs index ce718cd30..59fdc5b34 100644 --- a/packages/axum-http-tracker-server/src/v1/handlers/announce.rs +++ b/packages/axum-http-tracker-server/src/v1/handlers/announce.rs @@ -160,7 +160,7 @@ mod tests { let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); let authentication_service = Arc::new(AuthenticationService::new(&config.core, &in_memory_key_repository)); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let announce_handler = Arc::new(AnnounceHandler::new( &config.core, &whitelist_authorization, diff --git a/packages/axum-rest-tracker-api-server/tests/server/mod.rs b/packages/axum-rest-tracker-api-server/tests/server/mod.rs index 9dea49a4c..80fd9d9b2 100644 --- a/packages/axum-rest-tracker-api-server/tests/server/mod.rs +++ b/packages/axum-rest-tracker-api-server/tests/server/mod.rs @@ -3,7 +3,7 @@ pub mod v1; use std::sync::Arc; -use bittorrent_tracker_core::databases::Database; +use bittorrent_tracker_core::databases::SchemaMigrator; /// It forces a database error by dropping all tables. That makes all queries /// fail. @@ -14,6 +14,6 @@ use bittorrent_tracker_core::databases::Database; /// /// - Inject a database mock in the future. /// - Inject directly the database reference passed to the Tracker type. -pub fn force_database_error(tracker: &Arc>) { - tracker.drop_database_tables().unwrap(); +pub fn force_database_error(schema_migrator: &Arc) { + schema_migrator.drop_database_tables().unwrap(); } diff --git a/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/auth_key.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/auth_key.rs index 3781f4f60..fd78791d3 100644 --- a/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/auth_key.rs +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/auth_key.rs @@ -135,7 +135,7 @@ async fn should_fail_when_the_auth_key_cannot_be_generated() { let env = Started::new(&configuration::ephemeral().into()).await; - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator); let request_id = Uuid::new_v4(); @@ -315,7 +315,7 @@ async fn should_fail_when_the_auth_key_cannot_be_deleted() { .await .unwrap(); - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator); let request_id = Uuid::new_v4(); @@ -433,7 +433,7 @@ async fn should_fail_when_keys_cannot_be_reloaded() { .await .unwrap(); - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator); let response = Client::new(env.get_connection_info()) .unwrap() @@ -598,7 +598,7 @@ mod deprecated_generate_key_endpoint { let env = Started::new(&configuration::ephemeral().into()).await; - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator); let request_id = Uuid::new_v4(); let seconds_valid = 60; diff --git a/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/whitelist.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/whitelist.rs index 61fc233d0..0bee10881 100644 --- a/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/whitelist.rs +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/whitelist.rs @@ -115,7 +115,7 @@ async fn should_fail_when_the_torrent_cannot_be_whitelisted() { let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator); let request_id = Uuid::new_v4(); @@ -266,7 +266,7 @@ async fn should_fail_when_the_torrent_cannot_be_removed_from_the_whitelist() { .await .unwrap(); - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator); let request_id = Uuid::new_v4(); @@ -392,7 +392,7 @@ async fn should_fail_when_the_whitelist_cannot_be_reloaded_from_the_database() { .await .unwrap(); - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator); let request_id = Uuid::new_v4(); diff --git a/packages/http-tracker-core/benches/helpers/util.rs b/packages/http-tracker-core/benches/helpers/util.rs index 028d7c535..5c703929c 100644 --- a/packages/http-tracker-core/benches/helpers/util.rs +++ b/packages/http-tracker-core/benches/helpers/util.rs @@ -48,7 +48,7 @@ pub fn initialize_core_tracker_services_with_config(config: &Configuration) -> ( let core_config = Arc::new(config.core.clone()); let database = initialize_database(&config.core); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); diff --git a/packages/http-tracker-core/src/services/announce.rs b/packages/http-tracker-core/src/services/announce.rs index 766f08c12..5b1cce6f0 100644 --- a/packages/http-tracker-core/src/services/announce.rs +++ b/packages/http-tracker-core/src/services/announce.rs @@ -242,7 +242,7 @@ mod tests { let core_config = Arc::new(config.core.clone()); let database = initialize_database(&config.core); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); diff --git a/packages/http-tracker-core/src/services/scrape.rs b/packages/http-tracker-core/src/services/scrape.rs index 4587bc90a..9c5aad3e9 100644 --- a/packages/http-tracker-core/src/services/scrape.rs +++ b/packages/http-tracker-core/src/services/scrape.rs @@ -200,7 +200,7 @@ mod tests { let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); let authentication_service = Arc::new(AuthenticationService::new(&config.core, &in_memory_key_repository)); diff --git a/packages/tracker-core/src/authentication/handler.rs b/packages/tracker-core/src/authentication/handler.rs index b764faeb5..780837026 100644 --- a/packages/tracker-core/src/authentication/handler.rs +++ b/packages/tracker-core/src/authentication/handler.rs @@ -299,7 +299,7 @@ mod tests { use crate::authentication::key::repository::in_memory::InMemoryKeyRepository; use crate::authentication::key::repository::persisted::DatabaseKeyRepository; use crate::databases::setup::initialize_database; - use crate::databases::{Database, MockAuthKeyStore}; + use crate::databases::{AuthKeyStore, MockAuthKeyStore}; fn instantiate_keys_handler() -> KeysHandler { let config = configuration::ephemeral_private(); @@ -307,8 +307,8 @@ mod tests { instantiate_keys_handler_with_configuration(&config) } - fn instantiate_keys_handler_with_database(database: &Arc>) -> KeysHandler { - let db_key_repository = Arc::new(DatabaseKeyRepository::new(database)); + fn instantiate_keys_handler_with_database(auth_key_store: &Arc) -> KeysHandler { + let db_key_repository = Arc::new(DatabaseKeyRepository::new(auth_key_store)); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); KeysHandler::new(&db_key_repository, &in_memory_key_repository) @@ -317,130 +317,15 @@ mod tests { fn instantiate_keys_handler_with_configuration(config: &Configuration) -> KeysHandler { // todo: pass only Core configuration - let database = initialize_database(&config.core); - let db_key_repository = Arc::new(DatabaseKeyRepository::new(&database)); + let stores = initialize_database(&config.core); + let db_key_repository = Arc::new(DatabaseKeyRepository::new(&stores.auth_key_store)); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); KeysHandler::new(&db_key_repository, &in_memory_key_repository) } - /// Test double that satisfies `Database` by delegating auth-key calls to - /// `MockAuthKeyStore` and panicking for all other traits. - #[cfg(test)] - #[derive(Default)] - struct AuthKeyStoreMock { - pub inner: MockAuthKeyStore, - } - #[cfg(test)] - impl crate::databases::SchemaMigrator for AuthKeyStoreMock { - fn create_database_tables(&self) -> Result<(), crate::databases::error::Error> { - unimplemented!() - } - - fn drop_database_tables(&self) -> Result<(), crate::databases::error::Error> { - unimplemented!() - } - } - - #[cfg(test)] - impl crate::databases::TorrentMetricsStore for AuthKeyStoreMock { - fn load_all_torrents_downloads( - &self, - ) -> Result { - unimplemented!() - } - - fn load_torrent_downloads( - &self, - _info_hash: &bittorrent_primitives::info_hash::InfoHash, - ) -> Result, crate::databases::error::Error> { - unimplemented!() - } - - fn save_torrent_downloads( - &self, - _info_hash: &bittorrent_primitives::info_hash::InfoHash, - _downloaded: u32, - ) -> Result<(), crate::databases::error::Error> { - unimplemented!() - } - - fn increase_downloads_for_torrent( - &self, - _info_hash: &bittorrent_primitives::info_hash::InfoHash, - ) -> Result<(), crate::databases::error::Error> { - unimplemented!() - } - - fn load_global_downloads( - &self, - ) -> Result, crate::databases::error::Error> { - unimplemented!() - } - - fn save_global_downloads( - &self, - _downloaded: torrust_tracker_primitives::NumberOfDownloads, - ) -> Result<(), crate::databases::error::Error> { - unimplemented!() - } - - fn increase_global_downloads(&self) -> Result<(), crate::databases::error::Error> { - unimplemented!() - } - } - - #[cfg(test)] - impl crate::databases::WhitelistStore for AuthKeyStoreMock { - fn load_whitelist(&self) -> Result, crate::databases::error::Error> { - unimplemented!() - } - - fn get_info_hash_from_whitelist( - &self, - _info_hash: bittorrent_primitives::info_hash::InfoHash, - ) -> Result, crate::databases::error::Error> { - unimplemented!() - } - - fn add_info_hash_to_whitelist( - &self, - _info_hash: bittorrent_primitives::info_hash::InfoHash, - ) -> Result { - unimplemented!() - } - - fn remove_info_hash_from_whitelist( - &self, - _info_hash: bittorrent_primitives::info_hash::InfoHash, - ) -> Result { - unimplemented!() - } - } - - #[cfg(test)] - impl crate::databases::AuthKeyStore for AuthKeyStoreMock { - fn load_keys(&self) -> Result, crate::databases::error::Error> { - self.inner.load_keys() - } - - fn get_key_from_keys( - &self, - key: &crate::authentication::Key, - ) -> Result, crate::databases::error::Error> { - self.inner.get_key_from_keys(key) - } - - fn add_key_to_keys( - &self, - auth_key: &crate::authentication::PeerKey, - ) -> Result { - self.inner.add_key_to_keys(auth_key) - } - - fn remove_key_from_keys(&self, key: &crate::authentication::Key) -> Result { - self.inner.remove_key_from_keys(key) - } + fn mock_auth_key_store() -> MockAuthKeyStore { + MockAuthKeyStore::new() } mod handling_expiring_peer_keys { @@ -476,12 +361,12 @@ mod tests { use torrust_tracker_clock::clock::{self, Time}; use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::{ - instantiate_keys_handler, instantiate_keys_handler_with_database, AuthKeyStoreMock, + instantiate_keys_handler, instantiate_keys_handler_with_database, mock_auth_key_store, }; use crate::authentication::handler::AddKeyRequest; use crate::authentication::PeerKey; use crate::databases::driver::Driver; - use crate::databases::{self, Database}; + use crate::databases::{self, AuthKeyStore}; use crate::error::PeerKeyError; use crate::CurrentClock; @@ -510,9 +395,8 @@ mod tests { // The key should be valid the next 60 seconds. let expected_valid_until = clock::Stopped::now_add(&Duration::from_secs(60)).unwrap(); - let mut database_mock = AuthKeyStoreMock::default(); + let mut database_mock = mock_auth_key_store(); database_mock - .inner .expect_add_key_to_keys() .with(function(move |peer_key: &PeerKey| { peer_key.valid_until == Some(expected_valid_until) @@ -524,9 +408,9 @@ mod tests { driver: Driver::Sqlite3, }) }); - let database_mock: Arc> = Arc::new(Box::new(database_mock)); + let auth_key_store: Arc = Arc::new(database_mock); - let keys_handler = instantiate_keys_handler_with_database(&database_mock); + let keys_handler = instantiate_keys_handler_with_database(&auth_key_store); let result = keys_handler .add_peer_key(AddKeyRequest { @@ -549,12 +433,12 @@ mod tests { use torrust_tracker_clock::clock::{self, Time}; use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::{ - instantiate_keys_handler, instantiate_keys_handler_with_database, AuthKeyStoreMock, + instantiate_keys_handler, instantiate_keys_handler_with_database, mock_auth_key_store, }; use crate::authentication::handler::AddKeyRequest; use crate::authentication::{Key, PeerKey}; use crate::databases::driver::Driver; - use crate::databases::{self, Database}; + use crate::databases::{self, AuthKeyStore}; use crate::error::PeerKeyError; use crate::CurrentClock; @@ -618,9 +502,8 @@ mod tests { valid_until: Some(expected_valid_until), }; - let mut database_mock = AuthKeyStoreMock::default(); + let mut database_mock = mock_auth_key_store(); database_mock - .inner .expect_add_key_to_keys() .with(predicate::eq(expected_peer_key)) .times(1) @@ -630,9 +513,9 @@ mod tests { driver: Driver::Sqlite3, }) }); - let database_mock: Arc> = Arc::new(Box::new(database_mock)); + let auth_key_store: Arc = Arc::new(database_mock); - let keys_handler = instantiate_keys_handler_with_database(&database_mock); + let keys_handler = instantiate_keys_handler_with_database(&auth_key_store); let result = keys_handler .add_peer_key(AddKeyRequest { @@ -656,12 +539,12 @@ mod tests { use mockall::predicate::function; use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::{ - instantiate_keys_handler, instantiate_keys_handler_with_database, AuthKeyStoreMock, + instantiate_keys_handler, instantiate_keys_handler_with_database, mock_auth_key_store, }; use crate::authentication::handler::AddKeyRequest; use crate::authentication::PeerKey; use crate::databases::driver::Driver; - use crate::databases::{self, Database}; + use crate::databases::{self, AuthKeyStore}; use crate::error::PeerKeyError; #[tokio::test] @@ -690,9 +573,8 @@ mod tests { #[tokio::test] async fn it_should_fail_adding_a_randomly_generated_key_when_there_is_a_database_error() { - let mut database_mock = AuthKeyStoreMock::default(); + let mut database_mock = mock_auth_key_store(); database_mock - .inner .expect_add_key_to_keys() .with(function(move |peer_key: &PeerKey| peer_key.valid_until.is_none())) .times(1) @@ -702,9 +584,9 @@ mod tests { driver: Driver::Sqlite3, }) }); - let database_mock: Arc> = Arc::new(Box::new(database_mock)); + let auth_key_store: Arc = Arc::new(database_mock); - let keys_handler = instantiate_keys_handler_with_database(&database_mock); + let keys_handler = instantiate_keys_handler_with_database(&auth_key_store); let result = keys_handler .add_peer_key(AddKeyRequest { @@ -725,12 +607,12 @@ mod tests { use mockall::predicate; use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::{ - instantiate_keys_handler, instantiate_keys_handler_with_database, AuthKeyStoreMock, + instantiate_keys_handler, instantiate_keys_handler_with_database, mock_auth_key_store, }; use crate::authentication::handler::AddKeyRequest; use crate::authentication::{Key, PeerKey}; use crate::databases::driver::Driver; - use crate::databases::{self, Database}; + use crate::databases::{self, AuthKeyStore}; use crate::error::PeerKeyError; #[tokio::test] @@ -775,9 +657,8 @@ mod tests { valid_until: None, }; - let mut database_mock = AuthKeyStoreMock::default(); + let mut database_mock = mock_auth_key_store(); database_mock - .inner .expect_add_key_to_keys() .with(predicate::eq(expected_peer_key)) .times(1) @@ -787,9 +668,9 @@ mod tests { driver: Driver::Sqlite3, }) }); - let database_mock: Arc> = Arc::new(Box::new(database_mock)); + let auth_key_store: Arc = Arc::new(database_mock); - let keys_handler = instantiate_keys_handler_with_database(&database_mock); + let keys_handler = instantiate_keys_handler_with_database(&auth_key_store); let result = keys_handler .add_peer_key(AddKeyRequest { diff --git a/packages/tracker-core/src/authentication/key/repository/persisted.rs b/packages/tracker-core/src/authentication/key/repository/persisted.rs index e84a23c9b..c0724f4e2 100644 --- a/packages/tracker-core/src/authentication/key/repository/persisted.rs +++ b/packages/tracker-core/src/authentication/key/repository/persisted.rs @@ -2,15 +2,15 @@ use std::sync::Arc; use crate::authentication::key::{Key, PeerKey}; -use crate::databases::{self, Database}; +use crate::databases::{self, AuthKeyStore}; /// A repository for storing authentication keys in a persistent database. /// /// This repository provides methods to add, remove, and load authentication /// keys from the underlying database. It wraps an instance of a type -/// implementing the [`Database`] trait. +/// implementing the [`AuthKeyStore`] trait. pub struct DatabaseKeyRepository { - database: Arc>, + database: Arc, } impl DatabaseKeyRepository { @@ -18,13 +18,13 @@ impl DatabaseKeyRepository { /// /// # Arguments /// - /// * `database` - A shared reference to a boxed database implementation. + /// * `database` - A shared reference to an auth-key store implementation. /// /// # Returns /// /// A new instance of `DatabaseKeyRepository` #[must_use] - pub fn new(database: &Arc>) -> Self { + pub fn new(database: &Arc) -> Self { Self { database: database.clone(), } @@ -98,9 +98,9 @@ mod tests { fn persist_a_new_peer_key() { let configuration = ephemeral_configuration(); - let database = initialize_database(&configuration); + let stores = initialize_database(&configuration); - let repository = DatabaseKeyRepository::new(&database); + let repository = DatabaseKeyRepository::new(&stores.auth_key_store); let peer_key = PeerKey { key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), @@ -118,9 +118,9 @@ mod tests { fn remove_a_persisted_peer_key() { let configuration = ephemeral_configuration(); - let database = initialize_database(&configuration); + let stores = initialize_database(&configuration); - let repository = DatabaseKeyRepository::new(&database); + let repository = DatabaseKeyRepository::new(&stores.auth_key_store); let peer_key = PeerKey { key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), @@ -140,9 +140,9 @@ mod tests { fn load_all_persisted_peer_keys() { let configuration = ephemeral_configuration(); - let database = initialize_database(&configuration); + let stores = initialize_database(&configuration); - let repository = DatabaseKeyRepository::new(&database); + let repository = DatabaseKeyRepository::new(&stores.auth_key_store); let peer_key = PeerKey { key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), diff --git a/packages/tracker-core/src/authentication/mod.rs b/packages/tracker-core/src/authentication/mod.rs index 12b742b8b..6c3d39f29 100644 --- a/packages/tracker-core/src/authentication/mod.rs +++ b/packages/tracker-core/src/authentication/mod.rs @@ -64,8 +64,8 @@ mod tests { fn instantiate_keys_manager_and_authentication_with_configuration( config: &Configuration, ) -> (Arc, Arc) { - let database = initialize_database(&config.core); - let db_key_repository = Arc::new(DatabaseKeyRepository::new(&database)); + let stores = initialize_database(&config.core); + let db_key_repository = Arc::new(DatabaseKeyRepository::new(&stores.auth_key_store)); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); let authentication_service = Arc::new(service::AuthenticationService::new(&config.core, &in_memory_key_repository)); let keys_handler = Arc::new(KeysHandler::new( diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/mod.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/mod.rs index 1656b2303..02462a365 100644 --- a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/mod.rs +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/mod.rs @@ -1,17 +1,17 @@ use std::path::PathBuf; -use std::sync::Arc; use std::time::Duration; use anyhow::{anyhow, Context, Result}; use bittorrent_tracker_core::databases::driver::Driver; -use bittorrent_tracker_core::databases::Database; +use bittorrent_tracker_core::databases::setup::DatabaseStores; +use bittorrent_tracker_core::databases::SchemaMigrator; use testcontainers::{ContainerAsync, GenericImage}; mod mysql; mod sqlite; pub(super) struct ActiveDatabase { - pub(super) database: Option>>, + pub(super) database: Option, resource: Option, } @@ -56,12 +56,12 @@ impl Drop for ActiveDatabase { } } -pub(super) async fn reset_database(database: &dyn Database) -> Result<()> { - create_database_tables_with_retry(database).await?; - database +pub(super) async fn reset_database(schema_migrator: &dyn SchemaMigrator) -> Result<()> { + create_database_tables_with_retry(schema_migrator).await?; + schema_migrator .drop_database_tables() .context("failed to drop benchmark database tables")?; - create_database_tables_with_retry(database).await + create_database_tables_with_retry(schema_migrator).await } /// Retries table creation until the database is ready. @@ -72,11 +72,11 @@ pub(super) async fn reset_database(database: &dyn Database) -> Result<()> { /// # Errors /// /// Returns an error if the database is still not ready after all retries. -async fn create_database_tables_with_retry(database: &dyn Database) -> Result<()> { +async fn create_database_tables_with_retry(schema_migrator: &dyn SchemaMigrator) -> Result<()> { let mut last_error: Option = None; for _ in 0..5 { - match database.create_database_tables() { + match schema_migrator.create_database_tables() { Ok(()) => return Ok(()), Err(error) => { last_error = Some(error.into()); diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/mod.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/mod.rs index a91fbbc56..33805a20d 100644 --- a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/mod.rs +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/mod.rs @@ -23,15 +23,15 @@ pub struct RawOperationSamples { /// operation fails. pub async fn run(driver: Driver, db_version: &str, ops: OpsCount) -> Result> { let active_database = database::ActiveDatabase::new(driver, db_version).await?; - let db = active_database.database.as_deref().unwrap().as_ref(); - database::reset_database(db).await?; + let stores = active_database.database.as_ref().unwrap(); + database::reset_database(&*stores.schema_migrator).await?; let ops = ops.get(); let mut operations_samples = Vec::new(); - operations::benchmark_torrent_operations(db, ops, &mut operations_samples)?; - operations::benchmark_whitelist_operations(db, ops, &mut operations_samples)?; - operations::benchmark_key_operations(db, ops, &mut operations_samples)?; + operations::benchmark_torrent_operations(&*stores.torrent_metrics_store, ops, &mut operations_samples)?; + operations::benchmark_whitelist_operations(&*stores.whitelist_store, ops, &mut operations_samples)?; + operations::benchmark_key_operations(&*stores.auth_key_store, ops, &mut operations_samples)?; Ok(operations_samples) } diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/keys.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/keys.rs index 484640784..02ed709e8 100644 --- a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/keys.rs +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/keys.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use bittorrent_tracker_core::authentication; -use bittorrent_tracker_core::databases::Database; +use bittorrent_tracker_core::databases::AuthKeyStore; use super::super::sampling::measure_operation; use super::super::RawOperationSamples; @@ -11,7 +11,7 @@ use super::super::RawOperationSamples; /// /// Returns an error if any setup or measured database operation fails. pub(super) fn benchmark_key_operations( - database: &dyn Database, + database: &dyn AuthKeyStore, ops: usize, operations: &mut Vec, ) -> Result<()> { diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/mod.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/mod.rs index 69ec5bc42..962806a46 100644 --- a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/mod.rs +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/mod.rs @@ -3,12 +3,12 @@ mod torrent; mod whitelist; use anyhow::Result; -use bittorrent_tracker_core::databases::Database; +use bittorrent_tracker_core::databases::{AuthKeyStore, TorrentMetricsStore, WhitelistStore}; use super::RawOperationSamples; pub(super) fn benchmark_torrent_operations( - database: &dyn Database, + database: &dyn TorrentMetricsStore, ops: usize, operations: &mut Vec, ) -> Result<()> { @@ -16,7 +16,7 @@ pub(super) fn benchmark_torrent_operations( } pub(super) fn benchmark_whitelist_operations( - database: &dyn Database, + database: &dyn WhitelistStore, ops: usize, operations: &mut Vec, ) -> Result<()> { @@ -24,7 +24,7 @@ pub(super) fn benchmark_whitelist_operations( } pub(super) fn benchmark_key_operations( - database: &dyn Database, + database: &dyn AuthKeyStore, ops: usize, operations: &mut Vec, ) -> Result<()> { diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/torrent.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/torrent.rs index 993a60c74..38b6152f4 100644 --- a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/torrent.rs +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/torrent.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use bittorrent_tracker_core::databases::Database; +use bittorrent_tracker_core::databases::TorrentMetricsStore; use super::super::sampling::{downloads_from_index, info_hash_from_index, measure_operation}; use super::super::RawOperationSamples; @@ -13,7 +13,7 @@ use super::super::RawOperationSamples; /// /// Returns an error if any setup or measured database operation fails. pub(super) fn benchmark_torrent_operations( - database: &dyn Database, + database: &dyn TorrentMetricsStore, ops: usize, operations: &mut Vec, ) -> Result<()> { diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/whitelist.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/whitelist.rs index 2c5b8366e..44e77d3a5 100644 --- a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/whitelist.rs +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/whitelist.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use bittorrent_tracker_core::databases::Database; +use bittorrent_tracker_core::databases::WhitelistStore; use super::super::sampling::{info_hash_from_index, measure_operation}; use super::super::RawOperationSamples; @@ -10,7 +10,7 @@ use super::super::RawOperationSamples; /// /// Returns an error if any setup or measured database operation fails. pub(super) fn benchmark_whitelist_operations( - database: &dyn Database, + database: &dyn WhitelistStore, ops: usize, operations: &mut Vec, ) -> Result<()> { diff --git a/packages/tracker-core/src/container.rs b/packages/tracker-core/src/container.rs index 93b8efd7e..e849b723f 100644 --- a/packages/tracker-core/src/container.rs +++ b/packages/tracker-core/src/container.rs @@ -8,8 +8,7 @@ use crate::authentication::handler::KeysHandler; use crate::authentication::key::repository::in_memory::InMemoryKeyRepository; use crate::authentication::key::repository::persisted::DatabaseKeyRepository; use crate::authentication::service::AuthenticationService; -use crate::databases::setup::initialize_database; -use crate::databases::Database; +use crate::databases::setup::{initialize_database, DatabaseStores}; use crate::scrape_handler::ScrapeHandler; use crate::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; use crate::torrent::manager::TorrentsManager; @@ -22,7 +21,7 @@ use crate::{statistics, whitelist}; pub struct TrackerCoreContainer { pub core_config: Arc, - pub database: Arc>, + pub database_stores: DatabaseStores, pub announce_handler: Arc, pub scrape_handler: Arc, pub keys_handler: Arc, @@ -42,11 +41,11 @@ impl TrackerCoreContainer { core_config: &Arc, swarm_coordination_registry_container: &Arc, ) -> Self { - let database = initialize_database(core_config); + let db = initialize_database(core_config); let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(core_config, &in_memory_whitelist.clone())); - let whitelist_manager = initialize_whitelist_manager(database.clone(), in_memory_whitelist.clone()); - let db_key_repository = Arc::new(DatabaseKeyRepository::new(&database)); + let whitelist_manager = initialize_whitelist_manager(db.whitelist_store.clone(), in_memory_whitelist.clone()); + let db_key_repository = Arc::new(DatabaseKeyRepository::new(&db.auth_key_store)); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); let authentication_service = Arc::new(AuthenticationService::new(core_config, &in_memory_key_repository)); let keys_handler = Arc::new(KeysHandler::new( @@ -56,7 +55,7 @@ impl TrackerCoreContainer { let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::new( swarm_coordination_registry_container.swarms.clone(), )); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&db.torrent_metrics_store)); let torrents_manager = Arc::new(TorrentsManager::new( core_config, @@ -77,7 +76,7 @@ impl TrackerCoreContainer { Self { core_config: core_config.clone(), - database, + database_stores: db, announce_handler, scrape_handler, keys_handler, diff --git a/packages/tracker-core/src/databases/driver/mod.rs b/packages/tracker-core/src/databases/driver/mod.rs index 7126e2e98..bc84eef9c 100644 --- a/packages/tracker-core/src/databases/driver/mod.rs +++ b/packages/tracker-core/src/databases/driver/mod.rs @@ -1,15 +1,12 @@ //! Database driver factory. use std::str::FromStr; -use mysql::Mysql; use serde::{Deserialize, Serialize}; -use sqlite::Sqlite; use super::error::Error; -use super::Database; /// Metric name in DB for the total number of downloads across all torrents. -const TORRENTS_DOWNLOADS_TOTAL: &str = "torrents_downloads_total"; +pub(super) const TORRENTS_DOWNLOADS_TOTAL: &str = "torrents_downloads_total"; /// The database management system used by the tracker. /// @@ -50,71 +47,15 @@ impl FromStr for Driver { } } -/// It builds a new database driver. -/// -/// Example for `SQLite3`: -/// -/// ```text -/// use bittorrent_tracker_core::databases; -/// use bittorrent_tracker_core::databases::driver::Driver; -/// -/// let db_driver = Driver::Sqlite3; -/// let db_path = "./storage/tracker/lib/database/sqlite3.db".to_string(); -/// let database = databases::driver::build(&db_driver, &db_path); -/// ``` -/// -/// Example for `MySQL`: -/// -/// ```text -/// use bittorrent_tracker_core::databases; -/// use bittorrent_tracker_core::databases::driver::Driver; -/// -/// let db_driver = Driver::MySQL; -/// let db_path = "mysql://db_user:db_user_secret_password@mysql:3306/torrust_tracker".to_string(); -/// let database = databases::driver::build(&db_driver, &db_path); -/// ``` -/// -/// Refer to the [configuration documentation](https://docs.rs/torrust-tracker-configuration) -/// for more information about the database configuration. -/// -/// > **WARNING**: The driver instantiation runs database migrations. -/// -/// # Errors -/// -/// This function will return an error if unable to connect to the database. -/// -/// # Panics -/// -/// This function will panic if unable to create database tables. pub mod mysql; pub mod sqlite; -/// It builds a new database driver. -/// -/// # Panics -/// -/// Will panic if unable to create database tables. -/// -/// # Errors -/// -/// Will return `Error` if unable to build the driver. -pub(crate) fn build(driver: &Driver, db_path: &str) -> Result, Error> { - let database: Box = match driver { - Driver::Sqlite3 => Box::new(Sqlite::new(db_path)?), - Driver::MySQL => Box::new(Mysql::new(db_path)?), - }; - - database.create_database_tables().expect("Could not create database tables."); - - Ok(database) -} - #[cfg(test)] pub(crate) mod tests { use std::sync::Arc; use std::time::Duration; - use crate::databases::Database; + use crate::databases::traits::Database; pub async fn run_tests(driver: &Arc>) { // Since the interface is very simple and there are no conflicts between @@ -184,7 +125,7 @@ pub(crate) mod tests { use std::sync::Arc; - use crate::databases::Database; + use crate::databases::traits::Database; use crate::test_helpers::tests::sample_info_hash; // Metrics per torrent @@ -269,7 +210,7 @@ pub(crate) mod tests { use std::time::Duration; use crate::authentication::key::{generate_expiring_key, generate_permanent_key}; - use crate::databases::Database; + use crate::databases::traits::Database; pub fn it_should_load_the_keys(driver: &Arc>) { let permanent_peer_key = generate_permanent_key(); @@ -326,7 +267,7 @@ pub(crate) mod tests { use std::sync::Arc; - use crate::databases::Database; + use crate::databases::traits::Database; use crate::test_helpers::tests::random_info_hash; pub fn it_should_load_the_whitelist(driver: &Arc>) { diff --git a/packages/tracker-core/src/databases/driver/mysql.rs b/packages/tracker-core/src/databases/driver/mysql.rs index 068a4b223..71070f85e 100644 --- a/packages/tracker-core/src/databases/driver/mysql.rs +++ b/packages/tracker-core/src/databases/driver/mysql.rs @@ -5,7 +5,7 @@ //! [`TorrentMetricsStore`](crate::databases::TorrentMetricsStore), //! [`WhitelistStore`](crate::databases::WhitelistStore), //! [`AuthKeyStore`](crate::databases::AuthKeyStore) -//! for `MySQL` using the `r2d2_mysql` connection pool. It configures the MySQL +//! for `MySQL` using the `r2d2_mysql` connection pool. It configures the `MySQL` //! connection based on a URL, creates the necessary tables (for torrent metrics, //! torrent whitelist, and authentication keys), and implements all CRUD //! operations required by the persistence layer. @@ -366,7 +366,7 @@ mod tests { use super::Mysql; use crate::databases::driver::tests::run_tests; - use crate::databases::Database; + use crate::databases::traits::Database; #[derive(Debug, Default)] struct StoppedMysqlContainer {} diff --git a/packages/tracker-core/src/databases/driver/sqlite.rs b/packages/tracker-core/src/databases/driver/sqlite.rs index 3277fd6d7..979b32b8b 100644 --- a/packages/tracker-core/src/databases/driver/sqlite.rs +++ b/packages/tracker-core/src/databases/driver/sqlite.rs @@ -404,7 +404,7 @@ mod tests { use crate::databases::driver::sqlite::Sqlite; use crate::databases::driver::tests::run_tests; - use crate::databases::Database; + use crate::databases::traits::Database; fn ephemeral_configuration() -> Core { let mut config = Core::default(); diff --git a/packages/tracker-core/src/databases/mod.rs b/packages/tracker-core/src/databases/mod.rs index 9dff50ab0..ccbaffca6 100644 --- a/packages/tracker-core/src/databases/mod.rs +++ b/packages/tracker-core/src/databases/mod.rs @@ -60,6 +60,6 @@ pub mod setup; pub mod traits; pub use traits::{ - AuthKeyStore, Database, MockAuthKeyStore, MockSchemaMigrator, MockTorrentMetricsStore, MockWhitelistStore, SchemaMigrator, + AuthKeyStore, MockAuthKeyStore, MockSchemaMigrator, MockTorrentMetricsStore, MockWhitelistStore, SchemaMigrator, TorrentMetricsStore, WhitelistStore, }; diff --git a/packages/tracker-core/src/databases/setup.rs b/packages/tracker-core/src/databases/setup.rs index 6ba9f2a64..d98bf3876 100644 --- a/packages/tracker-core/src/databases/setup.rs +++ b/packages/tracker-core/src/databases/setup.rs @@ -3,25 +3,46 @@ use std::sync::Arc; use torrust_tracker_configuration::Core; -use super::driver::{self, Driver}; -use super::Database; +use super::driver::mysql::Mysql; +use super::driver::sqlite::Sqlite; +use super::driver::Driver; +use super::traits::{AuthKeyStore, SchemaMigrator, TorrentMetricsStore, WhitelistStore}; -/// Initializes and returns a database instance based on the provided configuration. +/// A bundle of narrow-trait store references, one per persistence context. /// -/// This function creates a new database instance according to the settings +/// The factory (`initialize_database`) constructs the concrete driver once and +/// coerces it into each narrow `Arc`. Individual services are +/// wired at construction time by passing the relevant field +/// (e.g. `database_stores.auth_key_store.clone()`) to each constructor. +/// Services themselves never hold a `DatabaseStores`; they only see the narrow +/// trait they need. +pub struct DatabaseStores { + /// Schema lifecycle: create / drop tables. + pub schema_migrator: Arc, + /// Per-torrent and global download counters. + pub torrent_metrics_store: Arc, + /// Torrent infohash whitelist. + pub whitelist_store: Arc, + /// Authentication key persistence. + pub auth_key_store: Arc, +} + +/// Initializes and returns a [`DatabaseStores`] bundle based on the provided +/// configuration. +/// +/// This function creates a new database driver according to the settings /// defined in the [`Core`] configuration. It selects the appropriate driver /// (either `Sqlite3` or `MySQL`) as specified in `config.database.driver` and /// attempts to build the database connection using the path defined in /// `config.database.path`. /// -/// The resulting database instance is wrapped in a shared pointer (`Arc`) to a -/// boxed trait object, allowing safe sharing of the database connection across -/// multiple threads. +/// The concrete driver is constructed once and coerced into four narrow +/// `Arc` references, one for each persistence context. /// /// # Panics /// /// This function will panic if the database cannot be initialized (i.e., if the -/// driver fails to build the connection). This is enforced by the use of +/// driver fails to build the connection). This is enforced by the use of /// [`expect`](std::result::Result::expect) in the implementation. /// /// # Example @@ -34,18 +55,37 @@ use super::Database; /// let config = Core::default(); /// /// // Initialize the database; this will panic if initialization fails. -/// let database = initialize_database(&config); -/// -/// // The returned database instance can now be used for persistence operations. +/// let stores = initialize_database(&config); /// ``` #[must_use] -pub fn initialize_database(config: &Core) -> Arc> { +pub fn initialize_database(config: &Core) -> DatabaseStores { let driver = match config.database.driver { torrust_tracker_configuration::Driver::Sqlite3 => Driver::Sqlite3, torrust_tracker_configuration::Driver::MySQL => Driver::MySQL, }; - Arc::new(driver::build(&driver, &config.database.path).expect("Database driver build failed.")) + match driver { + Driver::Sqlite3 => { + let db = Arc::new(Sqlite::new(&config.database.path).expect("Database driver build failed.")); + db.create_database_tables().expect("Could not create database tables."); + DatabaseStores { + schema_migrator: db.clone(), + torrent_metrics_store: db.clone(), + whitelist_store: db.clone(), + auth_key_store: db, + } + } + Driver::MySQL => { + let db = Arc::new(Mysql::new(&config.database.path).expect("Database driver build failed.")); + db.create_database_tables().expect("Could not create database tables."); + DatabaseStores { + schema_migrator: db.clone(), + torrent_metrics_store: db.clone(), + whitelist_store: db.clone(), + auth_key_store: db, + } + } + } } #[cfg(test)] diff --git a/packages/tracker-core/src/statistics/persisted/downloads.rs b/packages/tracker-core/src/statistics/persisted/downloads.rs index 6248bdc73..4c81fb50b 100644 --- a/packages/tracker-core/src/statistics/persisted/downloads.rs +++ b/packages/tracker-core/src/statistics/persisted/downloads.rs @@ -5,14 +5,14 @@ use bittorrent_primitives::info_hash::InfoHash; use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; use crate::databases::error::Error; -use crate::databases::Database; +use crate::databases::TorrentMetricsStore; /// It persists torrent metrics in a database. /// /// This repository persists only a subset of the torrent data: the torrent /// metrics, specifically the number of downloads (or completed counts) for each /// torrent. It relies on a database driver (either `SQLite3` or `MySQL`) that -/// implements the [`Database`] trait to perform the actual persistence +/// implements the [`TorrentMetricsStore`] trait to perform the actual persistence /// operations. /// /// # Note @@ -20,28 +20,27 @@ use crate::databases::Database; /// Not all in-memory torrent data is persisted; only the aggregate metrics are /// stored. pub struct DatabaseDownloadsMetricRepository { - /// A shared reference to the database driver implementation. + /// A shared reference to the torrent metrics store implementation. /// - /// The driver must implement the [`Database`] trait. This allows for - /// different underlying implementations (e.g., `SQLite3` or `MySQL`) to be - /// used interchangeably. - database: Arc>, + /// This allows for different underlying implementations (e.g., `SQLite3` + /// or `MySQL`) to be used interchangeably. + database: Arc, } impl DatabaseDownloadsMetricRepository { - /// Creates a new instance of `DatabasePersistentTorrentRepository`. + /// Creates a new instance of `DatabaseDownloadsMetricRepository`. /// /// # Arguments /// - /// * `database` - A shared reference to a boxed database driver - /// implementing the [`Database`] trait. + /// * `database` - A shared reference to a torrent metrics store + /// implementing the [`TorrentMetricsStore`] trait. /// /// # Returns /// - /// A new `DatabasePersistentTorrentRepository` instance with a cloned - /// reference to the provided database. + /// A new `DatabaseDownloadsMetricRepository` instance with a cloned + /// reference to the provided store. #[must_use] - pub fn new(database: &Arc>) -> DatabaseDownloadsMetricRepository { + pub fn new(database: &Arc) -> DatabaseDownloadsMetricRepository { Self { database: database.clone(), } @@ -149,8 +148,8 @@ mod tests { fn initialize_db_persistent_torrent_repository() -> DatabaseDownloadsMetricRepository { let config = ephemeral_configuration(); - let database = initialize_database(&config); - DatabaseDownloadsMetricRepository::new(&database) + let stores = initialize_database(&config); + DatabaseDownloadsMetricRepository::new(&stores.torrent_metrics_store) } #[test] diff --git a/packages/tracker-core/src/test_helpers.rs b/packages/tracker-core/src/test_helpers.rs index bf21e6f94..1d3b9e117 100644 --- a/packages/tracker-core/src/test_helpers.rs +++ b/packages/tracker-core/src/test_helpers.rs @@ -130,14 +130,14 @@ pub(crate) mod tests { #[must_use] pub fn initialize_handlers(config: &Configuration) -> (Arc, Arc) { - let database = initialize_database(&config.core); + let stores = initialize_database(&config.core); let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(whitelist::authorization::WhitelistAuthorization::new( &config.core, &in_memory_whitelist.clone(), )); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&stores.torrent_metrics_store)); let announce_handler = Arc::new(AnnounceHandler::new( &config.core, diff --git a/packages/tracker-core/src/torrent/manager.rs b/packages/tracker-core/src/torrent/manager.rs index 5acc27980..60ccb54eb 100644 --- a/packages/tracker-core/src/torrent/manager.rs +++ b/packages/tracker-core/src/torrent/manager.rs @@ -170,7 +170,8 @@ mod tests { let swarms = Arc::new(Registry::default()); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::new(swarms)); let database = initialize_database(&config); - let database_persistent_torrent_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let database_persistent_torrent_repository = + Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let torrents_manager = Arc::new(TorrentsManager::new( &config, diff --git a/packages/tracker-core/src/whitelist/manager.rs b/packages/tracker-core/src/whitelist/manager.rs index 452fcb6c5..eed0f3a2e 100644 --- a/packages/tracker-core/src/whitelist/manager.rs +++ b/packages/tracker-core/src/whitelist/manager.rs @@ -96,14 +96,12 @@ mod tests { use torrust_tracker_configuration::Core; use crate::databases::setup::initialize_database; - use crate::databases::Database; use crate::test_helpers::tests::ephemeral_configuration_for_listed_tracker; use crate::whitelist::manager::WhitelistManager; use crate::whitelist::repository::in_memory::InMemoryWhitelist; use crate::whitelist::repository::persisted::DatabaseWhitelist; struct WhitelistManagerDeps { - pub _database: Arc>, pub database_whitelist: Arc, pub in_memory_whitelist: Arc, } @@ -114,8 +112,8 @@ mod tests { } fn initialize_whitelist_manager_and_deps(config: &Core) -> (Arc, Arc) { - let database = initialize_database(config); - let database_whitelist = Arc::new(DatabaseWhitelist::new(database.clone())); + let stores = initialize_database(config); + let database_whitelist = Arc::new(DatabaseWhitelist::new(stores.whitelist_store.clone())); let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_manager = Arc::new(WhitelistManager::new(database_whitelist.clone(), in_memory_whitelist.clone())); @@ -123,7 +121,6 @@ mod tests { ( whitelist_manager, Arc::new(WhitelistManagerDeps { - _database: database, database_whitelist, in_memory_whitelist, }), diff --git a/packages/tracker-core/src/whitelist/repository/persisted.rs b/packages/tracker-core/src/whitelist/repository/persisted.rs index eec6704d6..b449ffadc 100644 --- a/packages/tracker-core/src/whitelist/repository/persisted.rs +++ b/packages/tracker-core/src/whitelist/repository/persisted.rs @@ -3,22 +3,21 @@ use std::sync::Arc; use bittorrent_primitives::info_hash::InfoHash; -use crate::databases::{self, Database}; +use crate::databases::{self, WhitelistStore}; /// The persisted list of allowed torrents. /// /// This repository handles adding, removing, and loading torrents /// from a persistent database like `SQLite` or `MySQL`ç. pub struct DatabaseWhitelist { - /// A database driver implementation: [`Sqlite3`](crate::core::databases::sqlite) - /// or [`MySQL`](crate::core::databases::mysql) - database: Arc>, + /// A whitelist store implementation (e.g., `SQLite3` or `MySQL`). + database: Arc, } impl DatabaseWhitelist { /// Creates a new `DatabaseWhitelist`. #[must_use] - pub fn new(database: Arc>) -> Self { + pub fn new(database: Arc) -> Self { Self { database } } @@ -75,8 +74,8 @@ mod tests { fn initialize_database_whitelist() -> DatabaseWhitelist { let configuration = ephemeral_configuration_for_listed_tracker(); - let database = initialize_database(&configuration); - DatabaseWhitelist::new(database) + let stores = initialize_database(&configuration); + DatabaseWhitelist::new(stores.whitelist_store) } #[test] diff --git a/packages/tracker-core/src/whitelist/setup.rs b/packages/tracker-core/src/whitelist/setup.rs index cb18c1478..b1c163f97 100644 --- a/packages/tracker-core/src/whitelist/setup.rs +++ b/packages/tracker-core/src/whitelist/setup.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use super::manager::WhitelistManager; use super::repository::in_memory::InMemoryWhitelist; use super::repository::persisted::DatabaseWhitelist; -use crate::databases::Database; +use crate::databases::WhitelistStore; /// Initializes the `WhitelistManager` by combining in-memory and database /// repositories. @@ -22,20 +22,20 @@ use crate::databases::Database; /// /// # Arguments /// -/// * `database` - An `Arc>` representing the database connection, -/// sed for persistent whitelist storage. -/// * `in_memory_whitelist` - An `Arc` representing the in-memory -/// whitelist repository for fast access. +/// * `whitelist_store` - An `Arc` representing the +/// whitelist persistence store. +/// * `in_memory_whitelist` - An `Arc` representing the +/// in-memory whitelist repository for fast access. /// /// # Returns /// -/// An `Arc` instance that manages both the in-memory and database -/// whitelist repositories. +/// An `Arc` instance that manages both the in-memory and +/// database whitelist repositories. #[must_use] pub fn initialize_whitelist_manager( - database: Arc>, + whitelist_store: Arc, in_memory_whitelist: Arc, ) -> Arc { - let database_whitelist = Arc::new(DatabaseWhitelist::new(database)); + let database_whitelist = Arc::new(DatabaseWhitelist::new(whitelist_store)); Arc::new(WhitelistManager::new(database_whitelist, in_memory_whitelist)) } diff --git a/packages/tracker-core/src/whitelist/test_helpers.rs b/packages/tracker-core/src/whitelist/test_helpers.rs index cf1699be4..c5f66e1df 100644 --- a/packages/tracker-core/src/whitelist/test_helpers.rs +++ b/packages/tracker-core/src/whitelist/test_helpers.rs @@ -18,10 +18,10 @@ pub(crate) mod tests { #[must_use] pub fn initialize_whitelist_services(config: &Configuration) -> (Arc, Arc) { - let database = initialize_database(&config.core); + let stores = initialize_database(&config.core); let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); - let whitelist_manager = initialize_whitelist_manager(database.clone(), in_memory_whitelist.clone()); + let whitelist_manager = initialize_whitelist_manager(stores.whitelist_store.clone(), in_memory_whitelist.clone()); (whitelist_authorization, whitelist_manager) } diff --git a/packages/udp-tracker-server/src/handlers/announce.rs b/packages/udp-tracker-server/src/handlers/announce.rs index ea19611ce..dac0f8e26 100644 --- a/packages/udp-tracker-server/src/handlers/announce.rs +++ b/packages/udp-tracker-server/src/handlers/announce.rs @@ -896,7 +896,7 @@ pub(crate) mod tests { let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let request = AnnounceRequestBuilder::default() .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) diff --git a/packages/udp-tracker-server/src/handlers/mod.rs b/packages/udp-tracker-server/src/handlers/mod.rs index add576a89..4aefb6b79 100644 --- a/packages/udp-tracker-server/src/handlers/mod.rs +++ b/packages/udp-tracker-server/src/handlers/mod.rs @@ -273,7 +273,7 @@ pub(crate) mod tests { let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let announce_handler = Arc::new(AnnounceHandler::new( &config.core, &whitelist_authorization, From 83fb63673f986d3532756830ea0be60adc458c3e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 29 Apr 2026 12:05:49 +0100 Subject: [PATCH 4/9] refactor(tracker-core): split database drivers into folder modules --- .../src/databases/driver/mysql.rs | 481 ------------------ .../databases/driver/mysql/auth_key_store.rs | 78 +++ .../src/databases/driver/mysql/mod.rs | 219 ++++++++ .../databases/driver/mysql/schema_migrator.rs | 81 +++ .../driver/mysql/torrent_metrics_store.rs | 84 +++ .../databases/driver/mysql/whitelist_store.rs | 57 +++ .../src/databases/driver/sqlite.rs | 431 ---------------- .../databases/driver/sqlite/auth_key_store.rs | 105 ++++ .../src/databases/driver/sqlite/mod.rs | 125 +++++ .../driver/sqlite/schema_migrator.rs | 69 +++ .../driver/sqlite/torrent_metrics_store.rs | 91 ++++ .../driver/sqlite/whitelist_store.rs | 70 +++ .../src/handlers/announce.rs | 3 +- 13 files changed, 981 insertions(+), 913 deletions(-) delete mode 100644 packages/tracker-core/src/databases/driver/mysql.rs create mode 100644 packages/tracker-core/src/databases/driver/mysql/auth_key_store.rs create mode 100644 packages/tracker-core/src/databases/driver/mysql/mod.rs create mode 100644 packages/tracker-core/src/databases/driver/mysql/schema_migrator.rs create mode 100644 packages/tracker-core/src/databases/driver/mysql/torrent_metrics_store.rs create mode 100644 packages/tracker-core/src/databases/driver/mysql/whitelist_store.rs delete mode 100644 packages/tracker-core/src/databases/driver/sqlite.rs create mode 100644 packages/tracker-core/src/databases/driver/sqlite/auth_key_store.rs create mode 100644 packages/tracker-core/src/databases/driver/sqlite/mod.rs create mode 100644 packages/tracker-core/src/databases/driver/sqlite/schema_migrator.rs create mode 100644 packages/tracker-core/src/databases/driver/sqlite/torrent_metrics_store.rs create mode 100644 packages/tracker-core/src/databases/driver/sqlite/whitelist_store.rs diff --git a/packages/tracker-core/src/databases/driver/mysql.rs b/packages/tracker-core/src/databases/driver/mysql.rs deleted file mode 100644 index 71070f85e..000000000 --- a/packages/tracker-core/src/databases/driver/mysql.rs +++ /dev/null @@ -1,481 +0,0 @@ -//! The `MySQL` database driver. -//! -//! This module provides implementations of the four narrow database traits -//! ([`SchemaMigrator`](crate::databases::SchemaMigrator), -//! [`TorrentMetricsStore`](crate::databases::TorrentMetricsStore), -//! [`WhitelistStore`](crate::databases::WhitelistStore), -//! [`AuthKeyStore`](crate::databases::AuthKeyStore) -//! for `MySQL` using the `r2d2_mysql` connection pool. It configures the `MySQL` -//! connection based on a URL, creates the necessary tables (for torrent metrics, -//! torrent whitelist, and authentication keys), and implements all CRUD -//! operations required by the persistence layer. -use std::str::FromStr; -use std::time::Duration; - -use bittorrent_primitives::info_hash::InfoHash; -use r2d2::Pool; -use r2d2_mysql::mysql::prelude::Queryable; -use r2d2_mysql::mysql::{params, Opts, OptsBuilder}; -use r2d2_mysql::MySqlConnectionManager; -use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; - -use super::{Driver, Error, TORRENTS_DOWNLOADS_TOTAL}; -use crate::authentication::key::AUTH_KEY_LENGTH; -use crate::authentication::{self, Key}; -use crate::databases::{AuthKeyStore, SchemaMigrator, TorrentMetricsStore, WhitelistStore}; - -const DRIVER: Driver = Driver::MySQL; - -/// `MySQL` driver implementation. -/// -/// This struct encapsulates a connection pool for `MySQL`, built using the -/// `r2d2_mysql` connection manager. It implements the [`Database`] trait to -/// provide persistence operations. -pub(crate) struct Mysql { - pool: Pool, -} - -impl Mysql { - /// It instantiates a new `MySQL` database driver. - /// - /// - /// # Errors - /// - /// Will return `r2d2::Error` if `db_path` is not able to create `MySQL` database. - pub fn new(db_path: &str) -> Result { - let opts = Opts::from_url(db_path)?; - let builder = OptsBuilder::from_opts(opts); - let manager = MySqlConnectionManager::new(builder); - let pool = r2d2::Pool::builder().build(manager).map_err(|e| (e, DRIVER))?; - - Ok(Self { pool }) - } - - fn load_torrent_aggregate_metric(&self, metric_name: &str) -> Result, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let query = conn.exec_first::( - "SELECT value FROM torrent_aggregate_metrics WHERE metric_name = :metric_name", - params! { "metric_name" => metric_name }, - ); - - let persistent_torrent = query?; - - Ok(persistent_torrent) - } - - fn save_torrent_aggregate_metric(&self, metric_name: &str, completed: NumberOfDownloads) -> Result<(), Error> { - const COMMAND : &str = "INSERT INTO torrent_aggregate_metrics (metric_name, value) VALUES (:metric_name, :completed) ON DUPLICATE KEY UPDATE value = VALUES(value)"; - - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - Ok(conn.exec_drop(COMMAND, params! { metric_name, completed })?) - } -} - -impl SchemaMigrator for Mysql { - fn create_database_tables(&self) -> Result<(), Error> { - let create_whitelist_table = " - CREATE TABLE IF NOT EXISTS whitelist ( - id integer PRIMARY KEY AUTO_INCREMENT, - info_hash VARCHAR(40) NOT NULL UNIQUE - );" - .to_string(); - - let create_torrents_table = " - CREATE TABLE IF NOT EXISTS torrents ( - id integer PRIMARY KEY AUTO_INCREMENT, - info_hash VARCHAR(40) NOT NULL UNIQUE, - completed INTEGER DEFAULT 0 NOT NULL - );" - .to_string(); - - let create_torrent_aggregate_metrics_table = " - CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( - id integer PRIMARY KEY AUTO_INCREMENT, - metric_name VARCHAR(50) NOT NULL UNIQUE, - value INTEGER DEFAULT 0 NOT NULL - );" - .to_string(); - - let create_keys_table = format!( - " - CREATE TABLE IF NOT EXISTS `keys` ( - `id` INT NOT NULL AUTO_INCREMENT, - `key` VARCHAR({}) NOT NULL, - `valid_until` INT(10), - PRIMARY KEY (`id`), - UNIQUE (`key`) - );", - i8::try_from(AUTH_KEY_LENGTH).expect("authentication key length should fit within a i8!") - ); - - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.query_drop(&create_torrents_table) - .expect("Could not create torrents table."); - conn.query_drop(&create_torrent_aggregate_metrics_table) - .expect("Could not create create_torrent_aggregate_metrics_table table."); - conn.query_drop(&create_keys_table).expect("Could not create keys table."); - conn.query_drop(&create_whitelist_table) - .expect("Could not create whitelist table."); - - Ok(()) - } - - fn drop_database_tables(&self) -> Result<(), Error> { - let drop_whitelist_table = " - DROP TABLE `whitelist`;" - .to_string(); - - let drop_torrents_table = " - DROP TABLE `torrents`;" - .to_string(); - - let drop_keys_table = " - DROP TABLE `keys`;" - .to_string(); - - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.query_drop(&drop_whitelist_table) - .expect("Could not drop `whitelist` table."); - conn.query_drop(&drop_torrents_table) - .expect("Could not drop `torrents` table."); - conn.query_drop(&drop_keys_table).expect("Could not drop `keys` table."); - - Ok(()) - } -} - -impl TorrentMetricsStore for Mysql { - fn load_all_torrents_downloads(&self) -> Result { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let torrents = conn.query_map( - "SELECT info_hash, completed FROM torrents", - |(info_hash_string, completed): (String, u32)| { - let info_hash = InfoHash::from_str(&info_hash_string).unwrap(); - (info_hash, completed) - }, - )?; - - Ok(torrents.iter().copied().collect()) - } - - fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let query = conn.exec_first::( - "SELECT completed FROM torrents WHERE info_hash = :info_hash", - params! { "info_hash" => info_hash.to_hex_string() }, - ); - - let persistent_torrent = query?; - - Ok(persistent_torrent) - } - - fn save_torrent_downloads(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { - const COMMAND : &str = "INSERT INTO torrents (info_hash, completed) VALUES (:info_hash_str, :completed) ON DUPLICATE KEY UPDATE completed = VALUES(completed)"; - - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hash_str = info_hash.to_string(); - - Ok(conn.exec_drop(COMMAND, params! { info_hash_str, completed })?) - } - - fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hash_str = info_hash.to_string(); - - conn.exec_drop( - "UPDATE torrents SET completed = completed + 1 WHERE info_hash = :info_hash_str", - params! { info_hash_str }, - )?; - - Ok(()) - } - - fn load_global_downloads(&self) -> Result, Error> { - self.load_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL) - } - - fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error> { - self.save_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL, downloaded) - } - - fn increase_global_downloads(&self) -> Result<(), Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let metric_name = TORRENTS_DOWNLOADS_TOTAL; - - conn.exec_drop( - "UPDATE torrent_aggregate_metrics SET value = value + 1 WHERE metric_name = :metric_name", - params! { metric_name }, - )?; - - Ok(()) - } -} - -impl WhitelistStore for Mysql { - fn load_whitelist(&self) -> Result, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hashes = conn.query_map("SELECT info_hash FROM whitelist", |info_hash: String| { - InfoHash::from_str(&info_hash).unwrap() - })?; - - Ok(info_hashes) - } - - fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let select = conn.exec_first::( - "SELECT info_hash FROM whitelist WHERE info_hash = :info_hash", - params! { "info_hash" => info_hash.to_hex_string() }, - )?; - - let info_hash = select.map(|f| InfoHash::from_str(&f).expect("Failed to decode InfoHash String from DB!")); - - Ok(info_hash) - } - - fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hash_str = info_hash.to_string(); - - conn.exec_drop( - "INSERT INTO whitelist (info_hash) VALUES (:info_hash_str)", - params! { info_hash_str }, - )?; - - Ok(1) - } - - fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hash = info_hash.to_string(); - - conn.exec_drop("DELETE FROM whitelist WHERE info_hash = :info_hash", params! { info_hash })?; - - Ok(1) - } -} - -impl AuthKeyStore for Mysql { - fn load_keys(&self) -> Result, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let keys = conn.query_map( - "SELECT `key`, valid_until FROM `keys`", - |(key, valid_until): (String, Option)| match valid_until { - Some(valid_until) => authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), - }, - None => authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: None, - }, - }, - )?; - - Ok(keys) - } - - fn get_key_from_keys(&self, key: &Key) -> Result, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let query = conn.exec_first::<(String, Option), _, _>( - "SELECT `key`, valid_until FROM `keys` WHERE `key` = :key", - params! { "key" => key.to_string() }, - ); - - let key = query?; - - Ok(key.map(|(key, opt_valid_until)| match opt_valid_until { - Some(valid_until) => authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), - }, - None => authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: None, - }, - })) - } - - fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - match auth_key.valid_until { - Some(valid_until) => conn.exec_drop( - "INSERT INTO `keys` (`key`, valid_until) VALUES (:key, :valid_until)", - params! { "key" => auth_key.key.to_string(), "valid_until" => valid_until.as_secs().to_string() }, - )?, - None => conn.exec_drop( - "INSERT INTO `keys` (`key`) VALUES (:key)", - params! { "key" => auth_key.key.to_string() }, - )?, - } - - Ok(1) - } - - fn remove_key_from_keys(&self, key: &Key) -> Result { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.exec_drop("DELETE FROM `keys` WHERE `key` = :key", params! { "key" => key.to_string() })?; - - Ok(1) - } -} - -#[cfg(all(test, feature = "db-compatibility-tests"))] -mod tests { - use std::sync::Arc; - - use testcontainers::core::IntoContainerPort; - /* - We run a MySQL container and run all the tests against the same container and database. - - Test for this driver are executed with: - - `TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true \ - cargo test -p bittorrent-tracker-core --features db-compatibility-tests run_mysql_driver_tests` - - The `Database` trait is very simple and we only have one driver that needs - a container. In the future we might want to use different approaches like: - - - https://github.com/testcontainers/testcontainers-rs/issues/707 - - https://www.infinyon.com/blog/2021/04/rust-custom-test-harness/ - - https://github.com/torrust/torrust-tracker/blob/develop/src/bin/e2e_tests_runner.rs - - If we increase the number of methods or the number or drivers. - */ - use testcontainers::runners::AsyncRunner; - use testcontainers::{ContainerAsync, GenericImage, ImageExt}; - use torrust_tracker_configuration::Core; - - use super::Mysql; - use crate::databases::driver::tests::run_tests; - use crate::databases::traits::Database; - - #[derive(Debug, Default)] - struct StoppedMysqlContainer {} - - impl StoppedMysqlContainer { - async fn run(self, config: &MysqlConfiguration) -> Result> { - let image_tag = std::env::var("TORRUST_TRACKER_CORE_MYSQL_DRIVER_IMAGE_TAG").unwrap_or_else(|_| "8.0".to_string()); - - let container = GenericImage::new("mysql", image_tag.as_str()) - .with_exposed_port(config.internal_port.tcp()) - // todo: this does not work - //.with_wait_for(WaitFor::message_on_stdout("ready for connections")) - .with_env_var("MYSQL_ROOT_PASSWORD", config.db_root_password.clone()) - .with_env_var("MYSQL_DATABASE", config.database.clone()) - .with_env_var("MYSQL_ROOT_HOST", "%") - .start() - .await?; - - Ok(RunningMysqlContainer::new(container, config.internal_port)) - } - } - - struct RunningMysqlContainer { - container: ContainerAsync, - internal_port: u16, - } - - impl RunningMysqlContainer { - fn new(container: ContainerAsync, internal_port: u16) -> Self { - Self { - container, - internal_port, - } - } - - async fn stop(self) { - self.container.stop().await.unwrap(); - } - - async fn get_host(&self) -> url::Host { - self.container.get_host().await.unwrap() - } - - async fn get_host_port_ipv4(&self) -> u16 { - self.container.get_host_port_ipv4(self.internal_port).await.unwrap() - } - } - - impl Default for MysqlConfiguration { - fn default() -> Self { - Self { - internal_port: 3306, - database: "torrust_tracker_test".to_string(), - db_user: "root".to_string(), - db_root_password: "test".to_string(), - } - } - } - - struct MysqlConfiguration { - pub internal_port: u16, - pub database: String, - pub db_user: String, - pub db_root_password: String, - } - - fn core_configuration(host: &url::Host, port: u16, mysql_configuration: &MysqlConfiguration) -> Core { - let mut config = Core::default(); - - let database = mysql_configuration.database.clone(); - let db_user = mysql_configuration.db_user.clone(); - let db_password = mysql_configuration.db_root_password.clone(); - - config.database.path = format!("mysql://{db_user}:{db_password}@{host}:{port}/{database}"); - - config - } - - fn initialize_driver(config: &Core) -> Arc> { - let driver: Arc> = Arc::new(Box::new(Mysql::new(&config.database.path).unwrap())); - driver - } - - // This test is invoked by `.github/workflows/testing.yaml` in the - // `database-compatibility` job to validate supported MySQL versions. - #[tokio::test] - async fn run_mysql_driver_tests() -> Result<(), Box> { - if std::env::var("TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST").is_err() { - println!("Skipping the MySQL driver tests."); - return Ok(()); - } - - let mysql_configuration = MysqlConfiguration::default(); - - let stopped_mysql_container = StoppedMysqlContainer::default(); - - let mysql_container = stopped_mysql_container.run(&mysql_configuration).await.unwrap(); - - let host = mysql_container.get_host().await; - let port = mysql_container.get_host_port_ipv4().await; - - let config = core_configuration(&host, port, &mysql_configuration); - - let driver = initialize_driver(&config); - - run_tests(&driver).await; - - mysql_container.stop().await; - - Ok(()) - } -} diff --git a/packages/tracker-core/src/databases/driver/mysql/auth_key_store.rs b/packages/tracker-core/src/databases/driver/mysql/auth_key_store.rs new file mode 100644 index 000000000..178b9b2e5 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/mysql/auth_key_store.rs @@ -0,0 +1,78 @@ +use std::time::Duration; + +use r2d2_mysql::mysql::params; +use r2d2_mysql::mysql::prelude::Queryable; + +use super::{Mysql, DRIVER}; +use crate::authentication::{self, Key}; +use crate::databases::error::Error; +use crate::databases::AuthKeyStore; + +impl AuthKeyStore for Mysql { + fn load_keys(&self) -> Result, Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let keys = conn.query_map( + "SELECT `key`, valid_until FROM `keys`", + |(key, valid_until): (String, Option)| match valid_until { + Some(valid_until) => authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), + }, + None => authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: None, + }, + }, + )?; + + Ok(keys) + } + + fn get_key_from_keys(&self, key: &Key) -> Result, Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let query = conn.exec_first::<(String, Option), _, _>( + "SELECT `key`, valid_until FROM `keys` WHERE `key` = :key", + params! { "key" => key.to_string() }, + ); + + let key = query?; + + Ok(key.map(|(key, opt_valid_until)| match opt_valid_until { + Some(valid_until) => authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), + }, + None => authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: None, + }, + })) + } + + fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + match auth_key.valid_until { + Some(valid_until) => conn.exec_drop( + "INSERT INTO `keys` (`key`, valid_until) VALUES (:key, :valid_until)", + params! { "key" => auth_key.key.to_string(), "valid_until" => valid_until.as_secs().to_string() }, + )?, + None => conn.exec_drop( + "INSERT INTO `keys` (`key`) VALUES (:key)", + params! { "key" => auth_key.key.to_string() }, + )?, + } + + Ok(1) + } + + fn remove_key_from_keys(&self, key: &Key) -> Result { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + conn.exec_drop("DELETE FROM `keys` WHERE `key` = :key", params! { "key" => key.to_string() })?; + + Ok(1) + } +} diff --git a/packages/tracker-core/src/databases/driver/mysql/mod.rs b/packages/tracker-core/src/databases/driver/mysql/mod.rs new file mode 100644 index 000000000..c776e959f --- /dev/null +++ b/packages/tracker-core/src/databases/driver/mysql/mod.rs @@ -0,0 +1,219 @@ +//! The `MySQL` database driver. +//! +//! This module provides implementations of the four narrow database traits +//! ([`SchemaMigrator`](crate::databases::SchemaMigrator), +//! [`TorrentMetricsStore`](crate::databases::TorrentMetricsStore), +//! [`WhitelistStore`](crate::databases::WhitelistStore), +//! [`AuthKeyStore`](crate::databases::AuthKeyStore) +//! for `MySQL` using the `r2d2_mysql` connection pool. It configures the `MySQL` +//! connection based on a URL, creates the necessary tables (for torrent metrics, +//! torrent whitelist, and authentication keys), and implements all CRUD +//! operations required by the persistence layer. +use r2d2::Pool; +use r2d2_mysql::mysql::{Opts, OptsBuilder}; +use r2d2_mysql::MySqlConnectionManager; +use torrust_tracker_primitives::NumberOfDownloads; + +use super::{Driver, Error}; + +mod auth_key_store; +mod schema_migrator; +mod torrent_metrics_store; +mod whitelist_store; + +const DRIVER: Driver = Driver::MySQL; + +/// `MySQL` driver implementation. +/// +/// This struct encapsulates a connection pool for `MySQL`, built using the +/// `r2d2_mysql` connection manager. It implements the [`Database`] trait to +/// provide persistence operations. +pub(crate) struct Mysql { + pool: Pool, +} + +impl Mysql { + /// It instantiates a new `MySQL` database driver. + /// + /// + /// # Errors + /// + /// Will return `r2d2::Error` if `db_path` is not able to create `MySQL` database. + pub fn new(db_path: &str) -> Result { + let opts = Opts::from_url(db_path)?; + let builder = OptsBuilder::from_opts(opts); + let manager = MySqlConnectionManager::new(builder); + let pool = r2d2::Pool::builder().build(manager).map_err(|e| (e, DRIVER))?; + + Ok(Self { pool }) + } + + fn load_torrent_aggregate_metric(&self, metric_name: &str) -> Result, Error> { + use r2d2_mysql::mysql::params; + use r2d2_mysql::mysql::prelude::Queryable; + + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let query = conn.exec_first::( + "SELECT value FROM torrent_aggregate_metrics WHERE metric_name = :metric_name", + params! { "metric_name" => metric_name }, + ); + + let persistent_torrent = query?; + + Ok(persistent_torrent) + } + + fn save_torrent_aggregate_metric(&self, metric_name: &str, completed: NumberOfDownloads) -> Result<(), Error> { + use r2d2_mysql::mysql::params; + use r2d2_mysql::mysql::prelude::Queryable; + + const COMMAND : &str = "INSERT INTO torrent_aggregate_metrics (metric_name, value) VALUES (:metric_name, :completed) ON DUPLICATE KEY UPDATE value = VALUES(value)"; + + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + Ok(conn.exec_drop(COMMAND, params! { metric_name, completed })?) + } +} + +#[cfg(all(test, feature = "db-compatibility-tests"))] +mod tests { + use std::sync::Arc; + + use testcontainers::core::IntoContainerPort; + /* + We run a MySQL container and run all the tests against the same container and database. + + Test for this driver are executed with: + + `TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true \ + cargo test -p bittorrent-tracker-core --features db-compatibility-tests run_mysql_driver_tests` + + The `Database` trait is very simple and we only have one driver that needs + a container. In the future we might want to use different approaches like: + + - https://github.com/testcontainers/testcontainers-rs/issues/707 + - https://www.infinyon.com/blog/2021/04/rust-custom-test-harness/ + - https://github.com/torrust/torrust-tracker/blob/develop/src/bin/e2e_tests_runner.rs + + If we increase the number of methods or the number or drivers. + */ + use testcontainers::runners::AsyncRunner; + use testcontainers::{ContainerAsync, GenericImage, ImageExt}; + use torrust_tracker_configuration::Core; + + use super::Mysql; + use crate::databases::driver::tests::run_tests; + use crate::databases::traits::Database; + + #[derive(Debug, Default)] + struct StoppedMysqlContainer {} + + impl StoppedMysqlContainer { + async fn run(self, config: &MysqlConfiguration) -> Result> { + let image_tag = std::env::var("TORRUST_TRACKER_CORE_MYSQL_DRIVER_IMAGE_TAG").unwrap_or_else(|_| "8.0".to_string()); + + let container = GenericImage::new("mysql", image_tag.as_str()) + .with_exposed_port(config.internal_port.tcp()) + // todo: this does not work + //.with_wait_for(WaitFor::message_on_stdout("ready for connections")) + .with_env_var("MYSQL_ROOT_PASSWORD", config.db_root_password.clone()) + .with_env_var("MYSQL_DATABASE", config.database.clone()) + .with_env_var("MYSQL_ROOT_HOST", "%") + .start() + .await?; + + Ok(RunningMysqlContainer::new(container, config.internal_port)) + } + } + + struct RunningMysqlContainer { + container: ContainerAsync, + internal_port: u16, + } + + impl RunningMysqlContainer { + fn new(container: ContainerAsync, internal_port: u16) -> Self { + Self { + container, + internal_port, + } + } + + async fn stop(self) { + self.container.stop().await.unwrap(); + } + + async fn get_host(&self) -> url::Host { + self.container.get_host().await.unwrap() + } + + async fn get_host_port_ipv4(&self) -> u16 { + self.container.get_host_port_ipv4(self.internal_port).await.unwrap() + } + } + + impl Default for MysqlConfiguration { + fn default() -> Self { + Self { + internal_port: 3306, + database: "torrust_tracker_test".to_string(), + db_user: "root".to_string(), + db_root_password: "test".to_string(), + } + } + } + + struct MysqlConfiguration { + pub internal_port: u16, + pub database: String, + pub db_user: String, + pub db_root_password: String, + } + + fn core_configuration(host: &url::Host, port: u16, mysql_configuration: &MysqlConfiguration) -> Core { + let mut config = Core::default(); + + let database = mysql_configuration.database.clone(); + let db_user = mysql_configuration.db_user.clone(); + let db_password = mysql_configuration.db_root_password.clone(); + + config.database.path = format!("mysql://{db_user}:{db_password}@{host}:{port}/{database}"); + + config + } + + fn initialize_driver(config: &Core) -> Arc> { + let driver: Arc> = Arc::new(Box::new(Mysql::new(&config.database.path).unwrap())); + driver + } + + // This test is invoked by `.github/workflows/testing.yaml` in the + // `database-compatibility` job to validate supported MySQL versions. + #[tokio::test] + async fn run_mysql_driver_tests() -> Result<(), Box> { + if std::env::var("TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST").is_err() { + println!("Skipping the MySQL driver tests."); + return Ok(()); + } + + let mysql_configuration = MysqlConfiguration::default(); + + let stopped_mysql_container = StoppedMysqlContainer::default(); + + let mysql_container = stopped_mysql_container.run(&mysql_configuration).await.unwrap(); + + let host = mysql_container.get_host().await; + let port = mysql_container.get_host_port_ipv4().await; + + let config = core_configuration(&host, port, &mysql_configuration); + + let driver = initialize_driver(&config); + + run_tests(&driver).await; + + mysql_container.stop().await; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/driver/mysql/schema_migrator.rs b/packages/tracker-core/src/databases/driver/mysql/schema_migrator.rs new file mode 100644 index 000000000..c06f49f98 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/mysql/schema_migrator.rs @@ -0,0 +1,81 @@ +use r2d2_mysql::mysql::prelude::Queryable; + +use super::{Mysql, DRIVER}; +use crate::authentication::key::AUTH_KEY_LENGTH; +use crate::databases::error::Error; +use crate::databases::SchemaMigrator; + +impl SchemaMigrator for Mysql { + fn create_database_tables(&self) -> Result<(), Error> { + let create_whitelist_table = " + CREATE TABLE IF NOT EXISTS whitelist ( + id integer PRIMARY KEY AUTO_INCREMENT, + info_hash VARCHAR(40) NOT NULL UNIQUE + );" + .to_string(); + + let create_torrents_table = " + CREATE TABLE IF NOT EXISTS torrents ( + id integer PRIMARY KEY AUTO_INCREMENT, + info_hash VARCHAR(40) NOT NULL UNIQUE, + completed INTEGER DEFAULT 0 NOT NULL + );" + .to_string(); + + let create_torrent_aggregate_metrics_table = " + CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( + id integer PRIMARY KEY AUTO_INCREMENT, + metric_name VARCHAR(50) NOT NULL UNIQUE, + value INTEGER DEFAULT 0 NOT NULL + );" + .to_string(); + + let create_keys_table = format!( + " + CREATE TABLE IF NOT EXISTS `keys` ( + `id` INT NOT NULL AUTO_INCREMENT, + `key` VARCHAR({}) NOT NULL, + `valid_until` INT(10), + PRIMARY KEY (`id`), + UNIQUE (`key`) + );", + i8::try_from(AUTH_KEY_LENGTH).expect("authentication key length should fit within a i8!") + ); + + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + conn.query_drop(&create_torrents_table) + .expect("Could not create torrents table."); + conn.query_drop(&create_torrent_aggregate_metrics_table) + .expect("Could not create create_torrent_aggregate_metrics_table table."); + conn.query_drop(&create_keys_table).expect("Could not create keys table."); + conn.query_drop(&create_whitelist_table) + .expect("Could not create whitelist table."); + + Ok(()) + } + + fn drop_database_tables(&self) -> Result<(), Error> { + let drop_whitelist_table = " + DROP TABLE `whitelist`;" + .to_string(); + + let drop_torrents_table = " + DROP TABLE `torrents`;" + .to_string(); + + let drop_keys_table = " + DROP TABLE `keys`;" + .to_string(); + + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + conn.query_drop(&drop_whitelist_table) + .expect("Could not drop `whitelist` table."); + conn.query_drop(&drop_torrents_table) + .expect("Could not drop `torrents` table."); + conn.query_drop(&drop_keys_table).expect("Could not drop `keys` table."); + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/driver/mysql/torrent_metrics_store.rs b/packages/tracker-core/src/databases/driver/mysql/torrent_metrics_store.rs new file mode 100644 index 000000000..9c4f69379 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/mysql/torrent_metrics_store.rs @@ -0,0 +1,84 @@ +use std::str::FromStr; + +use bittorrent_primitives::info_hash::InfoHash; +use r2d2_mysql::mysql::params; +use r2d2_mysql::mysql::prelude::Queryable; +use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; + +use super::{Mysql, DRIVER}; +use crate::databases::driver::TORRENTS_DOWNLOADS_TOTAL; +use crate::databases::error::Error; +use crate::databases::TorrentMetricsStore; + +impl TorrentMetricsStore for Mysql { + fn load_all_torrents_downloads(&self) -> Result { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let torrents = conn.query_map( + "SELECT info_hash, completed FROM torrents", + |(info_hash_string, completed): (String, u32)| { + let info_hash = InfoHash::from_str(&info_hash_string).unwrap(); + (info_hash, completed) + }, + )?; + + Ok(torrents.iter().copied().collect()) + } + + fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result, Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let query = conn.exec_first::( + "SELECT completed FROM torrents WHERE info_hash = :info_hash", + params! { "info_hash" => info_hash.to_hex_string() }, + ); + + let persistent_torrent = query?; + + Ok(persistent_torrent) + } + + fn save_torrent_downloads(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { + const COMMAND : &str = "INSERT INTO torrents (info_hash, completed) VALUES (:info_hash_str, :completed) ON DUPLICATE KEY UPDATE completed = VALUES(completed)"; + + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let info_hash_str = info_hash.to_string(); + + Ok(conn.exec_drop(COMMAND, params! { info_hash_str, completed })?) + } + + fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let info_hash_str = info_hash.to_string(); + + conn.exec_drop( + "UPDATE torrents SET completed = completed + 1 WHERE info_hash = :info_hash_str", + params! { info_hash_str }, + )?; + + Ok(()) + } + + fn load_global_downloads(&self) -> Result, Error> { + self.load_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL) + } + + fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error> { + self.save_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL, downloaded) + } + + fn increase_global_downloads(&self) -> Result<(), Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let metric_name = TORRENTS_DOWNLOADS_TOTAL; + + conn.exec_drop( + "UPDATE torrent_aggregate_metrics SET value = value + 1 WHERE metric_name = :metric_name", + params! { metric_name }, + )?; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/driver/mysql/whitelist_store.rs b/packages/tracker-core/src/databases/driver/mysql/whitelist_store.rs new file mode 100644 index 000000000..f99b7a880 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/mysql/whitelist_store.rs @@ -0,0 +1,57 @@ +use std::str::FromStr; + +use bittorrent_primitives::info_hash::InfoHash; +use r2d2_mysql::mysql::params; +use r2d2_mysql::mysql::prelude::Queryable; + +use super::{Mysql, DRIVER}; +use crate::databases::error::Error; +use crate::databases::WhitelistStore; + +impl WhitelistStore for Mysql { + fn load_whitelist(&self) -> Result, Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let info_hashes = conn.query_map("SELECT info_hash FROM whitelist", |info_hash: String| { + InfoHash::from_str(&info_hash).unwrap() + })?; + + Ok(info_hashes) + } + + fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result, Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let select = conn.exec_first::( + "SELECT info_hash FROM whitelist WHERE info_hash = :info_hash", + params! { "info_hash" => info_hash.to_hex_string() }, + )?; + + let info_hash = select.map(|f| InfoHash::from_str(&f).expect("Failed to decode InfoHash String from DB!")); + + Ok(info_hash) + } + + fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let info_hash_str = info_hash.to_string(); + + conn.exec_drop( + "INSERT INTO whitelist (info_hash) VALUES (:info_hash_str)", + params! { info_hash_str }, + )?; + + Ok(1) + } + + fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let info_hash = info_hash.to_string(); + + conn.exec_drop("DELETE FROM whitelist WHERE info_hash = :info_hash", params! { info_hash })?; + + Ok(1) + } +} diff --git a/packages/tracker-core/src/databases/driver/sqlite.rs b/packages/tracker-core/src/databases/driver/sqlite.rs deleted file mode 100644 index 979b32b8b..000000000 --- a/packages/tracker-core/src/databases/driver/sqlite.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! The `SQLite3` database driver. -//! -//! This module provides implementations of the four narrow database traits -//! ([`SchemaMigrator`](crate::databases::SchemaMigrator), -//! [`TorrentMetricsStore`](crate::databases::TorrentMetricsStore), -//! [`WhitelistStore`](crate::databases::WhitelistStore), -//! [`AuthKeyStore`](crate::databases::AuthKeyStore) -//! for `SQLite3` using the `r2d2_sqlite` connection pool. It defines the schema -//! for whitelist, torrent metrics, and authentication keys, and provides methods -//! to create and drop tables as well as perform CRUD operations on these -//! persistent objects. -use std::panic::Location; -use std::str::FromStr; - -use bittorrent_primitives::info_hash::InfoHash; -use r2d2::Pool; -use r2d2_sqlite::rusqlite::params; -use r2d2_sqlite::rusqlite::types::Null; -use r2d2_sqlite::SqliteConnectionManager; -use torrust_tracker_primitives::{DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; - -use super::{Driver, Error, TORRENTS_DOWNLOADS_TOTAL}; -use crate::authentication::{self, Key}; -use crate::databases::{AuthKeyStore, SchemaMigrator, TorrentMetricsStore, WhitelistStore}; - -const DRIVER: Driver = Driver::Sqlite3; - -/// `SQLite` driver implementation. -/// -/// This struct encapsulates a connection pool for `SQLite` using the `r2d2_sqlite` -/// connection manager. -pub(crate) struct Sqlite { - pool: Pool, -} - -impl Sqlite { - /// Instantiates a new `SQLite3` database driver. - /// - /// This function creates a connection manager for the `SQLite` database - /// located at `db_path` and then builds a connection pool using `r2d2`. If - /// the pool cannot be created, an error is returned (wrapped with the - /// appropriate driver information). - /// - /// # Arguments - /// - /// * `db_path` - A string slice representing the file path to the `SQLite` database. - /// - /// # Errors - /// - /// Returns an [`Error`] if the connection pool cannot be built. - pub fn new(db_path: &str) -> Result { - let manager = SqliteConnectionManager::file(db_path); - let pool = r2d2::Pool::builder().build(manager).map_err(|e| (e, DRIVER))?; - - Ok(Self { pool }) - } - - fn load_torrent_aggregate_metric(&self, metric_name: &str) -> Result, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT value FROM torrent_aggregate_metrics WHERE metric_name = ?")?; - - let mut rows = stmt.query([metric_name])?; - - let persistent_torrent = rows.next()?; - - Ok(persistent_torrent.map(|f| { - let value: i64 = f.get(0).unwrap(); - u32::try_from(value).unwrap() - })) - } - - fn save_torrent_aggregate_metric(&self, metric_name: &str, completed: NumberOfDownloads) -> Result<(), Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let insert = conn.execute( - "INSERT INTO torrent_aggregate_metrics (metric_name, value) VALUES (?1, ?2) ON CONFLICT(metric_name) DO UPDATE SET value = ?2", - [metric_name.to_string(), completed.to_string()], - )?; - - if insert == 0 { - Err(Error::InsertFailed { - location: Location::caller(), - driver: DRIVER, - }) - } else { - Ok(()) - } - } -} - -impl SchemaMigrator for Sqlite { - fn create_database_tables(&self) -> Result<(), Error> { - let create_whitelist_table = " - CREATE TABLE IF NOT EXISTS whitelist ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - info_hash TEXT NOT NULL UNIQUE - );" - .to_string(); - - let create_torrents_table = " - CREATE TABLE IF NOT EXISTS torrents ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - info_hash TEXT NOT NULL UNIQUE, - completed INTEGER DEFAULT 0 NOT NULL - );" - .to_string(); - - let create_torrent_aggregate_metrics_table = " - CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - metric_name TEXT NOT NULL UNIQUE, - value INTEGER DEFAULT 0 NOT NULL - );" - .to_string(); - - let create_keys_table = " - CREATE TABLE IF NOT EXISTS keys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - key TEXT NOT NULL UNIQUE, - valid_until INTEGER - );" - .to_string(); - - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.execute(&create_whitelist_table, [])?; - conn.execute(&create_keys_table, [])?; - conn.execute(&create_torrents_table, [])?; - conn.execute(&create_torrent_aggregate_metrics_table, [])?; - - Ok(()) - } - - fn drop_database_tables(&self) -> Result<(), Error> { - let drop_whitelist_table = " - DROP TABLE whitelist;" - .to_string(); - - let drop_torrents_table = " - DROP TABLE torrents;" - .to_string(); - - let drop_keys_table = " - DROP TABLE keys;" - .to_string(); - - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.execute(&drop_whitelist_table, []) - .and_then(|_| conn.execute(&drop_torrents_table, [])) - .and_then(|_| conn.execute(&drop_keys_table, []))?; - - Ok(()) - } -} - -impl TorrentMetricsStore for Sqlite { - fn load_all_torrents_downloads(&self) -> Result { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT info_hash, completed FROM torrents")?; - - let torrent_iter = stmt.query_map([], |row| { - let info_hash_string: String = row.get(0)?; - let info_hash = InfoHash::from_str(&info_hash_string).unwrap(); - let completed: u32 = row.get(1)?; - Ok((info_hash, completed)) - })?; - - Ok(torrent_iter.filter_map(std::result::Result::ok).collect()) - } - - fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT completed FROM torrents WHERE info_hash = ?")?; - - let mut rows = stmt.query([info_hash.to_hex_string()])?; - - let persistent_torrent = rows.next()?; - - Ok(persistent_torrent.map(|f| { - let completed: i64 = f.get(0).unwrap(); - u32::try_from(completed).unwrap() - })) - } - - fn save_torrent_downloads(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let insert = conn.execute( - "INSERT INTO torrents (info_hash, completed) VALUES (?1, ?2) ON CONFLICT(info_hash) DO UPDATE SET completed = ?2", - [info_hash.to_string(), completed.to_string()], - )?; - - if insert == 0 { - Err(Error::InsertFailed { - location: Location::caller(), - driver: DRIVER, - }) - } else { - Ok(()) - } - } - - fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let _ = conn.execute( - "UPDATE torrents SET completed = completed + 1 WHERE info_hash = ?", - [info_hash.to_string()], - )?; - - Ok(()) - } - - fn load_global_downloads(&self) -> Result, Error> { - self.load_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL) - } - - fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error> { - self.save_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL, downloaded) - } - - fn increase_global_downloads(&self) -> Result<(), Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let metric_name = TORRENTS_DOWNLOADS_TOTAL; - - let _ = conn.execute( - "UPDATE torrent_aggregate_metrics SET value = value + 1 WHERE metric_name = ?", - [metric_name], - )?; - - Ok(()) - } -} - -impl WhitelistStore for Sqlite { - fn load_whitelist(&self) -> Result, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT info_hash FROM whitelist")?; - - let info_hash_iter = stmt.query_map([], |row| { - let info_hash: String = row.get(0)?; - - Ok(InfoHash::from_str(&info_hash).unwrap()) - })?; - - let info_hashes: Vec = info_hash_iter.filter_map(std::result::Result::ok).collect(); - - Ok(info_hashes) - } - - fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT info_hash FROM whitelist WHERE info_hash = ?")?; - - let mut rows = stmt.query([info_hash.to_hex_string()])?; - - let query = rows.next()?; - - Ok(query.map(|f| InfoHash::from_str(&f.get_unwrap::<_, String>(0)).unwrap())) - } - - fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let insert = conn.execute("INSERT INTO whitelist (info_hash) VALUES (?)", [info_hash.to_string()])?; - - if insert == 0 { - Err(Error::InsertFailed { - location: Location::caller(), - driver: DRIVER, - }) - } else { - Ok(insert) - } - } - - fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let deleted = conn.execute("DELETE FROM whitelist WHERE info_hash = ?", [info_hash.to_string()])?; - - if deleted == 1 { - // should only remove a single record. - Ok(deleted) - } else { - Err(Error::DeleteFailed { - location: Location::caller(), - error_code: deleted, - driver: DRIVER, - }) - } - } -} - -impl AuthKeyStore for Sqlite { - fn load_keys(&self) -> Result, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT key, valid_until FROM keys")?; - - let keys_iter = stmt.query_map([], |row| { - let key: String = row.get(0)?; - let opt_valid_until: Option = row.get(1)?; - - match opt_valid_until { - Some(valid_until) => Ok(authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: Some(DurationSinceUnixEpoch::from_secs(valid_until.unsigned_abs())), - }), - None => Ok(authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: None, - }), - } - })?; - - let keys: Vec = keys_iter.filter_map(std::result::Result::ok).collect(); - - Ok(keys) - } - - fn get_key_from_keys(&self, key: &Key) -> Result, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT key, valid_until FROM keys WHERE key = ?")?; - - let mut rows = stmt.query([key.to_string()])?; - - let key = rows.next()?; - - Ok(key.map(|f| { - let valid_until: Option = f.get(1).unwrap(); - let key: String = f.get(0).unwrap(); - - match valid_until { - Some(valid_until) => authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: Some(DurationSinceUnixEpoch::from_secs(valid_until.unsigned_abs())), - }, - None => authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: None, - }, - } - })) - } - - fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let insert = match auth_key.valid_until { - Some(valid_until) => conn.execute( - "INSERT INTO keys (key, valid_until) VALUES (?1, ?2)", - [auth_key.key.to_string(), valid_until.as_secs().to_string()], - )?, - None => conn.execute( - "INSERT INTO keys (key, valid_until) VALUES (?1, ?2)", - params![auth_key.key.to_string(), Null], - )?, - }; - - if insert == 0 { - Err(Error::InsertFailed { - location: Location::caller(), - driver: DRIVER, - }) - } else { - Ok(insert) - } - } - - fn remove_key_from_keys(&self, key: &Key) -> Result { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let deleted = conn.execute("DELETE FROM keys WHERE key = ?", [key.to_string()])?; - - if deleted == 1 { - // should only remove a single record. - Ok(deleted) - } else { - Err(Error::DeleteFailed { - location: Location::caller(), - error_code: deleted, - driver: DRIVER, - }) - } - } -} - -#[cfg(test)] -mod tests { - - use std::sync::Arc; - - use torrust_tracker_configuration::Core; - use torrust_tracker_test_helpers::configuration::ephemeral_sqlite_database; - - use crate::databases::driver::sqlite::Sqlite; - use crate::databases::driver::tests::run_tests; - use crate::databases::traits::Database; - - fn ephemeral_configuration() -> Core { - let mut config = Core::default(); - let temp_file = ephemeral_sqlite_database(); - temp_file.to_str().unwrap().clone_into(&mut config.database.path); - config - } - - fn initialize_driver(config: &Core) -> Arc> { - let driver: Arc> = Arc::new(Box::new(Sqlite::new(&config.database.path).unwrap())); - driver - } - - #[tokio::test] - async fn run_sqlite_driver_tests() -> Result<(), Box> { - let config = ephemeral_configuration(); - - let driver = initialize_driver(&config); - - run_tests(&driver).await; - - Ok(()) - } -} diff --git a/packages/tracker-core/src/databases/driver/sqlite/auth_key_store.rs b/packages/tracker-core/src/databases/driver/sqlite/auth_key_store.rs new file mode 100644 index 000000000..8ae9bb222 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/sqlite/auth_key_store.rs @@ -0,0 +1,105 @@ +use std::panic::Location; + +use r2d2_sqlite::rusqlite::params; +use r2d2_sqlite::rusqlite::types::Null; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::{Sqlite, DRIVER}; +use crate::authentication::{self, Key}; +use crate::databases::error::Error; +use crate::databases::AuthKeyStore; + +impl AuthKeyStore for Sqlite { + fn load_keys(&self) -> Result, Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let mut stmt = conn.prepare("SELECT key, valid_until FROM keys")?; + + let keys_iter = stmt.query_map([], |row| { + let key: String = row.get(0)?; + let opt_valid_until: Option = row.get(1)?; + + match opt_valid_until { + Some(valid_until) => Ok(authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: Some(DurationSinceUnixEpoch::from_secs(valid_until.unsigned_abs())), + }), + None => Ok(authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: None, + }), + } + })?; + + let keys: Vec = keys_iter.filter_map(std::result::Result::ok).collect(); + + Ok(keys) + } + + fn get_key_from_keys(&self, key: &Key) -> Result, Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let mut stmt = conn.prepare("SELECT key, valid_until FROM keys WHERE key = ?")?; + + let mut rows = stmt.query([key.to_string()])?; + + let key = rows.next()?; + + Ok(key.map(|f| { + let valid_until: Option = f.get(1).unwrap(); + let key: String = f.get(0).unwrap(); + + match valid_until { + Some(valid_until) => authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: Some(DurationSinceUnixEpoch::from_secs(valid_until.unsigned_abs())), + }, + None => authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: None, + }, + } + })) + } + + fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let insert = match auth_key.valid_until { + Some(valid_until) => conn.execute( + "INSERT INTO keys (key, valid_until) VALUES (?1, ?2)", + [auth_key.key.to_string(), valid_until.as_secs().to_string()], + )?, + None => conn.execute( + "INSERT INTO keys (key, valid_until) VALUES (?1, ?2)", + params![auth_key.key.to_string(), Null], + )?, + }; + + if insert == 0 { + Err(Error::InsertFailed { + location: Location::caller(), + driver: DRIVER, + }) + } else { + Ok(insert) + } + } + + fn remove_key_from_keys(&self, key: &Key) -> Result { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let deleted = conn.execute("DELETE FROM keys WHERE key = ?", [key.to_string()])?; + + if deleted == 1 { + // should only remove a single record. + Ok(deleted) + } else { + Err(Error::DeleteFailed { + location: Location::caller(), + error_code: deleted, + driver: DRIVER, + }) + } + } +} diff --git a/packages/tracker-core/src/databases/driver/sqlite/mod.rs b/packages/tracker-core/src/databases/driver/sqlite/mod.rs new file mode 100644 index 000000000..b82488933 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/sqlite/mod.rs @@ -0,0 +1,125 @@ +//! The `SQLite3` database driver. +//! +//! This module provides implementations of the four narrow database traits +//! ([`SchemaMigrator`](crate::databases::SchemaMigrator), +//! [`TorrentMetricsStore`](crate::databases::TorrentMetricsStore), +//! [`WhitelistStore`](crate::databases::WhitelistStore), +//! [`AuthKeyStore`](crate::databases::AuthKeyStore) +//! for `SQLite3` using the `r2d2_sqlite` connection pool. It defines the schema +//! for whitelist, torrent metrics, and authentication keys, and provides methods +//! to create and drop tables as well as perform CRUD operations on these +//! persistent objects. +use std::panic::Location; + +use r2d2::Pool; +use r2d2_sqlite::SqliteConnectionManager; +use torrust_tracker_primitives::NumberOfDownloads; + +use super::{Driver, Error}; + +mod auth_key_store; +mod schema_migrator; +mod torrent_metrics_store; +mod whitelist_store; + +const DRIVER: Driver = Driver::Sqlite3; + +/// `SQLite` driver implementation. +/// +/// This struct encapsulates a connection pool for `SQLite` using the `r2d2_sqlite` +/// connection manager. +pub(crate) struct Sqlite { + pool: Pool, +} + +impl Sqlite { + /// Instantiates a new `SQLite3` database driver. + /// + /// This function creates a connection manager for the `SQLite` database + /// located at `db_path` and then builds a connection pool using `r2d2`. If + /// the pool cannot be created, an error is returned (wrapped with the + /// appropriate driver information). + /// + /// # Arguments + /// + /// * `db_path` - A string slice representing the file path to the `SQLite` database. + /// + /// # Errors + /// + /// Returns an [`Error`] if the connection pool cannot be built. + pub fn new(db_path: &str) -> Result { + let manager = SqliteConnectionManager::file(db_path); + let pool = r2d2::Pool::builder().build(manager).map_err(|e| (e, DRIVER))?; + + Ok(Self { pool }) + } + + fn load_torrent_aggregate_metric(&self, metric_name: &str) -> Result, Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let mut stmt = conn.prepare("SELECT value FROM torrent_aggregate_metrics WHERE metric_name = ?")?; + + let mut rows = stmt.query([metric_name])?; + + let persistent_torrent = rows.next()?; + + Ok(persistent_torrent.map(|f| { + let value: i64 = f.get(0).unwrap(); + u32::try_from(value).unwrap() + })) + } + + fn save_torrent_aggregate_metric(&self, metric_name: &str, completed: NumberOfDownloads) -> Result<(), Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let insert = conn.execute( + "INSERT INTO torrent_aggregate_metrics (metric_name, value) VALUES (?1, ?2) ON CONFLICT(metric_name) DO UPDATE SET value = ?2", + [metric_name.to_string(), completed.to_string()], + )?; + + if insert == 0 { + Err(Error::InsertFailed { + location: Location::caller(), + driver: DRIVER, + }) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + + use std::sync::Arc; + + use torrust_tracker_configuration::Core; + use torrust_tracker_test_helpers::configuration::ephemeral_sqlite_database; + + use crate::databases::driver::sqlite::Sqlite; + use crate::databases::driver::tests::run_tests; + use crate::databases::traits::Database; + + fn ephemeral_configuration() -> Core { + let mut config = Core::default(); + let temp_file = ephemeral_sqlite_database(); + temp_file.to_str().unwrap().clone_into(&mut config.database.path); + config + } + + fn initialize_driver(config: &Core) -> Arc> { + let driver: Arc> = Arc::new(Box::new(Sqlite::new(&config.database.path).unwrap())); + driver + } + + #[tokio::test] + async fn run_sqlite_driver_tests() -> Result<(), Box> { + let config = ephemeral_configuration(); + + let driver = initialize_driver(&config); + + run_tests(&driver).await; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/driver/sqlite/schema_migrator.rs b/packages/tracker-core/src/databases/driver/sqlite/schema_migrator.rs new file mode 100644 index 000000000..1c3c51ad5 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/sqlite/schema_migrator.rs @@ -0,0 +1,69 @@ +use super::{Sqlite, DRIVER}; +use crate::databases::error::Error; +use crate::databases::SchemaMigrator; + +impl SchemaMigrator for Sqlite { + fn create_database_tables(&self) -> Result<(), Error> { + let create_whitelist_table = " + CREATE TABLE IF NOT EXISTS whitelist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + info_hash TEXT NOT NULL UNIQUE + );" + .to_string(); + + let create_torrents_table = " + CREATE TABLE IF NOT EXISTS torrents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + info_hash TEXT NOT NULL UNIQUE, + completed INTEGER DEFAULT 0 NOT NULL + );" + .to_string(); + + let create_torrent_aggregate_metrics_table = " + CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + metric_name TEXT NOT NULL UNIQUE, + value INTEGER DEFAULT 0 NOT NULL + );" + .to_string(); + + let create_keys_table = " + CREATE TABLE IF NOT EXISTS keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + valid_until INTEGER + );" + .to_string(); + + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + conn.execute(&create_whitelist_table, [])?; + conn.execute(&create_keys_table, [])?; + conn.execute(&create_torrents_table, [])?; + conn.execute(&create_torrent_aggregate_metrics_table, [])?; + + Ok(()) + } + + fn drop_database_tables(&self) -> Result<(), Error> { + let drop_whitelist_table = " + DROP TABLE whitelist;" + .to_string(); + + let drop_torrents_table = " + DROP TABLE torrents;" + .to_string(); + + let drop_keys_table = " + DROP TABLE keys;" + .to_string(); + + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + conn.execute(&drop_whitelist_table, []) + .and_then(|_| conn.execute(&drop_torrents_table, [])) + .and_then(|_| conn.execute(&drop_keys_table, []))?; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/driver/sqlite/torrent_metrics_store.rs b/packages/tracker-core/src/databases/driver/sqlite/torrent_metrics_store.rs new file mode 100644 index 000000000..f2a494650 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/sqlite/torrent_metrics_store.rs @@ -0,0 +1,91 @@ +use std::str::FromStr; + +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; + +use super::{Sqlite, DRIVER}; +use crate::databases::driver::TORRENTS_DOWNLOADS_TOTAL; +use crate::databases::error::Error; +use crate::databases::TorrentMetricsStore; + +impl TorrentMetricsStore for Sqlite { + fn load_all_torrents_downloads(&self) -> Result { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let mut stmt = conn.prepare("SELECT info_hash, completed FROM torrents")?; + + let torrent_iter = stmt.query_map([], |row| { + let info_hash_string: String = row.get(0)?; + let info_hash = InfoHash::from_str(&info_hash_string).unwrap(); + let completed: u32 = row.get(1)?; + Ok((info_hash, completed)) + })?; + + Ok(torrent_iter.filter_map(std::result::Result::ok).collect()) + } + + fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result, Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let mut stmt = conn.prepare("SELECT completed FROM torrents WHERE info_hash = ?")?; + + let mut rows = stmt.query([info_hash.to_hex_string()])?; + + let persistent_torrent = rows.next()?; + + Ok(persistent_torrent.map(|f| { + let completed: i64 = f.get(0).unwrap(); + u32::try_from(completed).unwrap() + })) + } + + fn save_torrent_downloads(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let insert = conn.execute( + "INSERT INTO torrents (info_hash, completed) VALUES (?1, ?2) ON CONFLICT(info_hash) DO UPDATE SET completed = ?2", + [info_hash.to_string(), completed.to_string()], + )?; + + if insert == 0 { + Err(Error::InsertFailed { + location: std::panic::Location::caller(), + driver: DRIVER, + }) + } else { + Ok(()) + } + } + + fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let _ = conn.execute( + "UPDATE torrents SET completed = completed + 1 WHERE info_hash = ?", + [info_hash.to_string()], + )?; + + Ok(()) + } + + fn load_global_downloads(&self) -> Result, Error> { + self.load_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL) + } + + fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error> { + self.save_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL, downloaded) + } + + fn increase_global_downloads(&self) -> Result<(), Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let metric_name = TORRENTS_DOWNLOADS_TOTAL; + + let _ = conn.execute( + "UPDATE torrent_aggregate_metrics SET value = value + 1 WHERE metric_name = ?", + [metric_name], + )?; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/driver/sqlite/whitelist_store.rs b/packages/tracker-core/src/databases/driver/sqlite/whitelist_store.rs new file mode 100644 index 000000000..4425488fc --- /dev/null +++ b/packages/tracker-core/src/databases/driver/sqlite/whitelist_store.rs @@ -0,0 +1,70 @@ +use std::panic::Location; +use std::str::FromStr; + +use bittorrent_primitives::info_hash::InfoHash; + +use super::{Sqlite, DRIVER}; +use crate::databases::error::Error; +use crate::databases::WhitelistStore; + +impl WhitelistStore for Sqlite { + fn load_whitelist(&self) -> Result, Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let mut stmt = conn.prepare("SELECT info_hash FROM whitelist")?; + + let info_hash_iter = stmt.query_map([], |row| { + let info_hash: String = row.get(0)?; + + Ok(InfoHash::from_str(&info_hash).unwrap()) + })?; + + let info_hashes: Vec = info_hash_iter.filter_map(std::result::Result::ok).collect(); + + Ok(info_hashes) + } + + fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result, Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let mut stmt = conn.prepare("SELECT info_hash FROM whitelist WHERE info_hash = ?")?; + + let mut rows = stmt.query([info_hash.to_hex_string()])?; + + let query = rows.next()?; + + Ok(query.map(|f| InfoHash::from_str(&f.get_unwrap::<_, String>(0)).unwrap())) + } + + fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let insert = conn.execute("INSERT INTO whitelist (info_hash) VALUES (?)", [info_hash.to_string()])?; + + if insert == 0 { + Err(Error::InsertFailed { + location: Location::caller(), + driver: DRIVER, + }) + } else { + Ok(insert) + } + } + + fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let deleted = conn.execute("DELETE FROM whitelist WHERE info_hash = ?", [info_hash.to_string()])?; + + if deleted == 1 { + // should only remove a single record. + Ok(deleted) + } else { + Err(Error::DeleteFailed { + location: Location::caller(), + error_code: deleted, + driver: DRIVER, + }) + } + } +} diff --git a/packages/udp-tracker-server/src/handlers/announce.rs b/packages/udp-tracker-server/src/handlers/announce.rs index dac0f8e26..447ee7b83 100644 --- a/packages/udp-tracker-server/src/handlers/announce.rs +++ b/packages/udp-tracker-server/src/handlers/announce.rs @@ -896,7 +896,8 @@ pub(crate) mod tests { let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); + let db_downloads_metric_repository = + Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let request = AnnounceRequestBuilder::default() .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) From b221dbb57798cf4b5e804da6b13c3d451b493d3b Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 29 Apr 2026 12:58:38 +0100 Subject: [PATCH 5/9] docs(adrs): clarify deferring torrent metric trait split --- ...00_keep_database_as_aggregate_supertrait.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md b/docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md index f0b169bb3..b6c606534 100644 --- a/docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md +++ b/docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md @@ -83,6 +83,24 @@ between two trait objects would be a different story, but is not needed here. passing only the narrow store it needs. At that point `Database` can be made fully private or removed. +### Clarification And Revisit Criteria + +For now, `TorrentMetricsStore` keeps both per-torrent downloads (stored in +`torrents`) and the global aggregate metric `TORRENTS_DOWNLOADS_TOTAL` +(stored in `torrent_aggregate_metrics`). This is intentional: in the current +domain model there is only one persisted per-torrent metric and one persisted +global metric, and they are strongly related. + +There is no near-term plan to add more tables, fields, or persisted objects in +this area. Therefore, introducing another split (for example, +`TorrentAggregateMetricStore`) is deferred to avoid extra API churn without +clear short-term benefit. + +This decision should be reconsidered if persistence scope changes, especially +if aggregate metrics grow and are no longer torrent-specific (for example, +global tracker metrics such as total unique peers that ever announced), or if +method count/responsibility in `TorrentMetricsStore` increases materially. + ## Date 2026-04-29 From d0d36ebc6c9077c61f9779395d82e27e19b858f6 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 29 Apr 2026 13:02:35 +0100 Subject: [PATCH 6/9] docs(tracker-core): link persistence code to ADR rationale --- docs/packages.md | 6 ++++++ packages/tracker-core/src/databases/mod.rs | 3 +++ packages/tracker-core/src/databases/setup.rs | 3 +++ packages/tracker-core/src/databases/traits/mod.rs | 3 +++ .../tracker-core/src/databases/traits/torrent_metrics.rs | 5 +++++ 5 files changed, 20 insertions(+) diff --git a/docs/packages.md b/docs/packages.md index 118046a87..0e2ac4be5 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -3,6 +3,7 @@ - [Package Conventions](#package-conventions) - [Package Catalog](#package-catalog) - [Architectural Philosophy](#architectural-philosophy) +- [Design Decisions](#design-decisions) - [Protocol Implementation Details](#protocol-implementation-details) - [Architectural Philosophy](#architectural-philosophy) @@ -57,6 +58,11 @@ Key Architectural Principles: 2. **Protocol Compliance**: `*-protocol` packages strictly implement BEP specifications. 3. **Extensibility**: Core logic is framework-agnostic for easy protocol additions. +## Design Decisions + +- Persistence trait boundaries and the aggregate supertrait choice: + [docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md](adrs/20260429000000_keep_database_as_aggregate_supertrait.md) + ## Package Catalog | Package | Description | Key Responsibilities | diff --git a/packages/tracker-core/src/databases/mod.rs b/packages/tracker-core/src/databases/mod.rs index ccbaffca6..0742c5481 100644 --- a/packages/tracker-core/src/databases/mod.rs +++ b/packages/tracker-core/src/databases/mod.rs @@ -10,6 +10,9 @@ //! - [`Database`] — aggregate supertrait; any type that implements all four //! narrow traits automatically satisfies `Database` via a blanket impl //! +//! Design rationale: see ADR +//! [`20260429000000_keep_database_as_aggregate_supertrait`](../../../docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md). +//! //! There are two implementations (two drivers): //! //! - **`MySQL`** diff --git a/packages/tracker-core/src/databases/setup.rs b/packages/tracker-core/src/databases/setup.rs index d98bf3876..cb668dd96 100644 --- a/packages/tracker-core/src/databases/setup.rs +++ b/packages/tracker-core/src/databases/setup.rs @@ -1,4 +1,7 @@ //! This module provides functionality for setting up databases. +//! +//! For the persistence trait boundary and wiring rationale, see ADR +//! [`20260429000000_keep_database_as_aggregate_supertrait`](../../../docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md). use std::sync::Arc; use torrust_tracker_configuration::Core; diff --git a/packages/tracker-core/src/databases/traits/mod.rs b/packages/tracker-core/src/databases/traits/mod.rs index eec9f6811..d1308566e 100644 --- a/packages/tracker-core/src/databases/traits/mod.rs +++ b/packages/tracker-core/src/databases/traits/mod.rs @@ -1,4 +1,7 @@ //! Narrow context traits and the aggregate [`Database`] supertrait. +//! +//! Design rationale and revisit criteria: +//! [`20260429000000_keep_database_as_aggregate_supertrait`](../../../../docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md). pub mod auth_keys; pub mod database; pub mod schema; diff --git a/packages/tracker-core/src/databases/traits/torrent_metrics.rs b/packages/tracker-core/src/databases/traits/torrent_metrics.rs index 9c2227631..0d77ac77a 100644 --- a/packages/tracker-core/src/databases/traits/torrent_metrics.rs +++ b/packages/tracker-core/src/databases/traits/torrent_metrics.rs @@ -1,4 +1,9 @@ //! The [`TorrentMetricsStore`] trait — torrent metrics context. +//! +//! Note: this trait currently includes both per-torrent metrics and the global +//! aggregate downloads metric. The decision and revisit criteria are documented +//! in ADR +//! [`20260429000000_keep_database_as_aggregate_supertrait`](../../../../docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md). use bittorrent_primitives::info_hash::InfoHash; use mockall::automock; use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; From a2e0867cf425773dd0ae48c180a3758dc46f51a5 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 29 Apr 2026 13:03:08 +0100 Subject: [PATCH 7/9] docs(packages): normalize markdown table formatting --- docs/packages.md | 66 ++++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/docs/packages.md b/docs/packages.md index 0e2ac4be5..7713242cf 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -43,14 +43,14 @@ contrib/ ## Package Conventions -| Prefix | Responsibility | Dependencies | -|-----------------|-----------------------------------------|---------------------------| -| `axum-*` | HTTP server components using Axum | Axum framework | -| `*-server` | Server implementations | Corresponding *-core | -| `*-core` | Domain logic & business rules | Protocol implementations | -| `*-protocol` | BitTorrent protocol implementations | BitTorrent protocol | -| `udp-*` | UDP Protocol-specific implementations | Tracker core | -| `http-*` | HTTP Protocol-specific implementations | Tracker core | +| Prefix | Responsibility | Dependencies | +| ------------ | -------------------------------------- | ------------------------ | +| `axum-*` | HTTP server components using Axum | Axum framework | +| `*-server` | Server implementations | Corresponding \*-core | +| `*-core` | Domain logic & business rules | Protocol implementations | +| `*-protocol` | BitTorrent protocol implementations | BitTorrent protocol | +| `udp-*` | UDP Protocol-specific implementations | Tracker core | +| `http-*` | HTTP Protocol-specific implementations | Tracker core | Key Architectural Principles: @@ -65,31 +65,31 @@ Key Architectural Principles: ## Package Catalog -| Package | Description | Key Responsibilities | -|---------|-------------|----------------------| -| **axum-*** | | | -| `axum-server` | Base Axum HTTP server infrastructure | HTTP server lifecycle management | -| `axum-http-tracker-server` | BitTorrent HTTP tracker (BEP 3/23) | Handle announce/scrape requests | -| `axum-rest-tracker-api-server` | Management REST API | Tracker configuration & monitoring | -| `axum-health-check-api-server` | Health monitoring endpoint | System health reporting | -| **Core Components** | | | -| `http-tracker-core` | HTTP-specific implementation | Request validation, Response formatting | -| `udp-tracker-core` | UDP-specific implementation | Connectionless request handling | -| `tracker-core` | Central tracker logic | Peer management | -| **Protocols** | | | -| `http-protocol` | HTTP tracker protocol (BEP 3/23) | Announce/scrape request parsing | -| `udp-protocol` | UDP tracker protocol (BEP 15) | UDP message framing/parsing | -| **Domain** | | | -| `torrent-repository` | Torrent metadata storage | InfoHash management, Peer coordination | -| `configuration` | Runtime configuration | Config file parsing, Environment variables | -| `primitives` | Domain-specific types | InfoHash, PeerId, Byte handling | -| **Utilities** | | | -| `clock` | Time abstraction | Mockable time source for testing | -| `located-error` | Diagnostic errors | Error tracing with source locations | -| `test-helpers` | Testing utilities | Mock servers, Test data generation | -| **Client Tools** | | | -| `tracker-client` | CLI client | Tracker interaction/testing | -| `rest-tracker-api-client` | API client library | REST API integration | +| Package | Description | Key Responsibilities | +| ------------------------------ | ------------------------------------ | ------------------------------------------ | +| **axum-\*** | | | +| `axum-server` | Base Axum HTTP server infrastructure | HTTP server lifecycle management | +| `axum-http-tracker-server` | BitTorrent HTTP tracker (BEP 3/23) | Handle announce/scrape requests | +| `axum-rest-tracker-api-server` | Management REST API | Tracker configuration & monitoring | +| `axum-health-check-api-server` | Health monitoring endpoint | System health reporting | +| **Core Components** | | | +| `http-tracker-core` | HTTP-specific implementation | Request validation, Response formatting | +| `udp-tracker-core` | UDP-specific implementation | Connectionless request handling | +| `tracker-core` | Central tracker logic | Peer management | +| **Protocols** | | | +| `http-protocol` | HTTP tracker protocol (BEP 3/23) | Announce/scrape request parsing | +| `udp-protocol` | UDP tracker protocol (BEP 15) | UDP message framing/parsing | +| **Domain** | | | +| `torrent-repository` | Torrent metadata storage | InfoHash management, Peer coordination | +| `configuration` | Runtime configuration | Config file parsing, Environment variables | +| `primitives` | Domain-specific types | InfoHash, PeerId, Byte handling | +| **Utilities** | | | +| `clock` | Time abstraction | Mockable time source for testing | +| `located-error` | Diagnostic errors | Error tracing with source locations | +| `test-helpers` | Testing utilities | Mock servers, Test data generation | +| **Client Tools** | | | +| `tracker-client` | CLI client | Tracker interaction/testing | +| `rest-tracker-api-client` | API client library | REST API integration | ## Protocol Implementation Details From 38a057457a4de1a0d9ad7ba239a174dac1a2aebc Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 29 Apr 2026 13:04:33 +0100 Subject: [PATCH 8/9] docs(workflow): require ADR and code cross-linking --- .github/agents/implementer.agent.md | 12 ++++++++++++ .github/skills/dev/planning/create-adr/SKILL.md | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/.github/agents/implementer.agent.md b/.github/agents/implementer.agent.md index a34033693..822abbf28 100644 --- a/.github/agents/implementer.agent.md +++ b/.github/agents/implementer.agent.md @@ -33,6 +33,18 @@ Reference: [Beck Design Rules](https://martinfowler.com/bliki/BeckDesignRules.ht - `.github/skills/dev/rust-code-quality/handle-errors-in-code/SKILL.md` — error handling. - `.github/skills/dev/git-workflow/commit-changes/SKILL.md` — commit conventions. +### ADR Discoverability Convention + +When a change introduces or updates an ADR that affects a specific code area: + +- Link the ADR to the key affected code files (for example in an "Affected Code" + section). +- Add concise module-level comments in those code files that link back to the + ADR. + +Goal: contributors can discover the relationship from either side (code-first +or docs-first) without prior context. + ## Required Workflow ### Step 1 — Analyse the Task diff --git a/.github/skills/dev/planning/create-adr/SKILL.md b/.github/skills/dev/planning/create-adr/SKILL.md index 930a4bfc9..0438b1800 100644 --- a/.github/skills/dev/planning/create-adr/SKILL.md +++ b/.github/skills/dev/planning/create-adr/SKILL.md @@ -94,6 +94,18 @@ Add a row to the index table in `docs/adrs/index.md`: - The first column links to the ADR file using the timestamp as display text. - The short description should allow a reader to understand the decision without opening the file. +### Step 3.5: Cross-link ADR and Affected Code + +When an ADR affects a specific area of code, keep discovery bidirectional: + +- Add a short "Affected Code" section in the ADR with links to key files + (module entry points, traits, setup/wiring files). +- Add concise module-level doc comments in those code files pointing back to + the ADR. + +This keeps rationale discoverable whether a contributor starts from docs or +from code. + ### Step 4: Validate and Commit ```bash @@ -106,6 +118,9 @@ git commit -S -m "docs(adrs): add ADR for {short description}" git push {your-fork-remote} {branch} ``` +If code comments were added to establish ADR links, include those files in the +same commit when practical. + ## Example ADR For a real example, see From 356699b1e6eb40b2e1a9f85ce023b69bf1c028ed Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 29 Apr 2026 13:46:47 +0100 Subject: [PATCH 9/9] fix(tracker-core): replace panicking unwrap/expect with error propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot PR review suggestions on PR #1716: - Add MalformedDatabaseRecord error variant for unparseable DB values - Convert InfoHash parse failures (binascii::ConvertError) to Err instead of unwrap/panic in torrent_metrics_store and whitelist_store drivers - Convert Key parse failures (ParseKeyError) to Err instead of unwrap in auth_key_store drivers (mysql and sqlite) - Replace expect() with ? in mysql schema_migrator create/drop methods - Extract build_database_stores() helper in setup.rs to deduplicate the two identical DatabaseStores construction branches - Remove stray ç character from whitelist/repository/persisted.rs doc - Remove duplicate Architectural Philosophy entry in docs/packages.md ToC --- docs/packages.md | 1 - .../databases/driver/mysql/auth_key_store.rs | 56 ++++++++------ .../databases/driver/mysql/schema_migrator.rs | 19 ++--- .../driver/mysql/torrent_metrics_store.rs | 20 +++-- .../databases/driver/mysql/whitelist_store.rs | 24 ++++-- .../databases/driver/sqlite/auth_key_store.rs | 73 ++++++++++--------- .../driver/sqlite/torrent_metrics_store.rs | 24 ++++-- .../driver/sqlite/whitelist_store.rs | 34 ++++++--- packages/tracker-core/src/databases/error.rs | 6 ++ packages/tracker-core/src/databases/setup.rs | 26 ++++--- .../src/whitelist/repository/persisted.rs | 2 +- 11 files changed, 173 insertions(+), 112 deletions(-) diff --git a/docs/packages.md b/docs/packages.md index 7713242cf..c07622dc3 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -5,7 +5,6 @@ - [Architectural Philosophy](#architectural-philosophy) - [Design Decisions](#design-decisions) - [Protocol Implementation Details](#protocol-implementation-details) -- [Architectural Philosophy](#architectural-philosophy) ```output packages/ diff --git a/packages/tracker-core/src/databases/driver/mysql/auth_key_store.rs b/packages/tracker-core/src/databases/driver/mysql/auth_key_store.rs index 178b9b2e5..b9b207e86 100644 --- a/packages/tracker-core/src/databases/driver/mysql/auth_key_store.rs +++ b/packages/tracker-core/src/databases/driver/mysql/auth_key_store.rs @@ -12,21 +12,26 @@ impl AuthKeyStore for Mysql { fn load_keys(&self) -> Result, Error> { let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - let keys = conn.query_map( + let raw: Vec<(String, Option)> = conn.query_map( "SELECT `key`, valid_until FROM `keys`", - |(key, valid_until): (String, Option)| match valid_until { - Some(valid_until) => authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), - }, - None => authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: None, - }, - }, + |(key, valid_until): (String, Option)| (key, valid_until), )?; - Ok(keys) + raw.into_iter() + .map(|(key, valid_until)| { + let key = key.parse::().map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + })?; + Ok(match valid_until { + Some(valid_until) => authentication::PeerKey { + key, + valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), + }, + None => authentication::PeerKey { key, valid_until: None }, + }) + }) + .collect() } fn get_key_from_keys(&self, key: &Key) -> Result, Error> { @@ -39,16 +44,23 @@ impl AuthKeyStore for Mysql { let key = query?; - Ok(key.map(|(key, opt_valid_until)| match opt_valid_until { - Some(valid_until) => authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), - }, - None => authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: None, - }, - })) + let peer_key = key + .map(|(key, opt_valid_until)| -> Result { + let key = key.parse::().map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + })?; + Ok(match opt_valid_until { + Some(valid_until) => authentication::PeerKey { + key, + valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), + }, + None => authentication::PeerKey { key, valid_until: None }, + }) + }) + .transpose()?; + + Ok(peer_key) } fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result { diff --git a/packages/tracker-core/src/databases/driver/mysql/schema_migrator.rs b/packages/tracker-core/src/databases/driver/mysql/schema_migrator.rs index c06f49f98..747ff6e47 100644 --- a/packages/tracker-core/src/databases/driver/mysql/schema_migrator.rs +++ b/packages/tracker-core/src/databases/driver/mysql/schema_migrator.rs @@ -44,13 +44,10 @@ impl SchemaMigrator for Mysql { let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - conn.query_drop(&create_torrents_table) - .expect("Could not create torrents table."); - conn.query_drop(&create_torrent_aggregate_metrics_table) - .expect("Could not create create_torrent_aggregate_metrics_table table."); - conn.query_drop(&create_keys_table).expect("Could not create keys table."); - conn.query_drop(&create_whitelist_table) - .expect("Could not create whitelist table."); + conn.query_drop(&create_torrents_table)?; + conn.query_drop(&create_torrent_aggregate_metrics_table)?; + conn.query_drop(&create_keys_table)?; + conn.query_drop(&create_whitelist_table)?; Ok(()) } @@ -70,11 +67,9 @@ impl SchemaMigrator for Mysql { let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - conn.query_drop(&drop_whitelist_table) - .expect("Could not drop `whitelist` table."); - conn.query_drop(&drop_torrents_table) - .expect("Could not drop `torrents` table."); - conn.query_drop(&drop_keys_table).expect("Could not drop `keys` table."); + conn.query_drop(&drop_whitelist_table)?; + conn.query_drop(&drop_torrents_table)?; + conn.query_drop(&drop_keys_table)?; Ok(()) } diff --git a/packages/tracker-core/src/databases/driver/mysql/torrent_metrics_store.rs b/packages/tracker-core/src/databases/driver/mysql/torrent_metrics_store.rs index 9c4f69379..0888e1a0f 100644 --- a/packages/tracker-core/src/databases/driver/mysql/torrent_metrics_store.rs +++ b/packages/tracker-core/src/databases/driver/mysql/torrent_metrics_store.rs @@ -14,15 +14,23 @@ impl TorrentMetricsStore for Mysql { fn load_all_torrents_downloads(&self) -> Result { let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - let torrents = conn.query_map( + let raw_rows: Vec<(String, u32)> = conn.query_map( "SELECT info_hash, completed FROM torrents", - |(info_hash_string, completed): (String, u32)| { - let info_hash = InfoHash::from_str(&info_hash_string).unwrap(); - (info_hash, completed) - }, + |(info_hash_string, completed): (String, u32)| (info_hash_string, completed), )?; - Ok(torrents.iter().copied().collect()) + raw_rows + .into_iter() + .map(|(s, completed)| { + InfoHash::from_str(&s) + .map(|info_hash| (info_hash, completed)) + .map_err(|e| Error::MalformedDatabaseRecord { + message: format!("{e:?}"), + driver: DRIVER, + }) + }) + .collect::, Error>>() + .map(|v| v.iter().copied().collect()) } fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result, Error> { diff --git a/packages/tracker-core/src/databases/driver/mysql/whitelist_store.rs b/packages/tracker-core/src/databases/driver/mysql/whitelist_store.rs index f99b7a880..b0ffb7cc5 100644 --- a/packages/tracker-core/src/databases/driver/mysql/whitelist_store.rs +++ b/packages/tracker-core/src/databases/driver/mysql/whitelist_store.rs @@ -12,11 +12,16 @@ impl WhitelistStore for Mysql { fn load_whitelist(&self) -> Result, Error> { let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - let info_hashes = conn.query_map("SELECT info_hash FROM whitelist", |info_hash: String| { - InfoHash::from_str(&info_hash).unwrap() - })?; - - Ok(info_hashes) + let raw: Vec = conn.query_map("SELECT info_hash FROM whitelist", |info_hash: String| info_hash)?; + + raw.into_iter() + .map(|s| { + InfoHash::from_str(&s).map_err(|e| Error::MalformedDatabaseRecord { + message: format!("{e:?}"), + driver: DRIVER, + }) + }) + .collect() } fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result, Error> { @@ -27,7 +32,14 @@ impl WhitelistStore for Mysql { params! { "info_hash" => info_hash.to_hex_string() }, )?; - let info_hash = select.map(|f| InfoHash::from_str(&f).expect("Failed to decode InfoHash String from DB!")); + let info_hash = select + .map(|s| { + InfoHash::from_str(&s).map_err(|e| Error::MalformedDatabaseRecord { + message: format!("{e:?}"), + driver: DRIVER, + }) + }) + .transpose()?; Ok(info_hash) } diff --git a/packages/tracker-core/src/databases/driver/sqlite/auth_key_store.rs b/packages/tracker-core/src/databases/driver/sqlite/auth_key_store.rs index 8ae9bb222..57e6eef7a 100644 --- a/packages/tracker-core/src/databases/driver/sqlite/auth_key_store.rs +++ b/packages/tracker-core/src/databases/driver/sqlite/auth_key_store.rs @@ -15,25 +15,26 @@ impl AuthKeyStore for Sqlite { let mut stmt = conn.prepare("SELECT key, valid_until FROM keys")?; - let keys_iter = stmt.query_map([], |row| { - let key: String = row.get(0)?; - let opt_valid_until: Option = row.get(1)?; - - match opt_valid_until { - Some(valid_until) => Ok(authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: Some(DurationSinceUnixEpoch::from_secs(valid_until.unsigned_abs())), - }), - None => Ok(authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: None, - }), - } - })?; - - let keys: Vec = keys_iter.filter_map(std::result::Result::ok).collect(); - - Ok(keys) + let raw: Vec<(String, Option)> = stmt + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)))? + .filter_map(std::result::Result::ok) + .collect(); + + raw.into_iter() + .map(|(key, opt_valid_until)| { + let key = key.parse::().map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + })?; + Ok(match opt_valid_until { + Some(valid_until) => authentication::PeerKey { + key, + valid_until: Some(DurationSinceUnixEpoch::from_secs(valid_until.unsigned_abs())), + }, + None => authentication::PeerKey { key, valid_until: None }, + }) + }) + .collect() } fn get_key_from_keys(&self, key: &Key) -> Result, Error> { @@ -45,21 +46,25 @@ impl AuthKeyStore for Sqlite { let key = rows.next()?; - Ok(key.map(|f| { - let valid_until: Option = f.get(1).unwrap(); - let key: String = f.get(0).unwrap(); - - match valid_until { - Some(valid_until) => authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: Some(DurationSinceUnixEpoch::from_secs(valid_until.unsigned_abs())), - }, - None => authentication::PeerKey { - key: key.parse::().unwrap(), - valid_until: None, - }, - } - })) + let peer_key = key + .map(|f| -> Result { + let valid_until: Option = f.get(1).map_err(Error::from)?; + let key: String = f.get(0).map_err(Error::from)?; + let key = key.parse::().map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + })?; + Ok(match valid_until { + Some(valid_until) => authentication::PeerKey { + key, + valid_until: Some(DurationSinceUnixEpoch::from_secs(valid_until.unsigned_abs())), + }, + None => authentication::PeerKey { key, valid_until: None }, + }) + }) + .transpose()?; + + Ok(peer_key) } fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result { diff --git a/packages/tracker-core/src/databases/driver/sqlite/torrent_metrics_store.rs b/packages/tracker-core/src/databases/driver/sqlite/torrent_metrics_store.rs index f2a494650..67dc54891 100644 --- a/packages/tracker-core/src/databases/driver/sqlite/torrent_metrics_store.rs +++ b/packages/tracker-core/src/databases/driver/sqlite/torrent_metrics_store.rs @@ -14,14 +14,22 @@ impl TorrentMetricsStore for Sqlite { let mut stmt = conn.prepare("SELECT info_hash, completed FROM torrents")?; - let torrent_iter = stmt.query_map([], |row| { - let info_hash_string: String = row.get(0)?; - let info_hash = InfoHash::from_str(&info_hash_string).unwrap(); - let completed: u32 = row.get(1)?; - Ok((info_hash, completed)) - })?; - - Ok(torrent_iter.filter_map(std::result::Result::ok).collect()) + let raw: Vec<(String, u32)> = stmt + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?)))? + .filter_map(std::result::Result::ok) + .collect(); + + raw.into_iter() + .map(|(s, completed)| { + InfoHash::from_str(&s) + .map(|info_hash| (info_hash, completed)) + .map_err(|e| Error::MalformedDatabaseRecord { + message: format!("{e:?}"), + driver: DRIVER, + }) + }) + .collect::, Error>>() + .map(|v| v.iter().copied().collect()) } fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result, Error> { diff --git a/packages/tracker-core/src/databases/driver/sqlite/whitelist_store.rs b/packages/tracker-core/src/databases/driver/sqlite/whitelist_store.rs index 4425488fc..9cfb3f600 100644 --- a/packages/tracker-core/src/databases/driver/sqlite/whitelist_store.rs +++ b/packages/tracker-core/src/databases/driver/sqlite/whitelist_store.rs @@ -13,15 +13,19 @@ impl WhitelistStore for Sqlite { let mut stmt = conn.prepare("SELECT info_hash FROM whitelist")?; - let info_hash_iter = stmt.query_map([], |row| { - let info_hash: String = row.get(0)?; - - Ok(InfoHash::from_str(&info_hash).unwrap()) - })?; - - let info_hashes: Vec = info_hash_iter.filter_map(std::result::Result::ok).collect(); - - Ok(info_hashes) + let raw: Vec = stmt + .query_map([], |row| row.get::<_, String>(0))? + .filter_map(std::result::Result::ok) + .collect(); + + raw.into_iter() + .map(|s| { + InfoHash::from_str(&s).map_err(|e| Error::MalformedDatabaseRecord { + message: format!("{e:?}"), + driver: DRIVER, + }) + }) + .collect() } fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result, Error> { @@ -33,7 +37,17 @@ impl WhitelistStore for Sqlite { let query = rows.next()?; - Ok(query.map(|f| InfoHash::from_str(&f.get_unwrap::<_, String>(0)).unwrap())) + let info_hash = query + .map(|f| -> Result { + let s: String = f.get(0).map_err(Error::from)?; + InfoHash::from_str(&s).map_err(|e| Error::MalformedDatabaseRecord { + message: format!("{e:?}"), + driver: DRIVER, + }) + }) + .transpose()?; + + Ok(info_hash) } fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result { diff --git a/packages/tracker-core/src/databases/error.rs b/packages/tracker-core/src/databases/error.rs index 2df2cb277..1b6d718f2 100644 --- a/packages/tracker-core/src/databases/error.rs +++ b/packages/tracker-core/src/databases/error.rs @@ -69,6 +69,12 @@ pub enum Error { driver: Driver, }, + /// Indicates that a row read from the database contains a malformed value + /// (e.g., a corrupt or manually-edited `info_hash` or key string that + /// cannot be parsed into the expected domain type). + #[error("Malformed {driver} database record: {message}")] + MalformedDatabaseRecord { message: String, driver: Driver }, + /// Indicates a failure to connect to the database. /// /// This error variant wraps connection-related errors, such as those caused by an invalid URL. diff --git a/packages/tracker-core/src/databases/setup.rs b/packages/tracker-core/src/databases/setup.rs index cb668dd96..71a0c1e73 100644 --- a/packages/tracker-core/src/databases/setup.rs +++ b/packages/tracker-core/src/databases/setup.rs @@ -30,6 +30,18 @@ pub struct DatabaseStores { pub auth_key_store: Arc, } +fn build_database_stores(db: Arc) -> DatabaseStores +where + T: SchemaMigrator + TorrentMetricsStore + WhitelistStore + AuthKeyStore + Send + Sync + 'static, +{ + DatabaseStores { + schema_migrator: db.clone(), + torrent_metrics_store: db.clone(), + whitelist_store: db.clone(), + auth_key_store: db, + } +} + /// Initializes and returns a [`DatabaseStores`] bundle based on the provided /// configuration. /// @@ -71,22 +83,12 @@ pub fn initialize_database(config: &Core) -> DatabaseStores { Driver::Sqlite3 => { let db = Arc::new(Sqlite::new(&config.database.path).expect("Database driver build failed.")); db.create_database_tables().expect("Could not create database tables."); - DatabaseStores { - schema_migrator: db.clone(), - torrent_metrics_store: db.clone(), - whitelist_store: db.clone(), - auth_key_store: db, - } + build_database_stores(db) } Driver::MySQL => { let db = Arc::new(Mysql::new(&config.database.path).expect("Database driver build failed.")); db.create_database_tables().expect("Could not create database tables."); - DatabaseStores { - schema_migrator: db.clone(), - torrent_metrics_store: db.clone(), - whitelist_store: db.clone(), - auth_key_store: db, - } + build_database_stores(db) } } } diff --git a/packages/tracker-core/src/whitelist/repository/persisted.rs b/packages/tracker-core/src/whitelist/repository/persisted.rs index b449ffadc..950ab13a0 100644 --- a/packages/tracker-core/src/whitelist/repository/persisted.rs +++ b/packages/tracker-core/src/whitelist/repository/persisted.rs @@ -8,7 +8,7 @@ use crate::databases::{self, WhitelistStore}; /// The persisted list of allowed torrents. /// /// This repository handles adding, removing, and loading torrents -/// from a persistent database like `SQLite` or `MySQL`ç. +/// from a persistent database like `SQLite` or `MySQL`. pub struct DatabaseWhitelist { /// A whitelist store implementation (e.g., `SQLite3` or `MySQL`). database: Arc,