Files
portal/app/tests/Feature/Scheduler/SchedulerHeartbeatTest.php
T
Дмитрий 29daf584ea fix(сторож пульса): свежая команда в расписании не считается умершей, ожившая закрывает свою жалобу
Живой случай 07.08.2026. Выкат в 12:04 добавил три ежедневные команды
(obzvon:chistka 03:45, obzvon:chistka-materialov 03:50, lena:proverka-obhoda
06:10). Их время в этот день уже прошло, записи пульса не было — и часовой
сторож с окном дедупа 60 мин заводил три жалобы КАЖДЫЙ ЧАС, каждая со своим
письмом владельцу. К утру набежало бы под полсотни. Тем же способом 05-06.08
набралось 18 записей про incidents:watch-failures.

Две правки:

1. Нет записи пульса — не жалуемся, а заводим отметку «команда известна с
   этого мгновения» и молчим, пока не выйдет срок первого запуска: два
   ожидаемых интервала, отсчёт от отметки. Не отработала за срок — жалоба будет.

2. Команда снова здорова — её открытые жалобы гасятся. Без этого записи копятся
   навсегда: 06.08 сторож починился в 20:37, а 18 жалоб остались открытыми, и
   лампа «Здоровье портала» горела сутки после устранения причины. Кнопки
   «закрыть инцидент» в админке нет.

Якорь пробел-тире в поиске жалобы обязателен: без него имя команды совпало бы с
любой другой, начинающейся так же. Обратный слэш в именах джобов экранируется —
в LIKE Postgres он служебный.

Тесты: 5 новых, из них 3 сначала красные, каждый по своей причине; 2 сторожевых
держат границы — молчание не вечно, больную команду не закрываем. Прогон 13/13,
соседние 84/84, composer stan 0 ошибок, pint чисто.

Выкачено на боевой 07.08.2026 14:30 МСК: тревог стало 0 вместо 3 в час.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:36:22 +03:00

412 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Console\Commands\SchedulerCheckHeartbeats;
use App\Mail\SchedulerHeartbeatMissingMail;
use App\Services\SchedulerHeartbeatTracker;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// Гарантируем PDO-sharing перед каждым тестом, затрагивающим pgsql_supplier.
// SharesSupplierPdo::setUpSharesSupplierPdo() вызывается автоматически через
// setUp{TraitName}, но явный beforeEach страхует от edge-cases.
beforeEach(function (): void {
DB::connection('pgsql_supplier')->setPdo(
DB::connection('pgsql')->getPdo()
);
DB::connection('pgsql_supplier')->setReadPdo(
DB::connection('pgsql')->getReadPdo()
);
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Получить строку heartbeat через default connection.
* В test-env оба pgsql + pgsql_supplier указывают на liderra_testing.
*/
function getHeartbeat(string $name): ?object
{
return DB::table('scheduler_heartbeats')
->where('command_name', $name)
->first();
}
function insertHeartbeat(array $data): void
{
$defaults = [
'last_run_at' => null,
'last_success_at' => null,
'last_error' => null,
'runtime_ms' => null,
'consecutive_failures' => 0,
'created_at' => now(),
'updated_at' => now(),
];
DB::table('scheduler_heartbeats')->insert(array_merge($defaults, $data));
}
/**
* Гарантирует наличие активного saas_admin_user для FK incidents_log.
* Паттерн из IncidentsWatchFailuresTest::ensureSystemAdmin().
*/
function ensureHeartbeatAdmin(): int
{
$id = DB::table('saas_admin_users')->where('is_active', true)->whereNull('deleted_at')->value('id');
if ($id !== null) {
return (int) $id;
}
return (int) DB::table('saas_admin_users')->insertGetId([
'email' => 'hb-check-admin@liderra.ru',
'full_name' => 'Heartbeat Check Admin',
'password_hash' => '$2y$12$placeholder',
'is_active' => true,
'role' => 'support',
'created_at' => now(),
]);
}
// ---------------------------------------------------------------------------
// SchedulerHeartbeatTracker::recordRun — успешный запуск
// ---------------------------------------------------------------------------
it('recordRun обновляет last_run_at и last_success_at при успехе', function (): void {
$tracker = app(SchedulerHeartbeatTracker::class);
$before = now()->subSecond();
$tracker->recordRun('test:success', fn () => null);
$row = getHeartbeat('test:success');
expect($row)->not->toBeNull('строка heartbeat не создана')
->and(Carbon::parse($row->last_run_at))->toBeGreaterThan($before)
->and(Carbon::parse($row->last_success_at))->toBeGreaterThan($before)
->and($row->consecutive_failures)->toBe(0)
->and($row->last_error)->toBeNull();
});
it('recordRun сбрасывает consecutive_failures до 0 после успеха', function (): void {
// Создаём строку с ненулевыми consecutive_failures
insertHeartbeat([
'command_name' => 'test:reset-failures',
'consecutive_failures' => 5,
'last_error' => 'prev error',
]);
$tracker = app(SchedulerHeartbeatTracker::class);
$tracker->recordRun('test:reset-failures', fn () => null);
$row = getHeartbeat('test:reset-failures');
expect($row->consecutive_failures)->toBe(0)
->and($row->last_error)->toBeNull();
});
// ---------------------------------------------------------------------------
// SchedulerHeartbeatTracker::recordRun — исключение
// ---------------------------------------------------------------------------
it('recordRun обновляет last_error и инкрементирует consecutive_failures при exception', function (): void {
$tracker = app(SchedulerHeartbeatTracker::class);
$before = now()->subSecond();
$thrown = false;
try {
$tracker->recordRun('test:fail', function (): never {
throw new RuntimeException('test error message');
});
} catch (RuntimeException) {
$thrown = true;
}
expect($thrown)->toBeTrue('исключение должно пробрасываться');
$row = getHeartbeat('test:fail');
expect($row)->not->toBeNull('строка heartbeat не создана')
->and(Carbon::parse($row->last_run_at))->toBeGreaterThan($before)
->and($row->last_success_at)->toBeNull()
->and($row->last_error)->toContain('test error message')
->and($row->consecutive_failures)->toBe(1);
});
it('recordRun инкрементирует consecutive_failures накопительно', function (): void {
insertHeartbeat([
'command_name' => 'test:multi-fail',
'consecutive_failures' => 2,
]);
$tracker = app(SchedulerHeartbeatTracker::class);
try {
$tracker->recordRun('test:multi-fail', function (): never {
throw new RuntimeException('again');
});
} catch (RuntimeException) {
}
$row = getHeartbeat('test:multi-fail');
expect($row->consecutive_failures)->toBe(3);
});
// ---------------------------------------------------------------------------
// SchedulerCheckHeartbeats — детекция пропавшего пульса
// ---------------------------------------------------------------------------
it('SchedulerCheckHeartbeats флагует команду с last_run_at старше 2× интервала', function (): void {
Mail::fake();
ensureHeartbeatAdmin();
// Команда incidents:watch-failures: интервал 10 минут → старше 20 мин = флаг
insertHeartbeat([
'command_name' => 'incidents:watch-failures',
'last_run_at' => now()->subMinutes(25),
'last_success_at' => now()->subMinutes(25),
]);
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk()->run();
$incident = DB::table('incidents_log')
->where('summary', 'like', '%incidents:watch-failures%')
->first();
expect($incident)->not->toBeNull('incident не создан для пропавшего пульса')
->and($incident->severity)->toBe('high');
});
it('SchedulerCheckHeartbeats флагует команду с consecutive_failures >= 3', function (): void {
Mail::fake();
ensureHeartbeatAdmin();
insertHeartbeat([
'command_name' => 'supplier:retry-failed',
'last_run_at' => now()->subMinutes(5),
'last_success_at' => now()->subMinutes(65),
'consecutive_failures' => 3,
]);
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
$incident = DB::table('incidents_log')
->where('summary', 'like', '%supplier:retry-failed%')
->first();
expect($incident)->not->toBeNull('incident не создан при consecutive_failures=3');
});
// ---------------------------------------------------------------------------
// Dedup — повторный запуск не дублирует инцидент
// ---------------------------------------------------------------------------
it('SchedulerCheckHeartbeats не дублирует incident при повторном запуске', function (): void {
Mail::fake();
ensureHeartbeatAdmin();
insertHeartbeat([
'command_name' => 'audit:verify-chains',
'last_run_at' => now()->subDays(3),
'last_success_at' => now()->subDays(3),
]);
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
$count = DB::table('incidents_log')
->where('summary', 'like', '%audit:verify-chains%')
->count();
expect($count)->toBe(1, 'инцидент задублирован — dedup не работает');
});
// ---------------------------------------------------------------------------
// Mailable отправляется
// ---------------------------------------------------------------------------
it('SchedulerCheckHeartbeats отправляет SchedulerHeartbeatMissingMail', function (): void {
Mail::fake();
ensureHeartbeatAdmin();
insertHeartbeat([
'command_name' => 'partitions:create-months',
'last_run_at' => now()->subHours(50),
'last_success_at' => now()->subHours(50),
]);
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
Mail::assertSent(SchedulerHeartbeatMissingMail::class, function ($mail) {
return $mail->hasTo('kdv1@bk.ru');
});
});
// ---------------------------------------------------------------------------
// Свежая команда в расписании — срок ещё не наступил
//
// Живой случай 07.08.2026: выкат в 12:04 добавил три ежедневные команды
// (obzvon:chistka 03:45, obzvon:chistka-materialov 03:50, lena:proverka-obhoda
// 06:10). Их время в этот день уже прошло, записи пульса не было, и часовой
// сторож с окном дедупа 60 мин заводил три жалобы КАЖДЫЙ ЧАС до утра. Ровно так
// же 05–06.08 набежало 18 записей про incidents:watch-failures.
// ---------------------------------------------------------------------------
it('SchedulerCheckHeartbeats молчит про команду, впервые появившуюся в расписании', function (): void {
Mail::fake();
ensureHeartbeatAdmin();
// Свежий выкат: команда в расписании есть, записи пульса нет вовсе.
DB::table('scheduler_heartbeats')->where('command_name', 'obzvon:chistka')->delete();
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
$incident = DB::table('incidents_log')
->whereNull('resolved_at')
->where('summary', 'like', 'Scheduler heartbeat: obzvon:chistka —%')
->first();
expect($incident)->toBeNull('пожаловался на команду, чьё время по расписанию ещё не наступило');
$row = getHeartbeat('obzvon:chistka');
expect($row)->not->toBeNull('не заведена отметка «команда известна с этого мгновения»')
->and($row->last_run_at)->toBeNull('отметка не должна выдавать несостоявшийся запуск за состоявшийся');
});
it('SchedulerCheckHeartbeats молчит, пока не вышел срок первого запуска', function (): void {
Mail::fake();
ensureHeartbeatAdmin();
// Отметка заведена час назад, команда ежедневная (1440 мин) — срок не вышел.
insertHeartbeat([
'command_name' => 'obzvon:chistka-materialov',
'last_run_at' => null,
'created_at' => now()->subHour(),
]);
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
$incident = DB::table('incidents_log')
->where('summary', 'like', 'Scheduler heartbeat: obzvon:chistka-materialov —%')
->first();
expect($incident)->toBeNull('пожаловался раньше срока первого запуска');
});
it('SchedulerCheckHeartbeats жалуется, если команда так и не запустилась за отведённый срок', function (): void {
Mail::fake();
ensureHeartbeatAdmin();
// Отметка заведена трое суток назад, ежедневная команда так и не отработала.
// Это граница: молчание не должно стать вечным.
insertHeartbeat([
'command_name' => 'lena:proverka-obhoda',
'last_run_at' => null,
'created_at' => now()->subDays(3),
]);
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
$incident = DB::table('incidents_log')
->where('summary', 'like', 'Scheduler heartbeat: lena:proverka-obhoda —%')
->first();
expect($incident)->not->toBeNull('промолчал про команду, которая давно должна была отработать');
});
// ---------------------------------------------------------------------------
// Сторож ожил — жалоба закрывается сама
//
// Без этого записи копятся навсегда: 06.08 сторож починился в 20:37, а 18 жалоб
// про него остались открытыми, и лампа «Здоровье портала» горела красным сутки
// после устранения причины. Кнопки «закрыть» в админке нет.
// ---------------------------------------------------------------------------
it('SchedulerCheckHeartbeats закрывает свою жалобу, когда команда ожила', function (): void {
Mail::fake();
$adminId = ensureHeartbeatAdmin();
// Команда здорова: только что отработала, ошибок подряд нет.
insertHeartbeat([
'command_name' => 'incidents:watch-failures',
'last_run_at' => now()->subMinute(),
'last_success_at' => now()->subMinute(),
'consecutive_failures' => 0,
]);
// ...а с прошлой аварии висит открытая жалоба на неё.
DB::table('incidents_log')->insert([
'type' => 'other',
'severity' => 'high',
'summary' => "Scheduler heartbeat: incidents:watch-failures — Команда 'incidents:watch-failures' завершилась с ошибкой 189 раз подряд.",
'started_at' => now()->subDay(),
'detected_at' => now()->subDay(),
'resolved_at' => null,
'created_by_admin_id' => $adminId,
'created_at' => now()->subDay(),
'updated_at' => now()->subDay(),
]);
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
$incident = DB::table('incidents_log')
->where('summary', 'like', 'Scheduler heartbeat: incidents:watch-failures —%')
->orderByDesc('id')
->first();
expect($incident->resolved_at)->not->toBeNull('жалоба осталась открытой, хотя сторож ожил');
});
it('SchedulerCheckHeartbeats не закрывает жалобу на команду, которая всё ещё больна', function (): void {
Mail::fake();
$adminId = ensureHeartbeatAdmin();
// Команда по-прежнему падает.
insertHeartbeat([
'command_name' => 'supplier:retry-failed',
'last_run_at' => now()->subMinutes(5),
'last_success_at' => now()->subMinutes(65),
'consecutive_failures' => 7,
]);
DB::table('incidents_log')->insert([
'type' => 'other',
'severity' => 'high',
'summary' => "Scheduler heartbeat: supplier:retry-failed — Команда 'supplier:retry-failed' завершилась с ошибкой 5 раз подряд.",
'started_at' => now()->subHours(3),
'detected_at' => now()->subHours(3),
'resolved_at' => null,
'created_by_admin_id' => $adminId,
'created_at' => now()->subHours(3),
'updated_at' => now()->subHours(3),
]);
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
$otkrytyh = DB::table('incidents_log')
->whereNull('resolved_at')
->where('summary', 'like', 'Scheduler heartbeat: supplier:retry-failed —%')
->count();
expect($otkrytyh)->toBeGreaterThan(0, 'закрыл жалобу на команду, которая всё ещё падает');
});