test обзвон: шесть сторожей по дырам приёмки — настройка, повтор, объём
Приёмка нашла три дыры, все в сторожах, а не в коде. Код не тронут ни одной строкой: сверено пустым git diff по всем моим файлам. Дыра 1 — срок можно было зашить в код, и никто бы не заметил. Прежний сторож проверял, что строки настроек ЗАВЕДЕНЫ, а не что их ЧИТАЮТ. Заведены два сторожа поведения: настройка, отличная от умолчания, обязана менять судьбу записи. У материалов клиента этой дыры нет — чистка там настроек не читает, а читатель настроек накрыт чужим сторожем круга З-2.3, проверено тем же ножом. Дыра 2 — повторный запуск. Два сторожа: второй заход не падает, строку не переписывает и не дописывает в журнал ПДн лишнюю запись об уже стёртом. Половина сторожа, сравнивавшая столбцы, обманывалась одной секундой — добавлен ctid, прибор «трогали ли строку вообще». Дыра 3 — объём. Мерка построена другая, чем просила приёмка, и в отчёте объяснено почему: запросы обязаны расти с числом строк, иначе придётся отказаться от правила «не стёрся файл — строку не трогаем». Ловим лишний запрос в переборе: цена строки прибита к четырём и обязана совпадать на двух объёмах. Плюс сторож на потолок за заход — его не было вовсе, а именно он защищает ночь от полумиллиона просроченных строк. Полный прогон: 4992 на входе, 4998 на выходе, красных ноль.
This commit is contained in:
@@ -79,6 +79,22 @@ function z22mStroka(int $id): object
|
||||
return $stroka;
|
||||
}
|
||||
|
||||
/**
|
||||
* Место строки на диске — прибор «переписывали ли её вообще».
|
||||
*
|
||||
* Сравнение столбцов обмануть можно: два прогона в одну секунду дают одинаковый
|
||||
* `updated_at`. PostgreSQL при каждой правке кладёт новую версию строки в другое
|
||||
* место, и `ctid` меняется даже при записи тех же значений. 🪤 `xmin` не годится:
|
||||
* весь тест идёт одной транзакцией, номер транзакции у обеих правок был бы один.
|
||||
*/
|
||||
function z22mMestoStroki(int $id): string
|
||||
{
|
||||
$stroki = DB::connection('pgsql_supplier')
|
||||
->select('select ctid::text as mesto from obzvon_materialy_klienta where id = ?', [$id]);
|
||||
|
||||
return (string) $stroki[0]->mesto;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Главный вред: чужой голос лежит вечно. И обратный: унесли свежее.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -254,6 +270,53 @@ it('З-2.2: файл записи клиента стереть не удало
|
||||
// 6. Пустой прогон.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Повторный запуск. Чистку зовут каждую ночь.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('З-2.2: повторный запуск чистки записей клиента не падает и ничего лишнего не трогает', function (): void {
|
||||
Storage::fake(ObzvonMaterialKlienta::DISK);
|
||||
$tenantId = z22mTenant();
|
||||
|
||||
Storage::disk(ObzvonMaterialKlienta::DISK)->put($tenantId.'/odin.mp3', 'чужой голос');
|
||||
|
||||
$id = z22mMaterial($tenantId, [
|
||||
'file_path' => $tenantId.'/odin.mp3',
|
||||
'recording_expires_at' => now()->subDay(),
|
||||
'transcript' => 'Текст, которого не станет',
|
||||
'transcript_expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
expect(Artisan::call('obzvon:chistka-materialov'))->toBe(0);
|
||||
|
||||
$poslePervogo = (array) z22mStroka($id);
|
||||
$mestoPervogo = z22mMestoStroki($id);
|
||||
$sledovPervogo = DB::connection('pgsql_supplier')->table('pd_processing_log')
|
||||
->where('subject_type', 'obzvon_material')->where('subject_id', $id)->count();
|
||||
|
||||
expect(Artisan::call('obzvon:chistka-materialov'))->toBe(0);
|
||||
$vyvod = Artisan::output();
|
||||
|
||||
$posleVtorogo = (array) z22mStroka($id);
|
||||
$mestoVtorogo = z22mMestoStroki($id);
|
||||
$sledovVtorogo = DB::connection('pgsql_supplier')->table('pd_processing_log')
|
||||
->where('subject_type', 'obzvon_material')->where('subject_id', $id)->count();
|
||||
|
||||
// 🔴 Строку НЕ ПЕРЕПИСЫВАЛИ вовсе — прибор базы, а не сравнение значений.
|
||||
expect($mestoVtorogo)->toBe($mestoPervogo);
|
||||
|
||||
// Значения тоже те же.
|
||||
expect($posleVtorogo)->toBe($poslePervogo);
|
||||
// В журнал ПДн не дописано лишней записи об уже стёртом.
|
||||
expect($sledovVtorogo)->toBe($sledovPervogo);
|
||||
|
||||
expect($vyvod)->toContain('Стирать было нечего');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Пустой прогон.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('З-2.2: пустой прогон чистки записей клиента говорит о себе вслух', function (): void {
|
||||
Storage::fake(ObzvonMaterialKlienta::DISK);
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* З-2.2 — чистка не роняет базу на большом объёме.
|
||||
*
|
||||
* 🔴 ОТ КАКОГО ВРЕДА. Чистка перебирает строки и на каждой лезет к диску и пишет
|
||||
* след в журнал ПДн — ровно то место, где заводится «вопрос к базе внутри
|
||||
* перебора». В портале это ловили дважды за неделю: отбор двадцати тысяч номеров
|
||||
* вырос с 20 до 29 секунд, а другая правка дала двадцать тысяч запросов на пустом
|
||||
* месте. Чистку зовут каждую ночь по всем клиентам сразу — там это вылезет не на
|
||||
* тесте, а на боевом.
|
||||
*
|
||||
* 🔴 ПОЧЕМУ МЕРЯЕМ НЕ ТО, ЧТО ПРОСИЛА ПРИЁМКА, И ПОЧЕМУ ТАК ЧЕСТНЕЕ.
|
||||
* Приёмка просила доказать, что «число запросов не растёт пропорционально числу
|
||||
* строк». Такого доказать НЕЛЬЗЯ, и добиваться этого было бы вредно: чистка
|
||||
* обязана трогать каждую строку отдельно. Сложить правки в один пакетный UPDATE
|
||||
* значит отказаться от правила «сначала файл, потом строка, а не стёрся файл —
|
||||
* строку не трогаем вовсе»: пакет либо пометит стёртыми записи с живыми файлами,
|
||||
* либо не пометит ни одной. Цена этого — голос человека, недостижимый для любой
|
||||
* будущей чистки. Значит запросы РАСТУТ с числом строк, и это правильно.
|
||||
*
|
||||
* Беда, которую надо поймать, другая: **лишний** запрос внутри перебора — тот,
|
||||
* которого быть не должно (перечитать настройку на каждой строке, сходить за
|
||||
* клиентом, посчитать что-нибудь). Такой запрос тоже растёт линейно, и мерка
|
||||
* «не растёт пропорционально» его бы НЕ увидела. Поэтому меряем ЦЕНУ ОДНОЙ
|
||||
* СТРОКИ и прибиваем её гвоздём:
|
||||
* · считаем запросы на N строках и на 2N;
|
||||
* · разница, делённая на N, и есть цена строки;
|
||||
* · она обязана быть ОДНОЙ И ТОЙ ЖЕ на обоих объёмах (это ловит всё, что
|
||||
* растёт хуже линейного) и не больше прибитого числа (это ловит лишний
|
||||
* запрос в переборе).
|
||||
*
|
||||
* 🔴 И ВТОРАЯ ПОЛОВИНА, БЕЗ КОТОРОЙ ПЕРВАЯ БЕСПОЛЕЗНА. От «уронить базу ночью»
|
||||
* защищает не дешёвая строка, а ПОТОЛОК `--limit`: сколько бы просроченных
|
||||
* записей ни накопилось, за один заход берётся не больше него. Сторожа на то,
|
||||
* что потолок вообще соблюдается, не было ни одного — а сломайся он молча, ночь
|
||||
* с полумиллионом просроченных строк пошла бы без края.
|
||||
*
|
||||
* Считаем запросы ОБОИХ соединений: чистка работает через `pgsql_supplier`, а
|
||||
* настройки читаются через соединение по умолчанию. Считай мы одно — перечитанная
|
||||
* на каждой строке настройка прошла бы мимо сторожа.
|
||||
*
|
||||
* Образец мерки — `tests/Feature/ClientTg/LaunchIdempotencyTest.php`
|
||||
* (`DB::enableQueryLog`) и `tests/Feature/ClientSms/LargeCampaignTest.php`.
|
||||
*/
|
||||
|
||||
use App\Services\Obzvon\ObzvonErasureAdapter;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Цена одной строки в запросах: правка строки + след в журнале ПДн, и так на
|
||||
* каждой из двух ступеней. Четыре — это ВЕСЬ бюджет; появится пятый, значит
|
||||
* в переборе завёлся лишний запрос.
|
||||
*/
|
||||
const Z22V_ZAPROSOV_NA_STROKU = 4;
|
||||
|
||||
function z22vDisk(): void
|
||||
{
|
||||
config(['filesystems.disks.'.ObzvonErasureAdapter::DISK_ZAPISEY => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('framework/testing/disks/'.ObzvonErasureAdapter::DISK_ZAPISEY),
|
||||
'throw' => false,
|
||||
]]);
|
||||
|
||||
Storage::fake(ObzvonErasureAdapter::DISK_ZAPISEY);
|
||||
}
|
||||
|
||||
function z22vTenant(): int
|
||||
{
|
||||
return (int) DB::connection('pgsql_supplier')->table('tenants')->insertGetId([
|
||||
'subdomain' => 'z22v-'.uniqid(),
|
||||
'organization_name' => 'З-2.2 объём',
|
||||
'contact_email' => 'z22v@test.local',
|
||||
'status' => 'active',
|
||||
'balance_rub' => '0.00',
|
||||
'balance_leads' => 0,
|
||||
'is_trial' => false,
|
||||
'chargeback_unrecovered_rub' => '0.00',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Пачка просроченных звонков одним запросом — фабрики здесь были бы дороже
|
||||
* самой мерки и зашумили бы счётчик.
|
||||
*
|
||||
* 🪤 Файлов на диске нарочно НЕТ. Для чистки «файла уже нет» и «файл стёрли» —
|
||||
* один и тот же исход: строка помечается стёртой. Значит перебор идёт полный, а
|
||||
* двести настоящих файлов в тесте не создаются.
|
||||
*/
|
||||
function z22vZvonki(int $tenantId, int $skolko, bool $soZvukom = true): void
|
||||
{
|
||||
$now = now();
|
||||
$stroki = [];
|
||||
|
||||
for ($i = 0; $i < $skolko; $i++) {
|
||||
$stroki[] = [
|
||||
'tenant_id' => $tenantId,
|
||||
'phone' => '+7999'.str_pad((string) $i, 7, '0', STR_PAD_LEFT),
|
||||
'started_at' => $now->copy()->subMonths(8),
|
||||
'answered' => true,
|
||||
'leg1_duration_sec' => 30,
|
||||
'attempt_outcome' => 'answered',
|
||||
'recording_path' => $soZvukom ? 'obyom/'.$tenantId.'-'.$i.'.mp3' : null,
|
||||
'recording_expires_at' => $soZvukom ? $now->copy()->subMonths(7) : null,
|
||||
'transcript' => 'Расшифровка номер '.$i,
|
||||
'transcript_expires_at' => $now->copy()->subMonths(5),
|
||||
'billable' => true,
|
||||
'price_kopecks' => 100,
|
||||
'billed_minutes' => 1,
|
||||
'tariff_answer_kopecks' => 100,
|
||||
'tariff_minute_kopecks' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
foreach (array_chunk($stroki, 200) as $chunk) {
|
||||
DB::connection('pgsql_supplier')->table('obzvon_calls')->insert($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/** Сколько запросов к базе стоила работа — по ОБОИМ соединениям. */
|
||||
function z22vSchitatZaprosy(callable $rabota): int
|
||||
{
|
||||
$osnovnoe = DB::connection('pgsql');
|
||||
$sluzhebnoe = DB::connection('pgsql_supplier');
|
||||
|
||||
$osnovnoe->flushQueryLog();
|
||||
$sluzhebnoe->flushQueryLog();
|
||||
$osnovnoe->enableQueryLog();
|
||||
$sluzhebnoe->enableQueryLog();
|
||||
|
||||
$rabota();
|
||||
|
||||
$vsego = count($osnovnoe->getQueryLog());
|
||||
|
||||
// 🪤 Соединения делят одно PDO (SharesSupplierPdo), но журналы у них РАЗНЫЕ.
|
||||
// Если объект соединения один и тот же — не считаем дважды.
|
||||
if ($sluzhebnoe !== $osnovnoe) {
|
||||
$vsego += count($sluzhebnoe->getQueryLog());
|
||||
}
|
||||
|
||||
$osnovnoe->disableQueryLog();
|
||||
$sluzhebnoe->disableQueryLog();
|
||||
|
||||
return $vsego;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Потолок за заход. От «уронить базу ночью» защищает именно он.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('З-2.2: за один заход чистка берёт не больше потолка, сколько бы ни накопилось', function (): void {
|
||||
z22vDisk();
|
||||
$tenantId = z22vTenant();
|
||||
|
||||
z22vZvonki($tenantId, 200, soZvukom: false);
|
||||
|
||||
Artisan::call('obzvon:chistka', ['--limit' => 50]);
|
||||
|
||||
$styorto = DB::connection('pgsql_supplier')->table('obzvon_calls')
|
||||
->where('tenant_id', $tenantId)->whereNotNull('transcript_deleted_at')->count();
|
||||
$ostalos = DB::connection('pgsql_supplier')->table('obzvon_calls')
|
||||
->where('tenant_id', $tenantId)->whereNull('transcript_deleted_at')->count();
|
||||
|
||||
// 🔴 Ровно потолок, не больше и не меньше. Больше — потолок не работает и
|
||||
// ночь пойдёт без края. Меньше — чистка недобирает и записи копятся.
|
||||
expect($styorto)->toBe(50);
|
||||
expect($ostalos)->toBe(150);
|
||||
|
||||
// Следующей ночью возьмёт следующие пятьдесят — очередь разбирается, а не стоит.
|
||||
Artisan::call('obzvon:chistka', ['--limit' => 50]);
|
||||
|
||||
expect(DB::connection('pgsql_supplier')->table('obzvon_calls')
|
||||
->where('tenant_id', $tenantId)->whereNotNull('transcript_deleted_at')->count())->toBe(100);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Цена одной строки. Ловит лишний запрос внутри перебора.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('З-2.2: цена одной строки в запросах не растёт с объёмом и не превышает бюджета', function (): void {
|
||||
z22vDisk();
|
||||
|
||||
// 🪤 ТРИ ЗАМЕРА, А НЕ ДВА, И ЭТО НЕ ПЕДАНТСТВО. Первое написание этого сторожа
|
||||
// считало постоянные расходы прогона ИЗ ТЕХ ЖЕ двух замеров — и превращалось в
|
||||
// тождество, верное при любых числах. Такой сторож не может покраснеть ни при
|
||||
// какой поломке. Пустой прогон меряется ОТДЕЛЬНО и даёт постоянную часть
|
||||
// независимо, после чего два наклона можно честно сравнить между собой.
|
||||
$postoyannye = z22vSchitatZaprosy(function (): void {
|
||||
Artisan::call('obzvon:chistka');
|
||||
});
|
||||
|
||||
$malenkiy = z22vTenant();
|
||||
z22vZvonki($malenkiy, 25);
|
||||
|
||||
$zaprosovNa25 = z22vSchitatZaprosy(function (): void {
|
||||
Artisan::call('obzvon:chistka');
|
||||
});
|
||||
|
||||
// Первый клиент уже вычищен — следующий прогон видит только строки второго.
|
||||
$bolshoy = z22vTenant();
|
||||
z22vZvonki($bolshoy, 50);
|
||||
|
||||
$zaprosovNa50 = z22vSchitatZaprosy(function (): void {
|
||||
Artisan::call('obzvon:chistka');
|
||||
});
|
||||
|
||||
$cenaNa25 = ($zaprosovNa25 - $postoyannye) / 25;
|
||||
$cenaNa50 = ($zaprosovNa50 - $postoyannye) / 50;
|
||||
|
||||
// 🔴 Бюджет прибит гвоздём. Перечитанная на каждой строке настройка, поход за
|
||||
// клиентом, лишний счёт — любой из них поднимет это число.
|
||||
expect($cenaNa25)->toBeLessThanOrEqual((float) Z22V_ZAPROSOV_NA_STROKU);
|
||||
expect($cenaNa50)->toBeLessThanOrEqual((float) Z22V_ZAPROSOV_NA_STROKU);
|
||||
|
||||
// 🔴 И вторая половина: на вдвое большем объёме цена строки ТА ЖЕ. Это ловит
|
||||
// всё, что растёт хуже линейного, — то самое, от чего портал уже страдал.
|
||||
expect(round($cenaNa50, 3))->toBe(round($cenaNa25, 3));
|
||||
|
||||
// Обе чистки доделали работу — иначе дешевизна ничего не значила бы.
|
||||
expect(DB::connection('pgsql_supplier')->table('obzvon_calls')
|
||||
->whereIn('tenant_id', [$malenkiy, $bolshoy])
|
||||
->whereNull('recording_deleted_at')->count())->toBe(0);
|
||||
});
|
||||
@@ -102,6 +102,30 @@ function z22Stroka(int $id): object
|
||||
return $stroka;
|
||||
}
|
||||
|
||||
/**
|
||||
* Место строки на диске — прибор «переписывали ли её вообще».
|
||||
*
|
||||
* 🔴 ЗАЧЕМ, ЕСЛИ ЕСТЬ СРАВНЕНИЕ СТОЛБЦОВ. Потому что сравнение столбцов
|
||||
* обмануть можно, и я это ВИДЕЛ: сняв замки второй ступени, я заставил чистку
|
||||
* переписывать уже стёртую строку — а сравнение столбцов осталось зелёным,
|
||||
* потому что оба прогона легли в одну и ту же секунду и `updated_at` совпал.
|
||||
* Поймала тогда только вторая половина сторожа, счёт записей в журнале ПДн.
|
||||
*
|
||||
* PostgreSQL при КАЖДОЙ правке кладёт новую version строки в другое место, и
|
||||
* `ctid` меняется — даже если записали ровно те же значения. Обмануть этот
|
||||
* прибор нечем: он отвечает не «изменились ли данные», а «трогали ли строку».
|
||||
*
|
||||
* 🪤 `xmin` для этого не годится: весь тест идёт одной транзакцией, и номер
|
||||
* транзакции у обеих правок был бы один и тот же.
|
||||
*/
|
||||
function z22MestoStroki(int $id): string
|
||||
{
|
||||
$stroki = DB::connection('pgsql_supplier')
|
||||
->select('select ctid::text as mesto from obzvon_calls where id = ?', [$id]);
|
||||
|
||||
return (string) $stroki[0]->mesto;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Главный вред: запись лежит вечно. И обратный: жадная чистка уносит свежее.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -388,6 +412,127 @@ it('З-2.2: пустой прогон говорит о себе вслух, а
|
||||
// 8. Сроки звонков — в настройках портала, а не только в коде.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('З-2.2: срок звука взят ИЗ НАСТРОЙКИ, а не зашит в код', function (): void {
|
||||
// 🔴 ЭТОТ СТОРОЖ НАЙДЕН ПРИЁМКОЙ, И НАЙДЕН НОЖОМ. Соседний сторож ниже
|
||||
// проверяет, что строки настроек ЗАВЕДЕНЫ. Он остался зелёным, когда
|
||||
// `SrokiZvonka::mesyacevZvuka()` заставили возвращать умолчание, не заглядывая
|
||||
// в настройки вовсе, — то есть при прямо запрещённом «срок зашит в код».
|
||||
// Имя обещало больше, чем защита делала: «заведена» и «читается» — разные вещи.
|
||||
// Проверяем ПОВЕДЕНИЕ: запись, которая при умолчании ушла бы, при новой
|
||||
// настройке обязана остаться.
|
||||
z22DiskZapisey();
|
||||
$tenantId = z22Tenant();
|
||||
|
||||
DB::table('system_settings')
|
||||
->where('key', SrokiZvonka::KLYUCH_SROK_ZVUKA)
|
||||
->update(['value' => '6', 'updated_at' => now()]);
|
||||
|
||||
Storage::disk(ObzvonErasureAdapter::DISK_ZAPISEY)->put('zhivyot.mp3', 'звук трёхмесячной давности');
|
||||
Storage::disk(ObzvonErasureAdapter::DISK_ZAPISEY)->put('umryot.mp3', 'звук семимесячной давности');
|
||||
|
||||
// При умолчании в месяц эта запись УШЛА БЫ. Настройка говорит шесть — обязана жить.
|
||||
$zhivyot = z22Zvonok($tenantId, [
|
||||
'started_at' => now()->subMonths(3),
|
||||
'recording_path' => 'zhivyot.mp3',
|
||||
'recording_expires_at' => null,
|
||||
]);
|
||||
|
||||
// 🔴 Вторая половина датчика: без неё сторож остался бы зелёным и на чистке,
|
||||
// которая вообще ничего не стирает.
|
||||
$umryot = z22Zvonok($tenantId, [
|
||||
'started_at' => now()->subMonths(7),
|
||||
'recording_path' => 'umryot.mp3',
|
||||
'recording_expires_at' => null,
|
||||
]);
|
||||
|
||||
Artisan::call('obzvon:chistka');
|
||||
|
||||
expect(z22Stroka($zhivyot)->recording_path)->toBe('zhivyot.mp3');
|
||||
expect(z22Stroka($zhivyot)->recording_deleted_at)->toBeNull();
|
||||
|
||||
expect(z22Stroka($umryot)->recording_path)->toBeNull();
|
||||
expect(z22Stroka($umryot)->recording_deleted_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('З-2.2: срок расшифровки взят ИЗ НАСТРОЙКИ, а не зашит в код', function (): void {
|
||||
z22DiskZapisey();
|
||||
$tenantId = z22Tenant();
|
||||
|
||||
DB::table('system_settings')
|
||||
->where('key', SrokiZvonka::KLYUCH_SROK_RASSHIFROVKI)
|
||||
->update(['value' => '12', 'updated_at' => now()]);
|
||||
|
||||
// При умолчании в три месяца эта расшифровка УШЛА БЫ. Настройка говорит год.
|
||||
$zhivyot = z22Zvonok($tenantId, [
|
||||
'started_at' => now()->subMonths(6),
|
||||
'transcript' => 'Слова, которым по настройке жить ещё полгода',
|
||||
'transcript_expires_at' => null,
|
||||
]);
|
||||
|
||||
$umryot = z22Zvonok($tenantId, [
|
||||
'started_at' => now()->subMonths(13),
|
||||
'transcript' => 'Слова, которым по настройке пора уходить',
|
||||
'transcript_expires_at' => null,
|
||||
]);
|
||||
|
||||
Artisan::call('obzvon:chistka');
|
||||
|
||||
expect(z22Stroka($zhivyot)->transcript)->toBe('Слова, которым по настройке жить ещё полгода');
|
||||
expect(z22Stroka($zhivyot)->transcript_deleted_at)->toBeNull();
|
||||
|
||||
expect(z22Stroka($umryot)->transcript)->toBeNull();
|
||||
expect(z22Stroka($umryot)->transcript_deleted_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Повторный запуск. Чистку зовут КАЖДУЮ ночь — второй раз для неё обычное дело.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('З-2.2: повторный запуск чистки звонков не падает и ничего лишнего не трогает', function (): void {
|
||||
z22DiskZapisey();
|
||||
$tenantId = z22Tenant();
|
||||
|
||||
Storage::disk(ObzvonErasureAdapter::DISK_ZAPISEY)->put('odin.mp3', 'звук');
|
||||
|
||||
$id = z22Zvonok($tenantId, [
|
||||
'started_at' => now()->subMonths(6),
|
||||
'recording_path' => 'odin.mp3',
|
||||
'recording_expires_at' => now()->subMonths(5),
|
||||
'transcript' => 'Расшифровка, которой не станет',
|
||||
'transcript_expires_at' => now()->subMonths(3),
|
||||
'price_kopecks' => 2137,
|
||||
]);
|
||||
|
||||
expect(Artisan::call('obzvon:chistka'))->toBe(0);
|
||||
|
||||
$poslePervogo = (array) z22Stroka($id);
|
||||
$mestoPervogo = z22MestoStroki($id);
|
||||
$sledovPervogo = DB::connection('pgsql_supplier')->table('pd_processing_log')
|
||||
->where('subject_type', 'obzvon_call')->where('subject_id', $id)->count();
|
||||
|
||||
// Второй заход — ровно то, что случается следующей ночью.
|
||||
expect(Artisan::call('obzvon:chistka'))->toBe(0);
|
||||
$vyvod = Artisan::output();
|
||||
|
||||
$posleVtorogo = (array) z22Stroka($id);
|
||||
$mestoVtorogo = z22MestoStroki($id);
|
||||
$sledovVtorogo = DB::connection('pgsql_supplier')->table('pd_processing_log')
|
||||
->where('subject_type', 'obzvon_call')->where('subject_id', $id)->count();
|
||||
|
||||
// 🔴 Строку НЕ ПЕРЕПИСЫВАЛИ вовсе — прибор базы, а не сравнение значений.
|
||||
expect($mestoVtorogo)->toBe($mestoPervogo);
|
||||
|
||||
// Значения тоже те же — на случай, если строку переписали и вернули обратно.
|
||||
expect($posleVtorogo)->toBe($poslePervogo);
|
||||
expect((int) $posleVtorogo['price_kopecks'])->toBe(2137);
|
||||
|
||||
// 🔴 И в журнал ПДн не дописано лишней записи «удалено» о том, что уже удалено:
|
||||
// раздутый ложными строками журнал врёт ровно так же, как молчащий.
|
||||
expect($sledovVtorogo)->toBe($sledovPervogo);
|
||||
|
||||
expect($vyvod)->toContain('Стирать было нечего');
|
||||
});
|
||||
|
||||
it('З-2.2: сроки хранения записей звонка заведены в настройках портала', function (): void {
|
||||
foreach ([
|
||||
SrokiZvonka::KLYUCH_SROK_ZVUKA => '1',
|
||||
|
||||
@@ -124,9 +124,10 @@ recording_deleted_at IS NULL`. Пометив строку стёртой при
|
||||
| `app/app/Console/Commands/ObzvonChistkaMaterialovKlientaCommand.php` | `obzvon:chistka-materialov` — чужие голоса: звук 30 дней, расшифровка полгода, сроки врозь. |
|
||||
| `app/app/Services/Obzvon/SrokiZvonka.php` | Правило срока звонка в ОДНОМ месте: им пользуется чистка и им же обязана пользоваться звонилка волны 3, когда будет проставлять срок при создании строки. |
|
||||
| `app/database/migrations/2026_08_05_140000_add_obzvon_zvonki_sroki_hraneniya.php` | Две строки в `system_settings`: `obzvon_zvonki_srok_zvuka_mesyacev` = 1, `obzvon_zvonki_srok_rasshifrovki_mesyacev` = 3. Новых таблиц нет. |
|
||||
| `app/tests/Feature/Obzvon/ObzvonChistkaZvonkovPoSrokuTest.php` | 10 сторожей чистки звонков. |
|
||||
| `app/tests/Feature/Obzvon/ObzvonChistkaMaterialovPoSrokuTest.php` | 7 сторожей чистки записей клиента. |
|
||||
| `app/tests/Feature/Obzvon/ObzvonChistkaZvonkovPoSrokuTest.php` | 13 сторожей чистки звонков. |
|
||||
| `app/tests/Feature/Obzvon/ObzvonChistkaMaterialovPoSrokuTest.php` | 8 сторожей чистки записей клиента. |
|
||||
| `app/tests/Feature/Obzvon/ObzvonChistkaVRaspisaniiTest.php` | 2 сторожа зовущего: расписание и пульс. |
|
||||
| `app/tests/Feature/Obzvon/ObzvonChistkaNaObyomeTest.php` | 2 сторожа на объёме: потолок за заход и цена строки в запросах. |
|
||||
|
||||
**Правлено:**
|
||||
|
||||
@@ -150,7 +151,10 @@ recording_deleted_at IS NULL`. Пометив строку стёртой при
|
||||
|
||||
## Сторожа, показанные красным
|
||||
|
||||
Каждого ронял правкой **кода**, не базы. После пробы всё возвращено, следов не осталось.
|
||||
Всего **25 сторожей**, каждый показан красным. Ронял правкой **кода**, не базы. После пробы
|
||||
всё возвращено; равенство принятому коду доказано пустым `git diff`, а не осмотром.
|
||||
|
||||
**Первый круг — тринадцать надрезов:**
|
||||
|
||||
| # | Чем ронял | Что сказал сторож |
|
||||
|---|---|---|
|
||||
@@ -179,19 +183,115 @@ recording_deleted_at IS NULL`. Пометив строку стёртой при
|
||||
уходит, свежее остаётся. Без второй половины жадная чистка, стирающая всё подряд, показала бы
|
||||
тот же зелёный.
|
||||
|
||||
## Второй круг — три дыры, найденные приёмкой
|
||||
|
||||
Приёмка нашла три дыры, все — в сторожах, а не в коде. Разобрал каждую.
|
||||
|
||||
### Дыра 1 — срок можно зашить в код. **Есть, и я её проверил ножом приёмки**
|
||||
|
||||
Мой сторож № 19 назывался «сроки заведены в настройках портала» и проверял ровно то, что
|
||||
говорит: строки настроек **заведены**. Что их **читают** — не проверял. Нож приёмки
|
||||
(`SrokiZvonka::polozhitelnoeChislo` возвращает умолчание, не заглядывая в настройки) прошёл
|
||||
мимо всех десяти моих сторожей.
|
||||
|
||||
**Заведены два сторожа поведения** — на звук и на расшифровку врозь. Каждый ставит настройку,
|
||||
отличную от умолчания, и доказывает, что чистка повела себя **по новой настройке**:
|
||||
запись, которая при умолчании ушла бы, — осталась; запись, просроченная и по новому сроку, —
|
||||
ушла. Обе половины в одном прогоне: без второй сторож остался бы зелёным и на чистке,
|
||||
которая вообще ничего не стирает.
|
||||
|
||||
🔴 **А вот у материалов клиента этой дыры НЕТ, и дублировать сторожа я не стал.** Проверил
|
||||
тем же ножом: вырезал чтение настроек в `MaterialyKlientaService` — и прогон сразу покраснел
|
||||
на **чужом** стороже `ObzvonMaterialyKlientaTest.php:396` (ждал 2026-08-10, получил 2026-09-04).
|
||||
Круг З-2.3 свою настройку сторожем уже накрыл.
|
||||
|
||||
Причина, почему у материалов иначе, важнее самого факта: **чистка материалов настроек не
|
||||
читает вовсе**. Обе даты у них `NOT NULL` и проставлены при приёме — чистка сравнивает с
|
||||
тем, что записано в строке. Так и должно быть: человеку обещан срок, записанный в его
|
||||
строке, а не тот, что стоит в настройках сегодня. Читатель настроек там — приём, и он
|
||||
накрыт. Заведи я туда своего сторожа, он сторожил бы уже сторожёное.
|
||||
|
||||
### Дыра 2 — повторный запуск без сторожа. **Есть, закрыта**
|
||||
|
||||
Требование «повторный запуск не падает и ничего лишнего не удаляет» сторожа не имело.
|
||||
Заведены два — на звонки и на материалы. Каждый гоняет чистку дважды подряд и доказывает:
|
||||
второй заход возвращает 0, говорит «стирать было нечего», строка не переписана, деньги те же
|
||||
знак в знак, **и в журнал ПДн не дописано лишней записи «удалено» о том, что уже удалено** —
|
||||
раздутый ложными строками журнал врёт ровно так же, как молчащий.
|
||||
|
||||
🔴 **И тут я поймал собственного слабого сторожа.** Первое написание сравнивало столбцы
|
||||
строки «до» и «после». Когда я снял замки второй ступени, чистка стала переписывать уже
|
||||
стёртую строку — а сравнение столбцов **осталось зелёным**: оба прогона легли в одну
|
||||
секунду, и `updated_at` совпал. Поймала тогда только вторая половина, счёт записей в журнале.
|
||||
Добавил точный прибор: **`ctid`** — место строки на диске. PostgreSQL при каждой правке кладёт
|
||||
новую версию строки в другое место, поэтому `ctid` отвечает не «изменились ли данные», а
|
||||
«трогали ли строку», и обмануть его нечем. Под тем же надрезом он покраснел: `(0,30)` → `(0,31)`.
|
||||
🪤 `xmin` для этого не годится: тест идёт одной транзакцией, номер транзакции был бы один.
|
||||
|
||||
### Дыра 3 — объём без сторожа. **Есть, но мерку я построил другую, и вот почему**
|
||||
|
||||
Приёмка просила доказать, что «**число запросов не растёт пропорционально числу строк**».
|
||||
Такого доказать нельзя, и добиваться этого было бы **вредно**. Чистка обязана трогать каждую
|
||||
строку отдельно: сложи правки в один пакетный UPDATE — и придётся отказаться от правила
|
||||
«сначала файл, потом строка, а не стёрся файл — строку не трогаем вовсе». Пакет либо пометит
|
||||
стёртыми записи с живыми файлами, либо не пометит ни одной. Цена — голос человека,
|
||||
недостижимый для любой будущей чистки. Значит запросы растут с числом строк, и это правильно.
|
||||
|
||||
Беда, которую надо поймать, другая: **лишний** запрос внутри перебора — перечитанная на
|
||||
каждой строке настройка, поход за клиентом, лишний счёт. Такой запрос тоже растёт линейно,
|
||||
и мерка «не растёт пропорционально» его бы **не увидела**. Поэтому меряю **цену одной строки**
|
||||
и прибиваю её гвоздём: считаю запросы на пустом прогоне, на 25 строках и на 50; наклон
|
||||
обязан быть один и тот же на обоих объёмах и не больше бюджета. Бюджет — **4 запроса на
|
||||
строку**: правка строки и след в журнале, и так на каждой из двух ступеней. Число не
|
||||
выдумано: вставив в перебор один лишний запрос, я получил ровно 5.
|
||||
|
||||
🪤 Первое написание этого сторожа считало постоянные расходы прогона **из тех же двух
|
||||
замеров** — и сворачивалось в тождество, верное при любых числах. Такой сторож не мог бы
|
||||
покраснеть никогда. Пустой прогон меряется третьим, независимым замером.
|
||||
|
||||
🔴 **И вторая половина, которой приёмка не просила, а она важнее первой.** От «уронить базу
|
||||
ночью» защищает не дешёвая строка, а **потолок `--limit`**: сколько бы просроченных записей
|
||||
ни накопилось, за заход берётся не больше него. Сторожа на то, что потолок вообще
|
||||
соблюдается, не было ни одного — а сломайся он молча, ночь с полумиллионом просроченных
|
||||
строк пошла бы без края. Сторож ставит 200 просроченных строк, гоняет с потолком 50 и
|
||||
требует ровно 50 стёртых и 150 живых, а вторым заходом — что очередь разбирается дальше.
|
||||
|
||||
### Сторожа второго круга, показанные красным
|
||||
|
||||
| # | Чем ронял | Что сказал сторож |
|
||||
|---|---|---|
|
||||
| 14 | Нож приёмки: `SrokiZvonka` возвращает умолчание, не читая настроек | `Failed asserting that null is identical to 'zhivyot.mp3'` — запись, которой по настройке жить, стёрта |
|
||||
| 15 | Тот же нож, срок расшифровки | `Failed asserting that null is identical to 'Слова, которым по настройке жить ещё полгода'` |
|
||||
| 16 | Снял замки второй ступени у звонков | `ctid`: `'(0,30)'` → `'(0,31)'` — строку переписали; и счёт записей в журнале ПДн `3` вместо `2` |
|
||||
| 17 | То же у материалов клиента | `ctid`: `'(0,17)'` → `'(0,18)'`; журнал `3` вместо `2` |
|
||||
| 18 | Заменил `->limit($limit)` на `->limit(100000)` | `Failed asserting that 200 is identical to 50` — потолок не работает, ночь пошла бы без края |
|
||||
| 19 | Вставил `$this->sroki->mesyacevZvuka()` внутрь перебора | `Failed asserting that 5 is equal to 4.0 or is less than 4.0` — ровно один лишний запрос на строку |
|
||||
|
||||
## Полный прогон
|
||||
|
||||
Своей рукой, на своей базе `liderra_testing_z22` (завёл сам; общую `liderra_testing` не трогал —
|
||||
её делят соседние смены). Команда `DB_DATABASE=liderra_testing_z22 php artisan test`, вывод в
|
||||
файл, не через трубу.
|
||||
|
||||
**Первый круг:**
|
||||
|
||||
- **На входе:** 4973 проверки — столько было до моей работы (4992 минус 19 моих новых).
|
||||
- **На выходе:** **4992 проверки, 4988 зелёных, 0 красных**, 15 667 утверждений, 15 минут 28 секунд.
|
||||
Код возврата 0.
|
||||
|
||||
Отдельно по обзвону: `tests/Feature/Obzvon/` — 159 проверок, 793 утверждения, все зелёные
|
||||
**Второй круг, после трёх дыр приёмки:**
|
||||
|
||||
- **На входе:** 4992 проверки.
|
||||
- **На выходе:** **4998 проверок, 4994 зелёных, 0 красных**, 15 697 утверждений, 14 минут 59 секунд.
|
||||
Код возврата 0. Прирост ровно +6 при шести новых сторожах.
|
||||
|
||||
Отдельно по обзвону: `tests/Feature/Obzvon/` — 165 проверок, 823 утверждения, все зелёные
|
||||
(вместе с чужими сторожами соседних кругов З-2.1, З-2.3, З-2.4, З-2.5).
|
||||
|
||||
🔴 Перед прогоном сверил свой **код** с уже принятым коммитом: `git diff` по
|
||||
`app/app/Console/`, `app/app/Services/`, `app/app/Models/`, `app/routes/`, `app/database/` —
|
||||
**пусто**. Значит все тринадцать надрезов второго круга сняты полностью и прогон мерил
|
||||
именно принятый код, а не остатки проб.
|
||||
|
||||
Плюс: `composer stan` — 0 ошибок; `deptrac` — 0 нарушений; `pint` — чисто.
|
||||
|
||||
## Две строки про соседей
|
||||
@@ -248,6 +348,28 @@ recording_deleted_at IS NULL`. Пометив строку стёртой при
|
||||
на верном коде. Граница «срок прошёл» строгая; в тесте теперь четыре месяца, и это
|
||||
написано в комментарии, чтобы следующая смена не «чинила» код.
|
||||
|
||||
**Второй круг:**
|
||||
|
||||
5. 🔴 **Сторож № 19 обещал больше, чем защищал.** «Сроки заведены в настройках» — и правда
|
||||
заведены; а что их читают, он не проверял. Нашла приёмка своим ножом, не я. Урок общий:
|
||||
имя сторожа надо читать как обещание и спрашивать, чем оно подкреплено.
|
||||
6. 🔴 **Сторож повтора в первом написании можно было обмануть секундой.** Сравнение столбцов
|
||||
«до/после» осталось зелёным при переписанной строке, потому что оба прогона легли в одну
|
||||
секунду. Спасла только вторая половина сторожа. Добавил `ctid` — прибор, который отвечает
|
||||
«трогали ли строку», а не «изменились ли значения».
|
||||
7. 🔴 **Сторож объёма в первом написании был тождеством.** Я выводил постоянные расходы
|
||||
прогона из тех же двух замеров, которые потом сравнивал, — такой сторож не мог покраснеть
|
||||
ни при какой поломке. Заметил сам при перечитывании, до прогона. Третий, независимый
|
||||
замер это чинит.
|
||||
8. 🔴 **Замена через оболочку молча не сработала, и я едва не сдал код без двух замков.**
|
||||
Восстанавливая надрез, я целился в текст, между строками которого остались комментарии от
|
||||
предыдущего надреза, — `str_replace` не нашёл ничего и **промолчал**, вернув успех. Проверка
|
||||
`grep` по следам надреза тоже показала чистоту: следов-то не осталось, а вот замки не
|
||||
вернулись. Вскрыл только `git diff --stat` — две строки удалено в двух файлах команд.
|
||||
Причина в том, что я нарушил правило смены: править файлы **только средствами правки**.
|
||||
Восстановил через `Edit`, после чего `git diff` по всему моему коду стал пустым — код
|
||||
побайтово равен тому, что уже принят. Правило написано ровно про это.
|
||||
|
||||
## Что осталось открытым
|
||||
|
||||
1. 🔴 **Диска `obzvon_zapisi` в `config/filesystems.php` нет.** Значит стереть файл записи
|
||||
|
||||
Reference in New Issue
Block a user