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 26, 2024
1 parent a81b785 commit 75f7c5c
Show file tree
Hide file tree
Showing 25 changed files with 842 additions and 93 deletions.
5 changes: 4 additions & 1 deletion cli-script.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
die();
}

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

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


$skipDatabase = in_array('--no-database', $argv);
if ($skipDatabase) {
DB::set_conn(new NullDatabase());
DB::set_conn(new NullDatabase(), DB::CONN_PRIMARY);
}
// Default application
$kernel = new CoreKernel(BASE_PATH);
Expand Down
21 changes: 21 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 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 @@ -295,6 +304,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
142 changes: 113 additions & 29 deletions src/Core/CoreKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use SilverStripe\Dev\Install\DatabaseAdapterRegistry;
use SilverStripe\ORM\DB;
use Exception;
use InvalidArgumentException;
use LogicException;
use SilverStripe\Dev\Deprecation;
use SilverStripe\ORM\Connect\NullDatabase;
Expand All @@ -15,6 +16,7 @@
*/
class CoreKernel extends BaseKernel
{

protected bool $bootDatabase = true;

/**
Expand All @@ -41,7 +43,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 All @@ -65,7 +67,7 @@ protected function validateDatabase()
if (!$this->bootDatabase) {
return;
}
$databaseConfig = DB::getConfig();
$databaseConfig = DB::getConfig(DB::CONN_PRIMARY);
// Gracefully fail if no DB is configured
if (empty($databaseConfig['database'])) {
$msg = 'Silverstripe Framework requires a "database" key in DB::getConfig(). ' .
Expand All @@ -76,7 +78,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 @@ -87,41 +89,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);

// 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 @@ -130,12 +153,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 @@ -146,7 +229,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 +245,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 +291,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
4 changes: 4 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,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
Loading

0 comments on commit 75f7c5c

Please sign in to comment.