277 lines
		
	
	
		
			9.6 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			277 lines
		
	
	
		
			9.6 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php
 | |
| 
 | |
| namespace App\Console\Commands;
 | |
| 
 | |
| use Illuminate\Console\Command;
 | |
| use Illuminate\Support\Facades\Redis;
 | |
| 
 | |
| class CleanupRedis extends Command
 | |
| {
 | |
|     protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup}';
 | |
| 
 | |
|     protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, and related data)';
 | |
| 
 | |
|     public function handle()
 | |
|     {
 | |
|         $redis = Redis::connection('horizon');
 | |
|         $prefix = config('horizon.prefix');
 | |
|         $dryRun = $this->option('dry-run');
 | |
|         $skipOverlapping = $this->option('skip-overlapping');
 | |
| 
 | |
|         if ($dryRun) {
 | |
|             $this->info('DRY RUN MODE - No data will be deleted');
 | |
|         }
 | |
| 
 | |
|         $deletedCount = 0;
 | |
|         $totalKeys = 0;
 | |
| 
 | |
|         // Get all keys with the horizon prefix
 | |
|         $keys = $redis->keys('*');
 | |
|         $totalKeys = count($keys);
 | |
| 
 | |
|         $this->info("Scanning {$totalKeys} keys for cleanup...");
 | |
| 
 | |
|         foreach ($keys as $key) {
 | |
|             $keyWithoutPrefix = str_replace($prefix, '', $key);
 | |
|             $type = $redis->command('type', [$keyWithoutPrefix]);
 | |
| 
 | |
|             // Handle hash-type keys (individual jobs)
 | |
|             if ($type === 5) {
 | |
|                 if ($this->shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)) {
 | |
|                     $deletedCount++;
 | |
|                 }
 | |
|             }
 | |
|             // Handle other key types (metrics, lists, etc.)
 | |
|             else {
 | |
|                 if ($this->shouldDeleteOtherKey($redis, $keyWithoutPrefix, $key, $dryRun)) {
 | |
|                     $deletedCount++;
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // Clean up overlapping queues if not skipped
 | |
|         if (! $skipOverlapping) {
 | |
|             $this->info('Cleaning up overlapping queues...');
 | |
|             $overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
 | |
|             $deletedCount += $overlappingCleaned;
 | |
|         }
 | |
| 
 | |
|         if ($dryRun) {
 | |
|             $this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
 | |
|         } else {
 | |
|             $this->info("Deleted {$deletedCount} out of {$totalKeys} keys");
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private function shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)
 | |
|     {
 | |
|         $data = $redis->command('hgetall', [$keyWithoutPrefix]);
 | |
|         $status = data_get($data, 'status');
 | |
| 
 | |
|         // Delete completed and failed jobs
 | |
|         if (in_array($status, ['completed', 'failed'])) {
 | |
|             if ($dryRun) {
 | |
|                 $this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})");
 | |
|             } else {
 | |
|                 $redis->command('del', [$keyWithoutPrefix]);
 | |
|                 $this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})");
 | |
|             }
 | |
| 
 | |
|             return true;
 | |
|         }
 | |
| 
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryRun)
 | |
|     {
 | |
|         // Clean up various Horizon data structures
 | |
|         $patterns = [
 | |
|             'recent_jobs' => 'Recent jobs list',
 | |
|             'failed_jobs' => 'Failed jobs list',
 | |
|             'completed_jobs' => 'Completed jobs list',
 | |
|             'job_classes' => 'Job classes metrics',
 | |
|             'queues' => 'Queue metrics',
 | |
|             'processes' => 'Process metrics',
 | |
|             'supervisors' => 'Supervisor data',
 | |
|             'metrics' => 'General metrics',
 | |
|             'workload' => 'Workload data',
 | |
|         ];
 | |
| 
 | |
|         foreach ($patterns as $pattern => $description) {
 | |
|             if (str_contains($keyWithoutPrefix, $pattern)) {
 | |
|                 if ($dryRun) {
 | |
|                     $this->line("Would delete {$description}: {$keyWithoutPrefix}");
 | |
|                 } else {
 | |
|                     $redis->command('del', [$keyWithoutPrefix]);
 | |
|                     $this->line("Deleted {$description}: {$keyWithoutPrefix}");
 | |
|                 }
 | |
| 
 | |
|                 return true;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // Clean up old timestamped data (older than 7 days)
 | |
|         if (preg_match('/(\d{10})/', $keyWithoutPrefix, $matches)) {
 | |
|             $timestamp = (int) $matches[1];
 | |
|             $weekAgo = now()->subDays(7)->timestamp;
 | |
| 
 | |
|             if ($timestamp < $weekAgo) {
 | |
|                 if ($dryRun) {
 | |
|                     $this->line("Would delete old timestamped data: {$keyWithoutPrefix}");
 | |
|                 } else {
 | |
|                     $redis->command('del', [$keyWithoutPrefix]);
 | |
|                     $this->line("Deleted old timestamped data: {$keyWithoutPrefix}");
 | |
|                 }
 | |
| 
 | |
|                 return true;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
 | |
|     {
 | |
|         $cleanedCount = 0;
 | |
|         $queueKeys = [];
 | |
| 
 | |
|         // Find all queue-related keys
 | |
|         $allKeys = $redis->keys('*');
 | |
|         foreach ($allKeys as $key) {
 | |
|             $keyWithoutPrefix = str_replace($prefix, '', $key);
 | |
|             if (str_contains($keyWithoutPrefix, 'queue:') || preg_match('/queues?[:\-]/', $keyWithoutPrefix)) {
 | |
|                 $queueKeys[] = $keyWithoutPrefix;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         $this->info('Found '.count($queueKeys).' queue-related keys');
 | |
| 
 | |
|         // Group queues by name pattern to find duplicates
 | |
|         $queueGroups = [];
 | |
|         foreach ($queueKeys as $queueKey) {
 | |
|             // Extract queue name (remove timestamps, suffixes)
 | |
|             $baseName = preg_replace('/[:\-]\d+$/', '', $queueKey);
 | |
|             $baseName = preg_replace('/[:\-](pending|reserved|delayed|processing)$/', '', $baseName);
 | |
| 
 | |
|             if (! isset($queueGroups[$baseName])) {
 | |
|                 $queueGroups[$baseName] = [];
 | |
|             }
 | |
|             $queueGroups[$baseName][] = $queueKey;
 | |
|         }
 | |
| 
 | |
|         // Process each group for overlaps
 | |
|         foreach ($queueGroups as $baseName => $keys) {
 | |
|             if (count($keys) > 1) {
 | |
|                 $cleanedCount += $this->deduplicateQueueGroup($redis, $baseName, $keys, $dryRun);
 | |
|             }
 | |
| 
 | |
|             // Also check for duplicate jobs within individual queues
 | |
|             foreach ($keys as $queueKey) {
 | |
|                 $cleanedCount += $this->deduplicateQueueContents($redis, $queueKey, $dryRun);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return $cleanedCount;
 | |
|     }
 | |
| 
 | |
|     private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
 | |
|     {
 | |
|         $cleanedCount = 0;
 | |
|         $this->line("Processing queue group: {$baseName} (".count($keys).' keys)');
 | |
| 
 | |
|         // Sort keys to keep the most recent one
 | |
|         usort($keys, function ($a, $b) {
 | |
|             // Prefer keys without timestamps (they're usually the main queue)
 | |
|             $aHasTimestamp = preg_match('/\d{10}/', $a);
 | |
|             $bHasTimestamp = preg_match('/\d{10}/', $b);
 | |
| 
 | |
|             if ($aHasTimestamp && ! $bHasTimestamp) {
 | |
|                 return 1;
 | |
|             }
 | |
|             if (! $aHasTimestamp && $bHasTimestamp) {
 | |
|                 return -1;
 | |
|             }
 | |
| 
 | |
|             // If both have timestamps, prefer the newer one
 | |
|             if ($aHasTimestamp && $bHasTimestamp) {
 | |
|                 preg_match('/(\d{10})/', $a, $aMatches);
 | |
|                 preg_match('/(\d{10})/', $b, $bMatches);
 | |
| 
 | |
|                 return ($bMatches[1] ?? 0) <=> ($aMatches[1] ?? 0);
 | |
|             }
 | |
| 
 | |
|             return strcmp($a, $b);
 | |
|         });
 | |
| 
 | |
|         // Keep the first (preferred) key, remove others that are empty or redundant
 | |
|         $keepKey = array_shift($keys);
 | |
| 
 | |
|         foreach ($keys as $redundantKey) {
 | |
|             $type = $redis->command('type', [$redundantKey]);
 | |
|             $shouldDelete = false;
 | |
| 
 | |
|             if ($type === 1) { // LIST type
 | |
|                 $length = $redis->command('llen', [$redundantKey]);
 | |
|                 if ($length == 0) {
 | |
|                     $shouldDelete = true;
 | |
|                 }
 | |
|             } elseif ($type === 3) { // SET type
 | |
|                 $count = $redis->command('scard', [$redundantKey]);
 | |
|                 if ($count == 0) {
 | |
|                     $shouldDelete = true;
 | |
|                 }
 | |
|             } elseif ($type === 4) { // ZSET type
 | |
|                 $count = $redis->command('zcard', [$redundantKey]);
 | |
|                 if ($count == 0) {
 | |
|                     $shouldDelete = true;
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             if ($shouldDelete) {
 | |
|                 if ($dryRun) {
 | |
|                     $this->line("  Would delete empty queue: {$redundantKey}");
 | |
|                 } else {
 | |
|                     $redis->command('del', [$redundantKey]);
 | |
|                     $this->line("  Deleted empty queue: {$redundantKey}");
 | |
|                 }
 | |
|                 $cleanedCount++;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return $cleanedCount;
 | |
|     }
 | |
| 
 | |
|     private function deduplicateQueueContents($redis, $queueKey, $dryRun)
 | |
|     {
 | |
|         $cleanedCount = 0;
 | |
|         $type = $redis->command('type', [$queueKey]);
 | |
| 
 | |
|         if ($type === 1) { // LIST type - common for job queues
 | |
|             $length = $redis->command('llen', [$queueKey]);
 | |
|             if ($length > 1) {
 | |
|                 $items = $redis->command('lrange', [$queueKey, 0, -1]);
 | |
|                 $uniqueItems = array_unique($items);
 | |
| 
 | |
|                 if (count($uniqueItems) < count($items)) {
 | |
|                     $duplicates = count($items) - count($uniqueItems);
 | |
| 
 | |
|                     if ($dryRun) {
 | |
|                         $this->line("  Would remove {$duplicates} duplicate jobs from queue: {$queueKey}");
 | |
|                     } else {
 | |
|                         // Rebuild the list with unique items
 | |
|                         $redis->command('del', [$queueKey]);
 | |
|                         foreach (array_reverse($uniqueItems) as $item) {
 | |
|                             $redis->command('lpush', [$queueKey, $item]);
 | |
|                         }
 | |
|                         $this->line("  Removed {$duplicates} duplicate jobs from queue: {$queueKey}");
 | |
|                     }
 | |
|                     $cleanedCount += $duplicates;
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return $cleanedCount;
 | |
|     }
 | |
| }
 | 
