From fa404e98ecfa5d103d60da8348ef5a67484a3e26 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: Sun, 28 Jun 2026 07:32:17 +0300 Subject: [PATCH] =?UTF-8?q?fix(=D0=B1=D0=B0=D0=BB=D0=B0=D0=BD=D1=81=D1=8B)?= =?UTF-8?q?:=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B8=D0=B9=20query-builder=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B8=D1=82=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8E?= =?UTF-8?q?=20=D0=B4=D0=B6=D0=BE=D0=B1=D1=8B=20(PK=20violation=20=D0=BD?= =?UTF-8?q?=D0=B0=202-=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Переиспользование одного DB-билдера в цикле накапливало where-клаузы → updateOrInsert уходил в INSERT существующей строки → SQLSTATE 23505 на проде при повторном сборе. Билдер теперь создаётся внутри цикла. + тест на 2 прогона. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Jobs/External/RefreshExternalBalancesJob.php | 7 +++++-- .../External/RefreshExternalBalancesJobTest.php | 12 ++++++++++++ docs/observer/STATUS.md | 12 +++++------- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/app/Jobs/External/RefreshExternalBalancesJob.php b/app/app/Jobs/External/RefreshExternalBalancesJob.php index 1f72a421..92da3a04 100644 --- a/app/app/Jobs/External/RefreshExternalBalancesJob.php +++ b/app/app/Jobs/External/RefreshExternalBalancesJob.php @@ -42,14 +42,17 @@ class RefreshExternalBalancesJob implements ShouldQueue public function handle(): void { - $table = DB::connection(self::DB_CONNECTION)->table('external_service_balances'); - foreach ($this->providers() as $cls) { /** @var BalanceProvider $p */ $p = app($cls); $key = $p->serviceKey(); $reading = $p->fetch(); // не бросает + // Свежий query-builder на КАЖДУЮ итерацию: переиспользование одного билдера + // накапливает where-клаузы (service_key=A AND service_key=B…) → updateOrInsert + // ошибочно идёт в INSERT существующей строки → нарушение PK. + $table = DB::connection(self::DB_CONNECTION)->table('external_service_balances'); + if (! $reading->ok) { // Оставляем прошлый баланс, помечаем ok=false + ошибку. $table->updateOrInsert( diff --git a/app/tests/Feature/External/RefreshExternalBalancesJobTest.php b/app/tests/Feature/External/RefreshExternalBalancesJobTest.php index b0b0753b..4b46cc65 100644 --- a/app/tests/Feature/External/RefreshExternalBalancesJobTest.php +++ b/app/tests/Feature/External/RefreshExternalBalancesJobTest.php @@ -51,6 +51,18 @@ it('пишет балансы трёх сервисов + считает све expect($rows['dadata']->ok)->toBeTruthy(); }); +it('повторный запуск обновляет строки, а не падает на PK (свежий builder/итерация)', function () { + app()->instance(DadataBalanceProvider::class, fakeProvider('dadata', BalanceReading::ok('dadata', 4500, 'RUB', 100))); + app()->instance(SupplierBalanceProvider::class, fakeProvider('supplier', BalanceReading::fail('supplier', 'таймаут'))); + app()->instance(YandexCloudBalanceProvider::class, fakeProvider('yandex_cloud', BalanceReading::ok('yandex_cloud', 42000, 'RUB', 600))); + + (new RefreshExternalBalancesJob)->handle(); + (new RefreshExternalBalancesJob)->handle(); // второй прогон не должен бросить UniqueConstraint + + $rows = DB::connection('pgsql_supplier')->table('external_service_balances')->get(); + expect($rows)->toHaveCount(3); // строк по-прежнему 3, без дублей +}); + it('упавший провайдер не роняет джобу и сохраняет ошибку, остальные пишутся', function () { app()->instance(DadataBalanceProvider::class, fakeProvider('dadata', BalanceReading::fail('dadata', 'HTTP 403'))); app()->instance(SupplierBalanceProvider::class, fakeProvider('supplier', BalanceReading::ok('supplier', 50000, 'RUB', null))); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index b9e5f80e..2315e2db 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-28T04:13:25.256Z +Last updated: 2026-06-28T04:26:08.585Z | Контролёр | Состояние | Детали | |---|---|---| @@ -33,7 +33,7 @@ Last updated: 2026-06-28T04:13:25.256Z | enforce-coverage-verify.mjs | `enforce-coverage-verify.mjs` | 🔴 | | enforce-todowrite-skill-verifier.mjs | `enforce-todowrite-skill-verifier.mjs` | 🔴 | -Недавние escape владельца: 0 · Недавние блоки: 7 +Недавние escape владельца: 0 · Недавние блоки: 5 **Недавние блоки (детали):** @@ -44,8 +44,6 @@ Last updated: 2026-06-28T04:13:25.256Z | 2026-06-27T09:25:54.127Z | bash:node -e "for (const d of ['протокол-наставника','проблема-закрытия-вопросов-протокола','содержит']) { try { const p | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "for (const d | | 2026-06-27T07:03:56.852Z | bash:node -e "1" 2>/dev/null; for f in docs/secretary/*/protocol.md; do printf '%6s %s\n' "$(wc -l < "$f")" "$f"; done | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "1" 2>/dev/nul | | 2026-06-27T05:45:19.915Z | bash:rm ~/.claude/runtime/secretary-mode-*.json | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:rm ~/.claude/runtime/s | -| 2026-06-27T04:18:53.490Z | powershell:Stop-Process -Id 4072 -Force | floor: опасная PowerShell-команда без аварийного выхода — блок (правило 8, V1-PS); FLOOR-ESCAPE: powershell:Stop-Process | -| 2026-06-27T04:18:44.603Z | powershell:Stop-Process -Id 4072 -Force; if ($?) { "killed 4072" }; Start-Sleep -Milliseconds 300; Get-CimInstance Win32 | floor: опасная PowerShell-команда без аварийного выхода — блок (правило 8, V1-PS); FLOOR-ESCAPE: powershell:Stop-Process | ## Метрики (информационные, не алерты) @@ -129,9 +127,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3440 | MsMpEng | 17.01ч | 220544.2ч | -| 21928 | Code | 7.32ч | 0.0ч | -| 1212 | svchost | 4.38ч | 10093734.7ч | +| 3440 | MsMpEng | 17.07ч | 0.0ч | +| 21928 | Code | 7.36ч | 451351.4ч | +| 1212 | svchost | 4.38ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.