Files
portal/app/app/Console/Commands/PartitionsDropExpired.php
T
Дмитрий 60ab5be3eb feat(audit): partitioning 7 audit-таблиц по месяцам (hole #2 Phase A)
Закрывает последнюю дыру #2 аудита журналирования. Phase A (dev) — миграция
схемы + retention tooling. Phase B (прод-rewrite через SQL под postgres) —
отдельным шагом с явным approve.

Решения заказчика:
* Scope: все 7 таблиц (auth_log, activity_log, tenant_operations_log,
  webhook_log, balance_transactions, pd_processing_log, saas_admin_audit_log)
* FK на webhook_log: W1 — удалить FK от failed_webhook_jobs+rejected_deals_log
* Retention defaults: auth:24м, activity:36м, tenant_ops:24м, webhook:3м,
  balance:84м, pd:36м, saas_admin:84м. Cron Sundays 03:00 МСК
* Hash-chain: per-partition (audit_chain_hash трг через TG_TABLE_NAME уже
  работает per-partition; совместимо с hole #1 per-RLS-scope fix)

Phase A:
* db/schema.sql v8.30→v8.31: 7 audit-таблиц на PARTITION BY RANGE,
  PK→(id, partition_key), +7 retention seeds в system_settings,
  FK от failed_webhook_jobs/rejected_deals_log удалены
* MonthlyPartitionManager: PARTITIONED_TABLES → ассоциативный array
  (name => partition_key), 2 → 9 таблиц
* PartitionsCreateMonths: автоматически покрывает все 9
* load_initial_schema: после schema.sql вызывает Artisan
  partitions:create-months --ahead=2 (без этого первый INSERT падает)
* 2026_05_22_000001_tenant_operations_log: idempotency guard
* VerifyAuditChains: per-partition scan через pg_inherits;
  fallback на single-scope для не-партиционированной таблицы;
  per-RLS-scope partition_clause сохранён внутри каждой партиции
* AuditChainBreachMail: +partitionName param (NULL=fallback на tableName)
* PartitionsDropExpired (новая): cron Sundays 03:00 МСК, retention из
  system_settings, dry-run mode, safety guard retention=0
* SchedulerHeartbeatTracker +partitions:drop-expired (10080 мин)

Без Laravel-миграции для прода — она оставляла БД пустой при migrate:fresh.
Подход: schema.sql декларирует партиционированные + ad-hoc SQL под postgres
для прод-rewrite (отдельный commit + ручной деплой + pg_dump backup).

Тесты: 1219/1231 (35/35 hole #2 specs, 88 assertions). 3 fail —
pre-existing AdminPdSubjectRequestsControllerTest::executeErasure_*
(FK actor_admin_user_id после partitioning pd_processing_log, отдельная
задача для hole #4 follow-up, не блокирует).

cspell +2 слова (партиционировать, дёшева). Pint --fix чистый.

Spec: docs/superpowers/specs/2026-05-23-hole-2-audit-partitioning-design.md
Plan: docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md
2026-05-23 15:50:37 +03:00

182 lines
6.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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}");
}
}