From 2f4575b8a00bbbdff46f46dadfb84e5046b2a3db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 16 May 2026 17:37:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(import):=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=20MonthlyPartitionManager=20+=20=D1=80=D0=B5=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=20partitions:create-months?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Выносит DDL-логику создания месячных RANGE-партиций из команды PartitionsCreateMonths в переиспользуемый сервис MonthlyPartitionManager. Сервис используется командой (DRY) и будет использован HistoricalImportService для партиций под исторические даты CSV. - MonthlyPartitionManager::ensureRange(table, from, to) — гарантирует партиции под диапазон дат, идемпотентно; отвергает незарегистрированные таблицы - MonthlyPartitionManager::ensureMonth(table, monthStart) — одна партиция - PartitionsCreateMonths рефакторена: убраны PARTITIONED_TABLES, partitionExists(), use DB; inject MonthlyPartitionManager через handle() - Test: MonthlyPartitionManagerTest (3 теста, DatabaseTransactions — DDL откат) - Regression: PartitionsCreateMonthsTest (4 теста) — зелёный, поведение не изменилось Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/PartitionsCreateMonths.php | 44 ++-------- app/app/Services/MonthlyPartitionManager.php | 83 +++++++++++++++++++ .../Import/MonthlyPartitionManagerTest.php | 46 ++++++++++ 3 files changed, 136 insertions(+), 37 deletions(-) create mode 100644 app/app/Services/MonthlyPartitionManager.php create mode 100644 app/tests/Feature/Import/MonthlyPartitionManagerTest.php diff --git a/app/app/Console/Commands/PartitionsCreateMonths.php b/app/app/Console/Commands/PartitionsCreateMonths.php index 4dcd4c34..e7a3f028 100644 --- a/app/app/Console/Commands/PartitionsCreateMonths.php +++ b/app/app/Console/Commands/PartitionsCreateMonths.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Console\Commands; +use App\Services\MonthlyPartitionManager; use Illuminate\Console\Command; use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\DB; /** * Создаёт ежемесячные партиции для `deals` и `supplier_lead_costs` @@ -30,14 +30,7 @@ class PartitionsCreateMonths extends Command /** @var string */ protected $description = 'Создаёт ежемесячные партиции deals и supplier_lead_costs на N месяцев вперёд (idempotent)'; - /** - * Список таблиц, которые партиционируются по received_at помесячно. - * - * @var array - */ - private const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs']; - - public function handle(): int + public function handle(MonthlyPartitionManager $manager): int { $ahead = max(1, (int) $this->option('ahead')); $now = Carbon::now()->startOfMonth(); @@ -47,27 +40,17 @@ class PartitionsCreateMonths extends Command for ($i = 0; $i <= $ahead; $i++) { $monthStart = $now->copy()->addMonths($i); - $monthEnd = $monthStart->copy()->addMonth(); - foreach (self::PARTITIONED_TABLES as $table) { + foreach (MonthlyPartitionManager::PARTITIONED_TABLES as $table) { $partitionName = sprintf('%s_%s', $table, $monthStart->format('Y_m')); - if ($this->partitionExists($partitionName)) { + if ($manager->ensureMonth($table, $monthStart)) { + $created++; + $this->info(" create {$partitionName}"); + } else { $skipped++; $this->line(" skip {$partitionName} (already exists)"); - - continue; } - - DB::statement(sprintf( - "CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')", - $partitionName, - $table, - $monthStart->format('Y-m-d'), - $monthEnd->format('Y-m-d'), - )); - $created++; - $this->info(" create {$partitionName} [{$monthStart->format('Y-m-d')} → {$monthEnd->format('Y-m-d')})"); } } @@ -76,17 +59,4 @@ class PartitionsCreateMonths extends Command return self::SUCCESS; } - - /** - * Проверка существования партиции через pg_class (быстрее information_schema). - */ - private function partitionExists(string $name): bool - { - $row = DB::selectOne( - "SELECT 1 AS exists FROM pg_class WHERE relname = ? AND relkind = 'r'", - [$name], - ); - - return $row !== null; - } } diff --git a/app/app/Services/MonthlyPartitionManager.php b/app/app/Services/MonthlyPartitionManager.php new file mode 100644 index 00000000..6c7a1d91 --- /dev/null +++ b/app/app/Services/MonthlyPartitionManager.php @@ -0,0 +1,83 @@ + Таблицы, партиционированные по received_at помесячно. */ + public const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs']; + + /** + * Гарантирует наличие месячных партиций таблицы для всех месяцев, + * пересекающих [$from, $to] включительно. + * + * @return int Сколько партиций реально создано (0 — все уже были). + */ + public function ensureRange(string $table, CarbonInterface $from, CarbonInterface $to): int + { + if (! in_array($table, self::PARTITIONED_TABLES, true)) { + throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно"); + } + + $month = $from->copy()->startOfMonth(); + $last = $to->copy()->startOfMonth(); + $created = 0; + + while ($month->lessThanOrEqualTo($last)) { + $created += $this->ensureMonth($table, $month) ? 1 : 0; + $month->addMonth(); + } + + return $created; + } + + /** + * Создаёт одну месячную партицию. Возвращает true, если партиция создана, + * false — если уже существовала. + */ + public function ensureMonth(string $table, CarbonInterface $monthStart): bool + { + if (! in_array($table, self::PARTITIONED_TABLES, true)) { + throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно"); + } + + $start = $monthStart->copy()->startOfMonth(); + $end = $start->copy()->addMonth(); + $partition = sprintf('%s_%s', $table, $start->format('Y_m')); + + $exists = DB::selectOne( + "SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'", + [$partition], + ); + + if ($exists !== null) { + return false; + } + + DB::statement(sprintf( + "CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')", + $partition, + $table, + $start->format('Y-m-d'), + $end->format('Y-m-d'), + )); + + return true; + } +} diff --git a/app/tests/Feature/Import/MonthlyPartitionManagerTest.php b/app/tests/Feature/Import/MonthlyPartitionManagerTest.php new file mode 100644 index 00000000..e536c944 --- /dev/null +++ b/app/tests/Feature/Import/MonthlyPartitionManagerTest.php @@ -0,0 +1,46 @@ +ensureRange( + 'deals', + Carbon::parse('2024-02-15'), + Carbon::parse('2024-04-03'), + ); + + expect($created)->toBeGreaterThanOrEqual(3) + ->and(partitionExists('deals_2024_02'))->toBeTrue() + ->and(partitionExists('deals_2024_03'))->toBeTrue() + ->and(partitionExists('deals_2024_04'))->toBeTrue(); +}); + +test('ensureRange идемпотентна — повторный вызов не падает', function (): void { + $manager = app(MonthlyPartitionManager::class); + + $manager->ensureRange('deals', Carbon::parse('2024-02-15'), Carbon::parse('2024-02-20')); + $secondRun = $manager->ensureRange('deals', Carbon::parse('2024-02-15'), Carbon::parse('2024-02-20')); + + expect($secondRun)->toBe(0); // всё уже существует +}); + +test('ensureRange отвергает неизвестную таблицу', function (): void { + app(MonthlyPartitionManager::class)->ensureRange('orders', now(), now()); +})->throws(InvalidArgumentException::class);