Skip to content

SQL Server: failed savepoint rollback hides the original error and leaves a stale transaction level, so later transactions never commit #61829

Open

Description

@esetnik

Laravel Version

13.34.0 (also current 13.x; ManagesTransactions is unchanged)

PHP Version

8.4.26 (reproduction); also seen on 8.5.10 in production

Database Driver & Version

SQL Server 2022 (mcr.microsoft.com/mssql/server:2022-latest), pdo_sqlsrv 5.13.3, ODBC Driver 18 for SQL Server

Description

Some SQL Server errors roll back the entire transaction on the server, not just the failing statement. Examples: MERGE error 8672 (which upsert() compiles to when a source key repeats and already exists), a trigger that runs ROLLBACK TRANSACTION, or any error under SET XACT_ABORT ON. When one of these happens inside a nested DB::transaction(), the savepoint is gone, and three things go wrong:

  1. The real error is lost. rollBack() runs ROLLBACK TRANSACTION trans2, which fails with SQLSTATE[25000] Cannot roll back trans2. No transaction or savepoint of that name was found. handleRollBackException() rethrows that, and the original exception is dropped (it is not set as previous). Each enclosing transaction() then retries the same savepoint and fails the same way, so the caller only ever sees the rollback error.
  2. The transaction level is never reset. $this->transactions = $toLevel only runs when performRollBack() succeeds, so after the exception escapes, transactionLevel() is still 2. Meanwhile, the ODBC driver (manual-commit mode) has opened a new server transaction: @@TRANCOUNT is 1.
  3. Later writes on the same connection are silently never committed. The next top-level DB::transaction() sees level 2, so it only creates and releases a savepoint and never issues COMMIT. It returns normally, but its writes are rolled back when the connection closes. In a queue worker or Octane, every later transaction on that connection is lost without an error.

The deadlock path already accounts for "the server rolled back everything" (handleTransactionException() skips the savepoint rollback for concurrency errors and decrements the level), but the other SQL Server errors that do the same are not handled.

For comparison, a statement-level error in the same position (a duplicate-key insert) behaves correctly: the original UniqueConstraintViolationException surfaces, the level returns to 0, and later transactions commit.

This is probably the same root cause as #49514, which was closed for lack of a reproduction. Below is a self-contained Docker one.

Possible direction: when a savepoint rollback fails, check whether the server transaction still exists (SELECT @@TRANCOUNT, or the driver's equivalent). If it is gone, reset the level to 0, roll back the stray driver transaction, and rethrow the original exception with the rollback failure attached, instead of the rollback failure alone.

Steps To Reproduce

Save these four files in an empty directory and run docker compose run --rm app (SQL Server runs under amd64 emulation on Apple Silicon).

compose.yml
services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    platform: linux/amd64
    environment:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: "Repro_Passw0rd!"
    healthcheck:
      test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'Repro_Passw0rd!' -Q 'SELECT 1' || exit 1"]
      interval: 5s
      retries: 30
  app:
    build: .
    depends_on:
      sqlserver:
        condition: service_healthy
Dockerfile
FROM php:8.4-cli-bookworm

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl gnupg unzip unixodbc-dev \
 && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
 && curl -fsSL https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list \
 && apt-get update \
 && ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 \
 && pecl install sqlsrv pdo_sqlsrv \
 && docker-php-ext-enable sqlsrv pdo_sqlsrv

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json .
RUN composer install --no-interaction --no-progress
COPY repro.php .
CMD ["php", "repro.php"]
composer.json
{
    "require": {
        "illuminate/database": "13.34.0"
    }
}
repro.php
<?php

require __DIR__.'/vendor/autoload.php';

use Illuminate\Database\Capsule\Manager as DB;

$config = [
    'driver' => 'sqlsrv',
    'host' => 'sqlserver',
    'port' => 1433,
    'database' => 'master',
    'username' => 'sa',
    'password' => 'Repro_Passw0rd!',
    'trust_server_certificate' => true,
];

$capsule = new DB;
$capsule->addConnection($config);
$capsule->setAsGlobal();

DB::unprepared("IF DB_ID('repro') IS NULL CREATE DATABASE repro");
$capsule->addConnection(['database' => 'repro'] + $config);
$capsule->getDatabaseManager()->purge('default');

DB::unprepared(<<<'SQL'
    DROP TABLE IF EXISTS tags;
    DROP TABLE IF EXISTS guarded;
    CREATE TABLE tags (id int IDENTITY PRIMARY KEY, name nvarchar(100) NOT NULL);
    CREATE UNIQUE INDEX tags_name ON tags (name);
    INSERT INTO tags (name) VALUES (N'existing');
    CREATE TABLE guarded (id int IDENTITY PRIMARY KEY, value int NOT NULL);
SQL);
DB::unprepared(<<<'SQL'
    CREATE OR ALTER TRIGGER guarded_reject_negative ON guarded AFTER INSERT AS
    BEGIN
        IF EXISTS (SELECT 1 FROM inserted WHERE value < 0)
        BEGIN
            RAISERROR('value must not be negative', 16, 1);
            ROLLBACK TRANSACTION;
        END
    END
SQL);

/**
 * Run $inner inside a nested transaction, report what the caller sees, then show whether the
 * connection can still open a transaction.
 */
function scenario(string $title, Closure $inner): void
{
    echo "\n=== {$title}\n";

    try {
        DB::transaction(function () use ($inner) {
            DB::transaction($inner);
        });
        echo "no exception\n";
    } catch (Throwable $e) {
        echo 'caught:            '.get_class($e).': '.$e->getMessage()."\n";
        echo 'previous:          '.($e->getPrevious() ? get_class($e->getPrevious()).': '.$e->getPrevious()->getMessage() : 'none')."\n";
    }

    echo 'transactionLevel(): '.DB::connection()->transactionLevel()."\n";
    echo 'server @@TRANCOUNT: '.DB::selectOne('SELECT @@TRANCOUNT AS c')->c."\n";

    // A later, unrelated unit of work on the same connection (e.g. the next job in a queue worker).
    $marker = 'written after: '.$title;
    try {
        DB::transaction(fn () => DB::table('tags')->insert(['name' => substr($marker, 0, 100)]));
        echo "next transaction(): returned normally\n";
    } catch (Throwable $e) {
        echo 'next transaction(): '.get_class($e).': '.$e->getMessage()."\n";
    }

    // Start the next scenario from a fresh connection, then check the later write was committed.
    $GLOBALS['capsule']->getDatabaseManager()->purge('default');
    $persisted = DB::table('tags')->where('name', substr($marker, 0, 100))->exists();
    echo 'its write persisted: '.($persisted ? 'yes' : 'NO (never committed)')."\n";
}

scenario('Control: statement-level error (duplicate key) inside a nested transaction', function () {
    DB::table('tags')->insert(['name' => 'existing']);
});

scenario('MERGE error 8672: upsert source repeats a key that already exists', function () {
    DB::table('tags')->upsert([['name' => 'existing'], ['name' => 'existing']], 'name');
});

scenario('Trigger issues ROLLBACK TRANSACTION', function () {
    DB::table('guarded')->insert(['value' => -1]);
});

Output:

=== Control: statement-level error (duplicate key) inside a nested transaction
caught:            Illuminate\Database\UniqueConstraintViolationException: SQLSTATE[23000]: [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Cannot insert duplicate key row in object 'dbo.tags' with unique index 'tags_name'. The duplicate key value is (existing). (Connection: default, Host: sqlserver, Port: 1433, Database: repro, SQL: insert into [tags] ([name]) values (existing))
previous:          PDOException: SQLSTATE[23000]: [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Cannot insert duplicate key row in object 'dbo.tags' with unique index 'tags_name'. The duplicate key value is (existing).
transactionLevel(): 0
server @@TRANCOUNT: 0
next transaction(): returned normally
its write persisted: yes

=== MERGE error 8672: upsert source repeats a key that already exists
caught:            PDOException: SQLSTATE[25000]: [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Cannot roll back trans2. No transaction or savepoint of that name was found.
previous:          none
transactionLevel(): 2
server @@TRANCOUNT: 1
next transaction(): returned normally
its write persisted: NO (never committed)

=== Trigger issues ROLLBACK TRANSACTION
caught:            PDOException: SQLSTATE[25000]: [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Cannot roll back trans2. No transaction or savepoint of that name was found.
previous:          none
transactionLevel(): 2
server @@TRANCOUNT: 1
next transaction(): returned normally
its write persisted: NO (never committed)

Expected: the MERGE and trigger scenarios surface the original QueryException (error 8672, and the trigger's RAISERROR message), transactionLevel() is 0 afterwards, and the later write is committed, the same as the control scenario.

Activity

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