Merge branch 'srdjan-slave-conn' of https://github.com/srdjan-catalyst/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Tue, 19 May 2020 04:29:20 +0000 (12:29 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Tue, 19 May 2020 04:29:20 +0000 (12:29 +0800)
19 files changed:
config-dist.php
lib/dml/moodle_database.php
lib/dml/moodle_read_slave_trait.php [new file with mode: 0644]
lib/dml/mysqli_native_moodle_database.php
lib/dml/pgsql_native_moodle_database.php
lib/dml/tests/dml_mysqli_read_slave_test.php [new file with mode: 0644]
lib/dml/tests/dml_pgsql_read_slave_test.php [new file with mode: 0644]
lib/dml/tests/dml_read_slave_test.php [new file with mode: 0644]
lib/dml/tests/dml_test.php
lib/dml/tests/fixtures/read_slave_moodle_database.php [new file with mode: 0644]
lib/dml/tests/fixtures/read_slave_moodle_database_mock_mysqli.php [new file with mode: 0644]
lib/dml/tests/fixtures/read_slave_moodle_database_mock_pgsql.php [new file with mode: 0644]
lib/dml/tests/fixtures/read_slave_moodle_database_special.php [new file with mode: 0644]
lib/dml/tests/fixtures/read_slave_moodle_database_table_names.php [new file with mode: 0644]
lib/dml/tests/fixtures/read_slave_moodle_recordset_special.php [new file with mode: 0644]
lib/dml/tests/fixtures/test_moodle_database.php [new file with mode: 0644]
lib/dml/tests/fixtures/test_moodle_read_slave_trait.php [new file with mode: 0644]
lib/dml/tests/fixtures/test_sql_generator.php [new file with mode: 0644]
lib/moodlelib.php

index 90989bb..30855a4 100644 (file)
@@ -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
 );
 
 
index 1d86919..db0a31c 100644 (file)
@@ -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 (file)
index 0000000..95d05a4
--- /dev/null
@@ -0,0 +1,393 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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];
+    }
+}
index 0b58d07..80dc020 100644 (file)
@@ -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
index 9bd1c88..848cee1 100644 (file)
@@ -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 (file)
index 0000000..d2d8a95
--- /dev/null
@@ -0,0 +1,87 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 (file)
index 0000000..8cb49ee
--- /dev/null
@@ -0,0 +1,162 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 (file)
index 0000000..541f2ee
--- /dev/null
@@ -0,0 +1,432 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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();
+    }
+}
index 609c200..c1fe0f8 100644 (file)
@@ -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 (file)
index 0000000..6029e91
--- /dev/null
@@ -0,0 +1,232 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 (file)
index 0000000..29165f6
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 (file)
index 0000000..60367fd
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 (file)
index 0000000..f344919
--- /dev/null
@@ -0,0 +1,76 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 (file)
index 0000000..a0a199c
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 (file)
index 0000000..452385f
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 (file)
index 0000000..fcc4bfa
--- /dev/null
@@ -0,0 +1,384 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 (file)
index 0000000..bba7765
--- /dev/null
@@ -0,0 +1,89 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 (file)
index 0000000..d4bb7b2
--- /dev/null
@@ -0,0 +1,128 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 [];
+    }
+
+}
index d5d2735..6f4a6c0 100644 (file)
@@ -9530,6 +9530,12 @@ function get_performance_info() {
     $info['html'] .= '<li class="dbqueries col-sm-4">DB reads/writes: '.$info['dbqueries'].'</li> ';
     $info['txt'] .= 'db reads/writes: '.$info['dbqueries'].' ';
 
+    if ($DB->want_read_slave()) {
+        $info['dbreads_slave'] = $DB->perf_get_reads_slave();
+        $info['html'] .= '<li class="dbqueries col-sm-4">DB reads from slave: '.$info['dbreads_slave'].'</li> ';
+        $info['txt'] .= 'db reads from slave: '.$info['dbreads_slave'].' ';
+    }
+
     $info['dbtime'] = round($DB->perf_get_queries_time(), 5);
     $info['html'] .= '<li class="dbtime col-sm-4">DB queries time: '.$info['dbtime'].' secs</li> ';
     $info['txt'] .= 'db queries time: ' . $info['dbtime'] . 's ';