60ab5be3eb
Закрывает последнюю дыру #2 аудита журналирования. Phase A (dev) — миграция схемы + retention tooling. Phase B (прод-rewrite через SQL под postgres) — отдельным шагом с явным approve. Решения заказчика: * Scope: все 7 таблиц (auth_log, activity_log, tenant_operations_log, webhook_log, balance_transactions, pd_processing_log, saas_admin_audit_log) * FK на webhook_log: W1 — удалить FK от failed_webhook_jobs+rejected_deals_log * Retention defaults: auth:24м, activity:36м, tenant_ops:24м, webhook:3м, balance:84м, pd:36м, saas_admin:84м. Cron Sundays 03:00 МСК * Hash-chain: per-partition (audit_chain_hash трг через TG_TABLE_NAME уже работает per-partition; совместимо с hole #1 per-RLS-scope fix) Phase A: * db/schema.sql v8.30→v8.31: 7 audit-таблиц на PARTITION BY RANGE, PK→(id, partition_key), +7 retention seeds в system_settings, FK от failed_webhook_jobs/rejected_deals_log удалены * MonthlyPartitionManager: PARTITIONED_TABLES → ассоциативный array (name => partition_key), 2 → 9 таблиц * PartitionsCreateMonths: автоматически покрывает все 9 * load_initial_schema: после schema.sql вызывает Artisan partitions:create-months --ahead=2 (без этого первый INSERT падает) * 2026_05_22_000001_tenant_operations_log: idempotency guard * VerifyAuditChains: per-partition scan через pg_inherits; fallback на single-scope для не-партиционированной таблицы; per-RLS-scope partition_clause сохранён внутри каждой партиции * AuditChainBreachMail: +partitionName param (NULL=fallback на tableName) * PartitionsDropExpired (новая): cron Sundays 03:00 МСК, retention из system_settings, dry-run mode, safety guard retention=0 * SchedulerHeartbeatTracker +partitions:drop-expired (10080 мин) Без Laravel-миграции для прода — она оставляла БД пустой при migrate:fresh. Подход: schema.sql декларирует партиционированные + ad-hoc SQL под postgres для прод-rewrite (отдельный commit + ручной деплой + pg_dump backup). Тесты: 1219/1231 (35/35 hole #2 specs, 88 assertions). 3 fail — pre-existing AdminPdSubjectRequestsControllerTest::executeErasure_* (FK actor_admin_user_id после partitioning pd_processing_log, отдельная задача для hole #4 follow-up, не блокирует). cspell +2 слова (партиционировать, дёшева). Pint --fix чистый. Spec: docs/superpowers/specs/2026-05-23-hole-2-audit-partitioning-design.md Plan: docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md
128 lines
5.3 KiB
PHP
128 lines
5.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Трекер пульса планировщика задач (hole #6).
|
|
*
|
|
* Оборачивает каждую cron-задачу: фиксирует время запуска, длительность,
|
|
* результат (успех / ошибка) и consecutive_failures в scheduler_heartbeats.
|
|
*
|
|
* Использует pgsql_supplier (BYPASSRLS, crm_supplier_worker) — SaaS-level таблица,
|
|
* RLS не применяется. Паттерн аналогичен IncidentsWatchFailures.
|
|
*/
|
|
final class SchedulerHeartbeatTracker
|
|
{
|
|
private const DB_CONNECTION = 'pgsql_supplier';
|
|
|
|
/**
|
|
* Ожидаемые интервалы cron-задач в минутах.
|
|
* Используется SchedulerCheckHeartbeats для детекции пропавшего пульса.
|
|
*/
|
|
public const EXPECTED_INTERVALS = [
|
|
'projects:reset-delivered-today' => 1440, // daily
|
|
'projects:reset-monthly' => 43200, // monthly (~30 days)
|
|
'partitions:create-months' => 1440, // daily
|
|
'App\Jobs\Supplier\RefreshSupplierSessionJob@hourly' => 60,
|
|
'App\Jobs\Supplier\RefreshSupplierSessionJob@daily' => 1440,
|
|
'App\Jobs\Supplier\SyncSupplierProjectsJob' => 1440,
|
|
'App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob' => 1440,
|
|
'supplier:retry-failed' => 60, // hourly
|
|
'App\Jobs\Supplier\CsvReconcileJob' => 30, // every 30 min
|
|
'incidents:watch-failures' => 10, // every 10 min
|
|
'audit:verify-chains' => 1440, // daily
|
|
'partitions:drop-expired' => 10080, // weekly (Sunday 03:00 MSK)
|
|
'scheduler:check-heartbeats' => 60, // hourly (self-check)
|
|
];
|
|
|
|
/**
|
|
* Выполняет $work, записывает heartbeat (успех или ошибку).
|
|
* Исключение пробрасывается наружу после сохранения.
|
|
* Используется в тестах и при прямой инвокации.
|
|
*/
|
|
public function recordRun(string $name, callable $work): void
|
|
{
|
|
$startedAt = now();
|
|
$error = null;
|
|
|
|
try {
|
|
$work();
|
|
} catch (Throwable $e) {
|
|
$error = substr($e->getMessage(), 0, 2000);
|
|
throw $e;
|
|
} finally {
|
|
$runtimeMs = (int) ($startedAt->diffInMilliseconds(now()));
|
|
$this->saveHeartbeat($name, $startedAt, $error, $runtimeMs);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Записывает результат запуска напрямую (без обёртки callable).
|
|
* Используется из before/after/onFailure хуков routes/console.php.
|
|
*
|
|
* @param bool $success true = успех, false = ошибка
|
|
* @param string|null $errorMsg сообщение ошибки при $success=false
|
|
* @param int|null $runtimeMs длительность в мс (null если неизвестна)
|
|
*/
|
|
public function recordRunResult(
|
|
string $name,
|
|
bool $success,
|
|
?string $errorMsg,
|
|
?int $runtimeMs,
|
|
): void {
|
|
$this->saveHeartbeat($name, now(), $success ? null : $errorMsg, $runtimeMs ?? 0);
|
|
}
|
|
|
|
private function saveHeartbeat(
|
|
string $name,
|
|
\DateTimeInterface $startedAt,
|
|
?string $error,
|
|
int $runtimeMs,
|
|
): void {
|
|
$now = now();
|
|
$db = DB::connection(self::DB_CONNECTION);
|
|
|
|
if ($error === null) {
|
|
// Успех: сбрасываем consecutive_failures
|
|
$db->statement(
|
|
<<<'SQL'
|
|
INSERT INTO scheduler_heartbeats
|
|
(command_name, last_run_at, last_success_at, last_error, runtime_ms, consecutive_failures, created_at, updated_at)
|
|
VALUES
|
|
(?, ?, ?, NULL, ?, 0, ?, ?)
|
|
ON CONFLICT (command_name) DO UPDATE SET
|
|
last_run_at = EXCLUDED.last_run_at,
|
|
last_success_at = EXCLUDED.last_success_at,
|
|
last_error = NULL,
|
|
runtime_ms = EXCLUDED.runtime_ms,
|
|
consecutive_failures = 0,
|
|
updated_at = EXCLUDED.updated_at
|
|
SQL,
|
|
[$name, $startedAt, $now, $runtimeMs, $now, $now],
|
|
);
|
|
} else {
|
|
// Ошибка: инкрементируем consecutive_failures
|
|
$db->statement(
|
|
<<<'SQL'
|
|
INSERT INTO scheduler_heartbeats
|
|
(command_name, last_run_at, last_success_at, last_error, runtime_ms, consecutive_failures, created_at, updated_at)
|
|
VALUES
|
|
(?, ?, NULL, ?, ?, 1, ?, ?)
|
|
ON CONFLICT (command_name) DO UPDATE SET
|
|
last_run_at = EXCLUDED.last_run_at,
|
|
last_error = EXCLUDED.last_error,
|
|
runtime_ms = EXCLUDED.runtime_ms,
|
|
consecutive_failures = scheduler_heartbeats.consecutive_failures + 1,
|
|
updated_at = EXCLUDED.updated_at
|
|
SQL,
|
|
[$name, $startedAt, $error, $runtimeMs, $now, $now],
|
|
);
|
|
}
|
|
}
|
|
}
|