291 lines
10 KiB
PHP
291 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands\Enhance;
|
|
|
|
use App\Models\EnhanceJob;
|
|
use App\Services\Enhance\EnhanceProcessorFactory;
|
|
use App\Services\Enhance\EnhanceStorageService;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Throwable;
|
|
|
|
final class EnhanceRunCommand extends Command
|
|
{
|
|
protected $signature = 'enhance:run
|
|
{--id= : Process specific job ID(s), comma-separated}
|
|
{--limit=1 : Max pending/queued jobs to pick up from the queue (0 = all)}
|
|
{--engine= : Override the processing engine for this run (stub, external_worker)}
|
|
{--failed : Also include failed jobs when scanning the queue}
|
|
{--dry-run : Show what would be processed without executing}';
|
|
|
|
protected $description = 'Synchronously process pending enhance jobs inline — useful for debugging with -v / -vv / -vvv.';
|
|
|
|
private const PROCESSABLE_STATUSES = [
|
|
EnhanceJob::STATUS_PENDING,
|
|
EnhanceJob::STATUS_QUEUED,
|
|
EnhanceJob::STATUS_PROCESSING,
|
|
EnhanceJob::STATUS_FAILED,
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly EnhanceProcessorFactory $processorFactory,
|
|
private readonly EnhanceStorageService $storage,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
public function handle(): int
|
|
{
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
$engineOverride = trim((string) $this->option('engine'));
|
|
$idOption = trim((string) $this->option('id'));
|
|
$limit = max(0, (int) $this->option('limit'));
|
|
$includeFailed = (bool) $this->option('failed');
|
|
|
|
if ($engineOverride !== '' && ! in_array($engineOverride, [EnhanceJob::ENGINE_STUB, EnhanceJob::ENGINE_EXTERNAL_WORKER], true)) {
|
|
$this->error("Unknown engine override: {$engineOverride}. Use 'stub' or 'external_worker'.");
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$jobs = $this->resolveJobs($idOption, $limit, $includeFailed);
|
|
|
|
if ($jobs->isEmpty()) {
|
|
$this->info('No eligible enhance jobs found.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$this->info(sprintf('Found %d job(s) to process.', $jobs->count()));
|
|
$this->newLine();
|
|
|
|
if ($dryRun) {
|
|
$this->warn('Dry-run mode — no jobs will be processed.');
|
|
|
|
foreach ($jobs as $job) {
|
|
$engine = $engineOverride !== '' ? "{$engineOverride} (overridden)" : $job->engine;
|
|
$this->line(sprintf(
|
|
' [dry-run] Job #%d status=%-12s engine=%-18s scale=%dx mode=%-14s user_id=%d',
|
|
$job->id,
|
|
$job->status,
|
|
$engine,
|
|
$job->scale,
|
|
$job->mode,
|
|
$job->user_id,
|
|
));
|
|
}
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$processed = 0;
|
|
$failed = 0;
|
|
|
|
foreach ($jobs as $job) {
|
|
if ($this->processJob($job, $engineOverride)) {
|
|
$processed++;
|
|
} else {
|
|
$failed++;
|
|
}
|
|
|
|
$this->newLine();
|
|
}
|
|
|
|
$this->info(sprintf('Done: %d completed, %d failed.', $processed, $failed));
|
|
|
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
|
|
private function resolveJobs(string $idOption, int $limit, bool $includeFailed): Collection
|
|
{
|
|
if ($idOption !== '') {
|
|
$ids = array_filter(array_map('intval', explode(',', $idOption)));
|
|
|
|
return EnhanceJob::query()
|
|
->whereIn('id', $ids)
|
|
->whereIn('status', self::PROCESSABLE_STATUSES)
|
|
->get();
|
|
}
|
|
|
|
$statuses = [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING];
|
|
|
|
if ($includeFailed) {
|
|
$statuses[] = EnhanceJob::STATUS_FAILED;
|
|
}
|
|
|
|
$query = EnhanceJob::query()->whereIn('status', $statuses)->oldest();
|
|
|
|
if ($limit > 0) {
|
|
$query->limit($limit);
|
|
}
|
|
|
|
return $query->get();
|
|
}
|
|
|
|
private function processJob(EnhanceJob $job, string $engineOverride): bool
|
|
{
|
|
$engine = $engineOverride !== '' ? $engineOverride : (string) $job->engine;
|
|
|
|
$this->line(sprintf('<comment>--- Job #%d ---</comment>', $job->id));
|
|
$this->line(sprintf(' Status : %s', $job->status));
|
|
$this->line(sprintf(' Engine : %s%s', $engine, $engineOverride !== '' ? ' (overridden)' : ''));
|
|
$this->line(sprintf(' Scale : %dx', $job->scale));
|
|
$this->line(sprintf(' Mode : %s', $job->mode));
|
|
$this->line(sprintf(' User : #%d', $job->user_id));
|
|
|
|
if ($this->output->isVerbose()) {
|
|
$this->line(sprintf(
|
|
' Source : disk=%-10s path=%s',
|
|
$job->source_disk ?: '(default)',
|
|
$job->source_path ?: '—',
|
|
));
|
|
$this->line(sprintf(
|
|
' Input : %dx%d size=%s mime=%s',
|
|
(int) $job->input_width,
|
|
(int) $job->input_height,
|
|
$this->formatBytes((int) $job->input_filesize),
|
|
$job->input_mime ?: '—',
|
|
));
|
|
|
|
if ($job->error_message !== null) {
|
|
$this->warn(sprintf(' Previous error: %s', $job->error_message));
|
|
}
|
|
}
|
|
|
|
if (! in_array($job->status, self::PROCESSABLE_STATUSES, true)) {
|
|
$this->warn(sprintf(' Skipping: status "%s" is not processable.', $job->status));
|
|
|
|
return false;
|
|
}
|
|
|
|
$job->forceFill([
|
|
'status' => EnhanceJob::STATUS_PROCESSING,
|
|
'started_at' => now(),
|
|
'finished_at' => null,
|
|
'error_message' => null,
|
|
])->save();
|
|
|
|
$started = microtime(true);
|
|
$completedExpiryDays = (int) config('enhance.lifecycle.completed_expires_after_days', 30);
|
|
|
|
try {
|
|
$this->line(' Processing...');
|
|
|
|
$processor = $this->processorFactory->make($engine);
|
|
$result = $processor->process($job);
|
|
|
|
if ($this->output->isVerbose()) {
|
|
$this->line(sprintf(
|
|
' Output : %dx%d size=%s mime=%s',
|
|
$result->width,
|
|
$result->height,
|
|
$this->formatBytes($result->filesize),
|
|
$result->mime,
|
|
));
|
|
$this->line(sprintf(
|
|
' Stored : disk=%-10s path=%s',
|
|
$result->disk,
|
|
$result->path,
|
|
));
|
|
}
|
|
|
|
$this->line(' Generating preview...');
|
|
$preview = $this->storage->createPreviewFromStoredOutput($job, $result->disk, $result->path) ?? [];
|
|
|
|
$outputHash = null;
|
|
$outputContents = Storage::disk($result->disk)->get($result->path);
|
|
|
|
if (is_string($outputContents) && $outputContents !== '') {
|
|
$outputHash = hash('sha256', $outputContents);
|
|
}
|
|
|
|
$job->forceFill([
|
|
'status' => EnhanceJob::STATUS_COMPLETED,
|
|
'output_disk' => $result->disk,
|
|
'output_path' => $result->path,
|
|
'output_hash' => $outputHash,
|
|
'output_width' => $result->width,
|
|
'output_height' => $result->height,
|
|
'output_filesize' => $result->filesize,
|
|
'output_mime' => $result->mime,
|
|
'metadata' => array_merge($job->metadata ?? [], $result->metadata ?? []),
|
|
'processing_seconds' => (int) round(microtime(true) - $started),
|
|
'finished_at' => now(),
|
|
'expires_at' => $completedExpiryDays > 0 ? now()->addDays($completedExpiryDays) : null,
|
|
] + $preview)->save();
|
|
|
|
$elapsed = round(microtime(true) - $started, 2);
|
|
$this->info(sprintf(' Completed in %.2fs', $elapsed));
|
|
|
|
if ($this->output->isVerbose() && ! empty($result->metadata)) {
|
|
$this->line(' Metadata:');
|
|
|
|
foreach ($result->metadata as $key => $value) {
|
|
$display = is_scalar($value) ? (string) $value : json_encode($value);
|
|
$this->line(sprintf(' %-30s %s', $key . ':', $display));
|
|
}
|
|
}
|
|
|
|
return true;
|
|
} catch (Throwable $exception) {
|
|
$elapsed = round(microtime(true) - $started, 2);
|
|
|
|
$job->forceFill([
|
|
'status' => EnhanceJob::STATUS_FAILED,
|
|
'error_message' => Str::limit($exception->getMessage(), 1000),
|
|
'processing_seconds' => (int) round(microtime(true) - $started),
|
|
'finished_at' => now(),
|
|
])->save();
|
|
|
|
$this->error(sprintf(' Failed in %.2fs: %s', $elapsed, $exception->getMessage()));
|
|
|
|
if ($this->output->isVerbose()) {
|
|
$this->line(sprintf(' Exception : %s', get_class($exception)));
|
|
$this->line(sprintf(' At : %s:%d', $exception->getFile(), $exception->getLine()));
|
|
|
|
$previous = $exception->getPrevious();
|
|
|
|
if ($previous !== null) {
|
|
$this->line(sprintf(' Caused by : %s: %s', get_class($previous), $previous->getMessage()));
|
|
}
|
|
}
|
|
|
|
if ($this->output->isVeryVerbose()) {
|
|
$this->line(' Stack trace:');
|
|
$frames = array_slice(explode("\n", $exception->getTraceAsString()), 0, 25);
|
|
|
|
foreach ($frames as $frame) {
|
|
$this->line(' ' . $frame);
|
|
}
|
|
}
|
|
|
|
Log::warning('enhance.run.command.failed', [
|
|
'enhance_job_id' => $job->id,
|
|
'engine' => $engine,
|
|
'message' => $exception->getMessage(),
|
|
'exception' => get_class($exception),
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private function formatBytes(int $bytes): string
|
|
{
|
|
if ($bytes < 1024) {
|
|
return $bytes . 'B';
|
|
}
|
|
|
|
if ($bytes < 1_048_576) {
|
|
return round($bytes / 1024, 1) . 'KB';
|
|
}
|
|
|
|
return round($bytes / 1_048_576, 1) . 'MB';
|
|
}
|
|
}
|