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 $tableCache = null;
26
27 private readonly int $cacheTTL;
28
30 private $normalizationCallback;
32 private $insertCallback;
33
52 public function __construct(
53 private readonly ILoadBalancer $loadBalancer,
54 private readonly WANObjectCache $cache,
55 private readonly LoggerInterface $logger,
56 private readonly string $table,
57 private readonly string $idField,
58 private readonly string $nameField,
59 ?callable $normalizationCallback = null,
60 private readonly bool|string $domain = false,
61 ?callable $insertCallback = null,
62 ) {
63 $this->normalizationCallback = $normalizationCallback;
64 $this->cacheTTL = BagOStuff::TTL_MONTH;
65 $this->insertCallback = $insertCallback;
66 }
67
73 private function getDBConnection( $index, $flags = 0 ) {
74 return $this->loadBalancer->getConnection( $index, [], $this->domain, $flags );
75 }
76
85 private function getCacheKey() {
86 return $this->cache->makeGlobalKey(
87 'NameTableSqlStore',
88 $this->table,
89 $this->loadBalancer->resolveDomainID( $this->domain )
90 );
91 }
92
97 private function normalizeName( $name ) {
98 if ( $this->normalizationCallback === null ) {
99 return $name;
100 }
101 return ( $this->normalizationCallback )( $name );
102 }
103
119 public function acquireId( string $name ) {
120 $name = $this->normalizeName( $name );
121
122 $table = $this->getTableFromCachesOrReplica();
123 $searchResult = array_search( $name, $table, true );
124 if ( $searchResult === false ) {
125 $id = $this->store( $name );
126
127 if ( isset( $table[$id] ) ) {
128 // This can happen when a name is assigned an ID within a transaction due to
129 // CONN_TRX_AUTOCOMMIT being unable to use a separate connection (e.g. SQLite).
130 // The right thing to do in this case is to discard the old value. According to
131 // the contract of acquireId, the caller should not have used it outside the
132 // transaction, so it should not be persisted anywhere after the rollback.
133 $m = "Got ID $id for '$name' from insert"
134 . " into '{$this->table}', but ID $id was previously associated with"
135 . " the name '{$table[$id]}'. Overriding the old value, which presumably"
136 . " has been removed from the database due to a transaction rollback.";
137 $this->logger->warning( $m );
138 }
139
140 $table[$id] = $name;
141 $searchResult = $id;
142
143 $this->tableCache = $table;
144 }
145
146 return $searchResult;
147 }
148
161 public function reloadMap( $connFlags = 0 ) {
162 $dbw = $this->getDBConnection( DB_PRIMARY, $connFlags );
163 $this->tableCache = $this->loadTable( $dbw );
164 $dbw->onTransactionPreCommitOrIdle( function () {
165 $this->cache->delete( $this->getCacheKey() );
166 }, __METHOD__ );
167
168 return $this->tableCache;
169 }
170
181 public function getId( string $name ) {
182 $name = $this->normalizeName( $name );
183
184 $table = $this->getTableFromCachesOrReplica();
185 $searchResult = array_search( $name, $table, true );
186
187 if ( $searchResult !== false ) {
188 return $searchResult;
189 }
190
191 throw NameTableAccessException::newFromDetails( $this->table, 'name', $name );
192 }
193
205 public function getName( int $id ) {
206 $table = $this->getTableFromCachesOrReplica();
207 if ( array_key_exists( $id, $table ) ) {
208 return $table[$id];
209 }
210 $fname = __METHOD__;
211
212 $table = $this->cache->getWithSetCallback(
213 $this->getCacheKey(),
214 $this->cacheTTL,
215 function ( $oldValue, &$ttl, &$setOpts ) use ( $id, $fname ) {
216 // Check if cached value is up-to-date enough to have $id
217 if ( is_array( $oldValue ) && array_key_exists( $id, $oldValue ) ) {
218 // Completely leave the cache key alone
219 $ttl = WANObjectCache::TTL_UNCACHEABLE;
220 // Use the old value
221 return $oldValue;
222 }
223 // Regenerate from replica DB, and primary DB if needed
224 foreach ( [ DB_REPLICA, DB_PRIMARY ] as $source ) {
225 // Log a fallback to primary
226 if ( $source === DB_PRIMARY ) {
227 $this->logger->info(
228 $fname . ' falling back to primary select from ' .
229 $this->table . ' with id ' . $id
230 );
231 }
232 $db = $this->getDBConnection( $source );
233 $cacheSetOpts = Database::getCacheSetOptions( $db );
234 $table = $this->loadTable( $db );
235 if ( array_key_exists( $id, $table ) ) {
236 break; // found it
237 }
238 }
239 // Use the value from last source checked
240 $setOpts += $cacheSetOpts;
241
242 return $table;
243 },
244 [ 'minAsOf' => INF ] // force callback run
245 );
246
247 $this->tableCache = $table;
248
249 if ( array_key_exists( $id, $table ) ) {
250 return $table[$id];
251 }
252
253 throw NameTableAccessException::newFromDetails( $this->table, 'id', $id );
254 }
255
263 public function getMap() {
264 return $this->getTableFromCachesOrReplica();
265 }
266
270 private function getTableFromCachesOrReplica() {
271 if ( $this->tableCache !== null ) {
272 return $this->tableCache;
273 }
274
275 $table = $this->cache->getWithSetCallback(
276 $this->getCacheKey(),
277 $this->cacheTTL,
278 function ( $oldValue, &$ttl, &$setOpts ) {
279 $dbr = $this->getDBConnection( DB_REPLICA );
280 $setOpts += Database::getCacheSetOptions( $dbr );
281 return $this->loadTable( $dbr );
282 }
283 );
284
285 $this->tableCache = $table;
286
287 return $table;
288 }
289
296 private function loadTable( IReadableDatabase $db ) {
297 $result = $db->newSelectQueryBuilder()
298 ->select( [
299 'id' => $this->idField,
300 'name' => $this->nameField
301 ] )
302 ->from( $this->table )
303 ->orderBy( 'id' )
304 ->caller( __METHOD__ )->fetchResultSet();
305
306 $assocArray = [];
307 foreach ( $result as $row ) {
308 $assocArray[(int)$row->id] = $row->name;
309 }
310
311 return $assocArray;
312 }
313
320 private function store( string $name ) {
321 Assert::parameter( $name !== '', '$name', 'should not be an empty string' );
322 // Note: this is only called internally so normalization of $name has already occurred.
323
324 $dbw = $this->getDBConnection( DB_PRIMARY, ILoadBalancer::CONN_TRX_AUTOCOMMIT );
325
326 $dbw->newInsertQueryBuilder()
327 ->insertInto( $this->table )
328 ->ignore()
329 ->row( $this->getFieldsToStore( $name ) )
330 ->caller( __METHOD__ )->execute();
331
332 if ( $dbw->affectedRows() > 0 ) {
333 $id = $dbw->insertId();
334 // As store returned an ID we know we inserted so delete from WAN cache
335 $dbw->onTransactionPreCommitOrIdle(
336 function () {
337 $this->cache->delete( $this->getCacheKey() );
338 },
339 __METHOD__
340 );
341
342 return $id;
343 }
344
345 $this->logger->info(
346 'Tried to insert name into table ' . $this->table . ', but value already existed.'
347 );
348
349 // Note that in MySQL, even if this method somehow runs in a transaction, a plain
350 // (non-locking) SELECT will see the new row created by the other transaction, even
351 // with REPEATABLE-READ. This is due to how "consistent reads" works: the latest
352 // version of rows become visible to the snapshot after the transaction sees those
353 // rows as either matching an update query or conflicting with an insert query.
354 $id = $dbw->newSelectQueryBuilder()
355 ->select( [ 'id' => $this->idField ] )
356 ->from( $this->table )
357 ->where( [ $this->nameField => $name ] )
358 ->caller( __METHOD__ )->fetchField();
359
360 if ( $id === false ) {
361 // Insert failed due to IGNORE flag, but DB_PRIMARY didn't give us the data
362 $m = "No insert possible but primary DB didn't give us a record for " .
363 "'{$name}' in '{$this->table}'";
364 $this->logger->error( $m );
365 throw new NameTableAccessException( $m );
366 }
367
368 return (int)$id;
369 }
370
376 private function getFieldsToStore( $name, $id = null ) {
377 $fields = [];
378
379 $fields[$this->nameField] = $name;
380
381 if ( $id !== null ) {
382 $fields[$this->idField] = $id;
383 }
384
385 if ( $this->insertCallback !== null ) {
386 $fields = ( $this->insertCallback )( $fields );
387 }
388 return $fields;
389 }
390
391}
const DB_REPLICA
Definition defines.php:26
const DB_PRIMARY
Definition defines.php:28
static newFromDetails( $tableName, $accessType, $accessValue)
__construct(private readonly ILoadBalancer $loadBalancer, private readonly WANObjectCache $cache, private readonly LoggerInterface $logger, private readonly string $table, private readonly string $idField, private readonly string $nameField, ?callable $normalizationCallback=null, private readonly bool|string $domain=false, ?callable $insertCallback=null,)
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.
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