MediaWiki 1.40.4
NameTableStore.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Storage;
22
23use Exception;
25use Psr\Log\LoggerInterface;
27use Wikimedia\Assert\Assert;
31use Wikimedia\RequestTimeout\TimeoutException;
32
38
40 private $loadBalancer;
41
43 private $cache;
44
46 private $logger;
47
49 private $tableCache = null;
50
52 private $domain;
53
55 private $cacheTTL;
56
58 private $table;
60 private $idField;
62 private $nameField;
64 private $normalizationCallback;
66 private $insertCallback;
67
86 public function __construct(
87 ILoadBalancer $dbLoadBalancer,
88 WANObjectCache $cache,
89 LoggerInterface $logger,
90 $table,
91 $idField,
92 $nameField,
93 callable $normalizationCallback = null,
94 $dbDomain = false,
95 callable $insertCallback = null
96 ) {
97 $this->loadBalancer = $dbLoadBalancer;
98 $this->cache = $cache;
99 $this->logger = $logger;
100 $this->table = $table;
101 $this->idField = $idField;
102 $this->nameField = $nameField;
103 $this->normalizationCallback = $normalizationCallback;
104 $this->domain = $dbDomain;
105 $this->cacheTTL = IExpiringStore::TTL_MONTH;
106 $this->insertCallback = $insertCallback;
107 }
108
114 private function getDBConnection( $index, $flags = 0 ) {
115 return $this->loadBalancer->getConnection( $index, [], $this->domain, $flags );
116 }
117
126 private function getCacheKey() {
127 return $this->cache->makeGlobalKey(
128 'NameTableSqlStore',
129 $this->table,
130 $this->loadBalancer->resolveDomainID( $this->domain )
131 );
132 }
133
138 private function normalizeName( $name ) {
139 if ( $this->normalizationCallback === null ) {
140 return $name;
141 }
142 return call_user_func( $this->normalizationCallback, $name );
143 }
144
162 public function acquireId( string $name ) {
163 $name = $this->normalizeName( $name );
164
165 $table = $this->getTableFromCachesOrReplica();
166 $searchResult = array_search( $name, $table, true );
167 if ( $searchResult === false ) {
168 $id = $this->store( $name );
169 if ( $id === null ) {
170 // RACE: $name was already in the db, probably just inserted, so load from primary DB.
171 // Use DBO_TRX to avoid missing inserts due to other threads or REPEATABLE-READs.
172 $table = $this->reloadMap( ILoadBalancer::CONN_TRX_AUTOCOMMIT );
173
174 $searchResult = array_search( $name, $table, true );
175 if ( $searchResult === false ) {
176 // Insert failed due to IGNORE flag, but DB_PRIMARY didn't give us the data
177 $m = "No insert possible but primary DB didn't give us a record for " .
178 "'{$name}' in '{$this->table}'";
179 $this->logger->error( $m );
180 throw new NameTableAccessException( $m );
181 }
182 } else {
183 if ( isset( $table[$id] ) ) {
184 // This can happen when a transaction is rolled back and acquireId is called in
185 // an onTransactionResolution() callback, which gets executed before retryStore()
186 // has a chance to run. The right thing to do in this case is to discard the old
187 // value. According to the contract of acquireId, the caller should not have
188 // used it outside the transaction, so it should not be persisted anywhere after
189 // the rollback.
190 $m = "Got ID $id for '$name' from insert"
191 . " into '{$this->table}', but ID $id was previously associated with"
192 . " the name '{$table[$id]}'. Overriding the old value, which presumably"
193 . " has been removed from the database due to a transaction rollback.";
194
195 $this->logger->warning( $m );
196 }
197
198 $table[$id] = $name;
199 $searchResult = $id;
200
201 // As store returned an ID we know we inserted so delete from WAN cache
202 $dbw = $this->getDBConnection( DB_PRIMARY );
203 $dbw->onTransactionPreCommitOrIdle( function () {
204 $this->cache->delete( $this->getCacheKey(), WANObjectCache::HOLDOFF_TTL_NONE );
205 }, __METHOD__ );
206 }
207 $this->tableCache = $table;
208 }
209
210 return $searchResult;
211 }
212
225 public function reloadMap( $connFlags = 0 ) {
226 if ( $connFlags !== 0 && defined( 'MW_PHPUNIT_TEST' ) ) {
227 // HACK: We can't use $connFlags while doing PHPUnit tests, because the
228 // fake database tables are bound to a single connection.
229 $connFlags = 0;
230 }
231
232 $dbw = $this->getDBConnection( DB_PRIMARY, $connFlags );
233 $this->tableCache = $this->loadTable( $dbw );
234 $dbw->onTransactionPreCommitOrIdle( function () {
235 $this->cache->delete( $this->getCacheKey() );
236 }, __METHOD__ );
237
238 return $this->tableCache;
239 }
240
251 public function getId( string $name ) {
252 $name = $this->normalizeName( $name );
253
254 $table = $this->getTableFromCachesOrReplica();
255 $searchResult = array_search( $name, $table, true );
256
257 if ( $searchResult !== false ) {
258 return $searchResult;
259 }
260
261 throw NameTableAccessException::newFromDetails( $this->table, 'name', $name );
262 }
263
275 public function getName( int $id ) {
276 $table = $this->getTableFromCachesOrReplica();
277 if ( array_key_exists( $id, $table ) ) {
278 return $table[$id];
279 }
280 $fname = __METHOD__;
281
282 $table = $this->cache->getWithSetCallback(
283 $this->getCacheKey(),
284 $this->cacheTTL,
285 function ( $oldValue, &$ttl, &$setOpts ) use ( $id, $fname ) {
286 // Check if cached value is up-to-date enough to have $id
287 if ( is_array( $oldValue ) && array_key_exists( $id, $oldValue ) ) {
288 // Completely leave the cache key alone
289 $ttl = WANObjectCache::TTL_UNCACHEABLE;
290 // Use the old value
291 return $oldValue;
292 }
293 // Regenerate from replica DB, and primary DB if needed
294 foreach ( [ DB_REPLICA, DB_PRIMARY ] as $source ) {
295 // Log a fallback to primary
296 if ( $source === DB_PRIMARY ) {
297 $this->logger->info(
298 $fname . ' falling back to primary select from ' .
299 $this->table . ' with id ' . $id
300 );
301 }
302 $db = $this->getDBConnection( $source );
303 $cacheSetOpts = Database::getCacheSetOptions( $db );
304 $table = $this->loadTable( $db );
305 if ( array_key_exists( $id, $table ) ) {
306 break; // found it
307 }
308 }
309 // Use the value from last source checked
310 $setOpts += $cacheSetOpts;
311
312 return $table;
313 },
314 [ 'minAsOf' => INF ] // force callback run
315 );
316
317 $this->tableCache = $table;
318
319 if ( array_key_exists( $id, $table ) ) {
320 return $table[$id];
321 }
322
323 throw NameTableAccessException::newFromDetails( $this->table, 'id', $id );
324 }
325
333 public function getMap() {
334 return $this->getTableFromCachesOrReplica();
335 }
336
340 private function getTableFromCachesOrReplica() {
341 if ( $this->tableCache !== null ) {
342 return $this->tableCache;
343 }
344
345 $table = $this->cache->getWithSetCallback(
346 $this->getCacheKey(),
347 $this->cacheTTL,
348 function ( $oldValue, &$ttl, &$setOpts ) {
349 $dbr = $this->getDBConnection( DB_REPLICA );
350 $setOpts += Database::getCacheSetOptions( $dbr );
351 return $this->loadTable( $dbr );
352 }
353 );
354
355 $this->tableCache = $table;
356
357 return $table;
358 }
359
367 private function loadTable( IDatabase $db ) {
368 $result = $db->newSelectQueryBuilder()
369 ->select( [
370 'id' => $this->idField,
371 'name' => $this->nameField
372 ] )
373 ->from( $this->table )
374 ->orderBy( 'id' )
375 ->caller( __METHOD__ )->fetchResultSet();
376
377 $assocArray = [];
378 foreach ( $result as $row ) {
379 $assocArray[$row->id] = $row->name;
380 }
381
382 return $assocArray;
383 }
384
391 private function store( string $name ) {
392 Assert::parameter( $name !== '', '$name', 'should not be an empty string' );
393 // Note: this is only called internally so normalization of $name has already occurred.
394
395 $dbw = $this->getDBConnection( DB_PRIMARY );
396
397 $id = null;
398 $dbw->doAtomicSection(
399 __METHOD__,
400 function ( IDatabase $unused, $fname )
401 use ( $name, &$id, $dbw ) {
402 // NOTE: use IDatabase from the parent scope here, not the function parameter.
403 // If $dbw is a wrapper around the actual DB, we need to call the wrapper here,
404 // not the inner instance.
405 $dbw->insert(
406 $this->table,
407 $this->getFieldsToStore( $name ),
408 $fname,
409 [ 'IGNORE' ]
410 );
411
412 if ( $dbw->affectedRows() === 0 ) {
413 $this->logger->info(
414 'Tried to insert name into table ' . $this->table . ', but value already existed.'
415 );
416
417 return;
418 }
419
420 $id = $dbw->insertId();
421
422 // Any open transaction may still be rolled back. If that happens, we have to re-try the
423 // insertion and restore a consistent state of the cached table.
424 $dbw->onAtomicSectionCancel(
425 function ( $trigger, IDatabase $unused ) use ( $name, $id, $dbw ) {
426 $this->retryStore( $dbw, $name, $id );
427 },
428 $fname );
429 },
430 IDatabase::ATOMIC_CANCELABLE
431 );
432
433 return $id;
434 }
435
444 private function retryStore( IDatabase $dbw, $name, $id ) {
445 // NOTE: in the closure below, use the IDatabase from the original method call,
446 // not the one passed to the closure as a parameter.
447 // If $dbw is a wrapper around the actual DB, we need to call the wrapper,
448 // not the inner instance.
449
450 try {
451 $dbw->doAtomicSection(
452 __METHOD__,
453 function ( IDatabase $unused, $fname ) use ( $name, $id, $dbw ) {
454 // Try to insert a row with the ID we originally got.
455 // If that fails (because of a key conflict), we will just try to get another ID again later.
456 $dbw->insert(
457 $this->table,
458 $this->getFieldsToStore( $name, $id ),
459 $fname
460 );
461
462 // Make sure we re-load the map in case this gets rolled back again.
463 // We could re-try once more, but that bears the risk of an infinite loop.
464 // So let's just give up on the ID.
465 $dbw->onAtomicSectionCancel(
466 function ( $trigger, IDatabase $unused ) {
467 $this->logger->warning(
468 'Re-insertion of name into table ' . $this->table
469 . ' was rolled back. Giving up and reloading the cache.'
470 );
471 $this->reloadMap( ILoadBalancer::CONN_TRX_AUTOCOMMIT );
472 },
473 $fname
474 );
475
476 $this->logger->info(
477 'Re-insert name into table ' . $this->table . ' after failed transaction.'
478 );
479 },
480 IDatabase::ATOMIC_CANCELABLE
481 );
482 } catch ( TimeoutException $e ) {
483 throw $e;
484 } catch ( Exception $ex ) {
485 $this->logger->error(
486 'Re-insertion of name into table ' . $this->table . ' failed: ' . $ex->getMessage()
487 );
488 } finally {
489 // NOTE: we reload regardless of whether the above insert succeeded. There is
490 // only three possibilities: the insert succeeded, so the new map will have
491 // the desired $id/$name mapping. Or the insert failed because another
492 // process already inserted that same $id/$name mapping, in which case the
493 // new map will also have it. Or another process grabbed the desired ID for
494 // another name, or the database refuses to insert the given ID into the
495 // auto increment field - in that case, the new map will not have a mapping
496 // for $name (or has a different mapping for $name). In that last case, we can
497 // only hope that the ID produced within the failed transaction has not been
498 // used outside that transaction.
499
500 $this->reloadMap( ILoadBalancer::CONN_TRX_AUTOCOMMIT );
501 }
502 }
503
509 private function getFieldsToStore( $name, $id = null ) {
510 $fields = [];
511
512 $fields[$this->nameField] = $name;
513
514 if ( $id !== null ) {
515 $fields[$this->idField] = $id;
516 }
517
518 if ( $this->insertCallback !== null ) {
519 $fields = call_user_func( $this->insertCallback, $fields );
520 }
521 return $fields;
522 }
523
524}
Exception representing a failure to look up a row from a name table.
static newFromDetails( $tableName, $accessType, $accessValue)
acquireId(string $name)
Acquire the id of the given name.
getId(string $name)
Get the id of the given name.
getMap()
Get the whole table, in no particular order as a map of ids to names.
getName(int $id)
Get the name of the given id.
reloadMap( $connFlags=0)
Reloads the name table from the primary database, and purges the WAN cache entry.
__construct(ILoadBalancer $dbLoadBalancer, WANObjectCache $cache, LoggerInterface $logger, $table, $idField, $nameField, callable $normalizationCallback=null, $dbDomain=false, callable $insertCallback=null)
Multi-datacenter aware caching interface.
static getCacheSetOptions(?IDatabase ... $dbs)
Merge the result of getSessionLagStatus() for several DBs using the most pessimistic values to estima...
Generic interface providing TTL constants for lightweight expiring object stores.
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:36
This class is a delegate to ILBFactory for a given database cluster.
$source
const DB_REPLICA
Definition defines.php:26
const DB_PRIMARY
Definition defines.php:28