From a734683ea091fedc9356aee7b12aa8d452d18522 Mon Sep 17 00:00:00 2001 From: Steve Boyd Date: Fri, 13 Sep 2024 18:02:32 +1200 Subject: [PATCH] NEW Allow database read-only replicas --- cli-script.php | 3 + src/Core/CoreKernel.php | 122 ++++++++++++++++++++++++++++++---------- src/ORM/DB.php | 78 ++++++++++++++++++++++++- 3 files changed, 171 insertions(+), 32 deletions(-) diff --git a/cli-script.php b/cli-script.php index 879b2de6546..74092dab1ca 100755 --- a/cli-script.php +++ b/cli-script.php @@ -16,6 +16,9 @@ die(); } +// CLI scripts must only use the primary database connection and not replicas +DB::setMustUsePrimary(true); + // Build request and detect flush $request = CLIRequestBuilder::createFromEnvironment(); diff --git a/src/Core/CoreKernel.php b/src/Core/CoreKernel.php index 7f968c8a3f4..f0634c70388 100644 --- a/src/Core/CoreKernel.php +++ b/src/Core/CoreKernel.php @@ -66,31 +66,40 @@ protected function bootDatabaseGlobals() global $databaseConfig; global $database; - // Case 1: $databaseConfig global exists. Merge $database in as needed - if (!empty($databaseConfig)) { - if (!empty($database)) { - $databaseConfig['database'] = $this->getDatabasePrefix() . $database . $this->getDatabaseSuffix(); + for ($i = 0; $i <= 99; $i++) { + if ($i === 0) { + $key = 'default'; + } else { + $key = DB::getReplicaConfigKey($i); + if (!DB::hasConfig($key)) { + break; + } } - // Only set it if its valid, otherwise ignore $databaseConfig entirely - if (!empty($databaseConfig['database'])) { - DB::setConfig($databaseConfig); - - return; + // Case 1: $databaseConfig global exists. Merge $database in as needed + if (!empty($databaseConfig)) { + if (!empty($database)) { + $databaseConfig['database'] = $this->getDatabasePrefix() . $database . $this->getDatabaseSuffix(); + } + + // Only set it if its valid, otherwise ignore $databaseConfig entirely + if (!empty($databaseConfig['database'])) { + DB::setConfig($databaseConfig, $key); + return; + } } - } - - // Case 2: $database merged into existing config - if (!empty($database)) { - $existing = DB::getConfig(); - $existing['database'] = $this->getDatabasePrefix() . $database . $this->getDatabaseSuffix(); - DB::setConfig($existing); + // Case 2: $database merged into existing config + if (!empty($database)) { + $existing = DB::getConfig($key); + $existing['database'] = $this->getDatabasePrefix() . $database . $this->getDatabaseSuffix(); + DB::setConfig($existing, $key); + } } } /** - * Load default database configuration from environment variable + * Load default database configuration from environment variables */ protected function bootDatabaseEnvVars() { @@ -98,20 +107,58 @@ protected function bootDatabaseEnvVars() $databaseConfig = $this->getDatabaseConfig(); $databaseConfig['database'] = $this->getDatabaseName(); DB::setConfig($databaseConfig); + + // Set database replicas config + for ($i = 1; $i <= 99; $i++) { + $envKey = $this->getReplicaEnvKey('SS_DATABASE_SERVER', $i); + if (!Environment::hasEnv($envKey)) { + break; + } + $replicaDatabaseConfig = $this->getDatabaseReplicaConfig($i); + $configKey = DB::getReplicaConfigKey($i); + DB::setConfig($replicaDatabaseConfig, $configKey); + } } /** - * Load database config from environment + * Convert a database key to a replica key + * e.g. SS_DATABASE_SERVER -> SS_DATABASE_SERVER_REPLICA_01 + */ + private function getReplicaEnvKey(string $key, int $replica): string + { + // Left pad replica number with a zero if it's less than 10 + return $key . '_REPLICA_' . str_pad($replica, 2, '0', STR_PAD_LEFT); + } + + /** + * Reads a single database configuration variable from the environment + * For replica databases, it will first attempt to find replica-specific configuration + * before falling back to the default configuration. * - * @return array + * Replicate specific configuration has `_REPLICA_01` appended to the key + * where 01 is the replica number. */ - protected function getDatabaseConfig() + private function getDatabaseConfigVariable(string $key, int $replica): string + { + if ($replica > 0) { + $replicaKey = $this->getReplicaEnvKey($key, $replica); + if (Environment::hasEnv($replicaKey)) { + return Environment::getEnv($replicaKey); + } + } + if (Environment::hasEnv($key)) { + return Environment::getEnv($key); + } + return ''; + } + + private function getSingleDataBaseConfig(int $replica): array { $databaseConfig = [ - "type" => Environment::getEnv('SS_DATABASE_CLASS') ?: 'MySQLDatabase', - "server" => Environment::getEnv('SS_DATABASE_SERVER') ?: 'localhost', - "username" => Environment::getEnv('SS_DATABASE_USERNAME') ?: null, - "password" => Environment::getEnv('SS_DATABASE_PASSWORD') ?: null, + "type" => $this->getDatabaseConfigVariable('SS_DATABASE_CLASS', $replica) ?: 'MySQLDatabase', + "server" => $this->getDatabaseConfigVariable('SS_DATABASE_SERVER', $replica) ?: 'localhost', + "username" => $this->getDatabaseConfigVariable('SS_DATABASE_USERNAME', $replica) ?: null, + "password" => $this->getDatabaseConfigVariable('SS_DATABASE_PASSWORD', $replica) ?: null, ]; // Only add SSL keys in the array if there is an actual value associated with them @@ -122,7 +169,7 @@ protected function getDatabaseConfig() 'ssl_cipher' => 'SS_DATABASE_SSL_CIPHER', ]; foreach ($sslConf as $key => $envVar) { - $envValue = Environment::getEnv($envVar); + $envValue = $this->getDatabaseConfigVariable($envVar, $replica); if ($envValue) { $databaseConfig[$key] = $envValue; } @@ -138,25 +185,25 @@ protected function getDatabaseConfig() } // Set the port if called for - $dbPort = Environment::getEnv('SS_DATABASE_PORT'); + $dbPort = $this->getDatabaseConfigVariable('SS_DATABASE_PORT', $replica); if ($dbPort) { $databaseConfig['port'] = $dbPort; } // Set the timezone if called for - $dbTZ = Environment::getEnv('SS_DATABASE_TIMEZONE'); + $dbTZ = $this->getDatabaseConfigVariable('SS_DATABASE_TIMEZONE', $replica); if ($dbTZ) { $databaseConfig['timezone'] = $dbTZ; } // For schema enabled drivers: - $dbSchema = Environment::getEnv('SS_DATABASE_SCHEMA'); + $dbSchema = $this->getDatabaseConfigVariable('SS_DATABASE_SCHEMA', $replica); if ($dbSchema) { $databaseConfig["schema"] = $dbSchema; } // For SQlite3 memory databases (mainly for testing purposes) - $dbMemory = Environment::getEnv('SS_DATABASE_MEMORY'); + $dbMemory = $this->getDatabaseConfigVariable('SS_DATABASE_MEMORY', $replica); if ($dbMemory) { $databaseConfig["memory"] = $dbMemory; } @@ -166,6 +213,22 @@ protected function getDatabaseConfig() return $databaseConfig; } + /** + * Load database config from environment + * + * @return array + */ + protected function getDatabaseConfig() + { + return $this->getSingleDataBaseConfig(0); + } + + protected function getDatabaseReplicaConfig(int $replica) + { + $replica = 1; + return $this->getSingleDataBaseConfig($replica); + } + /** * @return string */ @@ -184,6 +247,7 @@ protected function getDatabaseSuffix() /** * Get name of database + * Note that any replicas must have the same database name * * @return string */ diff --git a/src/ORM/DB.php b/src/ORM/DB.php index 054b858d02f..c3f093064ed 100644 --- a/src/ORM/DB.php +++ b/src/ORM/DB.php @@ -311,11 +311,33 @@ public static function setConfig($databaseConfig, $name = 'default') */ public static function getConfig($name = 'default') { - if (isset(static::$configs[$name])) { + if (static::hasConfig($name)) { return static::$configs[$name]; } } + /** + * Check if a named connection config exists + */ + public static function hasConfig($name = 'default'): bool + { + return isset(static::$configs[$name]); + } + + /** + * Get a replica database configuration key + * e.g. replica_01 + */ + public static function getReplicaConfigKey(int $replica): string + { + return 'replica_' . str_pad($replica, 2, '0', STR_PAD_LEFT); + } + + public static function hasReplicas(): bool + { + return array_key_exists('replica_01', static::$configs); + } + /** * Returns true if a database connection has been attempted. * In particular, it lets the caller know if we're still so early in the execution pipeline that @@ -336,7 +358,57 @@ public static function query($sql, $errorLevel = E_USER_ERROR) { DB::$lastQuery = $sql; - return DB::get_conn()->query($sql, $errorLevel); + return DB::getDatabaseConnection($sql)->query($sql, $errorLevel); + } + + /** + * Only use the primary database connection for the current request + * + * @internal + */ + private static bool $mustUsePrimary = false; + + public static function setMustUsePrimary(bool $mustUsePrimary): void + { + DB::$mustUsePrimary = $mustUsePrimary; + } + + private static function getDatabaseConnection(string $sql): Database + { + // Use a replica if one exists + // unless: + // - there are no replicas + // - the current sql is a write operation + // - a write operation has already happened in this request - $mustUsePrimary is set to true + // - we are running a CLI script + // - ~~current url is /admin (maybe, unsure, todo)~~ + $key = 'default'; + if (!DB::$mustUsePrimary && DB::hasReplicas()) { + $primaryDatabase = DB::get_conn('default'); + $primaryConnector = $primaryDatabase->getConnector(); + if ($primaryConnector->isQueryMutable($sql)) { + DB::$mustUsePrimary = true; + } else { + $key = static::getRandomReplicaConfigKey(); + } + } + return DB::get_conn($key); + } + + private static function getRandomReplicaConfigKey(): string + { + $replicaCount = 0; + for ($i = 1; $i < 99; $i++) { + $replicaKey = DB::getReplicaConfigKey($i); + if (DB::hasConfig($replicaKey)) { + $replicaCount++; + } else { + break; + } + } + // Choose a random replica + $replicaNumber = rand(1, $replicaCount); + return DB::getReplicaConfigKey($replicaNumber); } /** @@ -428,7 +500,7 @@ public static function prepared_query($sql, $parameters, $errorLevel = E_USER_ER { DB::$lastQuery = $sql; - return DB::get_conn()->preparedQuery($sql, $parameters, $errorLevel); + return DB::getDatabaseConnection($sql)->preparedQuery($sql, $parameters, $errorLevel); } /**