See Release Notes
Long Term Support Release
<?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); } }