How do Nested Transactions work in Laravel?
Asked Answered
P

2

6

I've a master function A that is called before function B and after function C. Both save one model and have their own begin transaction and master transaction in function A, how is data stored?

I tried to throw an Exception on function C, but function B stores variable $modelB anywhere

        public function B(){
            DB::beginTransaction();
            try{
            $modelB->save();
                DB::commit();
            }catch(\Exception $e){
                DB::rollback();
            }
        }

        public function C(){
            DB::beginTransaction();
            try{
            $modelC->save();
                DB::commit();
            }catch(\Exception $e){
                DB::rollback();
            }
        }

        public function A(){
            DB::beginTransaction();
            try{
                $this->B();
                $this->C();
                DB::commit();
            } catch(\Exception $e){
                DB::rollback();
            }
        }
Pacifica answered 7/5, 2019 at 12:12 Comment(2)
You'll have to re-throw the exceptions so that they're caught in A.Halden
it should be: DB::beginTransaction() function A; DB::beginTransaction(); function B; $modelB->save(); DB::commit() function B; DB::beginTransaction() function C; Throw Exception function C; DB::rollback() function C; DB::rollback() function A; Correct? variable $modelB how does it behave? is it saved?Pacifica
A
1

In Laravel 6 you can use:

DB::connection(DB::getDefaultConnection())->transactionLevel()

to get the curret active transaction number. Personally I prefer to use single transaction like:

if(DB::connection(DB::getDefaultConnection())->transactionLevel()==0){
    DB::beginTransaction();
}
    try{
        //DO SOME STUFF

        if(DB::connection(DB::getDefaultConnection())->transactionLevel()==0)
        {
            DB::commit();
        }  // else, leave commit to parent transaction
    }catch(\Throwable $e)
    {
        DB::rollback();
        throw $e;
    }
Animadvert answered 5/3, 2020 at 17:31 Comment(3)
The condition on the DB::commit() is not executed, because is this point the level transaction is 1.Doublejointed
Also the rollback should be left for parent transaction if that is the case.Ticon
That could be handled with a flag when beginning transaction in this place and condition the commit and rollback with that flag.Ticon
D
1

Based on the response of @cirkopel, I've created a simple class that creates a single transaction, then only commits if the method that is invoking the commit method is the first caller to the transaction.

Each time that we call DB::beginTransaction() the transaction level is incremented, that's the point of only keeping 1 transaction as had mentioned by @cirkopel.

Taking care of this, another problem that we need to approach is when some error happens and we need to call DB::rollback() it must be called before any DB::commit().

To summarize, we need to only start the transaction in the root invoker and commit the transaction in this same, considering that we could have many services related between them with their own transaction management.

The class:

<?php

namespace App\Models;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

   class DBUtils
   {
       /**
        * This method starts a transaction only if the level of the transaction is 0.
        * So it is useful when you have nested transactions.
        * @return int The number of transaction-level that invokes the method.
        */
       public static function beginSingleTransaction(): int {
           $current_level = static::getCurrentTransactionLevel();
           if($current_level ==0 ){
               Log::debug('-------------- Beginning Transaction at LEVEL = ' . $current_level .' -------------- ');
               DB::beginTransaction();
           }
           return $current_level;
       }
   
       /**
        * This method commits the current transaction up to reach the root invoker.
        * So it is useful when you have nested transactions.
        *
        * @param int $at_level indicate the method that invokes the current transaction, pass the value of {#beginSingleTransaction}
        *
        * @return void
        */
       public static function commitSingleTransaction(int $at_level) {
           if($at_level == 0) {
               Log::debug('-------------- Commit Transaction at LEVEL = ' . $at_level. ' -------------- ');
               DB::commit();
           }
       }
   
       private static function getCurrentTransactionLevel(): int {
           return DB::connection(DB::getDefaultConnection())->transactionLevel();
       }
   
   }

Now we can have multiples services that could be combined. DeleteUseCase.php

<?php
namespace App\Features\UseCases;

use App\Models\DBUtils;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
 * DeleteUseCase.
 * ...
 */
class DeleteUseCase
{
    
    /**
     * @throws \Exception
     */
    public function delete(int $id): bool {
        $level = DBUtils::beginSingleTransaction();
        try {
            // your process of deletion (repository, eloquent, etc)
            DBUtils::commitSingleTransaction($level);
            return true;
        } catch (\Exception $e) {
            DB::rollBack();
            throw $e;
        }
    }

}

We can call DeleteUseCase inside another class creating nested transactions: OtherUseCase.php

<?php
namespace App\Features\UseCases;

use App\Features\UseCases\DeleteUseCase.php;
use App\Models\DBUtils;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
 * OtherUseCase.
 * ...
 */
class OtherUseCase
{
    private DeleteUseCase $deleteUseCase;

    public function __construct(
        DeleteUseCase            $deleteUseCase)
    {
        $this->deleteUseCase = $deleteUseCase;
    }
    
    /**
     * @throws \Exception
     */
    public function other(int $id): bool {
        // the actual level is 0, after this the level will be 1
        $level = DBUtils::beginSingleTransaction();
        try {
            // your OTHER process
            // .....

            // call to the inner service, here no transaction will be created
            // and also any commit will be performed, because the delete method
            // is not the root invoker.
            $this->deleteUseCase->delete($id);
            
            // here the transaction will be performed.
            DBUtils::commitSingleTransaction($level);
            return true;
        } catch (\Exception $e) {
            // at any point, as only one transaction was created the rollback is performed without problem.
            DB::rollBack();
            throw $e;
        }
    }

}

You can invoke the services together or separately and a single transaction would be created.

I hope that it could be useful for someone. Regards.

Doublejointed answered 31/3, 2022 at 18:34 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.