Files
portal/app/app/Services/MonthlyPartitionManager.php
T
Дмитрий 662be183db feat(schema): project_routing_snapshots partitioned table + MonthlyPartitionManager entry (Task 2.1, Slepok routing Etap 2)
- 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>
2026-05-27 07:56:08 +03:00

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}' не партиционирована помесячно");
}
}
}