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