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:
@@ -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);
|
||||
Reference in New Issue
Block a user