4 * Holds tests for LoadBalancer MediaWiki class.
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
24 use Wikimedia\Rdbms\DBError
;
25 use Wikimedia\Rdbms\DatabaseDomain
;
26 use Wikimedia\Rdbms\Database
;
27 use Wikimedia\Rdbms\LoadBalancer
;
28 use Wikimedia\Rdbms\LoadMonitorNull
;
29 use Wikimedia\TestingAccessWrapper
;
34 * @covers \Wikimedia\Rdbms\LoadBalancer
36 class LoadBalancerTest
extends MediaWikiTestCase
{
37 private function makeServerConfig( $flags = DBO_DEFAULT
) {
38 global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir;
41 'host' => $wgDBserver,
42 'dbname' => $wgDBname,
43 'tablePrefix' => $this->dbPrefix(),
45 'password' => $wgDBpassword,
47 'dbDirectory' => $wgSQLiteDataDir,
54 * @covers LoadBalancer::getLocalDomainID()
55 * @covers LoadBalancer::resolveDomainID()
57 public function testWithoutReplica() {
61 $lb = new LoadBalancer( [
62 // Simulate web request with DBO_TRX
63 'servers' => [ $this->makeServerConfig( DBO_TRX
) ],
64 'queryLogger' => MediaWiki\Logger\LoggerFactory
::getInstance( 'DBQuery' ),
65 'localDomain' => new DatabaseDomain( $wgDBname, null, $this->dbPrefix() ),
66 'chronologyCallback' => function () use ( &$called ) {
71 $ld = DatabaseDomain
::newFromId( $lb->getLocalDomainID() );
72 $this->assertEquals( $wgDBname, $ld->getDatabase(), 'local domain DB set' );
73 $this->assertEquals( $this->dbPrefix(), $ld->getTablePrefix(), 'local domain prefix set' );
74 $this->assertSame( 'my_test_wiki', $lb->resolveDomainID( 'my_test_wiki' ) );
75 $this->assertSame( $ld->getId(), $lb->resolveDomainID( false ) );
76 $this->assertSame( $ld->getId(), $lb->resolveDomainID( $ld ) );
77 $this->assertFalse( $called );
79 $dbw = $lb->getConnection( DB_MASTER
);
80 $this->assertTrue( $called );
81 $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' );
82 $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX
), "DBO_TRX set on master" );
83 $this->assertWriteAllowed( $dbw );
85 $dbr = $lb->getConnection( DB_REPLICA
);
86 $this->assertTrue( $dbr->getLBInfo( 'master' ), 'DB_REPLICA also gets the master' );
87 $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX
), "DBO_TRX set on replica" );
89 if ( !$lb->getServerAttributes( $lb->getWriterIndex() )[$dbw::ATTR_DB_LEVEL_LOCKING
] ) {
90 $dbwAuto = $lb->getConnection( DB_MASTER
, [], false, $lb::CONN_TRX_AUTOCOMMIT
);
92 $dbwAuto->getFlag( $dbw::DBO_TRX
), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" );
93 $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX
), "DBO_TRX still set on master" );
94 $this->assertNotEquals(
95 $dbw, $dbwAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" );
97 $dbrAuto = $lb->getConnection( DB_REPLICA
, [], false, $lb::CONN_TRX_AUTOCOMMIT
);
99 $dbrAuto->getFlag( $dbw::DBO_TRX
), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" );
100 $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX
), "DBO_TRX still set on replica" );
101 $this->assertNotEquals(
102 $dbr, $dbrAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" );
104 $dbwAuto2 = $lb->getConnection( DB_MASTER
, [], false, $lb::CONN_TRX_AUTOCOMMIT
);
105 $this->assertEquals( $dbwAuto2, $dbwAuto, "CONN_TRX_AUTOCOMMIT reuses connections" );
111 public function testWithReplica() {
114 // Simulate web request with DBO_TRX
115 $lb = $this->newMultiServerLocalLoadBalancer( [], [ 'flags' => DBO_TRX
] );
117 $this->assertEquals( 8, $lb->getServerCount() );
118 $this->assertTrue( $lb->hasReplicaServers() );
119 $this->assertTrue( $lb->hasStreamingReplicaServers() );
121 $dbw = $lb->getConnection( DB_MASTER
);
122 $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' );
124 ( $wgDBserver != '' ) ?
$wgDBserver : 'localhost',
125 $dbw->getLBInfo( 'clusterMasterHost' ),
126 'cluster master set' );
127 $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX
), "DBO_TRX set on master" );
128 $this->assertWriteAllowed( $dbw );
130 $dbr = $lb->getConnection( DB_REPLICA
);
131 $this->assertTrue( $dbr->getLBInfo( 'replica' ), 'replica shows as replica' );
133 ( $wgDBserver != '' ) ?
$wgDBserver : 'localhost',
134 $dbr->getLBInfo( 'clusterMasterHost' ),
135 'cluster master set' );
136 $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX
), "DBO_TRX set on replica" );
137 $this->assertWriteForbidden( $dbr );
139 if ( !$lb->getServerAttributes( $lb->getWriterIndex() )[$dbw::ATTR_DB_LEVEL_LOCKING
] ) {
140 $dbwAuto = $lb->getConnection( DB_MASTER
, [], false, $lb::CONN_TRX_AUTOCOMMIT
);
142 $dbwAuto->getFlag( $dbw::DBO_TRX
), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" );
143 $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX
), "DBO_TRX still set on master" );
144 $this->assertNotEquals(
145 $dbw, $dbwAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" );
147 $dbrAuto = $lb->getConnection( DB_REPLICA
, [], false, $lb::CONN_TRX_AUTOCOMMIT
);
149 $dbrAuto->getFlag( $dbw::DBO_TRX
), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" );
150 $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX
), "DBO_TRX still set on replica" );
151 $this->assertNotEquals(
152 $dbr, $dbrAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" );
154 $dbwAuto2 = $lb->getConnection( DB_MASTER
, [], false, $lb::CONN_TRX_AUTOCOMMIT
);
155 $this->assertEquals( $dbwAuto2, $dbwAuto, "CONN_TRX_AUTOCOMMIT reuses connections" );
161 private function newSingleServerLocalLoadBalancer() {
164 return new LoadBalancer( [
165 'servers' => [ $this->makeServerConfig() ],
166 'localDomain' => new DatabaseDomain( $wgDBname, null, $this->dbPrefix() )
170 private function newMultiServerLocalLoadBalancer( $lbExtra = [], $srvExtra = [] ) {
171 global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir;
176 'host' => $wgDBserver,
177 'dbname' => $wgDBname,
178 'tablePrefix' => $this->dbPrefix(),
180 'password' => $wgDBpassword,
182 'dbDirectory' => $wgSQLiteDataDir,
187 'host' => $wgDBserver,
188 'dbname' => $wgDBname,
189 'tablePrefix' => $this->dbPrefix(),
191 'password' => $wgDBpassword,
193 'dbDirectory' => $wgSQLiteDataDir,
197 'host' => $wgDBserver,
198 'dbname' => $wgDBname,
199 'tablePrefix' => $this->dbPrefix(),
201 'password' => $wgDBpassword,
203 'dbDirectory' => $wgSQLiteDataDir,
208 'host' => $wgDBserver,
209 'dbname' => $wgDBname,
210 'tablePrefix' => $this->dbPrefix(),
212 'password' => $wgDBpassword,
214 'dbDirectory' => $wgSQLiteDataDir,
217 'recentchanges' => 100,
221 // Logging replica DBs
223 'host' => $wgDBserver,
224 'dbname' => $wgDBname,
225 'tablePrefix' => $this->dbPrefix(),
227 'password' => $wgDBpassword,
229 'dbDirectory' => $wgSQLiteDataDir,
236 'host' => $wgDBserver,
237 'dbname' => $wgDBname,
238 'tablePrefix' => $this->dbPrefix(),
240 'password' => $wgDBpassword,
242 'dbDirectory' => $wgSQLiteDataDir,
248 // Maintenance query replica DBs
250 'host' => $wgDBserver,
251 'dbname' => $wgDBname,
252 'tablePrefix' => $this->dbPrefix(),
254 'password' => $wgDBpassword,
256 'dbDirectory' => $wgSQLiteDataDir,
262 // Replica DB that only has a copy of some static tables
264 'host' => $wgDBserver,
265 'dbname' => $wgDBname,
266 'tablePrefix' => $this->dbPrefix(),
268 'password' => $wgDBpassword,
270 'dbDirectory' => $wgSQLiteDataDir,
279 return new LoadBalancer( $lbExtra +
[
280 'servers' => $servers,
281 'localDomain' => new DatabaseDomain( $wgDBname, null, $this->dbPrefix() ),
282 'queryLogger' => MediaWiki\Logger\LoggerFactory
::getInstance( 'DBQuery' ),
283 'loadMonitorClass' => LoadMonitorNull
::class
287 private function assertWriteForbidden( Database
$db ) {
289 $db->delete( 'some_table', [ 'id' => 57634126 ], __METHOD__
);
290 $this->fail( 'Write operation should have failed!' );
291 } catch ( DBError
$ex ) {
292 // check that the exception message contains "Write operation"
293 $constraint = new PHPUnit_Framework_Constraint_StringContains( 'Write operation' );
295 if ( !$constraint->evaluate( $ex->getMessage(), '', true ) ) {
296 // re-throw original error, to preserve stack trace
302 private function assertWriteAllowed( Database
$db ) {
303 $table = $db->tableName( 'some_table' );
304 // Trigger a transaction so that rollback() will remove all the tables.
305 // Don't do this for MySQL/Oracle as they auto-commit transactions for DDL
306 // statements such as CREATE TABLE.
307 $useAtomicSection = in_array( $db->getType(), [ 'sqlite', 'postgres', 'mssql' ], true );
309 $db->dropTable( 'some_table' ); // clear for sanity
310 $this->assertNotEquals( $db::STATUS_TRX_ERROR
, $db->trxStatus() );
312 if ( $useAtomicSection ) {
313 $db->startAtomic( __METHOD__
);
315 // Use only basic SQL and trivial types for these queries for compatibility
316 $this->assertNotSame(
318 $db->query( "CREATE TABLE $table (id INT, time INT)", __METHOD__
),
321 $this->assertNotEquals( $db::STATUS_TRX_ERROR
, $db->trxStatus() );
322 $this->assertNotSame(
324 $db->query( "DELETE FROM $table WHERE id=57634126", __METHOD__
),
327 $this->assertNotEquals( $db::STATUS_TRX_ERROR
, $db->trxStatus() );
329 if ( !$useAtomicSection ) {
330 // Drop the table to clean up, ignoring any error.
331 $db->dropTable( 'some_table' );
333 // Rollback the atomic section for sqlite's benefit.
334 $db->rollback( __METHOD__
, 'flush' );
335 $this->assertNotEquals( $db::STATUS_TRX_ERROR
, $db->trxStatus() );
339 public function testServerAttributes() {
342 'dbname' => 'my_unittest_wiki',
343 'tablePrefix' => 'unittest_',
345 'dbDirectory' => "some_directory",
350 $lb = new LoadBalancer( [
351 'servers' => $servers,
352 'localDomain' => new DatabaseDomain( 'my_unittest_wiki', null, 'unittest_' ),
353 'loadMonitorClass' => LoadMonitorNull
::class
356 $this->assertTrue( $lb->getServerAttributes( 0 )[Database
::ATTR_DB_LEVEL_LOCKING
] );
361 'user' => 'wikiuser',
362 'password' => 'none',
363 'dbname' => 'my_unittest_wiki',
364 'tablePrefix' => 'unittest_',
368 [ // emulated replica
370 'user' => 'wikiuser',
371 'password' => 'none',
372 'dbname' => 'my_unittest_wiki',
373 'tablePrefix' => 'unittest_',
379 $lb = new LoadBalancer( [
380 'servers' => $servers,
381 'localDomain' => new DatabaseDomain( 'my_unittest_wiki', null, 'unittest_' ),
382 'loadMonitorClass' => LoadMonitorNull
::class
385 $this->assertFalse( $lb->getServerAttributes( 1 )[Database
::ATTR_DB_LEVEL_LOCKING
] );
389 * @covers LoadBalancer::openConnection()
390 * @covers LoadBalancer::getAnyOpenConnection()
392 function testOpenConnection() {
393 $lb = $this->newSingleServerLocalLoadBalancer();
395 $i = $lb->getWriterIndex();
396 $this->assertEquals( null, $lb->getAnyOpenConnection( $i ) );
398 $conn1 = $lb->getConnection( $i );
399 $this->assertNotEquals( null, $conn1 );
400 $this->assertEquals( $conn1, $lb->getAnyOpenConnection( $i ) );
401 $this->assertFalse( $conn1->getFlag( DBO_TRX
) );
403 $conn2 = $lb->getConnection( $i, [], false, $lb::CONN_TRX_AUTOCOMMIT
);
404 $this->assertNotEquals( null, $conn2 );
405 $this->assertFalse( $conn2->getFlag( DBO_TRX
) );
407 if ( $lb->getServerAttributes( $i )[Database
::ATTR_DB_LEVEL_LOCKING
] ) {
408 $this->assertEquals( null,
409 $lb->getAnyOpenConnection( $i, $lb::CONN_TRX_AUTOCOMMIT
) );
410 $this->assertEquals( $conn1,
412 $i, [], false, $lb::CONN_TRX_AUTOCOMMIT
), $lb::CONN_TRX_AUTOCOMMIT
);
414 $this->assertEquals( $conn2,
415 $lb->getAnyOpenConnection( $i, $lb::CONN_TRX_AUTOCOMMIT
) );
416 $this->assertEquals( $conn2,
417 $lb->getConnection( $i, [], false, $lb::CONN_TRX_AUTOCOMMIT
) );
419 $conn2->startAtomic( __METHOD__
);
421 $lb->getConnection( $i, [], false, $lb::CONN_TRX_AUTOCOMMIT
);
422 $conn2->endAtomic( __METHOD__
);
423 $this->fail( "No exception thrown." );
424 } catch ( DBUnexpectedError
$e ) {
426 'Handle requested with CONN_TRX_AUTOCOMMIT yet it has a transaction',
430 $conn2->endAtomic( __METHOD__
);
436 public function testTransactionCallbackChains() {
437 global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir;
441 'host' => $wgDBserver,
442 'dbname' => $wgDBname,
443 'tablePrefix' => $this->dbPrefix(),
445 'password' => $wgDBpassword,
447 'dbDirectory' => $wgSQLiteDataDir,
449 'flags' => DBO_TRX
// simulate a web request with DBO_TRX
453 $lb = new LoadBalancer( [
454 'servers' => $servers,
455 'localDomain' => new DatabaseDomain( $wgDBname, null, $this->dbPrefix() )
458 $conn1 = $lb->openConnection( $lb->getWriterIndex(), false );
459 $conn2 = $lb->openConnection( $lb->getWriterIndex(), '' );
462 $lb->forEachOpenMasterConnection( function () use ( &$count ) {
465 $this->assertEquals( 2, $count, 'Connection handle count' );
468 $lb->setTransactionListener( 'test-listener', function () use ( &$tlCalls ) {
472 $lb->beginMasterChanges( __METHOD__
);
473 $bc = array_fill_keys( [ 'a', 'b', 'c', 'd' ], 0 );
474 $conn1->onTransactionPreCommitOrIdle( function () use ( &$bc, $conn1, $conn2 ) {
476 $conn2->onTransactionPreCommitOrIdle( function () use ( &$bc, $conn1, $conn2 ) {
478 $conn1->onTransactionPreCommitOrIdle( function () use ( &$bc, $conn1, $conn2 ) {
480 $conn1->onTransactionPreCommitOrIdle( function () use ( &$bc, $conn1, $conn2 ) {
486 $lb->finalizeMasterChanges();
487 $lb->approveMasterChanges( [] );
488 $lb->commitMasterChanges( __METHOD__
);
489 $lb->runMasterTransactionIdleCallbacks();
490 $lb->runMasterTransactionListenerCallbacks();
492 $this->assertEquals( array_fill_keys( [ 'a', 'b', 'c', 'd' ], 1 ), $bc );
493 $this->assertEquals( 2, $tlCalls );
496 $lb->beginMasterChanges( __METHOD__
);
497 $ac = array_fill_keys( [ 'a', 'b', 'c', 'd' ], 0 );
498 $conn1->onTransactionCommitOrIdle( function () use ( &$ac, $conn1, $conn2 ) {
500 $conn2->onTransactionCommitOrIdle( function () use ( &$ac, $conn1, $conn2 ) {
502 $conn1->onTransactionCommitOrIdle( function () use ( &$ac, $conn1, $conn2 ) {
504 $conn1->onTransactionCommitOrIdle( function () use ( &$ac, $conn1, $conn2 ) {
510 $lb->finalizeMasterChanges();
511 $lb->approveMasterChanges( [] );
512 $lb->commitMasterChanges( __METHOD__
);
513 $lb->runMasterTransactionIdleCallbacks();
514 $lb->runMasterTransactionListenerCallbacks();
516 $this->assertEquals( array_fill_keys( [ 'a', 'b', 'c', 'd' ], 1 ), $ac );
517 $this->assertEquals( 2, $tlCalls );
523 public function testDBConnRefReadsMasterAndReplicaRoles() {
524 $lb = $this->newSingleServerLocalLoadBalancer();
526 $rConn = $lb->getConnectionRef( DB_REPLICA
);
527 $wConn = $lb->getConnectionRef( DB_MASTER
);
528 $wConn2 = $lb->getConnectionRef( 0 );
530 $v = [ 'value' => '1', '1' ];
531 $sql = 'SELECT MAX(1) AS value';
532 foreach ( [ $rConn, $wConn, $wConn2 ] as $conn ) {
533 $conn->clearFlag( $conn::DBO_TRX
);
535 $res = $conn->query( $sql, __METHOD__
);
536 $this->assertEquals( $v, $conn->fetchRow( $res ) );
538 $res = $conn->query( $sql, __METHOD__
, $conn::QUERY_REPLICA_ROLE
);
539 $this->assertEquals( $v, $conn->fetchRow( $res ) );
542 $wConn->getScopedLockAndFlush( 'key', __METHOD__
, 1 );
543 $wConn2->getScopedLockAndFlush( 'key2', __METHOD__
, 1 );
547 * @expectedException \Wikimedia\Rdbms\DBReadOnlyRoleError
549 public function testDBConnRefWritesReplicaRole() {
550 $lb = $this->newSingleServerLocalLoadBalancer();
552 $rConn = $lb->getConnectionRef( DB_REPLICA
);
554 $rConn->query( 'DELETE FROM sometesttable WHERE 1=0' );
558 * @expectedException \Wikimedia\Rdbms\DBReadOnlyRoleError
560 public function testDBConnRefWritesReplicaRoleIndex() {
561 $lb = $this->newMultiServerLocalLoadBalancer();
563 $rConn = $lb->getConnectionRef( 1 );
565 $rConn->query( 'DELETE FROM sometesttable WHERE 1=0' );
569 * @expectedException \Wikimedia\Rdbms\DBReadOnlyRoleError
571 public function testDBConnRefWritesReplicaRoleInsert() {
572 $lb = $this->newMultiServerLocalLoadBalancer();
574 $rConn = $lb->getConnectionRef( DB_REPLICA
);
576 $rConn->insert( 'test', [ 't' => 1 ], __METHOD__
);
579 public function testQueryGroupIndex() {
580 $lb = $this->newMultiServerLocalLoadBalancer( [ 'defaultGroup' => false ] );
581 /** @var LoadBalancer $lbWrapper */
582 $lbWrapper = TestingAccessWrapper
::newFromObject( $lb );
584 $rGeneric = $lb->getConnectionRef( DB_REPLICA
);
585 $mainIndexPicked = $rGeneric->getLBInfo( 'serverIndex' );
587 $this->assertEquals( $mainIndexPicked, $lbWrapper->getExistingReaderIndex( false ) );
588 $this->assertTrue( in_array( $mainIndexPicked, [ 1, 2 ] ) );
589 for ( $i = 0; $i < 300; ++
$i ) {
590 $rLog = $lb->getConnectionRef( DB_REPLICA
, [] );
593 $rLog->getLBInfo( 'serverIndex' ),
594 "Main index unchanged" );
597 $rRC = $lb->getConnectionRef( DB_REPLICA
, [ 'recentchanges' ] );
598 $rWL = $lb->getConnectionRef( DB_REPLICA
, [ 'watchlist' ] );
600 $this->assertEquals( 3, $rRC->getLBInfo( 'serverIndex' ) );
601 $this->assertEquals( 3, $rWL->getLBInfo( 'serverIndex' ) );
603 $rLog = $lb->getConnectionRef( DB_REPLICA
, [ 'logging', 'watchlist' ] );
604 $logIndexPicked = $rLog->getLBInfo( 'serverIndex' );
606 $this->assertEquals( $logIndexPicked, $lbWrapper->getExistingReaderIndex( 'logging' ) );
607 $this->assertTrue( in_array( $logIndexPicked, [ 4, 5 ] ) );
609 for ( $i = 0; $i < 300; ++
$i ) {
610 $rLog = $lb->getConnectionRef( DB_REPLICA
, [ 'logging', 'watchlist' ] );
612 $logIndexPicked, $rLog->getLBInfo( 'serverIndex' ), "Index unchanged" );
615 $rVslow = $lb->getConnectionRef( DB_REPLICA
, [ 'vslow', 'logging' ] );
616 $vslowIndexPicked = $rVslow->getLBInfo( 'serverIndex' );
618 $this->assertEquals( $vslowIndexPicked, $lbWrapper->getExistingReaderIndex( 'vslow' ) );
619 $this->assertEquals( 6, $vslowIndexPicked );