33462bf52e
Без активного 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).
161 lines
6.3 KiB
PHP
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;
|
|
}
|
|
}
|