182 lines
6.6 KiB
PHP
182 lines
6.6 KiB
PHP
|
|
<?php
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
namespace App\Console\Commands;
|
|||
|
|
|
|||
|
|
use App\Models\SystemSetting;
|
|||
|
|
use App\Services\MonthlyPartitionManager;
|
|||
|
|
use Illuminate\Console\Command;
|
|||
|
|
use Illuminate\Support\Carbon;
|
|||
|
|
use Illuminate\Support\Facades\DB;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Удаляет устаревшие месячные партиции согласно retention-настройкам.
|
|||
|
|
*
|
|||
|
|
* Retention для каждой таблицы хранится в system_settings:
|
|||
|
|
* key = 'partition_retention_months_<table>'
|
|||
|
|
* value = количество месяцев (integer >= 1)
|
|||
|
|
*
|
|||
|
|
* Защита от опасных значений:
|
|||
|
|
* - NULL / отсутствие ключа → пропустить таблицу (не дропать ничего)
|
|||
|
|
* - 0 → пропустить (запрет стирания всего)
|
|||
|
|
* - < 0 → пропустить
|
|||
|
|
* - Минимальное значение, принятое к выполнению: 1 месяц
|
|||
|
|
*
|
|||
|
|
* Формат имени партиции: <table>_y<YYYY>_m<MM>
|
|||
|
|
* Партиция считается устаревшей, если её месяц < (текущий месяц − 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('<fg=yellow>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(" <fg=gray>skip</> {$table}: retention not configured");
|
|||
|
|
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$partitions = $manager->listPartitions($table);
|
|||
|
|
|
|||
|
|
if (empty($partitions)) {
|
|||
|
|
$this->line(" <fg=gray>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(" <fg=yellow>[dry-run] would drop</> {$partitionName}");
|
|||
|
|
} else {
|
|||
|
|
$this->dropPartition($partitionName);
|
|||
|
|
$this->line(" <fg=red>dropped</> {$partitionName}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$dropped++;
|
|||
|
|
$totalDropped++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if ($dropped === 0) {
|
|||
|
|
$this->line(" <fg=green>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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Парсит имя партиции вида <anything>_y<YYYY>_m<MM> и возвращает 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
|
|||
|
|
{
|
|||
|
|
DB::statement("DROP TABLE IF EXISTS {$partitionName}");
|
|||
|
|
}
|
|||
|
|
}
|