MediaWiki master
NameTableStore.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Storage;
22
23use Psr\Log\LoggerInterface;
24use Wikimedia\Assert\Assert;
31
37
39 private $loadBalancer;
40
42 private $cache;
43
45 private $logger;
46
48 private $tableCache = null;
49
51 private $domain;
52
54 private $cacheTTL;
55
57 private $table;
59 private $idField;
61 private $nameField;
63 private $normalizationCallback;
65 private $insertCallback;
66
85 public function __construct(
86 ILoadBalancer $dbLoadBalancer,
87 WANObjectCache $cache,
88 LoggerInterface $logger,
89 $table,
90 $idField,
91 $nameField,
92 ?callable $normalizationCallback = null,
93 $dbDomain = false,
94 ?callable $insertCallback = null
95 ) {
96 $this->loadBalancer = $dbLoadBalancer;
97 $this->cache = $cache;
98 $this->logger = $logger;
99 $this->table = $table;
100 $this->idField = $idField;
101 $this->nameField = $nameField;
102 $this->normalizationCallback = $normalizationCallback;
103 $this->domain = $dbDomain;
104 $this->cacheTTL = BagOStuff::TTL_MONTH;
105 $this->insertCallback = $insertCallback;
106 }
107
113 private function getDBConnection( $index, $flags = 0 ) {
114 return $this->loadBalancer->getConnection( $index, [], $this->domain, $flags );
115 }
116
125 private function getCacheKey() {
126 return $this->cache->makeGlobalKey(
127 'NameTableSqlStore',
128 $this->table,
129 $this->loadBalancer->resolveDomainID( $this->domain )
130 );
131 }
132
137 private function normalizeName( $name ) {
138 if ( $this->normalizationCallback === null ) {
139 return $name;
140 }
141 return call_user_func( $this->normalizationCallback, $name );
142 }
143
159 public function acquireId( string $name ) {
160 $name = $this->normalizeName( $name );
161
162 $table = $this->getTableFromCachesOrReplica();
163 $searchResult = array_search( $name, $table, true );
164 if ( $searchResult === false ) {
165 $id = $this->store( $name );
166
167 if ( isset( $table[$id] ) ) {
168 // This can happen when a name is assigned an ID within a transaction due to
169 // CONN_TRX_AUTOCOMMIT being unable to use a separate connection (e.g. SQLite).
170 // The right thing to do in this case is to discard the old value. According to
171 // the contract of acquireId, the caller should not have used it outside the
172 // transaction, so it should not be persisted anywhere after the rollback.
173 $m = "Got ID $id for '$name' from insert"
174 . " into '{$this->table}', but ID $id was previously associated with"
175 . " the name '{$table[$id]}'. Overriding the old value, which presumably"
176 . " has been removed from the database due to a transaction rollback.";
177 $this->logger->warning( $m );
178 }
179
180 $table[$id] = $name;
181 $searchResult = $id;
182
183 $this->tableCache = $table;
184 }
185
186 return $searchResult;
187 }
188
201 public function reloadMap( $connFlags = 0 ) {
202 $dbw = $this->getDBConnection( DB_PRIMARY, $connFlags );
203 $this->tableCache = $this->loadTable( $dbw );
204 $dbw->onTransactionPreCommitOrIdle( function () {
205 $this->cache->delete( $this->getCacheKey() );
206 }, __METHOD__ );
207
208 return $this->tableCache;
209 }
210
221 public function getId( string $name ) {
222 $name = $this->normalizeName( $name );
223
224 $table = $this->getTableFromCachesOrReplica();
225 $searchResult = array_search( $name, $table, true );
226
227 if ( $searchResult !== false ) {
228 return $searchResult;
229 }
230
231 throw NameTableAccessException::newFromDetails( $this->table, 'name', $name );
232 }
233
245 public function getName( int $id ) {
246 $table = $this->getTableFromCachesOrReplica();
247 if ( array_key_exists( $id, $table ) ) {
248 return $table[$id];
249 }
250 $fname = __METHOD__;
251
252 $table = $this->cache->getWithSetCallback(
253 $this->getCacheKey(),
254 $this->cacheTTL,
255 function ( $oldValue, &$ttl, &$setOpts ) use ( $id, $fname ) {
256 // Check if cached value is up-to-date enough to have $id
257 if ( is_array( $oldValue ) && array_key_exists( $id, $oldValue ) ) {
258 // Completely leave the cache key alone
259 $ttl = WANObjectCache::TTL_UNCACHEABLE;
260 // Use the old value
261 return $oldValue;
262 }
263 // Regenerate from replica DB, and primary DB if needed
264 foreach ( [ DB_REPLICA, DB_PRIMARY ] as $source ) {
265 // Log a fallback to primary
266 if ( $source === DB_PRIMARY ) {
267 $this->logger->info(
268 $fname . ' falling back to primary select from ' .
269 $this->table . ' with id ' . $id
270 );
271 }
272 $db = $this->getDBConnection( $source );
273 $cacheSetOpts = Database::getCacheSetOptions( $db );
274 $table = $this->loadTable( $db );
275 if ( array_key_exists( $id, $table ) ) {
276 break; // found it
277 }
278 }
279 // Use the value from last source checked
280 $setOpts += $cacheSetOpts;
281
282 return $table;
283 },
284 [ 'minAsOf' => INF ] // force callback run
285 );
286
287 $this->tableCache = $table;
288
289 if ( array_key_exists( $id, $table ) ) {
290 return $table[$id];
291 }
292
293 throw NameTableAccessException::newFromDetails( $this->table, 'id', $id );
294 }
295
303 public function getMap() {
304 return $this->getTableFromCachesOrReplica();
305 }
306
310 private function getTableFromCachesOrReplica() {
311 if ( $this->tableCache !== null ) {
312 return $this->tableCache;
313 }
314
315 $table = $this->cache->getWithSetCallback(
316 $this->getCacheKey(),
317 $this->cacheTTL,
318 function ( $oldValue, &$ttl, &$setOpts ) {
319 $dbr = $this->getDBConnection( DB_REPLICA );
320 $setOpts += Database::getCacheSetOptions( $dbr );
321 return $this->loadTable( $dbr );
322 }
323 );
324
325 $this->tableCache = $table;
326
327 return $table;
328 }
329
336 private function loadTable( IReadableDatabase $db ) {
337 $result = $db->newSelectQueryBuilder()
338 ->select( [
339 'id' => $this->idField,
340 'name' => $this->nameField
341 ] )
342 ->from( $this->table )
343 ->orderBy( 'id' )
344 ->caller( __METHOD__ )->fetchResultSet();
345
346 $assocArray = [];
347 foreach ( $result as $row ) {
348 $assocArray[(int)$row->id] = $row->name;
349 }
350
351 return $assocArray;
352 }
353
360 private function store( string $name ) {
361 Assert::parameter( $name !== '', '$name', 'should not be an empty string' );
362 // Note: this is only called internally so normalization of $name has already occurred.
363
364 $dbw = $this->getDBConnection( DB_PRIMARY, ILoadBalancer::CONN_TRX_AUTOCOMMIT );
365
366 $dbw->newInsertQueryBuilder()
367 ->insertInto( $this->table )
368 ->ignore()
369 ->row( $this->getFieldsToStore( $name ) )
370 ->caller( __METHOD__ )->execute();
371
372 if ( $dbw->affectedRows() > 0 ) {
373 $id = $dbw->insertId();
374 // As store returned an ID we know we inserted so delete from WAN cache
375 $dbw->onTransactionPreCommitOrIdle(
376 function () {
377 $this->cache->delete( $this->getCacheKey() );
378 },
379 __METHOD__
380 );
381
382 return $id;
383 }
384
385 $this->logger->info(
386 'Tried to insert name into table ' . $this->table . ', but value already existed.'
387 );
388
389 // Note that in MySQL, even if this method somehow runs in a transaction, a plain
390 // (non-locking) SELECT will see the new row created by the other transaction, even
391 // with REPEATABLE-READ. This is due to how "consistent reads" works: the latest
392 // version of rows become visible to the snapshot after the transaction sees those
393 // rows as either matching an update query or conflicting with an insert query.
394 $id = $dbw->newSelectQueryBuilder()
395 ->select( [ 'id' => $this->idField ] )
396 ->from( $this->table )
397 ->where( [ $this->nameField => $name ] )
398 ->caller( __METHOD__ )->fetchField();
399
400 if ( $id === false ) {
401 // Insert failed due to IGNORE flag, but DB_PRIMARY didn't give us the data
402 $m = "No insert possible but primary DB didn't give us a record for " .
403 "'{$name}' in '{$this->table}'";
404 $this->logger->error( $m );
405 throw new NameTableAccessException( $m );
406 }
407
408 return (int)$id;
409 }
410
416 private function getFieldsToStore( $name, $id = null ) {
417 $fields = [];
418
419 $fields[$this->nameField] = $name;
420
421 if ( $id !== null ) {
422 $fields[$this->idField] = $id;
423 }
424
425 if ( $this->insertCallback !== null ) {
426 $fields = call_user_func( $this->insertCallback, $fields );
427 }
428 return $fields;
429 }
430
431}
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:88
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:45
This class is a delegate to ILBFactory for a given database cluster.
A database connection without write operations.
$source
const DB_REPLICA
Definition defines.php:26
const DB_PRIMARY
Definition defines.php:28