Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.84% covered (warning)
83.84%
166 / 198
52.63% covered (warning)
52.63%
10 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
LinkCache
84.26% covered (warning)
84.26%
166 / 197
52.63% covered (warning)
52.63%
10 / 19
106.57
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheKey
54.84% covered (warning)
54.84%
17 / 31
0.00% covered (danger)
0.00%
0 / 1
25.26
 getGoodLinkID
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getGoodLinkFieldObj
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
13.08
 isBadLink
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 addGoodLinkObjFromRow
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 addBadLinkObj
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 clearBadLink
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 clearLink
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getSelectFields
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 addLinkObj
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getGoodLinkRowInternal
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
13.03
 getGoodLinkRow
66.67% covered (warning)
66.67%
14 / 21
0.00% covered (danger)
0.00%
0 / 1
7.33
 getPersistentCacheKey
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 usePersistentCache
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 fetchPageRow
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 invalidateTitle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 clear
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Page existence cache.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Cache
22 */
23
24namespace MediaWiki\Cache;
25
26use InvalidArgumentException;
27use MediaWiki\MainConfigNames;
28use MediaWiki\MediaWikiServices;
29use MediaWiki\Page\PageIdentity;
30use MediaWiki\Page\PageReference;
31use MediaWiki\Page\PageStoreRecord;
32use MediaWiki\Title\NamespaceInfo;
33use MediaWiki\Title\TitleFormatter;
34use MediaWiki\Title\TitleValue;
35use Psr\Log\LoggerAwareInterface;
36use Psr\Log\LoggerInterface;
37use Psr\Log\NullLogger;
38use stdClass;
39use Wikimedia\MapCacheLRU\MapCacheLRU;
40use Wikimedia\ObjectCache\WANObjectCache;
41use Wikimedia\Parsoid\Core\LinkTarget;
42use Wikimedia\Rdbms\Database;
43use Wikimedia\Rdbms\IDBAccessObject;
44use Wikimedia\Rdbms\ILoadBalancer;
45use Wikimedia\Rdbms\IReadableDatabase;
46
47/**
48 * Cache for article titles (prefixed DB keys) and ids linked from one source
49 *
50 * @ingroup Cache
51 */
52class LinkCache implements LoggerAwareInterface {
53    /** @var MapCacheLRU */
54    private $entries;
55    /** @var WANObjectCache */
56    private $wanCache;
57    /** @var TitleFormatter */
58    private $titleFormatter;
59    /** @var NamespaceInfo */
60    private $nsInfo;
61    /** @var ILoadBalancer|null */
62    private $loadBalancer;
63    /** @var LoggerInterface */
64    private $logger;
65
66    /** How many Titles to store */
67    private const MAX_SIZE = 10000;
68
69    /** Key to page row object or null */
70    private const ROW = 0;
71    /** Key to query READ_* flags */
72    private const FLAGS = 1;
73
74    /**
75     * @param TitleFormatter $titleFormatter
76     * @param WANObjectCache $cache
77     * @param NamespaceInfo $nsInfo
78     * @param ILoadBalancer|null $loadBalancer Use null when no database is set up, for example on installation
79     */
80    public function __construct(
81        TitleFormatter $titleFormatter,
82        WANObjectCache $cache,
83        NamespaceInfo $nsInfo,
84        ?ILoadBalancer $loadBalancer = null
85    ) {
86        $this->entries = new MapCacheLRU( self::MAX_SIZE );
87        $this->wanCache = $cache;
88        $this->titleFormatter = $titleFormatter;
89        $this->nsInfo = $nsInfo;
90        $this->loadBalancer = $loadBalancer;
91        $this->logger = new NullLogger();
92    }
93
94    public function setLogger( LoggerInterface $logger ) {
95        $this->logger = $logger;
96    }
97
98    /**
99     * @param LinkTarget|PageReference|array|string $page
100     * @param bool $passThrough Return $page if $page is a string
101     * @return ?string the cache key
102     */
103    private function getCacheKey( $page, $passThrough = false ) {
104        if ( is_string( $page ) ) {
105            if ( $passThrough ) {
106                return $page;
107            } else {
108                throw new InvalidArgumentException( 'They key may not be given as a string here' );
109            }
110        }
111
112        if ( is_array( $page ) ) {
113            $namespace = $page['page_namespace'];
114            $dbkey = $page['page_title'];
115            return strtr( $this->titleFormatter->formatTitle( $namespace, $dbkey ), ' ', '_' );
116        }
117
118        if ( $page instanceof PageReference && $page->getWikiId() !== PageReference::LOCAL ) {
119            // No cross-wiki support yet. Perhaps LinkCache can become wiki-aware in the future.
120            $this->logger->info(
121                'cross-wiki page reference',
122                [
123                    'page-wiki' => $page->getWikiId(),
124                    'page-reference' => $this->titleFormatter->getFullText( $page )
125                ]
126            );
127            return null;
128        }
129
130        if ( $page instanceof PageIdentity && !$page->canExist() ) {
131            // Non-proper page, perhaps a special page or interwiki link or relative section link.
132            $this->logger->warning(
133                'non-proper page reference: {page-reference}',
134                [ 'page-reference' => $this->titleFormatter->getFullText( $page ) ]
135            );
136            return null;
137        }
138
139        if ( $page instanceof LinkTarget
140            && ( $page->isExternal() || $page->getText() === '' || $page->getNamespace() < 0 )
141        ) {
142            // Interwiki link or relative section link. These do not have a page ID, so they
143            // can neither be "good" nor "bad" in the sense of this class.
144            $this->logger->warning(
145                'link to non-proper page: {page-link}',
146                [ 'page-link' => $this->titleFormatter->getFullText( $page ) ]
147            );
148            return null;
149        }
150
151        return $this->titleFormatter->getPrefixedDBkey( $page );
152    }
153
154    /**
155     * Get the ID of a page known to the process cache
156     *
157     * @param LinkTarget|PageReference|array|string $page The page to get the ID for,
158     *        as an object, an array containing the page_namespace and page_title fields,
159     *        or a prefixed DB key. In MediaWiki 1.36 and earlier, only a string was accepted.
160     * @return int Page ID, or zero if the page was not cached or does not exist or is not a
161     *         proper page (e.g. a special page or an interwiki link).
162     */
163    public function getGoodLinkID( $page ) {
164        $key = $this->getCacheKey( $page, true );
165        if ( $key === null ) {
166            return 0;
167        }
168
169        $entry = $this->entries->get( $key );
170        if ( !$entry ) {
171            return 0;
172        }
173
174        $row = $entry[self::ROW];
175
176        return $row ? (int)$row->page_id : 0;
177    }
178
179    /**
180     * Get the field of a page known to the process cache
181     *
182     * If this link is not a cached good title, it will return NULL.
183     * @param LinkTarget|PageReference|array $page The page to get cached info for.
184     *        Can be given as an object or an associative array containing the
185     *        page_namespace and page_title fields.
186     *        In MediaWiki 1.36 and earlier, only LinkTarget was accepted.
187     * @param string $field ( 'id', 'length', 'redirect', 'revision', 'model', 'lang' )
188     * @return string|int|null The field value, or null if the page was not cached or does not exist
189     *         or is not a proper page (e.g. a special page or interwiki link).
190     */
191    public function getGoodLinkFieldObj( $page, string $field ) {
192        $key = $this->getCacheKey( $page );
193        if ( $key === null ) {
194            return null;
195        }
196
197        $entry = $this->entries->get( $key );
198        if ( !$entry ) {
199            return null;
200        }
201
202        $row = $entry[self::ROW];
203        if ( !$row ) {
204            return null;
205        }
206
207        switch ( $field ) {
208            case 'id':
209                return (int)$row->page_id;
210            case 'length':
211                return (int)$row->page_len;
212            case 'redirect':
213                return (int)$row->page_is_redirect;
214            case 'revision':
215                return (int)$row->page_latest;
216            case 'model':
217                return !empty( $row->page_content_model )
218                    ? (string)$row->page_content_model
219                    : null;
220            case 'lang':
221                return !empty( $row->page_lang )
222                    ? (string)$row->page_lang
223                    : null;
224            default:
225                throw new InvalidArgumentException( "Unknown field: $field" );
226        }
227    }
228
229    /**
230     * Check if a page is known to be missing based on the process cache
231     *
232     * @param LinkTarget|PageReference|array|string $page The page to get cached info for,
233     *        as an object, an array containing the page_namespace and page_title fields,
234     *        or a prefixed DB key. In MediaWiki 1.36 and earlier, only a string was accepted.
235     *        In MediaWiki 1.36 and earlier, only a string was accepted.
236     * @return bool Whether the page is known to be missing based on the process cache
237     */
238    public function isBadLink( $page ) {
239        $key = $this->getCacheKey( $page, true );
240        if ( $key === null ) {
241            return false;
242        }
243
244        $entry = $this->entries->get( $key );
245
246        return ( $entry && !$entry[self::ROW] );
247    }
248
249    /**
250     * Add information about an existing page to the process cache
251     *
252     * Callers must set the READ_LATEST flag if the row came from a DB_PRIMARY source.
253     * However, the use of such data is highly discouraged; most callers rely on seeing
254     * consistent DB_REPLICA data (e.g. REPEATABLE-READ point-in-time snapshots) and the
255     * accidental use of DB_PRIMARY data via LinkCache is prone to causing anomalies.
256     *
257     * @param LinkTarget|PageReference|array $page The page to set cached info for.
258     *        Can be given as an object or an associative array containing the
259     *        page_namespace and page_title fields.
260     *        In MediaWiki 1.36 and earlier, only LinkTarget was accepted.
261     * @param stdClass $row Object which has all fields returned by getSelectFields().
262     * @param int $queryFlags The query flags used to retrieve the row, IDBAccessObject::READ_*
263     * @since 1.19
264     */
265    public function addGoodLinkObjFromRow(
266        $page,
267        stdClass $row,
268        int $queryFlags = IDBAccessObject::READ_NORMAL
269    ) {
270        $key = $this->getCacheKey( $page );
271        if ( $key === null ) {
272            return;
273        }
274
275        foreach ( self::getSelectFields() as $field ) {
276            if ( !property_exists( $row, $field ) ) {
277                throw new InvalidArgumentException( "Missing field: $field" );
278            }
279        }
280
281        $this->entries->set( $key, [ self::ROW => $row, self::FLAGS => $queryFlags ] );
282    }
283
284    /**
285     * Add information about a missing page to the process cache
286     *
287     * Callers must set the READ_LATEST flag if the row came from a DB_PRIMARY source.
288     * However, the use of such data is highly discouraged; most callers rely on seeing
289     * consistent DB_REPLICA data (e.g. REPEATABLE-READ point-in-time snapshots) and the
290     * accidental use of DB_PRIMARY data via LinkCache is prone to causing anomalies.
291     *
292     * @param LinkTarget|PageReference|array $page The page to set cached info for.
293     *        Can be given as an object or an associative array containing the
294     *        page_namespace and page_title fields.
295     *        In MediaWiki 1.36 and earlier, only LinkTarget was accepted.
296     * @param int $queryFlags The query flags used to retrieve the row, IDBAccessObject::READ_*
297     */
298    public function addBadLinkObj( $page, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
299        $key = $this->getCacheKey( $page );
300        if ( $key === null ) {
301            return;
302        }
303
304        $this->entries->set( $key, [ self::ROW => null, self::FLAGS => $queryFlags ] );
305    }
306
307    /**
308     * Clear information about a page being missing from the process cache
309     *
310     * @param LinkTarget|PageReference|array|string $page The page to clear cached info for,
311     *        as an object, an array containing the page_namespace and page_title fields,
312     *        or a prefixed DB key. In MediaWiki 1.36 and earlier, only a string was accepted.
313     *        In MediaWiki 1.36 and earlier, only a string was accepted.
314     */
315    public function clearBadLink( $page ) {
316        $key = $this->getCacheKey( $page, true );
317        if ( $key === null ) {
318            return;
319        }
320
321        $entry = $this->entries->get( $key );
322        if ( $entry && !$entry[self::ROW] ) {
323            $this->entries->clear( $key );
324        }
325    }
326
327    /**
328     * Clear information about a page from the process cache
329     *
330     * @param LinkTarget|PageReference|array $page The page to clear cached info for.
331     *        Can be given as an object or an associative array containing the
332     *        page_namespace and page_title fields.
333     *        In MediaWiki 1.36 and earlier, only LinkTarget was accepted.
334     */
335    public function clearLink( $page ) {
336        $key = $this->getCacheKey( $page );
337        if ( $key !== null ) {
338            $this->entries->clear( $key );
339        }
340    }
341
342    /**
343     * Fields that LinkCache needs to select
344     *
345     * @since 1.28
346     * @return array
347     */
348    public static function getSelectFields() {
349        $pageLanguageUseDB = MediaWikiServices::getInstance()->getMainConfig()
350            ->get( MainConfigNames::PageLanguageUseDB );
351
352        $fields = array_merge(
353            PageStoreRecord::REQUIRED_FIELDS,
354            [
355                'page_len',
356                'page_content_model',
357            ]
358        );
359
360        if ( $pageLanguageUseDB ) {
361            $fields[] = 'page_lang';
362        }
363
364        return $fields;
365    }
366
367    /**
368     * Add a title to the link cache, return the page_id or zero if non-existent.
369     * This causes the link to be looked up in the database if it is not yet cached.
370     *
371     * @deprecated since 1.37, use PageStore::getPageForLink() instead.
372     *
373     * @param LinkTarget|PageReference|array $page The page to load.
374     *        Can be given as an object or an associative array containing the
375     *        page_namespace and page_title fields.
376     *        In MediaWiki 1.36 and earlier, only LinkTarget was accepted.
377     * @param int $queryFlags IDBAccessObject::READ_XXX
378     *
379     * @return int Page ID or zero
380     */
381    public function addLinkObj( $page, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
382        $row = $this->getGoodLinkRow(
383            $page->getNamespace(),
384            $page->getDBkey(),
385            [ $this, 'fetchPageRow' ],
386            $queryFlags
387        );
388
389        return $row ? (int)$row->page_id : 0;
390    }
391
392    /**
393     * @param TitleValue $link
394     * @param callable|null $fetchCallback
395     * @param int $queryFlags
396     * @return array [ $shouldAddGoodLink, $row ], $shouldAddGoodLink is a bool indicating
397     * whether addGoodLinkObjFromRow should be called, and $row is the row the caller was looking
398     * for (or null, when it was not found).
399     */
400    private function getGoodLinkRowInternal(
401        TitleValue $link,
402        ?callable $fetchCallback = null,
403        int $queryFlags = IDBAccessObject::READ_NORMAL
404    ): array {
405        $callerShouldAddGoodLink = false;
406
407        $key = $this->getCacheKey( $link );
408        if ( $key === null ) {
409            return [ $callerShouldAddGoodLink, null ];
410        }
411
412        $ns = $link->getNamespace();
413        $dbkey = $link->getDBkey();
414
415        $entry = $this->entries->get( $key );
416        if ( $entry && $entry[self::FLAGS] >= $queryFlags ) {
417            return [ $callerShouldAddGoodLink, $entry[self::ROW] ?: null ];
418        }
419
420        if ( !$fetchCallback ) {
421            return [ $callerShouldAddGoodLink, null ];
422        }
423
424        $callerShouldAddGoodLink = true;
425
426        $wanCacheKey = $this->getPersistentCacheKey( $link );
427        if ( $wanCacheKey !== null && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) {
428            // Some pages are often transcluded heavily, so use persistent caching
429            $row = $this->wanCache->getWithSetCallback(
430                $wanCacheKey,
431                WANObjectCache::TTL_DAY,
432                function ( $curValue, &$ttl, array &$setOpts ) use ( $fetchCallback, $ns, $dbkey ) {
433                    $dbr = $this->loadBalancer->getConnection( ILoadBalancer::DB_REPLICA );
434                    $setOpts += Database::getCacheSetOptions( $dbr );
435
436                    $row = $fetchCallback( $dbr, $ns, $dbkey, [] );
437                    $mtime = $row ? (int)wfTimestamp( TS_UNIX, $row->page_touched ) : false;
438                    $ttl = $this->wanCache->adaptiveTTL( $mtime, $ttl );
439
440                    return $row;
441                }
442            );
443        } else {
444            // No persistent caching needed, but we can still use the callback.
445            if ( ( $queryFlags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
446                $dbr = $this->loadBalancer->getConnection( DB_PRIMARY );
447            } else {
448                $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
449            }
450            $options = [];
451            if ( ( $queryFlags & IDBAccessObject::READ_EXCLUSIVE ) == IDBAccessObject::READ_EXCLUSIVE ) {
452                $options[] = 'FOR UPDATE';
453            } elseif ( ( $queryFlags & IDBAccessObject::READ_LOCKING ) == IDBAccessObject::READ_LOCKING ) {
454                $options[] = 'LOCK IN SHARE MODE';
455            }
456            $row = $fetchCallback( $dbr, $ns, $dbkey, $options );
457        }
458
459        return [ $callerShouldAddGoodLink, $row ?: null ];
460    }
461
462    /**
463     * Returns the row for the page if the page exists (subject to race conditions).
464     * The row will be returned from local cache or WAN cache if possible, or it
465     * will be looked up using the callback provided.
466     *
467     * @param int $ns
468     * @param string $dbkey
469     * @param callable|null $fetchCallback A callback that will retrieve the link row with the
470     *        signature ( IReadableDatabase $db, int $ns, string $dbkey, array $queryOptions ): ?stdObj.
471     * @param int $queryFlags IDBAccessObject::READ_XXX
472     *
473     * @return stdClass|null
474     * @internal for use by PageStore. Other code should use a PageLookup instead.
475     */
476    public function getGoodLinkRow(
477        int $ns,
478        string $dbkey,
479        ?callable $fetchCallback = null,
480        int $queryFlags = IDBAccessObject::READ_NORMAL
481    ): ?stdClass {
482        $link = TitleValue::tryNew( $ns, $dbkey );
483        if ( $link === null ) {
484            return null;
485        }
486
487        [ $shouldAddGoodLink, $row ] = $this->getGoodLinkRowInternal(
488            $link,
489            $fetchCallback,
490            $queryFlags
491        );
492
493        if ( $row ) {
494            if ( $shouldAddGoodLink ) {
495                try {
496                    $this->addGoodLinkObjFromRow( $link, $row, $queryFlags );
497                } catch ( InvalidArgumentException $e ) {
498                    // a field is missing from $row; maybe we used a cache?; invalidate it and try again
499                    $this->invalidateTitle( $link );
500                    [ , $row ] = $this->getGoodLinkRowInternal(
501                        $link,
502                        $fetchCallback,
503                        $queryFlags
504                    );
505                    $this->addGoodLinkObjFromRow( $link, $row, $queryFlags );
506                }
507            }
508        } else {
509            $this->addBadLinkObj( $link );
510        }
511
512        return $row ?: null;
513    }
514
515    /**
516     * @param LinkTarget|PageReference|TitleValue $page
517     * @return string|null
518     */
519    private function getPersistentCacheKey( $page ) {
520        // if no key can be derived, the page isn't cacheable
521        if ( $this->getCacheKey( $page ) === null || !$this->usePersistentCache( $page ) ) {
522            return null;
523        }
524
525        return $this->wanCache->makeKey(
526            'page',
527            $page->getNamespace(),
528            sha1( $page->getDBkey() )
529        );
530    }
531
532    /**
533     * @param LinkTarget|PageReference|int $pageOrNamespace
534     * @return bool
535     */
536    private function usePersistentCache( $pageOrNamespace ) {
537        $ns = is_int( $pageOrNamespace ) ? $pageOrNamespace : $pageOrNamespace->getNamespace();
538        if ( in_array( $ns, [ NS_TEMPLATE, NS_FILE, NS_CATEGORY, NS_MEDIAWIKI ] ) ) {
539            return true;
540        }
541        // Focus on transcluded pages more than the main content
542        if ( $this->nsInfo->isContent( $ns ) ) {
543            return false;
544        }
545        // Non-talk extension namespaces (e.g. NS_MODULE)
546        return ( $ns >= 100 && $this->nsInfo->isSubject( $ns ) );
547    }
548
549    /**
550     * @param IReadableDatabase $db
551     * @param int $ns
552     * @param string $dbkey
553     * @param array $options Query options, see IDatabase::select() for details.
554     * @return stdClass|false
555     */
556    private function fetchPageRow( IReadableDatabase $db, int $ns, string $dbkey, $options = [] ) {
557        $queryBuilder = $db->newSelectQueryBuilder()
558            ->select( self::getSelectFields() )
559            ->from( 'page' )
560            ->where( [ 'page_namespace' => $ns, 'page_title' => $dbkey ] )
561            ->options( $options );
562
563        return $queryBuilder->caller( __METHOD__ )->fetchRow();
564    }
565
566    /**
567     * Purge the persistent link cache for a title
568     *
569     * @param LinkTarget|PageReference $page
570     *        In MediaWiki 1.36 and earlier, only LinkTarget was accepted.
571     * @since 1.28
572     */
573    public function invalidateTitle( $page ) {
574        $wanCacheKey = $this->getPersistentCacheKey( $page );
575        if ( $wanCacheKey !== null ) {
576            $this->wanCache->delete( $wanCacheKey );
577        }
578
579        $this->clearLink( $page );
580    }
581
582    /**
583     * Clears cache
584     */
585    public function clear() {
586        $this->entries->clear();
587    }
588}
589
590/** @deprecated class alias since 1.42 */
591class_alias( LinkCache::class, 'LinkCache' );