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
+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);
}
}