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