2

I really like this NestedPDO solution for Yii but I need some different transaction handling.

I want to commit my nested transactions only if all nested transactions could be commited and if ONE transaction does a rollback all transactions should be rolled back.

How can I do that?

My try of changing the rollBack function which didn't work:

public function rollBack() {
    $this->transLevel--;

    if($this->transLevel == 0 || !$this->nestable()) {
        parent::rollBack();
    } else {

        $level = $this->transLevel;
        for($level; $level>1; $level--){
            $this->exec("ROLLBACK TO SAVEPOINT LEVEL{$this->constantlevel}");
        }
        //parent::rollBack();
    }
}

I was thinking of adapting the NestedPDO: In function commit() do a commit only on the outermost transaction and in function rollBack() do a rollback to the outermost transaction no matter which sub transaction caused the rollback. But I could not get it done...

I'm using MySQL and InnoDB tables and I'm not sure about autocommit but when echoing the value of autocommit within a transaction I always get the value 1 which should mean autocommit is on but within a transaction autocommit should be set to 0. I'm not sure whether this is the cause why a whole rollback does not work for me?

cwiggo
  • 2,541
  • 9
  • 44
  • 87
sandro1111
  • 63
  • 1
  • 8
  • No, autocommit is **not** supposed to switch to 0 by default in a transaction. And if you are able to rollback one step, then you should be able to rollback everything. Also, if both `commit()` and `rollback()` will commit and rollback the outermost transaction (as you put it in the before-last paragraph), then you are back to square one with a regular, non-nested transaction. Or am I missing something? Please post your code that doesn't work. – RandomSeed Jun 13 '13 at 07:10
  • Ah okay, I didn't know about the behavior of autocommit in a transaction. So how would you recommend to do that or what am I doing wrong? I changed the example in my initial post to the realistic code I'm using in my application. – sandro1111 Jun 13 '13 at 07:54
  • Take out the inner transactions, and then it will work as you want - if one step fails it is all rolled back. If you sometimes need the inner transactions in other circumstances, you could make that a method parameter `$useTransactions`, defaulting to true. – halfer Jun 13 '13 at 07:55
  • thanks @halfer, but I would like to avoid that way because this would be quite a big effort. I would like to change the behavior of multiple transactions globally to do a commit only if it is the outer most transaction and otherwise just ignore it. And no mather which transaction level is doing a rollback, everything should be rolled back. so if there is a level0 transaction and a level1 transaction: when the level1 transaction does a commit then ignore it and when the level1 transaction does a rollback then rollback level1 and level0. – sandro1111 Jun 13 '13 at 08:05
  • @sandro1111 In the code you posted, I fail to see an attempt to implement the behaviour you seek. Pehaps the relevant code is rather in the `NestedPDO` class? – RandomSeed Jun 13 '13 at 08:50
  • @YaK, sorry I thought you were probably be looking at these functions via the link to the NestedPDO site of Yii. Nevertheless I added it to my initial post. – sandro1111 Jun 13 '13 at 08:57
  • @sandro1111 You said "I could not get it done": please show us what you tried, because I do not see the difficulty here: in `NestedPDO::rollBack()`, issue an unconditional `parent::rollBack();`, and perhaps throw an exception to terminate the failling process. – RandomSeed Jun 13 '13 at 09:07
  • Now I come to think about it, how will you distinguish situations where you want the whole transaction be rolled back, from situations where you only want the nested transaction be rolled back? Is it "if the current transaction is nested"? If so, then it equates to not using nested transactions at all... – RandomSeed Jun 13 '13 at 09:14
  • I've posted my try of changing the rollback function which didn't work. It throws an exception on failure but some parts of the changes are commited... As you can see from my changed rollback function I'm trying to rollback all transactions if the function is called once and that's the behaviour I need for my application. It might be that there are multiple nested transactions at the same time and on some failure everything should be rolled back. Do you have an idea to solve my problem? – sandro1111 Jun 13 '13 at 10:01
  • @sandro1111 That's what I thought. What is the reason why you want nested transactions at all, if you want to rollback the **entire transaction** when any failure happens in a *nested transaction*? Please remove irrelevant code from your post (the only relevant code snippet is your modified `NestedPDO::rollBack()`). – RandomSeed Jun 13 '13 at 16:41
  • @YaK, sorry for my delayed answer... I removed unnecessary code from my post. To your question: I know I'm not really having nested transactions, but I need some special behaviour because my application sometimes calls functions within a transaction that begin a transaction themselves. I think it would also be possible to rewrite the NestedPDO to start a transaction in beginTransaction() ONLY on the first call and for an "inner" transaction it should do nothing. What do you think about that? What that be a nice solution? Or do have a better hint for me, please? – sandro1111 Jun 17 '13 at 11:15
  • @sandro1111 Please review my new answer. I think I now understand what you are trying to achieve. – RandomSeed Jun 17 '13 at 12:37

3 Answers3

0

If you want the whole transaction be rolled back automatically as soon as an error occurs, you could just re-throw the exception from B's exception handler when called from some specific locations (eg. from A()):

function A(){
   ...
   $this->B(true);
   ...
}

/*
* @param B boolean Throw an exception if the transaction is rolled back
*/
function B($rethrow) {
    $transaction=Yii::app()->db->beginTransaction();
    try {
        //do something
        $transaction->commit();
    } catch(Exception $e) {
        $transaction->rollBack();
        if ($rethrow) throw $e;
    }
}

Now I understand you actually just want your wrapper to detect if a transaction is already in progress, and in this case not start the transaction.

Therefore you do not really need the NestedPDO class. You could create a class like this instead:

class SingleTransactionManager extends PDO {
    private $nestingDepth = 0;

    public function beginTransaction() {
        if(!$this->nestingDepth++ == 0) {
            parent::beginTransaction();
        } // else do nothing
    }
    public function commit() {
        $this->nestingDepth--;
        if (--$this->nestingDepth == 0) {
            parent::commit();
        } // else do nothing
    }

    public function rollback() {
        parent::rollback();
        if (--$this->nestingDepth > 0) {
            $this->nestingDepth = 0;
            throw new Exception(); // so as to interrupt outer the transaction ASAP, which has become pointless
        }

    }
}
RandomSeed
  • 29,301
  • 6
  • 52
  • 87
  • Thanks for that @YaK but I would like to avoid that because it would be quite a big effort to do that for the whole application. I really would prefer some solution that can be realized within the NestedPDO class to change the behavior globally because the whole application should be changed to that behavior. – sandro1111 Jun 13 '13 at 06:04
  • sorry but this does not work. I still need to handle rollbacks because a rollback can only be done if a transaction is active and I still don't know how to handle commits because what if sub called function calls a commit but another sub function calls a rollback, then the transaction is already closed. so I think I would need some global counter to count the number of calls for beginTransactions and the number of counts for commits. – sandro1111 Jun 17 '13 at 12:54
  • @sandro1111 I see. What about this? – RandomSeed Jun 17 '13 at 13:11
  • the rollback does not work because the function is called multiple times but the transaction exists only on first call. I changed your rollback function to check that. But there are still records created in the database when using this SingleTransactionManager... what could be the reason for that? – sandro1111 Jun 18 '13 at 06:36
  • do you have an idea what still is wrong with this because there are still records that stay in database when using this kind of transaction manager and doing a rollback? – sandro1111 Jun 24 '13 at 10:03
0

Based on the answer of @RandomSeed I've created a 'drop in' for default Yii transaction handling:

$connection = Yii::app()->db;
$transaction=$connection->beginTransaction();
try
{
   $connection->createCommand($sql1)->execute();
   $connection->createCommand($sql2)->execute();
   //.... other SQL executions
   $transaction->commit();
}
catch(Exception $e)
{
   $transaction->rollback();
}

This is my SingleTransactionManager class:

class SingleTransactionManager extends CComponent 
{
    // The current transaction level.
    private $transLevel = 0;

    // The CDbConnection object that should be wrapped
    public $dbConnection;

    public function init()
    {
        if($this->dbConnection===null)
            throw new Exception('Property `dbConnection` must be set.');

        $this->dbConnection=$this->evaluateExpression($this->dbConnection);
    }
    // We only start a transaction if we're the first doing so
    public function beginTransaction() {
        if($this->transLevel == 0) {
            $transaction = parent::beginTransaction();
        } else {
            $transaction = new SingleTransactionManager_Transaction($this->dbConnection, false);
        }
        // always increase transaction level:
        $this->transLevel++;

        return $transaction;
    }

    public function __call($name, $parameters)
    {
        return call_user_func_array(array($this->dbConnection, $name), $parameters);
    }
}

class SingleTransactionManager_Transaction extends CDbTransaction
{
    // boolean, whether this instance 'really' started the transaction
    private $_startedTransaction;

    public function __construct(CDbConnection $connection, $startedTransaction = false)
    {
        $this->_startedTransaction = $startedTransaction;
        parent::__construct($connection);
        $this->setActive($startedTransaction);
    }

    // We only commit a transaction if we've started the transaction
    public function commit() {
        if($this->_startedTransaction)
            parent::commit();
    }

    // We only rollback a transaction if we've started the transaction
    // else throw an Exception to revert parent transactions/take adquate action
    public function rollback() {
        if($this->_startedTransaction)
            parent::rollback();
        else
            throw new Exception('Child transaction rolled back!');
    }
}

This class 'wraps' the main database connection, you should declare it as component like this in your config:

'components'=>array(

    // database
    'db'=>array(
        'class' => 'CDbConnection',
        // using mysql
        'connectionString'=>'....',
        'username'=>'...',
        'password'=>'....',
    ),

    // database
    'singleTransaction'=>array(
        'class' => 'pathToComponents.db.SingleTransactionManager',
        'dbConnection' => 'Yii::app()->db'
    )

Note that the dbConnection property should be an expression to the master database connection. Now, when nesting transactions in nested try catch blocks, you can create an error in for example nested transaction 3, and the ones on 1 and 2 are rolled back also.

Test code:

$connection = Yii::app()->singleTransaction;

$connection->createCommand('CREATE TABLE IF NOT EXISTS `test_transactions` (
  `number` int(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;')->execute();

$connection->createCommand('TRUNCATE TABLE `test_transactions`;')->execute();

testNesting(4, 3, 1);

echo '<br>';
echo 'Rows:';
echo '<br>';
$rows = $connection->createCommand('SELECT * FROM `test_transactions`')->queryAll();
if($rows)
{
    foreach($rows as $row)
    {
        print_r($row);
    }
}
else
    echo 'Table is empty!';

function testNesting(int $total, int $createErrorIn = null, int $current = 1)
{
    if($current>=$total)
        return;

    $connection = Yii::app()->singleTransaction;
    $indent = str_repeat('&nbsp;', ($current*4));

    echo $indent.'Transaction '.$current;
    echo '<br>';
    $transaction=$connection->beginTransaction();
    try
    {
        // create nonexisting columnname when we need to create an error in this nested transaction
        $columnname = 'number'.($createErrorIn===$current ? 'rr' : '');
        $connection->createCommand('INSERT INTO `test_transactions` (`'.$columnname.'`) VALUES ('.$current.')')->execute();

        testNesting($total, $createErrorIn, ($current+1));

        $transaction->commit();
    }
    catch(Exception $e)
    {
        echo $indent.'Exception';
        echo '<br>';
        echo $indent.$e->getMessage();
        echo '<br>';
        $transaction->rollback();
    }
}

Results in the following output:

    Transaction 1
        Transaction 2
            Transaction 3
            Exception
            CDbCommand failed to execute the SQL statement: SQLSTATE[42S22]: Column not found: 1054 Unknown column 'numberrr' in 'field list'. The SQL statement executed was: INSERT INTO `test_transactions` (`numberrr`) VALUES (3)
        Exception
        Child transaction rolled back!
    Exception
    Child transaction rolled back!

Rows:
Table is empty! 
Piemol
  • 857
  • 8
  • 17
0

IMHO, the idea of simulating "nested transactions" in application code is an anti-pattern. There are numerous anomaly cases that are impossible to solve in the application (see my answer to https://stackoverflow.com/a/319939/20860).

In PHP, it's better to keep it simple. Work is organized naturally into requests, so use the request as the transaction scope.

  • Start a transaction at the controller level, before you call any model classes.
  • Let models throw exceptions if anything goes wrong.
  • Catch the exception at the controller level, and rollback if necessary.
  • Commit if no exception is caught.

Forget about all the nonsense about transaction levels. Models should not be starting, committing, or rolling back any transactions.

Bill Karwin
  • 538,548
  • 86
  • 673
  • 828
  • Sometimes a function including a transaction can be called directly, but also can can be part of nested function calls, each with or without transaction. In the latter (nested function calls) you want the most clean solution for transaction management, not knowing where which request is coming from or which usecase is being handled. It doesn't matter If those functions are defined in models, controllers or components. I think a central class handling those use cases can be extremely helpful, depending on the application logic of course! – Piemol Jan 09 '19 at 17:56
  • But the central class leads to erroneous transaction handling. See the answer I linked to. It's better for _all_ functions to avoid starting their own transactions, and instead rely on the caller to start and commit the transaction. – Bill Karwin Jan 09 '19 at 18:00
  • IMHO:Scenario #1 is a design problem, Scenario #2, if this is true, than it's also true for transactions not nested, Scenario #3 is true if code is wrong, just like writing too many closing tags "}" or ")" I think there are at least two use cases of 'nested transactions': they should behave independently, or all 'as one'. But in both cases (very important requirement) code should not alter if functions are called directly or within another function with transaction. – Piemol Jan 09 '19 at 18:56
  • Part 2 :) The answer I wrote on this page helps managing 'transactions behaving as one' (single transaction?), while the application logic is not to be altered. In this case the transaction handing IS handled by the caller. Subsequent calls are ignored. – Piemol Jan 09 '19 at 18:56