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:
parent
b1ceda61f9
commit
45e311e17b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue