feat(import): сервис MonthlyPartitionManager + рефактор partitions:create-months

Выносит 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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-16 17:37:29 +03:00
parent cf95b87f30
commit 2f4575b8a0
3 changed files with 136 additions and 37 deletions
@@ -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<int, string>
*/
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 <fg=green>{$partitionName}</>");
} else {
$skipped++;
$this->line(" skip <fg=gray>{$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 <fg=green>{$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;
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/**
* Создаёт месячные RANGE-партиции для таблиц, партиционированных по received_at.
*
* Native-замена pg_partman (расширение недоступно на Windows-стеке без сборки
* из исходников). Идемпотентна: партиция, которая уже есть, пропускается.
*
* Используется:
* - cron `partitions:create-months` N месяцев вперёд;
* - HistoricalImportService под исторический диапазон дат CSV.
*/
class MonthlyPartitionManager
{
/** @var array<int, string> Таблицы, партиционированные по 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;
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Services\MonthlyPartitionManager;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
function partitionExists(string $name): bool
{
return DB::selectOne(
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
[$name],
) !== null;
}
test('ensureRange создаёт месячные партиции deals под диапазон', function (): void {
$manager = app(MonthlyPartitionManager::class);
$created = $manager->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);