Files
portal/app/app/Services/SchedulerHeartbeatTracker.php
T
Дмитрий 60ab5be3eb feat(audit): partitioning 7 audit-таблиц по месяцам (hole #2 Phase A)
Закрывает последнюю дыру #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
2026-05-23 15:50:37 +03:00

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],
);
}
}
}