From 46cfde3d9501f22304390bfe32d96d023c2b0fe4 Mon Sep 17 00:00:00 2001 From: Srdjan Date: Thu, 7 May 2020 14:14:29 +1000 Subject: [PATCH] MDL-19711 dml: Enable use of readonly slave database handles Implemented with moodle_read_slave_trait Functionality is triggered by supplying config dboption['readonly']. See config-dist.php for more info on supported dboptions. pgsql and mysqli drivers are using this feature. Also added support for connection timeout for these two drivers. --- config-dist.php | 39 ++ lib/dml/moodle_database.php | 20 +- lib/dml/moodle_read_slave_trait.php | 393 ++++++++++++++++ lib/dml/mysqli_native_moodle_database.php | 80 +++- lib/dml/pgsql_native_moodle_database.php | 86 +++- lib/dml/tests/dml_mysqli_read_slave_test.php | 87 ++++ lib/dml/tests/dml_pgsql_read_slave_test.php | 162 +++++++ lib/dml/tests/dml_read_slave_test.php | 432 ++++++++++++++++++ lib/dml/tests/dml_test.php | 10 + .../fixtures/read_slave_moodle_database.php | 232 ++++++++++ ...read_slave_moodle_database_mock_mysqli.php | 64 +++ .../read_slave_moodle_database_mock_pgsql.php | 41 ++ .../read_slave_moodle_database_special.php | 76 +++ ...read_slave_moodle_database_table_names.php | 52 +++ .../read_slave_moodle_recordset_special.php | 69 +++ .../tests/fixtures/test_moodle_database.php | 384 ++++++++++++++++ .../fixtures/test_moodle_read_slave_trait.php | 89 ++++ lib/dml/tests/fixtures/test_sql_generator.php | 128 ++++++ lib/moodlelib.php | 6 + 19 files changed, 2422 insertions(+), 28 deletions(-) create mode 100644 lib/dml/moodle_read_slave_trait.php create mode 100644 lib/dml/tests/dml_mysqli_read_slave_test.php create mode 100644 lib/dml/tests/dml_pgsql_read_slave_test.php create mode 100644 lib/dml/tests/dml_read_slave_test.php create mode 100644 lib/dml/tests/fixtures/read_slave_moodle_database.php create mode 100644 lib/dml/tests/fixtures/read_slave_moodle_database_mock_mysqli.php create mode 100644 lib/dml/tests/fixtures/read_slave_moodle_database_mock_pgsql.php create mode 100644 lib/dml/tests/fixtures/read_slave_moodle_database_special.php create mode 100644 lib/dml/tests/fixtures/read_slave_moodle_database_table_names.php create mode 100644 lib/dml/tests/fixtures/read_slave_moodle_recordset_special.php create mode 100644 lib/dml/tests/fixtures/test_moodle_database.php create mode 100644 lib/dml/tests/fixtures/test_moodle_read_slave_trait.php create mode 100644 lib/dml/tests/fixtures/test_sql_generator.php diff --git a/config-dist.php b/config-dist.php index 90989bbd28a..30855a42a09 100644 --- a/config-dist.php +++ b/config-dist.php @@ -79,6 +79,45 @@ $CFG->dboptions = array( // set to zero if you are using pg_bouncer in // 'transaction' mode (it is fine in 'session' // mode). + /* + 'connecttimeout' => null, // Set connect timeout in seconds. Not all drivers support it. + 'readonly' => [ // Set to read-only slave details, to get safe reads + // from there instead of the master node. Optional. + // Currently supported by pgsql and mysqli variety classes. + // If not supported silently ignored. + 'instance' => [ // Readonly slave connection parameters + [ + 'dbhost' => 'slave.dbhost', + 'dbport' => '', // Defaults to master port + 'dbuser' => '', // Defaults to master user + 'dbpass' => '', // Defaults to master password + ], + [...], + ], + + Instance(s) can alternatively be specified as: + + 'instance' => 'slave.dbhost', + 'instance' => ['slave.dbhost1', 'slave.dbhost2'], + 'instance' => ['dbhost' => 'slave.dbhost', 'dbport' => '', 'dbuser' => '', 'dbpass' => ''], + + 'connecttimeout' => 2, // Set read-only slave connect timeout in seconds. See above. + 'latency' => 0.5, // Set read-only slave sync latency in seconds. + // When 'latency' seconds have lapsed after an update to a table + // it is deemed safe to use readonly slave for reading from the table. + // It is optional. If omitted once written to a table it will always + // use master handle for reading. + // Lower values increase the performance, but setting it too low means + // missing the master-slave sync. + 'exclude_tables' => [ // Tables to exclude from read-only slave feature. + 'table1', // Should not be used, unless in rare cases when some area of the system + 'table2', // is malfunctioning and you still want to use readonly feature. + ], // Then one can exclude offending tables while investigating. + + More info available in lib/dml/moodle_read_slave_trait.php where the feature is implemented. + ] + */ +// For all database config settings see https://docs.moodle.org/en/Database_settings ); diff --git a/lib/dml/moodle_database.php b/lib/dml/moodle_database.php index 1d86919c3a6..db0a31c9bb6 100644 --- a/lib/dml/moodle_database.php +++ b/lib/dml/moodle_database.php @@ -108,13 +108,13 @@ abstract class moodle_database { /** @var float Last time in seconds with millisecond precision. */ protected $last_time; /** @var bool Flag indicating logging of query in progress. This helps prevent infinite loops. */ - private $loggingquery = false; + protected $loggingquery = false; /** @var bool True if the db is used for db sessions. */ protected $used_for_db_sessions = false; /** @var array Array containing open transactions. */ - private $transactions = array(); + protected $transactions = array(); /** @var bool Flag used to force rollback of all current transactions. */ private $force_rollback = false; @@ -2717,6 +2717,22 @@ abstract class moodle_database { return $this->reads; } + /** + * Returns whether we want to connect to slave database for read queries. + * @return bool Want read only connection + */ + public function want_read_slave(): bool { + return false; + } + + /** + * Returns the number of reads before first write done by this database. + * @return int Number of reads. + */ + public function perf_get_reads_slave(): int { + return 0; + } + /** * Returns the number of writes done by this database. * @return int Number of writes. diff --git a/lib/dml/moodle_read_slave_trait.php b/lib/dml/moodle_read_slave_trait.php new file mode 100644 index 00000000000..95d05a49845 --- /dev/null +++ b/lib/dml/moodle_read_slave_trait.php @@ -0,0 +1,393 @@ +. + +/** + * Trait that adds read-only slave connection capability + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Trait to wrap connect() method of database driver classes that gives + * ability to use read only slave instances for SELECT queries. For the + * databases that support replication and read only connections to the slave. + * If the slave connection is configured there will be two database handles + * created, one for the master and another one for the slave. If there's no + * slave specified everything uses master handle. + * + * Classes that use this trait need to rename existing connect() method to + * raw_connect(). In addition, they need to provide get_db_handle() and + * set_db_handle() methods, due to dbhandle attributes not being named + * consistently across the database driver classes. + * + * Read only slave connection is configured in the $CFG->dboptions['readonly'] + * array. + * - It supports multiple 'instance' entries, in case one is not accessible, + * but only one (first connectable) instance is used. + * - 'latency' option: master -> slave sync latency in seconds (will probably + * be a fraction of a second). If specified, a table being written to is + * deemed fully synced and suitable for slave read. + * - 'exclude_tables' option: a list of tables that never go to the slave for + * querying. The feature is meant to be used in emergency only, so the + * readonly feature can still be used in case there is a rogue query that + * does not go through the standard dml interface or some other unaccounted + * situation. It should not be used under normal circumstances, and its use + * indicates a problem in the system that needs addressig. + * + * Choice of the database handle is based on following: + * - SQL_QUERY_INSERT, UPDATE and STRUCTURE record table from the query + * in the $written array and microtime() the event if the 'latency' option + * is set. For those queries master write handle is used. + * - SQL_QUERY_AUX queries will always use the master write handle because they + * are used for transactionstart/end, locking etc. In that respect, query_start() and + * query_end() *must not* be used during the connection phase. + * - SELECT queries will use the master write handle if: + * -- any of the tables involved is a temp table + * -- any of the tables involved is listed in the 'exclude_tables' option + * -- any of the tables involved is in the $written array: + * * If the 'latency' option is set then the microtime() is compared to + * the write microrime, and if more then latency time has passed the slave + * handle is used. + * * Otherwise (not enough time passed or 'latency' option not set) + * we choose the master write handle + * If none of the above conditions are met the slave instance is used. + * + * A 'latency' example: + * - we have set $CFG->dboptions['readonly']['latency'] to 0.2. + * - a SQL_QUERY_UPDATE to table tbl_x happens, and it is recorded in + * the $written array + * - 0.15 seconds later SQL_QUERY_SELECT with tbl_x is requested - the master + * connection is used + * - 0.10 seconds later (0.25 seconds after SQL_QUERY_UPDATE) another + * SQL_QUERY_SELECT with tbl_x is requested - this time more than 0.2 secs + * has gone and master -> slave sync is assumed, so the slave connection is + * used again + */ + +trait moodle_read_slave_trait { + + /** @var resource master write database handle */ + protected $dbhwrite; + + /** @var resource slave read only database handle */ + protected $dbhreadonly; + + private $wantreadslave = false; + private $readsslave = 0; + private $slavelatency = 0; + + private $written = []; // Track tables being written to. + private $readexclude = []; // Tables to exclude from using dbhreadonly. + + // Store original params. + private $pdbhost; + private $pdbuser; + private $pdbpass; + private $pdbname; + private $pprefix; + private $pdboptions; + + /** + * Gets db handle currently used with queries + * @return resource + */ + abstract protected function get_db_handle(); + + /** + * Sets db handle to be used with subsequent queries + * @param resource $dbh + * @return void + */ + abstract protected function set_db_handle($dbh): void; + + /** + * Connect to db + * The real connection establisment, called from connect() and set_dbhwrite() + * @param string $dbhost The database host. + * @param string $dbuser The database username. + * @param string $dbpass The database username's password. + * @param string $dbname The name of the database being connected to. + * @param mixed $prefix string means moodle db prefix, false used for external databases where prefix not used + * @param array $dboptions driver specific options + * @return bool true + * @throws dml_connection_exception if error + */ + abstract protected function raw_connect(string $dbhost, string $dbuser, string $dbpass, string $dbname, $prefix, array $dboptions = null): bool; + + /** + * Connect to db + * The connection parameters processor that sets up stage for master write and slave readonly handles. + * Must be called before other methods. + * @param string $dbhost The database host. + * @param string $dbuser The database username. + * @param string $dbpass The database username's password. + * @param string $dbname The name of the database being connected to. + * @param mixed $prefix string means moodle db prefix, false used for external databases where prefix not used + * @param array $dboptions driver specific options + * @return bool true + * @throws dml_connection_exception if error + */ + public function connect($dbhost, $dbuser, $dbpass, $dbname, $prefix, array $dboptions = null) { + $this->pdbhost = $dbhost; + $this->pdbuser = $dbuser; + $this->pdbpass = $dbpass; + $this->pdbname = $dbname; + $this->pprefix = $prefix; + $this->pdboptions = $dboptions; + + if ($dboptions) { + if (isset($dboptions['readonly'])) { + $this->wantreadslave = true; + $dboptionsro = $dboptions['readonly']; + + if (isset($dboptionsro['connecttimeout'])) { + $dboptions['connecttimeout'] = $dboptionsro['connecttimeout']; + } else if (!isset($dboptions['connecttimeout'])) { + $dboptions['connecttimeout'] = 2; // Default readonly connection timeout. + } + if (isset($dboptionsro['latency'])) { + $this->slavelatency = $dboptionsro['latency']; + } + if (isset($dboptionsro['exclude_tables'])) { + $this->readexclude = $dboptionsro['exclude_tables']; + if (!is_array($this->readexclude)) { + throw new configuration_exception('exclude_tables must be an array'); + } + } + $dbport = isset($dboptions['dbport']) ? $dboptions['dbport'] : null; + + $slaves = $dboptionsro['instance']; + if (!is_array($slaves) || !isset($slaves[0])) { + $slaves = [$slaves]; + } + + if (count($slaves) > 1) { + // Randomise things a bit. + shuffle($slaves); + } + + // Find first connectable readonly slave. + $rodb = []; + foreach ($slaves as $slave) { + if (!is_array($slave)) { + $slave = ['dbhost' => $slave]; + } + foreach (['dbhost', 'dbuser', 'dbpass'] as $dbparam) { + $rodb[$dbparam] = isset($slave[$dbparam]) ? $slave[$dbparam] : $$dbparam; + } + $dboptions['dbport'] = isset($slave['dbport']) ? $slave['dbport'] : $dbport; + + // @codingStandardsIgnoreStart + try { + $this->raw_connect($rodb['dbhost'], $rodb['dbuser'], $rodb['dbpass'], $dbname, $prefix, $dboptions); + $this->dbhreadonly = $this->get_db_handle(); + break; + } catch (dml_connection_exception $e) { + // If readonly slave is not connectable we'll have to do without it. + } + // @codingStandardsIgnoreEnd + } + // ... lock_db queries always go to master. + // Since it is a lock and as such marshalls concurrent connections, + // it is best to leave it out and avoid master/slave latency. + $this->readexclude[] = 'lock_db'; + // ... and sessions. + $this->readexclude[] = 'sessions'; + } + } + if (!$this->dbhreadonly) { + $this->set_dbhwrite(); + } + + return true; + } + + /** + * Set database handle to readwrite master + * Will connect if required. Calls set_db_handle() + * @return void + */ + private function set_dbhwrite(): void { + // Late connect to read/write master if needed. + if (!$this->dbhwrite) { + $this->raw_connect($this->pdbhost, $this->pdbuser, $this->pdbpass, $this->pdbname, $this->pprefix, $this->pdboptions); + $this->dbhwrite = $this->get_db_handle(); + } + $this->set_db_handle($this->dbhwrite); + } + + /** + * Returns whether we want to connect to slave database for read queries. + * @return bool Want read only connection + */ + public function want_read_slave(): bool { + return $this->wantreadslave; + } + + /** + * Returns the number of reads done by the read only database. + * @return int Number of reads. + */ + public function perf_get_reads_slave(): int { + return $this->readsslave; + } + + /** + * On DBs that support it, switch to transaction mode and begin a transaction + * @return moodle_transaction + */ + public function start_delegated_transaction() { + $this->set_dbhwrite(); + return parent::start_delegated_transaction(); + } + + /** + * Called before each db query. + * @param string $sql + * @param array $params array of parameters + * @param int $type type of query + * @param mixed $extrainfo driver specific extra information + * @return void + */ + protected function query_start($sql, array $params = null, $type, $extrainfo = null) { + parent::query_start($sql, $params, $type, $extrainfo); + $this->select_db_handle($type, $sql); + } + + /** + * Select appropriate db handle - readwrite or readonly + * @param int $type type of query + * @param string $sql + * @return void + */ + protected function select_db_handle(int $type, string $sql): void { + if ($this->dbhreadonly && $this->can_use_readonly($type, $sql)) { + $this->readsslave++; + $this->set_db_handle($this->dbhreadonly); + return; + } + $this->set_dbhwrite(); + } + + /** + * Check if The query qualifies for readonly connection execution + * Logging queries are exempt, those are write operations that circumvent + * standard query_start/query_end paths. + * @param int $type type of query + * @param string $sql + * @return bool + */ + protected function can_use_readonly(int $type, string $sql): bool { + if ($this->loggingquery) { + return false; + } + + if (during_initial_install()) { + return false; + } + + // Transactions are done as AUX, we cannot play with that. + switch ($type) { + case SQL_QUERY_SELECT: + if ($this->transactions) { + return false; + } + + $now = null; + foreach ($this->table_names($sql) as $tablename) { + if (in_array($tablename, $this->readexclude)) { + return false; + } + + if ($this->temptables && $this->temptables->is_temptable($tablename)) { + return false; + } + + if (isset($this->written[$tablename])) { + if ($this->slavelatency) { + $now = $now ?: microtime(true); + if ($now - $this->written[$tablename] < $this->slavelatency) { + return false; + } + unset($this->written[$tablename]); + } else { + return false; + } + } + } + + return true; + case SQL_QUERY_INSERT: + case SQL_QUERY_UPDATE: + // If we are in transaction we cannot set the written time yet. + $now = $this->slavelatency && !$this->transactions ? microtime(true) : true; + foreach ($this->table_names($sql) as $tablename) { + $this->written[$tablename] = $now; + } + return false; + case SQL_QUERY_STRUCTURE: + foreach ($this->table_names($sql) as $tablename) { + if (!in_array($tablename, $this->readexclude)) { + $this->readexclude[] = $tablename; + } + } + return false; + } + return false; + } + + /** + * Indicates delegated transaction finished successfully. + * Set written times after outermost transaction finished + * @param moodle_transaction $transaction The transaction to commit + * @return void + * @throws dml_transaction_exception Creates and throws transaction related exceptions. + */ + public function commit_delegated_transaction(moodle_transaction $transaction) { + parent::commit_delegated_transaction($transaction); + + if ($this->transactions) { + return; + } + + if (!$this->slavelatency) { + return; + } + + $now = null; + foreach ($this->written as $tablename => $when) { + if ($when === true) { + $now = $now ?: microtime(true); + $this->written[$tablename] = $now; + } + } + } + + /** + * Parse table names from query + * @param string $sql + * @return array + */ + protected function table_names(string $sql): array { + preg_match_all('/\b'.$this->prefix.'([a-z][A-Za-z0-9_]*)/', $sql, $match); + return $match[1]; + } +} diff --git a/lib/dml/mysqli_native_moodle_database.php b/lib/dml/mysqli_native_moodle_database.php index 0b58d07be17..80dc0200e5f 100644 --- a/lib/dml/mysqli_native_moodle_database.php +++ b/lib/dml/mysqli_native_moodle_database.php @@ -25,6 +25,7 @@ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__.'/moodle_database.php'); +require_once(__DIR__.'/moodle_read_slave_trait.php'); require_once(__DIR__.'/mysqli_native_moodle_recordset.php'); require_once(__DIR__.'/mysqli_native_moodle_temptables.php'); @@ -36,6 +37,9 @@ require_once(__DIR__.'/mysqli_native_moodle_temptables.php'); * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class mysqli_native_moodle_database extends moodle_database { + use moodle_read_slave_trait { + can_use_readonly as read_slave_can_use_readonly; + } /** @var mysqli $mysqli */ protected $mysqli = null; @@ -235,6 +239,14 @@ class mysqli_native_moodle_database extends moodle_database { if (isset($this->dboptions['dbcollation'])) { return $this->dboptions['dbcollation']; } + } + + /** + * Set 'dbcollation' option + * + * @return string $dbcollation + */ + private function detect_collation(): string { if ($this->external) { return null; } @@ -246,9 +258,7 @@ class mysqli_native_moodle_database extends moodle_database { $sql = "SELECT collation_name FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = DATABASE() AND table_name = '{$this->prefix}config' AND column_name = 'value'"; - $this->query_start($sql, NULL, SQL_QUERY_AUX); $result = $this->mysqli->query($sql); - $this->query_end($result); if ($rec = $result->fetch_assoc()) { // MySQL 8 BC: information_schema.* returns the fields in upper case. $rec = array_change_key_case($rec, CASE_LOWER); @@ -260,9 +270,7 @@ class mysqli_native_moodle_database extends moodle_database { if (!$collation) { // Get the default database collation, but only if using UTF-8. $sql = "SELECT @@collation_database"; - $this->query_start($sql, NULL, SQL_QUERY_AUX); $result = $this->mysqli->query($sql); - $this->query_end($result); if ($rec = $result->fetch_assoc()) { if (strpos($rec['@@collation_database'], 'utf8_') === 0 || strpos($rec['@@collation_database'], 'utf8mb4_') === 0) { $collation = $rec['@@collation_database']; @@ -275,9 +283,7 @@ class mysqli_native_moodle_database extends moodle_database { // We want only utf8 compatible collations. $collation = null; $sql = "SHOW COLLATION WHERE Collation LIKE 'utf8mb4\_%' AND Charset = 'utf8mb4'"; - $this->query_start($sql, NULL, SQL_QUERY_AUX); $result = $this->mysqli->query($sql); - $this->query_end($result); while ($res = $result->fetch_assoc()) { $collation = $res['Collation']; if (strtoupper($res['Default']) === 'YES') { @@ -518,7 +524,6 @@ class mysqli_native_moodle_database extends moodle_database { /** * Connect to db - * Must be called before other methods. * @param string $dbhost The database host. * @param string $dbuser The database username. * @param string $dbpass The database username's password. @@ -527,7 +532,7 @@ class mysqli_native_moodle_database extends moodle_database { * @param array $dboptions driver specific options * @return bool success */ - public function connect($dbhost, $dbuser, $dbpass, $dbname, $prefix, array $dboptions=null) { + public function raw_connect(string $dbhost, string $dbuser, string $dbpass, string $dbname, $prefix, array $dboptions=null): bool { $driverstatus = $this->driver_installed(); if ($driverstatus !== true) { @@ -556,10 +561,19 @@ class mysqli_native_moodle_database extends moodle_database { if ($dbhost and !empty($this->dboptions['dbpersist'])) { $dbhost = "p:$dbhost"; } - $this->mysqli = @new mysqli($dbhost, $dbuser, $dbpass, $dbname, $dbport, $dbsocket); + $this->mysqli = mysqli_init(); + if (!empty($this->dboptions['connecttimeout'])) { + $this->mysqli->options(MYSQLI_OPT_CONNECT_TIMEOUT, $this->dboptions['connecttimeout']); + } - if ($this->mysqli->connect_errno !== 0) { - $dberr = $this->mysqli->connect_error; + $conn = null; + try { + $conn = $this->mysqli->real_connect($dbhost, $dbuser, $dbpass, $dbname, $dbport, $dbsocket); + } catch (\Exception $e) { + $dberr = "$e"; + } + if (!$conn) { + $dberr = $dberr ?: $this->mysqli->connect_error; $this->mysqli = null; throw new dml_connection_exception($dberr); } @@ -568,16 +582,14 @@ class mysqli_native_moodle_database extends moodle_database { $this->query_log_prevent(); if (isset($dboptions['dbcollation'])) { - $collationinfo = explode('_', $dboptions['dbcollation']); - $this->dboptions['dbcollation'] = $dboptions['dbcollation']; + $collation = $this->dboptions['dbcollation'] = $dboptions['dbcollation']; } else { - $collationinfo = explode('_', $this->get_dbcollation()); + $collation = $this->detect_collation(); } + $collationinfo = explode('_', $collation); $charset = reset($collationinfo); - $this->query_start("--set_charset()", null, SQL_QUERY_AUX); $this->mysqli->set_charset($charset); - $this->query_end(true); // If available, enforce strict mode for the session. That guaranties // standard behaviour under some situations, avoiding some MySQL nasty @@ -587,9 +599,7 @@ class mysqli_native_moodle_database extends moodle_database { $si = $this->get_server_info(); if (version_compare($si['version'], '5.0.2', '>=')) { $sql = "SET SESSION sql_mode = 'STRICT_ALL_TABLES'"; - $this->query_start($sql, null, SQL_QUERY_AUX); $result = $this->mysqli->query($sql); - $this->query_end($result); } // We can enable logging now. @@ -614,6 +624,40 @@ class mysqli_native_moodle_database extends moodle_database { } } + /** + * Gets db handle currently used with queries + * @return resource + */ + protected function get_db_handle() { + return $this->mysqli; + } + + /** + * Sets db handle to be used with subsequent queries + * @param resource $dbh + * @return void + */ + protected function set_db_handle($dbh): void { + $this->mysqli = $dbh; + } + + /** + * Check if The query qualifies for readonly connection execution + * Logging queries are exempt, those are write operations that circumvent + * standard query_start/query_end paths. + * @param int $type type of query + * @param string $sql + * @return bool + */ + protected function can_use_readonly(int $type, string $sql): bool { + // ... *_LOCK queries always go to master. + if (preg_match('/\b(GET|RELEASE)_LOCK/i', $sql)) { + return false; + } + + return $this->read_slave_can_use_readonly($type, $sql); + } + /** * Returns database server info array * @return array Array containing 'description' and 'version' info diff --git a/lib/dml/pgsql_native_moodle_database.php b/lib/dml/pgsql_native_moodle_database.php index 9bd1c886bc1..848cee12967 100644 --- a/lib/dml/pgsql_native_moodle_database.php +++ b/lib/dml/pgsql_native_moodle_database.php @@ -25,6 +25,7 @@ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__.'/moodle_database.php'); +require_once(__DIR__.'/moodle_read_slave_trait.php'); require_once(__DIR__.'/pgsql_native_moodle_recordset.php'); require_once(__DIR__.'/pgsql_native_moodle_temptables.php'); @@ -36,6 +37,14 @@ require_once(__DIR__.'/pgsql_native_moodle_temptables.php'); * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class pgsql_native_moodle_database extends moodle_database { + use moodle_read_slave_trait { + select_db_handle as read_slave_select_db_handle; + can_use_readonly as read_slave_can_use_readonly; + query_start as read_slave_query_start; + } + + /** @var array $dbhcursor keep track of open cursors */ + private $dbhcursor = []; /** @var resource $pgsql database resource */ protected $pgsql = null; @@ -110,7 +119,6 @@ class pgsql_native_moodle_database extends moodle_database { /** * Connect to db - * Must be called before other methods. * @param string $dbhost The database host. * @param string $dbuser The database username. * @param string $dbpass The database username's password. @@ -120,7 +128,7 @@ class pgsql_native_moodle_database extends moodle_database { * @return bool true * @throws dml_connection_exception if error */ - public function connect($dbhost, $dbuser, $dbpass, $dbname, $prefix, array $dboptions=null) { + public function raw_connect(string $dbhost, string $dbuser, string $dbpass, string $dbname, $prefix, array $dboptions=null): bool { if ($prefix == '' and !$this->external) { //Enforce prefixes for everybody but mysql throw new dml_exception('prefixcannotbeempty', $this->get_dbfamily()); @@ -160,6 +168,10 @@ class pgsql_native_moodle_database extends moodle_database { $connection = "host='$this->dbhost' $port user='$this->dbuser' password='$pass' dbname='$this->dbname'"; } + if (!empty($this->dboptions['connecttimeout'])) { + $connection .= " connect_timeout=".$this->dboptions['connecttimeout']; + } + if (empty($this->dboptions['dbhandlesoptions'])) { // ALTER USER and ALTER DATABASE are overridden by these settings. $options = array('--client_encoding=utf8', '--standard_conforming_strings=on'); @@ -232,6 +244,64 @@ class pgsql_native_moodle_database extends moodle_database { } } + /** + * Gets db handle currently used with queries + * @return resource + */ + protected function get_db_handle() { + return $this->pgsql; + } + + /** + * Sets db handle to be used with subsequent queries + * @param resource $dbh + * @return void + */ + protected function set_db_handle($dbh): void { + $this->pgsql = $dbh; + } + + /** + * Select appropriate db handle - readwrite or readonly + * @param int $type type of query + * @param string $sql + * @return void + */ + protected function select_db_handle(int $type, string $sql): void { + $this->read_slave_select_db_handle($type, $sql); + + if (preg_match('/^DECLARE (crs\w*) NO SCROLL CURSOR/', $sql, $match)) { + $cursor = $match[1]; + $this->dbhcursor[$cursor] = $this->pgsql; + } + if (preg_match('/^(?:FETCH \d+ FROM|CLOSE) (crs\w*)\b/', $sql, $match)) { + $cursor = $match[1]; + $this->pgsql = $this->dbhcursor[$cursor]; + } + } + + /** + * Check if The query qualifies for readonly connection execution + * Logging queries are exempt, those are write operations that circumvent + * standard query_start/query_end paths. + * @param int $type type of query + * @param string $sql + * @return bool + */ + protected function can_use_readonly(int $type, string $sql): bool { + // ... pg_*lock queries always go to master. + if (preg_match('/\bpg_\w*lock/', $sql)) { + return false; + } + + // ... a nuisance - temptables use this. + if (preg_match('/\bpg_constraint/', $sql) && $this->temptables->get_temptables()) { + return false; + } + + return $this->read_slave_can_use_readonly($type, $sql); + + } /** * Called before each db query. @@ -242,8 +312,8 @@ class pgsql_native_moodle_database extends moodle_database { * @return void */ protected function query_start($sql, array $params=null, $type, $extrainfo=null) { - parent::query_start($sql, $params, $type, $extrainfo); - // pgsql driver tents to send debug to output, we do not need that ;-) + $this->read_slave_query_start($sql, $params, $type, $extrainfo); + // pgsql driver tends to send debug to output, we do not need that. $this->last_error_reporting = error_reporting(0); } @@ -733,8 +803,6 @@ class pgsql_native_moodle_database extends moodle_database { list($sql, $params, $type) = $this->fix_sql_params($sql, $params); - $this->query_start($sql, $params, SQL_QUERY_SELECT); - // For any query that doesn't explicitly specify a limit, we must use cursors to stop it // loading the entire thing (unless the config setting is turned off). $usecursors = !$limitnum && ($this->get_fetch_buffer_size() > 0); @@ -747,12 +815,14 @@ class pgsql_native_moodle_database extends moodle_database { // Do the query to a cursor. $sql = 'DECLARE ' . $cursorname . ' NO SCROLL CURSOR WITH HOLD FOR ' . $sql; - $result = pg_query_params($this->pgsql, $sql, $params); } else { - $result = pg_query_params($this->pgsql, $sql, $params); $cursorname = ''; } + $this->query_start($sql, $params, SQL_QUERY_SELECT); + + $result = pg_query_params($this->pgsql, $sql, $params); + $this->query_end($result); if ($usecursors) { pg_free_result($result); diff --git a/lib/dml/tests/dml_mysqli_read_slave_test.php b/lib/dml/tests/dml_mysqli_read_slave_test.php new file mode 100644 index 00000000000..d2d8a952ee7 --- /dev/null +++ b/lib/dml/tests/dml_mysqli_read_slave_test.php @@ -0,0 +1,87 @@ +. + +/** + * DML read/read-write database handle tests for mysqli_native_moodle_database + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/fixtures/read_slave_moodle_database_mock_mysqli.php'); + +/** + * DML mysqli_native_moodle_database read slave specific tests + * + * @package core + * @category dml + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_dml_mysqli_read_slave_testcase extends base_testcase { + /** + * Test readonly handle is not used for reading from special pg_*() call queries, + * pg_try_advisory_lock and pg_advisory_unlock. + * + * @return void + */ + public function test_lock() : void { + $DB = new read_slave_moodle_database_mock_mysqli(); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + + $DB->query_start("SELECT GET_LOCK('lock',1)", null, SQL_QUERY_SELECT); + $this->assertEquals('test_rw', $DB->get_db_handle()); + $DB->query_end(null); + $this->assertEquals(0, $DB->perf_get_reads_slave()); + + $DB->query_start("SELECT RELEASE_LOCK('lock',1)", null, SQL_QUERY_SELECT); + $this->assertEquals('test_rw', $DB->get_db_handle()); + $DB->query_end(null); + $this->assertEquals(0, $DB->perf_get_reads_slave()); + } + + /** + * Test readonly connection failure with real mysqli connection + * + * @return void + */ + public function test_real_readslave_connect_fail() : void { + global $DB; + + if ($DB->get_dbfamily() != 'mysql') { + $this->markTestSkipped("Not mysql"); + } + + // Open second connection. + $cfg = $DB->export_dbconfig(); + if (!isset($cfg->dboptions)) { + $cfg->dboptions = array(); + } + $cfg->dboptions['readonly'] = [ + 'instance' => ['host.that.is.not'], + 'connecttimeout' => 1 + ]; + + $db2 = moodle_database::get_driver_instance($cfg->dbtype, $cfg->dblibrary); + $db2->connect($cfg->dbhost, $cfg->dbuser, $cfg->dbpass, $cfg->dbname, $cfg->prefix, $cfg->dboptions); + $this->assertTrue(count($db2->get_records('user')) > 0); + } +} diff --git a/lib/dml/tests/dml_pgsql_read_slave_test.php b/lib/dml/tests/dml_pgsql_read_slave_test.php new file mode 100644 index 00000000000..8cb49ee128a --- /dev/null +++ b/lib/dml/tests/dml_pgsql_read_slave_test.php @@ -0,0 +1,162 @@ +. + +/** + * DML read/read-write database handle tests for pgsql_native_moodle_database + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/fixtures/read_slave_moodle_database_mock_pgsql.php'); + +/** + * DML pgsql_native_moodle_database read slave specific tests + * + * @package core + * @category dml + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_dml_pgsql_read_slave_testcase extends base_testcase { + /** + * Test correct database handles are used for cursors + * + * @return void + */ + public function test_cursors() : void { + $DB = new read_slave_moodle_database_mock_pgsql(); + + // Declare a cursor on a table that has not been written to. + list($sql, $params, $type) = $DB->fix_sql_params("SELECT * FROM {table}"); + $sql = "DECLARE crs1 NO SCROLL CURSOR WITH HOLD FOR $sql"; + $DB->query_start($sql, null, SQL_QUERY_SELECT); + $DB->query_end(null); + + // Declare a cursor on a table that has been written to. + list($sql, $params, $type) = $DB->fix_sql_params("INSERT INTO {table2} (name) VALUES ('blah')"); + $DB->query_start($sql, null, SQL_QUERY_INSERT); + $DB->query_end(null); + list($sql, $params, $type) = $DB->fix_sql_params("SELECT * FROM {table2}"); + $sql = "DECLARE crs2 NO SCROLL CURSOR WITH HOLD FOR $sql"; + $DB->query_start($sql, null, SQL_QUERY_SELECT); + $DB->query_end(null); + + // Read from the non-written to table cursor. + $sql = 'FETCH 1 FROM crs1'; + $DB->query_start($sql, null, SQL_QUERY_AUX); + $this->assertEquals('test_ro', $DB->get_db_handle()); + $DB->query_end(null); + + // Read from the written to table cursor. + $sql = 'FETCH 1 FROM crs2'; + $DB->query_start($sql, null, SQL_QUERY_AUX); + $this->assertEquals('test_rw', $DB->get_db_handle()); + $DB->query_end(null); + } + + /** + * Test readonly handle is used for reading from random pg_*() call queries. + * + * @return void + */ + public function test_read_pg_table() : void { + $DB = new read_slave_moodle_database_mock_pgsql(); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + + $DB->query_start('SELECT pg_whatever(1)', null, SQL_QUERY_SELECT); + $this->assertEquals('test_ro', $DB->get_db_handle()); + $DB->query_end(null); + $this->assertEquals(1, $DB->perf_get_reads_slave()); + } + + /** + * Test readonly handle is not used for reading from special pg_*() call queries, + * pg_try_advisory_lock and pg_advisory_unlock. + * + * @return void + */ + public function test_read_pg_lock_table() : void { + $DB = new read_slave_moodle_database_mock_pgsql(); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + + foreach (['pg_try_advisory_lock', 'pg_advisory_unlock'] as $fn) { + $DB->query_start("SELECT $fn(1)", null, SQL_QUERY_SELECT); + $this->assertEquals('test_rw', $DB->get_db_handle()); + $DB->query_end(null); + $this->assertEquals(0, $DB->perf_get_reads_slave()); + } + } + + /** + * Test readonly handle is not used for reading from temptables + * and getting temptables metadata. + * This test is only possible because of no pg_query error reporting. + * It may need to be removed in the future if we decide to handle null + * results in pgsql_native_moodle_database differently. + * + * @return void + */ + public function test_temp_table() : void { + $DB = new read_slave_moodle_database_mock_pgsql(); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + + $dbman = $DB->get_manager(); + $table = new xmldb_table('silly_test_table'); + $table->add_field('id', XMLDB_TYPE_INTEGER, 10, null, XMLDB_NOTNULL, XMLDB_SEQUENCE); + $table->add_field('msg', XMLDB_TYPE_CHAR, 255); + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $dbman->create_temp_table($table); + + $DB->get_columns('silly_test_table'); + $DB->get_records('silly_test_table'); + $this->assertEquals(0, $DB->perf_get_reads_slave()); + } + + /** + * Test readonly connection failure with real pgsql connection + * + * @return void + */ + public function test_real_readslave_connect_fail() : void { + global $DB; + + if ($DB->get_dbfamily() != 'postgres') { + $this->markTestSkipped("Not postgres"); + } + + // Open second connection. + $cfg = $DB->export_dbconfig(); + if (!isset($cfg->dboptions)) { + $cfg->dboptions = array(); + } + $cfg->dboptions['readonly'] = [ + 'instance' => ['host.that.is.not'], + 'connecttimeout' => 1 + ]; + + $db2 = moodle_database::get_driver_instance($cfg->dbtype, $cfg->dblibrary); + $db2->connect($cfg->dbhost, $cfg->dbuser, $cfg->dbpass, $cfg->dbname, $cfg->prefix, $cfg->dboptions); + $this->assertTrue(count($db2->get_records('user')) > 0); + } +} diff --git a/lib/dml/tests/dml_read_slave_test.php b/lib/dml/tests/dml_read_slave_test.php new file mode 100644 index 00000000000..541f2ee42c5 --- /dev/null +++ b/lib/dml/tests/dml_read_slave_test.php @@ -0,0 +1,432 @@ +. + +/** + * DML read/read-write database handle use tests + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/fixtures/read_slave_moodle_database_table_names.php'); +require_once(__DIR__.'/fixtures/read_slave_moodle_database_special.php'); + +/** + * DML read/read-write database handle use tests + * + * @package core + * @category dml + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_dml_read_slave_testcase extends base_testcase { + + /** @var float */ + static private $dbreadonlylatency = 0.8; + + /** + * Instantiates a test database interface object. + * + * @param bool $wantlatency + * @param mixed $readonly + * @param mixed $dbclass + * @return read_slave_moodle_database $db + */ + public function new_db( + $wantlatency = false, + $readonly = [ + ['dbhost' => 'test_ro1', 'dbport' => 1, 'dbuser' => 'test1', 'dbpass' => 'test1'], + ['dbhost' => 'test_ro2', 'dbport' => 2, 'dbuser' => 'test2', 'dbpass' => 'test2'], + ['dbhost' => 'test_ro3', 'dbport' => 3, 'dbuser' => 'test3', 'dbpass' => 'test3'], + ], + $dbclass = read_slave_moodle_database::class + ) : read_slave_moodle_database { + $dbhost = 'test_rw'; + $dbname = 'test'; + $dbuser = 'test'; + $dbpass = 'test'; + $prefix = 'test_'; + $dboptions = ['readonly' => ['instance' => $readonly, 'exclude_tables' => ['exclude']]]; + if ($wantlatency) { + $dboptions['readonly']['latency'] = self::$dbreadonlylatency; + } + + $db = new $dbclass(); + $db->connect($dbhost, $dbuser, $dbpass, $dbname, $prefix, $dboptions); + return $db; + } + + /** + * Asert that the mock handle returned from read_slave_moodle_database methods + * is a readonly slave handle. + * + * @param string $handle + * @return void + */ + private function assert_readonly_handle($handle) : void { + $this->assertRegExp('/^test_ro\d:\d:test\d:test\d$/', $handle); + } + + /** + * moodle_read_slave_trait::table_names() test data provider + * + * @return array + * @dataProvider table_names_provider + */ + public function table_names_provider() : array { + return [ + [ + "SELECT * + FROM {user} u + JOIN ( + SELECT DISTINCT u.id FROM {user} u + JOIN {user_enrolments} ue1 ON ue1.userid = u.id + JOIN {enrol} e ON e.id = ue1.enrolid + WHERE u.id NOT IN ( + SELECT DISTINCT ue.userid FROM {user_enrolments} ue + JOIN {enrol} e ON (e.id = ue.enrolid AND e.courseid = 1) + WHERE ue.status = 'active' + AND e.status = 'enabled' + AND ue.timestart < now() + AND (ue.timeend = 0 OR ue.timeend > now()) + ) + ) je ON je.id = u.id + JOIN ( + SELECT DISTINCT ra.userid + FROM {role_assignments} ra + WHERE ra.roleid IN (1, 2, 3) + AND ra.contextid = 'ctx' + ) rainner ON rainner.userid = u.id + WHERE u.deleted = 0", + [ + 'user', + 'user', + 'user_enrolments', + 'enrol', + 'user_enrolments', + 'enrol', + 'role_assignments', + ] + ], + ]; + } + + /** + * Test moodle_read_slave_trait::table_names() query parser. + * + * @param string $sql + * @param array $tables + * @return void + * @dataProvider table_names_provider + */ + public function test_table_names($sql, $tables) : void { + $db = new read_slave_moodle_database_table_names(); + + $this->assertEquals($tables, $db->table_names($db->fix_sql_params($sql)[0])); + } + + /** + * Test correct database handles are used in a read-read-write-read scenario. + * Test lazy creation of the write handle. + * + * @return void + */ + public function test_read_read_write_read() : void { + $DB = $this->new_db(true); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + $this->assertNull($DB->get_dbhwrite()); + + $handle = $DB->get_records('table'); + $this->assert_readonly_handle($handle); + $readsslave = $DB->perf_get_reads_slave(); + $this->assertGreaterThan(0, $readsslave); + $this->assertNull($DB->get_dbhwrite()); + + $handle = $DB->get_records('table2'); + $this->assert_readonly_handle($handle); + $readsslave = $DB->perf_get_reads_slave(); + $this->assertGreaterThan(1, $readsslave); + $this->assertNull($DB->get_dbhwrite()); + + $now = microtime(true); + $handle = $DB->insert_record_raw('table', array('name' => 'blah')); + $this->assertEquals('test_rw::test:test', $handle); + + if (microtime(true) - $now < self::$dbreadonlylatency) { + $handle = $DB->get_records('table'); + $this->assertEquals('test_rw::test:test', $handle); + $this->assertEquals($readsslave, $DB->perf_get_reads_slave()); + + sleep(1); + } + + $handle = $DB->get_records('table'); + $this->assert_readonly_handle($handle); + $this->assertEquals($readsslave + 1, $DB->perf_get_reads_slave()); + } + + /** + * Test correct database handles are used in a read-write-write scenario. + * + * @return void + */ + public function test_read_write_write() : void { + $DB = $this->new_db(); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + $this->assertNull($DB->get_dbhwrite()); + + $handle = $DB->get_records('table'); + $this->assert_readonly_handle($handle); + $readsslave = $DB->perf_get_reads_slave(); + $this->assertGreaterThan(0, $readsslave); + $this->assertNull($DB->get_dbhwrite()); + + $handle = $DB->insert_record_raw('table', array('name' => 'blah')); + $this->assertEquals('test_rw::test:test', $handle); + + $handle = $DB->update_record_raw('table', array('id' => 1, 'name' => 'blah2')); + $this->assertEquals('test_rw::test:test', $handle); + $this->assertEquals($readsslave, $DB->perf_get_reads_slave()); + } + + /** + * Test correct database handles are used in a write-read-read scenario. + * + * @return void + */ + public function test_write_read_read() : void { + $DB = $this->new_db(); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + $this->assertNull($DB->get_dbhwrite()); + + $handle = $DB->insert_record_raw('table', array('name' => 'blah')); + $this->assertEquals('test_rw::test:test', $handle); + $this->assertEquals(0, $DB->perf_get_reads_slave()); + + sleep(1); + $handle = $DB->get_records('table'); + $this->assertEquals('test_rw::test:test', $handle); + $this->assertEquals(0, $DB->perf_get_reads_slave()); + + $handle = $DB->get_records('table2'); + $this->assert_readonly_handle($handle); + $this->assertEquals(1, $DB->perf_get_reads_slave()); + + $handle = $DB->get_records_sql("SELECT * FROM {table2} JOIN {table}"); + $this->assertEquals('test_rw::test:test', $handle); + $this->assertEquals(1, $DB->perf_get_reads_slave()); + } + + /** + * Test readonly handle is not used for reading from temptables. + * + * @return void + */ + public function test_read_temptable() : void { + $DB = $this->new_db(); + $DB->add_temptable('temptable1'); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + $this->assertNull($DB->get_dbhwrite()); + + $handle = $DB->get_records('temptable1'); + $this->assertEquals('test_rw::test:test', $handle); + $this->assertEquals(0, $DB->perf_get_reads_slave()); + + $DB->delete_temptable('temptable1'); + } + + /** + * Test readonly handle is not used for reading from excluded tables. + * + * @return void + */ + public function test_read_excluded_tables() : void { + $DB = $this->new_db(); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + $this->assertNull($DB->get_dbhwrite()); + + $handle = $DB->get_records('exclude'); + $this->assertEquals('test_rw::test:test', $handle); + $this->assertEquals(0, $DB->perf_get_reads_slave()); + } + + /** + * Test readonly handle is not used during transactions. + * Test last written time is adjusted post-transaction, + * so the latency parameter is applied properly. + * + * @return void + */ + public function test_transaction() : void { + $DB = $this->new_db(true); + + $this->assertNull($DB->get_dbhwrite()); + + $transaction = $DB->start_delegated_transaction(); + $now = microtime(true); + $handle = $DB->get_records_sql("SELECT * FROM {table}"); + // Use rw handle during transaction. + $this->assertEquals('test_rw::test:test', $handle); + + $handle = $DB->insert_record_raw('table', array('name' => 'blah')); + // Introduce delay so we can check that table write timestamps + // are adjusted properly. + sleep(1); + $transaction->allow_commit(); + // This condition should always evaluate true, however we need to + // safeguard from an unaccounted delay that can break this test. + if (microtime(true) - $now < 1 + self::$dbreadonlylatency) { + // Not enough time passed, use rw handle. + $handle = $DB->get_records_sql("SELECT * FROM {table}"); + $this->assertEquals('test_rw::test:test', $handle); + + // Make sure enough time passes. + sleep(1); + } + + // Exceeded latency time, use ro handle. + $handle = $DB->get_records_sql("SELECT * FROM {table}"); + $this->assert_readonly_handle($handle); + } + + /** + * Test failed readonly connection falls back to write connection. + * + * @return void + */ + public function test_read_only_conn_fail() : void { + $DB = $this->new_db(false, 'test_ro_fail'); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + $this->assertNotNull($DB->get_dbhwrite()); + + $handle = $DB->get_records('table'); + $this->assertEquals('test_rw::test:test', $handle); + $readsslave = $DB->perf_get_reads_slave(); + $this->assertEquals(0, $readsslave); + } + + /** + * In multiple slaves scenario, test failed readonly connection falls back to + * another readonly connection. + * + * @return void + */ + public function test_read_only_conn_first_fail() : void { + $DB = $this->new_db(false, ['test_ro_fail', 'test_ro_ok']); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + $this->assertNull($DB->get_dbhwrite()); + + $handle = $DB->get_records('table'); + $this->assertEquals('test_ro_ok::test:test', $handle); + $readsslave = $DB->perf_get_reads_slave(); + $this->assertEquals(1, $readsslave); + } + + /** + * Helper to restore global $DB + * + * @param callable $test + * @return void + */ + private function with_global_db($test) { + global $DB; + + $dbsave = $DB; + try { + $test(); + } + finally { + $DB = $dbsave; + } + } + + /** + * Test lock_db table exclusion + * + * @return void + */ + public function test_lock_db() : void { + $this->with_global_db(function () { + global $DB; + + $DB = $this->new_db(true, ['test_ro'], read_slave_moodle_database_special::class); + $DB->set_tables([ + 'lock_db' => [ + 'columns' => [ + 'resourcekey' => (object)['meta_type' => ''], + 'owner' => (object)['meta_type' => ''], + ] + ] + ]); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + $this->assertNull($DB->get_dbhwrite()); + + $lockfactory = new \core\lock\db_record_lock_factory('default'); + if (!$lockfactory->is_available()) { + $this->markTestSkipped("db_record_lock_factory not available"); + } + + $lock = $lockfactory->get_lock('abc', 2); + $lock->release(); + $this->assertEquals(0, $DB->perf_get_reads_slave()); + $this->assertTrue($DB->perf_get_reads() > 0); + }); + } + + /** + * Test sessions table exclusion + * + * @return void + */ + public function test_sessions() : void { + $this->with_global_db(function () { + global $DB, $CFG; + + $CFG->dbsessions = true; + $DB = $this->new_db(true, ['test_ro'], read_slave_moodle_database_special::class); + $DB->set_tables([ + 'sessions' => [ + 'columns' => [ + 'sid' => (object)['meta_type' => ''], + ] + ] + ]); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + $this->assertNull($DB->get_dbhwrite()); + + $session = new \core\session\database(); + $session->handler_read('dummy'); + + $this->assertEquals(0, $DB->perf_get_reads_slave()); + $this->assertTrue($DB->perf_get_reads() > 0); + }); + + \core\session\manager::restart_with_write_lock(); + } +} diff --git a/lib/dml/tests/dml_test.php b/lib/dml/tests/dml_test.php index 609c200a63a..c1fe0f893e0 100644 --- a/lib/dml/tests/dml_test.php +++ b/lib/dml/tests/dml_test.php @@ -5140,6 +5140,16 @@ class core_dml_testcase extends database_driver_testcase { if (!isset($cfg->dboptions)) { $cfg->dboptions = array(); } + // If we have a readonly slave situation, we need to either observe + // the latency, or if the latency is not specified we need to take + // the slave out because the table may not have propagated yet. + if (isset($cfg->dboptions['readonly'])) { + if (isset($cfg->dboptions['readonly']['latency'])) { + usleep(intval(1000000 * $cfg->dboptions['readonly']['latency'])); + } else { + unset($cfg->dboptions['readonly']); + } + } $DB2 = moodle_database::get_driver_instance($cfg->dbtype, $cfg->dblibrary); $DB2->connect($cfg->dbhost, $cfg->dbuser, $cfg->dbpass, $cfg->dbname, $cfg->prefix, $cfg->dboptions); diff --git a/lib/dml/tests/fixtures/read_slave_moodle_database.php b/lib/dml/tests/fixtures/read_slave_moodle_database.php new file mode 100644 index 00000000000..6029e91a620 --- /dev/null +++ b/lib/dml/tests/fixtures/read_slave_moodle_database.php @@ -0,0 +1,232 @@ +. + +/** + * Database driver test class for testing moodle_read_slave_trait + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/test_moodle_database.php'); +require_once(__DIR__.'/../../moodle_read_slave_trait.php'); + +/** + * Database driver test class with moodle_read_slave_trait + * + * @package core + * @category dml + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class read_slave_moodle_database extends test_moodle_database { + use moodle_read_slave_trait; + + /** @var string */ + protected $handle; + + /** + * Does not connect to the database. Sets handle property to $dbhost + * @param string $dbhost + * @param string $dbuser + * @param string $dbpass + * @param string $dbname + * @param mixed $prefix + * @param array $dboptions + * @return bool true + */ + public function raw_connect(string $dbhost, string $dbuser, string $dbpass, string $dbname, $prefix, array $dboptions = null): bool { + $dbport = isset($dboptions['dbport']) ? $dboptions['dbport'] : ""; + $this->handle = implode(':', [$dbhost, $dbport, $dbuser, $dbpass]); + $this->prefix = $prefix; + + if ($dbhost == 'test_ro_fail') { + throw new dml_connection_exception($dbhost); + } + + return true; + } + + /** + * Begin database transaction + * @return void + */ + protected function begin_transaction() { + } + + /** + * Commit database transaction + * @return void + */ + protected function commit_transaction() { + } + + /** + * Abort database transaction + * @return void + */ + protected function rollback_transaction() { + $this->txnhandle = $this->handle; + } + + /** + * Query wrapper that calls query_start() and query_end() + * @param string $sql + * @param array $params + * @param int $querytype + * @return string $handle handle property + */ + private function with_query_start_end($sql, array $params = null, $querytype) { + $this->query_start($sql, $params, $querytype); + $ret = $this->handle; + $this->query_end(null); + return $ret; + } + + /** + * get_dbhwrite() + * @return string $dbhwrite handle property + */ + public function get_dbhwrite() { + return $this->dbhwrite; + } + + /** + * Calls with_query_start_end() + * @param string $sql + * @param array $params + * @return bool true + * @throws Exception + */ + public function execute($sql, array $params = null) { + list($sql, $params, $type) = $this->fix_sql_params($sql, $params); + return $this->with_query_start_end($sql, $params, SQL_QUERY_UPDATE); + } + + /** + * get_records_sql() override, calls with_query_start_end() + * @param string $sql the SQL select query to execute. + * @param array $params array of sql parameters + * @param int $limitfrom return a subset of records, starting at this point (optional). + * @param int $limitnum return a subset comprising this many records (optional, required if $limitfrom is set). + * @return string $handle handle property + */ + public function get_records_sql($sql, array $params = null, $limitfrom = 0, $limitnum = 0) { + list($sql, $params, $type) = $this->fix_sql_params($sql, $params); + return $this->with_query_start_end($sql, $params, SQL_QUERY_SELECT); + } + + /** + * Calls with_query_start_end() + * @param string $sql + * @param array $params + * @param int $limitfrom + * @param int $limitnum + * @return bool true + */ + public function get_recordset_sql($sql, array $params = null, $limitfrom = 0, $limitnum = 0) { + list($sql, $params, $type) = $this->fix_sql_params($sql, $params); + return $this->with_query_start_end($sql, $params, SQL_QUERY_SELECT); + } + + /** + * Calls with_query_start_end() + * @param string $table + * @param array $params + * @param bool $returnid + * @param bool $bulk + * @param bool $customsequence + * @return string $handle handle property + */ + public function insert_record_raw($table, $params, $returnid = true, $bulk = false, $customsequence = false) { + $fields = implode(',', array_keys($params)); + $i = 1; + foreach ($params as $value) { + $values[] = "\$".$i++; + } + $values = implode(',', $values); + $sql = "INSERT INTO {$this->prefix}$table ($fields) VALUES($values)"; + return $this->with_query_start_end($sql, $params, SQL_QUERY_INSERT); + } + + /** + * Calls with_query_start_end() + * @param string $table + * @param array $params + * @param bool $bulk + * @return string $handle handle property + */ + public function update_record_raw($table, $params, $bulk = false) { + $id = $params['id']; + unset($params['id']); + $i = 1; + $sets = array(); + foreach ($params as $field => $value) { + $sets[] = "$field = \$".$i++; + } + $params[] = $id; + $sets = implode(',', $sets); + $sql = "UPDATE {$this->prefix}$table SET $sets WHERE id=\$".$i; + return $this->with_query_start_end($sql, $params, SQL_QUERY_UPDATE); + } + + /** + * Gets handle property + * @return string $handle handle property + */ + protected function get_db_handle() { + return $this->handle; + } + + /** + * Sets handle property + * @param string $dbh + * @return void + */ + protected function set_db_handle($dbh) { + $this->handle = $dbh; + } + + /** + * Add temptable + * @param string $temptable + * @return void + */ + public function add_temptable($temptable) { + $this->temptables->add_temptable($temptable); + } + + /** + * Remove temptable + * @param string $temptable + * @return void + */ + public function delete_temptable($temptable) { + $this->temptables->delete_temptable($temptable); + } + + /** + * Is session lock supported in this driver? + * @return bool + */ + public function session_lock_supported() { + return true; + } +} diff --git a/lib/dml/tests/fixtures/read_slave_moodle_database_mock_mysqli.php b/lib/dml/tests/fixtures/read_slave_moodle_database_mock_mysqli.php new file mode 100644 index 00000000000..29165f6b3ba --- /dev/null +++ b/lib/dml/tests/fixtures/read_slave_moodle_database_mock_mysqli.php @@ -0,0 +1,64 @@ +. + +/** + * Database driver test class for testing mysqli_native_moodle_database with moodle_read_slave_trait + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/../../mysqli_native_moodle_database.php'); +require_once(__DIR__.'/test_moodle_read_slave_trait.php'); + +/** + * Database driver mock test class that exposes some methods + * + * @package core + * @category dml + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class read_slave_moodle_database_mock_mysqli extends mysqli_native_moodle_database { + use test_moodle_read_slave_trait; + + /** + * Return tables in database WITHOUT current prefix + * @param bool $usecache if true, returns list of cached tables. + * @return array of table names in lowercase and without prefix + */ + public function get_tables($usecache = true) { + if ($this->tables === null) { + $this->tables = []; + } + return $this->tables; + } + + /** + * To be used by database_manager + * @param string|array $sql query + * @param array|null $tablenames an array of xmldb table names affected by this request. + * @return bool true + * @throws ddl_change_structure_exception A DDL specific exception is thrown for any errors. + */ + public function change_database_structure($sql, $tablenames = null) { + return true; + } +} diff --git a/lib/dml/tests/fixtures/read_slave_moodle_database_mock_pgsql.php b/lib/dml/tests/fixtures/read_slave_moodle_database_mock_pgsql.php new file mode 100644 index 00000000000..60367fdd5f4 --- /dev/null +++ b/lib/dml/tests/fixtures/read_slave_moodle_database_mock_pgsql.php @@ -0,0 +1,41 @@ +. + +/** + * Database driver test class for testing pgsql_native_moodle_database with moodle_read_slave_trait + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/../../pgsql_native_moodle_database.php'); +require_once(__DIR__.'/test_moodle_read_slave_trait.php'); + +/** + * Database driver mock test class that exposes some methods + * + * @package core + * @category dml + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class read_slave_moodle_database_mock_pgsql extends pgsql_native_moodle_database { + use test_moodle_read_slave_trait; +} diff --git a/lib/dml/tests/fixtures/read_slave_moodle_database_special.php b/lib/dml/tests/fixtures/read_slave_moodle_database_special.php new file mode 100644 index 00000000000..f34491935ee --- /dev/null +++ b/lib/dml/tests/fixtures/read_slave_moodle_database_special.php @@ -0,0 +1,76 @@ +. + +/** + * Database driver test class for testing moodle_read_slave_trait + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/read_slave_moodle_database.php'); +require_once(__DIR__.'/read_slave_moodle_recordset_special.php'); + +/** + * Database driver mock test class that uses read_slave_moodle_recordset_special + * + * @package core + * @category dml + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class read_slave_moodle_database_special extends read_slave_moodle_database { + /** + * Returns empty array + * @param string $sql the SQL select query to execute. + * @param array $params array of sql parameters + * @param int $limitfrom return a subset of records, starting at this point (optional). + * @param int $limitnum return a subset comprising this many records (optional, required if $limitfrom is set). + * @return string $handle handle property + */ + public function get_records_sql($sql, array $params = null, $limitfrom = 0, $limitnum = 0) { + $dbhandle = parent::get_records_sql($sql, $params); + return []; + } + + /** + * Returns fake recordset + * @param string $sql + * @param array $params + * @param int $limitfrom + * @param int $limitnum + * @return bool true + */ + public function get_recordset_sql($sql, array $params = null, $limitfrom = 0, $limitnum = 0) { + $dbhandle = parent::get_recordset_sql($sql, $params); + return new read_slave_moodle_recordset_special(); + } + + /** + * Count the records in a table where all the given conditions met. + * + * @param string $table The table to query. + * @param array $conditions optional array $fieldname=>requestedvalue with AND in between + * @return int The count of records returned from the specified criteria. + */ + public function count_records($table, array $conditions = null) { + return 1; + } +} diff --git a/lib/dml/tests/fixtures/read_slave_moodle_database_table_names.php b/lib/dml/tests/fixtures/read_slave_moodle_database_table_names.php new file mode 100644 index 00000000000..a0a199c8716 --- /dev/null +++ b/lib/dml/tests/fixtures/read_slave_moodle_database_table_names.php @@ -0,0 +1,52 @@ +. + +/** + * Database driver test class for testing moodle_read_slave_trait + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/read_slave_moodle_database.php'); + +/** + * Database driver test class that exposes table_names() + * + * @package core + * @category dml + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class read_slave_moodle_database_table_names extends read_slave_moodle_database { + /** + * @var string + */ + protected $prefix = 't_'; + + /** + * Upgrade to public + * @param string $sql + * @return array + */ + public function table_names(string $sql) : array { + return parent::table_names($sql); + } +} diff --git a/lib/dml/tests/fixtures/read_slave_moodle_recordset_special.php b/lib/dml/tests/fixtures/read_slave_moodle_recordset_special.php new file mode 100644 index 00000000000..452385f557d --- /dev/null +++ b/lib/dml/tests/fixtures/read_slave_moodle_recordset_special.php @@ -0,0 +1,69 @@ +. + +/** + * Database driver test class for testing moodle_read_slave_trait + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Database recordset mock test class + * + * @package core + * @category dml + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class read_slave_moodle_recordset_special extends moodle_recordset { + /** + * Iterator interface + * @return void + */ + public function close() { + } + /** + * Iterator interface + * @return stdClass + */ + public function current() { + return new stdClass(); + } + /** + * Iterator interface + * @return void + */ + public function next() { + } + /** + * Iterator interface + * @return mixed + */ + public function key() { + } + /** + * Iterator interface + * @return bool + */ + public function valid() { + return false; + } +} diff --git a/lib/dml/tests/fixtures/test_moodle_database.php b/lib/dml/tests/fixtures/test_moodle_database.php new file mode 100644 index 00000000000..fcc4bfa9b3a --- /dev/null +++ b/lib/dml/tests/fixtures/test_moodle_database.php @@ -0,0 +1,384 @@ +. + +/** + * Abstract database driver test class providing some moodle database interface + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/../../moodle_database.php'); +require_once(__DIR__.'/../../moodle_temptables.php'); +require_once(__DIR__.'/../../../ddl/database_manager.php'); +require_once(__DIR__.'/test_sql_generator.php'); + +/** + * Abstract database driver test class + * + * @package core + * @category dml + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class test_moodle_database extends moodle_database { + + /** @var string */ + private $error; + + /** @var array */ + private $_tables = []; + + /** + * Constructor - Instantiates the database + * @param bool $external True means that an external database is used. + */ + public function __construct($external = false) { + parent::__construct($external); + + $this->temptables = new moodle_temptables($this); + } + + /** + * Default implementation + * @return boolean true + */ + public function driver_installed() { + return true; + } + + /** + * Default implementation + * @return string 'test' + */ + public function get_dbfamily() { + return 'test'; + } + + /** + * Default implementation + * @return string 'test' + */ + protected function get_dbtype() { + return 'test'; + } + + /** + * Default implementation + * @return string 'test' + */ + protected function get_dblibrary() { + return 'test'; + } + + /** + * Default implementation + * @return string 'test' + */ + public function get_name() { + return 'test'; + } + + /** + * Default implementation + * @return string + */ + public function get_configuration_help() { + return 'test database driver'; + } + + /** + * Default implementation + * @return array + */ + public function get_server_info() { + return ['description' => $this->name(), 'version' => '0']; + } + + /** + * Default implementation + * @return int 0 + */ + protected function allowed_param_types() { + return 0; + } + + /** + * Returns error property + * @return string $error + */ + public function get_last_error() { + return $this->error; + } + + /** + * Sets tables property + * @param array $tables + * @return void + */ + public function set_tables($tables) { + $this->_tables = $tables; + } + + /** + * Returns keys of tables property + * @param bool $usecache + * @return array $tablenames + */ + public function get_tables($usecache = true) { + return array_keys($this->_tables); + } + + /** + * Return table indexes + * @param string $table + * @return array $indexes + */ + public function get_indexes($table) { + return isset($this->_tables[$table]['indexes']) ? $this->_tables[$table]['indexes'] : []; + } + + /** + * Return table columns + * @param string $table + * @return array database_column_info[] of database_column_info objects indexed with column names + */ + public function fetch_columns($table) : array { + return $this->_tables[$table]['columns']; + } + + /** + * Default implementation + * @param StdClass $column metadata + * @param mixed $value + * @return mixed $value + */ + protected function normalise_value($column, $value) { + return $value; + } + + /** + * Default implementation + * @param string|array $sql + * @param array|null $tablenames + * @return bool true + */ + public function change_database_structure($sql, $tablenames = null) { + return true; + } + + /** + * Default implementation, throws Exception + * @param string $sql + * @param array $params + * @return bool true + * @throws Exception + */ + public function execute($sql, array $params = null) { + throw new Exception("execute() not implemented"); + } + + /** + * Default implementation, throws Exception + * @param string $sql + * @param array $params + * @param int $limitfrom + * @param int $limitnum + * @return bool true + * @throws Exception + */ + public function get_recordset_sql($sql, array $params = null, $limitfrom = 0, $limitnum = 0) { + throw new Exception("get_recordset_sql() not implemented"); + } + + /** + * Default implementation, throws Exception + * @param string $sql + * @param array $params + * @param int $limitfrom + * @param int $limitnum + * @return bool true + * @throws Exception + */ + public function get_records_sql($sql, array $params = null, $limitfrom = 0, $limitnum = 0) { + throw new Exception("get_records_sql() not implemented"); + } + + /** + * Default implementation, throws Exception + * @param string $sql + * @param array $params + * @return bool true + * @throws Exception + */ + public function get_fieldset_sql($sql, array $params = null) { + throw new Exception("get_fieldset_sql() not implemented"); + } + + /** + * Default implementation, throws Exception + * @param string $table + * @param array $params + * @param bool $returnid + * @param bool $bulk + * @param bool $customsequence + * @return bool|int true or new id + * @throws Exception + */ + public function insert_record_raw($table, $params, $returnid = true, $bulk = false, $customsequence = false) { + throw new Exception("insert_record_raw() not implemented"); + } + + /** + * Default implementation, throws Exception + * @param string $table + * @param StdObject $dataobject + * @param bool $returnid + * @param bool $bulk + * @return bool|int true or new id + * @throws Exception + */ + public function insert_record($table, $dataobject, $returnid = true, $bulk = false) { + return $this->insert_record_raw($table, (array)$dataobject, $returnid, $bulk); + } + + /** + * Default implementation, throws Exception + * @param string $table + * @param StdObject $dataobject + * @return bool true + * @throws Exception + */ + public function import_record($table, $dataobject) { + throw new Exception("import_record() not implemented"); + } + + /** + * Default implementation, throws Exception + * @param string $table + * @param array $params + * @param bool $bulk + * @return bool true + * @throws Exception + */ + public function update_record_raw($table, $params, $bulk = false) { + throw new Exception("update_record_raw() not implemented"); + } + + /** + * Default implementation, throws Exception + * @param string $table + * @param StdObject $dataobject + * @param bool $bulk + * @return bool true + * @throws Exception + */ + public function update_record($table, $dataobject, $bulk = false) { + throw new Exception("update_record() not implemented"); + } + + /** + * Default implementation, throws Exception + * @param string $table + * @param string $newfield + * @param string $newvalue + * @param string $select + * @param array $params + * @return bool true + * @throws Exception + */ + public function set_field_select($table, $newfield, $newvalue, $select, array $params = null) { + throw new Exception("set_field_select() not implemented"); + } + + /** + * Default implementation, throws Exception + * @param string $table + * @param string $select + * @param array $params + * @return bool true + * @throws Exception + */ + public function delete_records_select($table, $select, array $params = null) { + throw new Exception("delete_records_select() not implemented"); + } + + /** + * Default implementation, throws Exception + * @return string $sql + * @throws Exception + */ + public function sql_concat() { + throw new Exception("sql_concat() not implemented"); + } + + /** + * Default implementation, throws Exception + * @param string $separator + * @param array $elements + * @return string $sql + * @throws Exception + */ + public function sql_concat_join($separator = "' '", $elements = []) { + throw new Exception("sql_concat_join() not implemented"); + } + + /** + * Default implementation, throws Exception + * @return void + * @throws Exception + */ + protected function begin_transaction() { + throw new Exception("begin_transaction() not implemented"); + } + + /** + * Default implementation, throws Exception + * @return void + * @throws Exception + */ + protected function commit_transaction() { + throw new Exception("commit_transaction() not implemented"); + } + + /** + * Default implementation, throws Exception + * @return void + * @throws Exception + */ + protected function rollback_transaction() { + throw new Exception("rollback_transaction() not implemented"); + } + + /** + * Returns the database manager used for db manipulation. + * Used mostly in upgrade.php scripts. + * @return database_manager The instance used to perform ddl operations. + * @see lib/ddl/database_manager.php + */ + public function get_manager() { + if (!$this->database_manager) { + $generator = new test_sql_generator($this, $this->temptables); + + $this->database_manager = new database_manager($this, $generator); + } + return $this->database_manager; + } +} diff --git a/lib/dml/tests/fixtures/test_moodle_read_slave_trait.php b/lib/dml/tests/fixtures/test_moodle_read_slave_trait.php new file mode 100644 index 00000000000..bba77655352 --- /dev/null +++ b/lib/dml/tests/fixtures/test_moodle_read_slave_trait.php @@ -0,0 +1,89 @@ +. + +/** + * Read slave helper that exposes selected moodle_read_slave_trait metods + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/../../pgsql_native_moodle_database.php'); + +/** + * Read slave helper that exposes selected moodle_read_slave_trait metods + * + * @package core + * @category dml + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait test_moodle_read_slave_trait { + // @codingStandardsIgnoreStart + /** + * Constructs a mock db driver + * + * @param bool $external + */ + public function __construct($external = false) { + // @codingStandardsIgnoreEnd + parent::__construct($external); + + $this->wantreadslave = true; + $this->dbhwrite = 'test_rw'; + $this->dbhreadonly = 'test_ro'; + $this->set_db_handle($this->dbhwrite); + + $this->temptables = new moodle_temptables($this); + } + + /** + * Upgrade to public + * @return resource + */ + public function get_db_handle() { + return parent::get_db_handle(); + } + + /** + * Upgrade to public + * @param string $sql + * @param array $params + * @param int $type + * @param array $extrainfo + */ + public function query_start($sql, array $params = null, $type, $extrainfo = null) { + return parent::query_start($sql, $params, $type); + } + + /** + * Upgrade to public + * @param mixed $result + */ + public function query_end($result) { + $this->set_db_handle($this->dbhwrite); + } + + /** + * Upgrade to public + */ + public function dispose() { + } +} diff --git a/lib/dml/tests/fixtures/test_sql_generator.php b/lib/dml/tests/fixtures/test_sql_generator.php new file mode 100644 index 00000000000..d4bb7b2261d --- /dev/null +++ b/lib/dml/tests/fixtures/test_sql_generator.php @@ -0,0 +1,128 @@ +. + +/** + * Test SQL code generator class + * + * @package core + * @category dml + * @copyright 2018 Srdjan Janković, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/../../../ddl/sql_generator.php'); + +/** + * Test SQL code generator class + * + * @package core + * @category ddl + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class test_sql_generator extends sql_generator { + // @codingStandardsIgnoreStart + /** + * Reset a sequence to the id field of a table. + * + * @param xmldb_table|string $table name of table or the table object. + * @return array of sql statements + */ + public function getResetSequenceSQL($table) { + // @codingStandardsIgnoreEnd + return []; + } + + // @codingStandardsIgnoreStart + /** + * Given one correct xmldb_table, returns the SQL statements + * to create temporary table (inside one array). + * + * @param xmldb_table $xmldbtable The xmldb_table object instance. + * @return array of sql statements + */ + public function getCreateTempTableSQL($xmldbtable) { + // @codingStandardsIgnoreEnd + return []; + } + + // @codingStandardsIgnoreStart + /** + * Given one XMLDB Type, length and decimals, returns the DB proper SQL type. + * + * @param int $xmldbtype The xmldb_type defined constant. XMLDB_TYPE_INTEGER and other XMLDB_TYPE_* constants. + * @param int $xmldblength The length of that data type. + * @param int $xmldbdecimals The decimal places of precision of the data type. + * @return string The DB defined data type. + */ + public function getTypeSQL($xmldbtype, $xmldblength = null, $xmldbdecimals = null) { + // @codingStandardsIgnoreEnd + return ''; + } + + // @codingStandardsIgnoreStart + /** + * Returns the code (array of statements) needed to add one comment to the table. + * + * @param xmldb_table $xmldbtable The xmldb_table object instance. + * @return array Array of SQL statements to add one comment to the table. + */ + function getCommentSQL ($xmldbtable) { + // @codingStandardsIgnoreEnd + return []; + } + + // @codingStandardsIgnoreStart + /** + * Given one xmldb_table and one xmldb_field, return the SQL statements needed to add its default + * (usually invoked from getModifyDefaultSQL() + * + * @param xmldb_table $xmldbtable The xmldb_table object instance. + * @param xmldb_field $xmldbfield The xmldb_field object instance. + * @return array Array of SQL statements to create a field's default. + */ + public function getCreateDefaultSQL($xmldbtable, $xmldbfield) { + // @codingStandardsIgnoreEnd + return []; + } + + // @codingStandardsIgnoreStart + /** + * Given one xmldb_table and one xmldb_field, return the SQL statements needed to drop its default + * (usually invoked from getModifyDefaultSQL() + * + * @param xmldb_table $xmldbtable The xmldb_table object instance. + * @param xmldb_field $xmldbfield The xmldb_field object instance. + * @return array Array of SQL statements to create a field's default. + */ + public function getDropDefaultSQL($xmldbtable, $xmldbfield) { + // @codingStandardsIgnoreEnd + return []; + } + + // @codingStandardsIgnoreStart + /** + * Returns an array of reserved words (lowercase) for this DB + * @return array An array of database specific reserved words + */ + public static function getReservedWords() { + // @codingStandardsIgnoreEnd + return []; + } + +} diff --git a/lib/moodlelib.php b/lib/moodlelib.php index f1c9a30e560..4bfa5ab7961 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -9523,6 +9523,12 @@ function get_performance_info() { $info['html'] .= '
  • DB reads/writes: '.$info['dbqueries'].'
  • '; $info['txt'] .= 'db reads/writes: '.$info['dbqueries'].' '; + if ($DB->want_read_slave()) { + $info['dbreads_slave'] = $DB->perf_get_reads_slave(); + $info['html'] .= '
  • DB reads from slave: '.$info['dbreads_slave'].'
  • '; + $info['txt'] .= 'db reads from slave: '.$info['dbreads_slave'].' '; + } + $info['dbtime'] = round($DB->perf_get_queries_time(), 5); $info['html'] .= '
  • DB queries time: '.$info['dbtime'].' secs
  • '; $info['txt'] .= 'db queries time: ' . $info['dbtime'] . 's ';