Refactor releases table

This commit is contained in:
DariusIII
2026-08-14 08:46:01 +02:00
parent 3a4aa0f21b
commit 79e7736c4c
25 changed files with 3941 additions and 670 deletions
@@ -45,7 +45,7 @@ class AdditionalProcessingDiagnose extends Command
]);
$this->table(['Preflight', 'Value'], [
['Database driver', (string) $report['database_driver']],
['Covering claim index', (string) ($report['indexes']['covering_index'] ?? 'missing')],
['Claim queue index', (string) ($report['indexes']['claim_queue_index'] ?? 'missing')],
['Temp path', (string) $report['temp_path']['path']],
['Temp path writable', $report['temp_path']['writable'] ? 'yes' : 'no'],
]);
@@ -86,7 +86,6 @@ class NntmuxResetPostProcessing extends Command
'haspreview' => -1,
'jpgstatus' => 0,
'videostatus' => 0,
'audiostatus' => 0,
'nfostatus' => -1,
]
);
@@ -366,7 +365,6 @@ class NntmuxResetPostProcessing extends Command
'haspreview' => -1,
'jpgstatus' => 0,
'videostatus' => 0,
'audiostatus' => 0,
'nfostatus' => -1,
]);
Search::updateRelease((int) $releases->id);
@@ -75,7 +75,6 @@ class ProcessAdditionalGuid extends Command
'haspreview' => -1,
'jpgstatus' => 0,
'videostatus' => 0,
'audiostatus' => 0,
'nfostatus' => -1,
]);
Search::updateRelease((int) $release->id);
@@ -0,0 +1,287 @@
<?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;
#[Signature('releases:optimize-preflight {--json : Output machine-readable JSON}')]
#[Description('Read-only safety and data-volume report for the releases normalization migration')]
class ReleasesOptimizePreflight extends Command
{
/** @var list<string> */
private const array REMOVED_INDEXES = [
'ix_releases_adddate_only',
'ix_releases_videos_id',
'ix_releases_movieinfo_id',
'ix_releases_imdbid',
'ix_releases_tv_search_covering',
'ix_releases_passwordstatus',
'ix_releases_haspreview_passwordstatus',
'ix_releases_postdate_searchname',
'ix_releases_predb_id_searchname',
'ix_releases_size_cat',
'ix_releases_add_pp_claim_queue',
'ix_releases_nzb_creation_queue',
];
/** @var list<string> */
private const array NEW_INDEXES = [
'ux_releases_guid',
'ix_releases_predb_id',
'ix_releases_size',
'ix_releases_add_pp_claim_queue',
'ix_releases_nzb_creation_group_queue',
'ix_releases_nzb_creation_global_queue',
];
/** @var array<string, mixed> */
private array $report = [];
public function handle(): int
{
if (! Schema::hasTable('releases')) {
return $this->finish([
'ok' => false,
'database_driver' => DB::getDriverName(),
'blockers' => [['code' => 'missing-releases-table', 'count' => 1]],
]);
}
$identifierCounts = $this->identifierCounts();
$blockers = [];
foreach ($identifierCounts as $code => $count) {
if ($count > 0) {
$blockers[] = ['code' => $code, 'count' => $count];
}
}
$indexes = array_values(array_filter(array_map(
static fn (array $index): ?string => isset($index['name']) ? (string) $index['name'] : null,
Schema::getIndexes('releases'),
)));
sort($indexes);
$this->report = [
'ok' => $blockers === [],
'database_driver' => DB::getDriverName(),
'releases' => [
'rows' => DB::table('releases')->count(),
...$identifierCounts,
],
'storage' => $this->storage(),
'migration_data' => [
'nzb_password_rows' => $this->countNonEmpty('releases', 'nzb_password'),
'nzb_creation_failure_rows' => $this->retryStateCount(),
'visible_comment_rows' => $this->visibleCommentCount(),
'release_comment_counter_mismatches' => $this->commentCounterMismatchCount(),
],
'discarded_data' => $this->discardedDataCounts(),
'indexes' => [
'present' => $indexes,
'scheduled_for_removal' => array_values(array_intersect(self::REMOVED_INDEXES, $indexes)),
'already_present_replacements' => array_values(array_intersect(self::NEW_INDEXES, $indexes)),
],
'blockers' => $blockers,
];
return $this->finish($this->report);
}
/** @return array<string, int> */
private function identifierCounts(): array
{
$duplicateIds = DB::query()->fromSub(
DB::table('releases')->select('id')->groupBy('id')->havingRaw('COUNT(*) > 1'),
'duplicate_release_ids',
)->count();
$duplicateGuids = DB::query()->fromSub(
DB::table('releases')->selectRaw('LOWER(guid) AS normalized_guid')->groupByRaw('LOWER(guid)')->havingRaw('COUNT(*) > 1'),
'duplicate_release_guids',
)->count();
if (in_array(DB::getDriverName(), ['mariadb', 'mysql'], true)) {
$invalidGuids = DB::table('releases')
->whereNull('guid')
->orWhereRaw("guid NOT REGEXP '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$'")
->count();
} else {
$invalidGuids = DB::table('releases')->pluck('guid')->filter(
static fn (mixed $guid): bool => ! is_string($guid)
|| preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD', $guid) !== 1,
)->count();
}
return [
'duplicate_ids' => $duplicateIds,
'invalid_guids' => $invalidGuids,
'case_insensitive_duplicate_guids' => $duplicateGuids,
'leftguid_mismatches' => DB::table('releases')
->whereRaw('LOWER(leftguid) <> LOWER(SUBSTR(guid, 1, 1))')
->count(),
];
}
/** @return array{data_bytes: int|null, index_bytes: int|null, total_bytes: int|null, required_free_bytes: int|null} */
private function storage(): array
{
if (! in_array(DB::getDriverName(), ['mariadb', 'mysql'], true)) {
return ['data_bytes' => null, 'index_bytes' => null, 'total_bytes' => null, 'required_free_bytes' => null];
}
$tableName = DB::getTablePrefix().'releases';
$row = DB::selectOne(
'SELECT DATA_LENGTH AS data_bytes, INDEX_LENGTH AS index_bytes
FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?',
[$tableName],
);
$dataBytes = isset($row->data_bytes) ? (int) $row->data_bytes : 0;
$indexBytes = isset($row->index_bytes) ? (int) $row->index_bytes : 0;
$totalBytes = $dataBytes + $indexBytes;
return [
'data_bytes' => $dataBytes,
'index_bytes' => $indexBytes,
'total_bytes' => $totalBytes,
'required_free_bytes' => $totalBytes * 2,
];
}
/** @return array<string, int|null> */
private function discardedDataCounts(): array
{
$counts = [];
foreach ([
'updatetime' => ['not_null', null],
'gid' => ['non_empty', null],
'source' => ['not_null', null],
'proc_sorter' => ['non_zero', null],
'audiostatus' => ['non_zero', null],
] as $column => [$condition]) {
$counts['releases.'.$column] = $this->countByCondition('releases', $column, $condition);
}
foreach (['gid', 'cid', 'shareid', 'siteid'] as $column) {
$counts['release_comments.'.$column] = $this->countByCondition('release_comments', $column, 'non_empty');
}
foreach (['issynced', 'shared', 'sourceid'] as $column) {
$counts['release_comments.'.$column] = $this->countByCondition(
'release_comments',
$column,
$column === 'sourceid' ? 'not_null' : 'non_zero',
);
}
return $counts;
}
private function retryStateCount(): int
{
if (! Schema::hasColumn('releases', 'nzb_creation_attempts')) {
return Schema::hasTable('release_nzb_creation_failures')
? DB::table('release_nzb_creation_failures')->count()
: 0;
}
return DB::table('releases')->where(function ($query): void {
$query->where('nzb_creation_attempts', '>', 0)->orWhereNotNull('nzb_creation_last_error');
})->count();
}
private function visibleCommentCount(): int
{
return Schema::hasTable('release_comments')
? DB::table('release_comments')->where('isvisible', 1)->count()
: 0;
}
private function commentCounterMismatchCount(): int
{
if (! Schema::hasTable('release_comments') || ! Schema::hasColumn('releases', 'comments')) {
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();
}
private function countNonEmpty(string $table, string $column): int
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
return $table === 'releases' && Schema::hasTable('release_nzb_passwords')
? DB::table('release_nzb_passwords')->count()
: 0;
}
return DB::table($table)->whereNotNull($column)->where($column, '<>', '')->count();
}
private function countByCondition(string $table, string $column, string $condition): ?int
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
return null;
}
$query = DB::table($table);
return match ($condition) {
'non_empty' => $query->whereNotNull($column)->where($column, '<>', '')->count(),
'non_zero' => $query->where($column, '<>', 0)->count(),
default => $query->whereNotNull($column)->count(),
};
}
/** @param array<string, mixed> $report */
private function finish(array $report): int
{
if ($this->option('json')) {
$this->line((string) json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
} else {
$this->renderHumanReport($report);
}
return ($report['ok'] ?? false) ? self::SUCCESS : self::FAILURE;
}
/** @param array<string, mixed> $report */
private function renderHumanReport(array $report): void
{
if (! isset($report['releases'])) {
$this->error('The releases table does not exist.');
return;
}
$this->table(['Safety check', 'Count'], [
['Duplicate IDs', $report['releases']['duplicate_ids']],
['Invalid GUIDs', $report['releases']['invalid_guids']],
['Case-insensitive duplicate GUIDs', $report['releases']['case_insensitive_duplicate_guids']],
['Mismatched leftguid values', $report['releases']['leftguid_mismatches']],
]);
$this->table(['Migration data', 'Rows'], array_map(
static fn (string $key, int $value): array => [$key, $value],
array_keys($report['migration_data']),
array_values($report['migration_data']),
));
if ($report['ok']) {
$this->info('Releases optimization preflight passed.');
} else {
$this->error('Releases optimization preflight failed. Resolve every blocker before migration.');
}
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ class DetailsController extends BasePageController
}
if ($this->isPostBack($request)) {
ReleaseComment::addComment($data['id'], $data['gid'], $request->input('txtAddComment'), $this->userdata->id, $request->ip());
ReleaseComment::addComment((int) $data['id'], (string) $request->input('txtAddComment'), (int) $this->userdata->id, $request->ip());
return redirect()->route('details', ['guid' => $guid])->with('success', 'Comment posted successfully!');
}
+12 -1
View File
@@ -132,6 +132,18 @@ class Release extends Model
return $this->hasOne(ReleaseNfo::class, 'releases_id');
}
/** @return HasOne<ReleaseNzbPassword, $this> */
public function nzbPassword(): HasOne
{
return $this->hasOne(ReleaseNzbPassword::class, 'releases_id');
}
/** @return HasOne<ReleaseNzbCreationFailure, $this> */
public function nzbCreationFailure(): HasOne
{
return $this->hasOne(ReleaseNzbCreationFailure::class, 'releases_id');
}
/**
* @return HasMany<ReleaseComment, $this>
*/
@@ -212,7 +224,6 @@ class Release extends Model
'isrenamed' => $parameters['isrenamed'],
'iscategorized' => 1,
'predb_id' => $parameters['predb_id'],
'source' => $parameters['source'] ?? null,
]
);
+27 -32
View File
@@ -8,6 +8,7 @@ use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
/**
* App\Models\ReleaseComment.
@@ -16,34 +17,20 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property int $releases_id FK to releases.id
* @property string $text
* @property bool $isvisible
* @property bool $issynced
* @property string|null $gid
* @property string|null $cid
* @property string $text_hash
* @property string $username
* @property int $users_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string|null $host
* @property bool $shared
* @property string $shareid
* @property string $siteid
* @property int|null $sourceid
* @property-read Release $release
* @property-read User $user
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereCid($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereGid($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereHost($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereIssynced($value)
* @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 whereShared($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereShareid($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereSiteid($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereSourceid($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)
@@ -108,13 +95,18 @@ class ReleaseComment extends Model
/**
* Delete single comment on the site.
*/
public static function deleteComment(mixed $id): void
public static function deleteComment(int $id): void
{
$res = self::getCommentById($id);
if ($res) {
self::query()->where('id', $id)->delete();
self::updateReleaseCommentCount($res['gid']);
}
DB::transaction(function () use ($id): void {
$comment = self::query()->find($id, ['id', 'releases_id']);
if ($comment === null) {
return;
}
$releaseId = (int) $comment->releases_id;
$comment->delete();
self::updateReleaseCommentCount($releaseId);
});
}
/**
@@ -124,31 +116,31 @@ class ReleaseComment extends Model
*
* @throws \Exception
*/
public static function addComment(mixed $id, mixed $gid, mixed $text, mixed $userid, mixed $host): int
public static function addComment(int $releaseId, string $text, int $userId, ?string $host): int
{
if (config('nntmux:settings.store_user_ip') === false) {
$host = '';
}
$username = User::query()->where('id', $userid)->first(['username']);
$username = User::query()->where('id', $userId)->first(['username']);
$username = ($username === null ? 'ANON' : $username['username']);
$comid = self::query()
->insertGetId(
return DB::transaction(function () use ($releaseId, $text, $userId, $host, $username): int {
$commentId = self::query()->insertGetId(
[
'releases_id' => $id,
'gid' => $gid,
'releases_id' => $releaseId,
'text' => $text,
'users_id' => $userid,
'users_id' => $userId,
'created_at' => now(),
'updated_at' => now(),
'host' => $host,
'username' => $username,
]
);
self::updateReleaseCommentCount($id);
self::updateReleaseCommentCount($releaseId);
return $comid;
return $commentId;
});
}
/**
@@ -167,10 +159,13 @@ class ReleaseComment extends Model
/**
* Update the denormalised count of comments for a release.
*/
public static function updateReleaseCommentCount(mixed $gid): void
public static function updateReleaseCommentCount(int $releaseId): void
{
$commentCount = self::query()->where('gid', '=', 'releases.gid')->where('isvisible', '=', 1)->count('id');
Release::query()->where('gid', $gid)->update(['comments' => $commentCount]);
$commentCount = self::query()
->where('releases_id', $releaseId)
->where('isvisible', 1)
->count('id');
Release::query()->whereKey($releaseId)->update(['comments' => $commentCount]);
}
/**
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $releases_id
* @property int $attempts
* @property string|null $last_error
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Release $release
*/
class ReleaseNzbCreationFailure extends Model
{
protected $primaryKey = 'releases_id';
public $incrementing = false;
protected $keyType = 'int';
/** @var array<string> */
protected $guarded = [];
/** @return array<string, string> */
protected function casts(): array
{
return ['attempts' => 'integer'];
}
/** @return BelongsTo<Release, $this> */
public function release(): BelongsTo
{
return $this->belongsTo(Release::class, 'releases_id');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $releases_id
* @property string $password
* @property-read Release $release
*/
class ReleaseNzbPassword extends Model
{
public $timestamps = false;
protected $primaryKey = 'releases_id';
public $incrementing = false;
protected $keyType = 'int';
/** @var array<string> */
protected $guarded = [];
/** @return BelongsTo<Release, $this> */
public function release(): BelongsTo
{
return $this->belongsTo(Release::class, 'releases_id');
}
}
@@ -23,8 +23,9 @@ final class AdditionalProcessingDiagnostics
'haspreview',
'nzbstatus',
'leftguid',
'additional_pp_claimed_at',
'postdate',
'id',
'additional_pp_claimed_at',
];
/**
@@ -150,12 +151,12 @@ final class AdditionalProcessingDiagnostics
/**
* @param list<array{code: string, message: string}> $warnings
* @return array{required_columns: list<string>, present: list<string>, covering_index: string|null, missing: bool}
* @return array{required_columns: list<string>, present: list<string>, claim_queue_index: string|null, missing: bool}
*/
private function indexes(array &$warnings): array
{
$present = [];
$coveringIndex = null;
$claimQueueIndex = null;
if (Schema::hasTable('releases')) {
foreach (Schema::getIndexes('releases') as $index) {
@@ -169,13 +170,13 @@ final class AdditionalProcessingDiagnostics
$present[] = $name;
}
if ($columns === self::REQUIRED_CLAIM_INDEX_COLUMNS) {
$coveringIndex = $name;
$claimQueueIndex = $name;
}
}
}
sort($present);
$missing = $coveringIndex === null;
$missing = $claimQueueIndex === null;
if ($missing) {
$warnings[] = $this->warning(
'missing-index',
@@ -186,7 +187,7 @@ final class AdditionalProcessingDiagnostics
return [
'required_columns' => self::REQUIRED_CLAIM_INDEX_COLUMNS,
'present' => $present,
'covering_index' => $coveringIndex,
'claim_queue_index' => $claimQueueIndex,
'missing' => $missing,
];
}
@@ -390,7 +390,6 @@ class MediaExtractionService
}
@chmod($this->config->audioSavePath.$audioFileName, 0764);
Release::query()->where('id', $context->release->id)->update(['audiostatus' => 1]);
$result['audioSample'] = true;
$context->foundAudioSample = true;
}
@@ -352,7 +352,7 @@ class ReleaseUpdateService
'PAR2, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_par2' => 1],
'Filenames, ', 'file matched source: ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_files' => 1],
'PreDB FT Exact, ' => ['isrenamed' => 1, 'iscategorized' => 1],
'sorter, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_sorter' => 1],
'sorter, ' => ['isrenamed' => 1, 'iscategorized' => 1],
'UID, ', 'Mediainfo, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_uid' => 1],
'PAR2 hash, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_hash16k' => 1],
'SRR, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_srr' => 1],
+11 -31
View File
@@ -17,10 +17,6 @@ final class NzbCreationCandidateQuery
public const string CLAIM_TOKEN_COLUMN = 'nzb_creation_claim_token';
public const string ATTEMPTS_COLUMN = 'nzb_creation_attempts';
public const string LAST_ERROR_COLUMN = 'nzb_creation_last_error';
/**
* @return Builder<Release>
*/
@@ -83,12 +79,17 @@ final class NzbCreationCandidateQuery
]);
}
return Release::query()
$releaseQuery = Release::query()
->whereIn('id', $ids)
->with('category.parent')
->select(self::selectableColumns($columns, $supportsClaims))
->orderByRaw(self::idOrderExpression($ids))
->get();
->orderByRaw(self::idOrderExpression($ids));
if (self::supportsFailureState()) {
$releaseQuery->with('nzbCreationFailure');
}
return $releaseQuery->get();
}, 3);
}
@@ -116,31 +117,12 @@ final class NzbCreationCandidateQuery
}
return Schema::hasColumn('releases', self::CLAIMED_AT_COLUMN)
&& Schema::hasColumn('releases', self::CLAIM_TOKEN_COLUMN)
&& Schema::hasColumn('releases', self::ATTEMPTS_COLUMN)
&& Schema::hasColumn('releases', self::LAST_ERROR_COLUMN);
&& Schema::hasColumn('releases', self::CLAIM_TOKEN_COLUMN);
}
/**
* @return array<string, mixed>
*/
public static function failureUpdateValues(string $reason, bool $incrementAttempts): array
public static function supportsFailureState(): bool
{
if (! self::supportsClaims()) {
return [];
}
$values = [
self::CLAIMED_AT_COLUMN => null,
self::CLAIM_TOKEN_COLUMN => null,
self::LAST_ERROR_COLUMN => mb_substr($reason, 0, 1000),
];
if ($incrementAttempts) {
$values[self::ATTEMPTS_COLUMN] = DB::raw(self::ATTEMPTS_COLUMN.' + 1');
}
return $values;
return Schema::hasTable('release_nzb_creation_failures');
}
/**
@@ -183,8 +165,6 @@ final class NzbCreationCandidateQuery
static fn (string $column): bool => ! in_array($column, [
self::CLAIMED_AT_COLUMN,
self::CLAIM_TOKEN_COLUMN,
self::ATTEMPTS_COLUMN,
self::LAST_ERROR_COLUMN,
], true),
));
}
+11 -4
View File
@@ -6,6 +6,7 @@ namespace App\Services\Nzb;
use App\Models\Collection;
use App\Models\Release;
use App\Models\ReleaseNzbCreationFailure;
use App\Models\Settings;
use App\Services\Binaries\BinariesConfig;
use App\Services\CollectionCleanupService;
@@ -262,8 +263,16 @@ class NzbService
return NzbCreationResult::transient("Final NZB file is missing or unreadable: {$path}", $collectionIds, $path);
}
// Mark release as having NZB.
$release->update($this->successfulReleaseUpdateValues());
DB::transaction(function () use ($release): void {
$release->update($this->successfulReleaseUpdateValues());
if (NzbCreationCandidateQuery::supportsFailureState()) {
ReleaseNzbCreationFailure::query()
->where('releases_id', $release->id)
->delete();
$release->unsetRelation('nzbCreationFailure');
}
});
} catch (Throwable $e) {
return NzbCreationResult::transient('Failed to write NZB file: '.$e->getMessage(), $collectionIds, $path);
} finally {
@@ -729,8 +738,6 @@ class NzbService
if (NzbCreationCandidateQuery::supportsClaims()) {
$values += [
NzbCreationCandidateQuery::ATTEMPTS_COLUMN => 0,
NzbCreationCandidateQuery::LAST_ERROR_COLUMN => null,
NzbCreationCandidateQuery::CLAIMED_AT_COLUMN => null,
NzbCreationCandidateQuery::CLAIM_TOKEN_COLUMN => null,
];
+19 -9
View File
@@ -9,6 +9,7 @@ use App\Models\Category;
use App\Models\Collection;
use App\Models\MusicInfo;
use App\Models\Release;
use App\Models\ReleaseNzbCreationFailure;
use App\Models\Settings;
use App\Models\UsenetGroup;
use App\Services\Binaries\BinariesConfig;
@@ -690,7 +691,6 @@ final class ReleaseProcessingService
'groups_id',
'postdate',
'nzbstatus',
NzbCreationCandidateQuery::ATTEMPTS_COLUMN,
NzbCreationCandidateQuery::CLAIM_TOKEN_COLUMN,
];
@@ -754,24 +754,34 @@ final class ReleaseProcessingService
private function nextNzbCreationAttempt(Release $release): int
{
return ((int) ($release->{NzbCreationCandidateQuery::ATTEMPTS_COLUMN} ?? 0)) + 1;
$failureState = $release->relationLoaded('nzbCreationFailure')
? $release->getRelation('nzbCreationFailure')
: null;
return ($failureState instanceof ReleaseNzbCreationFailure ? $failureState->attempts : 0) + 1;
}
private function recordNzbCreationRetry(Release $release, NzbCreationResult $result): void
{
$values = NzbCreationCandidateQuery::failureUpdateValues($result->reason, true);
if ($values !== []) {
Release::query()
->where('id', $release->id)
->update($values);
}
$nextAttempt = $this->nextNzbCreationAttempt($release);
ReleaseNzbCreationFailure::query()->upsert(
[[
'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', [
'release_id' => $release->id,
'guid' => $release->guid,
'failure_type' => $result->failureType,
'reason' => $result->reason,
'next_attempt' => $this->nextNzbCreationAttempt($release),
'next_attempt' => $nextAttempt,
'max_attempts' => self::NZB_CREATION_MAX_ATTEMPTS,
]);
}
@@ -0,0 +1,454 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/** @var list<string> */
private const array REMOVED_RELEASE_COLUMNS = [
'updatetime',
'gid',
'source',
'nzb_password',
'nzb_creation_attempts',
'nzb_creation_last_error',
'proc_sorter',
'audiostatus',
];
/** @var list<string> */
private const array REMOVED_COMMENT_COLUMNS = [
'gid',
'cid',
'issynced',
'shared',
'shareid',
'siteid',
'sourceid',
];
/** @var list<string> */
private const array REMOVED_RELEASE_INDEXES = [
'ix_releases_guid',
'ix_releases_adddate_only',
'ix_releases_videos_id',
'ix_releases_movieinfo_id',
'ix_releases_imdbid',
'ix_releases_tv_search_covering',
'ix_releases_passwordstatus',
'ix_releases_haspreview_passwordstatus',
'ix_releases_postdate_searchname',
'ix_releases_predb_id_searchname',
'ix_releases_size_cat',
'ix_releases_add_pp_claim_queue',
'ix_releases_nzb_creation_queue',
];
public function up(): void
{
if (! Schema::hasTable('releases')) {
return;
}
$this->assertReleaseIdentifiersAreSafe();
$this->createSparseTables();
$this->backfillSparseTables();
$this->recountVisibleComments();
if ($this->isMariaDbOrMySql()) {
$this->rebuildReleasesForMariaDb();
} else {
$this->normalizeReleasesForPortableDatabase();
}
$this->addSparseForeignKeys();
if (Schema::hasTable('release_comments')) {
Schema::table('release_comments', function (Blueprint $table): void {
foreach (self::REMOVED_COMMENT_COLUMNS as $column) {
if (Schema::hasColumn('release_comments', $column)) {
$table->dropColumn($column);
}
}
});
}
}
public function down(): void
{
if (! Schema::hasTable('releases')) {
return;
}
$this->dropSparseForeignKeys();
if (Schema::hasTable('release_comments')) {
Schema::table('release_comments', function (Blueprint $table): void {
$table->string('gid', 32)->nullable();
$table->string('cid', 32)->nullable();
$table->boolean('issynced')->default(false);
$table->boolean('shared')->default(false);
$table->string('shareid', 40)->default('');
$table->string('siteid', 40)->default('');
$table->unsignedBigInteger('sourceid')->nullable();
});
}
if ($this->isMariaDbOrMySql()) {
$this->restoreReleasesForMariaDb();
} else {
$this->restoreReleasesForPortableDatabase();
}
$this->restoreSparseDataToReleases();
Schema::dropIfExists('release_nzb_creation_failures');
Schema::dropIfExists('release_nzb_passwords');
}
private function createSparseTables(): void
{
if (! Schema::hasTable('release_nzb_passwords')) {
Schema::create('release_nzb_passwords', function (Blueprint $table): void {
$table->unsignedInteger('releases_id')->primary();
$table->string('password', 255);
if (! $this->isMariaDbOrMySql()) {
$table->foreign('releases_id', 'FK_rnp_releases')
->references('id')->on('releases')
->cascadeOnUpdate()->cascadeOnDelete();
}
});
}
if (! Schema::hasTable('release_nzb_creation_failures')) {
Schema::create('release_nzb_creation_failures', function (Blueprint $table): void {
$table->unsignedInteger('releases_id')->primary();
$table->unsignedSmallInteger('attempts')->default(0);
$table->text('last_error')->nullable();
$table->timestamps();
if (! $this->isMariaDbOrMySql()) {
$table->foreign('releases_id', 'FK_rncf_releases')
->references('id')->on('releases')
->cascadeOnUpdate()->cascadeOnDelete();
}
});
}
}
private function addSparseForeignKeys(): void
{
if (! $this->isMariaDbOrMySql()) {
return;
}
DB::statement(
'ALTER TABLE '.$this->table('release_nzb_passwords')
.' ADD CONSTRAINT `FK_rnp_releases` FOREIGN KEY (`releases_id`) REFERENCES '
.$this->table('releases').' (`id`) ON DELETE CASCADE ON UPDATE CASCADE'
);
DB::statement(
'ALTER TABLE '.$this->table('release_nzb_creation_failures')
.' ADD CONSTRAINT `FK_rncf_releases` FOREIGN KEY (`releases_id`) REFERENCES '
.$this->table('releases').' (`id`) ON DELETE CASCADE ON UPDATE CASCADE'
);
}
private function dropSparseForeignKeys(): void
{
if (! $this->isMariaDbOrMySql()) {
return;
}
if (Schema::hasTable('release_nzb_passwords')) {
DB::statement('ALTER TABLE '.$this->table('release_nzb_passwords').' DROP FOREIGN KEY `FK_rnp_releases`');
}
if (Schema::hasTable('release_nzb_creation_failures')) {
DB::statement('ALTER TABLE '.$this->table('release_nzb_creation_failures').' DROP FOREIGN KEY `FK_rncf_releases`');
}
}
private function backfillSparseTables(): void
{
if (Schema::hasColumn('releases', 'nzb_password')) {
DB::table('releases')
->select(['id', 'nzb_password'])
->whereNotNull('nzb_password')
->where('nzb_password', '<>', '')
->orderBy('id')
->chunkById(1000, function ($releases): void {
$rows = [];
foreach ($releases as $release) {
$rows[] = [
'releases_id' => (int) $release->id,
'password' => (string) $release->nzb_password,
];
}
DB::table('release_nzb_passwords')->upsert($rows, ['releases_id'], ['password']);
});
}
if (Schema::hasColumn('releases', 'nzb_creation_attempts')) {
DB::table('releases')
->select(['id', 'nzb_creation_attempts', 'nzb_creation_last_error'])
->where(function ($query): void {
$query->where('nzb_creation_attempts', '>', 0)
->orWhereNotNull('nzb_creation_last_error');
})
->orderBy('id')
->chunkById(1000, function ($releases): void {
$now = now();
$rows = [];
foreach ($releases as $release) {
$rows[] = [
'releases_id' => (int) $release->id,
'attempts' => (int) $release->nzb_creation_attempts,
'last_error' => $release->nzb_creation_last_error,
'created_at' => $now,
'updated_at' => $now,
];
}
DB::table('release_nzb_creation_failures')->upsert(
$rows,
['releases_id'],
['attempts', 'last_error', 'updated_at'],
);
});
}
}
private function recountVisibleComments(): void
{
if (! Schema::hasTable('release_comments') || ! Schema::hasColumn('releases', 'comments')) {
return;
}
DB::statement(
'UPDATE '.$this->table('releases').' SET `comments` = (SELECT COUNT(*) FROM '
.$this->table('release_comments').' WHERE '
.$this->table('release_comments').'.`releases_id` = '
.$this->table('releases').'.`id` AND '
.$this->table('release_comments').'.`isvisible` = 1)'
);
}
private function rebuildReleasesForMariaDb(): void
{
$specifications = ['DROP PRIMARY KEY', 'ADD PRIMARY KEY (`id`)'];
foreach (self::REMOVED_RELEASE_INDEXES as $index) {
if ($this->indexExists($index)) {
$specifications[] = 'DROP INDEX `'.$index.'`';
}
}
$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::REMOVED_RELEASE_COLUMNS as $column) {
if (Schema::hasColumn('releases', $column)) {
$specifications[] = 'DROP COLUMN `'.$column.'`';
}
}
$specifications = [
...$specifications,
'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_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`)',
];
DB::statement('ALTER TABLE '.$this->table('releases').' '.implode(', ', $specifications));
}
private function normalizeReleasesForPortableDatabase(): void
{
Schema::table('releases', function (Blueprint $table): void {
foreach (self::REMOVED_RELEASE_INDEXES as $index) {
if ($this->indexExists($index)) {
$table->dropIndex($index);
}
}
foreach (self::REMOVED_RELEASE_COLUMNS as $column) {
if (Schema::hasColumn('releases', $column)) {
$table->dropColumn($column);
}
}
$table->unique('guid', 'ux_releases_guid');
$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'],
'ix_releases_add_pp_claim_queue',
);
$table->index(
['nzbstatus', 'groups_id', 'postdate', 'id', 'nzb_creation_claimed_at'],
'ix_releases_nzb_creation_group_queue',
);
$table->index(
['nzbstatus', 'postdate', 'id', 'nzb_creation_claimed_at'],
'ix_releases_nzb_creation_global_queue',
);
});
}
private function restoreReleasesForMariaDb(): void
{
$specifications = ['DROP PRIMARY KEY', 'ADD PRIMARY KEY (`id`, `categories_id`)'];
foreach ([
'ux_releases_guid',
'ix_releases_predb_id',
'ix_releases_size',
'ix_releases_add_pp_claim_queue',
'ix_releases_nzb_creation_group_queue',
'ix_releases_nzb_creation_global_queue',
] as $index) {
if ($this->indexExists($index)) {
$specifications[] = 'DROP INDEX `'.$index.'`';
}
}
$specifications = [
...$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'",
'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',
'ADD COLUMN `nzb_password` VARCHAR(255) NULL',
'ADD COLUMN `nzb_creation_attempts` SMALLINT UNSIGNED NOT NULL DEFAULT 0 AFTER `nzb_creation_claim_token`',
'ADD COLUMN `nzb_creation_last_error` TEXT NULL AFTER `nzb_creation_attempts`',
'ADD COLUMN `proc_sorter` TINYINT(1) NOT NULL DEFAULT 0 AFTER `proc_pp`',
'ADD COLUMN `audiostatus` TINYINT(1) NOT NULL DEFAULT 0 AFTER `videostatus`',
'ADD INDEX `ix_releases_guid` (`guid`)',
'ADD INDEX `ix_releases_adddate_only` (`adddate`)',
'ADD INDEX `ix_releases_videos_id` (`videos_id`)',
'ADD INDEX `ix_releases_movieinfo_id` (`movieinfo_id`)',
'ADD INDEX `ix_releases_imdbid` (`imdbid`)',
'ADD INDEX `ix_releases_tv_search_covering` (`passwordstatus`, `categories_id`, `postdate` DESC, `videos_id`, `tv_episodes_id`, `groups_id`)',
'ADD INDEX `ix_releases_passwordstatus` (`passwordstatus`)',
'ADD INDEX `ix_releases_haspreview_passwordstatus` (`haspreview`, `passwordstatus`)',
'ADD INDEX `ix_releases_postdate_searchname` (`postdate`, `searchname`)',
'ADD INDEX `ix_releases_predb_id_searchname` (`predb_id`, `searchname`)',
'ADD INDEX `ix_releases_size_cat` (`size`, `categories_id`, `passwordstatus`)',
'ADD INDEX `ix_releases_add_pp_claim_queue` (`passwordstatus`, `haspreview`, `nzbstatus`, `leftguid`, `additional_pp_claimed_at`, `postdate` DESC)',
'ADD INDEX `ix_releases_nzb_creation_queue` (`nzbstatus`, `groups_id`, `leftguid`, `nzb_creation_claimed_at`, `postdate` DESC)',
];
DB::statement('ALTER TABLE '.$this->table('releases').' '.implode(', ', $specifications));
}
private function restoreReleasesForPortableDatabase(): void
{
Schema::table('releases', function (Blueprint $table): void {
foreach ([
'ux_releases_guid',
'ix_releases_predb_id',
'ix_releases_size',
'ix_releases_add_pp_claim_queue',
'ix_releases_nzb_creation_group_queue',
'ix_releases_nzb_creation_global_queue',
] as $index) {
if ($this->indexExists($index)) {
$table->dropIndex($index);
}
}
$table->timestamp('updatetime')->useCurrent();
$table->string('gid', 32)->nullable();
$table->unsignedSmallInteger('source')->nullable();
$table->string('nzb_password')->nullable();
$table->unsignedSmallInteger('nzb_creation_attempts')->default(0);
$table->text('nzb_creation_last_error')->nullable();
$table->boolean('proc_sorter')->default(false);
$table->boolean('audiostatus')->default(false);
$table->index('guid', 'ix_releases_guid');
});
}
private function restoreSparseDataToReleases(): void
{
if (Schema::hasTable('release_nzb_passwords')) {
DB::table('release_nzb_passwords')->orderBy('releases_id')->chunkById(1000, function ($rows): void {
foreach ($rows as $row) {
DB::table('releases')->where('id', $row->releases_id)->update(['nzb_password' => $row->password]);
}
}, 'releases_id');
}
if (Schema::hasTable('release_nzb_creation_failures')) {
DB::table('release_nzb_creation_failures')->orderBy('releases_id')->chunkById(1000, function ($rows): void {
foreach ($rows as $row) {
DB::table('releases')->where('id', $row->releases_id)->update([
'nzb_creation_attempts' => $row->attempts,
'nzb_creation_last_error' => $row->last_error,
]);
}
}, 'releases_id');
}
}
private function assertReleaseIdentifiersAreSafe(): void
{
$duplicateIds = DB::query()->fromSub(
DB::table('releases')->select('id')->groupBy('id')->havingRaw('COUNT(*) > 1'),
'duplicate_release_ids',
)->count();
if ($this->isMariaDbOrMySql()) {
$invalidGuids = DB::table('releases')
->whereNull('guid')
->orWhereRaw("guid NOT REGEXP '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$'")
->count();
} else {
$invalidGuids = DB::table('releases')->pluck('guid')->filter(
static fn (mixed $guid): bool => ! is_string($guid)
|| preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD', $guid) !== 1,
)->count();
}
$duplicateGuids = DB::query()->fromSub(
DB::table('releases')->selectRaw('LOWER(guid) AS normalized_guid')->groupByRaw('LOWER(guid)')->havingRaw('COUNT(*) > 1'),
'duplicate_release_guids',
)->count();
$leftGuidMismatches = DB::table('releases')
->whereRaw('LOWER(leftguid) <> LOWER(SUBSTR(guid, 1, 1))')
->count();
$blockers = array_filter([
'duplicate release IDs' => $duplicateIds,
'invalid release GUIDs' => $invalidGuids,
'case-insensitive duplicate release GUIDs' => $duplicateGuids,
'mismatched release leftguid values' => $leftGuidMismatches,
]);
if ($blockers !== []) {
throw new RuntimeException('Releases optimization preflight failed: '.json_encode($blockers, JSON_THROW_ON_ERROR));
}
}
private function indexExists(string $name): bool
{
foreach (Schema::getIndexes('releases') as $index) {
if (($index['name'] ?? null) === $name) {
return true;
}
}
return false;
}
private function isMariaDbOrMySql(): bool
{
return in_array(DB::getDriverName(), ['mariadb', 'mysql'], true);
}
private function table(string $name): string
{
return '`'.DB::getTablePrefix().$name.'`';
}
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+32 -8
View File
@@ -69,6 +69,7 @@ class NzbCreationReliabilityTest extends TestCase
parent::setUp();
$this->tempNzbPath = sys_get_temp_dir().'/nntmux-nzb-reliability-'.uniqid('', true);
mkdir($this->tempNzbPath, 0777, true);
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => $this->databasePath]);
config(['nntmux_settings.path_to_nzbs' => $this->tempNzbPath]);
DB::purge();
@@ -140,8 +141,8 @@ class NzbCreationReliabilityTest extends TestCase
$this->assertSame(0, $service->createNZBs(null));
$this->assertSame(1, DB::table('releases')->count());
$this->assertSame(1, (int) DB::table('releases')->where('id', 1)->value('nzb_creation_attempts'));
$this->assertSame('Temporary filesystem failure.', DB::table('releases')->where('id', 1)->value('nzb_creation_last_error'));
$this->assertSame(1, (int) DB::table('release_nzb_creation_failures')->where('releases_id', 1)->value('attempts'));
$this->assertSame('Temporary filesystem failure.', DB::table('release_nzb_creation_failures')->where('releases_id', 1)->value('last_error'));
$this->assertNull(DB::table('releases')->where('id', 1)->value('nzb_creation_claimed_at'));
$this->assertSame(1, DB::table('collections')->count());
$this->assertSame('NZB creation failed; release will be retried', $nzbCreationLogger->warnings[0]['message']);
@@ -174,6 +175,7 @@ class NzbCreationReliabilityTest extends TestCase
$this->assertSame(0, DB::table('collections')->count());
$this->assertSame(0, DB::table('binaries')->count());
$this->assertSame(0, DB::table('parts')->count());
$this->assertSame(0, DB::table('release_nzb_creation_failures')->count());
$this->assertSame('Deleting release after NZB creation failure', $nzbCreationLogger->warnings[0]['message']);
$this->assertSame(1, $nzbCreationLogger->warnings[0]['context']['release_id']);
$this->assertSame(str_repeat('a', 36), $nzbCreationLogger->warnings[0]['context']['guid']);
@@ -203,6 +205,13 @@ class NzbCreationReliabilityTest extends TestCase
public function test_writer_streams_multiple_keyset_pages_in_segment_order(): void
{
$this->insertRelease(1, 'h');
DB::table('release_nzb_creation_failures')->insert([
'releases_id' => 1,
'attempts' => 1,
'last_error' => 'Earlier failure.',
'created_at' => now(),
'updated_at' => now(),
]);
$this->insertWritableCbp(200, 2000, 1);
DB::table('parts')->where('binaries_id', 2000)->delete();
DB::table('parts')->insert([
@@ -222,6 +231,7 @@ class NzbCreationReliabilityTest extends TestCase
$this->assertLessThan(strpos($xml, 'part02@example.test'), strpos($xml, 'part01@example.test'));
$this->assertLessThan(strpos($xml, 'part03@example.test'), strpos($xml, 'part02@example.test'));
$this->assertStringContainsString('<group>alt.test</group>', $xml);
$this->assertSame(0, DB::table('release_nzb_creation_failures')->count());
}
public function test_stale_temporary_nzb_cleanup_deletes_only_old_temp_files(): void
@@ -277,9 +287,17 @@ class NzbCreationReliabilityTest extends TestCase
'nzbstatus' => NzbService::NZB_NONE,
'nzb_creation_claimed_at' => $claimedAt?->format('Y-m-d H:i:s'),
'nzb_creation_claim_token' => $claimedAt === null ? null : 'claimed',
'nzb_creation_attempts' => $attempts,
'nzb_creation_last_error' => null,
]);
if ($attempts > 0) {
DB::table('release_nzb_creation_failures')->insert([
'releases_id' => $id,
'attempts' => $attempts,
'last_error' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
private function insertCbp(int $collectionId, int $binaryId, int $releaseId): void
@@ -337,7 +355,7 @@ class NzbCreationReliabilityTest extends TestCase
private function createSchema(): void
{
foreach (['parts', 'binaries', 'collections', 'releases', 'categories', 'root_categories', 'usenet_groups', 'settings'] as $table) {
foreach (['parts', 'binaries', 'collections', 'release_nzb_creation_failures', 'releases', 'categories', 'root_categories', 'usenet_groups', 'settings'] as $table) {
DB::statement("DROP TABLE IF EXISTS {$table}");
}
@@ -355,9 +373,15 @@ class NzbCreationReliabilityTest extends TestCase
postdate DATETIME NULL,
nzbstatus INTEGER,
nzb_creation_claimed_at DATETIME NULL,
nzb_creation_claim_token VARCHAR(64) NULL,
nzb_creation_attempts INTEGER DEFAULT 0,
nzb_creation_last_error TEXT NULL
nzb_creation_claim_token VARCHAR(64) NULL
)');
DB::statement('CREATE TABLE release_nzb_creation_failures (
releases_id INTEGER PRIMARY KEY,
attempts INTEGER DEFAULT 0,
last_error TEXT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
FOREIGN KEY (releases_id) REFERENCES releases(id) ON DELETE CASCADE
)');
DB::statement('CREATE TABLE usenet_groups (id INTEGER PRIMARY KEY, name VARCHAR(255))');
DB::statement('CREATE TABLE collections (
@@ -352,7 +352,7 @@ class PostProcessRunnerAdditionalThreadsTest extends TestCase
$this->assertContains('oversized-capacity', $warningCodes);
}
public function test_additional_diagnostics_recognizes_the_covering_claim_index(): void
public function test_additional_diagnostics_recognizes_the_claim_queue_index(): void
{
Schema::table('releases', function (Blueprint $table): void {
$table->index([
@@ -360,8 +360,9 @@ class PostProcessRunnerAdditionalThreadsTest extends TestCase
'haspreview',
'nzbstatus',
'leftguid',
'additional_pp_claimed_at',
'postdate',
'id',
'additional_pp_claimed_at',
], 'ix_releases_add_pp_claim_queue');
});
@@ -370,7 +371,7 @@ class PostProcessRunnerAdditionalThreadsTest extends TestCase
$this->assertSame(0, $status);
$this->assertFalse($report['indexes']['missing']);
$this->assertSame('ix_releases_add_pp_claim_queue', $report['indexes']['covering_index']);
$this->assertSame('ix_releases_add_pp_claim_queue', $report['indexes']['claim_queue_index']);
$this->assertNotContains('missing-index', array_column($report['warnings'], 'code'));
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\ReleaseComment;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use PDO;
use Tests\TestCase;
final class ReleaseCommentLifecycleTest extends TestCase
{
private string $databasePath = '';
public function createApplication()
{
$this->databasePath = sys_get_temp_dir().'/nntmux-release-comments.sqlite';
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
$pdo = new PDO('sqlite:'.$this->databasePath);
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
$pdo->exec("INSERT INTO settings VALUES ('categorizeforeign', '0'), ('catwebdl', '0')");
putenv('APP_ENV=testing');
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE='.$this->databasePath);
$_ENV['APP_ENV'] = $_SERVER['APP_ENV'] = 'testing';
$_ENV['DB_CONNECTION'] = $_SERVER['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = $_SERVER['DB_DATABASE'] = $this->databasePath;
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => $this->databasePath]);
DB::purge();
DB::reconnect();
DB::statement('CREATE TABLE users (id INTEGER PRIMARY KEY, username VARCHAR(255), deleted_at DATETIME NULL)');
DB::statement('CREATE TABLE releases (id INTEGER PRIMARY KEY, comments INTEGER DEFAULT 0)');
DB::statement('CREATE TABLE release_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT, releases_id INTEGER NOT NULL, text VARCHAR(2000),
isvisible INTEGER DEFAULT 1, username VARCHAR(255), users_id INTEGER,
created_at DATETIME, updated_at DATETIME, host VARCHAR(45)
)');
DB::table('users')->insert(['id' => 7, 'username' => 'commenter']);
DB::table('releases')->insert(['id' => 10, 'comments' => 99]);
}
protected function tearDown(): void
{
DB::disconnect();
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
parent::tearDown();
}
public function test_add_delete_and_visible_only_recount_use_release_id(): void
{
$commentId = ReleaseComment::addComment(10, 'Visible comment', 7, '127.0.0.1');
DB::table('release_comments')->insert([
'releases_id' => 10,
'text' => 'Hidden comment',
'isvisible' => 0,
'username' => 'commenter',
'users_id' => 7,
]);
ReleaseComment::updateReleaseCommentCount(10);
$this->assertSame(1, (int) DB::table('releases')->where('id', 10)->value('comments'));
$this->assertDatabaseHas('release_comments', [
'id' => $commentId,
'releases_id' => 10,
'text' => 'Visible comment',
]);
ReleaseComment::deleteComment($commentId);
$this->assertSame(0, (int) DB::table('releases')->where('id', 10)->value('comments'));
$this->assertDatabaseMissing('release_comments', ['id' => $commentId]);
}
public function test_details_controller_submission_no_longer_reads_or_passes_gid(): void
{
$controller = file_get_contents(app_path('Http/Controllers/DetailsController.php'));
$this->assertIsString($controller);
$this->assertStringContainsString('ReleaseComment::addComment((int) $data[\'id\']', $controller);
$this->assertStringNotContainsString("\$data['gid']", $controller);
}
}
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use PDO;
use Tests\TestCase;
final class ReleasesOptimizePreflightTest extends TestCase
{
private string $databasePath = '';
public function createApplication()
{
$this->databasePath = sys_get_temp_dir().'/nntmux-releases-preflight.sqlite';
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
$pdo = new PDO('sqlite:'.$this->databasePath);
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
$pdo->exec("INSERT INTO settings VALUES ('categorizeforeign', '0'), ('catwebdl', '0')");
$this->setEnvironmentValue('APP_ENV', 'testing');
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
$this->setEnvironmentValue('DB_DATABASE', $this->databasePath);
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => $this->databasePath]);
DB::purge();
DB::reconnect();
$this->createSchema();
}
protected function tearDown(): void
{
DB::disconnect();
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
parent::tearDown();
}
public function test_valid_data_reports_storage_and_every_migration_data_category(): void
{
$this->insertRelease(1, '01234567-89ab-cdef-0123-456789abcdef', '0', [
'nzb_password' => 'secret',
'nzb_creation_attempts' => 1,
'nzb_creation_last_error' => 'temporary',
'source' => 2,
]);
DB::table('release_comments')->insert([
['id' => 1, 'releases_id' => 1, 'isvisible' => 1, 'gid' => 'legacy'],
['id' => 2, 'releases_id' => 1, 'isvisible' => 0, 'gid' => null],
]);
$status = Artisan::call('releases:optimize-preflight', ['--json' => true]);
$report = json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR);
$this->assertSame(0, $status);
$this->assertTrue($report['ok']);
$this->assertSame(1, $report['migration_data']['nzb_password_rows']);
$this->assertSame(1, $report['migration_data']['nzb_creation_failure_rows']);
$this->assertSame(1, $report['migration_data']['visible_comment_rows']);
$this->assertSame(1, $report['migration_data']['release_comment_counter_mismatches']);
$this->assertSame(1, $report['discarded_data']['releases.source']);
$this->assertSame(1, $report['discarded_data']['release_comments.gid']);
$this->assertNull($report['storage']['total_bytes']);
}
public function test_every_identifier_blocker_fails_preflight(): void
{
$scenarios = [
'duplicate_ids' => function (): void {
$this->insertRelease(1, '01234567-89ab-cdef-0123-456789abcdef', '0');
$this->insertRelease(1, '11234567-89ab-cdef-0123-456789abcdef', '1');
},
'invalid_guids' => fn () => $this->insertRelease(1, 'not-a-guid', 'n'),
'case_insensitive_duplicate_guids' => function (): void {
$this->insertRelease(1, 'abcdef01-2345-6789-abcd-ef0123456789', 'a');
$this->insertRelease(2, 'ABCDEF01-2345-6789-ABCD-EF0123456789', 'A');
},
'leftguid_mismatches' => fn () => $this->insertRelease(1, '01234567-89ab-cdef-0123-456789abcdef', 'f'),
];
foreach ($scenarios as $expectedBlocker => $seed) {
DB::table('release_comments')->delete();
DB::table('releases')->delete();
$seed();
$status = Artisan::call('releases:optimize-preflight', ['--json' => true]);
$report = json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR);
$this->assertSame(1, $status, $expectedBlocker);
$this->assertContains($expectedBlocker, array_column($report['blockers'], 'code'));
}
}
/** @param array<string, mixed> $overrides */
private function insertRelease(int $id, string $guid, string $leftguid, array $overrides = []): void
{
DB::table('releases')->insert([
'id' => $id,
'guid' => $guid,
'leftguid' => $leftguid,
'comments' => 0,
'nzb_password' => null,
'nzb_creation_attempts' => 0,
'nzb_creation_last_error' => null,
'updatetime' => now(),
'gid' => null,
'source' => null,
'proc_sorter' => 0,
'audiostatus' => 0,
...$overrides,
]);
}
private function createSchema(): void
{
DB::statement('DROP TABLE IF EXISTS release_comments');
DB::statement('DROP TABLE IF EXISTS releases');
DB::statement('CREATE TABLE releases (
id INTEGER NOT NULL,
guid VARCHAR(40), leftguid CHAR(1), comments INTEGER DEFAULT 0,
nzb_password VARCHAR(255), nzb_creation_attempts INTEGER DEFAULT 0,
nzb_creation_last_error TEXT, updatetime DATETIME, gid VARCHAR(32),
source INTEGER, proc_sorter INTEGER DEFAULT 0, audiostatus INTEGER DEFAULT 0
)');
DB::statement('CREATE TABLE release_comments (
id INTEGER PRIMARY KEY, releases_id INTEGER NOT NULL, isvisible INTEGER DEFAULT 1,
gid VARCHAR(32), cid VARCHAR(32), issynced INTEGER DEFAULT 0, shared INTEGER DEFAULT 0,
shareid VARCHAR(40) DEFAULT \'\', siteid VARCHAR(40) DEFAULT \'\', sourceid INTEGER
)');
}
private function setEnvironmentValue(string $key, ?string $value): void
{
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
return;
}
putenv("{$key}={$value}");
$_ENV[$key] = $_SERVER[$key] = $value;
}
}
@@ -276,7 +276,7 @@ final class AdditionalCandidateQueryMariaDbTest extends TestCase
additional_pp_claimed_at TIMESTAMP NULL,
additional_pp_claim_token VARCHAR(64) NULL,
KEY ix_releases_haspreview_passwordstatus (haspreview, passwordstatus),
KEY ix_releases_add_pp_claim_queue (passwordstatus, haspreview, nzbstatus, leftguid, additional_pp_claimed_at, postdate DESC)
KEY ix_releases_add_pp_claim_queue (passwordstatus, haspreview, nzbstatus, leftguid, postdate DESC, id, additional_pp_claimed_at)
) ENGINE=InnoDB
SQL);
DB::table('settings')->insert([
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use App\Services\Nzb\NzbCreationCandidateQuery;
use Dotenv\Dotenv;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
final class NzbCreationClaimPlanMariaDbTest extends TestCase
{
private string $tablePrefix;
public function createApplication()
{
Dotenv::createMutable(dirname(__DIR__, 2))->safeLoad();
$this->tablePrefix = 'nzb_claim_'.getmypid().'_'.bin2hex(random_bytes(4)).'_';
$this->setEnvironmentValue('APP_ENV', 'testing');
$this->setEnvironmentValue('DB_URL', null);
$this->setEnvironmentValue('DB_CONNECTION', 'mariadb');
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$app->make('config')->set('database.connections.mariadb.prefix', $this->tablePrefix);
$app->make('db')->purge('mariadb');
return $app;
}
protected function setUp(): void
{
parent::setUp();
if (DB::getDriverName() !== 'mariadb') {
$this->markTestSkipped('MariaDB integration test.');
}
$this->createSchema();
}
protected function tearDown(): void
{
if (isset($this->tablePrefix) && preg_match('/^nzb_claim_\d+_[a-f0-9]{8}_$/', $this->tablePrefix) === 1) {
DB::statement('SET FOREIGN_KEY_CHECKS=0');
foreach (['release_nzb_creation_failures', 'releases', 'categories', 'root_categories', 'settings'] as $table) {
DB::statement('DROP TABLE IF EXISTS `'.$this->table($table).'`');
}
DB::statement('SET FOREIGN_KEY_CHECKS=1');
}
DB::disconnect();
parent::tearDown();
}
#[Test]
public function grouped_and_global_claim_plans_use_their_indexes_without_filesort(): void
{
$releases = $this->table('releases');
DB::statement(<<<SQL
INSERT INTO `{$releases}` (id, guid, groups_id, categories_id, nzbstatus, postdate, nzb_creation_claimed_at)
SELECT seq, CONCAT('guid-', seq), MOD(seq, 20) + 1, 1,
IF(MOD(seq, 25) = 0, 1, 0), DATE_SUB(NOW(), INTERVAL seq SECOND),
IF(MOD(seq, 997) = 0, NOW(), NULL)
FROM seq_1_to_50000
SQL);
DB::statement("ANALYZE TABLE `{$releases}`");
$groupPlan = $this->explain(<<<SQL
SELECT id FROM `{$releases}`
WHERE nzbstatus = 0 AND groups_id = 7
AND (nzb_creation_claimed_at IS NULL OR nzb_creation_claimed_at < DATE_SUB(NOW(), INTERVAL 300 SECOND))
ORDER BY postdate DESC, id ASC LIMIT 100 FOR UPDATE
SQL);
$globalPlan = $this->explain(<<<SQL
SELECT id FROM `{$releases}`
WHERE nzbstatus = 0
AND (nzb_creation_claimed_at IS NULL OR nzb_creation_claimed_at < DATE_SUB(NOW(), INTERVAL 300 SECOND))
ORDER BY postdate DESC, id ASC LIMIT 100 FOR UPDATE
SQL);
$this->assertStringContainsString('ix_releases_nzb_creation_group_queue', $groupPlan);
$this->assertStringContainsString('ix_releases_nzb_creation_global_queue', $globalPlan);
$this->assertStringNotContainsString('filesort', strtolower($groupPlan));
$this->assertStringNotContainsString('filesort', strtolower($globalPlan));
}
#[Test]
public function workers_cannot_claim_the_same_nzb_rows(): void
{
DB::table('releases')->insert(array_map(static fn (int $id): array => [
'id' => $id,
'guid' => 'claim-'.$id,
'groups_id' => 1,
'categories_id' => 1,
'nzbstatus' => 0,
'postdate' => now()->subSeconds($id),
], range(1, 10)));
$first = NzbCreationCandidateQuery::claimBatch(1, 5, 'worker-one', ['id', 'categories_id']);
$second = NzbCreationCandidateQuery::claimBatch(1, 5, 'worker-two', ['id', 'categories_id']);
$this->assertSame([1, 2, 3, 4, 5], $first->pluck('id')->all());
$this->assertSame([6, 7, 8, 9, 10], $second->pluck('id')->all());
$this->assertSame([], array_intersect($first->pluck('id')->all(), $second->pluck('id')->all()));
}
private function createSchema(): void
{
DB::statement('CREATE TABLE `'.$this->table('settings').'` (name VARCHAR(255) PRIMARY KEY, value TEXT) ENGINE=InnoDB');
DB::statement('CREATE TABLE `'.$this->table('root_categories').'` (id INT PRIMARY KEY, title VARCHAR(255)) ENGINE=InnoDB');
DB::statement('CREATE TABLE `'.$this->table('categories').'` (id INT PRIMARY KEY, title VARCHAR(255), root_categories_id INT) ENGINE=InnoDB');
DB::statement(<<<SQL
CREATE TABLE `{$this->table('releases')}` (
id INT UNSIGNED PRIMARY KEY, guid VARCHAR(64) NOT NULL, groups_id INT UNSIGNED NOT NULL,
categories_id INT NOT NULL, nzbstatus TINYINT NOT NULL DEFAULT 0, postdate DATETIME,
nzb_creation_claimed_at TIMESTAMP NULL, nzb_creation_claim_token VARCHAR(64) NULL,
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
SQL);
DB::statement('CREATE TABLE `'.$this->table('release_nzb_creation_failures').'` (
releases_id INT UNSIGNED PRIMARY KEY, attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
last_error TEXT NULL, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL,
FOREIGN KEY (releases_id) REFERENCES `'.$this->table('releases').'` (id) ON DELETE CASCADE
) ENGINE=InnoDB');
DB::table('settings')->insert([
['name' => 'categorizeforeign', 'value' => '0'],
['name' => 'catwebdl', 'value' => '0'],
['name' => 'releaseprocessingtimeout', 'value' => '120'],
]);
DB::table('root_categories')->insert(['id' => 1, 'title' => 'Other']);
DB::table('categories')->insert(['id' => 1, 'title' => 'Misc', 'root_categories_id' => 1]);
}
private function explain(string $sql): string
{
$row = DB::selectOne('EXPLAIN FORMAT=JSON '.$sql);
return (string) reset($row);
}
private function table(string $name): string
{
return $this->tablePrefix.$name;
}
private function setEnvironmentValue(string $key, ?string $value): void
{
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
return;
}
putenv("{$key}={$value}");
$_ENV[$key] = $_SERVER[$key] = $value;
}
}
@@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Dotenv\Dotenv;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
final class ReleaseSchemaOptimizationMigrationMariaDbTest extends TestCase
{
private string $tablePrefix;
public function createApplication()
{
Dotenv::createMutable(dirname(__DIR__, 2))->safeLoad();
$this->tablePrefix = 'release_opt_'.getmypid().'_'.bin2hex(random_bytes(4)).'_';
$this->setEnvironmentValue('APP_ENV', 'testing');
$this->setEnvironmentValue('DB_URL', null);
$this->setEnvironmentValue('DB_CONNECTION', 'mariadb');
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$app->make('config')->set('database.connections.mariadb.prefix', $this->tablePrefix);
$app->make('db')->purge('mariadb');
return $app;
}
protected function setUp(): void
{
parent::setUp();
if (DB::getDriverName() !== 'mariadb') {
$this->markTestSkipped('MariaDB integration test.');
}
$this->createSchema();
}
protected function tearDown(): void
{
if (isset($this->tablePrefix) && preg_match('/^release_opt_\d+_[a-f0-9]{8}_$/', $this->tablePrefix) === 1) {
DB::statement('SET FOREIGN_KEY_CHECKS=0');
foreach (['release_files', 'release_comments', 'release_nzb_creation_failures', 'release_nzb_passwords', 'releases'] as $table) {
DB::statement('DROP TABLE IF EXISTS `'.$this->table($table).'`');
}
DB::statement('SET FOREIGN_KEY_CHECKS=1');
}
DB::disconnect();
parent::tearDown();
}
#[Test]
public function migration_up_and_down_preserve_sparse_state_and_incoming_foreign_keys(): void
{
$this->seedLegacyData();
$migration = $this->migration();
$migration->up();
$this->assertTrue(Schema::hasTable('release_nzb_passwords'));
$this->assertTrue(Schema::hasTable('release_nzb_creation_failures'));
$this->assertSame('secret-one', DB::table('release_nzb_passwords')->where('releases_id', 1)->value('password'));
$this->assertSame(2, (int) DB::table('release_nzb_creation_failures')->where('releases_id', 1)->value('attempts'));
$this->assertSame('temporary', DB::table('release_nzb_creation_failures')->where('releases_id', 1)->value('last_error'));
$this->assertSame(['id'], $this->primaryKeyColumns());
$this->assertSame('ascii_general_ci', $this->columnCollation('guid'));
$this->assertSame('char(36)', strtolower($this->columnType('guid')));
$this->assertSame('ascii_general_ci', $this->columnCollation('leftguid'));
$this->assertSame(1, (int) DB::table('releases')->where('id', 1)->value('comments'));
$this->assertFalse(Schema::hasColumn('releases', 'nzb_password'));
$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'],
$this->indexColumns('ix_releases_add_pp_claim_queue'),
);
$this->assertSame(
['nzbstatus', 'groups_id', 'postdate', 'id', 'nzb_creation_claimed_at'],
$this->indexColumns('ix_releases_nzb_creation_group_queue'),
);
$this->assertSame(
['nzbstatus', 'postdate', 'id', 'nzb_creation_claimed_at'],
$this->indexColumns('ix_releases_nzb_creation_global_queue'),
);
try {
DB::table('releases')->insert($this->releaseRow(3, strtoupper('01234567-89ab-cdef-0123-456789abcdef')));
$this->fail('ASCII case-insensitive GUID uniqueness was not enforced.');
} catch (QueryException) {
$this->assertSame(2, DB::table('releases')->count());
}
DB::table('releases')->where('id', 2)->delete();
$this->assertDatabaseMissing('release_nzb_passwords', ['releases_id' => 2]);
DB::table('release_files')->insert(['releases_id' => 1, 'name' => 'kept.nzb']);
$migration->down();
$this->assertSame(['id', 'categories_id'], $this->primaryKeyColumns());
$this->assertTrue(Schema::hasColumn('releases', 'nzb_password'));
$this->assertTrue(Schema::hasColumn('releases', 'nzb_creation_attempts'));
$this->assertTrue(Schema::hasColumn('release_comments', 'gid'));
$this->assertSame('secret-one', DB::table('releases')->where('id', 1)->value('nzb_password'));
$this->assertSame(2, (int) DB::table('releases')->where('id', 1)->value('nzb_creation_attempts'));
$this->assertSame('temporary', DB::table('releases')->where('id', 1)->value('nzb_creation_last_error'));
$this->assertFalse(Schema::hasTable('release_nzb_passwords'));
$this->assertFalse(Schema::hasTable('release_nzb_creation_failures'));
$this->assertDatabaseHas('release_files', ['releases_id' => 1, 'name' => 'kept.nzb']);
}
private function createSchema(): void
{
$releases = $this->table('releases');
$comments = $this->table('release_comments');
$files = $this->table('release_files');
DB::statement(<<<SQL
CREATE TABLE `{$releases}` (
id INT UNSIGNED NOT NULL AUTO_INCREMENT, categories_id INT NOT NULL DEFAULT 10,
name VARCHAR(255) NOT NULL DEFAULT '', searchname VARCHAR(255) NOT NULL DEFAULT '',
groups_id INT UNSIGNED NOT NULL DEFAULT 0, size BIGINT UNSIGNED NOT NULL DEFAULT 0,
postdate DATETIME NULL, adddate DATETIME NULL, updatetime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
gid VARCHAR(32) NULL, guid VARCHAR(40) NOT NULL, leftguid CHAR(1) NOT NULL,
videos_id INT UNSIGNED NOT NULL DEFAULT 0, tv_episodes_id INT NOT NULL DEFAULT 0,
imdbid VARCHAR(100) NULL, movieinfo_id INT NULL, predb_id INT UNSIGNED NOT NULL DEFAULT 0,
comments INT NOT NULL DEFAULT 0, passwordstatus SMALLINT NOT NULL DEFAULT -1,
haspreview TINYINT NOT NULL DEFAULT 0, nzbstatus TINYINT NOT NULL DEFAULT 0,
source SMALLINT UNSIGNED NULL, nzb_password VARCHAR(255) NULL,
proc_pp TINYINT NOT NULL DEFAULT 0, proc_sorter TINYINT NOT NULL DEFAULT 0,
videostatus TINYINT NOT NULL DEFAULT 0, audiostatus TINYINT NOT NULL DEFAULT 0,
additional_pp_claimed_at TIMESTAMP NULL, additional_pp_claim_token VARCHAR(64) NULL,
nzb_creation_claimed_at TIMESTAMP NULL, nzb_creation_claim_token VARCHAR(64) NULL,
nzb_creation_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, nzb_creation_last_error TEXT NULL,
PRIMARY KEY (id, categories_id),
KEY ix_releases_guid (guid), KEY ix_releases_adddate_only (adddate),
KEY ix_releases_videos_id (videos_id), KEY ix_releases_movieinfo_id (movieinfo_id),
KEY ix_releases_imdbid (imdbid),
KEY ix_releases_tv_search_covering (passwordstatus, categories_id, postdate DESC, videos_id, tv_episodes_id, groups_id),
KEY ix_releases_passwordstatus (passwordstatus),
KEY ix_releases_haspreview_passwordstatus (haspreview, passwordstatus),
KEY ix_releases_postdate_searchname (postdate, searchname),
KEY ix_releases_predb_id_searchname (predb_id, searchname),
KEY ix_releases_size_cat (size, categories_id, passwordstatus),
KEY ix_releases_add_pp_claim_queue (passwordstatus, haspreview, nzbstatus, leftguid, additional_pp_claimed_at, postdate DESC),
KEY ix_releases_nzb_creation_queue (nzbstatus, groups_id, leftguid, nzb_creation_claimed_at, postdate DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci
SQL);
DB::statement(<<<SQL
CREATE TABLE `{$comments}` (
id INT UNSIGNED NOT NULL AUTO_INCREMENT, releases_id INT UNSIGNED NOT NULL,
text VARCHAR(2000) NOT NULL DEFAULT '', isvisible TINYINT NOT NULL DEFAULT 1,
issynced TINYINT NOT NULL DEFAULT 0, gid VARCHAR(32), cid VARCHAR(32),
shared TINYINT NOT NULL DEFAULT 0, shareid VARCHAR(40) NOT NULL DEFAULT '',
siteid VARCHAR(40) NOT NULL DEFAULT '', sourceid BIGINT UNSIGNED NULL,
PRIMARY KEY (id), CONSTRAINT fk_opt_comments FOREIGN KEY (releases_id)
REFERENCES `{$releases}` (id) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB
SQL);
DB::statement(<<<SQL
CREATE TABLE `{$files}` (
releases_id INT UNSIGNED NOT NULL, name VARCHAR(255) NOT NULL,
PRIMARY KEY (releases_id, name), CONSTRAINT fk_opt_files FOREIGN KEY (releases_id)
REFERENCES `{$releases}` (id) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB
SQL);
}
private function seedLegacyData(): void
{
DB::table('releases')->insert([
$this->releaseRow(1, '01234567-89ab-cdef-0123-456789abcdef', [
'nzb_password' => 'secret-one',
'nzb_creation_attempts' => 2,
'nzb_creation_last_error' => 'temporary',
'comments' => 99,
]),
$this->releaseRow(2, 'abcdef01-2345-6789-abcd-ef0123456789', ['nzb_password' => 'secret-two']),
]);
DB::table('release_comments')->insert([
['releases_id' => 1, 'text' => 'visible', 'isvisible' => 1],
['releases_id' => 1, 'text' => 'hidden', 'isvisible' => 0],
]);
}
/** @param array<string, mixed> $overrides */
private function releaseRow(int $id, string $guid, array $overrides = []): array
{
return [
'id' => $id,
'categories_id' => 10,
'name' => 'Release '.$id,
'searchname' => 'Release '.$id,
'groups_id' => 1,
'size' => 1000,
'postdate' => '2026-08-13 00:00:00',
'adddate' => '2026-08-13 00:00:00',
'guid' => $guid,
'leftguid' => strtolower($guid[0]),
'comments' => 0,
'nzb_password' => null,
'nzb_creation_attempts' => 0,
'nzb_creation_last_error' => null,
...$overrides,
];
}
/** @return list<string> */
private function primaryKeyColumns(): array
{
return array_map(
static fn (object $row): string => (string) $row->COLUMN_NAME,
DB::select(
'SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = \'PRIMARY\'
ORDER BY ORDINAL_POSITION',
[$this->table('releases')],
),
);
}
/** @return list<string> */
private function indexColumns(string $index): array
{
$rows = DB::select('SHOW INDEX FROM `'.$this->table('releases').'` WHERE Key_name = ?', [$index]);
usort($rows, static fn (object $left, object $right): int => (int) $left->Seq_in_index <=> (int) $right->Seq_in_index);
return array_map(
static fn (object $row): string => (string) $row->Column_name,
$rows,
);
}
private function columnCollation(string $column): string
{
return (string) DB::selectOne(
'SELECT COLLATION_NAME FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
[$this->table('releases'), $column],
)->COLLATION_NAME;
}
private function columnType(string $column): string
{
return (string) DB::selectOne(
'SELECT COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
[$this->table('releases'), $column],
)->COLUMN_TYPE;
}
private function migration(): Migration
{
/** @var Migration $migration */
$migration = require database_path('migrations/2026_08_13_001652_normalize_and_optimize_releases_table.php');
return $migration;
}
private function table(string $name): string
{
return $this->tablePrefix.$name;
}
private function setEnvironmentValue(string $key, ?string $value): void
{
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
return;
}
putenv("{$key}={$value}");
$_ENV[$key] = $_SERVER[$key] = $value;
}
}