Update cbp handling

This commit is contained in:
DariusIII
2026-08-03 16:48:25 +02:00
parent acf387e56e
commit f0a7e1a017
29 changed files with 2244 additions and 816 deletions
+9
View File
@@ -237,6 +237,15 @@ IMAGE_FETCH_TIMEOUT=30
IMAGE_FETCH_MAX_REDIRECTS=5
MAX_PAGER_RESULTS=125000
ECHOCLI=true
# Collections/binaries/parts processing limits. Lower values reduce peak memory
# and lock duration; higher values may improve throughput on larger DB servers.
CBP_HEADER_CHUNK_SIZE=500
CBP_SQL_CHUNK_SIZE=500
CBP_RECONCILE_BATCH_SIZE=500
CBP_NZB_STREAM_ROWS=5000
# Permit php artisan migrate to run the resumable CBP hash/key rewrite. Enable only during maintenance.
CBP_STORAGE_MIGRATION_EXECUTE=false
RENAME_PAR2=false
ADD_PAR2=false
RENAME_MUSIC_MEDIAINFO=true
+454
View File
@@ -0,0 +1,454 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\XrefService;
use App\Support\Utf8;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class OptimizeCbpStorage extends Command
{
protected $signature = 'cbp:optimize-storage
{--execute : Apply the reported storage changes}
{--batch= : Override the bounded reconciliation batch size}';
protected $description = 'Audit or resumably optimize collections, binaries, and parts storage';
public function handle(XrefService $xrefService): int
{
if (! \in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) {
$this->error('CBP storage optimization requires MySQL 8 or MariaDB. SQLite is test-only.');
return self::FAILURE;
}
foreach (['collections', 'binaries', 'parts'] as $table) {
if (! Schema::hasTable($table)) {
$this->error("Required table {$table} does not exist.");
return self::FAILURE;
}
}
$this->reportPreflight();
if (! (bool) $this->option('execute')) {
$this->newLine();
$this->warn('Dry-run only. Stop processing, verify a backup, then rerun with --execute.');
return self::SUCCESS;
}
$batchSize = max(1, min(5000, (int) ($this->option('batch') ?: config('nntmux.cbp.reconcile_batch_size', 500))));
$this->ensureCheckpointTable();
try {
$this->step('01_additive_schema', fn () => $this->prepareAdditiveSchema());
$this->step('02_collection_groups', fn () => $this->backfillCollectionGroups($xrefService, $batchSize));
$this->step('03_hash_columns', fn () => $this->prepareHashColumns());
$this->step('04_binary_hashes', fn () => $this->populateBinaryHashes($batchSize));
$this->step('05_binary_map', fn () => $this->buildBinaryMap());
$this->step('06_parts_shadow', fn () => $this->buildPartsShadow());
$this->step('07_swap_parts', fn () => $this->swapParts());
$this->step('08_merge_binaries', fn () => $this->mergeBinaries());
$this->step('09_collection_hash', fn () => $this->convertCollectionHash());
$this->step('10_constraints', fn () => $this->installConstraintsAndIndexes());
$this->step('11_aggregates', fn () => $this->rebuildAggregates($batchSize));
$this->step('12_finalize', fn () => $this->finalizeOptimization());
} catch (\Throwable $e) {
$this->error('Optimization stopped: '.$e->getMessage());
$this->warn('Completed checkpoints are retained; fix the cause and rerun the same command.');
return self::FAILURE;
}
$this->info('CBP storage optimization completed. Validate the preflight again before restarting workers.');
return self::SUCCESS;
}
private function reportPreflight(): void
{
$duplicateParts = (int) DB::scalar(
'SELECT COALESCE(SUM(n - 1), 0) FROM (
SELECT COUNT(*) AS n FROM parts GROUP BY binaries_id, partnumber HAVING COUNT(*) > 1
) duplicate_parts'
);
$duplicateBinaries = (int) DB::scalar(
'SELECT COALESCE(SUM(n - 1), 0) FROM (
SELECT COUNT(*) AS n FROM binaries GROUP BY collections_id, binaryhash HAVING COUNT(*) > 1
) duplicate_binaries'
);
$aggregateDrift = (int) DB::scalar(
'SELECT COUNT(*) FROM binaries b
LEFT JOIN (
SELECT binaries_id, COUNT(*) AS currentparts, COALESCE(SUM(size), 0) AS partsize
FROM parts GROUP BY binaries_id
) p ON p.binaries_id = b.id
WHERE b.currentparts <> COALESCE(p.currentparts, 0)
OR b.partsize <> COALESCE(p.partsize, 0)'
);
$orphanParts = (int) DB::scalar(
'SELECT COUNT(*) FROM parts p LEFT JOIN binaries b ON b.id = p.binaries_id WHERE b.id IS NULL'
);
$orphanBinaries = (int) DB::scalar(
'SELECT COUNT(*) FROM binaries b LEFT JOIN collections c ON c.id = b.collections_id WHERE c.id IS NULL'
);
$invalidMessageIds = (int) DB::scalar(
"SELECT COUNT(*) FROM parts WHERE messageid = '' OR messageid REGEXP '[^ -~]'"
);
$bytes = (int) DB::scalar(
"SELECT COALESCE(SUM(data_length + index_length), 0)
FROM information_schema.TABLES
WHERE table_schema = DATABASE() AND table_name IN ('collections', 'binaries', 'parts')"
);
$freeBytes = (int) DB::scalar(
"SELECT COALESCE(SUM(data_free), 0)
FROM information_schema.TABLES
WHERE table_schema = DATABASE() AND table_name IN ('collections', 'binaries', 'parts')"
);
$cascadeForeignKeys = (int) DB::scalar(
"SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME IN ('binaries', 'parts')
AND DELETE_RULE = 'CASCADE'"
);
$this->table(['Check', 'Result'], [
['Duplicate binary/part identities', number_format($duplicateBinaries)],
['Duplicate binary part numbers', number_format($duplicateParts)],
['Binary aggregate drift', number_format($aggregateDrift)],
['Orphan parts / binaries', number_format($orphanParts).' / '.number_format($orphanBinaries)],
['Required cascading foreign keys', $cascadeForeignKeys.'/2 healthy'],
['Empty or non-ASCII message IDs', number_format($invalidMessageIds)],
['Current CBP table+index bytes', number_format($bytes)],
['Reported reusable table bytes', number_format($freeBytes)],
['Conservative additional disk required', number_format(max($bytes, 1))],
]);
$this->line('Proposed: normalized group table, compact hashes, corrected identities, deterministic part merge, authoritative aggregates, cascade FKs, and hot-path indexes.');
}
private function ensureCheckpointTable(): void
{
DB::statement(
'CREATE TABLE IF NOT EXISTS cbp_optimization_checkpoints (
step_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
completed_at DATETIME NOT NULL,
PRIMARY KEY (step_name)
) ENGINE=InnoDB'
);
}
private function step(string $name, callable $operation): void
{
if (DB::table('cbp_optimization_checkpoints')->where('step_name', $name)->exists()) {
$this->line("Skipping completed step {$name}.");
return;
}
$this->info("Running {$name}...");
$operation();
DB::table('cbp_optimization_checkpoints')->insert([
'step_name' => $name,
'completed_at' => now(),
]);
}
private function prepareAdditiveSchema(): void
{
if (! Schema::hasColumn('collections', 'last_seen_at')) {
DB::statement('ALTER TABLE collections ADD last_seen_at DATETIME NULL AFTER dateadded');
}
DB::statement(
'CREATE TABLE IF NOT EXISTS collection_groups (
collections_id INT UNSIGNED NOT NULL,
group_name VARCHAR(255) NOT NULL,
PRIMARY KEY (collections_id, group_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci'
);
}
private function backfillCollectionGroups(XrefService $xrefService, int $batchSize): void
{
$lastId = 0;
do {
$rows = DB::select(
'SELECT c.id, c.xref, g.name AS group_name
FROM collections c
LEFT JOIN usenet_groups g ON g.id = c.groups_id
WHERE c.id > ? ORDER BY c.id LIMIT '.$batchSize,
[$lastId]
);
$values = [];
$bindings = [];
foreach ($rows as $row) {
$lastId = (int) $row->id;
$groups = $xrefService->extractGroupNames((string) $row->xref);
if ($groups === [] && (string) $row->group_name !== '') {
$groups = [(string) $row->group_name];
}
foreach ($groups as $group) {
$values[] = '(?, ?)';
$bindings[] = $lastId;
$bindings[] = $group;
}
}
if ($values !== []) {
DB::statement(
'INSERT IGNORE INTO collection_groups (collections_id, group_name) VALUES '.implode(',', $values),
$bindings
);
}
} while (\count($rows) === $batchSize);
}
private function prepareHashColumns(): void
{
if (! Schema::hasColumn('binaries', 'cbp_hash')) {
DB::statement('ALTER TABLE binaries ADD cbp_hash BINARY(16) NULL AFTER binaryhash');
}
if (! Schema::hasColumn('collections', 'cbp_hash')) {
DB::statement('ALTER TABLE collections ADD cbp_hash BINARY(20) NULL AFTER collectionhash');
}
}
private function populateBinaryHashes(int $batchSize): void
{
$lastId = 0;
do {
$rows = DB::select(
'SELECT b.id, b.name, b.filenumber, c.fromname
FROM binaries b INNER JOIN collections c ON c.id = b.collections_id
WHERE b.id > ? AND b.cbp_hash IS NULL ORDER BY b.id LIMIT '.$batchSize,
[$lastId]
);
foreach ($rows as $row) {
$lastId = (int) $row->id;
$hash = (int) $row->filenumber > 0
? md5('file:'.(int) $row->filenumber, true)
: md5('subject:'.$this->normalizeIdentity((string) $row->name)."\0poster:".$this->normalizeIdentity((string) $row->fromname), true);
DB::update('UPDATE binaries SET cbp_hash = ? WHERE id = ?', [$hash, $lastId]);
}
} while (\count($rows) === $batchSize);
}
private function buildBinaryMap(): void
{
DB::statement('DROP TABLE IF EXISTS cbp_binary_map');
DB::statement(
'CREATE TABLE cbp_binary_map (
old_id BIGINT UNSIGNED NOT NULL,
keep_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (old_id), KEY ix_cbp_binary_map_keep (keep_id)
) ENGINE=InnoDB'
);
DB::statement(
'INSERT INTO cbp_binary_map (old_id, keep_id)
SELECT id, MIN(id) OVER (PARTITION BY collections_id, cbp_hash) FROM binaries'
);
$binaryCount = (int) DB::table('binaries')->count();
$mappedCount = (int) DB::table('cbp_binary_map')->count();
if ($mappedCount !== $binaryCount) {
throw new \RuntimeException("Binary map is incomplete ({$mappedCount}/{$binaryCount}); the source tables were not changed.");
}
}
private function buildPartsShadow(): void
{
DB::statement('DROP TABLE IF EXISTS parts_cbp_new');
DB::statement(
'CREATE TABLE parts_cbp_new (
binaries_id BIGINT UNSIGNED NOT NULL,
messageid VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT \'\',
number BIGINT UNSIGNED NOT NULL DEFAULT 0,
partnumber INT UNSIGNED NOT NULL DEFAULT 0,
size INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (binaries_id, partnumber),
KEY ix_parts_number (number)
) ENGINE=InnoDB'
);
DB::statement(
'INSERT INTO parts_cbp_new (binaries_id, messageid, number, partnumber, size)
SELECT binaries_id, messageid, number, partnumber, size
FROM (
SELECT m.keep_id AS binaries_id, p.messageid, p.number, p.partnumber, p.size,
ROW_NUMBER() OVER (
PARTITION BY m.keep_id, p.partnumber
ORDER BY (p.messageid <> \'\') DESC, p.size DESC, p.number ASC
) AS preference
FROM parts p INNER JOIN cbp_binary_map m ON m.old_id = p.binaries_id
) ranked WHERE preference = 1'
);
$unmappedParts = (int) DB::scalar(
'SELECT COUNT(*) FROM parts p
LEFT JOIN cbp_binary_map m ON m.old_id = p.binaries_id
WHERE m.old_id IS NULL'
);
if ($unmappedParts > 0) {
throw new \RuntimeException("{$unmappedParts} parts are missing a binary-map entry; the source tables were not changed.");
}
$expectedParts = (int) DB::scalar(
'SELECT COUNT(*) FROM (
SELECT m.keep_id, p.partnumber
FROM parts p INNER JOIN cbp_binary_map m ON m.old_id = p.binaries_id
GROUP BY m.keep_id, p.partnumber
) retained_parts'
);
$retainedParts = (int) DB::table('parts_cbp_new')->count();
if ($retainedParts !== $expectedParts) {
throw new \RuntimeException("Parts shadow is incomplete ({$retainedParts}/{$expectedParts}); the source tables were not changed.");
}
}
private function swapParts(): void
{
if (! Schema::hasTable('parts_cbp_new') && Schema::hasTable('parts_cbp_pre_optimize')) {
return;
}
if (! Schema::hasTable('parts_cbp_new')) {
throw new \RuntimeException('parts_cbp_new is missing; rerun the parts shadow step.');
}
$this->dropForeignKeyIfExists('parts', 'FK_binaries');
DB::statement('DROP TABLE IF EXISTS parts_cbp_pre_optimize');
DB::statement('RENAME TABLE parts TO parts_cbp_pre_optimize, parts_cbp_new TO parts');
}
private function mergeBinaries(): void
{
if (! Schema::hasColumn('binaries', 'cbp_hash')) {
return;
}
DB::statement(
'UPDATE binaries keep_binary
INNER JOIN (
SELECT m.keep_id, MAX(b.totalparts) AS totalparts
FROM cbp_binary_map m INNER JOIN binaries b ON b.id = m.old_id
GROUP BY m.keep_id
) totals ON totals.keep_id = keep_binary.id
SET keep_binary.totalparts = totals.totalparts'
);
DB::statement(
'DELETE b FROM binaries b INNER JOIN cbp_binary_map m ON m.old_id = b.id WHERE m.old_id <> m.keep_id'
);
DB::statement('UPDATE binaries SET binaryhash = cbp_hash');
$this->dropIndexIfExists('binaries', 'ux_collection_id_filenumber');
$this->dropIndexIfExists('binaries', 'ix_binaries_binaryhash');
$this->dropIndexIfExists('binaries', 'ix_binaries_collection_hash');
$this->dropIndexIfExists('binaries', 'ux_binaries_collection_hash');
DB::statement('ALTER TABLE binaries MODIFY binaryhash BINARY(16) NOT NULL');
DB::statement('ALTER TABLE binaries DROP COLUMN cbp_hash');
DB::statement('CREATE UNIQUE INDEX ux_binaries_collection_hash ON binaries (collections_id, binaryhash)');
if (! $this->indexExists('binaries', 'ix_binaries_collection_filenumber')) {
DB::statement('CREATE INDEX ix_binaries_collection_filenumber ON binaries (collections_id, filenumber)');
}
}
private function convertCollectionHash(): void
{
if (! Schema::hasColumn('collections', 'cbp_hash')) {
return;
}
DB::statement(
"UPDATE collections SET cbp_hash = CASE
WHEN OCTET_LENGTH(collectionhash) = 20 THEN CAST(collectionhash AS BINARY)
WHEN OCTET_LENGTH(collectionhash) = 40 AND collectionhash REGEXP '^[0-9A-Fa-f]{40}$' THEN UNHEX(collectionhash)
ELSE UNHEX(SHA1(collectionhash)) END
WHERE cbp_hash IS NULL"
);
$this->dropIndexIfExists('collections', 'ix_collection_collectionhash');
DB::statement('ALTER TABLE collections DROP COLUMN collectionhash');
DB::statement('ALTER TABLE collections CHANGE cbp_hash collectionhash BINARY(20) NOT NULL');
DB::statement('CREATE UNIQUE INDEX ix_collection_collectionhash ON collections (collectionhash)');
}
private function installConstraintsAndIndexes(): void
{
if (! $this->indexExists('collections', 'ix_collections_group_filecheck_seen_id')) {
DB::statement('CREATE INDEX ix_collections_group_filecheck_seen_id ON collections (groups_id, filecheck, last_seen_at, id)');
}
$this->dropForeignKeyIfExists('collection_groups', 'fk_collection_groups_collection');
DB::statement(
'ALTER TABLE collection_groups ADD CONSTRAINT fk_collection_groups_collection
FOREIGN KEY (collections_id) REFERENCES collections(id) ON DELETE CASCADE ON UPDATE CASCADE'
);
$this->dropForeignKeyIfExists('parts', 'FK_binaries');
DB::statement(
'ALTER TABLE parts ADD CONSTRAINT FK_binaries
FOREIGN KEY (binaries_id) REFERENCES binaries(id) ON DELETE CASCADE ON UPDATE CASCADE'
);
}
private function rebuildAggregates(int $batchSize): void
{
$lastId = 0;
do {
$ids = DB::table('binaries')->where('id', '>', $lastId)->orderBy('id')->limit($batchSize)->pluck('id')->map(static fn ($id): int => (int) $id)->all();
if ($ids === []) {
break;
}
$lastId = (int) end($ids);
$placeholders = implode(',', array_fill(0, \count($ids), '?'));
DB::update(
"UPDATE binaries b LEFT JOIN (
SELECT binaries_id, COUNT(*) currentparts, COALESCE(SUM(size), 0) partsize
FROM parts WHERE binaries_id IN ({$placeholders}) GROUP BY binaries_id
) p ON p.binaries_id = b.id
SET b.currentparts = COALESCE(p.currentparts, 0),
b.partsize = COALESCE(p.partsize, 0),
b.partcheck = CASE WHEN COALESCE(p.currentparts, 0) >= b.totalparts THEN 1 ELSE 0 END
WHERE b.id IN ({$placeholders})",
[...$ids, ...$ids]
);
} while (\count($ids) === $batchSize);
DB::update(
'UPDATE collections c LEFT JOIN (
SELECT collections_id, COALESCE(SUM(partsize), 0) filesize FROM binaries GROUP BY collections_id
) b ON b.collections_id = c.id SET c.filesize = COALESCE(b.filesize, 0)'
);
}
private function finalizeOptimization(): void
{
DB::statement('DROP TABLE IF EXISTS parts_cbp_pre_optimize');
DB::statement('DROP TABLE IF EXISTS cbp_binary_map');
DB::statement('ANALYZE TABLE collections, binaries, parts, collection_groups');
}
private function normalizeIdentity(string $value): string
{
return mb_strtolower(trim(preg_replace('/\s+/u', ' ', Utf8::clean($value)) ?? ''));
}
private function indexExists(string $table, string $index): bool
{
return DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$index]) !== [];
}
private function dropIndexIfExists(string $table, string $index): void
{
if ($this->indexExists($table, $index)) {
DB::statement("ALTER TABLE `{$table}` DROP INDEX `{$index}`");
}
}
private function dropForeignKeyIfExists(string $table, string $constraint): void
{
$exists = DB::select(
'SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = ?
AND CONSTRAINT_NAME = ? AND CONSTRAINT_TYPE = \'FOREIGN KEY\'',
[$table, $constraint]
);
if ($exists !== []) {
DB::statement("ALTER TABLE `{$table}` DROP FOREIGN KEY `{$constraint}`");
}
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ use Illuminate\Database\Eloquent\Model;
* @property string $xref
* @property int $totalfiles
* @property int $groups_id
* @property string $collectionhash
* @property string $collectionhash Raw 20-byte SHA-1 digest
* @property int $collection_regexes_id FK to collection_regexes.id
* @property string|null $dateadded
* @property string $added
+9 -12
View File
@@ -21,20 +21,17 @@ final readonly class BinariesConfig
public int $newGroupDaysToScan = 3,
public int $partRepairLimit = 15000,
public int $partRepairMaxTries = 3,
public int $partsChunkSize = 5000,
public int $binariesUpdateChunkSize = 1000,
public bool $echoCli = false,
// Number of headers processed (and bulk-inserted) at a time inside
// HeaderStorageService. This MUST stay small because each chunk
// produces multi-row INSERTs/SELECTs whose binding count and SQL size
// grow linearly with the value. Defaulting it to partsChunkSize
// (which used to only control single-row part flushes) caused MySQL
// grow linearly with the value. Large unbounded chunks caused MySQL
// and PHP to allocate hundreds of MB per scan and run out of RAM.
public int $headerChunkSize = 500,
// Hard upper bound applied internally to bulk SELECT/INSERT/UPDATE
// operations regardless of caller-provided chunk size, so a
// misconfiguration cannot blow up server memory.
public int $bulkSqlChunkSize = 500,
// One shared hard upper bound for every bulk SELECT/INSERT/UPDATE.
public int $sqlChunkSize = 500,
public int $reconcileBatchSize = 500,
public int $nzbStreamRows = 5000,
) {}
/**
@@ -51,11 +48,11 @@ final readonly class BinariesConfig
newGroupDaysToScan: self::getSettingInt('newgroupdaystoscan', 3),
partRepairLimit: self::getSettingInt('maxpartrepair', 15000),
partRepairMaxTries: self::getSettingInt('partrepairmaxtries', 3),
partsChunkSize: max(100, (int) config('nntmux.parts_chunk_size', 5000)),
binariesUpdateChunkSize: max(100, min(1000, (int) config('nntmux.binaries_update_chunk_size', 1000))),
echoCli: (bool) config('nntmux.echocli'),
headerChunkSize: max(50, min(2000, (int) config('nntmux.header_chunk_size', 500))),
bulkSqlChunkSize: max(50, min(1000, (int) config('nntmux.bulk_sql_chunk_size', 500))),
headerChunkSize: max(50, min(2000, (int) config('nntmux.cbp.header_chunk_size', 500))),
sqlChunkSize: max(50, min(1000, (int) config('nntmux.cbp.sql_chunk_size', 500))),
reconcileBatchSize: max(50, min(2000, (int) config('nntmux.cbp.reconcile_batch_size', 500))),
nzbStreamRows: max(500, min(20000, (int) config('nntmux.cbp.nzb_stream_rows', 5000))),
);
}
+70 -44
View File
@@ -62,9 +62,7 @@ class BinariesService
private int $headersBlackListed = 0;
/**
* @var array<int, int|string>
*/
/** @var array<int, true> */
private array $headersReceived = [];
public function __construct(
@@ -80,7 +78,7 @@ class BinariesService
$this->missedPartHandler = $missedPartHandler ?? new MissedPartHandler(
$this->config->partRepairLimit,
$this->config->partRepairMaxTries,
$this->config->bulkSqlChunkSize
$this->config->sqlChunkSize
);
$this->nntp = $nntp;
$this->startUpdate = Carbon::now();
@@ -297,47 +295,68 @@ class BinariesService
// Extract article range info
$returnArray = $this->headerParser->getArticleRange($headers);
// Parse and filter headers
$this->headerParser->reset();
$parseResult = $this->headerParser->parse($headers, $groupMySQL['name'], $partRepair, $missingParts);
// Parse and store one bounded chunk at a time. The previous flow built
// a second full-size parsed header array before storage, doubling peak
// memory for large XOVER responses.
$this->startUpdate = Carbon::now(); // Reset before storage begins
$this->timeCleaning = $this->startUpdate->diffInSeconds($this->startCleaning, true);
$this->headersReceived = $parseResult['received'] ?? [];
$this->notYEnc = $parseResult['notYEnc'];
$this->headersBlackListed = $parseResult['blacklisted'];
$headersNotInserted = [];
$repairedNumbers = [];
$missingPartSet = $missingParts === null
? null
: array_fill_keys(array_map('intval', $missingParts), true);
$totalHeaders = \count($headers);
for ($offset = 0; $offset < $totalHeaders; $offset += $this->config->headerChunkSize) {
$rawChunk = array_slice($headers, $offset, $this->config->headerChunkSize);
$this->headerParser->reset();
$parseResult = $this->headerParser->parse($rawChunk, $groupMySQL['name'], $partRepair, $missingPartSet);
// Update blacklist last_activity
$this->headerParser->flushBlacklistUpdates();
foreach ($parseResult['received'] ?? [] as $number) {
$this->headersReceived[(int) $number] = true;
}
foreach ($parseResult['repaired'] ?? [] as $number) {
$repairedNumbers[(int) $number] = true;
}
$this->notYEnc += (int) $parseResult['notYEnc'];
$this->headersBlackListed += (int) $parseResult['blacklisted'];
$this->headerParser->flushBlacklistUpdates();
if ($parseResult['headers'] !== []) {
try {
$headersNotInserted = array_merge(
$headersNotInserted,
$this->headerStorage->store($parseResult['headers'], $groupMySQL, $addToPartRepair)
);
} catch (\Throwable $e) {
$this->logError('storeHeaders failed: '.$e->getMessage());
foreach ($parseResult['headers'] as $failedHeader) {
if (isset($failedHeader['Number'])) {
$headersNotInserted[] = $failedHeader['Number'];
}
}
}
}
unset($rawChunk, $parseResult);
}
unset($headers);
if ($this->config->echoCli && ! $partRepair) {
$this->outputHeaderInitial();
}
// Store headers
$this->startUpdate = Carbon::now(); // Reset before storage begins
$this->timeCleaning = $this->startUpdate->diffInSeconds($this->startCleaning, true);
$headersNotInserted = [];
if (! empty($parseResult['headers'])) {
try {
$headersNotInserted = $this->headerStorage->store($parseResult['headers'], $groupMySQL, $addToPartRepair);
} catch (\Throwable $e) {
$this->logError('storeHeaders failed: '.$e->getMessage());
}
}
$this->startPR = Carbon::now();
$this->timeInsert = $this->startPR->diffInSeconds($this->startUpdate, true);
// Handle repaired parts
if ($partRepair && ! empty($parseResult['repaired'])) {
$this->missedPartHandler->removeRepairedParts($parseResult['repaired'], $groupMySQL['id']);
if ($partRepair && $repairedNumbers !== []) {
$this->missedPartHandler->removeRepairedParts(array_keys($repairedNumbers), $groupMySQL['id']);
}
// Handle part repair tracking
if ($addToPartRepair) {
$this->handlePartRepairTracking($headersNotInserted, $parseResult['headers']);
$this->handlePartRepairTracking($headersNotInserted);
}
$this->outputHeaderDuration();
@@ -737,9 +756,8 @@ class BinariesService
/**
* @param array<int, int|string> $headersNotInserted
* @param array<int, array<string, mixed>> $parsedHeaders
*/
private function handlePartRepairTracking(array $headersNotInserted, array $parsedHeaders): void
private function handlePartRepairTracking(array $headersNotInserted): void
{
$notInsertedCount = \count($headersNotInserted);
if ($notInsertedCount > 0) {
@@ -747,21 +765,29 @@ class BinariesService
$this->log($notInsertedCount.' articles failed to insert!', __FUNCTION__, 'warning');
}
// Check for missing headers in range
$expectedCount = $this->last - $this->first - $this->notYEnc - $this->headersBlackListed + 1;
if ($expectedCount > \count($this->headersReceived)) {
$rangeNotReceived = array_values(array_diff(range($this->first, $this->last), $this->headersReceived));
$notReceivedCount = \count($rangeNotReceived);
if ($notReceivedCount > 0) {
$this->missedPartHandler->addMissingParts($rangeNotReceived, $this->groupMySQL['id']);
if ($this->config->echoCli) {
cli()->alternate(
'Server did not return '.$notReceivedCount.' articles from '.$this->groupMySQL['name'].'.'
);
}
// Find missing article numbers without materialising range($first, $last)
// and a second array_diff() copy. Flush bounded batches directly.
$missing = [];
$notReceivedCount = 0;
for ($number = $this->first; $number <= $this->last; $number++) {
if (isset($this->headersReceived[$number])) {
continue;
}
$missing[] = $number;
$notReceivedCount++;
if (\count($missing) >= $this->config->sqlChunkSize) {
$this->missedPartHandler->addMissingParts($missing, $this->groupMySQL['id']);
$missing = [];
}
}
if ($missing !== []) {
$this->missedPartHandler->addMissingParts($missing, $this->groupMySQL['id']);
}
if ($notReceivedCount > 0 && $this->config->echoCli) {
cli()->alternate(
'Server did not return '.$notReceivedCount.' articles from '.$this->groupMySQL['name'].'.'
);
}
}
+86 -136
View File
@@ -19,10 +19,7 @@ final class BinaryHandler
* generated SQL string and PDO parameter list bounded regardless of
* how many headers the caller passes in.
*/
private const MAX_SQL_ROWS_PER_STATEMENT = 500;
/** @var array<int, array{Size: int, Parts: int}> Pending binary updates */
private array $binariesUpdate = [];
private int $sqlChunkSize;
/** @var array<int, true> IDs of binaries created in this batch */
private array $insertedBinaryIds = [];
@@ -30,16 +27,21 @@ final class BinaryHandler
/** @var array<string, array{CollectionID: int, BinaryID: int}> Processed articles */
private array $articles = [];
public function __construct() {}
private ?\Throwable $lastException = null;
public function __construct(int $sqlChunkSize = 500)
{
$this->sqlChunkSize = max(1, min(1000, $sqlChunkSize));
}
/**
* Reset state for a new batch.
*/
public function reset(): void
{
$this->binariesUpdate = [];
$this->insertedBinaryIds = [];
$this->articles = [];
$this->lastException = null;
}
/**
@@ -54,18 +56,14 @@ final class BinaryHandler
int $groupId,
int $fileNumber
): ?int {
$articleKey = $this->articleKey($collectionId, $header['matches'][1]);
$hash = $this->identityHash($header, $fileNumber);
$articleKey = $this->binaryLookupKey($hash, $collectionId);
// Return cached if already processed
if (isset($this->articles[$articleKey])) {
$binaryId = $this->articles[$articleKey]['BinaryID'];
$this->binariesUpdate[$binaryId]['Size'] += $header['Bytes'];
$this->binariesUpdate[$binaryId]['Parts']++;
return $binaryId;
return $this->articles[$articleKey]['BinaryID'];
}
$hash = md5((string) ($header['matches'][1] ?? '').(string) ($header['From'] ?? '').(string) $groupId);
$driver = DB::getDriverName();
try {
@@ -78,7 +76,6 @@ final class BinaryHandler
);
if ($binaryId > 0) {
$this->binariesUpdate[$binaryId] = ['Size' => 0, 'Parts' => 0];
$this->articles[$articleKey] = [
'CollectionID' => $collectionId,
'BinaryID' => $binaryId,
@@ -87,6 +84,7 @@ final class BinaryHandler
return $binaryId;
}
} catch (\Throwable $e) {
$this->lastException = $e;
if (config('app.debug') === true) {
Log::error('Binary insert failed: '.$e->getMessage());
}
@@ -106,18 +104,15 @@ final class BinaryHandler
$resolved = [];
$pending = [];
$indexesByArticleKey = [];
$extraUpdatesByArticleKey = [];
foreach ($records as $index => $record) {
$header = $record['header'];
$collectionId = (int) $record['collection_id'];
$fileNumber = (int) $record['file_number'];
$articleKey = $this->articleKey($collectionId, $header['matches'][1]);
$hash = $this->identityHash($header, $fileNumber);
$articleKey = $this->binaryLookupKey($hash, $collectionId);
if (isset($this->articles[$articleKey])) {
$binaryId = $this->articles[$articleKey]['BinaryID'];
$this->binariesUpdate[$binaryId]['Size'] += (int) $header['Bytes'];
$this->binariesUpdate[$binaryId]['Parts']++;
$resolved[$index] = $binaryId;
continue;
@@ -125,20 +120,15 @@ final class BinaryHandler
$indexesByArticleKey[$articleKey][] = $index;
if (isset($pending[$articleKey])) {
$extraUpdatesByArticleKey[$articleKey]['Size'] = ($extraUpdatesByArticleKey[$articleKey]['Size'] ?? 0) + (int) $header['Bytes'];
$extraUpdatesByArticleKey[$articleKey]['Parts'] = ($extraUpdatesByArticleKey[$articleKey]['Parts'] ?? 0) + 1;
continue;
}
$hash = md5((string) ($header['matches'][1] ?? '').(string) ($header['From'] ?? '').(string) $groupId);
$pending[$articleKey] = [
'hash' => $hash,
'name' => Utf8::clean($header['matches'][1]),
'collections_id' => $collectionId,
'totalparts' => (int) $header['matches'][3],
'filenumber' => $fileNumber,
'partsize' => (int) $header['Bytes'],
];
}
@@ -149,8 +139,6 @@ final class BinaryHandler
try {
$result = $this->bulkInsertAndResolve($pending);
$idsByKey = $result['ids'];
$existingKeys = $result['existing'];
$driver = DB::getDriverName();
foreach ($pending as $articleKey => $row) {
$lookupKey = $this->binaryLookupKey($row['hash'], (int) $row['collections_id']);
$binaryId = $idsByKey[$lookupKey] ?? 0;
@@ -158,16 +146,6 @@ final class BinaryHandler
continue;
}
$this->binariesUpdate[$binaryId] = $this->binariesUpdate[$binaryId] ?? ['Size' => 0, 'Parts' => 0];
if ($driver === 'sqlite' && isset($existingKeys[$lookupKey])) {
$this->binariesUpdate[$binaryId]['Size'] += (int) $row['partsize'];
$this->binariesUpdate[$binaryId]['Parts']++;
}
if (isset($extraUpdatesByArticleKey[$articleKey])) {
$this->binariesUpdate[$binaryId]['Size'] += $extraUpdatesByArticleKey[$articleKey]['Size'];
$this->binariesUpdate[$binaryId]['Parts'] += $extraUpdatesByArticleKey[$articleKey]['Parts'];
}
$this->articles[$articleKey] = [
'CollectionID' => (int) $row['collections_id'],
'BinaryID' => $binaryId,
@@ -178,6 +156,7 @@ final class BinaryHandler
}
}
} catch (\Throwable $e) {
$this->lastException = $e;
if (config('app.debug') === true) {
Log::error('Bulk binary insert failed: '.$e->getMessage());
}
@@ -251,7 +230,7 @@ final class BinaryHandler
/** @param list<array<string, mixed>> $rows */
private function bulkInsertBinariesSqlite(array $rows): void
{
foreach (array_chunk($rows, self::MAX_SQL_ROWS_PER_STATEMENT) as $chunk) {
foreach (array_chunk($rows, $this->sqlChunkSize) as $chunk) {
$insertRows = [];
foreach ($chunk as $row) {
$insertRows[] = [
@@ -259,9 +238,9 @@ final class BinaryHandler
'name' => $row['name'],
'collections_id' => $row['collections_id'],
'totalparts' => $row['totalparts'],
'currentparts' => 1,
'currentparts' => 0,
'filenumber' => $row['filenumber'],
'partsize' => $row['partsize'],
'partsize' => 0,
];
}
@@ -272,26 +251,25 @@ final class BinaryHandler
/** @param list<array<string, mixed>> $rows */
private function bulkInsertBinariesMysql(array $rows): void
{
foreach (array_chunk($rows, self::MAX_SQL_ROWS_PER_STATEMENT) as $chunk) {
foreach (array_chunk($rows, $this->sqlChunkSize) as $chunk) {
$placeholders = [];
$bindings = [];
foreach ($chunk as $row) {
$placeholders[] = '(UNHEX(?), ?, ?, ?, 1, ?, ?)';
$placeholders[] = '(UNHEX(?), ?, ?, ?, 0, ?, 0)';
array_push(
$bindings,
$row['hash'],
$row['name'],
$row['collections_id'],
$row['totalparts'],
$row['filenumber'],
$row['partsize']
$row['filenumber']
);
}
DB::statement(
'INSERT INTO binaries (binaryhash, name, collections_id, totalparts, currentparts, filenumber, partsize) VALUES '
.implode(',', $placeholders)
.' ON DUPLICATE KEY UPDATE currentparts = currentparts + 1, partsize = partsize + VALUES(partsize)',
.' ON DUPLICATE KEY UPDATE totalparts = GREATEST(totalparts, VALUES(totalparts)), id = LAST_INSERT_ID(id)',
$bindings
);
}
@@ -323,7 +301,7 @@ final class BinaryHandler
$results = [];
foreach ($rowsByCollection as $collectionId => $hashes) {
$hashes = array_values(array_unique($hashes));
foreach (array_chunk($hashes, self::MAX_SQL_ROWS_PER_STATEMENT) as $chunk) {
foreach (array_chunk($hashes, $this->sqlChunkSize) as $chunk) {
$placeholders = implode(',', array_fill(0, \count($chunk), $driver === 'sqlite' ? '?' : 'UNHEX(?)'));
$bindings = $chunk;
$bindings[] = $collectionId;
@@ -360,13 +338,11 @@ final class BinaryHandler
): int {
$name = Utf8::clean($header['matches'][1]);
$totalParts = (int) $header['matches'][3];
$partSize = (int) $header['Bytes'];
if ($driver === 'sqlite') {
return $this->insertBinarySqlite($hash, $name, $collectionId, $totalParts, $fileNumber, $partSize);
return $this->insertBinarySqlite($hash, $name, $collectionId, $totalParts, $fileNumber);
}
return $this->insertBinaryMysql($hash, $name, $collectionId, $totalParts, $fileNumber, $partSize);
return $this->insertBinaryMysql($hash, $name, $collectionId, $totalParts, $fileNumber);
}
private function insertBinarySqlite(
@@ -374,17 +350,16 @@ final class BinaryHandler
string $name,
int $collectionId,
int $totalParts,
int $fileNumber,
int $partSize
int $fileNumber
): int {
$affected = DB::table('binaries')->insertOrIgnore([
'binaryhash' => $hash,
'name' => $name,
'collections_id' => $collectionId,
'totalparts' => $totalParts,
'currentparts' => 1,
'currentparts' => 0,
'filenumber' => $fileNumber,
'partsize' => $partSize,
'partsize' => 0,
]);
if ($affected > 0 && ($lastId = (int) DB::connection()->getPdo()->lastInsertId()) > 0) {
@@ -406,16 +381,15 @@ final class BinaryHandler
string $name,
int $collectionId,
int $totalParts,
int $fileNumber,
int $partSize
int $fileNumber
): int {
$sql = 'INSERT INTO binaries '
.'(binaryhash, name, collections_id, totalparts, currentparts, filenumber, partsize) '
.'VALUES (UNHEX(?), ?, ?, ?, 1, ?, ?) '
.'ON DUPLICATE KEY UPDATE currentparts = currentparts + 1, partsize = partsize + VALUES(partsize)';
.'VALUES (UNHEX(?), ?, ?, ?, 0, ?, 0) '
.'ON DUPLICATE KEY UPDATE totalparts = GREATEST(totalparts, VALUES(totalparts)), id = LAST_INSERT_ID(id)';
DB::statement($sql, [$hash, $name, $collectionId, $totalParts, $fileNumber, $partSize]);
DB::statement($sql, [$hash, $name, $collectionId, $totalParts, $fileNumber]);
$lastId = (int) DB::connection()->getPdo()->lastInsertId();
if ($lastId > 0) {
@@ -433,24 +407,56 @@ final class BinaryHandler
}
/**
* Flush accumulated size/parts updates to the database.
* Rebuild aggregates from stored parts for the binaries touched by a chunk.
* This is deliberately idempotent: ignored duplicate part inserts cannot
* inflate currentparts or partsize.
*
* @param list<int> $binaryIds
*/
public function flushUpdates(int $chunkSize = 1000): bool
public function refreshAggregates(array $binaryIds, int $chunkSize = 500): bool
{
$updates = $this->getPendingUpdates();
if (empty($updates)) {
$binaryIds = array_values(array_unique(array_map('intval', $binaryIds)));
if ($binaryIds === []) {
return true;
}
$driver = DB::getDriverName();
try {
if ($driver === 'sqlite') {
return $this->flushUpdatesSqlite($updates); // @phpstan-ignore argument.type
if (DB::getDriverName() === 'sqlite') {
foreach ($binaryIds as $binaryId) {
$aggregate = DB::selectOne(
'SELECT COUNT(*) AS currentparts, COALESCE(SUM(size), 0) AS partsize FROM parts WHERE binaries_id = ?',
[$binaryId]
);
DB::update(
'UPDATE binaries SET currentparts = ?, partsize = ?, partcheck = CASE WHEN ? >= totalparts THEN 1 ELSE 0 END WHERE id = ?',
[(int) $aggregate->currentparts, (int) $aggregate->partsize, (int) $aggregate->currentparts, $binaryId]
);
}
return true;
}
return $this->flushUpdatesMysql($updates, $chunkSize); // @phpstan-ignore argument.type
$chunkSize = max(1, min($chunkSize, $this->sqlChunkSize));
foreach (array_chunk($binaryIds, $chunkSize) as $chunk) {
$placeholders = implode(',', array_fill(0, \count($chunk), '?'));
DB::update(
"UPDATE binaries b
INNER JOIN (
SELECT p.binaries_id, COUNT(*) AS currentparts, COALESCE(SUM(p.size), 0) AS partsize
FROM parts p
WHERE p.binaries_id IN ({$placeholders})
GROUP BY p.binaries_id
) a ON a.binaries_id = b.id
SET b.currentparts = a.currentparts,
b.partsize = a.partsize,
b.partcheck = CASE WHEN a.currentparts >= b.totalparts THEN 1 ELSE 0 END",
$chunk
);
}
return true;
} catch (\Throwable $e) {
$this->lastException = $e;
if (config('app.debug') === true) {
Log::error('Binaries aggregate update failed: '.$e->getMessage());
}
@@ -459,54 +465,6 @@ final class BinaryHandler
}
}
/**
* @param array<string, mixed> $updates
*/
private function flushUpdatesSqlite(array $updates): bool
{
foreach ($updates as $row) {
DB::statement(
'UPDATE binaries SET partsize = partsize + ?, currentparts = currentparts + ? WHERE id = ?',
[$row['partsize'], $row['currentparts'], $row['id']]
);
}
return true;
}
/**
* @param array<string, mixed> $updates
*/
private function flushUpdatesMysql(array $updates, int $chunkSize): bool
{
// Clamp the caller-provided chunk size to our hard upper bound so an
// unusually large config value cannot produce a 100 KB+ UNION ALL
// query that exhausts memory on either side of the connection.
$chunkSize = max(1, min($chunkSize, self::MAX_SQL_ROWS_PER_STATEMENT));
foreach (array_chunk($updates, $chunkSize) as $chunk) {
$selects = [];
$bindings = [];
foreach ($chunk as $row) {
$selects[] = 'SELECT ? AS id, ? AS partsize, ? AS currentparts';
$bindings[] = $row['id'];
$bindings[] = $row['partsize'];
$bindings[] = $row['currentparts'];
}
$sql = 'UPDATE binaries b INNER JOIN ('
.implode(' UNION ALL ', $selects)
.') u ON u.id = b.id '
.'SET b.partsize = b.partsize + u.partsize, '
.'b.currentparts = b.currentparts + u.currentparts';
DB::statement($sql, $bindings);
}
return true;
}
/**
* Check if article is already processed.
*/
@@ -515,11 +473,6 @@ final class BinaryHandler
return isset($this->articles[$articleKey]);
}
private function articleKey(int $collectionId, string $name): string
{
return $collectionId.':'.$name;
}
/**
* Get IDs created in this batch.
*
@@ -530,24 +483,21 @@ final class BinaryHandler
return array_keys($this->insertedBinaryIds);
}
/**
* Get pending binary updates that haven't been flushed.
*
* @return list<array<string, int>>
*/
private function getPendingUpdates(): array
public function getLastException(): ?\Throwable
{
$rows = [];
foreach ($this->binariesUpdate as $binaryId => $binary) {
if (($binary['Size'] ?? 0) > 0 || ($binary['Parts'] ?? 0) > 0) {
$rows[] = [
'id' => $binaryId,
'partsize' => $binary['Size'],
'currentparts' => $binary['Parts'],
];
}
return $this->lastException;
}
/** @param array<string, mixed> $header */
private function identityHash(array $header, int $fileNumber): string
{
if ($fileNumber > 0) {
return md5('file:'.$fileNumber);
}
return $rows;
$subject = preg_replace('/\s+/u', ' ', Utf8::clean((string) ($header['matches'][1] ?? ''))) ?? '';
$poster = preg_replace('/\s+/u', ' ', Utf8::clean((string) ($header['From'] ?? ''))) ?? '';
return md5('subject:'.mb_strtolower(trim($subject))."\0poster:".mb_strtolower(trim($poster)));
}
}
+199 -104
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services\Binaries;
use App\Enums\CollectionFileCheckStatus;
use App\Models\Collection;
use App\Services\CollectionsCleaningService;
use App\Services\XrefService;
@@ -21,7 +22,7 @@ final class CollectionHandler
* (multi-row INSERT, IN(...) lookup, etc.). Keeps the generated SQL
* and PDO parameter count bounded regardless of caller batch size.
*/
private const MAX_SQL_ROWS_PER_STATEMENT = 500;
private int $sqlChunkSize;
private CollectionsCleaningService $collectionsCleaning;
@@ -36,18 +37,19 @@ final class CollectionHandler
/** @var array<string, true> Collection hashes touched in this batch */
private array $batchCollectionHashes = [];
/** @var array<string, string|null> Cached collection xrefs by collection key */
private array $existingXrefs = [];
/** @var array<string, int> Cached collection IDs by collectionhash (populated by bulk prefetch) */
private array $existingIdsByHash = [];
private ?\Throwable $lastException = null;
public function __construct(
?CollectionsCleaningService $collectionsCleaning = null,
?XrefService $xrefService = null
?XrefService $xrefService = null,
int $sqlChunkSize = 500,
) {
$this->collectionsCleaning = $collectionsCleaning ?? new CollectionsCleaningService;
$this->xrefService = $xrefService ?? new XrefService;
$this->sqlChunkSize = max(1, min(1000, $sqlChunkSize));
}
/**
@@ -58,8 +60,8 @@ final class CollectionHandler
$this->collectionIds = [];
$this->insertedCollectionIds = [];
$this->batchCollectionHashes = [];
$this->existingXrefs = [];
$this->existingIdsByHash = [];
$this->lastException = null;
}
/**
@@ -87,20 +89,14 @@ final class CollectionHandler
return $this->collectionIds[$collectionKey];
}
$collectionHash = sha1($collectionKey);
$collectionHash = sha1($collectionKey, true);
$this->batchCollectionHashes[$collectionHash] = true;
$headerDate = is_numeric($header['Date']) ? (int) $header['Date'] : strtotime($header['Date']);
$now = now()->timestamp;
$unixtime = min($headerDate, $now) ?: $now;
if (! array_key_exists($collectionKey, $this->existingXrefs)) {
$this->existingXrefs[$collectionKey] = Collection::whereCollectionhash($collectionHash)->value('xref');
}
$existingXref = $this->existingXrefs[$collectionKey];
$headerTokens = $this->xrefService->extractTokens($header['Xref'] ?? '');
$newTokens = $this->xrefService->diffNewTokens($existingXref, $header['Xref'] ?? '');
$finalXrefAppend = implode(' ', $newTokens);
$subject = substr(Utf8::clean($header['matches'][1]), 0, 255);
$fromName = Utf8::clean($header['From']);
@@ -114,7 +110,6 @@ final class CollectionHandler
$fromName,
$unixtime,
$headerTokens,
$finalXrefAppend,
$groupId,
$totalFiles,
$collectionHash,
@@ -124,10 +119,17 @@ final class CollectionHandler
if ($collectionId > 0) {
$this->collectionIds[$collectionKey] = $collectionId;
$this->insertCollectionGroups([
$collectionKey => array_fill_keys(
$this->xrefService->extractGroupNames($header['Xref'] ?? '') ?: [$groupName],
true
),
]);
return $collectionId;
}
} catch (\Throwable $e) {
$this->lastException = $e;
if (config('app.debug') === true) {
Log::error('Collection insert failed: '.$e->getMessage());
}
@@ -153,7 +155,8 @@ final class CollectionHandler
$resolved = [];
$pending = [];
$indexByCollectionKey = [];
$xrefsToPrefetch = [];
$hashesToPrefetch = [];
$groupNamesByCollectionKey = [];
foreach ($headers as $index => $header) {
$totalFiles = (int) ($totalFilesByIndex[$index] ?? 0);
@@ -163,6 +166,14 @@ final class CollectionHandler
);
$collectionKey = $collMatch['name'].$totalFiles;
$groupNames = $this->xrefService->extractGroupNames($header['Xref'] ?? '');
if ($groupNames === []) {
$groupNames = [$groupName];
}
foreach ($groupNames as $xrefGroupName) {
$groupNamesByCollectionKey[$collectionKey][$xrefGroupName] = true;
}
if (isset($this->collectionIds[$collectionKey])) {
$resolved[$index] = $this->collectionIds[$collectionKey];
@@ -174,10 +185,10 @@ final class CollectionHandler
continue;
}
$collectionHash = sha1($collectionKey);
$collectionHash = sha1($collectionKey, true);
$this->batchCollectionHashes[$collectionHash] = true;
$xrefsToPrefetch[$collectionKey] = $collectionHash;
$hashesToPrefetch[$collectionKey] = $collectionHash;
$headerDate = is_numeric($header['Date']) ? (int) $header['Date'] : strtotime($header['Date']);
$now = now()->timestamp;
@@ -189,7 +200,6 @@ final class CollectionHandler
'fromname' => Utf8::clean($header['From']),
'unixtime' => $unixtime,
'xref' => implode(' ', $headerTokens),
'header_xref' => $header['Xref'] ?? '',
'groups_id' => $groupId,
'totalfiles' => $totalFiles,
'collectionhash' => $collectionHash,
@@ -199,18 +209,12 @@ final class CollectionHandler
}
if ($pending === []) {
$this->insertCollectionGroups($groupNamesByCollectionKey);
return $resolved;
}
$this->prefetchExistingCollections($xrefsToPrefetch);
foreach ($pending as $collectionKey => &$row) {
$row['xref_append'] = implode(' ', $this->xrefService->diffNewTokens(
$this->existingXrefs[$collectionKey],
$row['header_xref']
));
unset($row['header_xref']);
}
unset($row);
$this->prefetchExistingCollections($hashesToPrefetch);
try {
$idsByHash = $this->bulkInsertAndResolve($pending);
@@ -225,7 +229,9 @@ final class CollectionHandler
$resolved[$index] = $collectionId;
}
}
$this->insertCollectionGroups($groupNamesByCollectionKey);
} catch (\Throwable $e) {
$this->lastException = $e;
if (config('app.debug') === true) {
Log::error('Bulk collection insert failed: '.$e->getMessage());
}
@@ -236,7 +242,7 @@ final class CollectionHandler
/**
* Prefetch existing collections in one round-trip. Populates both
* $existingXrefs (keyed by collectionKey) and $existingIdsByHash so the
* $existingIdsByHash so the
* subsequent bulkInsertAndResolve() can skip its existence-check SELECT
* and only re-query for the freshly inserted rows.
*
@@ -246,7 +252,7 @@ final class CollectionHandler
{
$missing = array_filter(
$collectionHashByKey,
fn (string $hash, string $collectionKey): bool => ! array_key_exists($collectionKey, $this->existingXrefs),
fn (string $hash): bool => ! isset($this->existingIdsByHash[$hash]),
ARRAY_FILTER_USE_BOTH
);
@@ -255,24 +261,22 @@ final class CollectionHandler
}
$rows = [];
foreach (array_chunk(array_values($missing), self::MAX_SQL_ROWS_PER_STATEMENT) as $chunk) {
foreach (Collection::query()
->whereIn('collectionhash', $chunk)
->select(['id', 'collectionhash', 'xref'])
->get() as $row) {
foreach (array_chunk(array_values($missing), $this->sqlChunkSize) as $chunk) {
$placeholders = implode(',', array_fill(0, \count($chunk), '?'));
$existingRows = DB::select(
"SELECT id, collectionhash FROM collections WHERE collectionhash IN ({$placeholders})",
$chunk
);
foreach ($existingRows as $row) {
$rows[(string) $row->collectionhash] = [
'id' => (int) $row->id,
'xref' => (string) $row->xref,
];
}
}
foreach ($missing as $collectionKey => $hash) {
if (isset($rows[$hash])) {
$this->existingXrefs[$collectionKey] = $rows[$hash]['xref'];
$this->existingIdsByHash[$hash] = $rows[$hash]['id'];
} else {
$this->existingXrefs[$collectionKey] = null;
}
}
}
@@ -300,7 +304,7 @@ final class CollectionHandler
if (DB::getDriverName() === 'sqlite') {
$this->bulkInsertCollectionsSqlite($rowsByCollectionKey);
} else {
$this->bulkInsertCollectionsMysql($rowsByCollectionKey, $existingHashes);
$this->bulkInsertCollectionsMysql($rowsByCollectionKey);
}
// Only resolve ids for the hashes we couldn't satisfy from the
@@ -344,18 +348,17 @@ final class CollectionHandler
];
}
foreach (array_chunk($rows, self::MAX_SQL_ROWS_PER_STATEMENT) as $chunk) {
foreach (array_chunk($rows, $this->sqlChunkSize) as $chunk) {
DB::table('collections')->insertOrIgnore($chunk);
}
}
/**
* @param array<string, array<string, mixed>> $rowsByCollectionKey
* @param array<string, true> $existingHashes
*/
private function bulkInsertCollectionsMysql(array $rowsByCollectionKey, array $existingHashes): void
private function bulkInsertCollectionsMysql(array $rowsByCollectionKey): void
{
foreach (array_chunk(array_values($rowsByCollectionKey), self::MAX_SQL_ROWS_PER_STATEMENT) as $chunk) {
foreach (array_chunk(array_values($rowsByCollectionKey), $this->sqlChunkSize) as $chunk) {
$placeholders = [];
$bindings = [];
foreach ($chunk as $row) {
@@ -386,50 +389,8 @@ final class CollectionHandler
);
}
$this->batchAppendXrefs($rowsByCollectionKey, $existingHashes);
}
/**
* Append xref tokens for every existing collection in a chunk in a single
* UPDATE...JOIN per sub-chunk instead of N standalone UPDATEs (one per row).
* Same UNION ALL shape as BinaryHandler::flushUpdatesMysql().
*
* @param array<string, array<string, mixed>> $rowsByCollectionKey
* @param array<string, true> $existingHashes
*/
private function batchAppendXrefs(array $rowsByCollectionKey, array $existingHashes): void
{
$updates = [];
foreach ($rowsByCollectionKey as $row) {
if (($row['xref_append'] ?? '') !== '' && isset($existingHashes[$row['collectionhash']])) {
$updates[] = [
'collectionhash' => $row['collectionhash'],
'xref_append' => $row['xref_append'],
];
}
}
if ($updates === []) {
return;
}
foreach (array_chunk($updates, self::MAX_SQL_ROWS_PER_STATEMENT) as $chunk) {
$selects = [];
$bindings = [];
foreach ($chunk as $u) {
$selects[] = 'SELECT ? AS collectionhash, ? AS xref_append';
$bindings[] = $u['collectionhash'];
$bindings[] = $u['xref_append'];
}
$sql = 'UPDATE collections c INNER JOIN ('
.implode(' UNION ALL ', $selects)
.') u ON u.collectionhash = c.collectionhash '
.'SET c.xref = CONCAT(c.xref, ?, u.xref_append)';
$bindings[] = "\n";
DB::statement($sql, $bindings);
}
// Cross-post groups are normalized in collection_groups. Avoid
// rewriting the wide collections row for every new article number.
}
/**
@@ -443,17 +404,56 @@ final class CollectionHandler
}
$resolved = [];
foreach (array_chunk($hashes, self::MAX_SQL_ROWS_PER_STATEMENT) as $chunk) {
$resolved += Collection::query()
->whereIn('collectionhash', $chunk)
->pluck('id', 'collectionhash')
->mapWithKeys(static fn (int|string $id, string $hash): array => [$hash => (int) $id])
->all();
foreach (array_chunk($hashes, $this->sqlChunkSize) as $chunk) {
$placeholders = implode(',', array_fill(0, \count($chunk), '?'));
foreach (DB::select(
"SELECT id, collectionhash FROM collections WHERE collectionhash IN ({$placeholders})",
$chunk
) as $row) {
$resolved[(string) $row->collectionhash] = (int) $row->id;
}
}
return $resolved;
}
/**
* @param array<string, array<string, true>> $groupNamesByCollectionKey
*/
private function insertCollectionGroups(array $groupNamesByCollectionKey): void
{
$rows = [];
foreach ($groupNamesByCollectionKey as $collectionKey => $groupNames) {
$collectionId = $this->collectionIds[$collectionKey] ?? null;
if ($collectionId === null) {
continue;
}
foreach (array_keys($groupNames) as $groupName) {
$rows[] = ['collections_id' => $collectionId, 'group_name' => $groupName];
}
}
foreach (array_chunk($rows, $this->sqlChunkSize) as $chunk) {
if (DB::getDriverName() === 'sqlite') {
DB::table('collection_groups')->insertOrIgnore($chunk);
continue;
}
$values = [];
$bindings = [];
foreach ($chunk as $row) {
$values[] = '(?, ?)';
$bindings[] = $row['collections_id'];
$bindings[] = $row['group_name'];
}
DB::statement(
'INSERT IGNORE INTO collection_groups (collections_id, group_name) VALUES '.implode(',', $values),
$bindings
);
}
}
/**
* @param array<string, mixed> $headerTokens
*/
@@ -463,7 +463,6 @@ final class CollectionHandler
string $fromName,
int $unixtime,
array $headerTokens,
string $finalXrefAppend,
int $groupId,
int $totalFiles,
string $collectionHash,
@@ -489,7 +488,6 @@ final class CollectionHandler
$fromName,
$unixtime,
$headerTokens,
$finalXrefAppend,
$groupId,
$totalFiles,
$collectionHash,
@@ -542,7 +540,6 @@ final class CollectionHandler
string $fromName,
int $unixtime,
array $headerTokens,
string $finalXrefAppend,
int $groupId,
int $totalFiles,
string $collectionHash,
@@ -569,14 +566,8 @@ final class CollectionHandler
$batchNoise,
];
if ($finalXrefAppend !== '') {
$insertSql .= ', xref = CONCAT(xref, "\\n", ?)';
$bindings[] = $finalXrefAppend;
}
// affectingStatement so we can distinguish a brand-new insert
// (rowCount = 1) from a duplicate-key hit (rowCount = 0 with no xref
// append, or 2 when xref was actually appended). LAST_INSERT_ID(id) in
// affectingStatement distinguishes a brand-new insert (rowCount = 1)
// from a duplicate-key hit (rowCount = 0). LAST_INSERT_ID(id) in
// the ODKU clause makes lastInsertId() return the existing row id
// even on a duplicate, so we can't rely on lastInsertId() alone.
$affected = (int) DB::affectingStatement($insertSql, $bindings);
@@ -593,6 +584,105 @@ final class CollectionHandler
return (int) (Collection::whereCollectionhash($collectionHash)->value('id') ?? 0);
}
/**
* Refresh size and readiness for only the collections touched by a header
* chunk. Binary and part aggregates have already been refreshed, so this
* replaces broad table-wide completion scans on the normal ingestion path.
*
* @param list<int> $collectionIds
*/
public function refreshAggregates(array $collectionIds, int $chunkSize = 500): bool
{
$collectionIds = array_values(array_unique(array_map('intval', $collectionIds)));
if ($collectionIds === []) {
return true;
}
$inProgressStatuses = [
CollectionFileCheckStatus::Default->value,
CollectionFileCheckStatus::CompleteCollection->value,
CollectionFileCheckStatus::CompleteParts->value,
CollectionFileCheckStatus::TempComplete->value,
CollectionFileCheckStatus::ZeroPart->value,
];
try {
$chunkSize = max(1, min($chunkSize, $this->sqlChunkSize));
if (DB::getDriverName() === 'sqlite') {
foreach ($collectionIds as $collectionId) {
$aggregate = DB::selectOne(
'SELECT COUNT(*) AS currentfiles,
COALESCE(SUM(CASE WHEN partcheck = 1 THEN 1 ELSE 0 END), 0) AS completefiles,
COALESCE(SUM(partsize), 0) AS filesize
FROM binaries WHERE collections_id = ?',
[$collectionId]
);
$collection = DB::selectOne(
'SELECT totalfiles, filecheck FROM collections WHERE id = ?',
[$collectionId]
);
if ($collection === null) {
continue;
}
$currentFiles = (int) $aggregate->currentfiles;
$totalFiles = (int) $collection->totalfiles;
$ready = \in_array((int) $collection->filecheck, $inProgressStatuses, true)
&& $totalFiles > 0
&& ($currentFiles === $totalFiles || $currentFiles === $totalFiles + 1)
&& (int) $aggregate->completefiles >= $totalFiles;
DB::update(
'UPDATE collections SET filesize = ?, last_seen_at = ?, filecheck = ? WHERE id = ?',
[
(int) $aggregate->filesize,
now()->format('Y-m-d H:i:s'),
$ready ? CollectionFileCheckStatus::Sized->value : (int) $collection->filecheck,
$collectionId,
]
);
}
return true;
}
foreach (array_chunk($collectionIds, $chunkSize) as $chunk) {
$idPlaceholders = implode(',', array_fill(0, \count($chunk), '?'));
$statusPlaceholders = implode(',', array_fill(0, \count($inProgressStatuses), '?'));
DB::update(
"UPDATE collections c
INNER JOIN (
SELECT b.collections_id,
COUNT(*) AS currentfiles,
SUM(CASE WHEN b.partcheck = 1 THEN 1 ELSE 0 END) AS completefiles,
COALESCE(SUM(b.partsize), 0) AS filesize
FROM binaries b
WHERE b.collections_id IN ({$idPlaceholders})
GROUP BY b.collections_id
) a ON a.collections_id = c.id
SET c.filesize = a.filesize,
c.last_seen_at = NOW(),
c.filecheck = CASE
WHEN c.filecheck IN ({$statusPlaceholders})
AND c.totalfiles > 0
AND a.currentfiles IN (c.totalfiles, c.totalfiles + 1)
AND a.completefiles >= c.totalfiles
THEN ? ELSE c.filecheck END",
[...$chunk, ...$inProgressStatuses, CollectionFileCheckStatus::Sized->value]
);
}
return true;
} catch (\Throwable $e) {
$this->lastException = $e;
if (config('app.debug') === true) {
Log::error('Collection aggregate refresh failed: '.$e->getMessage());
}
return false;
}
}
/**
* Get IDs created in this batch.
*
@@ -603,6 +693,11 @@ final class CollectionHandler
return array_keys($this->insertedCollectionIds);
}
public function getLastException(): ?\Throwable
{
return $this->lastException;
}
/**
* Get all collection IDs processed this batch.
*
+9 -7
View File
@@ -49,6 +49,11 @@ final class HeaderParser
$parsed = [];
$headersRepaired = [];
$receivedNumbers = [];
$missingPartSet = $missingParts === null
? null
: (array_is_list($missingParts)
? array_fill_keys(array_map('intval', $missingParts), true)
: $missingParts);
foreach ($headers as $header) {
// Check if we got the article
@@ -59,8 +64,8 @@ final class HeaderParser
$receivedNumbers[] = $header['Number'];
// For part repair, only process missing parts
if ($partRepair && $missingParts !== null) {
if (! \in_array($header['Number'], $missingParts, true)) {
if ($partRepair && $missingPartSet !== null) {
if (! isset($missingPartSet[(int) $header['Number']])) {
continue;
}
$headersRepaired[] = $header['Number'];
@@ -92,14 +97,11 @@ final class HeaderParser
$header['Bytes'] = $header[':bytes'] ?? 0;
}
$parsed[] = [
'header' => $header,
'repaired' => $partRepair,
];
$parsed[] = $header;
}
return [
'headers' => array_column($parsed, 'header'),
'headers' => $parsed,
'repaired' => $headersRepaired,
'received' => $receivedNumbers,
'notYEnc' => $this->notYEnc,
+69 -14
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Services\Binaries;
use Illuminate\Database\QueryException;
/**
* Orchestrates the header storage process.
*
@@ -12,6 +14,8 @@ namespace App\Services\Binaries;
*/
final class HeaderStorageService
{
private const int LOCK_RETRY_MAX = 5;
private CollectionHandler $collectionHandler;
private BinaryHandler $binaryHandler;
@@ -23,6 +27,8 @@ final class HeaderStorageService
/** @var array<int, int|string> Article numbers that failed to insert */
private array $failedInserts = [];
private ?\Throwable $lastStorageException = null;
public function __construct(
?CollectionHandler $collectionHandler = null,
?BinaryHandler $binaryHandler = null,
@@ -30,10 +36,10 @@ final class HeaderStorageService
?BinariesConfig $config = null
) {
$this->config = $config ?? BinariesConfig::fromSettings();
$this->collectionHandler = $collectionHandler ?? new CollectionHandler;
$this->binaryHandler = $binaryHandler ?? new BinaryHandler;
$this->collectionHandler = $collectionHandler ?? new CollectionHandler(sqlChunkSize: $this->config->sqlChunkSize);
$this->binaryHandler = $binaryHandler ?? new BinaryHandler($this->config->sqlChunkSize);
$this->partHandler = $partHandler ?? new PartHandler(
$this->config->partsChunkSize,
$this->config->sqlChunkSize,
true
);
}
@@ -54,17 +60,13 @@ final class HeaderStorageService
$this->failedInserts = [];
// Use the dedicated header chunk size, NOT partsChunkSize. The latter
// controls single-row part flushes and is normally much larger; using
// it here forces every collection/binary bulk INSERT and OR-clause
// SELECT to scale to thousands of rows per chunk, which exhausts PHP
// and MySQL memory.
// Header and SQL chunking are configured independently, but both are
// clamped by BinariesConfig so generated statements remain bounded.
$chunkSize = max(1, $this->config->headerChunkSize);
// Walk the array with offset slicing instead of array_chunk() so we
// don't materialize every chunk simultaneously in memory.
$total = \count($headers);
$headers = array_values($headers);
for ($offset = 0; $offset < $total; $offset += $chunkSize) {
$chunk = \array_slice($headers, $offset, $chunkSize);
$this->storeChunk($chunk, $groupMySQL, $addToPartRepair);
@@ -82,6 +84,30 @@ final class HeaderStorageService
*/
private function storeChunk(array $headers, array $groupMySQL, bool $addToPartRepair): void
{
$attempt = 0;
do {
$failedInsertCount = \count($this->failedInserts);
if ($this->storeChunkAttempt($headers, $groupMySQL, $addToPartRepair)) {
return;
}
$attempt++;
if ($attempt >= self::LOCK_RETRY_MAX || ! $this->isTransientLockError($this->lastStorageException)) {
return;
}
$this->failedInserts = array_slice($this->failedInserts, 0, $failedInsertCount);
usleep((min(500, 20 * $attempt) + random_int(0, 25)) * 1000);
} while (true);
}
/**
* @param array<int, array<string, mixed>> $headers
* @param array<string, mixed> $groupMySQL
*/
private function storeChunkAttempt(array $headers, array $groupMySQL, bool $addToPartRepair): bool
{
$this->lastStorageException = null;
$this->collectionHandler->reset();
$this->binaryHandler->reset();
$this->partHandler->reset();
@@ -113,13 +139,26 @@ final class HeaderStorageService
// Flush binary aggregate updates
if (! $transaction->hasErrors()) {
if (! $this->binaryHandler->flushUpdates($this->config->binariesUpdateChunkSize)) {
if (! $this->binaryHandler->refreshAggregates(
$this->partHandler->getTouchedBinaryIds(),
$this->config->sqlChunkSize
)) {
$transaction->markError();
}
if (! $transaction->hasErrors() && ! $this->collectionHandler->refreshAggregates(
$this->collectionHandler->getAllIds(),
$this->config->sqlChunkSize
)) {
$transaction->markError();
}
}
// Finish transaction
if (! $transaction->finish()) {
$this->lastStorageException = $transaction->getLastException()
?? $this->partHandler->getLastException()
?? $this->binaryHandler->getLastException()
?? $this->collectionHandler->getLastException();
if ($addToPartRepair) {
$this->failedInserts = array_merge(
$this->failedInserts,
@@ -128,13 +167,32 @@ final class HeaderStorageService
);
}
return;
return false;
}
$this->failedInserts = array_merge(
$this->failedInserts,
$this->partHandler->getFailedNumbers()
);
return true;
}
private function isTransientLockError(?\Throwable $exception): bool
{
if ($exception === null) {
return false;
}
if ($exception instanceof QueryException) {
$driverCode = (int) ($exception->errorInfo[1] ?? 0);
if ($exception->getCode() === '40001' || \in_array($driverCode, [1205, 1213], true)) {
return true;
}
}
return str_contains($exception->getMessage(), 'Deadlock found')
|| str_contains($exception->getMessage(), 'Lock wait timeout exceeded')
|| str_contains($exception->getMessage(), 'database is locked');
}
/**
@@ -198,9 +256,6 @@ final class HeaderStorageService
private function extractFileNumberAndTotal(array $header): array
{
$fileCount = $this->getFileCount($header['matches'][1]);
if ($fileCount[1] === 0 && $fileCount[3] === 0) {
$fileCount = $this->getFileCount($header['matches'][0]);
}
return [(int) $fileCount[1], (int) $fileCount[3]];
}
@@ -22,6 +22,8 @@ final class HeaderStorageTransaction
private bool $hadErrors = false;
private ?\Throwable $lastException = null;
public function __construct(
CollectionHandler $collectionHandler,
BinaryHandler $binaryHandler
@@ -46,6 +48,7 @@ final class HeaderStorageTransaction
{
DB::beginTransaction();
$this->hadErrors = false;
$this->lastException = null;
}
/**
@@ -80,6 +83,7 @@ final class HeaderStorageTransaction
return true;
} catch (\Throwable $e) {
$this->lastException = $e;
$this->rollbackAndCleanup();
if (config('app.debug') === true) {
@@ -90,6 +94,11 @@ final class HeaderStorageTransaction
}
}
public function getLastException(): ?\Throwable
{
return $this->lastException;
}
/**
* Perform rollback and cleanup any orphaned data.
*/
+107 -38
View File
@@ -9,34 +9,33 @@ use Illuminate\Support\Facades\Log;
/**
* Handles part record creation during header storage.
*
* @phpstan-type PartRow array{binaries_id: int, number: int|string, messageid: string, partnumber: int, size: int|string}
*/
final class PartHandler
{
/**
* Hard upper bound on rows packed into a single SQL statement
* (multi-row INSERT, OR-clause SELECT). The public chunkSize controls
* when we flush; this constant guarantees the actual SQL we emit is
* never large enough to blow up PHP/MySQL memory.
*/
private const MAX_SQL_ROWS_PER_STATEMENT = 500;
/** @var array<string, mixed> Pending parts to insert */
/** @var list<PartRow> Pending parts to insert */
private array $parts = [];
/** @var array<string, mixed> Part numbers successfully inserted */
/** @var list<int|string> Part numbers successfully inserted */
private array $insertedPartNumbers = [];
/** @var array<string, mixed> Part numbers that failed to insert */
/** @var list<int|string> Part numbers that failed to insert */
private array $failedPartNumbers = [];
/** @var array<int, true> Binary ids whose stored parts must be re-aggregated */
private array $touchedBinaryIds = [];
private int $chunkSize;
private ?\Throwable $lastException = null;
/** @phpstan-ignore property.onlyWritten */
private bool $addToPartRepair;
public function __construct(int $chunkSize = 5000, bool $addToPartRepair = true)
public function __construct(int $chunkSize = 500, bool $addToPartRepair = true)
{
$this->chunkSize = max(100, $chunkSize);
$this->chunkSize = max(1, $chunkSize);
$this->addToPartRepair = $addToPartRepair;
}
@@ -48,6 +47,8 @@ final class PartHandler
$this->parts = [];
$this->insertedPartNumbers = [];
$this->failedPartNumbers = [];
$this->touchedBinaryIds = [];
$this->lastException = null;
}
/**
@@ -66,11 +67,21 @@ final class PartHandler
*/
public function addPart(int $binaryId, array $header): bool
{
$partNumber = (int) ($header['matches'][2] ?? 0);
$messageId = trim((string) ($header['Message-ID'] ?? ''));
if ($partNumber <= 0 || $messageId === '' || strlen($messageId) > 255 || preg_match('/^[\x20-\x7E]+$/D', $messageId) !== 1) {
if (isset($header['Number'])) {
$this->failedPartNumbers[] = $header['Number'];
}
return false;
}
$this->parts[] = [
'binaries_id' => $binaryId,
'number' => $header['Number'],
'messageid' => $header['Message-ID'],
'partnumber' => $header['matches'][2],
'messageid' => $messageId,
'partnumber' => $partNumber,
'size' => $header['Bytes'],
];
@@ -91,10 +102,16 @@ final class PartHandler
return true;
}
$insertedCount = $this->insertChunk($this->parts);
$pendingParts = $this->parts;
$parts = $this->deduplicateParts($pendingParts);
foreach ($parts as $part) {
$this->touchedBinaryIds[(int) $part['binaries_id']] = true;
}
$insertedCount = $this->insertChunk($parts);
if ($insertedCount === null) {
foreach ($this->parts as $part) {
foreach ($pendingParts as $part) {
$this->failedPartNumbers[] = $part['number'];
}
@@ -103,8 +120,8 @@ final class PartHandler
return false;
}
if ($insertedCount === \count($this->parts)) {
foreach ($this->parts as $part) {
if ($insertedCount === \count($parts)) {
foreach ($parts as $part) {
$this->insertedPartNumbers[] = $part['number'];
}
@@ -113,9 +130,9 @@ final class PartHandler
return true;
}
$existingKeys = $this->existingPartKeys($this->parts);
foreach ($this->parts as $part) {
$key = $this->partKey((int) $part['binaries_id'], (int) $part['number']);
$existingKeys = $this->existingPartKeys($parts);
foreach ($parts as $part) {
$key = $this->partKey((int) $part['binaries_id'], (int) $part['partnumber']);
if (! isset($existingKeys[$key])) {
$this->failedPartNumbers[] = $part['number'];
}
@@ -127,7 +144,7 @@ final class PartHandler
}
/**
* @param array<string, mixed> $parts
* @param list<PartRow> $parts
*/
private function insertChunk(array $parts): ?int
{
@@ -135,7 +152,7 @@ final class PartHandler
$totalInserted = 0;
try {
foreach (array_chunk($parts, self::MAX_SQL_ROWS_PER_STATEMENT) as $chunk) {
foreach (array_chunk($parts, $this->chunkSize) as $chunk) {
$placeholders = [];
$bindings = [];
@@ -157,6 +174,7 @@ final class PartHandler
return $totalInserted;
} catch (\Throwable $e) {
$this->lastException = $e;
if (config('app.debug') === true) {
Log::error('Parts chunk insert failed: '.$e->getMessage());
}
@@ -166,7 +184,7 @@ final class PartHandler
}
/**
* @param array<string, mixed> $parts
* @param list<PartRow> $parts
* @return array<string, true>
*/
private function existingPartKeys(array $parts): array
@@ -175,49 +193,95 @@ final class PartHandler
return [];
}
// Deduplicate (binaries_id, number) pairs so we don't bind the same
// Deduplicate (binaries_id, partnumber) pairs so we don't bind the same
// tuple twice when a chunk contains repeats.
$uniquePairs = [];
foreach ($parts as $part) {
$bid = (int) $part['binaries_id'];
$num = (int) $part['number'];
$uniquePairs[$bid.':'.$num] = [$bid, $num];
$partNumber = (int) $part['partnumber'];
$uniquePairs[$bid.':'.$partNumber] = [$bid, $partNumber];
}
$keys = [];
// Single tuple-IN per sub-chunk: (binaries_id, number) IN ((?,?),...).
// Single tuple-IN per sub-chunk: (binaries_id, partnumber) IN ((?,?),...).
// Both MySQL and SQLite (3.15+) support this row-constructor form,
// which lets one SELECT replace the previous "one SELECT per binary".
foreach (array_chunk(array_values($uniquePairs), self::MAX_SQL_ROWS_PER_STATEMENT) as $chunk) {
foreach (array_chunk(array_values($uniquePairs), $this->chunkSize) as $chunk) {
$tuples = implode(',', array_fill(0, \count($chunk), '(?,?)'));
$bindings = [];
foreach ($chunk as [$bid, $num]) {
foreach ($chunk as [$bid, $partNumber]) {
$bindings[] = $bid;
$bindings[] = $num;
$bindings[] = $partNumber;
}
$rows = DB::select(
"SELECT binaries_id, number FROM parts WHERE (binaries_id, number) IN ({$tuples})",
"SELECT binaries_id, partnumber FROM parts WHERE (binaries_id, partnumber) IN ({$tuples})",
$bindings
);
foreach ($rows as $row) {
$keys[$this->partKey((int) $row->binaries_id, (int) $row->number)] = true;
$keys[$this->partKey((int) $row->binaries_id, (int) $row->partnumber)] = true;
}
}
return $keys;
}
private function partKey(int $binaryId, int $number): string
private function partKey(int $binaryId, int $partNumber): string
{
return $binaryId.':'.$number;
return $binaryId.':'.$partNumber;
}
/**
* Choose one deterministic segment for each (binary, part number).
* Prefer a non-empty message id, then the larger payload, then the lower
* article number. This matches the storage migration's duplicate policy.
*
* @param list<PartRow> $parts
* @return list<PartRow>
*/
private function deduplicateParts(array $parts): array
{
$deduplicated = [];
foreach ($parts as $part) {
$key = $this->partKey((int) $part['binaries_id'], (int) $part['partnumber']);
if (! isset($deduplicated[$key]) || $this->shouldPrefer($part, $deduplicated[$key])) {
$deduplicated[$key] = $part;
}
}
return array_values($deduplicated);
}
/**
* @param PartRow $candidate
* @param PartRow $current
*/
private function shouldPrefer(array $candidate, array $current): bool
{
$candidateHasMessageId = trim((string) $candidate['messageid']) !== '';
$currentHasMessageId = trim((string) $current['messageid']) !== '';
if ($candidateHasMessageId !== $currentHasMessageId) {
return $candidateHasMessageId;
}
if ((int) $candidate['size'] !== (int) $current['size']) {
return (int) $candidate['size'] > (int) $current['size'];
}
return (int) $candidate['number'] < (int) $current['number'];
}
/** @return list<int> */
public function getTouchedBinaryIds(): array
{
return array_keys($this->touchedBinaryIds);
}
/**
* Get numbers of successfully inserted parts.
*
* @return array<string, mixed>
* @return list<int|string>
*/
public function getInsertedNumbers(): array
{
@@ -227,13 +291,18 @@ final class PartHandler
/**
* Get numbers of failed part inserts.
*
* @return array<string, mixed>
* @return list<int|string>
*/
public function getFailedNumbers(): array
{
return $this->failedPartNumbers;
}
public function getLastException(): ?\Throwable
{
return $this->lastException;
}
/**
* Check if there are pending parts waiting to be flushed.
*/
+60 -8
View File
@@ -5,14 +5,13 @@ declare(strict_types=1);
namespace App\Services;
use App\Models\Settings;
use App\Services\Binaries\BinariesConfig;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class CollectionCleanupService
{
private const MAX_SQL_ROWS_PER_STATEMENT = 500;
/**
* Maximum number of retries for a lock-related DB error before giving up.
*/
@@ -31,7 +30,14 @@ class CollectionCleanupService
*/
private const LOCK_DRIVER_CODES = [1213, 1205];
public function __construct() {}
private ?bool $cascadeDeleteReady = null;
private readonly BinariesConfig $binariesConfig;
public function __construct(?BinariesConfig $binariesConfig = null)
{
$this->binariesConfig = $binariesConfig ?? BinariesConfig::fromSettings();
}
/**
* Deletes finished/old collections, cleans orphans, and removes collections missed after NZB creation.
@@ -61,7 +67,7 @@ class CollectionCleanupService
$ids = DB::table('collections')
->where('dateadded', '<', $cutoff)
->orderBy('id')
->limit(self::MAX_SQL_ROWS_PER_STATEMENT)
->limit($this->sqlChunkSize())
->pluck('id')
->all();
@@ -72,7 +78,7 @@ class CollectionCleanupService
$affected = $this->deleteCollectionsAndDescendants($ids, 'Cleanup', $echoCLI);
$batchDeleted += $affected;
if ($affected < self::MAX_SQL_ROWS_PER_STATEMENT) {
if ($affected < $this->sqlChunkSize()) {
break;
}
// Brief pause to reduce pressure on the lock manager in busy systems.
@@ -147,7 +153,7 @@ class CollectionCleanupService
{
$deleted = 0;
$maxBatches = 20; // hard cap per cycle; bounded backlog drain
$batchSize = self::MAX_SQL_ROWS_PER_STATEMENT;
$batchSize = $this->sqlChunkSize();
for ($i = 0; $i < $maxBatches; $i++) {
$ids = DB::table('collections as c')
@@ -196,7 +202,7 @@ class CollectionCleanupService
{
$deleted = 0;
$maxBatches = 20;
$batchSize = self::MAX_SQL_ROWS_PER_STATEMENT;
$batchSize = $this->sqlChunkSize();
for ($i = 0; $i < $maxBatches; $i++) {
$ids = DB::table('collections as c')
@@ -240,11 +246,18 @@ class CollectionCleanupService
$deletedCollections = 0;
foreach (array_chunk($collectionIds, self::MAX_SQL_ROWS_PER_STATEMENT) as $chunk) {
foreach (array_chunk($collectionIds, $this->sqlChunkSize()) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$deletedCollections += $this->retryOnLockError(
fn (): int => DB::transaction(
function () use ($chunk, $placeholders): int {
if ($this->cascadeDeleteReady()) {
return (int) DB::affectingStatement(
"DELETE FROM collections WHERE id IN ({$placeholders})",
$chunk
);
}
DB::statement(
"DELETE FROM parts WHERE binaries_id IN (SELECT id FROM binaries WHERE collections_id IN ({$placeholders}))",
$chunk
@@ -268,6 +281,45 @@ class CollectionCleanupService
return $deletedCollections;
}
private function sqlChunkSize(): int
{
return $this->binariesConfig->sqlChunkSize;
}
/**
* Production deletes can safely target only the parent table when both
* descendant foreign keys cascade. SQLite fixtures and incomplete legacy
* schemas deliberately retain the explicit descendant-delete fallback.
*/
private function cascadeDeleteReady(): bool
{
if ($this->cascadeDeleteReady !== null) {
return $this->cascadeDeleteReady;
}
if (DB::getDriverName() === 'sqlite') {
return $this->cascadeDeleteReady = false;
}
try {
$rows = DB::select(
"SELECT TABLE_NAME, DELETE_RULE
FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME IN ('binaries', 'parts')"
);
$cascades = [];
foreach ($rows as $row) {
if (strtoupper((string) $row->DELETE_RULE) === 'CASCADE') {
$cascades[(string) $row->TABLE_NAME] = true;
}
}
return $this->cascadeDeleteReady = isset($cascades['binaries'], $cascades['parts']);
} catch (\Throwable) {
return $this->cascadeDeleteReady = false;
}
}
/**
* Run a DB write inside a bounded retry loop that only swallows transient
* InnoDB lock errors (deadlock 1213, lock wait timeout 1205). Any other
+144 -67
View File
@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Services\Nzb;
use App\Models\Binary;
use App\Models\Collection;
use App\Models\Part;
use App\Models\Release;
use App\Models\Settings;
use App\Services\Binaries\BinariesConfig;
use App\Services\CollectionCleanupService;
use App\Support\Data\NzbCreationResult;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Throwable;
@@ -62,7 +62,9 @@ class NzbService
public function __construct(
private readonly CollectionCleanupService $collectionCleanupService,
?BinariesConfig $binariesConfig = null,
) {
$this->binariesConfig = $binariesConfig ?? BinariesConfig::fromSettings();
try {
$nzbSplitLevel = (int) Settings::settingValue('nzbsplitlevel');
} catch (QueryException $e) {
@@ -82,6 +84,8 @@ class NzbService
);
}
private readonly BinariesConfig $binariesConfig;
/**
* Write an NZB file for a release.
*
@@ -98,7 +102,9 @@ class NzbService
$collections = Collection::whereReleasesId($release->id)
->join('usenet_groups', 'collections.groups_id', '=', 'usenet_groups.id')
->select(['collections.*', DB::raw('UNIX_TIMESTAMP(collections.date) AS udate'), 'usenet_groups.name as groupname'])
->get();
->orderBy('collections.id')
->get()
->keyBy('id');
} catch (Throwable $e) {
return NzbCreationResult::transient('Failed to load release collections: '.$e->getMessage());
}
@@ -107,29 +113,30 @@ class NzbService
return NzbCreationResult::deterministic('Release has no collections to write into an NZB.');
}
// Pre-load every binary and part for the release in two flat queries
// and group them in PHP, instead of issuing one binaries SELECT per
// collection and one parts SELECT per binary (1 + N + N*M queries).
$collectionIds = $collections->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
try {
$binariesByCollection = Binary::query()
->whereIn('collections_id', $collectionIds)
->orderBy('name')
->get(['id', 'collections_id', 'name', 'totalparts'])
->groupBy('collections_id');
$emptyCollection = DB::selectOne(
'SELECT c.id FROM collections c
LEFT JOIN binaries b ON b.collections_id = c.id
WHERE c.releases_id = ? GROUP BY c.id HAVING COUNT(b.id) = 0 LIMIT 1',
[$release->id]
);
if ($emptyCollection !== null) {
return NzbCreationResult::deterministic("Collection {$emptyCollection->id} has no binaries.", $collectionIds);
}
$allBinaryIds = $binariesByCollection->flatten(1)->pluck('id')->all();
$partsByBinary = $allBinaryIds === []
? collect()
: Part::query()
->whereIn('binaries_id', $allBinaryIds)
// distinct() preserves the dedup the previous per-binary
// query had, in case two rows share (messageid, size,
// partnumber) for the same binary (defensive).
->distinct()
->orderBy('partnumber')
->get(['binaries_id', 'messageid', 'size', 'partnumber'])
->groupBy('binaries_id');
$emptyBinary = DB::selectOne(
'SELECT b.id FROM binaries b
INNER JOIN collections c ON c.id = b.collections_id
LEFT JOIN parts p ON p.binaries_id = b.id
WHERE c.releases_id = ? GROUP BY b.id HAVING COUNT(p.partnumber) = 0 LIMIT 1',
[$release->id]
);
if ($emptyBinary !== null) {
return NzbCreationResult::deterministic("Binary {$emptyBinary->id} has no parts.", $collectionIds);
}
$groupsByCollection = $this->loadCollectionGroups((int) $release->id, $collections);
} catch (Throwable $e) {
return NzbCreationResult::transient('Failed to load NZB binaries or parts: '.$e->getMessage(), $collectionIds);
}
@@ -173,55 +180,64 @@ class NzbService
return NzbCreationResult::transient("Failed to write NZB header to temporary file: {$tempPath}", $collectionIds, $path);
}
foreach ($collections as $collection) {
$binaries = $binariesByCollection->get($collection->id);
if ($binaries === null || $binaries->isEmpty()) {
return NzbCreationResult::deterministic("Collection {$collection->id} has no binaries.", $collectionIds, $path);
}
$groups = $this->groupsFromXref((string) $collection->xref);
if ($groups === []) {
return NzbCreationResult::deterministic("Collection {$collection->id} has no valid xref groups.", $collectionIds, $path);
}
$poster = $collection->fromname;
foreach ($binaries as $binary) {
$parts = $partsByBinary->get($binary->id);
if ($parts === null || $parts->isEmpty()) {
return NzbCreationResult::deterministic("Binary {$binary->id} has no parts.", $collectionIds, $path);
}
$subject = $this->buildBinarySubject($binary->name, $binary->totalparts);
$XMLWriter->startElement('file');
$XMLWriter->writeAttribute('poster', (string) $poster);
$XMLWriter->writeAttribute('date', (string) $collection->udate);
$XMLWriter->writeAttribute('subject', (string) $subject);
$XMLWriter->startElement('groups');
foreach ($groups as $group) {
$XMLWriter->writeElement('group', $group);
}
$XMLWriter->endElement(); // groups
$XMLWriter->startElement('segments');
foreach ($parts as $part) {
$messageId = $this->normalizeSegmentMessageId($part->messageid);
if ($messageId === '') {
return NzbCreationResult::deterministic("Part {$part->partnumber} for binary {$binary->id} has an empty message ID.", $collectionIds, $path);
$cursor = ['collection_id' => 0, 'name' => '', 'binary_id' => 0, 'partnumber' => 0];
$openBinaryId = 0;
do {
$page = $this->loadNzbRowPage((int) $release->id, $cursor);
foreach ($page as $row) {
$binaryId = (int) $row->binary_id;
if ($binaryId !== $openBinaryId) {
if ($openBinaryId > 0) {
$XMLWriter->endElement(); // segments
$XMLWriter->endElement(); // file
if (! $this->flushXmlWriter($XMLWriter, $gz)) {
return NzbCreationResult::transient("Failed to write NZB file entry to temporary file: {$tempPath}", $collectionIds, $path);
}
}
$XMLWriter->startElement('segment');
$XMLWriter->writeAttribute('bytes', (string) $part->size);
$XMLWriter->writeAttribute('number', (string) $part->partnumber);
$XMLWriter->text($messageId);
$XMLWriter->endElement();
}
$XMLWriter->endElement(); // segments
$XMLWriter->endElement(); // file
$collection = $collections->get((int) $row->collection_id);
$groups = $groupsByCollection[(int) $row->collection_id] ?? [];
if ($collection === null || $groups === []) {
return NzbCreationResult::deterministic("Collection {$row->collection_id} has no valid cross-post groups.", $collectionIds, $path);
}
if (! $this->flushXmlWriter($XMLWriter, $gz)) {
return NzbCreationResult::transient("Failed to write NZB file entry to temporary file: {$tempPath}", $collectionIds, $path);
$subject = $this->buildBinarySubject((string) $row->binary_name, (int) $row->totalparts);
$XMLWriter->startElement('file');
$XMLWriter->writeAttribute('poster', (string) $collection->fromname);
$XMLWriter->writeAttribute('date', (string) $collection->udate);
$XMLWriter->writeAttribute('subject', (string) $subject);
$XMLWriter->startElement('groups');
foreach ($groups as $group) {
$XMLWriter->writeElement('group', $group);
}
$XMLWriter->endElement(); // groups
$XMLWriter->startElement('segments');
$openBinaryId = $binaryId;
}
$messageId = $this->normalizeSegmentMessageId($row->messageid);
if ($messageId === '') {
return NzbCreationResult::deterministic("Part {$row->partnumber} for binary {$binaryId} has an empty message ID.", $collectionIds, $path);
}
$XMLWriter->startElement('segment');
$XMLWriter->writeAttribute('bytes', (string) $row->size);
$XMLWriter->writeAttribute('number', (string) $row->partnumber);
$XMLWriter->text($messageId);
$XMLWriter->endElement();
$cursor = [
'collection_id' => (int) $row->collection_id,
'name' => (string) $row->binary_name,
'binary_id' => $binaryId,
'partnumber' => (int) $row->partnumber,
];
}
} while (\count($page) === $this->binariesConfig->nzbStreamRows);
if ($openBinaryId > 0) {
$XMLWriter->endElement(); // segments
$XMLWriter->endElement(); // file
}
$XMLWriter->writeComment($this->siteCommentString);
@@ -279,6 +295,67 @@ class NzbService
return NzbCreationResult::success($path, $collectionIds);
}
/**
* @param \Illuminate\Support\Collection<int|string, mixed> $collections
* @return array<int, list<string>>
*/
private function loadCollectionGroups(int $releaseId, \Illuminate\Support\Collection $collections): array
{
$groups = [];
if (Schema::hasTable('collection_groups')) {
$rows = DB::select(
'SELECT cg.collections_id, cg.group_name
FROM collection_groups cg INNER JOIN collections c ON c.id = cg.collections_id
WHERE c.releases_id = ? ORDER BY cg.collections_id, cg.group_name',
[$releaseId]
);
foreach ($rows as $row) {
$groups[(int) $row->collections_id][] = (string) $row->group_name;
}
}
foreach ($collections as $collection) {
$collectionId = (int) $collection->id;
if (($groups[$collectionId] ?? []) === []) {
$groups[$collectionId] = $this->groupsFromXref((string) $collection->xref);
}
}
return $groups;
}
/**
* @param array{collection_id: int, name: string, binary_id: int, partnumber: int} $cursor
* @return list<object>
*/
private function loadNzbRowPage(int $releaseId, array $cursor): array
{
$limit = $this->binariesConfig->nzbStreamRows;
return DB::select(
'SELECT b.collections_id AS collection_id, b.id AS binary_id, b.name AS binary_name,
b.totalparts, p.messageid, p.size, p.partnumber
FROM binaries b
INNER JOIN collections c ON c.id = b.collections_id
INNER JOIN parts p ON p.binaries_id = b.id
WHERE c.releases_id = ? AND (
b.collections_id > ? OR
(b.collections_id = ? AND b.name > ?) OR
(b.collections_id = ? AND b.name = ? AND b.id > ?) OR
(b.collections_id = ? AND b.name = ? AND b.id = ? AND p.partnumber > ?)
)
ORDER BY b.collections_id, b.name, b.id, p.partnumber
LIMIT '.$limit,
[
$releaseId,
$cursor['collection_id'],
$cursor['collection_id'], $cursor['name'],
$cursor['collection_id'], $cursor['name'], $cursor['binary_id'],
$cursor['collection_id'], $cursor['name'], $cursor['binary_id'], $cursor['partnumber'],
]
);
}
/**
* Build a folder path on the hard drive where the NZB file will be stored.
*
+1 -1
View File
@@ -181,7 +181,7 @@ class RegexService
$rows = DB::select(
'SELECT
b.name, b.totalparts, b.currentparts, HEX(b.binaryhash) AS binaryhash,
c.fromname, c.collectionhash
c.fromname, HEX(c.collectionhash) AS collectionhash
FROM binaries b
INNER JOIN collections c ON c.id = b.collections_id'
);
+79 -33
View File
@@ -10,7 +10,6 @@ use App\Models\Collection;
use App\Models\Predb;
use App\Models\Release;
use App\Models\ReleaseRegex;
use App\Models\ReleasesGroups;
use App\Models\UsenetGroup;
use App\Services\Categorization\CategorizationService;
use App\Services\Nzb\NzbService;
@@ -18,6 +17,7 @@ use App\Services\Releases\ReleaseDuplicateFinder;
use App\Support\Utf8;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class ReleaseCreationService
@@ -56,6 +56,7 @@ class ReleaseCreationService
->join('usenet_groups', 'usenet_groups.id', '=', 'collections.groups_id')
->limit($limit);
$collections = $collectionsQuery->get();
$releaseGroupIds = $this->loadReleaseGroupIds($collections);
if ($echoCLI && $collections->count() > 0) {
cli()->primary(\count($collections).' Collections ready to be converted to releases.', true);
@@ -137,38 +138,12 @@ class ReleaseCreationService
'naming_regex_id' => $namingRegexId,
]);
if (preg_match_all('#(\S+):\S+#', $collection->xref, $hits)) {
foreach ($hits[1] as $grp) {
$grpTmp = UsenetGroup::isValidGroup($grp);
if ($grpTmp !== false) {
$xrefGrpID = UsenetGroup::getIDByName($grpTmp);
if ($xrefGrpID === '') {
$xrefGrpID = UsenetGroup::addGroup([
'name' => $grpTmp,
'description' => 'Added by Release processing',
'backfill_target' => 1,
'first_record' => 0,
'last_record' => 0,
'active' => 0,
'backfill' => 0,
'minfilestoformrelease' => '',
'minsizetoformrelease' => '',
]);
}
$relGroupsChk = ReleasesGroups::query()->where([
['releases_id', '=', $releaseID],
['groups_id', '=', $xrefGrpID],
])->first();
if ($relGroupsChk === null) {
ReleasesGroups::query()->insert([
'releases_id' => $releaseID,
'groups_id' => $xrefGrpID,
]);
}
}
}
$groupRows = [];
foreach ($releaseGroupIds[(int) $collection->id] ?? [] as $groupId) {
$groupRows[] = ['releases_id' => $releaseID, 'groups_id' => $groupId];
}
if ($groupRows !== []) {
DB::table('releases_groups')->insertOrIgnore($groupRows);
}
$returnCount++;
@@ -215,4 +190,75 @@ class ReleaseCreationService
return ['added' => $returnCount, 'dupes' => $duplicate];
}
/**
* Resolve all normalized cross-post groups for the selected collection
* batch up front, avoiding release/group membership N+1 queries.
*
* @param \Illuminate\Support\Collection<int, Collection> $collections
* @return array<int, list<int>>
*/
private function loadReleaseGroupIds(\Illuminate\Support\Collection $collections): array
{
$namesByCollection = [];
$collectionIds = $collections->pluck('id')->map(static fn ($id): int => (int) $id)->all();
if (Schema::hasTable('collection_groups')) {
foreach (array_chunk($collectionIds, 500) as $chunk) {
foreach (DB::table('collection_groups')->whereIn('collections_id', $chunk)->get() as $row) {
$namesByCollection[(int) $row->collections_id][(string) $row->group_name] = true;
}
}
}
foreach ($collections as $collection) {
$collectionId = (int) $collection->id;
if (($namesByCollection[$collectionId] ?? []) === []) {
$fallback = (new XrefService)->extractGroupNames((string) $collection->xref);
if ($fallback === []) {
$fallback = [(string) $collection->gname];
}
foreach ($fallback as $name) {
$namesByCollection[$collectionId][$name] = true;
}
}
}
$allNames = [];
foreach ($namesByCollection as $names) {
$allNames += $names;
}
$idsByName = UsenetGroup::query()
->whereIn('name', array_keys($allNames))
->pluck('id', 'name')
->map(static fn ($id): int => (int) $id)
->all();
foreach (array_keys($allNames) as $name) {
$validName = UsenetGroup::isValidGroup($name);
if ($validName === false || isset($idsByName[$validName])) {
continue;
}
$idsByName[$validName] = (int) UsenetGroup::addGroup([
'name' => $validName,
'description' => 'Added by Release processing',
'backfill_target' => 1,
'first_record' => 0,
'last_record' => 0,
'active' => 0,
'backfill' => 0,
'minfilestoformrelease' => '',
'minsizetoformrelease' => '',
]);
}
$resolved = [];
foreach ($namesByCollection as $collectionId => $names) {
foreach (array_keys($names) as $name) {
if (isset($idsByName[$name])) {
$resolved[$collectionId][] = $idsByName[$name];
}
}
}
return $resolved;
}
}
+242 -303
View File
@@ -5,13 +5,13 @@ declare(strict_types=1);
namespace App\Services;
use App\Enums\CollectionFileCheckStatus;
use App\Enums\FileCompletionStatus;
use App\Models\Category;
use App\Models\Collection;
use App\Models\MusicInfo;
use App\Models\Release;
use App\Models\Settings;
use App\Models\UsenetGroup;
use App\Services\Binaries\BinariesConfig;
use App\Services\Categorization\CategorizationService;
use App\Services\NNTP\NNTPService;
use App\Services\Nzb\NzbCreationCandidateQuery;
@@ -25,10 +25,11 @@ use App\Support\Data\ReleaseCreationResult;
use App\Support\Data\ReleaseDeleteStats;
use App\Support\ReleaseSearchIndexSync;
use DateTimeInterface;
use Illuminate\Database\QueryException;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Throwable;
/**
@@ -74,6 +75,8 @@ final class ReleaseProcessingService
private readonly ?PostProcessService $postProcessService;
private readonly BinariesConfig $binariesConfig;
public function __construct(
?NzbService $nzb = null,
?ReleaseCleaningService $releaseCleaning = null,
@@ -82,6 +85,7 @@ final class ReleaseProcessingService
?ReleaseCreationService $releaseCreationService = null,
?CollectionCleanupService $collectionCleanupService = null,
?PostProcessService $postProcessService = null,
?BinariesConfig $binariesConfig = null,
) {
$this->echoCLI = (bool) config('nntmux.echocli');
@@ -99,6 +103,7 @@ final class ReleaseProcessingService
app(ReleaseDuplicateFinder::class)
);
$this->postProcessService = $postProcessService;
$this->binariesConfig = $binariesConfig ?? BinariesConfig::fromSettings();
$this->settings = $this->loadSettings();
$this->validateSettings();
@@ -368,15 +373,8 @@ final class ReleaseProcessingService
$this->outputSubHeader('Finding Complete Collections');
$normalizedGroupId = $this->normalizeGroupId($groupID);
$whereSql = $this->buildGroupWhereSql($normalizedGroupId, 'c');
$this->processStuckCollections($normalizedGroupId ?? 0);
$this->runCollectionFileCheckStage1($normalizedGroupId ?? 0);
$this->runCollectionFileCheckStage2($normalizedGroupId ?? 0);
$this->runCollectionFileCheckStage3($whereSql);
$this->runCollectionFileCheckStage4($whereSql);
$this->runCollectionFileCheckStage5($normalizedGroupId ?? 0);
$this->runCollectionFileCheckStage6($whereSql);
$this->reconcileIncompleteCollections($normalizedGroupId);
$count = $this->countCompleteCollections($normalizedGroupId);
$this->outputStat('Complete collections found', $count);
@@ -394,34 +392,192 @@ final class ReleaseProcessingService
$this->outputSubHeader('Calculating Collection Sizes');
$updated = 0;
DB::transaction(function () use ($groupID, &$updated): void {
$normalizedGroupId = $this->normalizeGroupId($groupID);
$whereSql = $normalizedGroupId !== null
? " AND c.groups_id = {$normalizedGroupId} "
: ' ';
$sql = <<<SQL
UPDATE collections c
SET c.filesize = (
SELECT COALESCE(SUM(b.partsize), 0)
FROM binaries b
WHERE b.collections_id = c.id
),
c.filecheck = ?
WHERE c.filecheck = ?
AND c.filesize = 0{$whereSql}
SQL;
$updated = DB::update($sql, [
CollectionFileCheckStatus::Sized->value,
CollectionFileCheckStatus::CompleteParts->value,
$lastId = 0;
$normalizedGroupId = $this->normalizeGroupId($groupID);
do {
$query = Collection::query()
->where('id', '>', $lastId)
->where('filecheck', CollectionFileCheckStatus::CompleteParts->value)
->when($normalizedGroupId !== null, static fn ($q) => $q->where('groups_id', $normalizedGroupId))
->orderBy('id')
->limit($this->binariesConfig->reconcileBatchSize);
$ids = $query->pluck('id')->map(static fn ($id): int => (int) $id)->all();
if ($ids === []) {
break;
}
$lastId = (int) end($ids);
$updated += Collection::query()->whereIn('id', $ids)->update([
'filecheck' => CollectionFileCheckStatus::Sized->value,
]);
}, 10);
} while (\count($ids) === $this->binariesConfig->reconcileBatchSize);
$this->outputStat('Collections sized', $updated);
$this->outputElapsedTime($startTime);
}
/**
* Reconcile only a bounded keyset page at a time. Stored parts are the
* authority for binary counts/sizes; binary aggregates are then the
* authority for collection readiness and filesize.
*/
private function reconcileIncompleteCollections(?int $groupId): void
{
$lastId = 0;
$statuses = [
CollectionFileCheckStatus::Default->value,
CollectionFileCheckStatus::CompleteCollection->value,
CollectionFileCheckStatus::CompleteParts->value,
CollectionFileCheckStatus::TempComplete->value,
CollectionFileCheckStatus::ZeroPart->value,
10,
];
do {
$query = Collection::query()
->where('id', '>', $lastId)
->where(function ($query) use ($statuses): void {
$query->where('filecheck', CollectionFileCheckStatus::CompleteParts->value);
if (Schema::hasColumn('collections', 'last_seen_at')) {
$query->orWhere(function ($stale) use ($statuses): void {
$stale->whereIn('filecheck', array_values(array_diff(
$statuses,
[CollectionFileCheckStatus::CompleteParts->value]
)))->whereRaw(
'COALESCE(last_seen_at, dateadded, added) < ?',
[now()->subHours($this->settings->collectionDelayTime)]
);
});
} else {
$query->orWhereIn('filecheck', array_values(array_diff(
$statuses,
[CollectionFileCheckStatus::CompleteParts->value]
)));
}
})
->when($groupId !== null, static fn ($q) => $q->where('groups_id', $groupId))
->orderBy('id')
->limit($this->binariesConfig->reconcileBatchSize);
$ids = $query->pluck('id')->map(static fn ($id): int => (int) $id)->all();
if ($ids === []) {
break;
}
$lastId = (int) end($ids);
$this->reconcileCollectionIds($ids, $statuses);
} while (\count($ids) === $this->binariesConfig->reconcileBatchSize);
}
/**
* @param list<int> $collectionIds
* @param list<int> $statuses
*/
private function reconcileCollectionIds(array $collectionIds, array $statuses): void
{
if (DB::getDriverName() === 'sqlite') {
$this->reconcileCollectionIdsSqlite($collectionIds, $statuses);
return;
}
$idPlaceholders = implode(',', array_fill(0, \count($collectionIds), '?'));
$statusPlaceholders = implode(',', array_fill(0, \count($statuses), '?'));
DB::transaction(function () use ($collectionIds, $idPlaceholders, $statuses, $statusPlaceholders): void {
DB::update(
"UPDATE binaries b
LEFT JOIN (
SELECT p.binaries_id, COUNT(*) currentparts, COALESCE(SUM(p.size), 0) partsize
FROM parts p INNER JOIN binaries selected ON selected.id = p.binaries_id
WHERE selected.collections_id IN ({$idPlaceholders}) GROUP BY p.binaries_id
) p ON p.binaries_id = b.id
SET b.currentparts = COALESCE(p.currentparts, 0),
b.partsize = COALESCE(p.partsize, 0),
b.partcheck = CASE WHEN COALESCE(p.currentparts, 0) >= b.totalparts THEN 1 ELSE 0 END
WHERE b.collections_id IN ({$idPlaceholders})",
[...$collectionIds, ...$collectionIds]
);
DB::update(
"UPDATE collections c
LEFT JOIN (
SELECT b.collections_id, COUNT(*) currentfiles,
COALESCE(SUM(CASE WHEN b.partcheck = 1 THEN 1 ELSE 0 END), 0) completefiles,
COALESCE(SUM(b.partsize), 0) filesize
FROM binaries b WHERE b.collections_id IN ({$idPlaceholders}) GROUP BY b.collections_id
) a ON a.collections_id = c.id
SET c.filesize = COALESCE(a.filesize, 0),
c.totalfiles = CASE
WHEN c.dateadded < DATE_SUB(NOW(), INTERVAL ? HOUR)
AND c.filecheck IN (0, 1, 10)
THEN COALESCE(a.currentfiles, 0) ELSE c.totalfiles END,
c.filecheck = CASE
WHEN c.totalfiles > 0
AND COALESCE(a.currentfiles, 0) IN (c.totalfiles, c.totalfiles + 1)
AND COALESCE(a.completefiles, 0) >= c.totalfiles THEN ?
WHEN c.dateadded < DATE_SUB(NOW(), INTERVAL ? HOUR)
AND c.filecheck IN (0, 1, 10) THEN ?
ELSE c.filecheck END
WHERE c.id IN ({$idPlaceholders}) AND c.filecheck IN ({$statusPlaceholders})",
[
...$collectionIds,
$this->settings->collectionDelayTime,
CollectionFileCheckStatus::CompleteParts->value,
$this->settings->collectionDelayTime,
CollectionFileCheckStatus::CompleteParts->value,
...$collectionIds,
...$statuses,
]
);
}, self::MAX_RETRIES);
}
/**
* @param list<int> $collectionIds
* @param list<int> $statuses
*/
private function reconcileCollectionIdsSqlite(array $collectionIds, array $statuses): void
{
DB::transaction(function () use ($collectionIds, $statuses): void {
foreach ($collectionIds as $collectionId) {
$binaryIds = DB::table('binaries')->where('collections_id', $collectionId)->pluck('id')->all();
foreach ($binaryIds as $binaryId) {
$aggregate = DB::selectOne(
'SELECT COUNT(*) currentparts, COALESCE(SUM(size), 0) partsize FROM parts WHERE binaries_id = ?',
[$binaryId]
);
DB::update(
'UPDATE binaries SET currentparts = ?, partsize = ?, partcheck = CASE WHEN ? >= totalparts THEN 1 ELSE 0 END WHERE id = ?',
[(int) $aggregate->currentparts, (int) $aggregate->partsize, (int) $aggregate->currentparts, $binaryId]
);
}
$collection = DB::table('collections')->where('id', $collectionId)->first();
if ($collection === null || ! \in_array((int) $collection->filecheck, $statuses, true)) {
continue;
}
$aggregate = DB::selectOne(
'SELECT COUNT(*) currentfiles,
COALESCE(SUM(CASE WHEN partcheck = 1 THEN 1 ELSE 0 END), 0) completefiles,
COALESCE(SUM(partsize), 0) filesize
FROM binaries WHERE collections_id = ?',
[$collectionId]
);
$stale = strtotime((string) $collection->dateadded) < now()->subHours($this->settings->collectionDelayTime)->timestamp
&& \in_array((int) $collection->filecheck, [0, 1, 10], true);
$totalFiles = $stale ? (int) $aggregate->currentfiles : (int) $collection->totalfiles;
$ready = $totalFiles > 0
&& \in_array((int) $aggregate->currentfiles, [$totalFiles, $totalFiles + 1], true)
&& (int) $aggregate->completefiles >= $totalFiles;
DB::table('collections')->where('id', $collectionId)->update([
'filesize' => (int) $aggregate->filesize,
'totalfiles' => $totalFiles,
'filecheck' => $ready || $stale
? CollectionFileCheckStatus::CompleteParts->value
: (int) $collection->filecheck,
]);
}
}, self::MAX_RETRIES);
}
/**
* Delete collections that don't meet size/file count requirements.
*
@@ -445,24 +601,18 @@ final class ReleaseProcessingService
->where('c.filecheck', CollectionFileCheckStatus::Sized->value)
->where('c.filesize', '>', 0)
->groupBy('c.id')
->havingRaw("COUNT(b.id) = SUM(CASE WHEN b.name REGEXP '\\\\.(vol[0-9]+\\\\+[0-9]+\\\\.par2|par2)' THEN 1 ELSE 0 END)")
->pluck('c.id')
->all();
if ($par2OnlyCollectionIds !== []) {
$stats['par2Only'] += $this->collectionCleanupService->deleteCollectionsAndDescendants(
$par2OnlyCollectionIds,
'Par2-only cleanup',
$this->echoCLI
);
->havingRaw("COUNT(b.id) = SUM(CASE WHEN b.name REGEXP '\\\\.(vol[0-9]+\\\\+[0-9]+\\\\.par2|par2)' THEN 1 ELSE 0 END)");
if ($normalizedGroupId !== null) {
$par2OnlyCollectionIds->where('c.groups_id', $normalizedGroupId);
}
$stats['par2Only'] += $this->deleteCollectionQueryInBatches($par2OnlyCollectionIds, 'Par2-only cleanup');
foreach ($groupIDs as $grpID) {
$groupSettings = UsenetGroup::getGroupByID($grpID['id']);
$groupMinSize = (int) ($groupSettings['minsizetoformrelease'] ?? 0);
$groupMinFiles = (int) ($groupSettings['minfilestoformrelease'] ?? 0);
if (! $this->hasSizedCollections()) {
if (! $this->hasSizedCollections((int) $grpID['id'])) {
continue;
}
@@ -470,43 +620,28 @@ final class ReleaseProcessingService
if ($effectiveMinSize > 0) {
$ids = Collection::query()
->where('filecheck', CollectionFileCheckStatus::Sized->value)
->where('groups_id', (int) $grpID['id'])
->where('filesize', '>', 0)
->where('filesize', '<', $effectiveMinSize)
->pluck('id')
->all();
$stats['minSize'] += $this->collectionCleanupService->deleteCollectionsAndDescendants(
$ids,
'Min-size cleanup',
$this->echoCLI
);
->where('filesize', '<', $effectiveMinSize);
$stats['minSize'] += $this->deleteCollectionQueryInBatches($ids, 'Min-size cleanup');
}
if ($this->settings->maxSizeToFormRelease > 0) {
$ids = Collection::query()
->where('filecheck', CollectionFileCheckStatus::Sized->value)
->where('filesize', '>', $this->settings->maxSizeToFormRelease)
->pluck('id')
->all();
$stats['maxSize'] += $this->collectionCleanupService->deleteCollectionsAndDescendants(
$ids,
'Max-size cleanup',
$this->echoCLI
);
->where('groups_id', (int) $grpID['id'])
->where('filesize', '>', $this->settings->maxSizeToFormRelease);
$stats['maxSize'] += $this->deleteCollectionQueryInBatches($ids, 'Max-size cleanup');
}
$effectiveMinFiles = max($groupMinFiles, $this->settings->minFilesToFormRelease);
if ($effectiveMinFiles > 0) {
$ids = Collection::query()
->where('filecheck', CollectionFileCheckStatus::Sized->value)
->where('groups_id', (int) $grpID['id'])
->where('filesize', '>', 0)
->where('totalfiles', '<', $effectiveMinFiles)
->pluck('id')
->all();
$stats['minFiles'] += $this->collectionCleanupService->deleteCollectionsAndDescendants(
$ids,
'Min-files cleanup',
$this->echoCLI
);
->where('totalfiles', '<', $effectiveMinFiles);
$stats['minFiles'] += $this->deleteCollectionQueryInBatches($ids, 'Min-files cleanup');
}
}
@@ -823,14 +958,41 @@ final class ReleaseProcessingService
return $query->count('id');
}
private function hasSizedCollections(): bool
private function hasSizedCollections(int $groupId): bool
{
return Collection::query()
->where('filecheck', CollectionFileCheckStatus::Sized->value)
->where('groups_id', $groupId)
->where('filesize', '>', 0)
->exists();
}
/** @param Builder|\Illuminate\Database\Eloquent\Builder<Collection> $query */
private function deleteCollectionQueryInBatches(
Builder|\Illuminate\Database\Eloquent\Builder $query,
string $label,
): int {
$deleted = 0;
$idColumn = $query instanceof \Illuminate\Database\Eloquent\Builder ? 'id' : 'c.id';
do {
$ids = (clone $query)->orderBy($idColumn)->limit(self::BATCH_SIZE)->pluck($idColumn)->all();
if ($ids === []) {
break;
}
$affected = $this->collectionCleanupService->deleteCollectionsAndDescendants(
array_map('intval', $ids),
$label,
$this->echoCLI
);
$deleted += $affected;
if ($affected < self::BATCH_SIZE) {
break;
}
} while (true);
return $deleted;
}
private function normalizeGroupId(int|string|null $groupID): ?int
{
if ($groupID === null || $groupID === '') {
@@ -846,240 +1008,6 @@ final class ReleaseProcessingService
return $groupInfo !== null ? (int) $groupInfo['id'] : null;
}
private function buildGroupWhereSql(?int $groupID, string $alias = 'c'): string
{
return $groupID !== null ? " AND {$alias}.groups_id = {$groupID} " : ' ';
}
// ========================================================================
// Collection Processing Stages
// ========================================================================
/**
* @throws Throwable
*/
private function runCollectionFileCheckStage1(int $groupID): void
{
DB::transaction(static function () use ($groupID): void {
$collectionsQuery = Collection::query()
->select(['collections.id'])
->join('binaries', 'binaries.collections_id', '=', 'collections.id')
->where('collections.totalfiles', '>', 0)
->where('collections.filecheck', '=', CollectionFileCheckStatus::Default->value)
->groupBy(['binaries.collections_id', 'collections.totalfiles', 'collections.id'])
->havingRaw('COUNT(binaries.id) IN (collections.totalfiles, collections.totalfiles + 1)');
if ($groupID !== 0) {
$collectionsQuery->where('collections.groups_id', $groupID);
}
Collection::query()
->joinSub($collectionsQuery, 'r', static fn ($join) => $join->on('collections.id', '=', 'r.id'))
->update(['collections.filecheck' => CollectionFileCheckStatus::CompleteCollection->value]);
}, 10);
}
/**
* @throws Throwable
*/
private function runCollectionFileCheckStage2(int $groupID): void
{
DB::transaction(static function () use ($groupID): void {
$collectionsQuery = Collection::query()
->select(['collections.id'])
->join('binaries', 'binaries.collections_id', '=', 'collections.id')
->where('binaries.filenumber', '=', 0)
->where('collections.totalfiles', '>', 0)
->where('collections.filecheck', '=', CollectionFileCheckStatus::CompleteCollection->value)
->groupBy(['collections.id']);
if ($groupID !== 0) {
$collectionsQuery->where('collections.groups_id', $groupID);
}
Collection::query()
->joinSub($collectionsQuery, 'r', static fn ($join) => $join->on('collections.id', '=', 'r.id'))
->update(['collections.filecheck' => CollectionFileCheckStatus::ZeroPart->value]);
}, 10);
$this->updateCollectionsFilecheckInChunks(
$groupID,
CollectionFileCheckStatus::CompleteCollection->value,
CollectionFileCheckStatus::TempComplete->value
);
}
/**
* Update collections filecheck in small chunks to reduce deadlock risk.
* Retries on deadlock (1213) with exponential backoff.
*
* @throws Throwable
*/
private function updateCollectionsFilecheckInChunks(int $groupID, int $fromStatus, int $toStatus): void
{
$attempt = 0;
$maxAttempts = self::MAX_RETRIES + 1;
while ($attempt < $maxAttempts) {
try {
$updated = 0;
do {
$ids = Collection::query()
->where('filecheck', $fromStatus)
->when($groupID !== 0, static fn ($q) => $q->where('groups_id', $groupID))
->orderBy('id')
->limit(self::BATCH_SIZE)
->pluck('id')
->all();
if ($ids === []) {
break;
}
DB::transaction(static function () use ($ids, $toStatus): void {
Collection::query()
->whereIn('id', $ids)
->update(['filecheck' => $toStatus]);
}, 10);
$updated = \count($ids);
} while ($updated === self::BATCH_SIZE);
return;
} catch (QueryException $e) {
$isDeadlock = ($e->errorInfo[1] ?? 0) === 1213
|| str_contains($e->getMessage(), 'Deadlock');
if ($isDeadlock && $attempt < $maxAttempts - 1) {
$attempt++;
usleep(self::RETRY_BASE_DELAY_US * (2 ** $attempt));
} else {
throw $e;
}
}
}
}
/**
* @throws Throwable
*/
private function runCollectionFileCheckStage3(string $whereSql): void
{
DB::transaction(static function () use ($whereSql): void {
$sql = <<<SQL
UPDATE binaries b
INNER JOIN (
SELECT b.id
FROM binaries b
INNER JOIN collections c ON c.id = b.collections_id
WHERE c.filecheck = ?
AND b.partcheck = ? {$whereSql}
AND b.currentparts = b.totalparts
GROUP BY b.id, b.totalparts
) r ON b.id = r.id
SET b.partcheck = ?
SQL;
DB::update($sql, [
CollectionFileCheckStatus::TempComplete->value,
FileCompletionStatus::Incomplete->value,
FileCompletionStatus::Complete->value,
]);
}, 10);
DB::transaction(static function () use ($whereSql): void {
$sql = <<<SQL
UPDATE binaries b
INNER JOIN (
SELECT b.id
FROM binaries b
INNER JOIN collections c ON c.id = b.collections_id
WHERE c.filecheck = ?
AND b.partcheck = ? {$whereSql}
AND b.currentparts >= (b.totalparts + 1)
GROUP BY b.id, b.totalparts
) r ON b.id = r.id
SET b.partcheck = ?
SQL;
DB::update($sql, [
CollectionFileCheckStatus::ZeroPart->value,
FileCompletionStatus::Incomplete->value,
FileCompletionStatus::Complete->value,
]);
}, 10);
}
/**
* @throws Throwable
*/
private function runCollectionFileCheckStage4(string $whereSql): void
{
DB::transaction(static function () use ($whereSql): void {
$sql = <<<SQL
UPDATE collections c
INNER JOIN (
SELECT c.id
FROM collections c
INNER JOIN binaries b ON c.id = b.collections_id
WHERE b.partcheck = ? AND c.filecheck IN (?, ?) {$whereSql}
GROUP BY b.collections_id, c.totalfiles, c.id
HAVING COUNT(b.id) >= c.totalfiles
) r ON c.id = r.id
SET filecheck = ?
SQL;
DB::update($sql, [
FileCompletionStatus::Complete->value,
CollectionFileCheckStatus::TempComplete->value,
CollectionFileCheckStatus::ZeroPart->value,
CollectionFileCheckStatus::CompleteParts->value,
]);
}, 10);
}
/**
* @throws Throwable
*/
private function runCollectionFileCheckStage5(int $groupId): void
{
DB::transaction(static function () use ($groupId): void {
$query = Collection::query()
->whereIn('filecheck', [
CollectionFileCheckStatus::TempComplete->value,
CollectionFileCheckStatus::ZeroPart->value,
]);
if ($groupId !== 0) {
$query->where('groups_id', $groupId);
}
$query->update(['filecheck' => CollectionFileCheckStatus::CompleteCollection->value]);
}, 10);
}
/**
* @throws Throwable
*/
private function runCollectionFileCheckStage6(string $whereSql): void
{
DB::transaction(function () use ($whereSql): void {
$sql = <<<SQL
UPDATE collections c
SET filecheck = ?, totalfiles = (SELECT COUNT(b.id) FROM binaries b WHERE b.collections_id = c.id)
WHERE c.dateadded < NOW() - INTERVAL ? HOUR
AND c.filecheck IN (?, ?, 10){$whereSql}
SQL;
DB::update($sql, [
CollectionFileCheckStatus::CompleteParts->value,
$this->settings->collectionDelayTime,
CollectionFileCheckStatus::Default->value,
CollectionFileCheckStatus::CompleteCollection->value,
]);
}, 10);
}
/**
* @throws Throwable
*/
@@ -1128,9 +1056,20 @@ final class ReleaseProcessingService
do {
try {
$query = DB::table('collections')
->where('added', '<', $cutoff)
->whereIn('filecheck', [
CollectionFileCheckStatus::Default->value,
CollectionFileCheckStatus::CompleteCollection->value,
CollectionFileCheckStatus::TempComplete->value,
CollectionFileCheckStatus::ZeroPart->value,
10,
])
->orderBy('id')
->limit(self::BATCH_SIZE);
if (Schema::hasColumn('collections', 'last_seen_at')) {
$query->whereRaw('COALESCE(last_seen_at, dateadded, added) < ?', [$cutoff]);
} else {
$query->where('added', '<', $cutoff);
}
if ($groupID !== 0) {
$query->where('groups_id', '=', $groupID);
}
+14
View File
@@ -45,4 +45,18 @@ class XrefService
return array_values(array_diff($incoming, $existing));
}
/** @return list<string> */
public function extractGroupNames(?string $xref): array
{
$groups = [];
foreach ($this->extractTokens($xref) as $token) {
$group = preg_replace('/:\d+$/', '', (string) $token);
if ($group !== null && $group !== '') {
$groups[$group] = true;
}
}
return array_keys($groups);
}
}
+12
View File
@@ -18,6 +18,18 @@ return [
'multiprocessing_max_child_time' => env('NN_MULTIPROCESSING_MAX_CHILD_TIME', 1800),
'concurrency_timeout' => env('NN_CONCURRENCY_TIMEOUT'),
'stream_fork_output' => env('STREAM_FORK_OUTPUT', false),
'cbp' => [
// Bound the amount of header data retained and written in one transaction.
'header_chunk_size' => (int) env('CBP_HEADER_CHUNK_SIZE', 500),
// Hard limit for rows represented by a generated raw SQL statement.
'sql_chunk_size' => (int) env('CBP_SQL_CHUNK_SIZE', 500),
// Number of stale collections reconciled by one release-processing batch.
'reconcile_batch_size' => (int) env('CBP_RECONCILE_BATCH_SIZE', 500),
// Maximum flattened binary/part rows loaded while streaming an NZB.
'nzb_stream_rows' => (int) env('CBP_NZB_STREAM_ROWS', 5000),
// Explicit maintenance-window approval for the destructive hash/key migration.
'storage_migration_execute' => (bool) env('CBP_STORAGE_MIGRATION_EXECUTE', false),
],
'purge_inactive_users' => env('PURGE_INACTIVE_USERS', false),
'purge_inactive_users_days' => env('PURGE_INACTIVE_USERS_DAYS', 180),
'mysql_search_fallback' => env('MYSQL_SEARCH_FALLBACK', false), // Disable MySQL LIKE fallback when Manticore/Elasticsearch return no results
@@ -0,0 +1,69 @@
<?php
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
{
public function up(): void
{
if (Schema::hasTable('collections') && ! Schema::hasColumn('collections', 'last_seen_at')) {
Schema::table('collections', function (Blueprint $table): void {
$table->dateTime('last_seen_at')->nullable()->after('dateadded');
});
}
if (! Schema::hasTable('collection_groups')) {
Schema::create('collection_groups', function (Blueprint $table): void {
$table->unsignedInteger('collections_id');
$table->string('group_name', 255);
$table->primary(['collections_id', 'group_name']);
$table->foreign('collections_id', 'fk_collection_groups_collection')
->references('id')->on('collections')
->cascadeOnUpdate()->cascadeOnDelete();
});
}
if (Schema::hasTable('collections') && ! $this->indexExists('collections', 'ix_collections_group_filecheck_seen_id')) {
Schema::table('collections', function (Blueprint $table): void {
$table->index(
['groups_id', 'filecheck', 'last_seen_at', 'id'],
'ix_collections_group_filecheck_seen_id'
);
});
}
}
public function down(): void
{
if (Schema::hasTable('collection_groups')) {
Schema::drop('collection_groups');
}
if (Schema::hasTable('collections') && $this->indexExists('collections', 'ix_collections_group_filecheck_seen_id')) {
Schema::table('collections', function (Blueprint $table): void {
$table->dropIndex('ix_collections_group_filecheck_seen_id');
});
}
if (Schema::hasTable('collections') && Schema::hasColumn('collections', 'last_seen_at')) {
Schema::table('collections', function (Blueprint $table): void {
$table->dropColumn('last_seen_at');
});
}
}
private function indexExists(string $table, string $index): bool
{
if (DB::getDriverName() === 'sqlite') {
return DB::select(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name = ?",
[$table, $index]
) !== [];
}
return DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$index]) !== [];
}
};
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (DB::getDriverName() === 'sqlite' || ! $this->hasCbpTables()) {
return;
}
$issues = $this->schemaIssues();
if ($issues === []) {
return;
}
if (! config('nntmux.cbp.storage_migration_execute', false)) {
throw new RuntimeException(
"The CBP binary-hash storage migration requires an approved maintenance window.\n".
'Stop ingestion and queue workers, verify a backup, run `php artisan cbp:optimize-storage`, '.
'then set CBP_STORAGE_MIGRATION_EXECUTE=true and rerun `php artisan migrate`.'."\n".
'Pending changes: '.implode('; ', $issues)
);
}
$collectionCount = (int) DB::table('collections')->count();
$exitCode = Artisan::call('cbp:optimize-storage', [
'--execute' => true,
'--batch' => (int) config('nntmux.cbp.reconcile_batch_size', 500),
]);
if ($exitCode !== 0) {
throw new RuntimeException(
"The resumable CBP storage optimizer failed. Fix the reported error and rerun the migration.\n".
Artisan::output()
);
}
$optimizedCollectionCount = (int) DB::table('collections')->count();
if ($optimizedCollectionCount !== $collectionCount) {
throw new RuntimeException(
"CBP storage optimization changed the collection row count ({$collectionCount} to {$optimizedCollectionCount}). Restore the backup before retrying."
);
}
$issues = $this->schemaIssues();
if ($issues !== []) {
throw new RuntimeException(
'CBP storage optimization finished without satisfying the migration contract: '.implode('; ', $issues)
);
}
}
public function down(): void
{
if (DB::getDriverName() === 'sqlite' || ! $this->hasCbpTables()) {
return;
}
throw new RuntimeException(
'The CBP binary-hash migration cannot be rolled back: duplicate binaries and parts were merged. Restore the pre-migration backup instead.'
);
}
private function hasCbpTables(): bool
{
return Schema::hasTable('collections')
&& Schema::hasTable('binaries')
&& Schema::hasTable('parts');
}
/** @return list<string> */
private function schemaIssues(): array
{
$issues = [];
if (! $this->binaryColumnIs('collections', 'collectionhash', 20)) {
$issues[] = 'collections.collectionhash must be BINARY(20)';
}
if (! $this->binaryColumnIs('binaries', 'binaryhash', 16)) {
$issues[] = 'binaries.binaryhash must be BINARY(16)';
}
if (! $this->indexMatches('binaries', 'ux_binaries_collection_hash', ['collections_id', 'binaryhash'], true)) {
$issues[] = 'binaries requires UNIQUE (collections_id, binaryhash)';
}
if (! $this->indexMatches('binaries', 'ix_binaries_collection_filenumber', ['collections_id', 'filenumber'], false)) {
$issues[] = 'binaries requires a non-unique (collections_id, filenumber) lookup index';
}
if ($this->hasUniqueIndexForColumns('binaries', ['collections_id', 'filenumber'])) {
$issues[] = 'the legacy unique (collections_id, filenumber) constraint must be removed';
}
if (! $this->indexMatches('parts', 'PRIMARY', ['binaries_id', 'partnumber'], true)) {
$issues[] = 'parts primary key must be (binaries_id, partnumber)';
}
if (! $this->indexMatches('parts', 'ix_parts_number', ['number'], false)) {
$issues[] = 'parts requires a non-unique number lookup index';
}
if (! $this->messageIdUsesAsciiBinaryCollation()) {
$issues[] = 'parts.messageid must use an ASCII binary collation';
}
return $issues;
}
private function binaryColumnIs(string $table, string $column, int $length): bool
{
$definition = DB::selectOne(
'SELECT DATA_TYPE AS data_type, CHARACTER_MAXIMUM_LENGTH AS max_length
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
[$table, $column]
);
return $definition !== null
&& strtolower((string) $definition->data_type) === 'binary'
&& (int) $definition->max_length === $length;
}
/** @param list<string> $columns */
private function indexMatches(string $table, string $name, array $columns, bool $unique): bool
{
$rows = DB::select(
'SELECT COLUMN_NAME AS column_name, NON_UNIQUE AS non_unique
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
ORDER BY SEQ_IN_INDEX',
[$table, $name]
);
if ($rows === []) {
return false;
}
return array_map(static fn (object $row): string => (string) $row->column_name, $rows) === $columns
&& ((int) $rows[0]->non_unique === ($unique ? 0 : 1));
}
/** @param list<string> $columns */
private function hasUniqueIndexForColumns(string $table, array $columns): bool
{
$rows = DB::select(
'SELECT INDEX_NAME AS index_name, COLUMN_NAME AS column_name
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND NON_UNIQUE = 0
ORDER BY INDEX_NAME, SEQ_IN_INDEX',
[$table]
);
$indexes = [];
foreach ($rows as $row) {
$indexes[(string) $row->index_name][] = (string) $row->column_name;
}
return in_array($columns, $indexes, true);
}
private function messageIdUsesAsciiBinaryCollation(): bool
{
$definition = DB::selectOne(
'SELECT CHARACTER_SET_NAME AS character_set_name, COLLATION_NAME AS collation_name
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
['parts', 'messageid']
);
return $definition !== null
&& strtolower((string) $definition->character_set_name) === 'ascii'
&& str_ends_with(strtolower((string) $definition->collation_name), '_bin');
}
};
+19 -7
View File
@@ -66,7 +66,7 @@ DROP TABLE IF EXISTS `binaries`;
CREATE TABLE `binaries` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`binaryhash` blob NOT NULL DEFAULT '0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0',
`binaryhash` binary(16) NOT NULL,
`name` varchar(1000) NOT NULL DEFAULT '',
`collections_id` int(10) unsigned NOT NULL DEFAULT 0,
`filenumber` int(10) unsigned NOT NULL DEFAULT 0,
@@ -75,8 +75,8 @@ CREATE TABLE `binaries` (
`partcheck` tinyint(1) NOT NULL DEFAULT 0,
`partsize` bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_collection_id_filenumber` (`collections_id`,`filenumber`),
KEY `ix_binaries_binaryhash` (`binaryhash`(3072)),
UNIQUE KEY `ux_binaries_collection_hash` (`collections_id`,`binaryhash`),
KEY `ix_binaries_collection_filenumber` (`collections_id`,`filenumber`),
KEY `ix_binaries_collection` (`collections_id`),
KEY `ix_binaries_partcheck` (`partcheck`),
CONSTRAINT `FK_Collections` FOREIGN KEY (`collections_id`) REFERENCES `collections` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
@@ -190,9 +190,10 @@ CREATE TABLE `collections` (
`xref` varchar(2000) NOT NULL DEFAULT '',
`totalfiles` int(10) unsigned NOT NULL DEFAULT 0,
`groups_id` int(10) unsigned NOT NULL DEFAULT 0,
`collectionhash` varchar(255) NOT NULL DEFAULT '0',
`collectionhash` binary(20) NOT NULL,
`collection_regexes_id` int(11) NOT NULL DEFAULT 0 COMMENT 'FK to collection_regexes.id',
`dateadded` datetime DEFAULT NULL,
`last_seen_at` datetime DEFAULT NULL,
`added` timestamp NOT NULL DEFAULT current_timestamp(),
`filecheck` tinyint(1) NOT NULL DEFAULT 0,
`filesize` bigint(20) unsigned NOT NULL DEFAULT 0,
@@ -205,7 +206,17 @@ CREATE TABLE `collections` (
KEY `groups_id` (`groups_id`),
KEY `ix_collection_dateadded` (`dateadded`),
KEY `ix_collection_filecheck` (`filecheck`),
KEY `ix_collection_releaseid` (`releases_id`)
KEY `ix_collection_releaseid` (`releases_id`),
KEY `ix_collections_group_filecheck_seen_id` (`groups_id`,`filecheck`,`last_seen_at`,`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
DROP TABLE IF EXISTS `collection_groups`;
CREATE TABLE `collection_groups` (
`collections_id` int(10) unsigned NOT NULL,
`group_name` varchar(255) NOT NULL,
PRIMARY KEY (`collections_id`,`group_name`),
CONSTRAINT `fk_collection_groups_collection` FOREIGN KEY (`collections_id`) REFERENCES `collections` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
DROP TABLE IF EXISTS `consoleinfo`;
@@ -576,11 +587,12 @@ DROP TABLE IF EXISTS `parts`;
CREATE TABLE `parts` (
`binaries_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`messageid` varchar(255) NOT NULL DEFAULT '',
`messageid` varchar(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '',
`number` bigint(20) unsigned NOT NULL DEFAULT 0,
`partnumber` int(10) unsigned NOT NULL DEFAULT 0,
`size` int(10) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`binaries_id`,`number`),
PRIMARY KEY (`binaries_id`,`partnumber`),
KEY `ix_parts_number` (`number`),
CONSTRAINT `FK_binaries` FOREIGN KEY (`binaries_id`) REFERENCES `binaries` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+19 -7
View File
@@ -66,7 +66,7 @@ DROP TABLE IF EXISTS `binaries`;
CREATE TABLE `binaries` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`binaryhash` blob NOT NULL DEFAULT '0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0',
`binaryhash` binary(16) NOT NULL,
`name` varchar(1000) NOT NULL DEFAULT '',
`collections_id` int(10) unsigned NOT NULL DEFAULT 0,
`filenumber` int(10) unsigned NOT NULL DEFAULT 0,
@@ -75,8 +75,8 @@ CREATE TABLE `binaries` (
`partcheck` tinyint(1) NOT NULL DEFAULT 0,
`partsize` bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_collection_id_filenumber` (`collections_id`,`filenumber`),
KEY `ix_binaries_binaryhash` (`binaryhash`(3072)),
UNIQUE KEY `ux_binaries_collection_hash` (`collections_id`,`binaryhash`),
KEY `ix_binaries_collection_filenumber` (`collections_id`,`filenumber`),
KEY `ix_binaries_collection` (`collections_id`),
KEY `ix_binaries_partcheck` (`partcheck`),
CONSTRAINT `FK_Collections` FOREIGN KEY (`collections_id`) REFERENCES `collections` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
@@ -190,9 +190,10 @@ CREATE TABLE `collections` (
`xref` varchar(2000) NOT NULL DEFAULT '',
`totalfiles` int(10) unsigned NOT NULL DEFAULT 0,
`groups_id` int(10) unsigned NOT NULL DEFAULT 0,
`collectionhash` varchar(255) NOT NULL DEFAULT '0',
`collectionhash` binary(20) NOT NULL,
`collection_regexes_id` int(11) NOT NULL DEFAULT 0 COMMENT 'FK to collection_regexes.id',
`dateadded` datetime DEFAULT NULL,
`last_seen_at` datetime DEFAULT NULL,
`added` timestamp NOT NULL DEFAULT current_timestamp(),
`filecheck` tinyint(1) NOT NULL DEFAULT 0,
`filesize` bigint(20) unsigned NOT NULL DEFAULT 0,
@@ -205,7 +206,17 @@ CREATE TABLE `collections` (
KEY `groups_id` (`groups_id`),
KEY `ix_collection_dateadded` (`dateadded`),
KEY `ix_collection_filecheck` (`filecheck`),
KEY `ix_collection_releaseid` (`releases_id`)
KEY `ix_collection_releaseid` (`releases_id`),
KEY `ix_collections_group_filecheck_seen_id` (`groups_id`,`filecheck`,`last_seen_at`,`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
DROP TABLE IF EXISTS `collection_groups`;
CREATE TABLE `collection_groups` (
`collections_id` int(10) unsigned NOT NULL,
`group_name` varchar(255) NOT NULL,
PRIMARY KEY (`collections_id`,`group_name`),
CONSTRAINT `fk_collection_groups_collection` FOREIGN KEY (`collections_id`) REFERENCES `collections` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
DROP TABLE IF EXISTS `consoleinfo`;
@@ -576,11 +587,12 @@ DROP TABLE IF EXISTS `parts`;
CREATE TABLE `parts` (
`binaries_id` bigint(20) unsigned NOT NULL DEFAULT 0,
`messageid` varchar(255) NOT NULL DEFAULT '',
`messageid` varchar(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '',
`number` bigint(20) unsigned NOT NULL DEFAULT 0,
`partnumber` int(10) unsigned NOT NULL DEFAULT 0,
`size` int(10) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`binaries_id`,`number`),
PRIMARY KEY (`binaries_id`,`partnumber`),
KEY `ix_parts_number` (`number`),
CONSTRAINT `FK_binaries` FOREIGN KEY (`binaries_id`) REFERENCES `binaries` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+18 -9
View File
@@ -11,9 +11,8 @@ use Tests\TestCase;
* Regression tests guarding the SQL fan-out of the CBP write path.
*
* The collections/binaries/parts handlers were rewritten to consolidate
* redundant SELECTs (prefetchExistingXrefs + existingHashes + resolveIdsByHash
* 1 query, existingBinaryKeys + resolveBinaryIds 1 query) and to batch
* per-row UPDATEs into single JOIN..UNION ALL statements. These tests assert
* redundant SELECTs and derives aggregates from stored parts in bounded raw
* SQL updates. These tests assert
* the resulting per-chunk query count stays within a sane ceiling so the old
* fan-out cannot silently come back.
*/
@@ -58,9 +57,18 @@ class BinariesQueryCountTest extends TestCase
collectionhash VARCHAR(40) UNIQUE,
collection_regexes_id INT,
dateadded DATETIME NULL,
last_seen_at DATETIME NULL,
filecheck INT DEFAULT 0,
filesize INT DEFAULT 0,
noise VARCHAR(64) DEFAULT ""
)');
DB::statement('CREATE TABLE collection_groups (
collections_id INT,
group_name VARCHAR(255),
UNIQUE(collections_id, group_name)
)');
DB::statement('CREATE TABLE binaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
binaryhash BLOB,
@@ -70,6 +78,7 @@ class BinariesQueryCountTest extends TestCase
currentparts INT,
filenumber INT,
partsize INT,
partcheck INT DEFAULT 0,
UNIQUE(binaryhash, collections_id)
)');
@@ -79,7 +88,7 @@ class BinariesQueryCountTest extends TestCase
messageid VARCHAR(255),
partnumber INT,
size INT,
UNIQUE(binaries_id, number)
UNIQUE(binaries_id, partnumber)
)');
DB::statement('CREATE TABLE missed_parts (
@@ -127,8 +136,8 @@ class BinariesQueryCountTest extends TestCase
});
// Ceiling rationale (per chunk, 5 distinct collections, 5 binaries):
// collections: 1 prefetch + 1 INSERT + 1 resolve-new = 3
// binaries: 1 prefetch + 1 INSERT + 1 resolve-new = 3
// collections: 1 prefetch + 1 INSERT + 1 resolve-new + 1 aggregate = 4
// binaries: 1 prefetch + 1 INSERT + 1 resolve-new + 1 aggregate = 4
// parts: 1 INSERT (chunk fits in MAX_SQL_ROWS_PER_STATEMENT)
// Allow a small headroom so the regression test isn't brittle.
$this->assertLessThanOrEqual(8, $counts['collections'] ?? 0,
@@ -164,7 +173,7 @@ class BinariesQueryCountTest extends TestCase
// Second pass — exact same headers. The prefetch should hit every row,
// so no resolve-new SELECT should fire; existing rows skip the xref
// append UPDATE because the tokens already match.
// append UPDATE; normalized collection_groups inserts are idempotent.
$counts = $this->countQueriesPerTable(static function () use ($headers): void {
$freshHarness = new TestBinariesHarness;
$freshHarness->publicStoreHeaders($headers);
@@ -172,8 +181,8 @@ class BinariesQueryCountTest extends TestCase
// With nothing new to insert, we expect at most:
// collections: 1 prefetch + 1 INSERT (no-op via ODKU id=LAST_INSERT_ID(id))
// binaries: 1 prefetch + 1 INSERT (currentparts/partsize incremented by ODKU)
// parts: 1 INSERT IGNORE (no rows actually persist)
// binaries: 1 prefetch + 1 INSERT + 1 authoritative aggregate update
// parts: 1 INSERT IGNORE + 1 identity lookup (no rows actually persist)
$this->assertLessThanOrEqual(4, $counts['collections'] ?? 0);
$this->assertLessThanOrEqual(4, $counts['binaries'] ?? 0);
$this->assertLessThanOrEqual(2, $counts['parts'] ?? 0);
+123 -18
View File
@@ -53,7 +53,7 @@ class BinariesStorageInternalsTest extends TestCase
messageid VARCHAR(255),
partnumber INT,
size INT,
UNIQUE(binaries_id, number)
UNIQUE(binaries_id, partnumber)
)');
$handler = new PartHandler(100);
@@ -79,8 +79,17 @@ class BinariesStorageInternalsTest extends TestCase
currentparts INT,
filenumber INT,
partsize INT,
partcheck INT DEFAULT 0,
UNIQUE(binaryhash, collections_id)
)');
DB::statement('CREATE TABLE parts (
binaries_id INT,
number INT,
messageid VARCHAR(255),
partnumber INT,
size INT,
UNIQUE(binaries_id, partnumber)
)');
$handler = new BinaryHandler;
$first = $this->parsedHeader(251, 1, 'Aggregate.Release', 100);
@@ -89,7 +98,11 @@ class BinariesStorageInternalsTest extends TestCase
$binaryId = $handler->getOrCreateBinary($first, 1, 1, 0);
$this->assertNotNull($binaryId);
$this->assertSame($binaryId, $handler->getOrCreateBinary($second, 1, 1, 0));
$this->assertTrue($handler->flushUpdates());
DB::table('parts')->insert([
['binaries_id' => $binaryId, 'number' => 251, 'messageid' => '<251@example>', 'partnumber' => 1, 'size' => 100],
['binaries_id' => $binaryId, 'number' => 252, 'messageid' => '<252@example>', 'partnumber' => 2, 'size' => 50],
]);
$this->assertTrue($handler->refreshAggregates([$binaryId]));
$binary = DB::table('binaries')->where('id', $binaryId)->first();
$this->assertSame(2, (int) $binary->currentparts);
@@ -128,7 +141,7 @@ class BinariesStorageInternalsTest extends TestCase
{
$this->createHeaderStorageTables('CHECK(size < 500)');
$service = new HeaderStorageService($this->deterministicCollectionHandler(), config: new BinariesConfig(partsChunkSize: 2, headerChunkSize: 2));
$service = new HeaderStorageService($this->deterministicCollectionHandler(), config: new BinariesConfig(sqlChunkSize: 2, headerChunkSize: 2));
$failed = $service->store([
$this->parsedHeader(301, 1, 'Chunk.One', 100),
$this->parsedHeader(302, 2, 'Chunk.One', 100),
@@ -149,7 +162,7 @@ class BinariesStorageInternalsTest extends TestCase
{
$this->createHeaderStorageTables();
$service = new HeaderStorageService($this->deterministicCollectionHandler(), config: new BinariesConfig(partsChunkSize: 10));
$service = new HeaderStorageService($this->deterministicCollectionHandler(), config: new BinariesConfig(sqlChunkSize: 10));
$failed = $service->store([
$this->parsedHeader(401, 1, 'Batch.Release', 150),
$this->parsedHeader(402, 2, 'Batch.Release', 175),
@@ -169,7 +182,7 @@ class BinariesStorageInternalsTest extends TestCase
{
$this->createHeaderStorageTables();
$service = new HeaderStorageService($this->deterministicCollectionHandler(), config: new BinariesConfig(partsChunkSize: 10));
$service = new HeaderStorageService($this->deterministicCollectionHandler(), config: new BinariesConfig(sqlChunkSize: 10));
$this->assertSame([], $service->store([
$this->parsedHeader(501, 1, 'Existing.Batch.Release', 100),
], ['id' => 1, 'name' => 'alt.test'], true));
@@ -187,24 +200,106 @@ class BinariesStorageInternalsTest extends TestCase
$this->assertSame(250, (int) $binary->partsize);
}
public function test_three_identical_ingestions_leave_authoritative_counts_unchanged(): void
{
$this->createHeaderStorageTables();
$service = new HeaderStorageService($this->deterministicCollectionHandler(), config: new BinariesConfig(sqlChunkSize: 10));
$headers = [
$this->parsedHeader(551, 1, 'Idempotent.Release', 125),
$this->parsedHeader(552, 2, 'Idempotent.Release', 175),
];
for ($attempt = 0; $attempt < 3; $attempt++) {
$this->assertSame([], $service->store($headers, ['id' => 1, 'name' => 'alt.test'], true));
}
$binary = DB::table('binaries')->first();
$collection = DB::table('collections')->first();
$this->assertSame(1, DB::table('binaries')->count());
$this->assertSame(2, DB::table('parts')->count());
$this->assertSame(2, (int) $binary->currentparts);
$this->assertSame(300, (int) $binary->partsize);
$this->assertSame(300, (int) $collection->filesize);
}
public function test_duplicate_partnumber_prefers_non_empty_message_then_size_then_article_number(): void
{
DB::statement('CREATE TABLE parts (
binaries_id INT,
number INT,
messageid VARCHAR(255),
partnumber INT,
size INT,
UNIQUE(binaries_id, partnumber)
)');
$handler = new PartHandler(10);
$small = $this->parsedHeader(900, 1, 'Preference.Release', 100);
$large = $this->parsedHeader(901, 1, 'Preference.Release', 200);
$this->assertTrue($handler->addPart(7, $small));
$this->assertTrue($handler->addPart(7, $large));
$this->assertTrue($handler->flush());
$part = DB::table('parts')->first();
$this->assertSame(901, (int) $part->number);
$this->assertSame(200, (int) $part->size);
}
public function test_invalid_message_id_rolls_back_the_header_chunk(): void
{
$this->createHeaderStorageTables();
$service = new HeaderStorageService($this->deterministicCollectionHandler(), config: new BinariesConfig(sqlChunkSize: 10));
$header = $this->parsedHeader(910, 1, 'Invalid.Message.Release', 100);
$header['Message-ID'] = "<invalid\u{00E9}@example>";
$this->assertSame([910], $service->store([$header], ['id' => 1, 'name' => 'alt.test'], true));
$this->assertSame(0, DB::table('collections')->count());
$this->assertSame(0, DB::table('binaries')->count());
$this->assertSame(0, DB::table('parts')->count());
}
public function test_zero_file_number_uses_subject_and_poster_identity(): void
{
$this->createHeaderStorageTables();
$service = new HeaderStorageService($this->deterministicCollectionHandler(), config: new BinariesConfig(sqlChunkSize: 10));
$first = $this->parsedHeader(920, 1, 'Unknown.File.Release', 100);
$second = $this->parsedHeader(921, 1, 'Unknown.File.Release', 100);
$second['From'] = 'another-poster@example.com';
$this->assertSame([], $service->store([$first, $second], ['id' => 1, 'name' => 'alt.test'], true));
$this->assertSame(1, DB::table('collections')->count());
$this->assertSame(2, DB::table('binaries')->count());
$this->assertSame(2, DB::table('parts')->count());
}
public function test_cross_post_groups_are_normalized_and_deduplicated(): void
{
$this->createHeaderStorageTables();
$service = new HeaderStorageService($this->deterministicCollectionHandler(), config: new BinariesConfig(sqlChunkSize: 10));
$header = $this->parsedHeader(930, 1, 'Cross.Post.Release', 100);
$header['Xref'] = 'news.example alt.binaries.one:930 alt.binaries.two:931 alt.binaries.one:930';
$this->assertSame([], $service->store([$header], ['id' => 1, 'name' => 'alt.binaries.one'], true));
$this->assertSame(
['alt.binaries.one', 'alt.binaries.two'],
DB::table('collection_groups')->orderBy('group_name')->pluck('group_name')->all()
);
}
public function test_header_storage_does_not_merge_same_subject_across_different_collections(): void
{
$this->createHeaderStorageTables();
$service = new HeaderStorageService($this->deterministicCollectionHandler(), config: new BinariesConfig(partsChunkSize: 10));
$failed = $service->store([
$this->parsedHeaderWithTotal(601, 1, 2, 'Same.Subject', 100),
$this->parsedHeaderWithTotal(602, 1, 3, 'Same.Subject', 200),
], ['id' => 1, 'name' => 'alt.test'], true);
$handler = new BinaryHandler;
$header = $this->parsedHeader(601, 1, 'Same.Subject', 100);
$firstId = $handler->getOrCreateBinary($header, 10, 1, 0);
$secondId = $handler->getOrCreateBinary($header, 20, 1, 0);
$binaries = DB::table('binaries')->orderBy('partsize')->get();
$this->assertSame([], $failed);
$this->assertSame(2, DB::table('collections')->count());
$this->assertNotNull($firstId);
$this->assertNotNull($secondId);
$this->assertNotSame($firstId, $secondId);
$this->assertSame(2, DB::table('binaries')->count());
$this->assertSame(2, DB::table('parts')->count());
$this->assertSame([100, 200], $binaries->pluck('partsize')->map(static fn ($value): int => (int) $value)->all());
$this->assertSame([1, 1], $binaries->pluck('currentparts')->map(static fn ($value): int => (int) $value)->all());
}
private function rawHeader(int $number, string $subject): array
@@ -252,9 +347,18 @@ class BinariesStorageInternalsTest extends TestCase
collectionhash VARCHAR(40) UNIQUE,
collection_regexes_id INT,
dateadded DATETIME NULL,
last_seen_at DATETIME NULL,
filecheck INT DEFAULT 0,
filesize INT DEFAULT 0,
noise VARCHAR(64) DEFAULT \'\'
)');
DB::statement('CREATE TABLE collection_groups (
collections_id INT,
group_name VARCHAR(255),
UNIQUE(collections_id, group_name)
)');
DB::statement('CREATE TABLE binaries (
id INTEGER PRIMARY KEY,
binaryhash BLOB,
@@ -264,6 +368,7 @@ class BinariesStorageInternalsTest extends TestCase
currentparts INT,
filenumber INT,
partsize INT,
partcheck INT DEFAULT 0,
UNIQUE(binaryhash, collections_id)
)');
@@ -273,7 +378,7 @@ class BinariesStorageInternalsTest extends TestCase
messageid VARCHAR(255),
partnumber INT,
size INT '.$partSizeConstraint.',
UNIQUE(binaries_id, number)
UNIQUE(binaries_id, partnumber)
)');
DB::statement('CREATE TABLE collection_regexes (
@@ -48,9 +48,18 @@ class BinariesStoreHeadersMixedTest extends TestCase
collectionhash VARCHAR(40) UNIQUE,
collection_regexes_id INT,
dateadded DATETIME NULL,
last_seen_at DATETIME NULL,
filecheck INT DEFAULT 0,
filesize INT DEFAULT 0,
noise VARCHAR(64) DEFAULT ""
)');
DB::statement('CREATE TABLE collection_groups (
collections_id INT,
group_name VARCHAR(255),
UNIQUE(collections_id, group_name)
)');
DB::statement('CREATE TABLE binaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
binaryhash BLOB,
@@ -60,6 +69,7 @@ class BinariesStoreHeadersMixedTest extends TestCase
currentparts INT,
filenumber INT,
partsize INT,
partcheck INT DEFAULT 0,
UNIQUE(binaryhash, collections_id)
)');
@@ -70,7 +80,7 @@ class BinariesStoreHeadersMixedTest extends TestCase
messageid VARCHAR(255),
partnumber INT,
size INT,
UNIQUE(number)
UNIQUE(binaries_id, partnumber)
)');
DB::statement('CREATE TABLE missed_parts (
@@ -117,7 +127,7 @@ class BinariesStoreHeadersMixedTest extends TestCase
$group = ['id' => 3, 'name' => 'alt.mixed'];
// Chunk of 2: first flush succeeds, second flush fails
config(['nntmux.parts_chunk_size' => 2]);
config(['nntmux.cbp.sql_chunk_size' => 2]);
$headers = [
$this->makeHeader(4001, 1, 5, 101),
+13 -3
View File
@@ -49,9 +49,18 @@ class BinariesStoreHeadersTest extends TestCase
collectionhash VARCHAR(40) UNIQUE,
collection_regexes_id INT,
dateadded DATETIME NULL,
last_seen_at DATETIME NULL,
filecheck INT DEFAULT 0,
filesize INT DEFAULT 0,
noise VARCHAR(64) DEFAULT ""
)');
DB::statement('CREATE TABLE collection_groups (
collections_id INT,
group_name VARCHAR(255),
UNIQUE(collections_id, group_name)
)');
DB::statement('CREATE TABLE binaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
binaryhash BLOB,
@@ -61,6 +70,7 @@ class BinariesStoreHeadersTest extends TestCase
currentparts INT,
filenumber INT,
partsize INT,
partcheck INT DEFAULT 0,
UNIQUE(binaryhash, collections_id)
)');
@@ -71,7 +81,7 @@ class BinariesStoreHeadersTest extends TestCase
messageid VARCHAR(255),
partnumber INT,
size INT,
UNIQUE(number)
UNIQUE(binaries_id, partnumber)
)');
DB::statement('CREATE TABLE missed_parts (
@@ -159,7 +169,7 @@ class BinariesStoreHeadersTest extends TestCase
$harness = new TestBinariesHarness;
$group = ['id' => 2, 'name' => 'alt.rollback'];
config(['nntmux.parts_chunk_size' => 2]); // force flush earlier
config(['nntmux.cbp.sql_chunk_size' => 2]); // force flush earlier
$headers = [
$this->makeHeader(2001, 1, 3, 111),
@@ -185,7 +195,7 @@ class BinariesStoreHeadersTest extends TestCase
$group = ['id' => 3, 'name' => 'alt.mixed'];
// Chunk of 2: first flush succeeds, second flush fails
config(['nntmux.parts_chunk_size' => 2]);
config(['nntmux.cbp.sql_chunk_size' => 2]);
$headers = [
$this->makeHeader(4001, 1, 5, 101),
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Release;
use App\Services\Binaries\BinariesConfig;
use App\Services\CollectionCleanupService;
use App\Services\Nzb\NzbCreationCandidateQuery;
use App\Services\Nzb\NzbService;
@@ -199,6 +200,30 @@ class NzbCreationReliabilityTest extends TestCase
$this->assertSame(0, (int) DB::table('releases')->where('id', 1)->value('nzbstatus'));
}
public function test_writer_streams_multiple_keyset_pages_in_segment_order(): void
{
$this->insertRelease(1, 'h');
$this->insertWritableCbp(200, 2000, 1);
DB::table('parts')->where('binaries_id', 2000)->delete();
DB::table('parts')->insert([
['binaries_id' => 2000, 'messageid' => '<part02@example.test>', 'partnumber' => 2, 'size' => 200],
['binaries_id' => 2000, 'messageid' => '<part01@example.test>', 'partnumber' => 1, 'size' => 100],
['binaries_id' => 2000, 'messageid' => '<part03@example.test>', 'partnumber' => 3, 'size' => 300],
]);
$release = Release::query()->findOrFail(1);
$release->setRelation('category', (object) ['title' => 'Misc', 'parent' => (object) ['title' => 'Other']]);
$nzb = new NzbService(app(CollectionCleanupService::class), new BinariesConfig(nzbStreamRows: 2));
$result = $nzb->createNzbForRelease($release);
$this->assertTrue($result->success, $result->reason);
$xml = unzipGzipFile((string) $result->path);
$this->assertIsString($xml);
$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);
}
public function test_stale_temporary_nzb_cleanup_deletes_only_old_temp_files(): void
{
$directory = $this->tempNzbPath.'/a';
@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use App\Services\Binaries\BinariesConfig;
use App\Services\Binaries\CollectionHandler;
use App\Services\Binaries\HeaderStorageService;
use App\Services\CollectionsCleaningService;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
final class CbpMariaDbIngestionTest extends TestCase
{
public function createApplication()
{
$database = getenv('CBP_INTEGRATION_DB_DATABASE');
if ($database === false || $database === '') {
return parent::createApplication();
}
putenv('DB_CONNECTION=mariadb');
putenv('DB_DATABASE='.$database);
putenv('DB_HOST=mariadb');
putenv('DB_USERNAME='.(string) getenv('CBP_INTEGRATION_DB_USERNAME'));
putenv('DB_PASSWORD='.(string) getenv('CBP_INTEGRATION_DB_PASSWORD'));
$_ENV['DB_CONNECTION'] = $_SERVER['DB_CONNECTION'] = 'mariadb';
$_ENV['DB_DATABASE'] = $_SERVER['DB_DATABASE'] = $database;
$_ENV['DB_HOST'] = $_SERVER['DB_HOST'] = 'mariadb';
$_ENV['DB_USERNAME'] = $_SERVER['DB_USERNAME'] = (string) getenv('CBP_INTEGRATION_DB_USERNAME');
$_ENV['DB_PASSWORD'] = $_SERVER['DB_PASSWORD'] = (string) getenv('CBP_INTEGRATION_DB_PASSWORD');
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
protected function setUp(): void
{
parent::setUp();
if (! \in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) {
$this->markTestSkipped('MariaDB/MySQL integration test.');
}
foreach (['cbp_optimization_checkpoints', 'cbp_binary_map', 'parts_cbp_new', 'parts_cbp_pre_optimize'] as $table) {
DB::statement("DROP TABLE IF EXISTS {$table}");
}
DB::statement('CREATE TABLE usenet_groups (id INT UNSIGNED PRIMARY KEY, name VARCHAR(255) NOT NULL) ENGINE=InnoDB');
DB::statement('CREATE TABLE collection_regexes (id INT PRIMARY KEY, group_regex VARCHAR(255), regex VARCHAR(255), status TINYINT DEFAULT 1, ordinal INT DEFAULT 0) ENGINE=InnoDB');
DB::statement('CREATE TABLE collections (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
subject VARCHAR(255) NOT NULL, fromname VARCHAR(255) NOT NULL, date DATETIME NULL,
xref VARCHAR(2000) NOT NULL DEFAULT \'\', groups_id INT UNSIGNED NOT NULL,
totalfiles INT UNSIGNED NOT NULL DEFAULT 0, collectionhash BINARY(20) NOT NULL,
collection_regexes_id INT NOT NULL DEFAULT 0, dateadded DATETIME NULL,
last_seen_at DATETIME NULL, filecheck TINYINT NOT NULL DEFAULT 0,
filesize BIGINT UNSIGNED NOT NULL DEFAULT 0, noise CHAR(32) NOT NULL DEFAULT \'\',
UNIQUE KEY ix_collection_collectionhash (collectionhash),
KEY ix_collections_group_filecheck_seen_id (groups_id, filecheck, last_seen_at, id)
) ENGINE=InnoDB');
DB::statement('CREATE TABLE collection_groups (
collections_id INT UNSIGNED NOT NULL, group_name VARCHAR(255) NOT NULL,
PRIMARY KEY (collections_id, group_name),
CONSTRAINT fk_test_collection_groups FOREIGN KEY (collections_id) REFERENCES collections(id) ON DELETE CASCADE
) ENGINE=InnoDB');
DB::statement('CREATE TABLE binaries (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, binaryhash BINARY(16) NOT NULL,
name VARCHAR(1000) NOT NULL, collections_id INT UNSIGNED NOT NULL,
filenumber INT UNSIGNED NOT NULL DEFAULT 0, totalparts INT UNSIGNED NOT NULL DEFAULT 0,
currentparts INT UNSIGNED NOT NULL DEFAULT 0, partcheck TINYINT NOT NULL DEFAULT 0,
partsize BIGINT UNSIGNED NOT NULL DEFAULT 0,
UNIQUE KEY ux_binaries_collection_hash (collections_id, binaryhash),
KEY ix_binaries_collection_filenumber (collections_id, filenumber),
CONSTRAINT fk_test_binaries FOREIGN KEY (collections_id) REFERENCES collections(id) ON DELETE CASCADE
) ENGINE=InnoDB');
DB::statement('CREATE TABLE parts (
binaries_id BIGINT UNSIGNED NOT NULL,
messageid VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
number BIGINT UNSIGNED NOT NULL, partnumber INT UNSIGNED NOT NULL, size INT UNSIGNED NOT NULL,
PRIMARY KEY (binaries_id, partnumber), KEY ix_parts_number (number),
CONSTRAINT fk_test_parts FOREIGN KEY (binaries_id) REFERENCES binaries(id) ON DELETE CASCADE
) ENGINE=InnoDB');
DB::table('usenet_groups')->insert(['id' => 1, 'name' => 'alt.binaries.test']);
}
protected function tearDown(): void
{
if (\in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) {
DB::statement('SET FOREIGN_KEY_CHECKS=0');
foreach (['parts', 'parts_cbp_new', 'parts_cbp_pre_optimize', 'cbp_binary_map', 'cbp_optimization_checkpoints', 'binaries', 'collection_groups', 'collections', 'collection_regexes', 'usenet_groups'] as $table) {
DB::statement("DROP TABLE IF EXISTS {$table}");
}
DB::statement('SET FOREIGN_KEY_CHECKS=1');
}
parent::tearDown();
}
public function test_reingestion_is_idempotent_and_hot_lookups_use_indexes(): void
{
$service = new HeaderStorageService(
new CollectionHandler(new class extends CollectionsCleaningService
{
public function collectionsCleaner(string $subject, string $groupName = ''): array
{
return ['id' => 0, 'name' => $subject];
}
}),
config: new BinariesConfig(headerChunkSize: 2, sqlChunkSize: 2),
);
$headers = [$this->header(1001, 1, 125), $this->header(1002, 2, 175)];
for ($attempt = 0; $attempt < 3; $attempt++) {
$this->assertSame([], $service->store($headers, ['id' => 1, 'name' => 'alt.binaries.test']));
}
$binary = DB::table('binaries')->first();
$this->assertSame(1, DB::table('collections')->count());
$this->assertSame(1, DB::table('binaries')->count());
$this->assertSame(2, DB::table('parts')->count());
$this->assertSame(2, (int) $binary->currentparts);
$this->assertSame(300, (int) $binary->partsize);
$this->assertSame(300, (int) DB::table('collections')->value('filesize'));
$plan = DB::selectOne(
'EXPLAIN FORMAT=JSON SELECT id FROM binaries WHERE collections_id = ? AND binaryhash = ?',
[1, $binary->binaryhash]
);
$json = strtolower((string) array_values((array) $plan)[0]);
$this->assertStringContainsString('ux_binaries_collection_hash', $json);
$this->assertSame(0, Artisan::call('cbp:optimize-storage'));
$this->assertStringContainsString('Dry-run only', Artisan::output());
$this->restoreLegacyStorageShape();
$this->assertSame(2, DB::table('parts')->count());
config()->set('nntmux.cbp.reconcile_batch_size', 2);
$migration = require database_path('migrations/2026_08_03_000001_finalize_cbp_binary_hash_storage.php');
config()->set('nntmux.cbp.storage_migration_execute', false);
try {
$migration->up();
$this->fail('The legacy storage migration ran without explicit maintenance approval.');
} catch (\RuntimeException $exception) {
$this->assertStringContainsString('CBP_STORAGE_MIGRATION_EXECUTE=true', $exception->getMessage());
}
$this->assertSame(2, DB::table('parts')->count());
config()->set('nntmux.cbp.storage_migration_execute', true);
$migration->up();
$this->assertSame(1, DB::table('collections')->count());
$this->assertSame(1, DB::table('binaries')->count());
$this->assertSame(2, DB::table('parts')->count());
$this->assertSame(300, (int) DB::table('binaries')->value('partsize'));
}
private function restoreLegacyStorageShape(): void
{
DB::statement('ALTER TABLE parts DROP FOREIGN KEY fk_test_parts');
DB::statement('ALTER TABLE parts DROP PRIMARY KEY, DROP INDEX ix_parts_number, ADD PRIMARY KEY (binaries_id, number)');
DB::statement('ALTER TABLE parts MODIFY messageid VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_unicode_ci NOT NULL');
DB::statement('ALTER TABLE binaries DROP INDEX ux_binaries_collection_hash');
DB::statement('ALTER TABLE binaries MODIFY binaryhash BLOB NOT NULL');
DB::statement('CREATE INDEX ix_binaries_binaryhash ON binaries (binaryhash(16))');
DB::statement('CREATE UNIQUE INDEX ux_collection_id_filenumber ON binaries (collections_id, filenumber)');
DB::statement('ALTER TABLE collections DROP INDEX ix_collection_collectionhash');
DB::statement('ALTER TABLE collections MODIFY collectionhash BLOB NOT NULL');
DB::statement('CREATE UNIQUE INDEX ix_collection_collectionhash ON collections (collectionhash(20))');
}
/** @return array<string, mixed> */
private function header(int $number, int $part, int $bytes): array
{
return [
'Number' => $number,
'Subject' => "Integration.Release ({$part}/2)",
'From' => 'poster@example.test',
'Date' => time(),
'Bytes' => $bytes,
'Message-ID' => "<{$number}@example.test>",
'Xref' => "news.example alt.binaries.test:{$number}",
'matches' => [
0 => "Integration.Release ({$part}/2)",
1 => 'Integration.Release',
2 => $part,
3 => 2,
],
];
}
}
+1 -2
View File
@@ -33,8 +33,7 @@ class TestBinariesHarness extends BinariesService
newGroupDaysToScan: 3,
partRepairLimit: 15000,
partRepairMaxTries: 3,
partsChunkSize: 5000,
binariesUpdateChunkSize: 1000,
sqlChunkSize: 500,
echoCli: false
);