Make NameTableStore use LoadBalancer::getConnectionRef()
[lhc/web/wiklou.git] / tests / phpunit / includes / Storage / NameTableStoreTest.php
1 <?php
2
3 namespace MediaWiki\Tests\Storage;
4
5 use BagOStuff;
6 use EmptyBagOStuff;
7 use HashBagOStuff;
8 use MediaWiki\Storage\NameTableAccessException;
9 use MediaWiki\Storage\NameTableStore;
10 use MediaWikiTestCase;
11 use Psr\Log\NullLogger;
12 use WANObjectCache;
13 use Wikimedia\Rdbms\IDatabase;
14 use Wikimedia\Rdbms\LoadBalancer;
15 use Wikimedia\Rdbms\MaintainableDBConnRef;
16 use Wikimedia\TestingAccessWrapper;
17
18 /**
19 * @author Addshore
20 * @group Database
21 * @covers \MediaWiki\Storage\NameTableStore
22 */
23 class NameTableStoreTest extends MediaWikiTestCase {
24
25 public function setUp() {
26 $this->tablesUsed[] = 'slot_roles';
27 parent::setUp();
28 }
29
30 protected function addCoreDBData() {
31 // The default implementation causes the slot_roles to already have content. Skip that.
32 }
33
34 private function populateTable( $values ) {
35 $insertValues = [];
36 foreach ( $values as $name ) {
37 $insertValues[] = [ 'role_name' => $name ];
38 }
39 $this->db->insert( 'slot_roles', $insertValues );
40 }
41
42 private function getHashWANObjectCache( $cacheBag ) {
43 return new WANObjectCache( [ 'cache' => $cacheBag ] );
44 }
45
46 /**
47 * @param $db
48 * @return \PHPUnit_Framework_MockObject_MockObject|LoadBalancer
49 */
50 private function getMockLoadBalancer( $db ) {
51 $mock = $this->getMockBuilder( LoadBalancer::class )
52 ->disableOriginalConstructor()
53 ->getMock();
54 $mock->expects( $this->any() )
55 ->method( 'getConnectionRef' )
56 ->willReturnCallback( function ( $i ) use ( $mock, $db ) {
57 return new MaintainableDBConnRef( $mock, $db, $i );
58 } );
59 return $mock;
60 }
61
62 private function getCallCheckingDb( $insertCalls, $selectCalls ) {
63 $proxiedMethods = [
64 'select' => $selectCalls,
65 'insert' => $insertCalls,
66 'affectedRows' => null,
67 'insertId' => null,
68 'getSessionLagStatus' => null,
69 'writesPending' => null,
70 'onTransactionPreCommitOrIdle' => null
71 ];
72 $mock = $this->getMockBuilder( IDatabase::class )
73 ->disableOriginalConstructor()
74 ->getMock();
75 foreach ( $proxiedMethods as $method => $count ) {
76 $mock->expects( is_int( $count ) ? $this->exactly( $count ) : $this->any() )
77 ->method( $method )
78 ->willReturnCallback( function ( ...$args ) use ( $method ) {
79 return call_user_func_array( [ $this->db, $method ], $args );
80 } );
81 }
82 return $mock;
83 }
84
85 private function getNameTableSqlStore(
86 BagOStuff $cacheBag,
87 $insertCalls,
88 $selectCalls,
89 $normalizationCallback = null,
90 $insertCallback = null
91 ) {
92 return new NameTableStore(
93 $this->getMockLoadBalancer( $this->getCallCheckingDb( $insertCalls, $selectCalls ) ),
94 $this->getHashWANObjectCache( $cacheBag ),
95 new NullLogger(),
96 'slot_roles', 'role_id', 'role_name',
97 $normalizationCallback,
98 false,
99 $insertCallback
100 );
101 }
102
103 public function provideGetAndAcquireId() {
104 return [
105 'no wancache, empty table' =>
106 [ new EmptyBagOStuff(), true, 1, [], 'foo', 1 ],
107 'no wancache, one matching value' =>
108 [ new EmptyBagOStuff(), false, 1, [ 'foo' ], 'foo', 1 ],
109 'no wancache, one not matching value' =>
110 [ new EmptyBagOStuff(), true, 1, [ 'bar' ], 'foo', 2 ],
111 'no wancache, multiple, one matching value' =>
112 [ new EmptyBagOStuff(), false, 1, [ 'foo', 'bar' ], 'bar', 2 ],
113 'no wancache, multiple, no matching value' =>
114 [ new EmptyBagOStuff(), true, 1, [ 'foo', 'bar' ], 'baz', 3 ],
115 'wancache, empty table' =>
116 [ new HashBagOStuff(), true, 1, [], 'foo', 1 ],
117 'wancache, one matching value' =>
118 [ new HashBagOStuff(), false, 1, [ 'foo' ], 'foo', 1 ],
119 'wancache, one not matching value' =>
120 [ new HashBagOStuff(), true, 1, [ 'bar' ], 'foo', 2 ],
121 'wancache, multiple, one matching value' =>
122 [ new HashBagOStuff(), false, 1, [ 'foo', 'bar' ], 'bar', 2 ],
123 'wancache, multiple, no matching value' =>
124 [ new HashBagOStuff(), true, 1, [ 'foo', 'bar' ], 'baz', 3 ],
125 ];
126 }
127
128 /**
129 * @dataProvider provideGetAndAcquireId
130 * @param BagOStuff $cacheBag to use in the WANObjectCache service
131 * @param bool $needsInsert Does the value we are testing need to be inserted?
132 * @param int $selectCalls Number of times the select DB method will be called
133 * @param string[] $existingValues to be added to the db table
134 * @param string $name name to acquire
135 * @param int $expectedId the id we expect the name to have
136 */
137 public function testGetAndAcquireId(
138 $cacheBag,
139 $needsInsert,
140 $selectCalls,
141 $existingValues,
142 $name,
143 $expectedId
144 ) {
145 // Make sure the table is empty!
146 $this->truncateTable( 'slot_roles' );
147
148 $this->populateTable( $existingValues );
149 $store = $this->getNameTableSqlStore( $cacheBag, (int)$needsInsert, $selectCalls );
150
151 // Some names will not initially exist
152 try {
153 $result = $store->getId( $name );
154 $this->assertSame( $expectedId, $result );
155 } catch ( NameTableAccessException $e ) {
156 if ( $needsInsert ) {
157 $this->assertTrue( true ); // Expected exception
158 } else {
159 $this->fail( 'Did not expect an exception, but got one: ' . $e->getMessage() );
160 }
161 }
162
163 // All names should return their id here
164 $this->assertSame( $expectedId, $store->acquireId( $name ) );
165
166 // acquireId inserted these names, so now everything should exist with getId
167 $this->assertSame( $expectedId, $store->getId( $name ) );
168
169 // calling getId again will also still work, and not result in more selects
170 $this->assertSame( $expectedId, $store->getId( $name ) );
171 }
172
173 public function provideTestGetAndAcquireIdNameNormalization() {
174 yield [ 'A', 'a', 'strtolower' ];
175 yield [ 'b', 'B', 'strtoupper' ];
176 yield [
177 'X',
178 'X',
179 function ( $name ) {
180 return $name;
181 }
182 ];
183 yield [ 'ZZ', 'ZZ-a', __CLASS__ . '::appendDashAToString' ];
184 }
185
186 public static function appendDashAToString( $string ) {
187 return $string . '-a';
188 }
189
190 /**
191 * @dataProvider provideTestGetAndAcquireIdNameNormalization
192 */
193 public function testGetAndAcquireIdNameNormalization(
194 $nameIn,
195 $nameOut,
196 $normalizationCallback
197 ) {
198 $store = $this->getNameTableSqlStore(
199 new EmptyBagOStuff(),
200 1,
201 1,
202 $normalizationCallback
203 );
204 $acquiredId = $store->acquireId( $nameIn );
205 $this->assertSame( $nameOut, $store->getName( $acquiredId ) );
206 }
207
208 public function provideGetName() {
209 return [
210 [ new HashBagOStuff(), 3, 2 ],
211 [ new EmptyBagOStuff(), 3, 3 ],
212 ];
213 }
214
215 /**
216 * @dataProvider provideGetName
217 */
218 public function testGetName( BagOStuff $cacheBag, $insertCalls, $selectCalls ) {
219 $now = microtime( true );
220 $cacheBag->setMockTime( $now );
221 // Check for operations to in-memory cache (IMC) and persistent cache (PC)
222 $store = $this->getNameTableSqlStore( $cacheBag, $insertCalls, $selectCalls );
223
224 // Get 1 ID and make sure getName returns correctly
225 $fooId = $store->acquireId( 'foo' ); // regen PC, set IMC, update IMC, tombstone PC
226 $now += 0.01;
227 $this->assertSame( 'foo', $store->getName( $fooId ) ); // use IMC
228 $now += 0.01;
229
230 // Get another ID and make sure getName returns correctly
231 $barId = $store->acquireId( 'bar' ); // update IMC, tombstone PC
232 $now += 0.01;
233 $this->assertSame( 'bar', $store->getName( $barId ) ); // use IMC
234 $now += 0.01;
235
236 // Blitz the cache and make sure it still returns
237 TestingAccessWrapper::newFromObject( $store )->tableCache = null; // clear IMC
238 $this->assertSame( 'foo', $store->getName( $fooId ) ); // regen interim PC, set IMC
239 $this->assertSame( 'bar', $store->getName( $barId ) ); // use IMC
240
241 // Blitz the cache again and get another ID and make sure getName returns correctly
242 TestingAccessWrapper::newFromObject( $store )->tableCache = null; // clear IMC
243 $bazId = $store->acquireId( 'baz' ); // set IMC using interim PC, update IMC, tombstone PC
244 $now += 0.01;
245 $this->assertSame( 'baz', $store->getName( $bazId ) ); // uses IMC
246 $this->assertSame( 'baz', $store->getName( $bazId ) ); // uses IMC
247 }
248
249 public function testGetName_masterFallback() {
250 $store = $this->getNameTableSqlStore( new EmptyBagOStuff(), 1, 2 );
251
252 // Insert a new name
253 $fooId = $store->acquireId( 'foo' );
254
255 // Empty the process cache, getCachedTable() will now return this empty array
256 TestingAccessWrapper::newFromObject( $store )->tableCache = [];
257
258 // getName should fallback to master, which is why we assert 2 selectCalls above
259 $this->assertSame( 'foo', $store->getName( $fooId ) );
260 }
261
262 public function testGetMap_empty() {
263 $this->populateTable( [] );
264 $store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 1 );
265 $table = $store->getMap();
266 $this->assertSame( [], $table );
267 }
268
269 public function testGetMap_twoValues() {
270 $this->populateTable( [ 'foo', 'bar' ] );
271 $store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 1 );
272
273 // We are using a cache, so 2 calls should only result in 1 select on the db
274 $store->getMap();
275 $table = $store->getMap();
276
277 $expected = [ 1 => 'foo', 2 => 'bar' ];
278 $this->assertSame( $expected, $table );
279 // Make sure the table returned is the same as the cached table
280 $this->assertSame( $expected, TestingAccessWrapper::newFromObject( $store )->tableCache );
281 }
282
283 public function testReloadMap() {
284 $this->populateTable( [ 'foo' ] );
285 $store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 2 );
286
287 // force load
288 $this->assertCount( 1, $store->getMap() );
289
290 // add more stuff to the table, so the cache gets out of sync
291 $this->populateTable( [ 'bar' ] );
292
293 $expected = [ 1 => 'foo', 2 => 'bar' ];
294 $this->assertSame( $expected, $store->reloadMap() );
295 $this->assertSame( $expected, $store->getMap() );
296 }
297
298 public function testCacheRaceCondition() {
299 $wanHashBag = new HashBagOStuff();
300 $store1 = $this->getNameTableSqlStore( $wanHashBag, 1, 1 );
301 $store2 = $this->getNameTableSqlStore( $wanHashBag, 1, 0 );
302 $store3 = $this->getNameTableSqlStore( $wanHashBag, 1, 1 );
303
304 // Cache the current table in the instances we will use
305 // This simulates multiple requests running simultaneously
306 $store1->getMap();
307 $store2->getMap();
308 $store3->getMap();
309
310 // Store 2 separate names using different instances
311 $fooId = $store1->acquireId( 'foo' );
312 $barId = $store2->acquireId( 'bar' );
313
314 // Each of these instances should be aware of what they have inserted
315 $this->assertSame( $fooId, $store1->acquireId( 'foo' ) );
316 $this->assertSame( $barId, $store2->acquireId( 'bar' ) );
317
318 // A new store should be able to get both of these new Ids
319 // Note: before there was a race condition here where acquireId( 'bar' ) would update the
320 // cache with data missing the 'foo' key that it was not aware of
321 $store4 = $this->getNameTableSqlStore( $wanHashBag, 0, 1 );
322 $this->assertSame( $fooId, $store4->getId( 'foo' ) );
323 $this->assertSame( $barId, $store4->getId( 'bar' ) );
324
325 // If a store with old cached data tries to acquire these we will get the same ids.
326 $this->assertSame( $fooId, $store3->acquireId( 'foo' ) );
327 $this->assertSame( $barId, $store3->acquireId( 'bar' ) );
328 }
329
330 public function testGetAndAcquireIdInsertCallback() {
331 // FIXME: fails under postgres
332 $this->markTestSkippedIfDbType( 'postgres' );
333
334 $store = $this->getNameTableSqlStore(
335 new EmptyBagOStuff(),
336 1,
337 1,
338 null,
339 function ( $insertFields ) {
340 $insertFields['role_id'] = 7251;
341 return $insertFields;
342 }
343 );
344 $this->assertSame( 7251, $store->acquireId( 'A' ) );
345 }
346
347 }