Files
portal/app/tests/Feature/ClientSms/LargeCampaignTest.php
T
Дмитрий 537d3e9c4d perf(смс-клиент): 20 000 номеров не подвешивают портал
Строка приёмочного листа 2.9. Две правки на пути, который ждёт человек у экрана:
предпросмотр сметы и создание рассылки.

Стоп-листы больше не читаются целиком — спрашиваем только про номера этой
рассылки, пачками по 1 000. Общий стоп-лист портала общий на всех клиентов и
растёт без предела: раньше клиент с двумя сотнями номеров тянул его в память
весь. Ответ отбора не меняется, меняется цена вопроса.

Аудитория собирается построчно и сырыми строками, без сборки 20 000 моделей —
во всех трёх источниках. Правила модели при этом сохраняются, на это поставлен
отдельный сторож: удалённая сделка в получатели не попадает (вырезанием
доказано — в обход правил она возвращается).

Третье место из плана — квадрат в джобе отправки — было вылечено ещё в задаче
про снимок получателей; своей работой не считаю.

Замеры по два прогона на сторону, на одной базе. Предпросмотр 20 000: память
сверх маленькой рассылки 37 → 18 МБ, время 0.56 → 0.25 с. Создание 20 000:
1.4 → 1.05 с — разница мала, победой не называю. Большой чужой стоп-лист
(50 000) при 200 своих номерах: +27 → +4 МБ.

Тесты: 4 новых (три на объём, один сторож). Меряют разницей «маленькая
рассылка → большая», а не абсолютом: цена самого запроса так вычитается.
ClientSms 162/162 два прогона подряд, приём лидов 17/17, phpstan 0.

Живой прогон: 20 000 контактов в локальной базе. Предпросмотр 0.8 с, создание
кнопкой 1.9 с, снимок 20 000 из 20 000. Обе рассылки очередь отправила целиком
(40 000 сообщений, ≈148 в секунду в песочнице); во время отправки портал
открывался как обычно — список рассылок 0.5 с, сделки 0.6 с.

Скорость самой отправки не улучшалась — это очередь, а не экран.
2026-07-28 09:38:02 +03:00

237 lines
11 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\Jobs\SendClientSmsCampaignJob;
use App\Models\ClientSmsCampaignPhone;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
/**
* Строка листа 2.9: рассылка на 20 000 номеров не подвешивает портал.
*
* Портал — это то, чего ЖДЁТ человек у экрана: предпросмотр сметы и создание
* рассылки. Сама отправка идёт в очереди, отдельным процессом, и читает снимок
* пачками (ClientSmsSnapshotReader) — экран её не ждёт.
*
* Меряем ДВЕ вещи, и обе — приборами, а не на глаз:
* · время ответа (грубый потолок — ловит квадрат и построчные запросы);
* · память ИМЕННО НА ЗАПРОСЕ, и не абсолютом, а РАЗНИЦЕЙ «маленькая рассылка →
* большая»: одинаковая для обеих цена самого запроса так вычитается.
* 🪤 Два капкана. Первый: без `memory_reset_peak_usage()` (PHP 8.2+) пик
* считается за весь процесс, и в полном прогоне его задаёт чужой тест.
* Второй: `memory_get_peak_usage(true)` — это память, взятая у системы; она
* не убывает, и после первого большого теста показывает одно и то же число
* на любом замере. Нужен именно счёт самого PHP — без аргумента.
*
* Пороги стоят между измеренным «до» и «после» — каждый проверен вырезанием
* (вернул старый вариант — тест краснеет).
*
* Помощники с префиксом bigSms* — имена функций в Pest ГЛОБАЛЬНЫЕ, совпадение
* с соседним файлом роняет весь прогон.
*
* Синтетические номера 7999…/7998… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
/**
* Быстрая заливка контактов в «свою базу» — пачками, без фабрик.
* $from — с какого номера продолжать: на паре «телефон + клиент» стоит запрет
* дублей, и вторая заливка с нуля упала бы на нём.
*/
function bigSmsContacts(int $tenantId, int $count, int $from = 0, string $prefix = '7999'): void
{
$now = now();
foreach (array_chunk(range($from, $from + $count - 1), 1000) as $chunk) {
$rows = [];
foreach ($chunk as $i) {
$rows[] = [
'tenant_id' => $tenantId,
'phone' => $prefix.str_pad((string) $i, 11 - strlen($prefix), '0', STR_PAD_LEFT),
'operator' => 'МТС',
'created_at' => $now,
'updated_at' => $now,
];
}
DB::table('client_sms_contacts')->insert($rows);
}
}
/** Быстрая заливка ЧУЖИХ номеров в общий стоп-лист портала. */
function bigSmsGlobalOptouts(int $count, string $prefix = '7998'): void
{
$now = now();
foreach (array_chunk(range(0, $count - 1), 1000) as $chunk) {
$rows = [];
foreach ($chunk as $i) {
$rows[] = [
'phone' => $prefix.str_pad((string) $i, 11 - strlen($prefix), '0', STR_PAD_LEFT),
'reason' => null,
'created_at' => $now,
];
}
DB::table('sms_global_optouts')->insert($rows);
}
}
/** Живая сделка в окне рассылки. */
function bigSmsDeal(int $tenantId, string $phone): Deal
{
return Deal::withoutEvents(fn () => Deal::factory()->create([
'tenant_id' => $tenantId,
'phone' => $phone,
'phone_operator' => 'МТС',
'received_at' => now()->subDay(),
]));
}
/** Тенант + вошедший пользователь + песочница. */
function bigSmsActor(): array
{
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
return [$user, $tenant];
}
it('2.9 — предпросмотр на 20 000 номерах отвечает и не съедает память', function () {
[$user, $tenant] = bigSmsActor();
$preview = fn () => $this->actingAs($user)->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'base',
]);
// Сначала маленькая рассылка — она задаёт «стоимость самого запроса» на этой
// машине (загрузка приложения, маршруты, шаблоны). Дальше меряем РАЗНИЦУ:
// сколько стоят именно 19 800 добавленных номеров.
bigSmsContacts($tenant->id, 200);
memory_reset_peak_usage();
$preview()->assertOk()->assertJsonPath('sendable_count', 200);
$smallMb = memory_get_peak_usage() / 1024 / 1024;
bigSmsContacts($tenant->id, 19800, from: 200);
memory_reset_peak_usage();
$started = microtime(true);
$response = $preview();
$elapsed = microtime(true) - $started;
$bigMb = memory_get_peak_usage() / 1024 / 1024;
fwrite(STDERR, sprintf(
"\n[2.9] предпросмотр: 200 номеров %.0f МБ → 20 000 номеров %.0f МБ (разница %.0f МБ), %.2f с\n",
$smallMb, $bigMb, $bigMb - $smallMb, $elapsed,
));
$response->assertOk()->assertJsonPath('sendable_count', 20000);
// Измерено на этой машине: 37 МБ до правки, 18 МБ после (по два прогона на
// сторону). Порог посередине — краснеет, если 20 000 строк снова поднимут
// в память целиком.
expect($elapsed)->toBeLessThan(20.0)
->and($bigMb - $smallMb)->toBeLessThan(26.0);
});
it('2.9 — рассылка на 20 000 номеров создаётся, снимок полный', function () {
[$user, $tenant] = bigSmsActor();
bigSmsContacts($tenant->id, 20000);
// Отправку не выполняем: экран её НЕ ждёт — рассылка уходит в очередь, отдельным
// процессом. Здесь меряем ровно то, на что смотрит человек: ответ на «Отправить».
// 🪤 Без этого джоб выполняется внутри запроса, и в тестовой базе 20 000 вложенных
// транзакций (savepoint на каждое сообщение) валят PostgreSQL по max_locks (В-60).
Queue::fake();
memory_reset_peak_usage();
$started = microtime(true);
$response = $this->actingAs($user)->postJson('/api/sms/campaigns', [
'title' => 'Много',
'body' => 'Здравствуйте!',
'source' => 'base',
]);
$elapsed = microtime(true) - $started;
$peakMb = memory_get_peak_usage() / 1024 / 1024;
fwrite(STDERR, sprintf("\n[2.9] создание 20 000: %.2f с, пик %.0f МБ\n", $elapsed, $peakMb));
$response->assertCreated()->assertJsonPath('planned_count', 20000);
// Потолок времени здесь грубый: он ловит квадрат и построчные запросы, а не
// меряет победу. Измерено: 1.40 с до правок, 1.05 с после — разница слишком
// мала, чтобы называть её достижением, и в утверждение теста не вынесена.
// Память проверяется разницами в соседних тестах: абсолютный пик зависит от
// того, что делал процесс раньше, и в полном прогоне такой порог врал бы.
expect(ClientSmsCampaignPhone::where('campaign_id', $response->json('id'))->count())->toBe(20000)
->and($elapsed)->toBeLessThan(30.0);
Queue::assertPushed(SendClientSmsCampaignJob::class);
});
it('2.9 — огромный общий стоп-лист не тащится в память целиком', function () {
[$user, $tenant] = bigSmsActor();
bigSmsContacts($tenant->id, 200); // у клиента всего 200 своих номеров
$preview = fn () => $this->actingAs($user)->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'base',
]);
// Меряем не абсолютный потолок, а РАЗНИЦУ на одной и той же машине: сначала
// пустой общий стоп-лист, потом 50 000 ЧУЖИХ номеров в нём. Клиентские 200
// номеров не изменились — значит вся разница и есть цена лишнего чтения.
memory_reset_peak_usage();
$preview()->assertOk()->assertJsonPath('sendable_count', 200);
$emptyMb = memory_get_peak_usage() / 1024 / 1024;
bigSmsGlobalOptouts(50000);
memory_reset_peak_usage();
$preview()->assertOk()->assertJsonPath('sendable_count', 200);
$hugeMb = memory_get_peak_usage() / 1024 / 1024;
fwrite(STDERR, sprintf(
"\n[2.9] чужой стоп-лист: пусто %.0f МБ → 50 000 записей %.0f МБ (разница %.0f МБ)\n",
$emptyMb, $hugeMb, $hugeMb - $emptyMb,
));
// Чужие 50 000 номеров к этой рассылке отношения не имеют: спрашивать надо
// только про свои 200. Измерено: +27 МБ до правки, +4 МБ после.
expect($hugeMb - $emptyMb)->toBeLessThan(10.0);
});
it('2.9 — удалённая сделка в получатели не попадает', function () {
[$user, $tenant] = bigSmsActor();
// Сторож к правке этой же задачи: аудитория теперь читается «сырыми» строками,
// без сборки моделей. Правило «удалённые сделки не берём» живёт в модели, и
// если оно при таком чтении потеряется, СМС уйдёт человеку, чью сделку удалили.
$alive = bigSmsDeal($tenant->id, '79995551111');
$deleted = bigSmsDeal($tenant->id, '79995552222');
$deleted->delete();
$response = $this->actingAs($user)->postJson('/api/sms/preview', [
'body' => 'Здравствуйте!',
'source' => 'deals',
'audience_days' => 30,
]);
$response->assertOk()->assertJsonPath('sendable_count', 1);
expect($alive->trashed())->toBeFalse()
->and($deleted->fresh()->trashed())->toBeTrue();
});