MediaWiki master
LinkCache.php
Go to the documentation of this file.
1<?php
33use Psr\Log\LoggerAwareInterface;
34use Psr\Log\LoggerInterface;
35use Psr\Log\NullLogger;
39
45class LinkCache implements LoggerAwareInterface {
47 private $entries;
49 private $wanCache;
51 private $titleFormatter;
53 private $nsInfo;
55 private $loadBalancer;
57 private $logger;
58
60 private const MAX_SIZE = 10000;
61
63 private const ROW = 0;
65 private const FLAGS = 1;
66
73 public function __construct(
74 TitleFormatter $titleFormatter,
75 WANObjectCache $cache,
76 NamespaceInfo $nsInfo,
77 ILoadBalancer $loadBalancer = null
78 ) {
79 $this->entries = new MapCacheLRU( self::MAX_SIZE );
80 $this->wanCache = $cache;
81 $this->titleFormatter = $titleFormatter;
82 $this->nsInfo = $nsInfo;
83 $this->loadBalancer = $loadBalancer;
84 $this->logger = new NullLogger();
85 }
86
90 public function setLogger( LoggerInterface $logger ) {
91 $this->logger = $logger;
92 }
93
99 private function getCacheKey( $page, $passThrough = false ) {
100 if ( is_string( $page ) ) {
101 if ( $passThrough ) {
102 return $page;
103 } else {
104 throw new InvalidArgumentException( 'They key may not be given as a string here' );
105 }
106 }
107
108 if ( is_array( $page ) ) {
109 $namespace = $page['page_namespace'];
110 $dbkey = $page['page_title'];
111 return strtr( $this->titleFormatter->formatTitle( $namespace, $dbkey ), ' ', '_' );
112 }
113
114 if ( $page instanceof PageReference && $page->getWikiId() !== PageReference::LOCAL ) {
115 // No cross-wiki support yet. Perhaps LinkCache can become wiki-aware in the future.
116 $this->logger->info(
117 'cross-wiki page reference',
118 [
119 'page-wiki' => $page->getWikiId(),
120 'page-reference' => $this->titleFormatter->getFullText( $page )
121 ]
122 );
123 return null;
124 }
125
126 if ( $page instanceof PageIdentity && !$page->canExist() ) {
127 // Non-proper page, perhaps a special page or interwiki link or relative section link.
128 $this->logger->warning(
129 'non-proper page reference: {page-reference}',
130 [ 'page-reference' => $this->titleFormatter->getFullText( $page ) ]
131 );
132 return null;
133 }
134
135 if ( $page instanceof LinkTarget
136 && ( $page->isExternal() || $page->getText() === '' || $page->getNamespace() < 0 )
137 ) {
138 // Interwiki link or relative section link. These do not have a page ID, so they
139 // can neither be "good" nor "bad" in the sense of this class.
140 $this->logger->warning(
141 'link to non-proper page: {page-link}',
142 [ 'page-link' => $this->titleFormatter->getFullText( $page ) ]
143 );
144 return null;
145 }
146
147 return $this->titleFormatter->getPrefixedDBkey( $page );
148 }
149
159 public function getGoodLinkID( $page ) {
160 $key = $this->getCacheKey( $page, true );
161 if ( $key === null ) {
162 return 0;
163 }
164
165 $entry = $this->entries->get( $key );
166 if ( !$entry ) {
167 return 0;
168 }
169
170 $row = $entry[self::ROW];
171
172 return $row ? (int)$row->page_id : 0;
173 }
174
187 public function getGoodLinkFieldObj( $page, string $field ) {
188 $key = $this->getCacheKey( $page );
189 if ( $key === null ) {
190 return null;
191 }
192
193 $entry = $this->entries->get( $key );
194 if ( !$entry ) {
195 return null;
196 }
197
198 $row = $entry[self::ROW];
199 if ( !$row ) {
200 return null;
201 }
202
203 switch ( $field ) {
204 case 'id':
205 return (int)$row->page_id;
206 case 'length':
207 return (int)$row->page_len;
208 case 'redirect':
209 return (int)$row->page_is_redirect;
210 case 'revision':
211 return (int)$row->page_latest;
212 case 'model':
213 return !empty( $row->page_content_model )
214 ? (string)$row->page_content_model
215 : null;
216 case 'lang':
217 return !empty( $row->page_lang )
218 ? (string)$row->page_lang
219 : null;
220 default:
221 throw new InvalidArgumentException( "Unknown field: $field" );
222 }
223 }
224
234 public function isBadLink( $page ) {
235 $key = $this->getCacheKey( $page, true );
236 if ( $key === null ) {
237 return false;
238 }
239
240 $entry = $this->entries->get( $key );
241
242 return ( $entry && !$entry[self::ROW] );
243 }
244
261 public function addGoodLinkObjFromRow(
262 $page,
263 stdClass $row,
264 int $queryFlags = IDBAccessObject::READ_NORMAL
265 ) {
266 $key = $this->getCacheKey( $page );
267 if ( $key === null ) {
268 return;
269 }
270
271 foreach ( self::getSelectFields() as $field ) {
272 if ( !property_exists( $row, $field ) ) {
273 throw new InvalidArgumentException( "Missing field: $field" );
274 }
275 }
276
277 $this->entries->set( $key, [ self::ROW => $row, self::FLAGS => $queryFlags ] );
278 }
279
294 public function addBadLinkObj( $page, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
295 $key = $this->getCacheKey( $page );
296 if ( $key === null ) {
297 return;
298 }
299
300 $this->entries->set( $key, [ self::ROW => null, self::FLAGS => $queryFlags ] );
301 }
302
311 public function clearBadLink( $page ) {
312 $key = $this->getCacheKey( $page, true );
313 if ( $key === null ) {
314 return;
315 }
316
317 $entry = $this->entries->get( $key );
318 if ( $entry && !$entry[self::ROW] ) {
319 $this->entries->clear( $key );
320 }
321 }
322
331 public function clearLink( $page ) {
332 $key = $this->getCacheKey( $page );
333 if ( $key !== null ) {
334 $this->entries->clear( $key );
335 }
336 }
337
344 public static function getSelectFields() {
345 $pageLanguageUseDB = MediaWikiServices::getInstance()->getMainConfig()
346 ->get( MainConfigNames::PageLanguageUseDB );
347
348 $fields = array_merge(
349 PageStoreRecord::REQUIRED_FIELDS,
350 [
351 'page_len',
352 'page_content_model',
353 ]
354 );
355
356 if ( $pageLanguageUseDB ) {
357 $fields[] = 'page_lang';
358 }
359
360 return $fields;
361 }
362
377 public function addLinkObj( $page, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
378 $row = $this->getGoodLinkRow(
379 $page->getNamespace(),
380 $page->getDBkey(),
381 [ $this, 'fetchPageRow' ],
382 $queryFlags
383 );
384
385 return $row ? (int)$row->page_id : 0;
386 }
387
396 private function getGoodLinkRowInternal(
397 TitleValue $link,
398 callable $fetchCallback = null,
399 int $queryFlags = IDBAccessObject::READ_NORMAL
400 ): array {
401 $callerShouldAddGoodLink = false;
402
403 $key = $this->getCacheKey( $link );
404 if ( $key === null ) {
405 return [ $callerShouldAddGoodLink, null ];
406 }
407
408 $ns = $link->getNamespace();
409 $dbkey = $link->getDBkey();
410
411 $entry = $this->entries->get( $key );
412 if ( $entry && $entry[self::FLAGS] >= $queryFlags ) {
413 return [ $callerShouldAddGoodLink, $entry[self::ROW] ?: null ];
414 }
415
416 if ( !$fetchCallback ) {
417 return [ $callerShouldAddGoodLink, null ];
418 }
419
420 $callerShouldAddGoodLink = true;
421
422 $wanCacheKey = $this->getPersistentCacheKey( $link );
423 if ( $wanCacheKey !== null && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) {
424 // Some pages are often transcluded heavily, so use persistent caching
425 $row = $this->wanCache->getWithSetCallback(
426 $wanCacheKey,
427 WANObjectCache::TTL_DAY,
428 function ( $curValue, &$ttl, array &$setOpts ) use ( $fetchCallback, $ns, $dbkey ) {
429 $dbr = $this->loadBalancer->getConnectionRef( ILoadBalancer::DB_REPLICA );
430 $setOpts += Database::getCacheSetOptions( $dbr );
431
432 $row = $fetchCallback( $dbr, $ns, $dbkey, [] );
433 $mtime = $row ? (int)wfTimestamp( TS_UNIX, $row->page_touched ) : false;
434 $ttl = $this->wanCache->adaptiveTTL( $mtime, $ttl );
435
436 return $row;
437 }
438 );
439 } else {
440 // No persistent caching needed, but we can still use the callback.
441 [ $mode, $options ] = DBAccessObjectUtils::getDBOptions( $queryFlags );
442 $dbr = $this->loadBalancer->getConnectionRef( $mode );
443 $row = $fetchCallback( $dbr, $ns, $dbkey, $options );
444 }
445
446 return [ $callerShouldAddGoodLink, $row ?: null ];
447 }
448
463 public function getGoodLinkRow(
464 int $ns,
465 string $dbkey,
466 callable $fetchCallback = null,
467 int $queryFlags = IDBAccessObject::READ_NORMAL
468 ): ?stdClass {
469 $link = TitleValue::tryNew( $ns, $dbkey );
470 if ( $link === null ) {
471 return null;
472 }
473
474 [ $shouldAddGoodLink, $row ] = $this->getGoodLinkRowInternal(
475 $link,
476 $fetchCallback,
477 $queryFlags
478 );
479
480 if ( $row ) {
481 if ( $shouldAddGoodLink ) {
482 try {
483 $this->addGoodLinkObjFromRow( $link, $row, $queryFlags );
484 } catch ( InvalidArgumentException $e ) {
485 // a field is missing from $row; maybe we used a cache?; invalidate it and try again
486 $this->invalidateTitle( $link );
487 [ , $row ] = $this->getGoodLinkRowInternal(
488 $link,
489 $fetchCallback,
490 $queryFlags
491 );
492 $this->addGoodLinkObjFromRow( $link, $row, $queryFlags );
493 }
494 }
495 } else {
496 $this->addBadLinkObj( $link );
497 }
498
499 return $row ?: null;
500 }
501
506 private function getPersistentCacheKey( $page ) {
507 // if no key can be derived, the page isn't cacheable
508 if ( $this->getCacheKey( $page ) === null || !$this->usePersistentCache( $page ) ) {
509 return null;
510 }
511
512 return $this->wanCache->makeKey(
513 'page',
514 $page->getNamespace(),
515 sha1( $page->getDBkey()
516 ) );
517 }
518
523 private function usePersistentCache( $pageOrNamespace ) {
524 $ns = is_int( $pageOrNamespace ) ? $pageOrNamespace : $pageOrNamespace->getNamespace();
525 if ( in_array( $ns, [ NS_TEMPLATE, NS_FILE, NS_CATEGORY, NS_MEDIAWIKI ] ) ) {
526 return true;
527 }
528 // Focus on transcluded pages more than the main content
529 if ( $this->nsInfo->isContent( $ns ) ) {
530 return false;
531 }
532 // Non-talk extension namespaces (e.g. NS_MODULE)
533 return ( $ns >= 100 && $this->nsInfo->isSubject( $ns ) );
534 }
535
543 private function fetchPageRow( IDatabase $db, int $ns, string $dbkey, $options = [] ) {
544 $queryBuilder = $db->newSelectQueryBuilder()
545 ->select( self::getSelectFields() )
546 ->from( 'page' )
547 ->where( [ 'page_namespace' => $ns, 'page_title' => $dbkey ] )
548 ->options( $options );
549
550 return $queryBuilder->caller( __METHOD__ )->fetchRow();
551 }
552
560 public function invalidateTitle( $page ) {
561 $wanCacheKey = $this->getPersistentCacheKey( $page );
562 if ( $wanCacheKey !== null ) {
563 $this->wanCache->delete( $wanCacheKey );
564 }
565
566 $this->clearLink( $page );
567 }
568
572 public function clear() {
573 $this->entries->clear();
574 }
575}
const NS_FILE
Definition Defines.php:70
const NS_MEDIAWIKI
Definition Defines.php:72
const NS_TEMPLATE
Definition Defines.php:74
const NS_CATEGORY
Definition Defines.php:78
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
static getDBOptions( $bitfield)
Get an appropriate DB index, options, and fallback DB index for a query.
Cache for article titles (prefixed DB keys) and ids linked from one source.
Definition LinkCache.php:45
__construct(TitleFormatter $titleFormatter, WANObjectCache $cache, NamespaceInfo $nsInfo, ILoadBalancer $loadBalancer=null)
Definition LinkCache.php:73
isBadLink( $page)
Check if a page is known to be missing based on the process cache.
clear()
Clears cache.
invalidateTitle( $page)
Purge the persistent link cache for a title.
addLinkObj( $page, int $queryFlags=IDBAccessObject::READ_NORMAL)
Add a title to the link cache, return the page_id or zero if non-existent.
clearLink( $page)
Clear information about a page from the process cache.
addGoodLinkObjFromRow( $page, stdClass $row, int $queryFlags=IDBAccessObject::READ_NORMAL)
Add information about an existing page to the process cache.
clearBadLink( $page)
Clear information about a page being missing from the process cache.
addBadLinkObj( $page, int $queryFlags=IDBAccessObject::READ_NORMAL)
Add information about a missing page to the process cache.
getGoodLinkID( $page)
Get the ID of a page known to the process cache.
setLogger(LoggerInterface $logger)
Definition LinkCache.php:90
getGoodLinkRow(int $ns, string $dbkey, callable $fetchCallback=null, int $queryFlags=IDBAccessObject::READ_NORMAL)
Returns the row for the page if the page exists (subject to race conditions).
static getSelectFields()
Fields that LinkCache needs to select.
getGoodLinkFieldObj( $page, string $field)
Get the field of a page known to the process cache.
Store key-value entries in a size-limited in-memory LRU cache.
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Immutable data record representing an editable page on a wiki.
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Represents the target of a wiki link.
getDBkey()
Get the main part of the link target, in canonical database form.
getNamespace()
Get the namespace index.
Multi-datacenter aware caching interface.
Represents the target of a wiki link.
Interface for objects (potentially) representing an editable wiki page.
canExist()
Checks whether this PageIdentity represents a "proper" page, meaning that it could exist as an editab...
Interface for objects (potentially) representing a page that can be viewable and linked to on a wiki.
getWikiId()
Get the ID of the wiki this page belongs to.
A title formatter service for MediaWiki.
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:36
This class is a delegate to ILBFactory for a given database cluster.
newSelectQueryBuilder()
Create an empty SelectQueryBuilder which can be used to run queries against this connection.