Files
portal/app/app/Console/Commands/SchedulerCheckHeartbeats.php
T
Дмитрий 33462bf52e fix(ops): SchedulerCheckHeartbeats warn-only when no admin (hole #6 follow-up)
Без активного saas_admin_user команда возвращала FAILURE — это бесконечный
цикл: cron растит consecutive_failures, watcher пытается алертить, снова
FAILURE, инцидент не создаётся. Паттерн VerifyAuditChains: warn + SUCCESS.

Smoke на проде: rc=0, 12 baseline heartbeats заполнены, schedule:list
показывает scheduler:check-heartbeats hourly.

Tests: 8/8 green (24 assertions).
2026-05-23 11:54:55 +03:00

161 lines
6.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Mail\SchedulerHeartbeatMissingMail;
use App\Services\SchedulerHeartbeatTracker;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Hole #6: проверяет пульс всех зарегистрированных cron-задач.
*
* Критерии алерта (для каждой команды в scheduler_heartbeats):
* 1. last_run_at IS NULL ИЛИ отсутствует > 2× ожидаемого интервала.
* 2. consecutive_failures >= 3.
*
* При обнаружении:
* • Создаёт инцидент в incidents_log (type=other, severity=high).
* • Отправляет SchedulerHeartbeatMissingMail на kdv1@bk.ru.
* • Дедупликация: не создаёт повторный инцидент если открытый уже есть
* с той же командой в последние 60 минут.
*
* Запускается hourly через routes/console.php.
*/
final class SchedulerCheckHeartbeats extends Command
{
private const DB_CONNECTION = 'pgsql_supplier';
private const ALERT_EMAIL = 'kdv1@bk.ru';
private const DEDUP_MINUTES = 60;
private const FAILURE_THRESHOLD = 3;
protected $signature = 'scheduler:check-heartbeats';
protected $description = 'Проверяет пульс cron-задач и алертит при пропавшем пульсе или повторных ошибках';
public function handle(): int
{
$intervals = SchedulerHeartbeatTracker::EXPECTED_INTERVALS;
$db = DB::connection(self::DB_CONNECTION);
$now = Carbon::now();
$dedupAt = $now->copy()->subMinutes(self::DEDUP_MINUTES);
// Получаем adminId для FK incidents_log
$adminId = $db->table('saas_admin_users')
->where('is_active', true)
->whereNull('deleted_at')
->value('id');
if ($adminId === null) {
// Паттерн VerifyAuditChains (hole #1): warn + SUCCESS, не FAILURE.
// FAILURE здесь = бесконечный цикл self-alert (consecutive_failures растёт,
// watcher пытается алертить, снова FAILURE, инцидент не создаётся).
$this->warn('No active saas_admin_users — alerts disabled (warn-only mode).');
return self::SUCCESS;
}
// Загружаем все существующие heartbeats
$rows = $db->table('scheduler_heartbeats')
->get()
->keyBy('command_name');
$alerted = 0;
foreach ($intervals as $commandName => $expectedMinutes) {
$row = $rows->get($commandName);
// Проверка 1: пропавший пульс (нет строки вообще или last_run_at старше 2× интервала)
$heartbeatMissing = false;
if ($row === null) {
$heartbeatMissing = true;
$reason = "Команда '{$commandName}' не имеет ни одной записи heartbeat.";
} elseif ($row->last_run_at === null) {
$heartbeatMissing = true;
$reason = "Команда '{$commandName}' никогда не запускалась.";
} else {
$lastRun = Carbon::parse($row->last_run_at);
$ageMinutes = $lastRun->diffInMinutes($now);
$threshold = $expectedMinutes * 2;
if ($ageMinutes > $threshold) {
$heartbeatMissing = true;
$reason = "Команда '{$commandName}' не запускалась {$ageMinutes} мин. "
."(ожидаемый интервал: {$expectedMinutes} мин, порог: {$threshold} мин).";
}
}
// Проверка 2: consecutive_failures >= threshold
$consecutiveFailures = $row !== null ? (int) $row->consecutive_failures : 0;
$failureSpike = $consecutiveFailures >= self::FAILURE_THRESHOLD;
if (! $heartbeatMissing && ! $failureSpike) {
continue;
}
if (! isset($reason)) {
$reason = "Команда '{$commandName}' завершилась с ошибкой {$consecutiveFailures} раз подряд.";
} elseif ($failureSpike) {
$reason .= " Плюс {$consecutiveFailures} последовательных ошибок.";
}
$lastError = $row?->last_error;
// Дедупликация
$summary = "Scheduler heartbeat: {$commandName}{$reason}";
$sigPrefix = substr("Scheduler heartbeat: {$commandName}", 0, 80);
$alreadyOpen = $db->table('incidents_log')
->where('summary', 'like', '%'.addcslashes($sigPrefix, '%_\\').'%')
->whereNull('resolved_at')
->where('detected_at', '>=', $dedupAt)
->exists();
if ($alreadyOpen) {
$this->line("Dedup: {$commandName}");
continue;
}
// Создаём инцидент
$db->table('incidents_log')->insert([
'type' => 'other',
'severity' => 'high',
'summary' => $summary,
'root_cause' => null,
'started_at' => $now,
'detected_at' => $now,
'resolved_at' => null,
'created_by_admin_id' => $adminId,
'created_at' => $now,
'updated_at' => $now,
]);
// Отправляем email
Mail::to(self::ALERT_EMAIL)->send(
new SchedulerHeartbeatMissingMail(
commandName: $commandName,
reason: $reason,
lastError: $lastError,
consecutiveFailures: $consecutiveFailures,
)
);
$this->warn("Alert: {$commandName}{$reason}");
$alerted++;
unset($reason);
}
$this->info("Done. {$alerted} alert(s) created.");
return self::SUCCESS;
}
}