Transactions, rollback and commit callbacks

Transactions

$db->begin();

try {
    $db->insert('orders', $orderValues);
    $db->insert('order_items', $itemValues);

    $db->commit();
} catch (Throwable $exp) {
    $db->rollback();
    throw $exp;
}
  • begin(): starts a transaction.
  • commit(): saves the changes.
  • rollback(): throws the changes away.
  • inTransaction(): returns true while a transaction is open. The flag is shared by every adapter in the process, so with several connections it tells you that some connection has a transaction. For the state of one connection, use isConnectionInTransaction() (below).

Code that may run inside someone else's transaction checks inTransaction() first and leaves begin()/commit() to the code that started it.

Rollback callbacks

A rollback undoes only what is in the database. Some work done inside a transaction lives outside it: a file moved to the upload folder, a cache entry, a message sent to another system. When the transaction is rolled back, that work stays behind, and the database no longer has a record that matches it.

A rollback callback is the undo step for such work. You register it while the transaction is open, and it runs only if that transaction is really rolled back. It does not matter who calls rollback(): your code, a caller several layers up, or a store action in the framework.

$db->begin();

$db->insert('documents', $values);

$filePath = $uploadDir.$fileName;
$upload->moveTo($filePath);

$removeFile = function () use ($filePath) {
    if (is_file($filePath)) {
        unlink($filePath);
    }
};
$db->onRollback($removeFile);

// ... more work that may still fail

$db->commit();

If anything after moveTo() fails and the transaction is rolled back, the moved file is removed. If the transaction is committed, the callback is forgotten and the file stays.

The methods come from the ITransactionCallbacks interface. The PDO and WordPress adapters implement it, and so does every DataAccessObject, which passes the calls to its adapter:

Method What it does
onRollback(callable $callback): void Registers an undo step for the open transaction of this connection. Throws DatabaseException if this connection has no transaction.
onCommit(callable $callback): void Registers a finish step, run only after this connection's transaction is committed. Throws DatabaseException if this connection has no transaction.
isConnectionInTransaction(): bool Returns true if this connection has an open transaction. PDO connections are asked directly; others fall back to the shared inTransaction() flag.
getRollbackCallbackErrors(): array Returns the failures (Throwable[]) of the rollback callbacks of the transaction that just ended. Empty if it was committed.
getCommitCallbackErrors(): array Returns the failures (Throwable[]) of the commit callbacks of the transaction that just ended. Empty if it was rolled back.

Both accessors describe only the transaction that just ended: they are cleared when it ends and again when the next one begins, so a failure is never read as another transaction's.

Check for the interface before using it, because other IDataAccessObject implementations may not support it:

if ($db instanceof ITransactionCallbacks && $db->isConnectionInTransaction()) {
    $db->onRollback($removeFile);
}

How callbacks run

  • Only on a real rollback. commit() drops the callbacks without running them. begin() also starts with an empty list.
  • Once. A later rollback of a new transaction does not run callbacks of an earlier one.
  • Newest first. Undo steps unwind like a stack: if you create a directory and then a file inside it, the file is removed before the directory.
  • After the rollback. The database changes are already gone when a callback runs, so a callback must not write to the database.
  • Per connection. Callbacks belong to the storage connection (the PDO or wpdb object) they were registered on. Adapters and DataAccessObjects that share that connection share its callbacks. A rollback on another connection never runs them.

When a callback fails

rollback() is usually called while you are handling the error that caused it. That error must reach your caller, so rollback() never throws a callback's exception:

  • the other callbacks still run;
  • each failure is written to the PHP error log;
  • the failures are kept, and you can read them with getRollbackCallbackErrors() until the next transaction begins.
} catch (Throwable $exp) {
    $db->rollback();

    foreach ($db->getRollbackCallbackErrors() as $cleanupError) {
        $logger->warning('Cleanup failed: '.$cleanupError->getMessage());
    }

    throw $exp;
}

Commit callbacks

Some side effects need both halves: something to undo if the transaction is rolled back, and something to finish once it is committed. Replacing a file is the usual case. You cannot overwrite the old file before you know the save will succeed, so you keep a backup, restore it on rollback, and delete it on commit:

$db->begin();

$db->update('documents', $values, $search);

$backupPath = $filePath.'.backup';
rename($filePath, $backupPath);
$upload->moveTo($filePath);

$restoreOldFile = function () use ($filePath, $backupPath) {
    rename($backupPath, $filePath);
};
$deleteBackup = function () use ($backupPath) {
    if (is_file($backupPath)) {
        unlink($backupPath);
    }
};

$db->onRollback($restoreOldFile);
$db->onCommit($deleteBackup);

$db->commit();

Commit callbacks follow the same rules as rollback callbacks, with these differences:

  • Only on a real commit. rollback() drops them without running them. With nested code, only the commit of the outermost transaction counts.
  • In the order they were added. Finish steps run forward, not like a stack.
  • After the commit. The data is saved when a callback runs.
  • commit() never throws a callback's exception. The data is already committed, so an exception would wrongly tell the caller the save failed. Failures go to the PHP error log and to getCommitCallbackErrors().

Adapter support

Adapter Rollback and commit callbacks
ObjectPDOAdapter (MySQL, PostgreSQL, MSSQL, SQLite) Yes
ObjectWPDBAdapter Yes
ObjectCassandraAdapter No: it has no transactions, so onRollback() and onCommit() throw DatabaseException
ObjectHandlerSocketAdapter No: it has no transactions

In the Festi Framework

Store actions use this to fire Store::EVENT_ROLLBACK. FileField uses both kinds of callback: it removes files uploaded by a save that was rolled back, and when an upload replaces an existing file, it restores the old file on rollback and deletes the backup on commit. See the framework's docs/DGS/Events.md.