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:
@@ -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 для надёжного дедупа
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user