Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.57% covered (warning)
75.57%
167 / 221
35.71% covered (danger)
35.71%
5 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
BacklinkCache
75.91% covered (warning)
75.91%
167 / 220
35.71% covered (danger)
35.71%
5 / 14
107.67
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDB
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLinkPages
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 queryLinks
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
12.11
 getPrefix
64.29% covered (warning)
64.29%
9 / 14
0.00% covered (danger)
0.00%
0 / 1
3.41
 initQueryBuilderForTable
65.96% covered (warning)
65.96%
31 / 47
0.00% covered (danger)
0.00%
0 / 1
19.67
 hasLinks
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
4.43
 getNumLinks
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
3.04
 partition
76.92% covered (warning)
76.92%
30 / 39
0.00% covered (danger)
0.00%
0 / 1
4.20
 openBatchEnds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 partitionResult
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 getCascadeProtectedLinkPages
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 getCascadeProtectedLinksInternal
61.29% covered (warning)
61.29%
19 / 31
0.00% covered (danger)
0.00%
0 / 1
4.93
1<?php
2/**
3 * Class for fetching backlink lists, approximate backlink counts and
4 * partitions.
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
20 *
21 * @file
22 * @author Tim Starling
23 * @copyright © 2009, Tim Starling, Domas Mituzas
24 * @copyright © 2010, Max Sem
25 * @copyright © 2011, Antoine Musso
26 */
27
28namespace MediaWiki\Cache;
29
30use Iterator;
31use LogicException;
32use MediaWiki\Config\ServiceOptions;
33use MediaWiki\HookContainer\HookContainer;
34use MediaWiki\HookContainer\HookRunner;
35use MediaWiki\Linker\LinksMigration;
36use MediaWiki\MainConfigNames;
37use MediaWiki\Page\PageIdentity;
38use MediaWiki\Page\PageIdentityValue;
39use MediaWiki\Page\PageReference;
40use MediaWiki\Title\Title;
41use MediaWiki\Title\TitleValue;
42use Psr\Log\LoggerInterface;
43use RuntimeException;
44use stdClass;
45use Wikimedia\ObjectCache\WANObjectCache;
46use Wikimedia\Rdbms\Database;
47use Wikimedia\Rdbms\IConnectionProvider;
48use Wikimedia\Rdbms\IReadableDatabase;
49use Wikimedia\Rdbms\IResultWrapper;
50use Wikimedia\Rdbms\SelectQueryBuilder;
51
52/**
53 * Class for fetching backlink lists, approximate backlink counts and
54 * partitions. This is a shared cache.
55 *
56 * Instances of this class should typically be fetched with the method
57 * ::getBacklinkCache() from the BacklinkCacheFactory service.
58 *
59 * Ideally you should only get your backlinks from here when you think
60 * there is some advantage in caching them. Otherwise, it's just a waste
61 * of memory.
62 */
63class BacklinkCache {
64    /**
65     * @internal Used by ServiceWiring.php
66     */
67    public const CONSTRUCTOR_OPTIONS = [
68        MainConfigNames::UpdateRowsPerJob,
69    ];
70
71    /**
72     * Multi-dimensional array representing batches. Keys are:
73     *  > (string) links table name
74     *   > (int) batch size
75     *    > 'numRows' : Number of rows for this link table
76     *    > 'batches' : [ [ $start, $end ] ]
77     *
78     * @see BacklinkCache::partitionResult()
79     * @var array[]
80     */
81    private $partitionCache = [];
82
83    /**
84     * Contains the whole links from a database result.
85     * This is raw data that will be partitioned in $partitionCache
86     *
87     * Initialized with BacklinkCache::queryLinks()
88     *
89     * @var IResultWrapper[]
90     */
91    private $fullResultCache = [];
92
93    /**
94     * Cache for hasLinks()
95     *
96     * @var bool[]
97     */
98    private $hasLinksCache = [];
99
100    /** @var WANObjectCache */
101    private $wanCache;
102
103    /** @var HookRunner */
104    private $hookRunner;
105
106    /**
107     * Local copy of a PageReference object
108     * @var PageReference
109     */
110    private $page;
111
112    private const CACHE_EXPIRY = 3600;
113    private IConnectionProvider $dbProvider;
114    private ServiceOptions $options;
115    private LinksMigration $linksMigration;
116    private LoggerInterface $logger;
117
118    /**
119     * Create a new BacklinkCache
120     *
121     * @param ServiceOptions $options
122     * @param LinksMigration $linksMigration
123     * @param WANObjectCache $wanCache
124     * @param HookContainer $hookContainer
125     * @param IConnectionProvider $dbProvider
126     * @param LoggerInterface $logger
127     * @param PageReference $page Page to create a backlink cache for
128     */
129    public function __construct(
130        ServiceOptions $options,
131        LinksMigration $linksMigration,
132        WANObjectCache $wanCache,
133        HookContainer $hookContainer,
134        IConnectionProvider $dbProvider,
135        LoggerInterface $logger,
136        PageReference $page
137    ) {
138        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
139        $this->options = $options;
140        $this->linksMigration = $linksMigration;
141        $this->wanCache = $wanCache;
142        $this->hookRunner = new HookRunner( $hookContainer );
143        $this->dbProvider = $dbProvider;
144        $this->logger = $logger;
145        $this->page = $page;
146    }
147
148    /**
149     * @since 1.37
150     * @return PageReference
151     */
152    public function getPage(): PageReference {
153        return $this->page;
154    }
155
156    /**
157     * Get the replica DB connection to the database
158     *
159     * @return IReadableDatabase
160     */
161    private function getDB() {
162        return $this->dbProvider->getReplicaDatabase();
163    }
164
165    /**
166     * Get the backlinks for a given table. Cached in process memory only.
167     * @param string $table
168     * @param int|bool $startId
169     * @param int|bool $endId
170     * @param int|float $max Integer, or INF for no max
171     * @return Iterator<PageIdentity>
172     * @since 1.37
173     */
174    public function getLinkPages(
175        string $table, $startId = false, $endId = false, $max = INF
176    ): Iterator {
177        $i = 0;
178        foreach ( $this->queryLinks( $table, $startId, $endId, $max ) as $row ) {
179            yield PageIdentityValue::localIdentity(
180                $row->page_id, $row->page_namespace, $row->page_title );
181
182            // queryLinks() may return too many rows
183            if ( is_finite( $max ) && ++$i >= $max ) {
184                break;
185            }
186        }
187    }
188
189    /**
190     * Get the backlinks for a given table. Cached in process memory only.
191     *
192     * @param string $table
193     * @param int|bool $startId
194     * @param int|bool $endId
195     * @param int|float $max A hint for the maximum number of rows to return.
196     *   May return more rows if there is a previously cached result set.
197     * @param string $select 'all' or 'ids'
198     * @return IResultWrapper
199     */
200    private function queryLinks( $table, $startId, $endId, $max, $select = 'all' ) {
201        if ( !$startId && !$endId && isset( $this->fullResultCache[$table] ) ) {
202            $this->logger->debug( __METHOD__ . ': got results from cache' );
203            return $this->fullResultCache[$table];
204        }
205
206        $this->logger->debug( __METHOD__ . ': got results from DB' );
207        $queryBuilder = $this->initQueryBuilderForTable( $table, $select );
208        $fromField = $this->getPrefix( $table ) . '_from';
209        // Use the from field in the condition rather than the joined page_id,
210        // because databases are stupid and don't necessarily propagate indexes.
211        if ( $startId ) {
212            $queryBuilder->where(
213                $this->getDB()->expr( $fromField, '>=', $startId )
214            );
215        }
216        if ( $endId ) {
217            $queryBuilder->where(
218                $this->getDB()->expr( $fromField, '<=', $endId )
219            );
220        }
221        $queryBuilder->orderBy( $fromField );
222        if ( is_finite( $max ) && $max > 0 ) {
223            $queryBuilder->limit( $max );
224        }
225
226        $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
227
228        if ( $select === 'all' && !$startId && !$endId && $res->numRows() < $max ) {
229            // The full results fit within the limit, so cache them
230            $this->fullResultCache[$table] = $res;
231        } else {
232            $this->logger->debug( __METHOD__ . ": results from DB were uncacheable" );
233        }
234
235        return $res;
236    }
237
238    /**
239     * Get the field name prefix for a given table
240     * @param string $table
241     * @return null|string
242     */
243    private function getPrefix( $table ) {
244        static $prefixes = [
245            'pagelinks' => 'pl',
246            'imagelinks' => 'il',
247            'categorylinks' => 'cl',
248            'templatelinks' => 'tl',
249            'redirect' => 'rd',
250        ];
251
252        if ( isset( $prefixes[$table] ) ) {
253            return $prefixes[$table];
254        } else {
255            $prefix = null;
256            $this->hookRunner->onBacklinkCacheGetPrefix( $table, $prefix );
257            if ( $prefix ) {
258                return $prefix;
259            } else {
260                throw new LogicException( "Invalid table \"$table\" in " . __CLASS__ );
261            }
262        }
263    }
264
265    /**
266     * Initialize a new SelectQueryBuilder for selecting backlinks,
267     * with a join on the page table if needed.
268     *
269     * @param string $table
270     * @param string $select
271     * @return SelectQueryBuilder
272     */
273    private function initQueryBuilderForTable( string $table, string $select ): SelectQueryBuilder {
274        $prefix = $this->getPrefix( $table );
275        $queryBuilder = $this->getDB()->newSelectQueryBuilder();
276        $joinPageTable = $select !== 'ids';
277
278        if ( $select === 'ids' ) {
279            $queryBuilder->select( [ 'page_id' => $prefix . '_from' ] );
280        } else {
281            $queryBuilder->select( [ 'page_namespace', 'page_title', 'page_id' ] );
282        }
283        $queryBuilder->from( $table );
284
285        /*
286         * If the table is one of the tables known to this method,
287         * we can use a nice join() method later, always joining on page_id={$prefix}_from.
288         * If the table is unknown here, and only supported via a hook,
289         * the hook only produces a single $conds array,
290         * so we have to use a traditional / ANSI-89 JOIN,
291         * with the page table just added to the list of tables and the join conds in the WHERE part.
292         */
293        $knownTable = true;
294
295        switch ( $table ) {
296            case 'pagelinks':
297            case 'templatelinks':
298                $queryBuilder->where(
299                    $this->linksMigration->getLinksConditions( $table, TitleValue::newFromPage( $this->page ) )
300                );
301                break;
302            case 'redirect':
303                $queryBuilder->where( [
304                    "{$prefix}_namespace" => $this->page->getNamespace(),
305                    "{$prefix}_title" => $this->page->getDBkey(),
306                    "{$prefix}_interwiki" => [ '', null ],
307                ] );
308                break;
309            case 'imagelinks':
310            case 'categorylinks':
311                $queryBuilder->where( [
312                    "{$prefix}_to" => $this->page->getDBkey(),
313                ] );
314                break;
315            default:
316                $knownTable = false;
317                $conds = null;
318                $this->hookRunner->onBacklinkCacheGetConditions( $table,
319                    Title::newFromPageReference( $this->page ),
320                    $conds
321                );
322                if ( !$conds ) {
323                    throw new LogicException( "Invalid table \"$table\" in " . __CLASS__ );
324                }
325                if ( $joinPageTable ) {
326                    $queryBuilder->table( 'page' ); // join condition in $conds
327                } else {
328                    // remove any page_id condition from $conds
329                    $conds = array_filter( (array)$conds, static function ( $clause ) { // kind of janky
330                        return !preg_match( '/(\b|=)page_id(\b|=)/', (string)$clause );
331                    } );
332                }
333                $queryBuilder->where( $conds );
334                break;
335        }
336
337        if ( $knownTable && $joinPageTable ) {
338            $queryBuilder->join( 'page', null, "page_id={$prefix}_from" );
339        }
340        if ( $joinPageTable ) {
341            $queryBuilder->straightJoinOption();
342        }
343
344        return $queryBuilder;
345    }
346
347    /**
348     * Check if there are any backlinks. Only use the process cache, since the
349     * WAN cache is potentially stale (T368006).
350     *
351     * @param string $table
352     * @return bool
353     */
354    public function hasLinks( $table ) {
355        if ( isset( $this->hasLinksCache[$table] ) ) {
356            return $this->hasLinksCache[$table];
357        }
358        if ( isset( $this->partitionCache[$table] ) ) {
359            $entry = reset( $this->partitionCache[$table] );
360            return (bool)$entry['numRows'];
361        }
362        if ( isset( $this->fullResultCache[$table] ) ) {
363            return (bool)$this->fullResultCache[$table]->numRows();
364        }
365        $hasLinks = (bool)$this->queryLinks( $table, false, false, 1 )->numRows();
366        $this->hasLinksCache[$table] = $hasLinks;
367        return $hasLinks;
368    }
369
370    /**
371     * Get the approximate number of backlinks
372     * @param string $table
373     * @return int
374     */
375    public function getNumLinks( $table ) {
376        if ( isset( $this->partitionCache[$table] ) ) {
377            $entry = reset( $this->partitionCache[$table] );
378            return $entry['numRows'];
379        }
380
381        if ( isset( $this->fullResultCache[$table] ) ) {
382            return $this->fullResultCache[$table]->numRows();
383        }
384
385        return $this->wanCache->getWithSetCallback(
386            $this->wanCache->makeKey(
387                'numbacklinks',
388                CacheKeyHelper::getKeyForPage( $this->page ),
389                $table
390            ),
391            self::CACHE_EXPIRY,
392            function ( $oldValue, &$ttl, array &$setOpts ) use ( $table ) {
393                $setOpts += Database::getCacheSetOptions( $this->getDB() );
394
395                // Use partition() since it will batch the query and skip the JOIN.
396                // Use $wgUpdateRowsPerJob just to encourage cache reuse for jobs.
397                $batchSize = $this->options->get( MainConfigNames::UpdateRowsPerJob );
398                $this->partition( $table, $batchSize );
399                return $this->partitionCache[$table][$batchSize]['numRows'];
400            }
401        );
402    }
403
404    /**
405     * Partition the backlinks into batches.
406     * Returns an array giving the start and end of each range. The first
407     * batch has a start of false, and the last batch has an end of false.
408     *
409     * @param string $table The links table name
410     * @param int $batchSize
411     * @return array
412     */
413    public function partition( $table, $batchSize ) {
414        if ( isset( $this->partitionCache[$table][$batchSize] ) ) {
415            $this->logger->debug( __METHOD__ . ": got from partition cache" );
416
417            return $this->partitionCache[$table][$batchSize]['batches'];
418        }
419
420        $this->partitionCache[$table][$batchSize] = false;
421        $cacheEntry =& $this->partitionCache[$table][$batchSize];
422
423        if ( isset( $this->fullResultCache[$table] ) ) {
424            $res = $this->fullResultCache[$table];
425            $numRows = $res->numRows();
426            $batches = $this->partitionResult( $res, $numRows, $batchSize );
427            $this->openBatchEnds( $batches );
428            $cacheEntry = [ 'numRows' => $numRows, 'batches' => $batches ];
429            $this->logger->debug( __METHOD__ . ": got from full result cache" );
430
431            return $cacheEntry['batches'];
432        }
433
434        $cacheEntry = $this->wanCache->getWithSetCallback(
435            $this->wanCache->makeKey(
436                'backlinks',
437                CacheKeyHelper::getKeyForPage( $this->page ),
438                $table,
439                $batchSize
440            ),
441            self::CACHE_EXPIRY,
442            function ( $oldValue, &$ttl, array &$setOpts ) use ( $table, $batchSize ) {
443                $setOpts += Database::getCacheSetOptions( $this->getDB() );
444
445                $value = [ 'numRows' => 0, 'batches' => [] ];
446
447                // Do the selects in batches to avoid client-side OOMs (T45452).
448                // Use a LIMIT that plays well with $batchSize to keep equal sized partitions.
449                $selectSize = max( $batchSize, 200_000 - ( 200_000 % $batchSize ) );
450                $start = false;
451                do {
452                    $res = $this->queryLinks( $table, $start, false, $selectSize, 'ids' );
453                    $numRows = $res->numRows();
454                    $batches = $this->partitionResult( $res, $numRows, $batchSize );
455                    // Merge the link count and range partitions for this chunk
456                    $value['numRows'] += $numRows;
457                    $value['batches'] = array_merge( $value['batches'], $batches );
458                    if ( count( $batches ) ) {
459                        // pick up after this inclusive range
460                        $start = end( $batches )[1] + 1;
461                    }
462                } while ( $numRows >= $selectSize );
463                // Make sure the first range has start=false and the last one has end=false
464                $this->openBatchEnds( $value['batches'] );
465
466                return $value;
467            }
468        );
469
470        return $cacheEntry['batches'];
471    }
472
473    /**
474     * Modify an array of batches, setting the start of the first batch to
475     * false, and the end of the last batch to false, so that the complete
476     * set of batches covers the entire ID range from 0 to infinity.
477     *
478     * @param array &$batches
479     */
480    private function openBatchEnds( array &$batches ) {
481        if ( !count( $batches ) ) {
482            $batches = [ [ false, false ] ];
483        } else {
484            $batches[0][0] = false;
485            $batches[ array_key_last( $batches ) ][1] = false;
486        }
487    }
488
489    /**
490     * Partition a DB result with backlinks in it into batches
491     * @param IResultWrapper $res Database result
492     * @param int $numRows The number of rows to use from the result set
493     * @param int $batchSize
494     * @return int[][]
495     */
496    private function partitionResult( $res, $numRows, $batchSize ) {
497        $numBatches = ceil( $numRows / $batchSize );
498        $batches = [];
499        for ( $i = 0; $i < $numBatches; $i++ ) {
500            $rowNum = $i * $batchSize;
501            $res->seek( $rowNum );
502            $row = $res->fetchObject();
503            $start = (int)$row->page_id;
504
505            $rowNum = min( $numRows - 1, ( $i + 1 ) * $batchSize - 1 );
506            $res->seek( $rowNum );
507            $row = $res->fetchObject();
508            $end = (int)$row->page_id;
509
510            // Check order
511            if ( $start && $end && $start > $end ) {
512                throw new RuntimeException( __METHOD__ . ': Internal error: query result out of order' );
513            }
514
515            $batches[] = [ $start, $end ];
516        }
517
518        return $batches;
519    }
520
521    /**
522     * Get a PageIdentity iterator for cascade-protected template/file use backlinks
523     *
524     * @return Iterator<PageIdentity>
525     * @since 1.37
526     */
527    public function getCascadeProtectedLinkPages(): Iterator {
528        foreach ( $this->getCascadeProtectedLinksInternal() as $row ) {
529            yield PageIdentityValue::localIdentity(
530                $row->page_id, $row->page_namespace, $row->page_title );
531        }
532    }
533
534    /**
535     * Get an array of cascade-protected template/file use backlinks
536     *
537     * @return stdClass[]
538     */
539    private function getCascadeProtectedLinksInternal(): array {
540        $dbr = $this->getDB();
541
542        // @todo: use UNION without breaking tests that use temp tables
543        $resSets = [];
544        $linkConds = $this->linksMigration->getLinksConditions(
545            'templatelinks', TitleValue::newFromPage( $this->page )
546        );
547        $resSets[] = $dbr->newSelectQueryBuilder()
548            ->select( [ 'page_namespace', 'page_title', 'page_id' ] )
549            ->from( 'templatelinks' )
550            ->join( 'page_restrictions', null, 'tl_from = pr_page' )
551            ->join( 'page', null, 'page_id = tl_from' )
552            ->where( $linkConds )
553            ->andWhere( [ 'pr_cascade' => 1 ] )
554            ->distinct()
555            ->caller( __METHOD__ )->fetchResultSet();
556        if ( $this->page->getNamespace() === NS_FILE ) {
557            $resSets[] = $dbr->newSelectQueryBuilder()
558                ->select( [ 'page_namespace', 'page_title', 'page_id' ] )
559                ->from( 'imagelinks' )
560                ->join( 'page_restrictions', null, 'il_from = pr_page' )
561                ->join( 'page', null, 'page_id = il_from' )
562                ->where( [
563                    'il_to' => $this->page->getDBkey(),
564                    'pr_cascade' => 1,
565                ] )
566                ->distinct()
567                ->caller( __METHOD__ )->fetchResultSet();
568        }
569
570        // Combine and de-duplicate the results
571        $mergedRes = [];
572        foreach ( $resSets as $res ) {
573            foreach ( $res as $row ) {
574                // Index by page_id to remove duplicates
575                $mergedRes[$row->page_id] = $row;
576            }
577        }
578
579        // Now that we've de-duplicated, throw away the keys
580        return array_values( $mergedRes );
581    }
582}
583
584/** @deprecated class alias since 1.42 */
585class_alias( BacklinkCache::class, 'BacklinkCache' );