TransactionManagerBase.php
Same filename in other branches
Namespace
Drupal\Core\Database\TransactionFile
-
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.