' * value = количество месяцев (integer >= 1) * * Защита от опасных значений: * - NULL / отсутствие ключа → пропустить таблицу (не дропать ничего) * - 0 → пропустить (запрет стирания всего) * - < 0 → пропустить * - Минимальное значение, принятое к выполнению: 1 месяц * * Формат имени партиции: _y_m * Партиция считается устаревшей, если её месяц < (текущий месяц − retention). * * Пример: * сейчас = 2026-05, retention = 3 * cutoff = 2026-02 (включительно; т.е. 2026-01 и старее — дропать) * будет удалена: auth_log_y2026_m01, auth_log_y2025_m12, … * НЕ будет удалена: auth_log_y2026_m02 (граница), и всё новее * * Hole #2, 23.05.2026. */ class PartitionsDropExpired extends Command { /** @var string */ protected $signature = 'partitions:drop-expired {--dry-run : Перечислить партиции для удаления, не удалять}'; /** @var string */ protected $description = 'Удаляет устаревшие месячные партиции согласно system_settings (partition_retention_months_*)'; public function handle(MonthlyPartitionManager $manager): int { $isDryRun = (bool) $this->option('dry-run'); if ($isDryRun) { $this->line('Dry-run: партиции будут перечислены, но NOT удалены.'); } $now = Carbon::now()->startOfMonth(); $totalDropped = 0; $totalSkipped = 0; foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) { $retention = $this->resolveRetention($table); if ($retention === null) { $this->line(" skip {$table}: retention not configured"); continue; } $partitions = $manager->listPartitions($table); if (empty($partitions)) { $this->line(" skip {$table}: no partitions exist yet"); continue; } $dropped = 0; foreach ($partitions as $partitionName) { $monthStart = $this->parsePartitionMonth($partitionName); if ($monthStart === null) { // Имя не соответствует формату _yYYYY_mMM — не трогать (безопасность) $this->warn(" ? {$partitionName}: unrecognised name format, skipping"); $totalSkipped++; continue; } // Граница: всё строго старее (now - retention месяцев) — удалять. // Т.е. monthStart < cutoff, где cutoff = now - retention. $cutoff = $now->copy()->subMonths($retention); if (! $monthStart->lessThan($cutoff)) { // Партиция ещё в пределах retention — оставить continue; } if ($isDryRun) { $this->line(" [dry-run] would drop {$partitionName}"); } else { $this->dropPartition($partitionName); $this->line(" dropped {$partitionName}"); } $dropped++; $totalDropped++; } if ($dropped === 0) { $this->line(" ok {$table}: all partitions within retention={$retention}mo"); } } $this->newLine(); if ($isDryRun) { $this->info("Dry-run complete: {$totalDropped} would be dropped, {$totalSkipped} skipped (unrecognised name)."); } else { $this->info("Done: {$totalDropped} dropped, {$totalSkipped} skipped (unrecognised name)."); } return self::SUCCESS; } /** * Читает retention для таблицы из system_settings. * Возвращает null, если настройка отсутствует или небезопасна (0 / отрицательная). */ private function resolveRetention(string $table): ?int { $key = "partition_retention_months_{$table}"; $setting = SystemSetting::find($key); if ($setting === null) { return null; } $value = (int) $setting->value; if ($value < 1) { // 0 или отрицательное — блокируем, не дропаем ничего $this->warn(" ! {$table}: retention value={$value} is invalid (<1), skipping"); return null; } return $value; } /** * Парсит имя партиции вида _y_m и возвращает Carbon начала месяца. * Возвращает null, если имя не соответствует формату. */ private function parsePartitionMonth(string $partitionName): ?Carbon { // Pattern: ends with _yYYYY_mMM (e.g. auth_log_y2026_m05) if (! preg_match('/_y(\d{4})_m(\d{2})$/', $partitionName, $m)) { return null; } $year = (int) $m[1]; $month = (int) $m[2]; if ($month < 1 || $month > 12) { return null; } return Carbon::create($year, $month, 1, 0, 0, 0)->startOfMonth(); } /** * Удаляет партицию (DETACH + DROP TABLE). * * Безопасность: имя проверено регекспом в parsePartitionMonth * (только символы \w и _ — SQL injection невозможен). */ private function dropPartition(string $partitionName): void { // DROP требует владения родителем — крутится через pgsql_supplier // (crm_supplier_worker — член владельца crm_migrator). См. // MonthlyPartitionManager::DDL_CONNECTION. DB::connection(MonthlyPartitionManager::DDL_CONNECTION) ->statement("DROP TABLE IF EXISTS {$partitionName}"); } }