MediaWiki master
LinkCache.php
Go to the documentation of this file.
1<?php
24namespace MediaWiki\Cache;
25
27use InvalidArgumentException;
28use MapCacheLRU;
38use Psr\Log\LoggerAwareInterface;
39use Psr\Log\LoggerInterface;
40use Psr\Log\NullLogger;
41use stdClass;
46
52class LinkCache implements LoggerAwareInterface {
54 private $entries;
56 private $wanCache;
58 private $titleFormatter;
60 private $nsInfo;
62 private $loadBalancer;
64 private $logger;
65
67 private const MAX_SIZE = 10000;
68
70 private const ROW = 0;
72 private const FLAGS = 1;
73
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
97 public function setLogger( LoggerInterface $logger ) {
98 $this->logger = $logger;
99 }
100
106 private function getCacheKey( $page, $passThrough = false ) {
107 if ( is_string( $page ) ) {
108 if ( $passThrough ) {
109 return $page;
110 } else {
111 throw new InvalidArgumentException( 'They key may not be given as a string here' );
112 }
113 }
114
115 if ( is_array( $page ) ) {
116 $namespace = $page['page_namespace'];
117 $dbkey = $page['page_title'];
118 return strtr( $this->titleFormatter->formatTitle( $namespace, $dbkey ), ' ', '_' );
119 }
120
121 if ( $page instanceof PageReference && $page->getWikiId() !== PageReference::LOCAL ) {
122 // No cross-wiki support yet. Perhaps LinkCache can become wiki-aware in the future.
123 $this->logger->info(
124 'cross-wiki page reference',
125 [
126 'page-wiki' => $page->getWikiId(),
127 'page-reference' => $this->titleFormatter->getFullText( $page )
128 ]
129 );
130 return null;
131 }
132
133 if ( $page instanceof PageIdentity && !$page->canExist() ) {
134 // Non-proper page, perhaps a special page or interwiki link or relative section link.
135 $this->logger->warning(
136 'non-proper page reference: {page-reference}',
137 [ 'page-reference' => $this->titleFormatter->getFullText( $page ) ]
138 );
139 return null;
140 }
141
142 if ( $page instanceof LinkTarget
143 && ( $page->isExternal() || $page->getText() === '' || $page->getNamespace() < 0 )
144 ) {
145 // Interwiki link or relative section link. These do not have a page ID, so they
146 // can neither be "good" nor "bad" in the sense of this class.
147 $this->logger->warning(
148 'link to non-proper page: {page-link}',
149 [ 'page-link' => $this->titleFormatter->getFullText( $page ) ]
150 );
151 return null;
152 }
153
154 return $this->titleFormatter->getPrefixedDBkey( $page );
155 }
156
166 public function getGoodLinkID( $page ) {
167 $key = $this->getCacheKey( $page, true );
168 if ( $key === null ) {
169 return 0;
170 }
171
172 $entry = $this->entries->get( $key );
173 if ( !$entry ) {
174 return 0;
175 }
176
177 $row = $entry[self::ROW];
178
179 return $row ? (int)$row->page_id : 0;
180 }
181
194 public function getGoodLinkFieldObj( $page, string $field ) {
195 $key = $this->getCacheKey( $page );
196 if ( $key === null ) {
197 return null;
198 }
199
200 $entry = $this->entries->get( $key );
201 if ( !$entry ) {
202 return null;
203 }
204
205 $row = $entry[self::ROW];
206 if ( !$row ) {
207 return null;
208 }
209
210 switch ( $field ) {
211 case 'id':
212 return (int)$row->page_id;
213 case 'length':
214 return (int)$row->page_len;
215 case 'redirect':
216 return (int)$row->page_is_redirect;
217 case 'revision':
218 return (int)$row->page_latest;
219 case 'model':
220 return !empty( $row->page_content_model )
221 ? (string)$row->page_content_model
222 : null;
223 case 'lang':
224 return !empty( $row->page_lang )
225 ? (string)$row->page_lang
226 : null;
227 default:
228 throw new InvalidArgumentException( "Unknown field: $field" );
229 }
230 }
231
241 public function isBadLink( $page ) {
242 $key = $this->getCacheKey( $page, true );
243 if ( $key === null ) {
244 return false;
245 }
246
247 $entry = $this->entries->get( $key );
248
249 return ( $entry && !$entry[self::ROW] );
250 }
251
268 public function addGoodLinkObjFromRow(
269 $page,
270 stdClass $row,
271 int $queryFlags = IDBAccessObject::READ_NORMAL
272 ) {
273 $key = $this->getCacheKey( $page );
274 if ( $key === null ) {
275 return;
276 }
277
278 foreach ( self::getSelectFields() as $field ) {
279 if ( !property_exists( $row, $field ) ) {
280 throw new InvalidArgumentException( "Missing field: $field" );
281 }
282 }
283
284 $this->entries->set( $key, [ self::ROW => $row, self::FLAGS => $queryFlags ] );
285 }
286
301 public function addBadLinkObj( $page, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
302 $key = $this->getCacheKey( $page );
303 if ( $key === null ) {
304 return;
305 }
306
307 $this->entries->set( $key, [ self::ROW => null, self::FLAGS => $queryFlags ] );
308 }
309
318 public function clearBadLink( $page ) {
319 $key = $this->getCacheKey( $page, true );
320 if ( $key === null ) {
321 return;
322 }
323
324 $entry = $this->entries->get( $key );
325 if ( $entry && !$entry[self::ROW] ) {
326 $this->entries->clear( $key );
327 }
328 }
329
338 public function clearLink( $page ) {
339 $key = $this->getCacheKey( $page );
340 if ( $key !== null ) {
341 $this->entries->clear( $key );
342 }
343 }
344
351 public static function getSelectFields() {
352 $pageLanguageUseDB = MediaWikiServices::getInstance()->getMainConfig()
354
355 $fields = array_merge(
357 [
358 'page_len',
359 'page_content_model',
360 ]
361 );
362
363 if ( $pageLanguageUseDB ) {
364 $fields[] = 'page_lang';
365 }
366
367 return $fields;
368 }
369
384 public function addLinkObj( $page, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
385 $row = $this->getGoodLinkRow(
386 $page->getNamespace(),
387 $page->getDBkey(),
388 [ $this, 'fetchPageRow' ],
389 $queryFlags
390 );
391
392 return $row ? (int)$row->page_id : 0;
393 }
394
403 private function getGoodLinkRowInternal(
404 TitleValue $link,
405 callable $fetchCallback = null,
406 int $queryFlags = IDBAccessObject::READ_NORMAL
407 ): array {
408 $callerShouldAddGoodLink = false;
409
410 $key = $this->getCacheKey( $link );
411 if ( $key === null ) {
412 return [ $callerShouldAddGoodLink, null ];
413 }
414
415 $ns = $link->getNamespace();
416 $dbkey = $link->getDBkey();
417
418 $entry = $this->entries->get( $key );
419 if ( $entry && $entry[self::FLAGS] >= $queryFlags ) {
420 return [ $callerShouldAddGoodLink, $entry[self::ROW] ?: null ];
421 }
422
423 if ( !$fetchCallback ) {
424 return [ $callerShouldAddGoodLink, null ];
425 }
426
427 $callerShouldAddGoodLink = true;
428
429 $wanCacheKey = $this->getPersistentCacheKey( $link );
430 if ( $wanCacheKey !== null && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) {
431 // Some pages are often transcluded heavily, so use persistent caching
432 $row = $this->wanCache->getWithSetCallback(
433 $wanCacheKey,
434 WANObjectCache::TTL_DAY,
435 function ( $curValue, &$ttl, array &$setOpts ) use ( $fetchCallback, $ns, $dbkey ) {
436 $dbr = $this->loadBalancer->getConnection( ILoadBalancer::DB_REPLICA );
437 $setOpts += Database::getCacheSetOptions( $dbr );
438
439 $row = $fetchCallback( $dbr, $ns, $dbkey, [] );
440 $mtime = $row ? (int)wfTimestamp( TS_UNIX, $row->page_touched ) : false;
441 $ttl = $this->wanCache->adaptiveTTL( $mtime, $ttl );
442
443 return $row;
444 }
445 );
446 } else {
447 // No persistent caching needed, but we can still use the callback.
448 if ( ( $queryFlags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
449 $dbr = $this->loadBalancer->getConnection( DB_PRIMARY );
450 } else {
451 $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
452 }
453 $options = [];
454 if ( ( $queryFlags & IDBAccessObject::READ_EXCLUSIVE ) == IDBAccessObject::READ_EXCLUSIVE ) {
455 $options[] = 'FOR UPDATE';
456 } elseif ( ( $queryFlags & IDBAccessObject::READ_LOCKING ) == IDBAccessObject::READ_LOCKING ) {
457 $options[] = 'LOCK IN SHARE MODE';
458 }
459 $row = $fetchCallback( $dbr, $ns, $dbkey, $options );
460 }
461
462 return [ $callerShouldAddGoodLink, $row ?: null ];
463 }
464
479 public function getGoodLinkRow(
480 int $ns,
481 string $dbkey,
482 callable $fetchCallback = null,
483 int $queryFlags = IDBAccessObject::READ_NORMAL
484 ): ?stdClass {
485 $link = TitleValue::tryNew( $ns, $dbkey );
486 if ( $link === null ) {
487 return null;
488 }
489
490 [ $shouldAddGoodLink, $row ] = $this->getGoodLinkRowInternal(
491 $link,
492 $fetchCallback,
493 $queryFlags
494 );
495
496 if ( $row ) {
497 if ( $shouldAddGoodLink ) {
498 try {
499 $this->addGoodLinkObjFromRow( $link, $row, $queryFlags );
500 } catch ( InvalidArgumentException $e ) {
501 // a field is missing from $row; maybe we used a cache?; invalidate it and try again
502 $this->invalidateTitle( $link );
503 [ , $row ] = $this->getGoodLinkRowInternal(
504 $link,
505 $fetchCallback,
506 $queryFlags
507 );
508 $this->addGoodLinkObjFromRow( $link, $row, $queryFlags );
509 }
510 }
511 } else {
512 $this->addBadLinkObj( $link );
513 }
514
515 return $row ?: null;
516 }
517
522 private function getPersistentCacheKey( $page ) {
523 // if no key can be derived, the page isn't cacheable
524 if ( $this->getCacheKey( $page ) === null || !$this->usePersistentCache( $page ) ) {
525 return null;
526 }
527
528 return $this->wanCache->makeKey(
529 'page',
530 $page->getNamespace(),
531 sha1( $page->getDBkey()
532 ) );
533 }
534
539 private function usePersistentCache( $pageOrNamespace ) {
540 $ns = is_int( $pageOrNamespace ) ? $pageOrNamespace : $pageOrNamespace->getNamespace();
541 if ( in_array( $ns, [ NS_TEMPLATE, NS_FILE, NS_CATEGORY, NS_MEDIAWIKI ] ) ) {
542 return true;
543 }
544 // Focus on transcluded pages more than the main content
545 if ( $this->nsInfo->isContent( $ns ) ) {
546 return false;
547 }
548 // Non-talk extension namespaces (e.g. NS_MODULE)
549 return ( $ns >= 100 && $this->nsInfo->isSubject( $ns ) );
550 }
551
559 private function fetchPageRow( IDatabase $db, int $ns, string $dbkey, $options = [] ) {
560 $queryBuilder = $db->newSelectQueryBuilder()
561 ->select( self::getSelectFields() )
562 ->from( 'page' )
563 ->where( [ 'page_namespace' => $ns, 'page_title' => $dbkey ] )
564 ->options( $options );
565
566 return $queryBuilder->caller( __METHOD__ )->fetchRow();
567 }
568
576 public function invalidateTitle( $page ) {
577 $wanCacheKey = $this->getPersistentCacheKey( $page );
578 if ( $wanCacheKey !== null ) {
579 $this->wanCache->delete( $wanCacheKey );
580 }
581
582 $this->clearLink( $page );
583 }
584
588 public function clear() {
589 $this->entries->clear();
590 }
591}
592
594class_alias( LinkCache::class, 'LinkCache' );
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.
getCacheKey()
Get the cache key used to store status.
Store key-value entries in a size-limited in-memory LRU cache.
Cache for article titles (prefixed DB keys) and ids linked from one source.
Definition LinkCache.php:52
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).
addLinkObj( $page, int $queryFlags=IDBAccessObject::READ_NORMAL)
Add a title to the link cache, return the page_id or zero if non-existent.
__construct(TitleFormatter $titleFormatter, WANObjectCache $cache, NamespaceInfo $nsInfo, ILoadBalancer $loadBalancer=null)
Definition LinkCache.php:80
isBadLink( $page)
Check if a page is known to be missing based on the process cache.
clearBadLink( $page)
Clear information about a page being missing from the process cache.
getGoodLinkFieldObj( $page, string $field)
Get the field of a page known to the process cache.
setLogger(LoggerInterface $logger)
Definition LinkCache.php:97
invalidateTitle( $page)
Purge the persistent link cache for a title.
addBadLinkObj( $page, int $queryFlags=IDBAccessObject::READ_NORMAL)
Add information about a missing page to the process cache.
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.
getGoodLinkID( $page)
Get the ID of a page known to the process cache.
static getSelectFields()
Fields that LinkCache needs to select.
A class containing constants representing the names of configuration variables.
const PageLanguageUseDB
Name constant for the PageLanguageUseDB setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Immutable data record representing an editable page on a wiki.
const REQUIRED_FIELDS
Fields that must be present in the row object passed to the constructor.
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.
static getCacheSetOptions(?IReadableDatabase ... $dbs)
Merge the result of getSessionLagStatus() for several DBs using the most pessimistic values to estima...
Interface for database access objects.
Represents the target of a wiki link.
Interface for objects (potentially) representing an editable wiki page.
Interface for objects (potentially) representing a page that can be viewable and linked to on a wiki.
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.
const DB_REPLICA
Definition defines.php:26
const DB_PRIMARY
Definition defines.php:28