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