diff --git a/database/migrations/2026_08_13_001652_normalize_and_optimize_releases_table.php b/database/migrations/2026_08_13_001652_normalize_and_optimize_releases_table.php index 2d147f409..ab48b4e6f 100644 --- a/database/migrations/2026_08_13_001652_normalize_and_optimize_releases_table.php +++ b/database/migrations/2026_08_13_001652_normalize_and_optimize_releases_table.php @@ -50,6 +50,19 @@ return new class extends Migration 'ix_releases_nzb_creation_queue', ]; + /** + * Narrowed to `CHAR(32) ascii` as part of this rebuild rather than in a + * follow-up migration: the `guid` charset change already forces + * `ALGORITHM=COPY`, so folding these in costs nothing, whereas a separate + * migration would force a second full table rebuild. + * + * @var list + */ + private const array CLAIM_TOKEN_COLUMNS = [ + 'nzb_creation_claim_token', + 'additional_pp_claim_token', + ]; + public function up(): void { if (! Schema::hasTable('releases')) { @@ -275,6 +288,11 @@ return new class extends Migration $specifications[] = 'MODIFY `guid` CHAR(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL'; $specifications[] = "MODIFY `leftguid` CHAR(1) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL COMMENT 'The first letter of the release guid'"; + foreach (self::CLAIM_TOKEN_COLUMNS as $column) { + if (Schema::hasColumn('releases', $column)) { + $specifications[] = 'MODIFY `'.$column.'` CHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci NULL'; + } + } foreach (self::REMOVED_RELEASE_COLUMNS as $column) { if (Schema::hasColumn('releases', $column)) { $specifications[] = 'DROP COLUMN `'.$column.'`'; @@ -286,7 +304,7 @@ return new class extends Migration 'ADD UNIQUE INDEX `ux_releases_guid` (`guid`)', 'ADD INDEX `ix_releases_predb_id` (`predb_id`)', 'ADD INDEX `ix_releases_size` (`size`)', - 'ADD INDEX `ix_releases_add_pp_claim_queue` (`passwordstatus`, `haspreview`, `nzbstatus`, `leftguid`, `postdate` DESC, `id`, `additional_pp_claimed_at`)', + 'ADD INDEX `ix_releases_add_pp_claim_queue` (`passwordstatus`, `haspreview`, `nzbstatus`, `leftguid`, `postdate` DESC, `id`, `additional_pp_claimed_at`, `size`)', 'ADD INDEX `ix_releases_nzb_creation_group_queue` (`nzbstatus`, `groups_id`, `postdate` DESC, `id`, `nzb_creation_claimed_at`)', 'ADD INDEX `ix_releases_nzb_creation_global_queue` (`nzbstatus`, `postdate` DESC, `id`, `nzb_creation_claimed_at`)', ]; @@ -311,7 +329,7 @@ return new class extends Migration $table->index('predb_id', 'ix_releases_predb_id'); $table->index('size', 'ix_releases_size'); $table->index( - ['passwordstatus', 'haspreview', 'nzbstatus', 'leftguid', 'postdate', 'id', 'additional_pp_claimed_at'], + ['passwordstatus', 'haspreview', 'nzbstatus', 'leftguid', 'postdate', 'id', 'additional_pp_claimed_at', 'size'], 'ix_releases_add_pp_claim_queue', ); $table->index( @@ -345,6 +363,8 @@ return new class extends Migration ...$specifications, 'MODIFY `guid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL', "MODIFY `leftguid` CHAR(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'The first letter of the release guid'", + 'MODIFY `nzb_creation_claim_token` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL', + 'MODIFY `additional_pp_claim_token` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL', 'ADD COLUMN `updatetime` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `adddate`', 'ADD COLUMN `gid` VARCHAR(32) NULL AFTER `updatetime`', 'ADD COLUMN `source` SMALLINT UNSIGNED NULL', diff --git a/database/migrations/2026_08_14_121946_optimize_releases_claim_tokens_and_pp_index.php b/database/migrations/2026_08_14_121946_optimize_releases_claim_tokens_and_pp_index.php index 48167385d..4f43657b5 100644 --- a/database/migrations/2026_08_14_121946_optimize_releases_claim_tokens_and_pp_index.php +++ b/database/migrations/2026_08_14_121946_optimize_releases_claim_tokens_and_pp_index.php @@ -9,22 +9,30 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; /** - * Follow-up to the releases normalization rebuild. + * Catch-up migration for installs that already ran + * `2026_08_13_001652_normalize_and_optimize_releases_table` before it was + * extended to cover these two changes: * - * 1. Appends `size` to `ix_releases_add_pp_claim_queue`. Every additional + * 1. `size` appended to `ix_releases_add_pp_claim_queue`. Every additional * post-processing candidate query filters `size > min AND size < max` - * ({@see AdditionalCandidateQuery::applyPredicates()}), - * but `size` was not in the index, so each candidate that later failed the - * size filter still cost a primary key lookup. Appending it after the - * existing columns keeps the equality prefix and the `postdate DESC` - * ordering intact while enabling index condition pushdown. - * 2. Narrows both claim tokens to `CHAR(32) ascii`. They only ever hold + * ({@see AdditionalCandidateQuery::applyPredicates()}), but `size` was not in + * the index, so each candidate that later failed the size filter still cost a + * primary key lookup. Appending it after the existing columns keeps the + * equality prefix and the `postdate DESC` ordering intact while enabling + * index condition pushdown. + * 2. Both claim tokens narrowed to `CHAR(32) ascii`. They only ever hold * `bin2hex(random_bytes(16))`, so `VARCHAR(64)` utf8mb4 reserved four bytes * per character for hex digits. * - * `releases.imdbid` is deliberately left alone: it joins `movieinfo.imdbid`, - * and changing the charset on one side only would force an implicit conversion - * that disables index usage on that join. + * Both now happen inside the normalization rebuild's single `ALTER`, because the + * claim token type change is `ALGORITHM=COPY` only and would otherwise force a + * second full table rebuild. This migration therefore no-ops on any install that + * ran the merged version, and `down()` is intentionally empty: the normalization + * migration's own `down()` restores the legacy column types and index shape. + * + * `releases.imdbid` is deliberately left alone: it joins `movieinfo.imdbid`, and + * changing the charset on one side only would force an implicit conversion that + * disables index usage on that join. */ return new class extends Migration { @@ -42,17 +50,24 @@ return new class extends Migration return; } + $indexNeedsSize = ! $this->ppClaimIndexCoversSize(); + $columnsToNarrow = $this->claimTokenColumnsToNarrow(); + + if (! $indexNeedsSize && $columnsToNarrow === []) { + return; + } + if ($this->isMariaDbOrMySql()) { $specifications = []; - if ($this->indexExists(self::PP_CLAIM_INDEX)) { - $specifications[] = 'DROP INDEX `'.self::PP_CLAIM_INDEX.'`'; - } - $specifications[] = 'ADD INDEX `'.self::PP_CLAIM_INDEX.'` (`passwordstatus`, `haspreview`, `nzbstatus`,' - .' `leftguid`, `postdate` DESC, `id`, `additional_pp_claimed_at`, `size`)'; - foreach (self::CLAIM_TOKEN_COLUMNS as $column) { - if (Schema::hasColumn('releases', $column)) { - $specifications[] = 'MODIFY `'.$column.'` CHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci NULL'; + if ($indexNeedsSize) { + if ($this->indexExists(self::PP_CLAIM_INDEX)) { + $specifications[] = 'DROP INDEX `'.self::PP_CLAIM_INDEX.'`'; } + $specifications[] = 'ADD INDEX `'.self::PP_CLAIM_INDEX.'` (`passwordstatus`, `haspreview`, `nzbstatus`,' + .' `leftguid`, `postdate` DESC, `id`, `additional_pp_claimed_at`, `size`)'; + } + foreach ($columnsToNarrow as $column) { + $specifications[] = 'MODIFY `'.$column.'` CHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci NULL'; } DB::statement('ALTER TABLE '.$this->table('releases').' '.implode(', ', $specifications)); @@ -60,6 +75,10 @@ return new class extends Migration return; } + if (! $indexNeedsSize) { + return; + } + Schema::table('releases', function (Blueprint $table): void { if ($this->indexExists(self::PP_CLAIM_INDEX)) { $table->dropIndex(self::PP_CLAIM_INDEX); @@ -71,39 +90,44 @@ return new class extends Migration }); } - public function down(): void + /** + * Intentionally empty. The normalization migration owns both changes now, and + * its `down()` restores the legacy column types and index definition. Undoing + * them here as well would trigger a redundant full table rebuild. + */ + public function down(): void {} + + private function ppClaimIndexCoversSize(): bool { - if (! Schema::hasTable('releases')) { - return; + foreach (Schema::getIndexes('releases') as $index) { + if (($index['name'] ?? null) === self::PP_CLAIM_INDEX) { + return in_array('size', $index['columns'] ?? [], true); + } } - if ($this->isMariaDbOrMySql()) { - $specifications = []; - if ($this->indexExists(self::PP_CLAIM_INDEX)) { - $specifications[] = 'DROP INDEX `'.self::PP_CLAIM_INDEX.'`'; - } - $specifications[] = 'ADD INDEX `'.self::PP_CLAIM_INDEX.'` (`passwordstatus`, `haspreview`, `nzbstatus`,' - .' `leftguid`, `postdate` DESC, `id`, `additional_pp_claimed_at`)'; - foreach (self::CLAIM_TOKEN_COLUMNS as $column) { - if (Schema::hasColumn('releases', $column)) { - $specifications[] = 'MODIFY `'.$column.'` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL'; - } - } + return false; + } - DB::statement('ALTER TABLE '.$this->table('releases').' '.implode(', ', $specifications)); - - return; + /** @return list */ + private function claimTokenColumnsToNarrow(): array + { + if (! $this->isMariaDbOrMySql()) { + return []; } - Schema::table('releases', function (Blueprint $table): void { - if ($this->indexExists(self::PP_CLAIM_INDEX)) { - $table->dropIndex(self::PP_CLAIM_INDEX); + $types = []; + foreach (Schema::getColumns('releases') as $column) { + $types[$column['name']] = strtolower((string) ($column['type'] ?? '')); + } + + $pending = []; + foreach (self::CLAIM_TOKEN_COLUMNS as $column) { + if (isset($types[$column]) && ! str_starts_with($types[$column], 'char(32)')) { + $pending[] = $column; } - $table->index( - ['passwordstatus', 'haspreview', 'nzbstatus', 'leftguid', 'postdate', 'id', 'additional_pp_claimed_at'], - self::PP_CLAIM_INDEX, - ); - }); + } + + return $pending; } private function indexExists(string $name): bool diff --git a/database/schema/mysql-schema.sql b/database/schema/mysql-schema.sql index dfdd1f3ff..bd77b7436 100644 --- a/database/schema/mysql-schema.sql +++ b/database/schema/mysql-schema.sql @@ -1324,7 +1324,7 @@ CREATE TABLE `releases` ( `videostatus` tinyint(1) NOT NULL DEFAULT 0, `nzbstatus` tinyint(1) NOT NULL DEFAULT 0, `nzb_creation_claimed_at` timestamp NULL DEFAULT NULL, - `nzb_creation_claim_token` varchar(64) DEFAULT NULL, + `nzb_creation_claim_token` char(32) CHARACTER SET ascii COLLATE ascii_general_ci DEFAULT NULL, `iscategorized` tinyint(1) NOT NULL DEFAULT 0, `isrenamed` tinyint(1) NOT NULL DEFAULT 0, `proc_pp` tinyint(1) NOT NULL DEFAULT 0, @@ -1337,7 +1337,7 @@ CREATE TABLE `releases` ( `proc_crc32` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Has the release been crc32 processed', `pp_timeout_count` tinyint(4) NOT NULL DEFAULT 0 COMMENT 'Number of times this release timed out during additional post-processing', `additional_pp_claimed_at` timestamp NULL DEFAULT NULL, - `additional_pp_claim_token` varchar(64) DEFAULT NULL, + `additional_pp_claim_token` char(32) CHARACTER SET ascii COLLATE ascii_general_ci DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_releases_guid` (`guid`), KEY `ix_releases_groupsid` (`groups_id`,`passwordstatus`), @@ -1362,7 +1362,7 @@ CREATE TABLE `releases` ( KEY `ix_releases_adddate_id` (`adddate`,`id`), KEY `ix_releases_predb_id` (`predb_id`), KEY `ix_releases_size` (`size`), - KEY `ix_releases_add_pp_claim_queue` (`passwordstatus`,`haspreview`,`nzbstatus`,`leftguid`,`postdate` DESC,`id`,`additional_pp_claimed_at`), + KEY `ix_releases_add_pp_claim_queue` (`passwordstatus`,`haspreview`,`nzbstatus`,`leftguid`,`postdate` DESC,`id`,`additional_pp_claimed_at`,`size`), KEY `ix_releases_nzb_creation_group_queue` (`nzbstatus`,`groups_id`,`postdate` DESC,`id`,`nzb_creation_claimed_at`), KEY `ix_releases_nzb_creation_global_queue` (`nzbstatus`,`postdate` DESC,`id`,`nzb_creation_claimed_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/tests/Integration/ReleaseSchemaOptimizationMigrationMariaDbTest.php b/tests/Integration/ReleaseSchemaOptimizationMigrationMariaDbTest.php index 516f8f439..851edb87d 100644 --- a/tests/Integration/ReleaseSchemaOptimizationMigrationMariaDbTest.php +++ b/tests/Integration/ReleaseSchemaOptimizationMigrationMariaDbTest.php @@ -76,7 +76,7 @@ final class ReleaseSchemaOptimizationMigrationMariaDbTest extends TestCase $this->assertFalse(Schema::hasColumn('releases', 'proc_sorter')); $this->assertFalse(Schema::hasColumn('release_comments', 'gid')); $this->assertSame( - ['passwordstatus', 'haspreview', 'nzbstatus', 'leftguid', 'postdate', 'id', 'additional_pp_claimed_at'], + ['passwordstatus', 'haspreview', 'nzbstatus', 'leftguid', 'postdate', 'id', 'additional_pp_claimed_at', 'size'], $this->indexColumns('ix_releases_add_pp_claim_queue'), ); $this->assertSame( @@ -114,13 +114,11 @@ final class ReleaseSchemaOptimizationMigrationMariaDbTest extends TestCase } #[Test] - public function claim_token_and_pp_index_follow_up_narrows_columns_and_adds_size(): void + public function rebuild_narrows_claim_tokens_and_covers_size_in_one_pass(): void { $this->seedLegacyData(); - $this->migration()->up(); - $followUp = $this->followUpMigration(); - $followUp->up(); + $this->migration()->up(); $this->assertSame( ['passwordstatus', 'haspreview', 'nzbstatus', 'leftguid', 'postdate', 'id', 'additional_pp_claimed_at', 'size'], @@ -135,13 +133,55 @@ final class ReleaseSchemaOptimizationMigrationMariaDbTest extends TestCase DB::table('releases')->where('id', 1)->update(['additional_pp_claim_token' => $token]); $this->assertSame($token, DB::table('releases')->where('id', 1)->value('additional_pp_claim_token')); - $followUp->down(); + $this->migration()->down(); + + $this->assertSame('varchar(64)', strtolower($this->columnType('additional_pp_claim_token'))); + $this->assertSame('varchar(64)', strtolower($this->columnType('nzb_creation_claim_token'))); + } + + #[Test] + public function follow_up_migration_is_a_no_op_once_the_rebuild_covers_it(): void + { + $this->seedLegacyData(); + $this->migration()->up(); + + $before = $this->releasesCreateStatement(); + $this->followUpMigration()->up(); + + $this->assertSame($before, $this->releasesCreateStatement()); + } + + #[Test] + public function follow_up_migration_upgrades_an_install_left_on_the_old_shape(): void + { + $this->seedLegacyData(); + $this->migration()->up(); + + DB::statement( + 'ALTER TABLE `'.$this->table('releases').'` ' + .'DROP INDEX `ix_releases_add_pp_claim_queue`, ' + .'ADD INDEX `ix_releases_add_pp_claim_queue` (`passwordstatus`, `haspreview`, `nzbstatus`, `leftguid`,' + .' `postdate` DESC, `id`, `additional_pp_claimed_at`), ' + .'MODIFY `nzb_creation_claim_token` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL, ' + .'MODIFY `additional_pp_claim_token` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL' + ); + + $this->followUpMigration()->up(); $this->assertSame( - ['passwordstatus', 'haspreview', 'nzbstatus', 'leftguid', 'postdate', 'id', 'additional_pp_claimed_at'], + ['passwordstatus', 'haspreview', 'nzbstatus', 'leftguid', 'postdate', 'id', 'additional_pp_claimed_at', 'size'], $this->indexColumns('ix_releases_add_pp_claim_queue'), ); - $this->assertSame('varchar(64)', strtolower($this->columnType('additional_pp_claim_token'))); + foreach (['nzb_creation_claim_token', 'additional_pp_claim_token'] as $column) { + $this->assertSame('char(32)', strtolower($this->columnType($column))); + } + } + + private function releasesCreateStatement(): string + { + $row = DB::selectOne('SHOW CREATE TABLE `'.$this->table('releases').'`'); + + return (string) ($row->{'Create Table'} ?? ''); } private function createSchema(): void