Skip to content

Commit

Permalink
NEW Allow database read-only replicas
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Sep 18, 2024
1 parent c7ba8d1 commit 2d388e9
Show file tree
Hide file tree
Showing 22 changed files with 712 additions and 78 deletions.
3 changes: 3 additions & 0 deletions cli-script.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
die();
}

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

// Build request and detect flush
$request = CLIRequestBuilder::createFromEnvironment();

Expand Down
20 changes: 20 additions & 0 deletions src/Control/Director.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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 @@ -83,6 +84,14 @@ class Director implements TemplateGlobalProvider
*/
private static $default_base_url = '`SS_BASE_URL`';

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

public function __construct()
{
}
Expand Down Expand Up @@ -295,6 +304,17 @@ 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 is true during $request->match() below
$primaryDbOnlyRules = Director::config()->uninherited('must_use_primary_db_rules');
foreach ($primaryDbOnlyRules as $rule) {
if ($request->match($rule)) {
DB::setMustUsePrimary();
break;
}
}

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

$this->extend('updateRules', $rules);
Expand Down
117 changes: 91 additions & 26 deletions src/Core/CoreKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,31 +87,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()
{
Expand All @@ -122,6 +131,17 @@ 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);
}
}

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

private function getDatabaseReplicaConfig(int $replica)
{
return $this->getSingleDataBaseConfig($replica);
}

/**
* 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
{
// 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 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.
*
* Replicate specific configuration has `_REPLICA_01` appended to the key
* where 01 is the replica number.
*/
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 '';
}

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 @@ -146,7 +210,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 @@ -162,25 +226,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 @@ -208,6 +272,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: 5 additions & 0 deletions src/Dev/SapphireTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mailer\Transport\NullTransport;
use SilverStripe\ORM\DB;

/**
* Test case class for the Silverstripe framework.
Expand Down Expand Up @@ -395,6 +396,10 @@ protected function currentTestDisablesDatabase()
*/
public static function setUpBeforeClass(): void
{
// Disallow the use of DB replicas in tests
// This prevents replicas from being used if there are environment variables set to use them
DB::setCanUseReplicas(false);

// Start tests
static::start();

Expand Down
Loading

0 comments on commit 2d388e9

Please sign in to comment.