Repository navigation
SQL Server: failed savepoint rollback hides the original error and leaves a stale transaction level, so later transactions never commit #61829
Description
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:
- The real error is lost.
rollBack()runsROLLBACK TRANSACTION trans2, which fails withSQLSTATE[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 asprevious). Each enclosingtransaction()then retries the same savepoint and fails the same way, so the caller only ever sees the rollback error. - The transaction level is never reset.
$this->transactions = $toLevelonly runs whenperformRollBack()succeeds, so after the exception escapes,transactionLevel()is still2. Meanwhile, the ODBC driver (manual-commit mode) has opened a new server transaction:@@TRANCOUNTis1. - 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 issuesCOMMIT. 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_healthyDockerfile
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