MediaWiki master
NameTableStore.php
Go to the documentation of this file.
1<?php
7namespace MediaWiki\Storage;
8
9use Psr\Log\LoggerInterface;
10use Wikimedia\Assert\Assert;
17
23
25 private $loadBalancer;
26
28 private $cache;
29
31 private $logger;
32
34 private $tableCache = null;
35
37 private $domain;
38
40 private $cacheTTL;
41
43 private $table;
45 private $idField;
47 private $nameField;
49 private $normalizationCallback;
51 private $insertCallback;
52
71 public function __construct(
72 ILoadBalancer $dbLoadBalancer,
73 WANObjectCache $cache,
74 LoggerInterface $logger,
75 $table,
76 $idField,
77 $nameField,
78 ?callable $normalizationCallback = null,
79 $dbDomain = false,
80 ?callable $insertCallback = null
81 ) {
82 $this->loadBalancer = $dbLoadBalancer;
83 $this->cache = $cache;
84 $this->logger = $logger;
85 $this->table = $table;
86 $this->idField = $idField;
87 $this->nameField = $nameField;
88 $this->normalizationCallback = $normalizationCallback;
89 $this->domain = $dbDomain;
90 $this->cacheTTL = BagOStuff::TTL_MONTH;
91 $this->insertCallback = $insertCallback;
92 }
93
99 private function getDBConnection( $index, $flags = 0 ) {
100 return $this->loadBalancer->getConnection( $index, [], $this->domain, $flags );
101 }
102
111 private function getCacheKey() {
112 return $this->cache->makeGlobalKey(
113 'NameTableSqlStore',
114 $this->table,
115 $this->loadBalancer->resolveDomainID( $this->domain )
116 );
117 }
118
123 private function normalizeName( $name ) {
124 if ( $this->normalizationCallback === null ) {
125 return $name;
126 }
127 return ( $this->normalizationCallback )( $name );
128 }
129
145 public function acquireId( string $name ) {
146 $name = $this->normalizeName( $name );
147
148 $table = $this->getTableFromCachesOrReplica();
149 $searchResult = array_search( $name, $table, true );
150 if ( $searchResult === false ) {
151 $id = $this->store( $name );
152
153 if ( isset( $table[$id] ) ) {
154 // This can happen when a name is assigned an ID within a transaction due to
155 // CONN_TRX_AUTOCOMMIT being unable to use a separate connection (e.g. SQLite).
156 // The right thing to do in this case is to discard the old value. According to
157 // the contract of acquireId, the caller should not have used it outside the
158 // transaction, so it should not be persisted anywhere after the rollback.
159 $m = "Got ID $id for '$name' from insert"
160 . " into '{$this->table}', but ID $id was previously associated with"
161 . " the name '{$table[$id]}'. Overriding the old value, which presumably"
162 . " has been removed from the database due to a transaction rollback.";
163 $this->logger->warning( $m );
164 }
165
166 $table[$id] = $name;
167 $searchResult = $id;
168
169 $this->tableCache = $table;
170 }
171
172 return $searchResult;
173 }
174
187 public function reloadMap( $connFlags = 0 ) {
188 $dbw = $this->getDBConnection( DB_PRIMARY, $connFlags );
189 $this->tableCache = $this->loadTable( $dbw );
190 $dbw->onTransactionPreCommitOrIdle( function () {
191 $this->cache->delete( $this->getCacheKey() );
192 }, __METHOD__ );
193
194 return $this->tableCache;
195 }
196
207 public function getId( string $name ) {
208 $name = $this->normalizeName( $name );
209
210 $table = $this->getTableFromCachesOrReplica();
211 $searchResult = array_search( $name, $table, true );
212
213 if ( $searchResult !== false ) {
214 return $searchResult;
215 }
216
217 throw NameTableAccessException::newFromDetails( $this->table, 'name', $name );
218 }
219
231 public function getName( int $id ) {
232 $table = $this->getTableFromCachesOrReplica();
233 if ( array_key_exists( $id, $table ) ) {
234 return $table[$id];
235 }
236 $fname = __METHOD__;
237
238 $table = $this->cache->getWithSetCallback(
239 $this->getCacheKey(),
240 $this->cacheTTL,
241 function ( $oldValue, &$ttl, &$setOpts ) use ( $id, $fname ) {
242 // Check if cached value is up-to-date enough to have $id
243 if ( is_array( $oldValue ) && array_key_exists( $id, $oldValue ) ) {
244 // Completely leave the cache key alone
245 $ttl = WANObjectCache::TTL_UNCACHEABLE;
246 // Use the old value
247 return $oldValue;
248 }
249 // Regenerate from replica DB, and primary DB if needed
250 foreach ( [ DB_REPLICA, DB_PRIMARY ] as $source ) {
251 // Log a fallback to primary
252 if ( $source === DB_PRIMARY ) {
253 $this->logger->info(
254 $fname . ' falling back to primary select from ' .
255 $this->table . ' with id ' . $id
256 );
257 }
258 $db = $this->getDBConnection( $source );
259 $cacheSetOpts = Database::getCacheSetOptions( $db );
260 $table = $this->loadTable( $db );
261 if ( array_key_exists( $id, $table ) ) {
262 break; // found it
263 }
264 }
265 // Use the value from last source checked
266 $setOpts += $cacheSetOpts;
267
268 return $table;
269 },
270 [ 'minAsOf' => INF ] // force callback run
271 );
272
273 $this->tableCache = $table;
274
275 if ( array_key_exists( $id, $table ) ) {
276 return $table[$id];
277 }
278
279 throw NameTableAccessException::newFromDetails( $this->table, 'id', $id );
280 }
281
289 public function getMap() {
290 return $this->getTableFromCachesOrReplica();
291 }
292
296 private function getTableFromCachesOrReplica() {
297 if ( $this->tableCache !== null ) {
298 return $this->tableCache;
299 }
300
301 $table = $this->cache->getWithSetCallback(
302 $this->getCacheKey(),
303 $this->cacheTTL,
304 function ( $oldValue, &$ttl, &$setOpts ) {
305 $dbr = $this->getDBConnection( DB_REPLICA );
306 $setOpts += Database::getCacheSetOptions( $dbr );
307 return $this->loadTable( $dbr );
308 }
309 );
310
311 $this->tableCache = $table;
312
313 return $table;
314 }
315
322 private function loadTable( IReadableDatabase $db ) {
323 $result = $db->newSelectQueryBuilder()
324 ->select( [
325 'id' => $this->idField,
326 'name' => $this->nameField
327 ] )
328 ->from( $this->table )
329 ->orderBy( 'id' )
330 ->caller( __METHOD__ )->fetchResultSet();
331
332 $assocArray = [];
333 foreach ( $result as $row ) {
334 $assocArray[(int)$row->id] = $row->name;
335 }
336
337 return $assocArray;
338 }
339
346 private function store( string $name ) {
347 Assert::parameter( $name !== '', '$name', 'should not be an empty string' );
348 // Note: this is only called internally so normalization of $name has already occurred.
349
350 $dbw = $this->getDBConnection( DB_PRIMARY, ILoadBalancer::CONN_TRX_AUTOCOMMIT );
351
352 $dbw->newInsertQueryBuilder()
353 ->insertInto( $this->table )
354 ->ignore()
355 ->row( $this->getFieldsToStore( $name ) )
356 ->caller( __METHOD__ )->execute();
357
358 if ( $dbw->affectedRows() > 0 ) {
359 $id = $dbw->insertId();
360 // As store returned an ID we know we inserted so delete from WAN cache
361 $dbw->onTransactionPreCommitOrIdle(
362 function () {
363 $this->cache->delete( $this->getCacheKey() );
364 },
365 __METHOD__
366 );
367
368 return $id;
369 }
370
371 $this->logger->info(
372 'Tried to insert name into table ' . $this->table . ', but value already existed.'
373 );
374
375 // Note that in MySQL, even if this method somehow runs in a transaction, a plain
376 // (non-locking) SELECT will see the new row created by the other transaction, even
377 // with REPEATABLE-READ. This is due to how "consistent reads" works: the latest
378 // version of rows become visible to the snapshot after the transaction sees those
379 // rows as either matching an update query or conflicting with an insert query.
380 $id = $dbw->newSelectQueryBuilder()
381 ->select( [ 'id' => $this->idField ] )
382 ->from( $this->table )
383 ->where( [ $this->nameField => $name ] )
384 ->caller( __METHOD__ )->fetchField();
385
386 if ( $id === false ) {
387 // Insert failed due to IGNORE flag, but DB_PRIMARY didn't give us the data
388 $m = "No insert possible but primary DB didn't give us a record for " .
389 "'{$name}' in '{$this->table}'";
390 $this->logger->error( $m );
391 throw new NameTableAccessException( $m );
392 }
393
394 return (int)$id;
395 }
396
402 private function getFieldsToStore( $name, $id = null ) {
403 $fields = [];
404
405 $fields[$this->nameField] = $name;
406
407 if ( $id !== null ) {
408 $fields[$this->idField] = $id;
409 }
410
411 if ( $this->insertCallback !== null ) {
412 $fields = ( $this->insertCallback )( $fields );
413 }
414 return $fields;
415 }
416
417}
const DB_REPLICA
Definition defines.php:26
const DB_PRIMARY
Definition defines.php:28
static newFromDetails( $tableName, $accessType, $accessValue)
acquireId(string $name)
Acquire the id of the given name.
__construct(ILoadBalancer $dbLoadBalancer, WANObjectCache $cache, LoggerInterface $logger, $table, $idField, $nameField, ?callable $normalizationCallback=null, $dbDomain=false, ?callable $insertCallback=null)
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.
Abstract class for any ephemeral data store.
Definition BagOStuff.php:73
Multi-datacenter aware caching interface.
static getCacheSetOptions(?IReadableDatabase ... $dbs)
Merge the result of getSessionLagStatus() for several DBs using the most pessimistic values to estima...
Interface to a relational database.
Definition IDatabase.php:31
This class is a delegate to ILBFactory for a given database cluster.
A database connection without write operations.
$source