Skip to content

Database cache lock bug: SAVEPOINT trans2 does not exist #61707

Closed

Description

@mantas-done

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(); // true

Session 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 exist

Expected

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions