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 core_dml_read_slave_testcase extends base_testcase {> 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->assertRegExp('/^test_ro\d:\d:test\d:test\d$/', $handle);> $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());< 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');> $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'); $handle = $DB->get_records_sql("SELECT * FROM {table2} JOIN {table}"); > $this->assert_readonly_handle($handle); $this->assertEquals('test_rw::test:test', $handle); > $this->assertEquals(2, $DB->perf_get_reads_slave()); $this->assertEquals(1, $DB->perf_get_reads_slave()); >< $this->assertEquals('test_rw::test:test', $handle); < $this->assertEquals(1, $DB->perf_get_reads_slave());> $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_transactionpublic 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 failed readonly connection falls back to write connection. > } * > * @return void > /** */ > * Test readonly handle is not used immediately after update public function test_read_only_conn_fail() : void { > * Test last written time is adjusted post-write, $DB = $this->new_db(false, 'test_ro_fail'); > * so the latency parameter is applied properly. > * $this->assertEquals(0, $DB->perf_get_reads_slave()); > * @return void $this->assertNotNull($DB->get_dbhwrite()); > * @covers ::can_use_readonly > * @covers ::query_end $handle = $DB->get_records('table'); > */ $this->assertEquals('test_rw::test:test', $handle); > public function test_long_update(): void { $readsslave = $DB->perf_get_reads_slave(); > $DB = $this->new_db(true); $this->assertEquals(0, $readsslave); > } > $this->assertNull($DB->get_dbhwrite()); > /** > $skip = false; * In multiple slaves scenario, test failed readonly connection falls back to > * another readonly connection. > 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) { * @return void > sleep(1); */ > $now = microtime(true); public function test_read_only_conn_first_fail() : void { > }); $DB = $this->new_db(false, ['test_ro_fail', 'test_ro_ok']); > > // This condition should always evaluate true, however we need to $this->assertEquals(0, $DB->perf_get_reads_slave()); > // safeguard from an unaccounted delay that can break this test. $this->assertNull($DB->get_dbhwrite()); > if (microtime(true) - $now < self::$dbreadonlylatency) { > // Not enough time passed, use rw handle. $handle = $DB->get_records('table'); > $handle = $DB->get_records_sql("SELECT * FROM {table}"); $this->assertEquals('test_ro_ok::test:test', $handle); > $this->assertEquals('test_rw::test:test', $handle); $readsslave = $DB->perf_get_reads_slave(); > $this->assertEquals(1, $readsslave); > // Make sure enough time passes. } > sleep(1); > } else { /** > $skip = true; * Helper to restore global $DB > } * > * @param callable $test > // Exceeded latency time, use ro handle. * @return void > $handle = $DB->get_records_sql("SELECT * FROM {table}"); */ > $this->assert_readonly_handle($handle); private function with_global_db($test) { > global $DB; > if ($skip) { > $this->markTestSkipped("Delay too long to test write handle immediately after transaction"); $dbsave = $DB; > } try { > } $test(); > } > /** finally { > * Test readonly handle is not used with events $DB = $dbsave; > * when the latency parameter is applied properly. } > * } > * @return void > * @covers ::can_use_readonly /** > * @covers ::commit_delegated_transaction * Test lock_db table exclusion > */ * > public function test_transaction_with_events(): void { * @return void > $this->with_global_db(function () { */ > global $DB; public function test_lock_db() : void { > $this->with_global_db(function () { > $DB = $this->new_db(true, ['test_ro'], read_slave_moodle_database_special::class); global $DB; > $DB->set_tables([ > 'config_plugins' => [ $DB = $this->new_db(true, ['test_ro'], read_slave_moodle_database_special::class); > 'columns' => [ $DB->set_tables([ > 'plugin' => (object)['meta_type' => ''], 'lock_db' => [ > ] 'columns' => [ > ] 'resourcekey' => (object)['meta_type' => ''], > ]); 'owner' => (object)['meta_type' => ''], > ] > $this->assertNull($DB->get_dbhwrite()); ] > ]); > $this->_called = false; > $transaction = $DB->start_delegated_transaction(); $this->assertEquals(0, $DB->perf_get_reads_slave()); > $now = microtime(true); $this->assertNull($DB->get_dbhwrite()); > > $observers = [ $lockfactory = new \core\lock\db_record_lock_factory('default'); > [ if (!$lockfactory->is_available()) { > 'eventname' => '\core_tests\event\unittest_executed', $this->markTestSkipped("db_record_lock_factory not available"); > 'callback' => function (\core_tests\event\unittest_executed $event) use ($DB, $now) { } > $this->_called = true; > $this->assertFalse($DB->is_transaction_started()); $lock = $lockfactory->get_lock('abc', 2); > $lock->release(); > // This condition should always evaluate true, however we need to $this->assertEquals(0, $DB->perf_get_reads_slave()); > // safeguard from an unaccounted delay that can break this test. $this->assertTrue($DB->perf_get_reads() > 0); > 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); /** > * Test sessions table exclusion > // Make sure enough time passes. * > sleep(1); * @return void > } else { */ > $this->markTestSkipped("Delay too long to test write handle immediately after transaction"); public function test_sessions() : void { > } $this->with_global_db(function () { > global $DB, $CFG; > // Exceeded latency time, use ro handle. > $handle = $DB->get_records_sql_p("SELECT * FROM {table}"); $CFG->dbsessions = true; > $this->assertEquals('test_ro::test:test', $handle); $DB = $this->new_db(true, ['test_ro'], read_slave_moodle_database_special::class); > }, $DB->set_tables([ > 'internal' => 0, 'sessions' => [ > ], 'columns' => [ > ]; 'sid' => (object)['meta_type' => ''], > \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); $this->assertEquals(0, $DB->perf_get_reads_slave()); > $this->assertNull($DB->get_dbhwrite()); > $handle = $DB->insert_record_raw('table', array('name' => 'blah')); > // Introduce delay so we can check that table write timestamps $session = new \core\session\database(); > // are adjusted properly. $session->handler_read('dummy'); > sleep(1); > $event = \core_tests\event\unittest_executed::create([ $this->assertEquals(0, $DB->perf_get_reads_slave()); > 'context' => \context_system::instance(), $this->assertTrue($DB->perf_get_reads() > 0); > 'other' => ['sample' => 1] }); > ]); > $event->trigger(); \core\session\manager::restart_with_write_lock(); > $transaction->allow_commit(); } > } > $this->assertTrue($this->_called); > });< \core\session\manager::restart_with_write_lock();> \core\session\manager::restart_with_write_lock(false);