Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.86% covered (warning)
87.86%
123 / 140
69.23% covered (warning)
69.23%
9 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
NameTableStore
87.86% covered (warning)
87.86%
123 / 140
69.23% covered (warning)
69.23%
9 / 13
31.61
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getDBConnection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 normalizeName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 acquireId
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
3.33
 reloadMap
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getName
77.42% covered (warning)
77.42%
24 / 31
0.00% covered (danger)
0.00%
0 / 1
8.74
 getMap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTableFromCachesOrReplica
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 loadTable
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 store
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
3.02
 getFieldsToStore
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Storage;
8
9use Psr\Log\LoggerInterface;
10use Wikimedia\Assert\Assert;
11use Wikimedia\ObjectCache\BagOStuff;
12use Wikimedia\ObjectCache\WANObjectCache;
13use Wikimedia\Rdbms\Database;
14use Wikimedia\Rdbms\IDatabase;
15use Wikimedia\Rdbms\ILoadBalancer;
16use Wikimedia\Rdbms\IReadableDatabase;
17
18/**
19 * @since 1.31
20 * @author Addshore
21 */
22class NameTableStore {
23
24    /** @var ILoadBalancer */
25    private $loadBalancer;
26
27    /** @var WANObjectCache */
28    private $cache;
29
30    /** @var LoggerInterface */
31    private $logger;
32
33    /** @var array<int,string>|null */
34    private $tableCache = null;
35
36    /** @var bool|string */
37    private $domain;
38
39    /** @var int */
40    private $cacheTTL;
41
42    /** @var string */
43    private $table;
44    /** @var string */
45    private $idField;
46    /** @var string */
47    private $nameField;
48    /** @var null|callable */
49    private $normalizationCallback;
50    /** @var null|callable */
51    private $insertCallback;
52
53    /**
54     * @param ILoadBalancer $dbLoadBalancer A load balancer for acquiring database connections
55     * @param WANObjectCache $cache A cache manager for caching data. This can be the local
56     *        wiki's default instance even if $dbDomain refers to a different wiki, since
57     *        makeGlobalKey() is used to constructed a key that allows cached names from
58     *        the same database to be re-used between wikis. For example, enwiki and frwiki will
59     *        use the same cache keys for names from the wikidatawiki database, regardless
60     *        of the cache's default key space.
61     * @param LoggerInterface $logger
62     * @param string $table
63     * @param string $idField
64     * @param string $nameField
65     * @param callable|null $normalizationCallback Normalization to be applied to names before being
66     * saved or queried. This should be a callback that accepts and returns a single string.
67     * @param bool|string $dbDomain Database domain ID. Use false for the local database domain.
68     * @param callable|null $insertCallback Callback to change insert fields accordingly.
69     * This parameter was introduced in 1.32
70     */
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
94    /**
95     * @param int $index A database index, like DB_PRIMARY or DB_REPLICA
96     * @param int $flags Database connection flags
97     * @return IDatabase
98     */
99    private function getDBConnection( $index, $flags = 0 ) {
100        return $this->loadBalancer->getConnection( $index, [], $this->domain, $flags );
101    }
102
103    /**
104     * Gets the cache key for names.
105     *
106     * The cache key is constructed based on the wiki ID passed to the constructor, and allows
107     * sharing of name tables cached for a specific database between wikis.
108     *
109     * @return string
110     */
111    private function getCacheKey() {
112        return $this->cache->makeGlobalKey(
113            'NameTableSqlStore',
114            $this->table,
115            $this->loadBalancer->resolveDomainID( $this->domain )
116        );
117    }
118
119    /**
120     * @param string $name
121     * @return string
122     */
123    private function normalizeName( $name ) {
124        if ( $this->normalizationCallback === null ) {
125            return $name;
126        }
127        return ( $this->normalizationCallback )( $name );
128    }
129
130    /**
131     * Acquire the id of the given name.
132     * This creates a row in the table if it doesn't already exist.
133     *
134     * @note If called within an atomic section, there is a chance for the acquired ID to be
135     * lost on rollback. There is no guarantee that an ID returned by this method is valid
136     * outside the transaction in which it was produced. This means that calling code should
137     * not retain the return value beyond the scope of a transaction, but rather call acquireId()
138     * again after the transaction is complete. In some rare cases, this may produce an ID
139     * different from the first call.
140     *
141     * @param string $name
142     * @throws NameTableAccessException
143     * @return int
144     */
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
175    /**
176     * Reloads the name table from the primary database, and purges the WAN cache entry.
177     *
178     * @note This should only be called in situations where the local cache has been detected
179     * to be out of sync with the database. There should be no reason to call this method
180     * from outside the NameTableStore during normal operation. This method may however be
181     * useful in unit tests.
182     *
183     * @param int $connFlags ILoadBalancer::CONN_XXX flags. Optional.
184     *
185     * @return string[] The freshly reloaded name map
186     */
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
197    /**
198     * Get the id of the given name.
199     * If the name doesn't exist this will throw.
200     * This should be used in cases where we believe the name already exists or want to check for
201     * existence.
202     *
203     * @param string $name
204     * @throws NameTableAccessException The name does not exist
205     * @return int Id
206     */
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
220    /**
221     * Get the name of the given id.
222     * If the id doesn't exist this will throw.
223     * This should be used in cases where we believe the id already exists.
224     *
225     * Note: Calls to this method will result in a primary DB select for non existing IDs.
226     *
227     * @param int $id
228     * @throws NameTableAccessException The id does not exist
229     * @return string name
230     */
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
282    /**
283     * Get the whole table, in no particular order as a map of ids to names.
284     * This method could be subject to DB or cache lag.
285     *
286     * @return string[] keys are the name ids, values are the names themselves
287     *  Example: [ 1 => 'foo', 3 => 'bar' ]
288     */
289    public function getMap() {
290        return $this->getTableFromCachesOrReplica();
291    }
292
293    /**
294     * @return array<int,string>
295     */
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
316    /**
317     * Gets the table from the db
318     *
319     * @param IReadableDatabase $db
320     * @return array<int,string>
321     */
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
340    /**
341     * Stores the given name in the DB, returning the ID when an insert occurs.
342     *
343     * @param string $name
344     * @return int The new or colliding ID
345     */
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
397    /**
398     * @param string $name
399     * @param int|null $id
400     * @return array
401     */
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}