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 13, 2024
1 parent 9788a97 commit a734683
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 32 deletions.
3 changes: 3 additions & 0 deletions cli-script.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
122 changes: 93 additions & 29 deletions src/Core/CoreKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,52 +66,99 @@ 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()
{
// Set default database config
$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
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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
*/
Expand All @@ -184,6 +247,7 @@ protected function getDatabaseSuffix()

/**
* Get name of database
* Note that any replicas must have the same database name
*
* @return string
*/
Expand Down
78 changes: 75 additions & 3 deletions src/ORM/DB.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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);
}

/**
Expand Down

0 comments on commit a734683

Please sign in to comment.