Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NEW Allow database read-only replicas #11379

Merged
merged 1 commit into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bin/sake
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<?php

use SilverStripe\Cli\Sake;
use SilverStripe\ORM\DB;

// Ensure that people can't access this from a web-server
if (!in_array(PHP_SAPI, ['cli', 'cgi', 'cgi-fcgi'])) {
Expand All @@ -11,5 +12,8 @@ if (!in_array(PHP_SAPI, ['cli', 'cgi', 'cgi-fcgi'])) {

require_once __DIR__ . '/../src/includes/autoload.php';

// CLI scripts must only use the primary database connection and not replicas
DB::setMustUsePrimary();

$sake = new Sake();
$sake->run();
21 changes: 21 additions & 0 deletions src/Control/Director.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use SilverStripe\View\Requirements;
use SilverStripe\View\Requirements_Backend;
use SilverStripe\View\TemplateGlobalProvider;
use SilverStripe\ORM\DB;

/**
* Director is responsible for processing URLs, and providing environment information.
Expand Down Expand Up @@ -84,6 +85,14 @@ class Director implements TemplateGlobalProvider
*/
private static $default_base_url = '`SS_BASE_URL`';

/**
* List of routing rule patterns that must only use the primary database and not a replica
*/
private static array $rule_patterns_must_use_primary_db = [
'dev',
'Security',
];

public function __construct()
{
}
Expand Down Expand Up @@ -296,6 +305,18 @@ public function handleRequest(HTTPRequest $request)
{
Injector::inst()->registerService($request, HTTPRequest::class);

// Check if primary database must be used based on request rules
// Note this check must happend before the rules are processed as
// $shiftOnSuccess param is passed as true in `$request->match($pattern, true)` later on in
// this method, which modifies `$this->dirParts`, thus affecting `$request->match($rule)` directly below
$primaryDbOnlyRules = Director::config()->uninherited('rule_patterns_must_use_primary_db');
foreach ($primaryDbOnlyRules as $rule) {
if ($request->match($rule)) {
DB::setMustUsePrimary();
break;
}
}

$rules = Director::config()->uninherited('rules');

$this->extend('updateRules', $rules);
Expand Down
2 changes: 1 addition & 1 deletion src/Core/ClassInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public static function exists($class)
public static function hasTable($tableName)
{
$cache = ClassInfo::getCache();
$configData = serialize(DB::getConfig());
$configData = serialize(DB::getConfig(DB::CONN_PRIMARY));
$cacheKey = 'tableList_' . md5($configData);
$tableList = $cache->get($cacheKey) ?? [];
if (empty($tableList) && DB::is_active()) {
Expand Down
140 changes: 112 additions & 28 deletions src/Core/CoreKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
use SilverStripe\ORM\Connect\NullDatabase;
use SilverStripe\ORM\DB;
use Exception;
use InvalidArgumentException;

/**
* Simple Kernel container
*/
class CoreKernel extends BaseKernel
{

protected bool $bootDatabase = true;

/**
Expand All @@ -38,7 +40,7 @@ public function boot($flush = false)
$this->flush = $flush;

if (!$this->bootDatabase) {
DB::set_conn(new NullDatabase());
DB::set_conn(new NullDatabase(), DB::CONN_PRIMARY);
}

$this->bootPHP();
Expand Down Expand Up @@ -73,7 +75,7 @@ protected function validateDatabase()
}

/**
* Load default database configuration from the $database and $databaseConfig globals
* Load database configuration from the $database and $databaseConfig globals
*/
protected function bootDatabaseGlobals()
{
Expand All @@ -84,41 +86,62 @@ 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();
// Ensure global database config has prefix and suffix applied
if (!empty($databaseConfig) && !empty($database)) {
$databaseConfig['database'] = $this->getDatabasePrefix() . $database . $this->getDatabaseSuffix();
}

// Set config for primary and any replicas
for ($i = 0; $i <= DB::MAX_REPLICAS; $i++) {
if ($i === 0) {
$name = DB::CONN_PRIMARY;
} else {
$name = DB::getReplicaConfigKey($i);
if (!DB::hasConfig($name)) {
break;
}
}

// Case 1: $databaseConfig global exists
// Only set it if its valid, otherwise ignore $databaseConfig entirely
if (!empty($databaseConfig['database'])) {
DB::setConfig($databaseConfig);

if (!empty($databaseConfig) && !empty($databaseConfig['database'])) {
DB::setConfig($databaseConfig, $name);
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: $databaseConfig global does not exist
// Merge $database global into existing config
if (!empty($database)) {
$dbConfig = DB::getConfig($name);
$dbConfig['database'] = $this->getDatabasePrefix() . $database . $this->getDatabaseSuffix();
DB::setConfig($dbConfig, $name);
}
}
}

/**
* Load default database configuration from environment variable
* Load database configuration from environment variables
*/
protected function bootDatabaseEnvVars()
{
if (!$this->bootDatabase) {
return;
}
// Set default database config
// Set primary database config
$databaseConfig = $this->getDatabaseConfig();
$databaseConfig['database'] = $this->getDatabaseName();
DB::setConfig($databaseConfig);
DB::setConfig($databaseConfig, DB::CONN_PRIMARY);

GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
// Set database replicas config
for ($i = 1; $i <= DB::MAX_REPLICAS; $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);
}
}

/**
Expand All @@ -127,12 +150,72 @@ protected function bootDatabaseEnvVars()
* @return array
*/
protected function getDatabaseConfig()
{
return $this->getSingleDataBaseConfig(0);
}

private function getDatabaseReplicaConfig(int $replica)
{
if ($replica <= 0) {
throw new InvalidArgumentException('Replica number must be greater than 0');
}
return $this->getSingleDataBaseConfig($replica);
}

/**
* Convert a database key to a replica key
* e.g. SS_DATABASE_SERVER -> SS_DATABASE_SERVER_REPLICA_01
*
* @param string $key - The key to look up in the environment
* @param int $replica - Replica number
*/
private function getReplicaEnvKey(string $key, int $replica): string
{
if ($replica <= 0) {
throw new InvalidArgumentException('Replica number must be greater than 0');
}
// Do not allow replicas to define keys that could lead to unexpected behaviour if
// they do not match the primary database configuration
if (in_array($key, ['SS_DATABASE_CLASS', 'SS_DATABASE_NAME', 'SS_DATABASE_CHOOSE_NAME'])) {
return $key;
}
// Left pad replica number with a zeros to match the length of the maximum replica number
$len = strlen((string) DB::MAX_REPLICAS);
return $key . '_REPLICA_' . str_pad($replica, $len, '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 primary configuration.
*
* Replicate specific configuration has `_REPLICA_01` appended to the key
* where 01 is the replica number.
*
* @param string $key - The key to look up in the environment
* @param int $replica - Replica number. Passing 0 will return the primary database configuration
*/
private function getDatabaseConfigVariable(string $key, int $replica): string
{
if ($replica > 0) {
$key = $this->getReplicaEnvKey($key, $replica);
}
if (Environment::hasEnv($key)) {
return Environment::getEnv($key);
}
return '';
}

/**
* @param int $replica - Replica number. Passing 0 will return the primary database configuration
*/
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
Expand All @@ -143,7 +226,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;
}
Expand All @@ -159,25 +242,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;
}
Expand Down Expand Up @@ -205,6 +288,7 @@ protected function getDatabaseSuffix()

/**
* Get name of database
* Note that any replicas must have the same database name as the primary database
*
* @return string
*/
Expand Down
5 changes: 4 additions & 1 deletion src/Dev/SapphireTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
use SilverStripe\Dev\Exceptions\ExpectedErrorException;
use SilverStripe\Dev\Exceptions\ExpectedNoticeException;
use SilverStripe\Dev\Exceptions\ExpectedWarningException;
use SilverStripe\Dev\Exceptions\UnexpectedErrorException;
use SilverStripe\ORM\DB;

/**
* Test case class for the Silverstripe framework.
Expand Down Expand Up @@ -434,6 +434,9 @@ protected function currentTestDisablesDatabase()
*/
public static function setUpBeforeClass(): void
{
// Disallow the use of DB replicas in tests
DB::setMustUsePrimary();

// Start tests
static::start();

Expand Down
2 changes: 1 addition & 1 deletion src/ORM/Connect/TempDatabase.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class TempDatabase
*
* @param string $name DB Connection name to use
*/
public function __construct($name = 'default')
public function __construct($name = DB::CONN_PRIMARY)
{
$this->name = $name;
}
Expand Down
Loading
Loading