29daf584ea
Живой случай 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>
412 lines
16 KiB
PHP
412 lines
16 KiB
PHP
<?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, 'закрыл жалобу на команду, которая всё ещё падает');
|
||
});
|