fix(billing): письма о заморозке баланса не доходили — воркер не видел клиента под RLS

Прод-инцидент 14.07.2026. Пять писем (заморозка, напоминание, финальное,
разморозка, «проект остановлен — нет денег») уезжали в очередь с Eloquent-моделью
Tenant. SerializesModels заменяет модель на id, а воркер грузит её заново — под
ролью crm_app_user, где RLS-policy tenants_self_isolation без app.current_tenant_id
отдаёт 0 строк → ModelNotFoundException. Клиент №7 заморожен с 12.07 и не получил
ни одного письма; на проде это ломало письма о заморозке для ВСЕХ клиентов.

Письма больше не ходят в БД при отправке: несут снимок данных (без SerializesModels).

Заодно: сторож incidents:watch-failures плодил копию persistent-инцидента каждый час
(строка в failed_jobs живёт вечно, а дедуп был окном в 60 мин) — 2 залипшие ошибки
дали 31 запись за сутки и красную лампу «Очереди/джобы». Дедуп persistent теперь по
факту незакрытого инцидента, а не по возрасту последней копии.

Регрессия: BalanceMailsQueueRestoreTest (6 кейсов) + 2 теста сторожа.
Прогон: 165/165 billing+incidents, phpstan 0, pint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-14 08:55:37 +03:00
parent 8ce5619177
commit 5b7bbbda50
9 changed files with 176 additions and 13 deletions
@@ -178,8 +178,14 @@ class IncidentsWatchFailures extends Command
$excSig = (string) $row->exc_sig;
$dedupKey = "persistent:{$jobClass}:{$excSig}";
if ($this->isDup($dedupKey, $dedupAt)) {
$this->line("Skipping persistent (dedup): {$dedupKey}");
// Дедуп persistent — БЕЗ окна по времени (прод-инцидент 14.07.2026).
// Строка в failed_jobs живёт вечно, поэтому правило срабатывает на каждом
// прогоне (раз в 10 мин), а окно дедупа в 60 мин истекает → раньше сторож
// плодил копию того же инцидента КАЖДЫЙ ЧАС (2 залипшие строки → 31 запись
// за сутки, лампа «Очереди/джобы» красная с растущей цифрой). Пока прошлый
// инцидент не разобран — новый не нужен; после resolved_at сработает снова.
if ($this->hasOpenIncident($dedupKey)) {
$this->line("Skipping persistent (open incident exists): {$dedupKey}");
continue;
}
@@ -257,6 +263,20 @@ class IncidentsWatchFailures extends Command
return self::SUCCESS;
}
/**
* Есть ли НЕразобранный инцидент с этой сигнатурой без ограничения по времени.
* Для persistent-правила: источник (строка в failed_jobs) не исчезает сам, поэтому
* ориентир состояние инцидента, а не возраст его последней копии.
*/
private function hasOpenIncident(string $dedupKey): bool
{
return DB::connection(self::DB_CONNECTION)
->table('incidents_log')
->where('root_cause', $dedupKey)
->whereNull('resolved_at')
->exists();
}
private function isDup(string $dedupKey, Carbon $dedupAt): bool
{
// Сигнатура сохраняется в root_cause для надёжного дедупа
+3 -2
View File
@@ -10,16 +10,17 @@ use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Финальное письмо: приём лидов приостановлен 3 дня (Billing v2 Spec C §3.7, T+72h).
* После него повторов нет до следующего цикла заморозки.
*
* 🔴 БЕЗ SerializesModels НЕ возвращать: воркер не сможет достать тенанта из-под RLS.
* Причина и регрессия в BalanceFrozenMail / BalanceMailsQueueRestoreTest.
*/
final class BalanceFrozenFinalMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly Tenant $tenant,
+6 -2
View File
@@ -10,17 +10,21 @@ use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо клиенту: приём лидов приостановлен из-за нехватки баланса (Billing v2 Spec C §3.7).
*
* Триггер: BalancePreflightSweepJob при переходе тенанта active frozen (cut-off 18:00 MSK).
*
* 🔴 БЕЗ SerializesModels НЕ возвращать (прод-инцидент 14.07.2026). С этим трейтом
* в очередь уезжает только id тенанта, а воркер грузит модель заново под ролью
* crm_app_user, где RLS-policy tenants_self_isolation без app.current_tenant_id отдаёт
* 0 строк ModelNotFoundException, письмо не уходит НИКОГДА. Без трейта письмо несёт
* снимок данных и отправляется без похода в БД. Регрессия: BalanceMailsQueueRestoreTest.
*/
final class BalanceFrozenMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly Tenant $tenant,
+3 -2
View File
@@ -10,15 +10,16 @@ use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо-напоминание: приём лидов всё ещё приостановлен (Billing v2 Spec C §3.7, T+24h).
*
* 🔴 БЕЗ SerializesModels НЕ возвращать: воркер не сможет достать тенанта из-под RLS.
* Причина и регрессия в BalanceFrozenMail / BalanceMailsQueueRestoreTest.
*/
final class BalanceFrozenReminderMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly Tenant $tenant,
+3 -2
View File
@@ -10,17 +10,18 @@ use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо клиенту: приём лидов возобновлён (Billing v2 Spec C §3.7).
*
* Триггер: BalancePreflightSweepJob при переходе frozen active (пополнение/снижение лимита).
*
* 🔴 БЕЗ SerializesModels НЕ возвращать: воркер не сможет достать тенанта из-под RLS.
* Причина и регрессия в BalanceFrozenMail / BalanceMailsQueueRestoreTest.
*/
final class BalanceUnfrozenMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly Tenant $tenant,
+3 -2
View File
@@ -10,7 +10,6 @@ use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Email клиенту Лидерры о приостановке проекта из-за недостаточного баланса.
@@ -20,11 +19,13 @@ use Illuminate\Queue\SerializesModels;
* письмо с rate-limit 1/час/tenant (Redis SETNX).
*
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §4.4
*
* 🔴 БЕЗ SerializesModels НЕ возвращать: воркер не сможет достать тенанта и проект
* из-под RLS. Причина и регрессия в BalanceFrozenMail / BalanceMailsQueueRestoreTest.
*/
final class ZeroBalancePausedMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly Tenant $tenant,
+1 -1
View File
@@ -2043,7 +2043,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 8
count: 12
path: tests/Feature/Incidents/IncidentsWatchFailuresExpandedTest.php
-
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use App\Mail\BalanceFrozenFinalMail;
use App\Mail\BalanceFrozenMail;
use App\Mail\BalanceFrozenReminderMail;
use App\Mail\BalanceUnfrozenMail;
use App\Mail\ZeroBalancePausedMail;
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Billing\PreflightResult;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
/**
* Регрессия прод-инцидента 14.07.2026: письма о заморазке баланса падали в очереди
* с ModelNotFoundException [App\Models\Tenant].
*
* Причина: письмо кладётся в очередь с Eloquent-моделью Tenant, SerializesModels
* заменяет её на ModelIdentifier(id), а воркер восстанавливает модель ЗАНОВО
* запросом в БД под ролью crm_app_user, где RLS-policy tenants_self_isolation
* без app.current_tenant_id отдаёт 0 строк firstOrFail() падение. Ни одно
* письмо о заморозке до клиента не доходило.
*
* Здесь недоступность строки для воркера эмулируется удалением записи между
* постановкой в очередь и восстановлением точный аналог «RLS не отдал строку».
* Правильное поведение: письмо несёт снимок данных и отправляется без похода в БД.
*/
it('renders queued balance mail after tenant row is invisible to the worker', function (string $mailClass, string $expectedText) {
$tenant = Tenant::factory()->create([
'organization_name' => 'ООО Заморозка',
'contact_email' => 'frozen@example.test',
]);
$result = new PreflightResult(false, 30, 20, 10);
// Что уходит в очередь (Redis) — PHP-сериализация письма.
$payload = serialize(new $mailClass($tenant, $result));
// Воркер строку не видит (на проде — RLS; здесь — записи уже нет).
DB::table('tenants')->where('id', $tenant->id)->delete();
$rendered = unserialize($payload)->render();
expect($rendered)->toContain('ООО Заморозка');
expect($rendered)->toContain($expectedText);
})->with([
'заморозка' => [BalanceFrozenMail::class, 'приостановлен'],
'напоминание' => [BalanceFrozenReminderMail::class, 'всё ещё приостановлен'],
'последнее напоминание' => [BalanceFrozenFinalMail::class, 'последнее напоминание'],
'разморозка' => [BalanceUnfrozenMail::class, 'возобновлён'],
]);
it('addresses queued balance mail to the tenant after the row is invisible', function () {
$tenant = Tenant::factory()->create(['contact_email' => 'frozen@example.test']);
$payload = serialize(new BalanceFrozenMail($tenant, new PreflightResult(false, 30, 20, 10)));
DB::table('tenants')->where('id', $tenant->id)->delete();
$envelope = unserialize($payload)->envelope();
expect($envelope->to[0]->address)->toBe('frozen@example.test');
});
it('renders queued zero-balance mail after tenant and project rows are invisible', function () {
$tenant = Tenant::factory()->create([
'organization_name' => 'ООО Ноль',
'contact_email' => 'zero@example.test',
'balance_rub' => '0.00',
]);
$project = Project::factory()->for($tenant)->create(['name' => 'Стоматология Москва']);
$payload = serialize(new ZeroBalancePausedMail($tenant, $project, 5000));
DB::table('projects')->where('id', $project->id)->delete();
DB::table('tenants')->where('id', $tenant->id)->delete();
$rendered = unserialize($payload)->render();
expect($rendered)->toContain('Стоматология Москва');
expect($rendered)->toContain('приостановлен');
});
@@ -145,6 +145,57 @@ test('failed_jobs persistent exception creates incident severity=medium', functi
Mail::assertNotSent(IncidentDetectedMail::class);
});
/**
* Регрессия прод-инцидента 14.07.2026: 2 залипшие строки в failed_jobs дали 31
* незакрытый авто-инцидент за сутки (46 всего) «Очереди/джобы» горело красным
* с растущей цифрой.
*
* Причина: persistent-правило смотрит на строки старше N часов (такая строка живёт
* в failed_jobs вечно), а дедуп ограничен окном в 60 мин. Сторож бегает раз в 10 мин
* каждый час новая копия того же инцидента, бесконечно. Дедуп persistent-правила
* должен опираться на факт «незакрытый инцидент с этой сигнатурой уже есть», а не
* на возраст последней копии.
*/
test('persistent rule does not clone the incident hour after hour while it stays unresolved', function () {
makeFailedJob(
'App\\Mail\\BalanceFrozenMail',
'Illuminate\\Database\\Eloquent\\ModelNotFoundException: No query results for model [App\\Models\\Tenant].',
Carbon::now()->subHours(4)
);
$persistent = fn () => DB::table('incidents_log')->where('summary', 'like', '%persistent%')->count();
$this->artisan('incidents:watch-failures')->assertSuccessful();
expect($persistent())->toBe(1);
// Прошли сутки: ошибка та же, инцидент никто не разобрал, окно дедупа (60 мин) давно истекло.
Carbon::setTestNow(Carbon::now()->addDay());
$this->artisan('incidents:watch-failures')->assertSuccessful();
expect($persistent())->toBe(1);
Carbon::setTestNow();
});
test('persistent rule fires again after the previous incident was resolved', function () {
makeFailedJob(
'App\\Mail\\BalanceFrozenMail',
'Illuminate\\Database\\Eloquent\\ModelNotFoundException: No query results for model [App\\Models\\Tenant].',
Carbon::now()->subHours(4)
);
$this->artisan('incidents:watch-failures')->assertSuccessful();
// Админ разобрал инцидент, но ошибка повторилась снова — новый инцидент нужен.
DB::table('incidents_log')->update(['resolved_at' => Carbon::now()]);
Carbon::setTestNow(Carbon::now()->addDay());
$this->artisan('incidents:watch-failures')->assertSuccessful();
expect(DB::table('incidents_log')->where('summary', 'like', '%persistent%')->count())->toBe(2);
Carbon::setTestNow();
});
test('dedup prevents duplicate incidents for same failed_jobs spike', function () {
$now = Carbon::now();
for ($i = 0; $i < 11; $i++) {