From 9b66c7cd71f09d950076dc44aced9ddd7a0d60ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 6 Aug 2026 01:47:27 +0300 Subject: [PATCH] =?UTF-8?q?test=20=D0=BE=D0=B1=D0=B7=D0=B2=D0=BE=D0=BD:=20?= =?UTF-8?q?=D1=88=D0=B5=D1=81=D1=82=D1=8C=20=D1=81=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D0=B9=20=D0=BF=D0=BE=20=D0=B4=D1=8B=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=BA=D0=B8=20=E2=80=94?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0,=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80,=20=D0=BE=D0=B1=D1=8A?= =?UTF-8?q?=D1=91=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Приёмка нашла три дыры, все в сторожах, а не в коде. Код не тронут ни одной строкой: сверено пустым git diff по всем моим файлам. Дыра 1 — срок можно было зашить в код, и никто бы не заметил. Прежний сторож проверял, что строки настроек ЗАВЕДЕНЫ, а не что их ЧИТАЮТ. Заведены два сторожа поведения: настройка, отличная от умолчания, обязана менять судьбу записи. У материалов клиента этой дыры нет — чистка там настроек не читает, а читатель настроек накрыт чужим сторожем круга З-2.3, проверено тем же ножом. Дыра 2 — повторный запуск. Два сторожа: второй заход не падает, строку не переписывает и не дописывает в журнал ПДн лишнюю запись об уже стёртом. Половина сторожа, сравнивавшая столбцы, обманывалась одной секундой — добавлен ctid, прибор «трогали ли строку вообще». Дыра 3 — объём. Мерка построена другая, чем просила приёмка, и в отчёте объяснено почему: запросы обязаны расти с числом строк, иначе придётся отказаться от правила «не стёрся файл — строку не трогаем». Ловим лишний запрос в переборе: цена строки прибита к четырём и обязана совпадать на двух объёмах. Плюс сторож на потолок за заход — его не было вовсе, а именно он защищает ночь от полумиллиона просроченных строк. Полный прогон: 4992 на входе, 4998 на выходе, красных ноль. --- .../ObzvonChistkaMaterialovPoSrokuTest.php | 63 +++++ .../Obzvon/ObzvonChistkaNaObyomeTest.php | 234 ++++++++++++++++++ .../ObzvonChistkaZvonkovPoSrokuTest.php | 145 +++++++++++ .../stroyka-2/z-2-2-otchyot-2026-08-05.md | 132 +++++++++- 4 files changed, 569 insertions(+), 5 deletions(-) create mode 100644 app/tests/Feature/Obzvon/ObzvonChistkaNaObyomeTest.php diff --git a/app/tests/Feature/Obzvon/ObzvonChistkaMaterialovPoSrokuTest.php b/app/tests/Feature/Obzvon/ObzvonChistkaMaterialovPoSrokuTest.php index c534b838..95572e5c 100644 --- a/app/tests/Feature/Obzvon/ObzvonChistkaMaterialovPoSrokuTest.php +++ b/app/tests/Feature/Obzvon/ObzvonChistkaMaterialovPoSrokuTest.php @@ -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); diff --git a/app/tests/Feature/Obzvon/ObzvonChistkaNaObyomeTest.php b/app/tests/Feature/Obzvon/ObzvonChistkaNaObyomeTest.php new file mode 100644 index 00000000..f07853b9 --- /dev/null +++ b/app/tests/Feature/Obzvon/ObzvonChistkaNaObyomeTest.php @@ -0,0 +1,234 @@ + [ + '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); +}); diff --git a/app/tests/Feature/Obzvon/ObzvonChistkaZvonkovPoSrokuTest.php b/app/tests/Feature/Obzvon/ObzvonChistkaZvonkovPoSrokuTest.php index e2fba339..9d30802c 100644 --- a/app/tests/Feature/Obzvon/ObzvonChistkaZvonkovPoSrokuTest.php +++ b/app/tests/Feature/Obzvon/ObzvonChistkaZvonkovPoSrokuTest.php @@ -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', diff --git a/docs/superpowers/priyomka/stroyka-2/z-2-2-otchyot-2026-08-05.md b/docs/superpowers/priyomka/stroyka-2/z-2-2-otchyot-2026-08-05.md index 183e7fc5..f880ac59 100644 --- a/docs/superpowers/priyomka/stroyka-2/z-2-2-otchyot-2026-08-05.md +++ b/docs/superpowers/priyomka/stroyka-2/z-2-2-otchyot-2026-08-05.md @@ -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` нет.** Значит стереть файл записи