mirror of
https://github.com/moodle/moodle.git
synced 2025-07-25 16:21:47 +02:00
Moved written table timestamping from query_start() to query_end(): We are adjusting table last written times at the end of transaction. That does not apply to immediate database writes that are not performed within transaction. This change is to set last written time after the query has finished for such writes, rather than before it started. That way long write operations cannot spill over the latency parameter.
574 lines
20 KiB
PHP
574 lines
20 KiB
PHP
<?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;
|
|
$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;
|
|
$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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|