Repository navigation
Description
Laravel Version
13.33.0
PHP Version
8.5
Database Driver & Version
MySQL 8 (InnoDB)
Description
When inside of transaction you use cache lock, it doesn't handle correctly deadlock and swallows exception. Instead of stopping PHP code it allows it to execute further.
I am too stupid to explain in the the full detail. I will paste the AI explanation.
Basically this problem happens for us in production when multiple users hit the same code.
Steps To Reproduce
Text by AI:
DatabaseLock::acquire() catches any QueryException from its lock-table INSERT and tries an UPDATE. This includes MySQL deadlock error 1213, not just a duplicate-key error.
If the lock uses the same connection as an open application transaction, MySQL can roll back that entire transaction on a deadlock. Because the exception is caught, application code can continue while Laravel still reports an open transaction. Earlier writes are gone, later writes may autocommit, and a subsequent nested rollback can fail with SAVEPOINT trans2 does not exist.
Use MySQL/InnoDB with DB_CONNECTION=mysql, CACHE_STORE=database, migrated cache and cache_locks tables, and lock_connection unset. Open two fresh Tinker sessions. Use the same unused $id in both.
Session A:
$id = 'deadlock-report-001';
DB::beginTransaction();
foreach (range(1, 20) as $i) {
DB::table('cache')->insert([
'key' => "filler-$id-$i",
'value' => '1',
'expiration' => time() + 600,
]);
}
Cache::store('database')->lock("foo-$id", 600)->get(); // trueSession B:
$id = 'deadlock-report-001';
DB::beginTransaction();
DB::table('cache')->insert([
'key' => "marker-$id",
'value' => '1',
'expiration' => time() + 600,
]);Back in A: Run this; it should block waiting for B's uncommitted marker row.
DB::table('cache')->where('key', "marker-$id")->delete();While A is blocked, in B:
Cache::store('database')->lock("foo-$id", 600)->get();B now waits for A's lock, completing the deadlock cycle. The 20 filler rows make A the more expensive transaction to roll back, encouraging MySQL to choose B as the victim. Once A's delete unblocks, run DB::rollBack() in A so B's lock-acquisition fallback can finish.
Inspect B:
DB::transactionLevel(); // 1
DB::getPdo()->inTransaction(); // false
DB::table('cache')->where('key', "marker-$id")->exists(); // false
DB::transaction(fn () => throw new RuntimeException('probe'));
// May report: SAVEPOINT trans2 does not existExpected
The deadlock exception propagates; code does not continue under an invalid outer transaction.
Actual
The lock INSERT's deadlock exception is caught. Laravel can report transaction level 1 after MySQL has rolled the transaction back.
This manual interleaving depends on MySQL choosing B as the deadlock victim. The important evidence is the 1/false transaction-state mismatch; the savepoint error is a downstream symptom.
Activity