Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
<?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
 */

namespace core;

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');
require_once(__DIR__.'/../../tests/fixtures/event_fixtures.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
 * @covers     \moodle_read_slave_trait
 */
class dml_read_slave_test 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->assertMatchesRegularExpression('/^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());

        $handle = $DB->get_records('table');
        $this->assertEquals('test_rw::test:test', $handle);
        $this->assertEquals(0, $DB->perf_get_reads_slave());

        $handle = $DB->get_records_sql("SELECT * FROM {table2} JOIN {table}");
        $this->assertEquals('test_rw::test:test', $handle);
        $this->assertEquals(0, $DB->perf_get_reads_slave());

        sleep(1);

        $handle = $DB->get_records('table');
        $this->assert_readonly_handle($handle);
        $this->assertEquals(1, $DB->perf_get_reads_slave());

        $handle = $DB->get_records('table2');
        $this->assert_readonly_handle($handle);
        $this->assertEquals(2, $DB->perf_get_reads_slave());

        $handle = $DB->get_records_sql("SELECT * FROM {table2} JOIN {table}");
        $this->assert_readonly_handle($handle);
        $this->assertEquals(3, $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
     * @covers ::can_use_readonly
     * @covers ::commit_delegated_transaction
     */
    public function test_transaction(): void {
        $DB = $this->new_db(true);

        $this->assertNull($DB->get_dbhwrite());

        $skip = false;
        $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);
        } else {
            $skip = true;
        }

        // Exceeded latency time, use ro handle.
        $handle = $DB->get_records_sql("SELECT * FROM {table}");
        $this->assert_readonly_handle($handle);

        if ($skip) {
            $this->markTestSkipped("Delay too long to test write handle immediately after transaction");
        }
    }

    /**
     * Test readonly handle is not used immediately after update
     * Test last written time is adjusted post-write,
     * so the latency parameter is applied properly.
     *
     * @return void
     * @covers ::can_use_readonly
     * @covers ::query_end
     */
    public function test_long_update(): void {
        $DB = $this->new_db(true);

        $this->assertNull($DB->get_dbhwrite());

        $skip = false;

        list($sql, $params, $ptype) = $DB->fix_sql_params("UPDATE {table} SET a = 1 WHERE id = 1");
        $DB->with_query_start_end($sql, $params, SQL_QUERY_UPDATE, function ($dbh) use (&$now) {
            sleep(1);
            $now = microtime(true);
        });

        // This condition should always evaluate true, however we need to
        // safeguard from an unaccounted delay that can break this test.
        if (microtime(true) - $now < 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);
        } else {
            $skip = true;
        }

        // Exceeded latency time, use ro handle.
        $handle = $DB->get_records_sql("SELECT * FROM {table}");
        $this->assert_readonly_handle($handle);

        if ($skip) {
            $this->markTestSkipped("Delay too long to test write handle immediately after transaction");
        }
    }

    /**
     * Test readonly handle is not used with events
     * when the latency parameter is applied properly.
     *
     * @return void
     * @covers ::can_use_readonly
     * @covers ::commit_delegated_transaction
     */
    public function test_transaction_with_events(): void {
        $this->with_global_db(function () {
            global $DB;

            $DB = $this->new_db(true, ['test_ro'], read_slave_moodle_database_special::class);
            $DB->set_tables([
                'config_plugins' => [
                    'columns' => [
                        'plugin' => (object)['meta_type' => ''],
                    ]
                ]
            ]);

            $this->assertNull($DB->get_dbhwrite());

< $this->_called = false;
> $called = false;
$transaction = $DB->start_delegated_transaction(); $now = microtime(true); $observers = [ [ 'eventname' => '\core_tests\event\unittest_executed',
< 'callback' => function (\core_tests\event\unittest_executed $event) use ($DB, $now) { < $this->_called = true;
> 'callback' => function (\core_tests\event\unittest_executed $event) use ($DB, $now, &$called) { > $called = true;
$this->assertFalse($DB->is_transaction_started()); // 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_p("SELECT * FROM {table}"); $this->assertEquals('test_rw::test:test', $handle); // Make sure enough time passes. sleep(1); } else { $this->markTestSkipped("Delay too long to test write handle immediately after transaction"); } // Exceeded latency time, use ro handle. $handle = $DB->get_records_sql_p("SELECT * FROM {table}"); $this->assertEquals('test_ro::test:test', $handle); }, 'internal' => 0, ], ]; \core\event\manager::phpunit_replace_observers($observers); $handle = $DB->get_records_sql_p("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); $event = \core_tests\event\unittest_executed::create([ 'context' => \context_system::instance(), 'other' => ['sample' => 1] ]); $event->trigger(); $transaction->allow_commit();
< $this->assertTrue($this->_called);
> $this->assertTrue($called);
}); } /** * 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(false); } }