Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 129 additions & 114 deletions Cargo.lock

Large diffs are not rendered by default.

69 changes: 69 additions & 0 deletions packages/tracker-core/migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,72 @@
We don't support automatic migrations yet. The tracker creates all the needed tables when it starts. The SQL sentences are hardcoded in each database driver.

The migrations in this folder were introduced to add some new changes (permanent keys) and to allow users to migrate to the new version. In the future, we will remove the hardcoded SQL and start using a Rust crate for database migrations. For the time being, if you are using the initial schema described in the migration `20240730183000_torrust_tracker_create_all_tables.sql` you will need to run all the subsequent migrations manually.

## Database Tables

The tracker uses 4 tables:

### 1. `whitelist_v1`

Stores whitelisted torrent infohashes for private/whitelisted mode.

| Column | SQLite Type | MySQL Type | Description |
|--------|-------------|------------|-------------|
| `info_hash` | TEXT PRIMARY KEY NOT NULL | BINARY(20) PRIMARY KEY NOT NULL | BitTorrent V1 infohash (SQLite: 40-char hex string, MySQL: 20-byte binary) |

> **Note**: SQLite uses `WITHOUT ROWID` optimization. MySQL stores as binary for efficiency.

### 2. `completed_v1`

Stores per-torrent download completion counts.

| Column | SQLite Type | MySQL Type | Description |
|--------|-------------|------------|-------------|
| `info_hash` | TEXT PRIMARY KEY NOT NULL | BINARY(20) PRIMARY KEY NOT NULL | BitTorrent V1 infohash (SQLite: 40-char hex string, MySQL: 20-byte binary) |
| `count` | INTEGER DEFAULT 1 NOT NULL CHECK (count >= 1) | INTEGER DEFAULT 1 NOT NULL CHECK (count >= 1) | Number of times the torrent has been fully downloaded (minimum 1) |

> **Note**: SQLite uses `WITHOUT ROWID` optimization. MySQL stores info_hash as binary for efficiency.

### 3. `keys`

Stores authentication keys for private trackers.

| Column | SQLite Type | MySQL Type | Description |
|--------|-------------|------------|-------------|
| `id` | INTEGER PRIMARY KEY AUTOINCREMENT | INT NOT NULL AUTO_INCREMENT | Auto-increment ID |
| `key` | TEXT NOT NULL UNIQUE | VARCHAR(32) NOT NULL UNIQUE | Authentication token (32-char alphanumeric string) |
| `valid_until` | INTEGER (nullable) | INT(10) (nullable) | Unix timestamp for key expiration; NULL means permanent key |

### 4. `torrent_aggregate_metrics`

Stores global/aggregate metrics not tied to specific torrents (e.g., total downloads across all torrents).

| Column | SQLite Type | MySQL Type | Description |
|--------|-------------|------------|-------------|
| `id` | INTEGER PRIMARY KEY AUTOINCREMENT | integer PRIMARY KEY AUTO_INCREMENT | Auto-increment ID |
| `metric_name` | TEXT NOT NULL UNIQUE | VARCHAR(50) NOT NULL UNIQUE | Unique metric identifier (e.g., `torrents_downloads_total`) |
| `value` | INTEGER DEFAULT 0 NOT NULL | INTEGER DEFAULT 0 NOT NULL | The metric value |

## Migration Files

### SQLite

| Migration | Description |
|-----------|-------------|
| `20240730183000_torrust_tracker_create_all_tables.sql` | Creates initial tables: `whitelist`, `torrents`, `keys` |
| `20240730183500_torrust_tracker_keys_valid_until_nullable.sql` | Makes `valid_until` column nullable in `keys` table (for permanent keys) |
| `20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql` | Creates `torrent_aggregate_metrics` table for global metrics |
| `20260206120000_torrust_tracker_torrents_completed_non_zero.sql` | Removes rows with completed=0 and adds CHECK constraint (completed >= 1) |
| `20260206130000_torrust_tracker_whitelist_without_rowid.sql` | Optimizes whitelist table: removes `id` column, uses `info_hash` as PRIMARY KEY with WITHOUT ROWID |
| `20260206140000_torrust_tracker_completed_v1.sql` | Migrates `torrents` to `completed_v1`: removes `id` column, renames `completed` to `count`, uses `info_hash` as PRIMARY KEY with WITHOUT ROWID |

### MySQL

| Migration | Description |
|-----------|-------------|
| `20240730183000_torrust_tracker_create_all_tables.sql` | Creates initial tables: `whitelist`, `torrents`, `keys` |
| `20240730183500_torrust_tracker_keys_valid_until_nullable.sql` | Makes `valid_until` column nullable in `keys` table (for permanent keys) |
| `20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql` | Creates `torrent_aggregate_metrics` table for global metrics |
| `20260206120000_torrust_tracker_torrents_completed_non_zero.sql` | Removes rows with completed=0 and adds CHECK constraint (completed >= 1) |
| `20260206130000_torrust_tracker_whitelist_binary.sql` | Optimizes whitelist table: removes `id` column, uses BINARY(20) `info_hash` as PRIMARY KEY |
| `20260206140000_torrust_tracker_completed_v1.sql` | Migrates `torrents` to `completed_v1`: removes `id` column, renames `completed` to `count`, uses BINARY(20) `info_hash` as PRIMARY KEY |
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Remove any rows with completed = 0 (should not exist in normal operation)
DELETE FROM torrents WHERE completed = 0;

-- Add CHECK constraint to enforce completed >= 1
-- Note: MySQL 8.0.16+ enforces CHECK constraints
ALTER TABLE torrents ADD CONSTRAINT chk_completed_non_zero CHECK (completed >= 1);

-- Change the default value from 0 to 1
ALTER TABLE torrents ALTER completed SET DEFAULT 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Migrate whitelist table to whitelist_v1 with BINARY(20) PRIMARY KEY for efficient storage
-- BINARY(20) stores the raw 20-byte infohash instead of 40-char hex string

CREATE TABLE IF NOT EXISTS whitelist_v1 (
info_hash BINARY(20) PRIMARY KEY NOT NULL
);

-- Convert existing hex strings to binary
INSERT INTO whitelist_v1 (info_hash) SELECT UNHEX(info_hash) FROM whitelist;

DROP TABLE whitelist;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Migrate torrents table to completed_v1 with BINARY(20) info_hash as PRIMARY KEY
-- Rename 'completed' column to 'count'
-- BINARY(20) stores the raw 20-byte infohash instead of 40-char hex string

CREATE TABLE IF NOT EXISTS completed_v1 (
info_hash BINARY(20) PRIMARY KEY NOT NULL,
count INTEGER DEFAULT 1 NOT NULL CHECK (count >= 1)
);

-- Convert existing hex strings to binary
INSERT INTO completed_v1 (info_hash, count) SELECT UNHEX(info_hash), completed FROM torrents;

DROP TABLE torrents;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Remove any rows with completed = 0 (should not exist in normal operation)
DELETE FROM torrents WHERE completed = 0;

-- SQLite doesn't support adding CHECK constraints to existing tables directly.
-- We need to recreate the table with the new constraint.
CREATE TABLE IF NOT EXISTS torrents_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
info_hash TEXT NOT NULL UNIQUE,
completed INTEGER DEFAULT 1 NOT NULL CHECK (completed >= 1)
);

INSERT INTO torrents_new (id, info_hash, completed) SELECT id, info_hash, completed FROM torrents;

DROP TABLE torrents;

ALTER TABLE torrents_new RENAME TO torrents;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Migrate whitelist table to whitelist_v1 with info_hash as PRIMARY KEY and WITHOUT ROWID optimization
CREATE TABLE IF NOT EXISTS whitelist_v1 (
info_hash TEXT PRIMARY KEY NOT NULL
) WITHOUT ROWID;

INSERT INTO whitelist_v1 (info_hash) SELECT info_hash FROM whitelist;

DROP TABLE whitelist;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Migrate torrents table to completed_v1 with info_hash as PRIMARY KEY and WITHOUT ROWID optimization
-- Rename 'completed' column to 'count'
CREATE TABLE IF NOT EXISTS completed_v1 (
info_hash TEXT PRIMARY KEY NOT NULL,
count INTEGER DEFAULT 1 NOT NULL CHECK (count >= 1)
) WITHOUT ROWID;

INSERT INTO completed_v1 (info_hash, count) SELECT info_hash, completed FROM torrents;

DROP TABLE torrents;
81 changes: 41 additions & 40 deletions packages/tracker-core/src/databases/driver/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
//! 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;
Expand Down Expand Up @@ -73,17 +72,15 @@ impl Database for Mysql {
/// Refer to [`databases::Database::create_database_tables`](crate::core::databases::Database::create_database_tables).
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
CREATE TABLE IF NOT EXISTS whitelist_v1 (
info_hash BINARY(20) PRIMARY KEY NOT NULL
);"
.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
let create_completed_table = "
CREATE TABLE IF NOT EXISTS completed_v1 (
info_hash BINARY(20) PRIMARY KEY NOT NULL,
count INTEGER DEFAULT 1 NOT NULL CHECK (count >= 1)
);"
.to_string();

Expand All @@ -109,8 +106,8 @@ impl Database 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_completed_table)
.expect("Could not create completed_v1 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.");
Expand All @@ -123,11 +120,11 @@ impl Database for Mysql {
/// Refer to [`databases::Database::drop_database_tables`](crate::core::databases::Database::drop_database_tables).
fn drop_database_tables(&self) -> Result<(), Error> {
let drop_whitelist_table = "
DROP TABLE `whitelist`;"
DROP TABLE `whitelist_v1`;"
.to_string();

let drop_torrents_table = "
DROP TABLE `torrents`;"
let drop_completed_table = "
DROP TABLE `completed_v1`;"
.to_string();

let drop_keys_table = "
Expand All @@ -137,9 +134,9 @@ impl Database 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.");
.expect("Could not drop `whitelist_v1` table.");
conn.query_drop(&drop_completed_table)
.expect("Could not drop `completed_v1` table.");
conn.query_drop(&drop_keys_table).expect("Could not drop `keys` table.");

Ok(())
Expand All @@ -150,10 +147,10 @@ impl Database for Mysql {
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)
"SELECT info_hash, count FROM completed_v1",
|(info_hash_bytes, count): (Vec<u8>, u32)| {
let info_hash = InfoHash::from_bytes(&info_hash_bytes);
(info_hash, count)
},
)?;

Expand All @@ -165,8 +162,8 @@ impl Database for Mysql {
let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?;

let query = conn.exec_first::<u32, _, _>(
"SELECT completed FROM torrents WHERE info_hash = :info_hash",
params! { "info_hash" => info_hash.to_hex_string() },
"SELECT count FROM completed_v1 WHERE info_hash = :info_hash",
params! { "info_hash" => info_hash.bytes() },
);

let persistent_torrent = query?;
Expand All @@ -176,24 +173,25 @@ impl Database for Mysql {

/// Refer to [`databases::Database::save_persistent_torrent`](crate::core::databases::Database::save_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)";
const COMMAND: &str = "INSERT INTO completed_v1 (info_hash, count) VALUES (:info_hash_bytes, :count) ON DUPLICATE KEY UPDATE count = VALUES(count)";

let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?;

let info_hash_str = info_hash.to_string();
let info_hash_bytes = info_hash.bytes();
let count = completed;

Ok(conn.exec_drop(COMMAND, params! { info_hash_str, completed })?)
Ok(conn.exec_drop(COMMAND, params! { info_hash_bytes, count })?)
}

/// Refer to [`databases::Database::increase_number_of_downloads`](crate::core::databases::Database::increase_number_of_downloads).
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();
let info_hash_bytes = info_hash.bytes();

conn.exec_drop(
"UPDATE torrents SET completed = completed + 1 WHERE info_hash = :info_hash_str",
params! { info_hash_str },
"UPDATE completed_v1 SET count = count + 1 WHERE info_hash = :info_hash_bytes",
params! { info_hash_bytes },
)?;

Ok(())
Expand Down Expand Up @@ -248,8 +246,8 @@ impl Database for Mysql {
fn load_whitelist(&self) -> Result<Vec<InfoHash>, 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()
let info_hashes = conn.query_map("SELECT info_hash FROM whitelist_v1", |info_hash_bytes: Vec<u8>| {
InfoHash::from_bytes(&info_hash_bytes)
})?;

Ok(info_hashes)
Expand All @@ -259,12 +257,12 @@ impl Database for Mysql {
fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<Option<InfoHash>, Error> {
let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?;

let select = conn.exec_first::<String, _, _>(
"SELECT info_hash FROM whitelist WHERE info_hash = :info_hash",
params! { "info_hash" => info_hash.to_hex_string() },
let select = conn.exec_first::<Vec<u8>, _, _>(
"SELECT info_hash FROM whitelist_v1 WHERE info_hash = :info_hash",
params! { "info_hash" => info_hash.bytes() },
)?;

let info_hash = select.map(|f| InfoHash::from_str(&f).expect("Failed to decode InfoHash String from DB!"));
let info_hash = select.map(|bytes| InfoHash::from_bytes(&bytes));

Ok(info_hash)
}
Expand All @@ -273,11 +271,11 @@ impl Database for Mysql {
fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error> {
let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?;

let info_hash_str = info_hash.to_string();
let info_hash_bytes = info_hash.bytes();

conn.exec_drop(
"INSERT INTO whitelist (info_hash) VALUES (:info_hash_str)",
params! { info_hash_str },
"INSERT INTO whitelist_v1 (info_hash) VALUES (:info_hash_bytes)",
params! { info_hash_bytes },
)?;

Ok(1)
Expand All @@ -287,9 +285,12 @@ impl Database for Mysql {
fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error> {
let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?;

let info_hash = info_hash.to_string();
let info_hash_bytes = info_hash.bytes();

conn.exec_drop("DELETE FROM whitelist WHERE info_hash = :info_hash", params! { info_hash })?;
conn.exec_drop(
"DELETE FROM whitelist_v1 WHERE info_hash = :info_hash_bytes",
params! { info_hash_bytes },
)?;

Ok(1)
}
Expand Down
Loading
Loading