TransactionManagerBase.php

Same filename in other branches
  1. 10 core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php

Namespace

Drupal\Core\Database\Transaction

File

core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php

View source
<?php

declare (strict_types=1);
namespace Drupal\Core\Database\Transaction;

use Drupal\Core\Database\Connection;
use Drupal\Core\Database\Transaction;
use Drupal\Core\Database\TransactionCommitFailedException;
use Drupal\Core\Database\TransactionNameNonUniqueException;
use Drupal\Core\Database\TransactionOutOfOrderException;

/**
 * The database transaction manager base class.
 *
 * On many databases transactions cannot nest. Instead, we track nested calls
 * to transactions and collapse them into a single client transaction.
 *
 * Database drivers must implement their own class extending from this, and
 * instantiate it via their Connection::driverTransactionManager() method.
 *
 * @see \Drupal\Core\Database\Connection::driverTransactionManager()
 */
abstract class TransactionManagerBase implements TransactionManagerInterface {
    
    /**
     * The ID of the root Transaction object.
     *
     * The unique identifier of the first 'root' transaction object created, when
     * the stack is empty.
     *
     * Normally, during the transaction stack lifecycle only one 'root'
     * Transaction object is processed. Any post transaction callbacks are only
     * processed during its destruction. However, there are cases when there
     * could be multiple 'root' transaction objects in the stack. For example: a
     * 'root' transaction object is opened, then a DDL statement is executed in a
     * database that does not support transactional DDL, and because of that,
     * another 'root' is opened before the original one is closed.
     *
     * Keeping track of the first 'root' created allows us to process the post
     * transaction callbacks only during its destruction and not during
     * destruction of another one.
     */
    private ?string $rootId = NULL;
    
    /**
     * The stack of Drupal transactions currently active.
     *
     * This property is keeping track of the Transaction objects started and
     * ended as a LIFO (Last In, First Out) stack.
     *
     * The database API allows to begin transactions, add an arbitrary number of
     * additional savepoints, and release any savepoint in the sequence. When
     * this happens, the database will implicitly release all the savepoints
     * created after the one released. Given Drupal implementation of the
     * Transaction objects, we cannot force reducing the scope of the
     * corresponding Transaction savepoint objects from the manager, because they
     * live in the scope of the calling code. This stack ensures that when an
     * outlived Transaction object gets out of scope, it will not try to release
     * on the database a savepoint that no longer exists.
     *
     * Differently, rollbacks are strictly being checked for LIFO order: if a
     * rollback is requested against a savepoint that is not the last created,
     * the manager will throw a TransactionOutOfOrderException.
     *
     * The array key is the transaction's unique id, its value a StackItem.
     *
     * @var array<string,StackItem>
     */
    private array $stack = [];
    
    /**
     * A list of voided stack items.
     *
     * In some cases the active transaction can be automatically committed by the
     * database server (for example, MySql when a DDL statement is executed
     * during a transaction). In such cases we need to void the remaining items
     * on the stack, and we track them here.
     *
     * The array key is the transaction's unique id, its value a StackItem.
     *
     * @var array<string,StackItem>
     */
    private array $voidedItems = [];
    
    /**
     * A list of post-transaction callbacks.
     *
     * @var callable[]
     */
    private array $postTransactionCallbacks = [];
    
    /**
     * The state of the underlying client connection transaction.
     *
     * Note that this is a proxy of the actual state on the database server,
     * best determined through calls to methods in this class. The actual
     * state on the database server could be different.
     */
    private ClientConnectionTransactionState $connectionTransactionState;
    
    /**
     * Constructor.
     *
     * @param \Drupal\Core\Database\Connection $connection
     *   The database connection.
     */
    public function __construct(Connection $connection) {
    }
    
    /**
     * Destructor.
     *
     * When destructing, $stack must have been already emptied.
     */
    public function __destruct() {
        assert($this->stack === [], "Transaction \$stack was not empty. Active stack: " . $this->dumpStackItemsAsString());
    }
    
    /**
     * Returns the current depth of the transaction stack.
     *
     * @return int
     *   The current depth of the transaction stack.
     *
     * @todo consider making this function protected.
     *
     * @internal
     */
    public function stackDepth() : int {
        return count($this->stack());
    }
    
    /**
     * Returns the content of the transaction stack.
     *
     * Drivers should not override this method unless they also override the
     * $stack property.
     *
     * phpcs:ignore Drupal.Commenting.FunctionComment.InvalidReturn
     * @return array<string,StackItem>
     *   The elements of the transaction stack.
     */
    protected function stack() : array {
        return $this->stack;
    }
    
    /**
     * Commits the entire transaction stack.
     *
     * @internal
     *   This method exists only to work around a bug caused by Drupal incorrectly
     *   relying on object destruction order to commit transactions. Xdebug 3.3.0
     *   changes the order of object destruction when the develop mode is enabled.
     */
    public function commitAll() : void {
        foreach (array_reverse($this->stack()) as $id => $item) {
            $this->unpile($item->name, $id);
        }
    }
    
    /**
     * Adds an item to the transaction stack.
     *
     * Drivers should not override this method unless they also override the
     * $stack property.
     *
     * @param string $id
     *   The id of the transaction.
     * @param \Drupal\Core\Database\Transaction\StackItem $item
     *   The stack item.
     */
    protected function addStackItem(string $id, StackItem $item) : void {
        $this->stack[$id] = $item;
    }
    
    /**
     * Removes an item from the transaction stack.
     *
     * Drivers should not override this method unless they also override the
     * $stack property.
     *
     * @param string $id
     *   The id of the transaction.
     */
    protected function removeStackItem(string $id) : void {
        unset($this->stack[$id]);
    }
    
    /**
     * Voids an item from the transaction stack.
     *
     * Drivers should not override this method unless they also override the
     * $stack property.
     *
     * @param string $id
     *   The id of the transaction.
     */
    protected function voidStackItem(string $id) : void {
        // The item should be removed from $stack and added to $voidedItems for
        // later processing.
        $this->voidedItems[$id] = $this->stack[$id];
        $this->removeStackItem($id);
    }
    
    /**
     * Produces a string representation of the stack items.
     *
     * A helper method for exception messages.
     *
     * Drivers should not override this method unless they also override the
     * $stack property.
     *
     * @return string
     *   The string representation of the stack items.
     */
    protected function dumpStackItemsAsString() : string {
        if ($this->stack() === []) {
            return '*** empty ***';
        }
        $temp = [];
        foreach ($this->stack() as $id => $item) {
            $temp[] = $id . '\\' . $item->name;
        }
        return implode(' > ', $temp);
    }
    
    /**
     * {@inheritdoc}
     */
    public function inTransaction() : bool {
        return (bool) $this->stackDepth() && $this->getConnectionTransactionState() === ClientConnectionTransactionState::Active;
    }
    
    /**
     * {@inheritdoc}
     */
    public function push(string $name = '') : Transaction {
        if (!$this->inTransaction()) {
            // If there is no transaction active, name the transaction
            // 'drupal_transaction'.
            $name = 'drupal_transaction';
        }
        elseif (!$name) {
            // Within transactions, savepoints are used. Each savepoint requires a
            // name. So if no name is present we need to create one.
            $name = 'savepoint_' . $this->stackDepth();
        }
        if ($this->has($name)) {
            throw new TransactionNameNonUniqueException("A transaction named {$name} is already in use. Active stack: " . $this->dumpStackItemsAsString());
        }
        // Define a unique ID for the transaction.
        $id = uniqid('', TRUE);
        // Do the client-level processing.
        if ($this->stackDepth() === 0) {
            $this->beginClientTransaction();
            $type = StackItemType::Root;
            $this->setConnectionTransactionState(ClientConnectionTransactionState::Active);
            // Only set ::rootId if there's not one set already, which may happen in
            // case of broken transactions.
            if ($this->rootId === NULL) {
                $this->rootId = $id;
            }
        }
        else {
            // If we're already in a Drupal transaction then we want to create a
            // database savepoint, rather than try to begin another database
            // transaction.
            $this->addClientSavepoint($name);
            $type = StackItemType::Savepoint;
        }
        // Add an item on the stack, increasing its depth.
        $this->addStackItem($id, new StackItem($name, $type));
        // Actually return a new Transaction object.
        return new Transaction($this->connection, $name, $id);
    }
    
    /**
     * {@inheritdoc}
     */
    public function unpile(string $name, string $id) : void {
        // If this is a 'root' transaction, and it is voided (that is, no longer in
        // the stack), then the transaction on the database is no longer active. An
        // action such as a rollback, or a DDL statement, was executed that
        // terminated the database transaction. So, we can process the post
        // transaction callbacks.
        if (!isset($this->stack()[$id]) && isset($this->voidedItems[$id]) && $this->rootId === $id) {
            $this->processPostTransactionCallbacks();
            $this->rootId = NULL;
            unset($this->voidedItems[$id]);
            return;
        }
        // If the $id does not correspond to the one in the stack for that $name,
        // we are facing an orphaned Transaction object (for example in case of a
        // DDL statement breaking an active transaction). That should be listed in
        // $voidedItems, so we can remove it from there.
        if (!isset($this->stack()[$id]) || $this->stack()[$id]->name !== $name) {
            unset($this->voidedItems[$id]);
            return;
        }
        // If we are not releasing the last savepoint but an earlier one, or
        // committing a root transaction while savepoints are active, all
        // subsequent savepoints will be released as well. The stack must be
        // diminished accordingly.
        while (($i = array_key_last($this->stack())) != $id) {
            $this->voidStackItem((string) $i);
        }
        if ($this->getConnectionTransactionState() === ClientConnectionTransactionState::Active) {
            if ($this->stackDepth() > 1 && $this->stack()[$id]->type === StackItemType::Savepoint) {
                // Release the client transaction savepoint in case the Drupal
                // transaction is not a root one.
                $this->releaseClientSavepoint($name);
            }
            elseif ($this->stackDepth() === 1 && $this->stack()[$id]->type === StackItemType::Root) {
                // If this was the root Drupal transaction, we can commit the client
                // transaction.
                $this->processRootCommit();
                if ($this->rootId === $id) {
                    $this->processPostTransactionCallbacks();
                    $this->rootId = NULL;
                }
            }
            else {
                // The stack got corrupted.
                throw new TransactionOutOfOrderException("Transaction {$id}/{$name} is out of order. Active stack: " . $this->dumpStackItemsAsString());
            }
            // Remove the transaction from the stack.
            $this->removeStackItem($id);
            return;
        }
        // The stack got corrupted.
        throw new TransactionOutOfOrderException("Transaction {$id}/{$name} is out of order. Active stack: " . $this->dumpStackItemsAsString());
    }
    
    /**
     * {@inheritdoc}
     */
    public function rollback(string $name, string $id) : void {
        // Rolled back item should match the last one in stack.
        if ($id != array_key_last($this->stack()) || $name !== $this->stack()[$id]->name) {
            throw new TransactionOutOfOrderException("Error attempting rollback of {$id}\\{$name}. Active stack: " . $this->dumpStackItemsAsString());
        }
        if ($this->getConnectionTransactionState() === ClientConnectionTransactionState::Active) {
            if ($this->stackDepth() > 1 && $this->stack()[$id]->type === StackItemType::Savepoint) {
                // Rollback the client transaction to the savepoint when the Drupal
                // transaction is not a root one. Then, release the savepoint too. The
                // client connection remains active.
                $this->rollbackClientSavepoint($name);
                $this->releaseClientSavepoint($name);
                // The Transaction object remains open, and when it will get destructed
                // no commit should happen. Void the stack item.
                $this->voidStackItem($id);
            }
            elseif ($this->stackDepth() === 1 && $this->stack()[$id]->type === StackItemType::Root) {
                // If this was the root Drupal transaction, we can rollback the client
                // transaction. The transaction is closed.
                $this->processRootRollback();
                if ($this->getConnectionTransactionState() === ClientConnectionTransactionState::RolledBack) {
                    // The Transaction object remains open, and when it will get destructed
                    // no commit should happen. Void the stack item.
                    $this->voidStackItem($id);
                }
            }
            else {
                // The stack got corrupted.
                throw new TransactionOutOfOrderException("Error attempting rollback of {$id}\\{$name}. Active stack: " . $this->dumpStackItemsAsString());
            }
            return;
        }
        // The stack got corrupted.
        throw new TransactionOutOfOrderException("Error attempting rollback of {$id}\\{$name}. Active stack: " . $this->dumpStackItemsAsString());
    }
    
    /**
     * {@inheritdoc}
     */
    public function addPostTransactionCallback(callable $callback) : void {
        if (!$this->inTransaction()) {
            throw new \LogicException('Root transaction end callbacks can only be added when there is an active transaction.');
        }
        $this->postTransactionCallbacks[] = $callback;
    }
    
    /**
     * {@inheritdoc}
     */
    public function has(string $name) : bool {
        foreach ($this->stack() as $item) {
            if ($item->name === $name) {
                return TRUE;
            }
        }
        return FALSE;
    }
    
    /**
     * Sets the state of the client connection transaction.
     *
     * Note that this is a proxy of the actual state on the database server,
     * best determined through calls to methods in this class. The actual
     * state on the database server could be different.
     *
     * Drivers should not override this method unless they also override the
     * $connectionTransactionState property.
     *
     * @param \Drupal\Core\Database\Transaction\ClientConnectionTransactionState $state
     *   The state of the client connection.
     */
    protected function setConnectionTransactionState(ClientConnectionTransactionState $state) : void {
        $this->connectionTransactionState = $state;
    }
    
    /**
     * Gets the state of the client connection transaction.
     *
     * Note that this is a proxy of the actual state on the database server,
     * best determined through calls to methods in this class. The actual
     * state on the database server could be different.
     *
     * Drivers should not override this method unless they also override the
     * $connectionTransactionState property.
     *
     * @return \Drupal\Core\Database\Transaction\ClientConnectionTransactionState
     *   The state of the client connection.
     */
    protected function getConnectionTransactionState() : ClientConnectionTransactionState {
        return $this->connectionTransactionState;
    }
    
    /**
     * Processes the root transaction rollback.
     */
    protected function processRootRollback() : void {
        $this->rollbackClientTransaction();
    }
    
    /**
     * Processes the root transaction commit.
     *
     * @throws \Drupal\Core\Database\TransactionCommitFailedException
     *   If the commit of the root transaction failed.
     */
    protected function processRootCommit() : void {
        $clientCommit = $this->commitClientTransaction();
        if (!$clientCommit) {
            throw new TransactionCommitFailedException();
        }
    }
    
    /**
     * Processes the post-transaction callbacks.
     */
    protected function processPostTransactionCallbacks() : void {
        if (!empty($this->postTransactionCallbacks)) {
            $callbacks = $this->postTransactionCallbacks;
            $this->postTransactionCallbacks = [];
            foreach ($callbacks as $callback) {
                call_user_func($callback, $this->getConnectionTransactionState() === ClientConnectionTransactionState::Committed || $this->getConnectionTransactionState() === ClientConnectionTransactionState::Voided);
            }
        }
    }
    
    /**
     * Begins a transaction on the client connection.
     *
     * @return bool
     *   Returns TRUE on success or FALSE on failure.
     */
    protected abstract function beginClientTransaction() : bool;
    
    /**
     * Adds a savepoint on the client transaction.
     *
     * This is a generic implementation. Drivers should override this method
     * to use a method specific for their client connection.
     *
     * @param string $name
     *   The name of the savepoint.
     *
     * @return bool
     *   Returns TRUE on success or FALSE on failure.
     */
    protected function addClientSavepoint(string $name) : bool {
        $this->connection
            ->query('SAVEPOINT ' . $name);
        return TRUE;
    }
    
    /**
     * Rolls back to a savepoint on the client transaction.
     *
     * This is a generic implementation. Drivers should override this method
     * to use a method specific for their client connection.
     *
     * @param string $name
     *   The name of the savepoint.
     *
     * @return bool
     *   Returns TRUE on success or FALSE on failure.
     */
    protected function rollbackClientSavepoint(string $name) : bool {
        $this->connection
            ->query('ROLLBACK TO SAVEPOINT ' . $name);
        return TRUE;
    }
    
    /**
     * Releases a savepoint on the client transaction.
     *
     * This is a generic implementation. Drivers should override this method
     * to use a method specific for their client connection.
     *
     * @param string $name
     *   The name of the savepoint.
     *
     * @return bool
     *   Returns TRUE on success or FALSE on failure.
     */
    protected function releaseClientSavepoint(string $name) : bool {
        $this->connection
            ->query('RELEASE SAVEPOINT ' . $name);
        return TRUE;
    }
    
    /**
     * Rolls back a client transaction.
     *
     * @return bool
     *   Returns TRUE on success or FALSE on failure.
     */
    protected abstract function rollbackClientTransaction() : bool;
    
    /**
     * Commits a client transaction.
     *
     * @return bool
     *   Returns TRUE on success or FALSE on failure.
     */
    protected abstract function commitClientTransaction() : bool;
    
    /**
     * {@inheritdoc}
     */
    public function voidClientTransaction() : void {
        while ($i = array_key_last($this->stack())) {
            $this->voidStackItem((string) $i);
        }
        $this->setConnectionTransactionState(ClientConnectionTransactionState::Voided);
    }

}

Classes

Title Deprecated Summary
TransactionManagerBase The database transaction manager base class.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.