fix(балансы): свежий query-builder на итерацию джобы (PK violation на 2-м прогоне)

Переиспользование одного DB-билдера в цикле накапливало where-клаузы →
updateOrInsert уходил в INSERT существующей строки → SQLSTATE 23505 на проде
при повторном сборе. Билдер теперь создаётся внутри цикла. + тест на 2 прогона.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-28 07:32:17 +03:00
parent c03e2b319b
commit fa404e98ec
3 changed files with 22 additions and 9 deletions
+5 -2
View File
@@ -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(
@@ -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)));
+5 -7
View File
@@ -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-сессий.