290 lines
9.8 KiB
PHP
290 lines
9.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands\Enhance;
|
|
|
|
use App\Models\EnhanceJob;
|
|
use App\Services\Enhance\EnhanceStorageService;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Throwable;
|
|
|
|
final class CleanupEnhanceJobsCommand extends Command
|
|
{
|
|
protected $signature = 'enhance:cleanup
|
|
{--dry-run : Preview cleanup actions only}
|
|
{--force : Delete files and update records}
|
|
{--only= : Restrict cleanup to expired, deleted, failed, or orphaned}
|
|
{--days= : Override retention days for failed or deleted cleanup}';
|
|
|
|
protected $description = 'Safely clean expired, deleted, failed, and orphaned Enhance files.';
|
|
|
|
public function __construct(
|
|
private readonly EnhanceStorageService $storage,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
public function handle(): int
|
|
{
|
|
$target = strtolower(trim((string) $this->option('only')));
|
|
$validTargets = ['', 'expired', 'deleted', 'failed', 'orphaned'];
|
|
|
|
if (! in_array($target, $validTargets, true)) {
|
|
$this->error('The --only option must be one of: expired, deleted, failed, orphaned.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
if ((bool) $this->option('dry-run') && (bool) $this->option('force')) {
|
|
$this->error('Use either --dry-run or --force, not both.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$dryRun = (bool) $this->option('dry-run') || ! (bool) $this->option('force');
|
|
$daysOverride = $this->option('days');
|
|
$selectedTarget = $target !== '' ? $target : 'all';
|
|
|
|
Log::info('enhance.cleanup.started', [
|
|
'dry_run' => $dryRun,
|
|
'target' => $selectedTarget,
|
|
'days_override' => $daysOverride,
|
|
]);
|
|
|
|
if ($dryRun) {
|
|
$this->warn('Running in dry-run mode. No files will be deleted.');
|
|
}
|
|
|
|
$rows = [];
|
|
|
|
if ($target === '' || $target === 'expired') {
|
|
$expired = $this->cleanupExpiredCompletedJobs($dryRun);
|
|
$rows[] = ['expired', $expired['jobs'], $expired['files'], $dryRun ? 'dry-run' : 'cleaned'];
|
|
}
|
|
|
|
if ($target === '' || $target === 'failed') {
|
|
$failed = $this->cleanupFailedJobs($dryRun, $daysOverride);
|
|
$rows[] = ['failed', $failed['jobs'], $failed['files'], $dryRun ? 'dry-run' : 'cleaned'];
|
|
}
|
|
|
|
if ($target === '' || $target === 'deleted') {
|
|
$deleted = $this->cleanupSoftDeletedJobs($dryRun, $daysOverride);
|
|
$rows[] = ['deleted', $deleted['jobs'], $deleted['files'], $dryRun ? 'dry-run' : 'cleaned'];
|
|
}
|
|
|
|
if ($target === 'orphaned') {
|
|
$orphaned = $this->scanOrphanedFiles($dryRun);
|
|
$rows[] = ['orphaned', $orphaned['files'], $orphaned['deleted'], $dryRun ? 'dry-run' : 'deleted'];
|
|
|
|
if ($orphaned['unsupported']) {
|
|
$this->warn('Orphaned file scan was skipped because the configured disk does not support safe listing.');
|
|
}
|
|
|
|
foreach ($orphaned['sample'] as $path) {
|
|
$this->line(' - ' . $path);
|
|
}
|
|
}
|
|
|
|
if ($rows !== []) {
|
|
$this->table(['Target', 'Jobs/Files', 'Files deleted', 'Mode'], $rows);
|
|
}
|
|
|
|
Log::info('enhance.cleanup.completed', [
|
|
'dry_run' => $dryRun,
|
|
'target' => $selectedTarget,
|
|
'rows' => $rows,
|
|
]);
|
|
|
|
$this->info('Enhance cleanup finished.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function cleanupExpiredCompletedJobs(bool $dryRun): array
|
|
{
|
|
$query = EnhanceJob::query()
|
|
->where('status', EnhanceJob::STATUS_COMPLETED)
|
|
->whereNotNull('expires_at')
|
|
->where('expires_at', '<=', now());
|
|
|
|
return $this->cleanupJobs($query, $dryRun, 'expired', fn (): array => [
|
|
'status' => EnhanceJob::STATUS_EXPIRED,
|
|
]);
|
|
}
|
|
|
|
private function cleanupFailedJobs(bool $dryRun, mixed $daysOverride): array
|
|
{
|
|
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.failed_expires_after_days', 7));
|
|
$cutoff = now()->subDays($days);
|
|
|
|
$query = EnhanceJob::query()
|
|
->where('status', EnhanceJob::STATUS_FAILED)
|
|
->where(function (Builder $builder) use ($cutoff): void {
|
|
$builder
|
|
->where('finished_at', '<=', $cutoff)
|
|
->orWhere(function (Builder $fallback) use ($cutoff): void {
|
|
$fallback->whereNull('finished_at')->where('created_at', '<=', $cutoff);
|
|
});
|
|
});
|
|
|
|
return $this->cleanupJobs($query, $dryRun, 'failed-expired');
|
|
}
|
|
|
|
private function cleanupSoftDeletedJobs(bool $dryRun, mixed $daysOverride): array
|
|
{
|
|
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.deleted_file_grace_days', 1));
|
|
$cutoff = now()->subDays($days);
|
|
|
|
$query = EnhanceJob::withTrashed()
|
|
->whereNotNull('deleted_at')
|
|
->where('deleted_at', '<=', $cutoff);
|
|
|
|
return $this->cleanupJobs($query, $dryRun, 'deleted-grace');
|
|
}
|
|
|
|
private function cleanupJobs(Builder $query, bool $dryRun, string $reason, ?callable $attributes = null): array
|
|
{
|
|
$result = ['jobs' => 0, 'files' => 0];
|
|
$chunkSize = max(1, (int) config('enhance.lifecycle.cleanup_chunk_size', 100));
|
|
|
|
$query->chunkById($chunkSize, function ($jobs) use (&$result, $dryRun, $reason, $attributes): void {
|
|
foreach ($jobs as $job) {
|
|
$result['jobs']++;
|
|
$result['files'] += count($this->enhanceJobPaths($job));
|
|
|
|
if ($dryRun) {
|
|
continue;
|
|
}
|
|
|
|
$deleteResult = $this->storage->deleteFilesForJob($job);
|
|
$metadata = is_array($job->metadata) ? $job->metadata : [];
|
|
|
|
$job->forceFill(array_merge(
|
|
$this->cleanupAttributesForJob($job),
|
|
$attributes ? $attributes($job) : [],
|
|
[
|
|
'metadata' => array_merge($metadata, [
|
|
'cleanup' => [
|
|
'files_removed_at' => now()->toIso8601String(),
|
|
'reason' => $reason,
|
|
'deleted' => $deleteResult['deleted'],
|
|
],
|
|
]),
|
|
],
|
|
))->save();
|
|
}
|
|
});
|
|
|
|
return $result;
|
|
}
|
|
|
|
private function scanOrphanedFiles(bool $dryRun): array
|
|
{
|
|
$disk = $this->storage->diskName();
|
|
$knownPaths = array_fill_keys(array_map(
|
|
static fn (string $path): string => ltrim($path, '/'),
|
|
$this->storage->listKnownJobPaths(),
|
|
), true);
|
|
$sample = [];
|
|
$result = [
|
|
'files' => 0,
|
|
'deleted' => 0,
|
|
'unsupported' => false,
|
|
'sample' => [],
|
|
];
|
|
|
|
foreach ($this->enhancePrefixes() as $prefix) {
|
|
try {
|
|
$files = Storage::disk($disk)->allFiles($prefix);
|
|
} catch (Throwable) {
|
|
$result['unsupported'] = true;
|
|
|
|
return $result;
|
|
}
|
|
|
|
foreach ($files as $file) {
|
|
$normalized = ltrim($file, '/');
|
|
|
|
if (isset($knownPaths[$normalized])) {
|
|
continue;
|
|
}
|
|
|
|
$result['files']++;
|
|
|
|
if (count($sample) < 20) {
|
|
$sample[] = $normalized;
|
|
}
|
|
|
|
if (! $dryRun && $this->storage->safeDelete($disk, $normalized)) {
|
|
$result['deleted']++;
|
|
}
|
|
}
|
|
}
|
|
|
|
$result['sample'] = $sample;
|
|
|
|
return $result;
|
|
}
|
|
|
|
private function cleanupAttributesForJob(EnhanceJob $job): array
|
|
{
|
|
$attributes = [];
|
|
|
|
if ($this->storage->isEnhancePath($job->source_path)) {
|
|
$attributes['source_disk'] = null;
|
|
$attributes['source_path'] = null;
|
|
$attributes['source_hash'] = null;
|
|
}
|
|
|
|
if ($this->storage->isEnhancePath($job->output_path)) {
|
|
$attributes['output_disk'] = null;
|
|
$attributes['output_path'] = null;
|
|
$attributes['output_hash'] = null;
|
|
$attributes['output_width'] = null;
|
|
$attributes['output_height'] = null;
|
|
$attributes['output_filesize'] = null;
|
|
$attributes['output_mime'] = null;
|
|
}
|
|
|
|
if ($this->storage->isEnhancePath($job->preview_path)) {
|
|
$attributes['preview_disk'] = null;
|
|
$attributes['preview_path'] = null;
|
|
}
|
|
|
|
return $attributes;
|
|
}
|
|
|
|
private function enhanceJobPaths(EnhanceJob $job): array
|
|
{
|
|
return array_values(array_filter([
|
|
$this->storage->isEnhancePath($job->source_path) ? trim((string) $job->source_path) : null,
|
|
$this->storage->isEnhancePath($job->output_path) ? trim((string) $job->output_path) : null,
|
|
$this->storage->isEnhancePath($job->preview_path) ? trim((string) $job->preview_path) : null,
|
|
]));
|
|
}
|
|
|
|
private function enhancePrefixes(): array
|
|
{
|
|
return array_values(array_filter(array_unique(array_map(
|
|
static fn (string $prefix): string => trim($prefix, '/'),
|
|
[
|
|
(string) config('enhance.source_prefix', 'enhance/sources'),
|
|
(string) config('enhance.output_prefix', 'enhance/outputs'),
|
|
(string) config('enhance.preview_prefix', 'enhance/previews'),
|
|
],
|
|
))));
|
|
}
|
|
|
|
private function resolveDays(mixed $daysOverride, int $default): int
|
|
{
|
|
if ($daysOverride === null || $daysOverride === '') {
|
|
return max(0, $default);
|
|
}
|
|
|
|
return max(0, (int) $daysOverride);
|
|
}
|
|
} |