662be183db
- migration 2026_05_27_120000: CREATE TABLE project_routing_snapshots PARTITION BY RANGE (snapshot_date) composite PK (snapshot_date, project_id), FK tenant_id->tenants ON DELETE CASCADE RLS policy tenant_isolation, indexes tenant_date + signal GRANT crm_app_user (SELECT/INSERT/UPDATE), crm_supplier_worker (+DELETE) initial partitions y2026_m05 + y2026_m06 system_settings retention 3m - MonthlyPartitionManager::PARTITIONED_TABLES +'project_routing_snapshots' => 'snapshot_date' - db/schema.sql -> v8.39 - tests: ProjectRoutingSnapshotsTableTest (3) + Unit/MonthlyPartitionManagerTest (1) GREEN Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
162 lines
6.2 KiB
PHP
162 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use Carbon\CarbonInterface;
|
|
use Illuminate\Support\Facades\DB;
|
|
use InvalidArgumentException;
|
|
|
|
/**
|
|
* Создаёт месячные RANGE-партиции для таблиц, партиционированных помесячно.
|
|
*
|
|
* Native-замена pg_partman (расширение недоступно на Windows-стеке без сборки
|
|
* из исходников). Идемпотентна: партиция, которая уже есть, пропускается.
|
|
*
|
|
* Используется:
|
|
* - cron `partitions:create-months` — N месяцев вперёд;
|
|
* - `partitions:drop-expired` — дропает старые партиции;
|
|
* - HistoricalImportService — под исторический диапазон дат CSV.
|
|
*
|
|
* Hole #2 (23.05.2026): расширен до 9 таблиц (+7 audit-таблиц).
|
|
* Ключ партиционирования теперь задаётся per-table в PARTITIONED_TABLES map.
|
|
*/
|
|
class MonthlyPartitionManager
|
|
{
|
|
/**
|
|
* Connection used for partition DDL (CREATE / DROP).
|
|
*
|
|
* На проде партиционированные родители принадлежат `crm_migrator`;
|
|
* `crm_supplier_worker` — член `crm_migrator` (см. db/02_grants.sql),
|
|
* поэтому через `pgsql_supplier` создаёт/дропает партиции, а
|
|
* дефолтный `crm_app_user` — нет. На dev/тестах `pgsql_supplier`
|
|
* фоллбэчит на `postgres` (superuser) — DDL также проходит.
|
|
*
|
|
* Тесты, триггерящие CREATE/DROP через менеджер, должны подключать
|
|
* `Tests\Concerns\SharesSupplierPdo`, иначе DDL уйдёт мимо
|
|
* test-транзакции (см. trait doc).
|
|
*/
|
|
public const DDL_CONNECTION = 'pgsql_supplier';
|
|
|
|
/**
|
|
* Таблицы, партиционированные помесячно.
|
|
* Ключ → имя таблицы, значение → колонка-ключ партиционирования.
|
|
*
|
|
* @var array<string, string>
|
|
*/
|
|
public const PARTITIONED_TABLES = [
|
|
// Бизнес-таблицы (исходные)
|
|
'deals' => 'received_at',
|
|
'supplier_lead_costs' => 'received_at',
|
|
// Audit-таблицы (hole #2, 23.05.2026)
|
|
'auth_log' => 'created_at',
|
|
'activity_log' => 'created_at',
|
|
'tenant_operations_log' => 'created_at',
|
|
// webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts (legacy direct webhook removal)
|
|
'balance_transactions' => 'created_at',
|
|
'pd_processing_log' => 'created_at',
|
|
'saas_admin_audit_log' => 'created_at',
|
|
// Slepok routing (Этап 2, 27.05.2026)
|
|
'project_routing_snapshots' => 'snapshot_date',
|
|
];
|
|
|
|
/**
|
|
* Гарантирует наличие месячных партиций таблицы для всех месяцев,
|
|
* пересекающих [$from, $to] включительно.
|
|
*
|
|
* @return int Сколько партиций реально создано (0 — все уже были).
|
|
*/
|
|
public function ensureRange(string $table, CarbonInterface $from, CarbonInterface $to): int
|
|
{
|
|
$this->assertKnownTable($table);
|
|
|
|
$month = $from->copy()->startOfMonth();
|
|
$last = $to->copy()->startOfMonth();
|
|
$created = 0;
|
|
|
|
while ($month->lessThanOrEqualTo($last)) {
|
|
$created += $this->ensureMonth($table, $month) ? 1 : 0;
|
|
$month = $month->addMonth();
|
|
}
|
|
|
|
return $created;
|
|
}
|
|
|
|
/**
|
|
* Создаёт одну месячную партицию. Возвращает true, если партиция создана,
|
|
* false — если уже существовала.
|
|
*/
|
|
public function ensureMonth(string $table, CarbonInterface $monthStart): bool
|
|
{
|
|
$this->assertKnownTable($table);
|
|
|
|
$partitionKey = self::PARTITIONED_TABLES[$table];
|
|
$start = $monthStart->copy()->startOfMonth();
|
|
$end = $start->copy()->addMonth();
|
|
|
|
// Partition naming: <table>_y<YYYY>_m<MM>
|
|
$partition = sprintf('%s_y%s_m%s', $table, $start->format('Y'), $start->format('m'));
|
|
|
|
$exists = DB::selectOne(
|
|
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
|
|
[$partition],
|
|
);
|
|
|
|
if ($exists !== null) {
|
|
return false;
|
|
}
|
|
|
|
DB::connection(self::DDL_CONNECTION)->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;
|
|
}
|
|
|
|
/**
|
|
* Возвращает имя партиции для заданной таблицы и месяца.
|
|
* Утилита для тестов и команды drop-expired.
|
|
*/
|
|
public function partitionName(string $table, CarbonInterface $monthStart): string
|
|
{
|
|
$this->assertKnownTable($table);
|
|
$start = $monthStart->copy()->startOfMonth();
|
|
|
|
return sprintf('%s_y%s_m%s', $table, $start->format('Y'), $start->format('m'));
|
|
}
|
|
|
|
/**
|
|
* Возвращает список существующих партиций для таблицы через pg_inherits.
|
|
*
|
|
* @return list<string> Имена партиций (relname).
|
|
*/
|
|
public function listPartitions(string $table): array
|
|
{
|
|
$this->assertKnownTable($table);
|
|
|
|
$rows = DB::select(
|
|
'SELECT c.relname
|
|
FROM pg_inherits i
|
|
JOIN pg_class c ON c.oid = i.inhrelid
|
|
JOIN pg_class p ON p.oid = i.inhparent
|
|
WHERE p.relname = ?
|
|
ORDER BY c.relname',
|
|
[$table],
|
|
);
|
|
|
|
return array_map(fn ($r) => $r->relname, $rows);
|
|
}
|
|
|
|
private function assertKnownTable(string $table): void
|
|
{
|
|
if (! array_key_exists($table, self::PARTITIONED_TABLES)) {
|
|
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
|
|
}
|
|
}
|
|
}
|