From 6950cac3d4f73cd73d34473d846bc79dca0378c7 Mon Sep 17 00:00:00 2001
From: catch <catch@35733.no-reply.drupal.org>
Date: Fri, 15 Sep 2023 20:09:04 +0100
Subject: [PATCH] Issue #3384960 by mondrake, daffie, longwave: Strengthen
 TransactionManager

---
 core/lib/Drupal/Core/Database/Connection.php  |   2 +-
 core/lib/Drupal/Core/Database/Transaction.php |  17 ++-
 .../Core/Database/Transaction/StackItem.php   |  26 ++++
 .../Transaction/TransactionManagerBase.php    | 129 ++++++++++++------
 .../TransactionManagerInterface.php           |  18 ++-
 core/misc/cspell/dictionary.txt               |   1 +
 .../DriverSpecificTransactionTestBase.php     |  24 ++++
 7 files changed, 170 insertions(+), 47 deletions(-)
 create mode 100644 core/lib/Drupal/Core/Database/Transaction/StackItem.php

diff --git a/core/lib/Drupal/Core/Database/Connection.php b/core/lib/Drupal/Core/Database/Connection.php
index 67dc4a1a1a4b..97b528c25bed 100644
--- a/core/lib/Drupal/Core/Database/Connection.php
+++ b/core/lib/Drupal/Core/Database/Connection.php
@@ -1480,7 +1480,7 @@ public function startTransaction($name = '') {
   public function rollBack($savepoint_name = 'drupal_transaction') {
     @trigger_error(__METHOD__ . '() is deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. Do not rollback the connection, roll back the Transaction objects instead. See https://www.drupal.org/node/3381002', E_USER_DEPRECATED);
     if ($this->transactionManager()) {
-      $this->transactionManager()->rollback($savepoint_name);
+      $this->transactionManager()->rollback($savepoint_name, 'bc-force-rollback');
       return;
     }
     if (!$this->inTransaction()) {
diff --git a/core/lib/Drupal/Core/Database/Transaction.php b/core/lib/Drupal/Core/Database/Transaction.php
index 47069baafbaa..062dd177d09e 100644
--- a/core/lib/Drupal/Core/Database/Transaction.php
+++ b/core/lib/Drupal/Core/Database/Transaction.php
@@ -34,6 +34,11 @@ class Transaction {
    * A boolean value to indicate whether this transaction has been rolled back.
    *
    * @var bool
+   *
+   * @deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. There is
+   *   no replacement.
+   *
+   * @see https://www.drupal.org/node/3381002
    */
   protected $rolledBack = FALSE;
 
@@ -47,7 +52,11 @@ class Transaction {
    */
   protected $name;
 
-  public function __construct(Connection $connection, $name = NULL) {
+  public function __construct(
+    Connection $connection,
+    $name = NULL,
+    protected readonly string $id = '',
+  ) {
     if ($connection->transactionManager()) {
       $this->connection = $connection;
       $this->name = $name;
@@ -76,11 +85,12 @@ public function __construct(Connection $connection, $name = NULL) {
 
   public function __destruct() {
     if ($this->connection->transactionManager()) {
-      $this->connection->transactionManager()->unpile($this->name);
+      $this->connection->transactionManager()->unpile($this->name, $this->id);
       return;
     }
     // Start of BC layer.
     // If we rolled back then the transaction would have already been popped.
+    // @phpstan-ignore-next-line
     if (!$this->rolledBack) {
       // @phpstan-ignore-next-line
       $this->connection->popTransaction($this->name);
@@ -107,10 +117,11 @@ public function name() {
    */
   public function rollBack() {
     if ($this->connection->transactionManager()) {
-      $this->connection->transactionManager()->rollback($this->name);
+      $this->connection->transactionManager()->rollback($this->name, $this->id);
       return;
     }
     // Start of BC layer.
+    // @phpstan-ignore-next-line
     $this->rolledBack = TRUE;
     // @phpstan-ignore-next-line
     $this->connection->rollBack($this->name);
diff --git a/core/lib/Drupal/Core/Database/Transaction/StackItem.php b/core/lib/Drupal/Core/Database/Transaction/StackItem.php
new file mode 100644
index 000000000000..30b9126e7ca1
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Transaction/StackItem.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Database\Transaction;
+
+/**
+ * A value object for items on the transaction stack.
+ */
+final class StackItem {
+
+  /**
+   * Constructor.
+   *
+   * @param string $name
+   *   The name of the transaction.
+   * @param StackItemType $type
+   *   The stack item type.
+   */
+  public function __construct(
+    public readonly string $name,
+    public readonly StackItemType $type,
+  ) {
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php
index 112077b90eeb..9c50d552f34f 100644
--- a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php
+++ b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php
@@ -27,21 +27,31 @@ abstract class TransactionManagerBase implements TransactionManagerInterface {
   /**
    * The stack of Drupal transactions currently active.
    *
-   * This is not a real LIFO (Last In, First Out) stack, where we would only
-   * remove the layers according to the order they were introduced. For commits
-   * the layer order is enforced, while for rollbacks the API allows to
-   * rollback to savepoints before the last one.
+   * This property is keeping track of the Transaction objects started and
+   * ended as a LIFO (Last In, First Out) stack.
    *
-   * @var array<string,StackItemType>
-   */
-  private array $stack = [];
-
-  /**
-   * A list of Drupal transactions rolled back but not yet unpiled.
+   * 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 descoping 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,true>
+   * @var array<string,StackItem>
+   *
+   * @todo in https://www.drupal.org/project/drupal/issues/3384995, complete
+   *   the LIFO logic extending it to the root transaction too.
    */
-  private array $rollbacks = [];
+  private array $stack = [];
 
   /**
    * A list of post-transaction callbacks.
@@ -91,7 +101,7 @@ public function stackDepth(): int {
    * $stack property.
    *
    * phpcs:ignore Drupal.Commenting.FunctionComment.InvalidReturn
-   * @return array<string,StackItemType>
+   * @return array<string,StackItem>
    *   The elements of the transaction stack.
    */
   protected function stack(): array {
@@ -114,13 +124,13 @@ protected function resetStack(): void {
    * Drivers should not override this method unless they also override the
    * $stack property.
    *
-   * @param string $name
-   *   The name of the transaction.
-   * @param \Drupal\Core\Database\Transaction\StackItemType $type
-   *   The stack item type.
+   * @param string $id
+   *   The id of the transaction.
+   * @param \Drupal\Core\Database\Transaction\StackItem $item
+   *   The stack item.
    */
-  protected function addStackItem(string $name, StackItemType $type): void {
-    $this->stack[$name] = $type;
+  protected function addStackItem(string $id, StackItem $item): void {
+    $this->stack[$id] = $item;
   }
 
   /**
@@ -129,11 +139,11 @@ protected function addStackItem(string $name, StackItemType $type): void {
    * Drivers should not override this method unless they also override the
    * $stack property.
    *
-   * @param string $name
-   *   The name of the transaction.
+   * @param string $id
+   *   The id of the transaction.
    */
-  protected function removeStackItem(string $name): void {
-    unset($this->stack[$name]);
+  protected function removeStackItem(string $id): void {
+    unset($this->stack[$id]);
   }
 
   /**
@@ -176,23 +186,30 @@ public function push(string $name = ''): Transaction {
       $type = StackItemType::Savepoint;
     }
 
-    // Push the transaction on the stack, increasing its depth.
-    $this->addStackItem($name, $type);
+    // Define an unique id for the transaction.
+    $id = uniqid();
+
+    // Add an item on the stack, increasing its depth.
+    $this->addStackItem($id, new StackItem($name, $type));
 
-    return new Transaction($this->connection, $name);
+    // Actually return a new Transaction object.
+    return new Transaction($this->connection, $name, $id);
   }
 
   /**
    * {@inheritdoc}
    */
-  public function unpile(string $name): void {
-    // If an already rolled back Drupal transaction, do nothing on the client
-    // connection, just cleanup the list of transactions rolled back.
-    if (isset($this->rollbacks[$name])) {
-      unset($this->rollbacks[$name]);
+  public function unpile(string $name, string $id): void {
+    // 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), so there is nothing more
+    // to do.
+    if (!isset($this->stack()[$id]) || $this->stack()[$id]->name !== $name) {
       return;
     }
 
+    // If unpiling a savepoint, but that does not exist on the stack, the stack
+    // got corrupted.
     if ($name !== 'drupal_transaction' && !$this->has($name)) {
       throw new TransactionOutOfOrderException();
     }
@@ -201,14 +218,20 @@ public function unpile(string $name): void {
     // is not a root one.
     if (
       $this->has($name)
-      && $this->stack()[$name] === StackItemType::Savepoint
+      && $this->stack()[$id]->type === StackItemType::Savepoint
       && $this->getConnectionTransactionState() === ClientConnectionTransactionState::Active
     ) {
+      // If we are not releasing the last savepoint but an earlier one, all
+      // subsequent savepoints will have been released as well. The stack must
+      // be diminished accordingly.
+      while (($i = array_key_last($this->stack())) != $id) {
+        $this->removeStackItem((string) $i);
+      }
       $this->releaseClientSavepoint($name);
     }
 
     // Remove the transaction from the stack.
-    $this->removeStackItem($name);
+    $this->removeStackItem($id);
 
     // If this was the last Drupal transaction open, we can commit the client
     // transaction.
@@ -223,24 +246,47 @@ public function unpile(string $name): void {
   /**
    * {@inheritdoc}
    */
-  public function rollback(string $name): void {
+  public function rollback(string $name, string $id): void {
+    // @todo remove in drupal:11.0.0.
+    // Start of BC layer.
+    if ($id === 'bc-force-rollback') {
+      foreach ($this->stack() as $stackId => $item) {
+        if ($item->name === $name) {
+          $id = $stackId;
+          break;
+        }
+      }
+      if ($id === 'bc-force-rollback') {
+        throw new TransactionOutOfOrderException();
+      }
+    }
+    // End of BC layer.
+
+    // 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), so there is nothing more
+    // to do.
+    if (!isset($this->stack()[$id]) || $this->stack()[$id]->name !== $name) {
+      return;
+    }
+
     if (!$this->inTransaction()) {
       throw new TransactionNoActiveException();
     }
 
     // Do the client-level processing.
-    match ($this->stack()[$name]) {
+    match ($this->stack()[$id]->type) {
       StackItemType::Root => $this->processRootRollback(),
       StackItemType::Savepoint => $this->rollbackClientSavepoint($name),
     };
 
     // Rolled back item should match the last one in stack.
-    if ($name !== array_key_last($this->stack())) {
+    if ($id != array_key_last($this->stack())) {
       throw new TransactionOutOfOrderException();
     }
 
-    $this->rollbacks[$name] = TRUE;
-    $this->removeStackItem($name);
+    // Remove the transaction from the stack.
+    $this->removeStackItem($id);
 
     // If this was the last Drupal transaction open, we can commit the client
     // transaction.
@@ -263,7 +309,12 @@ public function addPostTransactionCallback(callable $callback): void {
    * {@inheritdoc}
    */
   public function has(string $name): bool {
-    return isset($this->stack()[$name]);
+    foreach ($this->stack() as $item) {
+      if ($item->name === $name) {
+        return TRUE;
+      }
+    }
+    return FALSE;
   }
 
   /**
diff --git a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php
index 86d5de4ac5ad..1bd0cd3e5fde 100644
--- a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php
+++ b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php
@@ -36,6 +36,8 @@ public function has(string $name): bool;
    * This begins a client connection transaction if there is not one active,
    * or adds a savepoint to the active one.
    *
+   * This method should only be called internally by a database driver.
+   *
    * @param string $name
    *   (optional) The name of the savepoint.
    *
@@ -54,15 +56,19 @@ public function push(string $name = ''): Transaction;
    * This method should only be called by a Transaction object going out of
    * scope.
    *
+   * This method should only be called internally by a database driver.
+   *
    * @param string $name
-   *   (optional) The name of the savepoint.
+   *   The name of the transaction.
+   * @param string $id
+   *   The id of the transaction.
    *
    * @throws \Drupal\Core\Database\TransactionOutOfOrderException
    *   If a Drupal Transaction with the specified name does not exist.
    * @throws \Drupal\Core\Database\TransactionCommitFailedException
    *   If the commit of the root transaction failed.
    */
-  public function unpile(string $name): void;
+  public function unpile(string $name, string $id): void;
 
   /**
    * Rolls back a Drupal transaction.
@@ -72,8 +78,12 @@ public function unpile(string $name): void;
    * to rolling back the client connection (or to committing it in the edge
    * case when the root was unpiled earlier).
    *
+   * This method should only be called internally by a database driver.
+   *
    * @param string $name
-   *   (optional) The name of the savepoint.
+   *   The name of the transaction.
+   * @param string $id
+   *   The id of the transaction.
    *
    * @throws \Drupal\Core\Database\TransactionNoActiveException
    *   If there is no active client connection.
@@ -83,7 +93,7 @@ public function unpile(string $name): void;
    * @throws \Drupal\Core\Database\TransactionCommitFailedException
    *   If the commit of the root transaction failed.
    */
-  public function rollback(string $name): void;
+  public function rollback(string $name, string $id): void;
 
   /**
    * Adds a root transaction end callback.
diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt
index 0d890fb02bed..5e3d3a80ccc7 100644
--- a/core/misc/cspell/dictionary.txt
+++ b/core/misc/cspell/dictionary.txt
@@ -306,6 +306,7 @@ desaturate
 desaturated
 desaturates
 desaturating
+descoping
 descripcion
 destructable
 deutsch
diff --git a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php
index 18c2efcb0ca3..b0e9fc3e9b6a 100644
--- a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php
+++ b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php
@@ -576,6 +576,30 @@ public function testQueryFailureInTransaction() {
     $this->assertEquals('24', $saved_age);
   }
 
+  /**
+   * Tests releasing a savepoint before last is safe.
+   */
+  public function testReleaseIntermediateSavepoint(): void {
+    $transaction = $this->connection->startTransaction();
+    $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+    $savepoint1 = $this->connection->startTransaction();
+    $this->assertSame(2, $this->connection->transactionManager()->stackDepth());
+    $savepoint2 = $this->connection->startTransaction();
+    $this->assertSame(3, $this->connection->transactionManager()->stackDepth());
+    $savepoint3 = $this->connection->startTransaction();
+    $this->assertSame(4, $this->connection->transactionManager()->stackDepth());
+    $savepoint4 = $this->connection->startTransaction();
+    $this->assertSame(5, $this->connection->transactionManager()->stackDepth());
+    $this->insertRow('row');
+    unset($savepoint2);
+    $this->assertSame(2, $this->connection->transactionManager()->stackDepth());
+    $this->assertRowPresent('row');
+    unset($savepoint1);
+    unset($transaction);
+    $this->assertFalse($this->connection->inTransaction());
+    $this->assertRowPresent('row');
+  }
+
   /**
    * Tests for transaction names.
    */
-- 
GitLab