fix(budgets): retry assignment deadlocks (#304)

## Summary
- lock the transaction row before reconciling `budget_transactions` so
concurrent assignment work serializes per transaction
- retry the assignment transaction on deadlocks using Laravel's built-in
transaction attempts
- add regression coverage that asserts the deadlock retry path remains
configured for `PHP-LARAVEL-D`

## Testing
- php artisan test --compact
tests/Feature/BudgetTransactionServiceTest.php
tests/Feature/Listeners/AssignTransactionToBudgetTest.php
This commit is contained in:
Víctor Falcón 2026-04-19 11:21:27 +01:00 committed by GitHub
parent b1ceda61f9
commit 45e311e17b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 41 additions and 1 deletions

View File

@ -56,6 +56,11 @@ class BudgetTransactionService
// Apply changes atomically so concurrent workers cannot leave the
// transaction half-assigned and the unique index guards duplicates.
DB::transaction(function () use ($transaction, $matchingPeriodIds) {
Transaction::query()
->whereKey($transaction->id)
->lockForUpdate()
->first();
BudgetTransaction::query()
->where('transaction_id', $transaction->id)
->when(
@ -75,7 +80,7 @@ class BudgetTransactionService
],
);
}
});
}, attempts: 5);
}
public function unassignTransaction(Transaction $transaction): void

View File

@ -8,6 +8,7 @@ use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use App\Services\BudgetTransactionService;
use Illuminate\Support\Facades\DB;
beforeEach(function () {
$this->service = app(BudgetTransactionService::class);
@ -430,6 +431,40 @@ test('assignTransaction is idempotent when called twice (regression for PHP-LARA
->and((int) $period->budgetTransactions()->first()->amount)->toBe(1500);
});
test('assignTransaction retries deadlocks during reconciliation (regression for PHP-LARAVEL-D)', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$budget = Budget::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
'start_date' => now()->subDays(30),
'end_date' => now()->addDays(30),
]);
$transaction = Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
'transaction_date' => now()->subDays(2),
'amount' => -1500,
]);
$databaseManager = DB::getFacadeRoot();
DB::shouldReceive('transaction')
->once()
->withArgs(fn ($callback, $attempts) => $callback instanceof Closure && $attempts === 5)
->andReturnUsing(fn ($callback, $attempts) => $databaseManager->transaction($callback, $attempts));
$this->service->assignTransaction($transaction);
expect($period->budgetTransactions()->count())->toBe(1)
->and((int) $period->budgetTransactions()->first()->amount)->toBe(1500);
});
test('assignTransaction removes stale rows when category changes', function () {
$oldCategory = Category::factory()->create(['user_id' => $this->user->id]);
$newCategory = Category::factory()->create(['user_id' => $this->user->id]);