From da2e2ce2ae859df89b9cc99cc98df4832caef4f6 Mon Sep 17 00:00:00 2001
From: Alex Pott <alex.a.pott@googlemail.com>
Date: Thu, 21 Mar 2024 08:15:21 +0000
Subject: [PATCH] Issue #3428565 by catch, phenaproxima, alexpott: Implement
 lazy database creation for sessions

---
 .../Core/Installer/InstallerRedirectTrait.php |   2 +-
 .../Drupal/Core/Session/SessionHandler.php    | 148 ++++++++++++++++--
 .../Drupal/Core/Session/SessionManager.php    |  11 +-
 .../tests/src/Kernel/mysql/DbDumpTest.php     |   4 +-
 core/modules/system/system.install            |  51 ------
 .../Installer/InstallerRedirectTraitTest.php  |   8 +-
 6 files changed, 148 insertions(+), 76 deletions(-)

diff --git a/core/lib/Drupal/Core/Installer/InstallerRedirectTrait.php b/core/lib/Drupal/Core/Installer/InstallerRedirectTrait.php
index 4dbb485a93a3..45ee1cdd5070 100644
--- a/core/lib/Drupal/Core/Installer/InstallerRedirectTrait.php
+++ b/core/lib/Drupal/Core/Installer/InstallerRedirectTrait.php
@@ -73,7 +73,7 @@ protected function shouldRedirectToInstaller(\Throwable $exception, Connection $
     // Redirect if the database is empty.
     if ($connection) {
       try {
-        return !$connection->schema()->tableExists('sessions');
+        return !$connection->schema()->tableExists('sequences');
       }
       catch (\Exception $e) {
         // If we still have an exception at this point, we need to be careful
diff --git a/core/lib/Drupal/Core/Session/SessionHandler.php b/core/lib/Drupal/Core/Session/SessionHandler.php
index e243c425c6db..f6fe3b28f58e 100644
--- a/core/lib/Drupal/Core/Session/SessionHandler.php
+++ b/core/lib/Drupal/Core/Session/SessionHandler.php
@@ -5,6 +5,7 @@
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Component\Utility\Crypt;
 use Drupal\Core\Database\Connection;
+use Drupal\Core\Database\DatabaseException;
 use Drupal\Core\DependencyInjection\DependencySerializationTrait;
 use Symfony\Component\HttpFoundation\RequestStack;
 use Symfony\Component\HttpFoundation\Session\Storage\Proxy\AbstractProxy;
@@ -62,10 +63,15 @@ public function open(string $save_path, string $name): bool {
   public function read(#[\SensitiveParameter] string $sid): string|false {
     $data = '';
     if (!empty($sid)) {
-      // Read the session data from the database.
-      $query = $this->connection
-        ->queryRange('SELECT [session] FROM {sessions} WHERE [sid] = :sid', 0, 1, [':sid' => Crypt::hashBase64($sid)]);
-      $data = (string) $query->fetchField();
+      try {
+        // Read the session data from the database.
+        $query = $this->connection
+          ->queryRange('SELECT [session] FROM {sessions} WHERE [sid] = :sid', 0, 1, [':sid' => Crypt::hashBase64($sid)]);
+        $data = (string) $query->fetchField();
+      }
+      // Swallow the error if the table hasn't been created yet.
+      catch (\Exception) {
+      }
     }
     return $data;
   }
@@ -74,6 +80,7 @@ public function read(#[\SensitiveParameter] string $sid): string|false {
    * {@inheritdoc}
    */
   public function write(#[\SensitiveParameter] string $sid, string $value): bool {
+    $try_again = FALSE;
     $request = $this->requestStack->getCurrentRequest();
     $fields = [
       'uid' => $request->getSession()->get('uid', 0),
@@ -81,10 +88,27 @@ public function write(#[\SensitiveParameter] string $sid, string $value): bool {
       'session' => $value,
       'timestamp' => $this->time->getRequestTime(),
     ];
-    $this->connection->merge('sessions')
-      ->keys(['sid' => Crypt::hashBase64($sid)])
-      ->fields($fields)
-      ->execute();
+    $doWrite = fn() =>
+      $this->connection->merge('sessions')
+        ->keys(['sid' => Crypt::hashBase64($sid)])
+        ->fields($fields)
+        ->execute();
+    try {
+      $doWrite();
+    }
+    catch (\Exception $e) {
+      // If there was an exception, try to create the table.
+      if (!$try_again = $this->ensureTableExists()) {
+        // If the exception happened for other reason than the missing
+        // table, propagate the exception.
+        throw $e;
+      }
+    }
+    // Now that the bin has been created, try again if necessary.
+    if ($try_again) {
+      $doWrite();
+    }
+
     return TRUE;
   }
 
@@ -99,10 +123,15 @@ public function close(): bool {
    * {@inheritdoc}
    */
   public function destroy(#[\SensitiveParameter] string $sid): bool {
-    // Delete session data.
-    $this->connection->delete('sessions')
-      ->condition('sid', Crypt::hashBase64($sid))
-      ->execute();
+    try {
+      // Delete session data.
+      $this->connection->delete('sessions')
+        ->condition('sid', Crypt::hashBase64($sid))
+        ->execute();
+    }
+    // Swallow the error if the table hasn't been created yet.
+    catch (\Exception) {
+    }
 
     return TRUE;
   }
@@ -116,9 +145,98 @@ public function gc(int $lifetime): int|false {
     // for three weeks before deleting them, you need to set gc_maxlifetime
     // to '1814400'. At that value, only after a user doesn't log in after
     // three weeks (1814400 seconds) will their session be removed.
-    return $this->connection->delete('sessions')
-      ->condition('timestamp', $this->time->getRequestTime() - $lifetime, '<')
-      ->execute();
+    try {
+      return $this->connection->delete('sessions')
+        ->condition('timestamp', $this->time->getRequestTime() - $lifetime, '<')
+        ->execute();
+    }
+    // Swallow the error if the table hasn't been created yet.
+    catch (\Exception) {
+    }
+    return FALSE;
+  }
+
+  /**
+   * Defines the schema for the session table.
+   *
+   * @internal
+   */
+  protected function schemaDefinition(): array {
+    $schema = [
+      'description' => "Drupal's session handlers read and write into the sessions table. Each record represents a user session, either anonymous or authenticated.",
+      'fields' => [
+        'uid' => [
+          'description' => 'The {users}.uid corresponding to a session, or 0 for anonymous user.',
+          'type' => 'int',
+          'unsigned' => TRUE,
+          'not null' => TRUE,
+        ],
+        'sid' => [
+          'description' => "A session ID (hashed). The value is generated by Drupal's session handlers.",
+          'type' => 'varchar_ascii',
+          'length' => 128,
+          'not null' => TRUE,
+        ],
+        'hostname' => [
+          'description' => 'The IP address that last used this session ID (sid).',
+          'type' => 'varchar_ascii',
+          'length' => 128,
+          'not null' => TRUE,
+          'default' => '',
+        ],
+        'timestamp' => [
+          'description' => 'The Unix timestamp when this session last requested a page. Old records are purged by PHP automatically.',
+          'type' => 'int',
+          'not null' => TRUE,
+          'default' => 0,
+          'size' => 'big',
+        ],
+        'session' => [
+          'description' => 'The serialized contents of the user\'s session, an array of name/value pairs that persists across page requests by this session ID. Drupal loads the user\'s session from here   at the start of each request and saves it at the end.',
+          'type' => 'blob',
+          'not null' => FALSE,
+          'size' => 'big',
+        ],
+      ],
+      'primary key' => [
+        'sid',
+      ],
+      'indexes' => [
+        'timestamp' => ['timestamp'],
+        'uid' => ['uid'],
+      ],
+      'foreign keys' => [
+        'session_user' => [
+          'table' => 'users',
+          'columns' => ['uid' => 'uid'],
+        ],
+      ],
+    ];
+
+    return $schema;
+  }
+
+  /**
+   * Check if the session table exists and create it if not.
+   *
+   * @return bool
+   *   TRUE if the table already exists or was created, FALSE if creation fails.
+   */
+  protected function ensureTableExists(): bool {
+    try {
+      $database_schema = $this->connection->schema();
+      $schema_definition = $this->schemaDefinition();
+      $database_schema->createTable('sessions', $schema_definition);
+    }
+    // If another process has already created the table, attempting to create
+    // it will throw an exception. In this case just catch the exception and do
+    // nothing.
+    catch (DatabaseException $e) {
+    }
+    catch (\Exception $e) {
+      return FALSE;
+    }
+    return TRUE;
   }
 
 }
diff --git a/core/lib/Drupal/Core/Session/SessionManager.php b/core/lib/Drupal/Core/Session/SessionManager.php
index cf794ebe4b6b..f43db06b29bf 100644
--- a/core/lib/Drupal/Core/Session/SessionManager.php
+++ b/core/lib/Drupal/Core/Session/SessionManager.php
@@ -234,9 +234,14 @@ public function delete($uid) {
     if (!$this->writeSafeHandler->isSessionWritable() || $this->isCli()) {
       return;
     }
-    $this->connection->delete('sessions')
-      ->condition('uid', $uid)
-      ->execute();
+    // The sessions table may not have been created yet.
+    try {
+      $this->connection->delete('sessions')
+        ->condition('uid', $uid)
+        ->execute();
+    }
+    catch (\Exception) {
+    }
   }
 
   /**
diff --git a/core/modules/mysql/tests/src/Kernel/mysql/DbDumpTest.php b/core/modules/mysql/tests/src/Kernel/mysql/DbDumpTest.php
index bc509a13b208..787095110f73 100644
--- a/core/modules/mysql/tests/src/Kernel/mysql/DbDumpTest.php
+++ b/core/modules/mysql/tests/src/Kernel/mysql/DbDumpTest.php
@@ -26,6 +26,8 @@ class DbDumpTest extends DriverSpecificKernelTestBase {
    * {@inheritdoc}
    */
   protected static $modules = [
+    // @todo system can be removed from this test once
+    //   https://www.drupal.org/project/drupal/issues/2851705 is committed.
     'system',
     'config',
     'dblog',
@@ -87,7 +89,6 @@ protected function setUp(): void {
     parent::setUp();
 
     // Create some schemas so our export contains tables.
-    $this->installSchema('system', ['sessions']);
     $this->installSchema('dblog', ['watchdog']);
     $this->installEntitySchema('block_content');
     $this->installEntitySchema('user');
@@ -131,7 +132,6 @@ protected function setUp(): void {
       'menu_link_content_data',
       'menu_link_content_revision',
       'menu_link_content_field_revision',
-      'sessions',
       'path_alias',
       'path_alias_revision',
       'user__roles',
diff --git a/core/modules/system/system.install b/core/modules/system/system.install
index 7474ae9846cf..09ea1af714f8 100644
--- a/core/modules/system/system.install
+++ b/core/modules/system/system.install
@@ -1621,57 +1621,6 @@ function system_schema() {
     'primary key' => ['value'],
   ];
 
-  $schema['sessions'] = [
-    'description' => "Drupal's session handlers read and write into the sessions table. Each record represents a user session, either anonymous or authenticated.",
-    'fields' => [
-      'uid' => [
-        'description' => 'The {users}.uid corresponding to a session, or 0 for anonymous user.',
-        'type' => 'int',
-        'unsigned' => TRUE,
-        'not null' => TRUE,
-      ],
-      'sid' => [
-        'description' => "A session ID (hashed). The value is generated by Drupal's session handlers.",
-        'type' => 'varchar_ascii',
-        'length' => 128,
-        'not null' => TRUE,
-      ],
-      'hostname' => [
-        'description' => 'The IP address that last used this session ID (sid).',
-        'type' => 'varchar_ascii',
-        'length' => 128,
-        'not null' => TRUE,
-        'default' => '',
-      ],
-      'timestamp' => [
-        'description' => 'The Unix timestamp when this session last requested a page. Old records are purged by PHP automatically.',
-        'type' => 'int',
-        'not null' => TRUE,
-        'default' => 0,
-        'size' => 'big',
-      ],
-      'session' => [
-        'description' => 'The serialized contents of the user\'s session, an array of name/value pairs that persists across page requests by this session ID. Drupal loads the user\'s session from here at the start of each request and saves it at the end.',
-        'type' => 'blob',
-        'not null' => FALSE,
-        'size' => 'big',
-      ],
-    ],
-    'primary key' => [
-      'sid',
-    ],
-    'indexes' => [
-      'timestamp' => ['timestamp'],
-      'uid' => ['uid'],
-    ],
-    'foreign keys' => [
-      'session_user' => [
-        'table' => 'users',
-        'columns' => ['uid' => 'uid'],
-      ],
-    ],
-  ];
-
   return $schema;
 }
 
diff --git a/core/tests/Drupal/KernelTests/Core/Installer/InstallerRedirectTraitTest.php b/core/tests/Drupal/KernelTests/Core/Installer/InstallerRedirectTraitTest.php
index 41b2e770e307..b26b69147fb8 100644
--- a/core/tests/Drupal/KernelTests/Core/Installer/InstallerRedirectTraitTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Installer/InstallerRedirectTraitTest.php
@@ -27,7 +27,7 @@ class InstallerRedirectTraitTest extends KernelTestBase {
    *   - Exceptions to be handled by shouldRedirectToInstaller()
    *   - Whether or not there is a database connection.
    *   - Whether or not there is database connection info.
-   *   - Whether or not there exists a sessions table in the database.
+   *   - Whether or not there exists a sequences table in the database.
    */
   public static function providerShouldRedirectToInstaller() {
     return [
@@ -67,7 +67,7 @@ public static function providerShouldRedirectToInstaller() {
    * @covers ::shouldRedirectToInstaller
    * @dataProvider providerShouldRedirectToInstaller
    */
-  public function testShouldRedirectToInstaller($expected, $exception, $connection, $connection_info, $session_table_exists = TRUE) {
+  public function testShouldRedirectToInstaller($expected, $exception, $connection, $connection_info, $sequences_table_exists = TRUE) {
     try {
       throw new $exception();
     }
@@ -106,8 +106,8 @@ public function testShouldRedirectToInstaller($expected, $exception, $connection
 
           $schema->expects($this->any())
             ->method('tableExists')
-            ->with('sessions')
-            ->willReturn($session_table_exists);
+            ->with('sequences')
+            ->willReturn($sequences_table_exists);
 
           $connection->expects($this->any())
             ->method('schema')
-- 
GitLab