Skip to content

[13.x] Savepoint Support for Database Transactions #55996

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

yitzwillroth
Copy link
Contributor

@yitzwillroth yitzwillroth commented Jun 11, 2025

💡 Description

This pull request introduces an API for utilizing database transaction savepoints (while fully respecting the framework’s usage of savepoints under the hood for simulating nested transactions) giving developers greater control over database commit logic and enabling the use of more complex failure handling patterns.

+ Features:

  • Comprehensive support for database savepoints within transactions, allowing developers to create, rollback to, release, and purge savepoints within transactions.
  • New events for savepoint creation, rollback, and release, enabling advanced transaction control and event handling.
  • Expanded connection interface and facade with methods to manage and query savepoints, enhancing transaction flexibility across supported database drivers (MySQL, MariaDB, PostgreSQL, SQLite, SQL Server with limitations).
  • New static methods for savepoint operations are accessible via the database facade.
  • Improved compatibility and error handling for savepoint operations, including clear feedback when unsupported on certain database systems.

+ Benefits:

  • Granular Error Handling: Ability to recover from partial failures within transactions
  • Performance: Avoid restarting entire transactions for minor failures
  • Complex Business Logic: Support for multi-step processes with checkpoints
  • Laravel-esque API: Follows Laravel's conventions and provides both manual and callback-based APIs

⚖️ Justification

⁇ Problem

Laravel's transaction system lacks intermediate rollback points, forcing developers to choose between rolling back entire transactions or implementing complex workarounds for partial rollbacks in multi-step operations.

Real-World Limitations:

  • All-or-Nothing Transactions: No way to rollback to specific points within a transaction
  • Complex Error Recovery: Failed operations require rolling back all previous work
  • Nested Operation Challenges: Multi-step processes can't preserve partial progress
  • Testing Difficulties: Hard to test intermediate states in complex workflows

Industry Standards Gap:

  • Database Support: MySQL, PostgreSQL, SQLite, SQL Server all support savepoints
  • Framework Gap: Laravel lacks native savepoint implementation
  • Developer Workarounds: Manual SQL execution or external transaction managers
  • Error Handling: Generic exceptions without context or debugging information

‼ Solution

Native savepoint management with automatic lifecycle handling, context-aware error messages, and seamless Laravel integration.

// basic savepoint creation & rollback
DB::transaction(function () {
    User::create(['name' => 'John']);
    
    DB::savepoint('user_created');
    
    try {
        $user->update(['email' => 'invalid-email']);
    } catch (Exception $e) {
        DB::rollbackToSavepoint('user_created'); 
    }
});

// callback-based execution with automatic cleanup
DB::savepoint('complex_operation', function () {
    $result = performComplexCalculation();
    
    if ($result->isValid()) {
        return $result;  // savepoint auto-release on success
    }
    
    throw new Exception('Invalid result');  // auto-rollback on failure
});

// nested savepoints for complex workflows
DB::transaction(function () {
    Order::create($orderData);
    
    DB::savepoint('order_created', function () {
        foreach ($items as $item) {
            DB::savepoint('item_' . $item->id, function () use ($item) {
                $item->process();
                $item->updateInventory();
                return $item;
            });
        }
    });
});

⚙️ Usage Examples

Real-World Scenarios

// e-commerce order processing with partial rollback
DB::transaction(function () use ($order, $items) {
    $order = Order::create($orderData);
    
    DB::savepoint('order_created');
    
    foreach ($items as $item) {
        try {
            $item->reserveInventory();
            $item->calculateShipping();
            $item->processPayment();
        } catch (InsufficientInventoryException $e) {
            DB::rollbackToSavepoint('order_created');
            $order->markAsBackordered();
            break;
        }
    }
});

// data migration with checkpoint recovery
DB::transaction(function () {
    $migrated = 0;
    
    foreach ($users->chunk(1000) as $chunk) {
        DB::savepoint("chunk_{$migrated}");
        
        try {
            $chunk->each->migrate();
            $migrated += $chunk->count();
        } catch (MigrationException $e) {
            DB::rollbackToSavepoint("chunk_{$migrated}");
            Log::warning("Migration failed at user {$migrated}");
            break;
        }
    }
});

// multi-tenant operation isolation
DB::transaction(function () use ($tenants) {
    foreach ($tenants as $tenant) {
        DB::savepoint("tenant_{$tenant->id}", function () use ($tenant) {
            $tenant->performMaintenanceOperations();
            $tenant->updateStatistics();
            $tenant->cleanupTempData();
            
            return $tenant->getMaintenanceReport();
        });
    }
});

// financial transaction with audit trail
DB::transaction(function () use ($transfer) {
    $sourceAccount->withdraw($transfer->amount);
    
    DB::savepoint('withdrawal_complete', function () use ($transfer) {
        $destinationAccount->deposit($transfer->amount);
        
        if (!$transfer->validateBalances()) {
            throw new BalanceMismatchException();
        }
        
        AuditLog::recordTransfer($transfer);
        return $transfer;
    });
});

Advanced Error Handling

// context-aware debugging information
try {
    DB::savepoint('complex_operation');
    performRiskyOperation();
} catch (InvalidArgumentException $e) {
    // Error: "Savepoint 'complex_operation' already exists at position 2 
    // in transaction level 1. Use a different name or call
    // rollbackToSavepoint('complex_operation') first. Current savepoints:
    // ['initial_setup', 'data_validation', 'complex_operation']."
}

// transaction level awareness
try {
    DB::savepoint('outside_transaction');
} catch (LogicException $e) {
    // Error: "Cannot create savepoint outside of transaction. Current
    // transaction level: 0. Call beginTransaction() first or use the
    // transaction() helper method."
}

// unknown savepoint debugging
try {
    DB::rollbackToSavepoint('nonexistent');
} catch (InvalidArgumentException $e) {
    // Error: "Savepoint 'nonexistent' does not exist in transaction
    // level 1. Available savepoints: ['setup', 'validation',
    // 'processing']."
}

Database Compatibility

// mysql/postgresql/sqlserver - full support
if (DB::supportsSavepoints() && DB::supportsSavepointRelease()) {
    DB::savepoint('checkpoint', function () {
        return performOperation(); // auto-release on success
    });
}

// sqlite - savepoints without release
if (DB::supportsSavepoints()) {
    DB::savepoint('checkpoint');
    performOperation();
    // sqlite doesn't support RELEASE SAVEPOINT
}

📔 API Refence

public function supportsSavepoints(): bool;
public function supportsSavepointRelease(): bool;
public function savepoint(string $name, ?callable $callback = null): mixed;
public function rollbackToSavepoint(string $name): void;
public function releaseSavepoint(string $name, ?int $level = null): void;
public function purgeSavepoints(?int $level = null): void;
public function hasSavepoint(string $name): bool;
public function getSavepoints(): array;
public function getCurrentSavepoint(): ?string;

🏗️ Technical Implementation

Core Architecture

Automatic Transaction Integration:

protected function initializeSavepointManagement(): void
{
    $this->events?->listen(function (TransactionBeginning $event) {
        $this->syncTransactionBeginning();  // initialize savepoint arrays
    });
    
    $this->events?->listen(function (TransactionCommitted $event) {
        $this->syncTransactionCommitted();  // cleanup savepoint arrays  
    });
}

Savepoint Name Sanitization:

protected function encodeSavepointName(string $name): string
{
    return bin2hex($name); // no conflict with framework savepoint use
}

// sql: SAVEPOINT "73617665706f696e74"  (hex-encoded)
// internal: 'savepoint' (original name preserved)

Context-Aware Error Messages:

protected function unknownSavepointError(string $name): void
{
    throw new InvalidArgumentException(
        "Savepoint '{$name}' does not exist in transaction level {$this->transactionLevel()}." .
        (empty($this->savepoints[$this->transactionLevel()] ?? [])
            ? " No savepoints exist at this transaction level."
            : " Available savepoints: ['" . implode("', '", $this->savepoints[$this->transactionLevel()]) . "'].")
    );
}

Savepoint Lifecycle Management

Callback-Based Execution:

public function savepoint(string $name, ?callable $callback = null): mixed
{
    $this->createSavepoint($name);
    
    if ($callback) {
        try {
            return $callback();  // execute user code
        } catch (Throwable $e) {
            if ($this->hasSavepoint($name)) {
                $this->rollbackToSavepoint($name);  // rollback on failure
            }
            throw $e;
        } finally {
            if ($this->supportsSavepointRelease() && $this->hasSavepoint($name)) {
                $this->releaseSavepoint($name);  // cleanup on success
            }
        }
    }
}

Transaction Level Isolation:

// savepoints stored per transaction level
protected array $savepoints = [
    1 => ['initial', 'validation'],     // level 1 savepoints
    2 => ['nested_operation'],          // level 2 savepoints  
    3 => ['deep_processing']            // level 3 savepoints
];

Database Grammar Support

Database Savepoints Release Status
MySQL SAVEPOINT RELEASE Full Support
PostgreSQL SAVEPOINT RELEASE Full Support
SQL Server SAVE TRANSACTION ❌ No Release Partial Support
SQLite SAVEPOINT ❌ No Release Partial Support

🪵 Changelog

image 2

⿲ Sequence Diagram

image

♻️ Backward Compatibility

This feature targets 13.x as ConnectionInterface now has additional methods.

✅ Testing

44 tests (199 assertions) cover complete functionality:

Core Functionality

✓ Savepoint Creation - Basic savepoint creation and naming
✓ Rollback Operations - Partial rollback to specific savepoints
✓ Release Management - Proper savepoint cleanup and release
✓ Callback Execution - Automatic success/failure handling

Error Handling & Security

✓ Context-Aware Errors - Detailed debugging with transaction context
✓ SQL injection protection - Hex encoding validation for all names
✓ Database capability detection - Proper support flag handling
✓ Transaction lifecycle - Outside-transaction error conditions

Advanced Scenarios

✓ Nested Transactions - Multi-level savepoint isolation
✓ Event Integration - Created/Released/RolledBack event firing
✓ Stack Management - Complex savepoint hierarchy handling
✓ Database Grammar Compatibility - MySQL/PostgreSQL/SQLite/SQLServer

Production Edge Cases

✓ PDO Failure Handling - Database operation error recovery
✓ Memory Management - Savepoint cleanup on transaction end
✓ Concurrent Operations - Thread-safe savepoint management
✓ Large Transaction Trees - Deep nesting and cleanup validation

Framework Integration

✓ Events - Proper event dispatcher integration
✓ Transaction Helpers - Works with existing transaction() method
✓ Connection Lifecycle - Automatic initialization and cleanup
✓ Multi-Connection Support - Independent savepoint management per connection


💁🏼‍♂️ The author is available for hire -- inquire at [email protected].

Copy link

Thanks for submitting a PR!

Note that draft PR's are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface.

Pull requests that are abandoned in draft may be closed due to inactivity.

@yitzwillroth yitzwillroth marked this pull request as ready for review June 11, 2025 20:22
@yitzwillroth yitzwillroth changed the title :feat: Savepoint Support for Database Transactions [Master] Savepoint Support for Database Transactions Jun 11, 2025
@yitzwillroth yitzwillroth changed the title [Master] Savepoint Support for Database Transactions [master] Savepoint Support for Database Transactions Jun 11, 2025
@crynobone crynobone changed the title [master] Savepoint Support for Database Transactions [13.x] Savepoint Support for Database Transactions Jul 7, 2025
@yitzwillroth yitzwillroth marked this pull request as draft July 11, 2025 15:01
@yitzwillroth yitzwillroth force-pushed the add-database-transaction-savepoints branch 5 times, most recently from d82ca40 to 06426c8 Compare July 11, 2025 17:10
@yitzwillroth yitzwillroth force-pushed the add-database-transaction-savepoints branch from 06426c8 to c676d98 Compare July 11, 2025 17:19
@yitzwillroth yitzwillroth marked this pull request as ready for review July 11, 2025 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants