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