Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
74.30% |
159 / 214 |
|
38.46% |
5 / 13 |
CRAP | |
0.00% |
0 / 1 |
BacklinkCache | |
74.65% |
159 / 213 |
|
38.46% |
5 / 13 |
118.66 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
getPage | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDB | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getLinkPages | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
queryLinks | |
91.30% |
21 / 23 |
|
0.00% |
0 / 1 |
13.11 | |||
getPrefix | |
64.29% |
9 / 14 |
|
0.00% |
0 / 1 |
3.41 | |||
initQueryBuilderForTable | |
53.19% |
25 / 47 |
|
0.00% |
0 / 1 |
30.33 | |||
hasLinks | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getNumLinks | |
88.00% |
22 / 25 |
|
0.00% |
0 / 1 |
5.04 | |||
partition | |
86.49% |
32 / 37 |
|
0.00% |
0 / 1 |
5.06 | |||
partitionResult | |
85.00% |
17 / 20 |
|
0.00% |
0 / 1 |
9.27 | |||
getCascadeProtectedLinkPages | |
33.33% |
1 / 3 |
|
0.00% |
0 / 1 |
3.19 | |||
getCascadeProtectedLinksInternal | |
61.29% |
19 / 31 |
|
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 | |
28 | namespace MediaWiki\Cache; |
29 | |
30 | use Iterator; |
31 | use LogicException; |
32 | use MediaWiki\Config\ServiceOptions; |
33 | use MediaWiki\HookContainer\HookContainer; |
34 | use MediaWiki\HookContainer\HookRunner; |
35 | use MediaWiki\Linker\LinksMigration; |
36 | use MediaWiki\MainConfigNames; |
37 | use MediaWiki\Page\PageIdentity; |
38 | use MediaWiki\Page\PageIdentityValue; |
39 | use MediaWiki\Page\PageReference; |
40 | use MediaWiki\Title\Title; |
41 | use MediaWiki\Title\TitleValue; |
42 | use RuntimeException; |
43 | use stdClass; |
44 | use WANObjectCache; |
45 | use Wikimedia\Rdbms\Database; |
46 | use Wikimedia\Rdbms\IConnectionProvider; |
47 | use Wikimedia\Rdbms\IReadableDatabase; |
48 | use Wikimedia\Rdbms\IResultWrapper; |
49 | use 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 | */ |
64 | class 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 */ |
564 | class_alias( BacklinkCache::class, 'BacklinkCache' ); |