test обзвон: шесть сторожей по дырам приёмки — настройка, повтор, объём

Приёмка нашла три дыры, все в сторожах, а не в коде. Код не тронут ни одной
строкой: сверено пустым git diff по всем моим файлам.

Дыра 1 — срок можно было зашить в код, и никто бы не заметил. Прежний сторож
проверял, что строки настроек ЗАВЕДЕНЫ, а не что их ЧИТАЮТ. Заведены два
сторожа поведения: настройка, отличная от умолчания, обязана менять судьбу
записи. У материалов клиента этой дыры нет — чистка там настроек не читает,
а читатель настроек накрыт чужим сторожем круга З-2.3, проверено тем же ножом.

Дыра 2 — повторный запуск. Два сторожа: второй заход не падает, строку не
переписывает и не дописывает в журнал ПДн лишнюю запись об уже стёртом.
Половина сторожа, сравнивавшая столбцы, обманывалась одной секундой —
добавлен ctid, прибор «трогали ли строку вообще».

Дыра 3 — объём. Мерка построена другая, чем просила приёмка, и в отчёте
объяснено почему: запросы обязаны расти с числом строк, иначе придётся
отказаться от правила «не стёрся файл — строку не трогаем». Ловим лишний
запрос в переборе: цена строки прибита к четырём и обязана совпадать на
двух объёмах. Плюс сторож на потолок за заход — его не было вовсе, а именно
он защищает ночь от полумиллиона просроченных строк.

Полный прогон: 4992 на входе, 4998 на выходе, красных ноль.
This commit is contained in:
Дмитрий
2026-08-06 01:47:27 +03:00
parent 9436134adf
commit 9b66c7cd71
4 changed files with 569 additions and 5 deletions
@@ -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` нет.** Значит стереть файл записи