537d3e9c4d
Строка приёмочного листа 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 с. Скорость самой отправки не улучшалась — это очередь, а не экран.
237 lines
11 KiB
PHP
237 lines
11 KiB
PHP
<?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();
|
||
});
|