Files
newznab-tmux-NNTmux/app/Console/Commands/NntmuxOffsetPopulate.php
T
2026-02-11 14:02:26 +01:00

428 lines
14 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Facades\Elasticsearch;
use App\Facades\Search;
use App\Models\Predb;
use App\Models\Release;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Process;
class NntmuxOffsetPopulate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'nntmux:offset-populate
{--manticore : Use ManticoreSearch}
{--elastic : Use ElasticSearch}
{--releases : Populates the releases index}
{--predb : Populates the predb index}
{--parallel=8 : Number of parallel processes}
{--batch-size=10000 : Batch size for bulk operations}
{--disable-keys : Disable database keys during population}
{--memory-limit=4G : Memory limit for each process}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Populate search indexes using offset-based parallel processing for maximum performance';
private const SUPPORTED_ENGINES = ['manticore', 'elastic'];
private const SUPPORTED_INDEXES = ['releases', 'predb'];
/**
* Execute the console command.
*/
public function handle(): int
{
$engine = $this->getSelectedEngine();
$index = $this->getSelectedIndex();
if (! $engine || ! $index) {
$this->error('You must specify both an engine (--manticore or --elastic) and an index (--releases or --predb).');
return Command::FAILURE;
}
$this->info("Starting offset-based parallel {$engine} {$index} population...");
$startTime = microtime(true);
$result = $this->populateIndexParallel($engine, $index);
$executionTime = round(microtime(true) - $startTime, 2);
if ($result === Command::SUCCESS) {
$this->info("Offset-based parallel population completed in {$executionTime} seconds.");
}
return $result;
}
/**
* Populate index using offset-based parallel processing
*/
private function populateIndexParallel(string $engine, string $index): int
{
$total = $this->getTotalRecords($index);
if (! $total) {
$this->warn("{$index} table is empty. Nothing to do.");
return Command::SUCCESS;
}
$parallelProcesses = (int) $this->option('parallel');
$recordsPerProcess = ceil($total / $parallelProcesses);
$this->info(sprintf(
'Processing %s records with %d parallel processes, ~%d records per process',
number_format($total),
$parallelProcesses,
$recordsPerProcess
));
// Clear the index first
$this->clearIndex($engine, $index);
// Create offset-based ranges for parallel processing
$ranges = $this->createOffsetRanges($total, $parallelProcesses);
$processes = [];
// Start parallel processes
foreach ($ranges as $i => $range) {
$command = $this->buildWorkerCommand($engine, $index, $range['offset'], $range['limit'], $i); // @phpstan-ignore argument.type
$this->info("Starting worker process {$i}: processing {$range['limit']} records from offset {$range['offset']}");
$process = Process::start($command);
$processes[] = [
'process' => $process,
'id' => $i,
'range' => $range,
];
}
// Monitor processes
$this->monitorProcesses($processes); // @phpstan-ignore argument.type
// Verify final count
$this->verifyIndexPopulation($engine, $index, $total);
$this->info('All parallel processes completed successfully!');
return Command::SUCCESS;
}
/**
* Create offset-based ranges for parallel execution
*
* @return array<string, mixed>
*/
private function createOffsetRanges(int $total, int $processes): array
{
$ranges = [];
$recordsPerProcess = ceil($total / $processes);
for ($i = 0; $i < $processes; $i++) {
$offset = $i * $recordsPerProcess;
$limit = min($recordsPerProcess, $total - $offset);
if ($limit > 0) {
$ranges[] = [
'offset' => $offset,
'limit' => $limit,
'worker_id' => $i,
];
}
}
return $ranges;
}
/**
* Build worker command for offset-based parallel processing
*
* @return list<array<string, float|int<0, max>>>
*/
private function buildWorkerCommand(string $engine, string $index, int $offset, int $limit, int $workerId): string
{
$memoryLimit = $this->option('memory-limit') ?? '4G';
$batchSize = $this->option('batch-size');
$artisanPath = base_path('artisan');
$options = [
"--{$engine}",
"--{$index}",
"--offset={$offset}",
"--limit={$limit}",
"--worker-id={$workerId}",
"--batch-size={$batchSize}",
"--memory-limit={$memoryLimit}",
];
if ($this->option('disable-keys')) {
$options[] = '--disable-keys';
}
return sprintf(
'php -d memory_limit=%s "%s" nntmux:offset-worker %s 2>&1',
$memoryLimit,
$artisanPath,
implode(' ', $options)
);
}
/**
* Monitor parallel processes
*
* @param array<string, mixed> $processes
*/
private function monitorProcesses(array $processes): void
{
$bar = $this->output->createProgressBar(count($processes));
$bar->setFormat('verbose');
$bar->start();
$completed = 0;
$completedProcesses = [];
$failedProcesses = [];
while ($completed < count($processes)) {
foreach ($processes as $index => $processInfo) {
$process = $processInfo['process'];
$processId = $processInfo['id'];
if (in_array($processId, $completedProcesses)) {
continue;
}
if (! $process->running()) {
$completedProcesses[] = $processId;
$completed++;
$bar->advance();
try {
$result = $process->wait();
$exitCode = $result->exitCode();
if ($exitCode === 0) {
$this->info("Worker {$processId} completed successfully");
} else {
$failedProcesses[] = $processId;
$this->error("Worker {$processId} failed with exit code: {$exitCode}");
$output = $result->output();
$errorOutput = $result->errorOutput();
if ($output) {
$this->error("Worker {$processId} output: ".substr($output, -500));
}
if ($errorOutput) {
$this->error("Worker {$processId} error: ".substr($errorOutput, -500));
}
}
} catch (Exception $e) {
$failedProcesses[] = $processId;
$this->error("Worker {$processId} exception: {$e->getMessage()}");
}
}
}
usleep(500000); // 0.5 second delay
}
$bar->finish();
$this->newLine();
if (! empty($failedProcesses)) {
$this->error('Failed workers: '.implode(', ', $failedProcesses));
}
}
/**
* Verify index population
*/
private function verifyIndexPopulation(string $engine, string $index, int $expectedTotal): void
{
$this->info('Verifying index population...');
if ($engine === 'elastic') {
try {
// Wait a moment for ElasticSearch to refresh
sleep(2);
$stats = Elasticsearch::indices()->stats(['index' => $index]);
$actualCount = $stats['indices'][$index]['total']['docs']['count'] ?? 0;
$this->info('Expected: '.number_format($expectedTotal).' records');
$this->info('Actual: '.number_format($actualCount).' records');
if ($actualCount >= $expectedTotal * 0.95) { // Allow for 5% tolerance
$this->info('✓ Index population successful!');
} else {
$this->warn('⚠ Index population may be incomplete');
}
} catch (Exception $e) {
$this->warn("Could not verify index population: {$e->getMessage()}");
}
} else {
$this->info('ManticoreSearch index verification not implemented yet');
}
}
/**
* Clear the search index
*/
private function clearIndex(string $engine, string $index): void
{
if ($engine === 'manticore') {
$indexName = $index === 'releases' ? 'releases_rt' : 'predb_rt';
Search::truncateIndex([$indexName]);
$this->info("Truncated ManticoreSearch index: {$indexName}");
} else {
// For ElasticSearch, just clear the data instead of recreating the index
try {
$exists = Elasticsearch::indices()->exists(['index' => $index]);
if ($exists) {
// Get current document count
$stats = Elasticsearch::indices()->stats(['index' => $index]);
$currentCount = $stats['indices'][$index]['total']['docs']['count'] ?? 0;
if ($currentCount > 0) {
$this->info("ElasticSearch index '{$index}' exists with {$currentCount} documents. Clearing data...");
// Delete all documents but keep the index structure
Elasticsearch::deleteByQuery([
'index' => $index,
'body' => [
'query' => ['match_all' => (object) []],
],
]);
// Force refresh to ensure deletions are visible
Elasticsearch::indices()->refresh(['index' => $index]);
$this->info("Cleared all documents from ElasticSearch index: {$index}");
} else {
$this->info("ElasticSearch index '{$index}' exists but is already empty");
}
} else {
$this->info("ElasticSearch index '{$index}' does not exist. Creating optimized index...");
$this->createOptimizedElasticIndex($index);
}
} catch (Exception $e) {
$this->warn("Could not clear ElasticSearch index: {$e->getMessage()}");
$this->info('Attempting to recreate index...');
// Fallback: delete and recreate
try {
Elasticsearch::indices()->delete(['index' => $index]);
} catch (Exception $e) {
// Index might not exist, that's okay
}
$this->createOptimizedElasticIndex($index);
}
}
}
/**
* Create optimized ElasticSearch index
*/
private function createOptimizedElasticIndex(string $indexName): void
{
$settings = [
'index' => $indexName,
'body' => [
'settings' => [
'number_of_shards' => 1,
'number_of_replicas' => 0,
'refresh_interval' => '30s',
'translog' => [
'durability' => 'async',
'sync_interval' => '30s',
'flush_threshold_size' => '1gb',
],
],
'mappings' => $this->getIndexMappings($indexName),
],
];
Elasticsearch::indices()->create($settings);
$this->info("Created optimized ElasticSearch index: {$indexName}");
}
/**
* Get index mappings
*
* @return array<string, mixed>
*/
private function getIndexMappings(string $indexName): array
{
if ($indexName === 'releases') {
return [
'properties' => [
'id' => ['type' => 'long'],
'name' => ['type' => 'text', 'analyzer' => 'standard'],
'searchname' => ['type' => 'text', 'analyzer' => 'standard'],
'plainsearchname' => ['type' => 'text', 'analyzer' => 'standard'],
'fromname' => ['type' => 'text', 'analyzer' => 'standard'],
'categories_id' => ['type' => 'integer'],
'filename' => ['type' => 'text', 'analyzer' => 'standard'],
'postdate' => ['type' => 'date'],
],
];
} else {
return [
'properties' => [
'id' => ['type' => 'long'],
'title' => ['type' => 'text', 'analyzer' => 'standard'],
'filename' => ['type' => 'text', 'analyzer' => 'standard'],
'source' => ['type' => 'keyword'],
],
];
}
}
/**
* Get total records
*/
private function getTotalRecords(string $index): int
{
return $index === 'releases' ? Release::count() : Predb::count();
}
/**
* Get selected engine
*/
private function getSelectedEngine(): ?string
{
foreach (self::SUPPORTED_ENGINES as $engine) {
if ($this->option($engine)) {
return $engine;
}
}
return null;
}
/**
* Get selected index
*/
private function getSelectedIndex(): ?string
{
foreach (self::SUPPORTED_INDEXES as $index) {
if ($this->option($index)) {
return $index;
}
}
return null;
}
}