Update migrations

This commit is contained in:
DariusIII
2026-08-14 12:43:33 +02:00
parent 28e54ee56c
commit 931fc55d8a
17 changed files with 1223 additions and 56 deletions
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* Reports the guid problems that block the releases normalization migration, and
* repairs the one class of problem that can be fixed without side effects.
*
* `leftguid` is derived data that nothing outside the database depends on, so it is
* rewritten in place. Invalid and duplicated `guid` values are only reported: a guid
* determines the on-disk NZB path and is published in download links, so replacing one
* is a migration of file and link state rather than a database repair.
*/
#[Signature('releases:normalize-guids
{--dry-run : Report the required changes without writing anything}
{--chunk=1000 : Rows handled per batch}')]
#[Description('Report release guid problems and resync leftguid so the releases normalization migration can run')]
class ReleasesNormalizeGuids extends Command
{
private const string UUID_PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD';
private bool $dryRun = false;
public function handle(): int
{
if (! Schema::hasTable('releases')) {
$this->error('The releases table does not exist.');
return self::FAILURE;
}
$this->dryRun = (bool) $this->option('dry-run');
if ($this->dryRun) {
$this->info('Dry run: no rows are modified.');
}
$leftGuids = $this->syncLeftGuids();
$invalid = $this->collectInvalidGuidIds();
$duplicates = $this->collectDuplicateGuidIds();
$this->table(['Issue', 'Releases'], [
['leftguid out of sync with guid', (string) $leftGuids],
['guid is not a UUID', (string) count($invalid)],
['guid duplicated case-insensitively', (string) count($duplicates)],
]);
$blocking = array_values(array_unique([...$invalid, ...$duplicates]));
if ($blocking === []) {
$this->info($this->dryRun
? 'No guid problems found. Re-run without --dry-run to apply the leftguid sync.'
: 'Release guids are consistent.');
return self::SUCCESS;
}
$this->reportBlockingReleases($blocking);
return self::FAILURE;
}
/**
* Print the releases that must be resolved by hand before the migration can run.
*
* @param list<int> $ids
*/
private function reportBlockingReleases(array $ids): void
{
$this->error(count($ids).' releases have a guid that blocks the normalization migration.');
$this->warn(
'These are not repaired automatically. A guid determines the release NZB path and is published in '
.'download links, so a new guid orphans the existing NZB file and breaks every link and download_stats '
.'row that references the old value. Resolve them deliberately: delete the affected releases, or assign '
.'new guids and relocate the matching NZB files yourself.'
);
$sample = array_slice($ids, 0, 20);
$rows = DB::table('releases')
->select(['id', 'guid', 'name'])
->whereIn('id', $sample)
->orderBy('id')
->get();
$this->table(['Release ID', 'Guid', 'Name'], $rows->map(static fn ($row): array => [
(string) $row->id,
(string) $row->guid,
Str::limit((string) $row->name, 60),
])->all());
if (count($ids) > count($sample)) {
$this->line(' and '.(count($ids) - count($sample)).' more.');
}
}
/**
* Point `leftguid` at the first character of `guid` wherever they disagree.
* Safe on its own: no file or index state depends on `leftguid`.
*/
private function syncLeftGuids(): int
{
$chunk = $this->chunkSize();
$total = 0;
$lastId = 0;
do {
$rows = DB::table('releases')
->select(['id', 'guid'])
->where('id', '>', $lastId)
->whereRaw('LOWER(leftguid) <> LOWER(SUBSTR(guid, 1, 1))')
->orderBy('id')
->limit($chunk)
->get();
foreach ($rows as $row) {
$lastId = max($lastId, (int) $row->id);
$total++;
if (! $this->dryRun) {
DB::table('releases')
->where('id', (int) $row->id)
->update(['leftguid' => substr((string) $row->guid, 0, 1)]);
}
}
if ($rows->count() < $chunk) {
break;
}
} while (true);
return $total;
}
/** @return list<int> */
private function collectInvalidGuidIds(): array
{
$ids = [];
DB::table('releases')->select(['id', 'guid'])->orderBy('id')->chunk($this->chunkSize(), function ($rows) use (&$ids): void {
foreach ($rows as $row) {
if (! is_string($row->guid) || preg_match(self::UUID_PATTERN, $row->guid) !== 1) {
$ids[] = (int) $row->id;
}
}
});
return $ids;
}
/**
* Every release that shares a case-insensitive guid with a lower-id release.
* The lowest id is treated as the owner of the guid and is not reported.
*
* @return list<int>
*/
private function collectDuplicateGuidIds(): array
{
$duplicated = DB::table('releases')
->selectRaw('LOWER(guid) AS normalized_guid')
->groupByRaw('LOWER(guid)')
->havingRaw('COUNT(*) > 1')
->pluck('normalized_guid');
$ids = [];
foreach ($duplicated->chunk(500) as $batch) {
$rows = DB::table('releases')
->select(['id', 'guid'])
->whereIn(DB::raw('LOWER(guid)'), $batch->all())
->orderBy('id')
->get();
$seen = [];
foreach ($rows as $row) {
$key = strtolower((string) $row->guid);
if (isset($seen[$key])) {
$ids[] = (int) $row->id;
continue;
}
$seen[$key] = true;
}
}
sort($ids);
return $ids;
}
private function chunkSize(): int
{
return max(100, min(10000, (int) $this->option('chunk')));
}
}
@@ -206,17 +206,22 @@ class ReleasesOptimizePreflight extends Command
return 0;
}
return DB::query()->fromSub(
DB::table('releases as r')
->leftJoin('release_comments as rc', function ($join): void {
$join->on('rc.releases_id', '=', 'r.id')->where('rc.isvisible', 1);
})
->select('r.id', 'r.comments')
->selectRaw('COUNT(rc.id) AS visible_comments')
->groupBy('r.id', 'r.comments')
->havingRaw('r.comments <> COUNT(rc.id)'),
'comment_counter_mismatches',
)->count();
// Raw SQL with explicit prefixed names: Laravel prefixes query aliases
// too, so `releases as r` would not match a raw `r.comments` reference.
$releases = $this->prefixedTable('releases');
$comments = $this->prefixedTable('release_comments');
return count(DB::select(
"SELECT r.`id` FROM {$releases} r LEFT JOIN {$comments} c
ON c.`releases_id` = r.`id` AND c.`isvisible` = 1
GROUP BY r.`id`, r.`comments`
HAVING r.`comments` <> COUNT(c.`id`)"
));
}
private function prefixedTable(string $name): string
{
return '`'.DB::getTablePrefix().$name.'`';
}
private function countNonEmpty(string $table, string $column): int
@@ -278,10 +283,103 @@ class ReleasesOptimizePreflight extends Command
array_values($report['migration_data']),
));
$this->renderStorageSection($report);
$this->renderDiscardedDataSection($report);
$this->renderIndexSection($report);
if ($report['ok']) {
$this->info('Releases optimization preflight passed.');
} else {
$this->error('Releases optimization preflight failed. Resolve every blocker before migration.');
}
}
/**
* Print the on-disk footprint and the free space the rebuild needs.
*
* @param array<string, mixed> $report
*/
private function renderStorageSection(array $report): void
{
$storage = $report['storage'] ?? [];
if (! is_array($storage) || ($storage['total_bytes'] ?? null) === null) {
$this->line('Storage: not reported for the '.$report['database_driver'].' driver.');
return;
}
$this->table(['Storage', 'Size'], [
['Data', self::formatBytes((int) $storage['data_bytes'])],
['Indexes', self::formatBytes((int) $storage['index_bytes'])],
['Total', self::formatBytes((int) $storage['total_bytes'])],
['Free space required', self::formatBytes((int) $storage['required_free_bytes'])],
]);
$this->warn(
'The rebuild copies the table, so at least '.self::formatBytes((int) $storage['required_free_bytes'])
.' must be free on the MySQL data volume before you migrate.'
);
}
/**
* Print how many values each dropped column is about to lose.
*
* @param array<string, mixed> $report
*/
private function renderDiscardedDataSection(array $report): void
{
$discarded = $report['discarded_data'] ?? [];
if (! is_array($discarded) || $discarded === []) {
return;
}
$rows = [];
foreach ($discarded as $column => $count) {
$rows[] = [$column, $count === null ? 'already dropped' : (string) $count];
}
$this->table(['Dropped column', 'Values destroyed'], $rows);
$destroyed = array_sum(array_map(static fn (?int $count): int => $count ?? 0, $discarded));
if ($destroyed > 0) {
$this->warn('These '.$destroyed.' values are permanently destroyed by the migration. Back up first.');
}
}
/**
* Print the index changes the migration will make.
*
* @param array<string, mixed> $report
*/
private function renderIndexSection(array $report): void
{
$indexes = $report['indexes'] ?? [];
if (! is_array($indexes)) {
return;
}
$removal = $indexes['scheduled_for_removal'] ?? [];
$existing = $indexes['already_present_replacements'] ?? [];
$missing = array_values(array_diff(self::NEW_INDEXES, $existing));
$this->table(['Index change', 'Names'], [
['Dropped', $removal === [] ? 'none' : implode(', ', $removal)],
['Created', $missing === [] ? 'none' : implode(', ', $missing)],
['Already present', $existing === [] ? 'none' : implode(', ', $existing)],
]);
}
private static function formatBytes(int $bytes): string
{
$units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
$value = (float) $bytes;
$unit = 0;
while ($value >= 1024.0 && $unit < count($units) - 1) {
$value /= 1024.0;
$unit++;
}
return $unit === 0
? $bytes.' B'
: sprintf('%.2f %s', $value, $units[$unit]);
}
}
-2
View File
@@ -17,7 +17,6 @@ use Illuminate\Support\Facades\DB;
* @property int $releases_id FK to releases.id
* @property string $text
* @property bool $isvisible
* @property string $text_hash
* @property string $username
* @property int $users_id
* @property Carbon|null $created_at
@@ -32,7 +31,6 @@ use Illuminate\Support\Facades\DB;
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereIsvisible($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereReleasesId($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereText($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereTextHash($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereUsername($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereUsersId($value)
+22 -4
View File
@@ -17,6 +17,10 @@ final class NzbCreationCandidateQuery
public const string CLAIM_TOKEN_COLUMN = 'nzb_creation_claim_token';
private static ?bool $supportsClaims = null;
private static ?bool $supportsFailureState = null;
/**
* @return Builder<Release>
*/
@@ -112,17 +116,31 @@ final class NzbCreationCandidateQuery
public static function supportsClaims(): bool
{
if (! Schema::hasTable('releases')) {
return false;
if (self::$supportsClaims !== null) {
return self::$supportsClaims;
}
return Schema::hasColumn('releases', self::CLAIMED_AT_COLUMN)
if (! Schema::hasTable('releases')) {
return self::$supportsClaims = false;
}
return self::$supportsClaims = Schema::hasColumn('releases', self::CLAIMED_AT_COLUMN)
&& Schema::hasColumn('releases', self::CLAIM_TOKEN_COLUMN);
}
public static function supportsFailureState(): bool
{
return Schema::hasTable('release_nzb_creation_failures');
return self::$supportsFailureState ??= Schema::hasTable('release_nzb_creation_failures');
}
/**
* Discard the memoized schema capability flags. Only needed when the schema
* changes inside a single process, such as between tests.
*/
public static function flushCapabilityCache(): void
{
self::$supportsClaims = null;
self::$supportsFailureState = null;
}
/**
+8 -7
View File
@@ -754,6 +754,10 @@ final class ReleaseProcessingService
private function nextNzbCreationAttempt(Release $release): int
{
if (! $release->relationLoaded('nzbCreationFailure') && NzbCreationCandidateQuery::supportsFailureState()) {
$release->loadMissing('nzbCreationFailure');
}
$failureState = $release->relationLoaded('nzbCreationFailure')
? $release->getRelation('nzbCreationFailure')
: null;
@@ -764,16 +768,13 @@ final class ReleaseProcessingService
private function recordNzbCreationRetry(Release $release, NzbCreationResult $result): void
{
$nextAttempt = $this->nextNzbCreationAttempt($release);
ReleaseNzbCreationFailure::query()->upsert(
[[
'releases_id' => (int) $release->id,
$failure = ReleaseNzbCreationFailure::query()->updateOrCreate(
['releases_id' => (int) $release->id],
[
'attempts' => $nextAttempt,
'last_error' => mb_substr($result->reason, 0, 1000),
]],
['releases_id'],
['attempts', 'last_error'],
],
);
$failure = ReleaseNzbCreationFailure::query()->findOrFail((int) $release->id);
$release->setRelation('nzbCreationFailure', $failure);
Log::channel('nzb_creation')->warning('NZB creation failed; release will be retried', [