mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update migrations
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', [
|
||||
|
||||
Reference in New Issue
Block a user