MediaWiki master
LinkCache.php
Go to the documentation of this file.
1<?php
24namespace MediaWiki\Cache;
25
28use InvalidArgumentException;
29use MapCacheLRU;
39use Psr\Log\LoggerAwareInterface;
40use Psr\Log\LoggerInterface;
41use Psr\Log\NullLogger;
42use stdClass;
47
53class LinkCache implements LoggerAwareInterface {
55 private $entries;
57 private $wanCache;
59 private $titleFormatter;
61 private $nsInfo;
63 private $loadBalancer;
65 private $logger;
66
68 private const MAX_SIZE = 10000;
69
71 private const ROW = 0;
73 private const FLAGS = 1;
74
81 public function __construct(
82 TitleFormatter $titleFormatter,
83 WANObjectCache $cache,
84 NamespaceInfo $nsInfo,
85 ILoadBalancer $loadBalancer = null
86 ) {
87 $this->entries = new MapCacheLRU( self::MAX_SIZE );
88 $this->wanCache = $cache;
89 $this->titleFormatter = $titleFormatter;
90 $this->nsInfo = $nsInfo;
91 $this->loadBalancer = $loadBalancer;
92 $this->logger = new NullLogger();
93 }
94
98 public function setLogger( LoggerInterface $logger ) {
99 $this->logger = $logger;
100 }
101
107 private function getCacheKey( $page, $passThrough = false ) {
108 if ( is_string( $page ) ) {
109 if ( $passThrough ) {
110 return $page;
111 } else {
112 throw new InvalidArgumentException( 'They key may not be given as a string here' );
113 }
114 }
115
116 if ( is_array( $page ) ) {
117 $namespace = $page['page_namespace'];
118 $dbkey = $page['page_title'];
119 return strtr( $this->titleFormatter->formatTitle( $namespace, $dbkey ), ' ', '_' );
120 }
121
122 if ( $page instanceof PageReference && $page->getWikiId() !== PageReference::LOCAL ) {
123 // No cross-wiki support yet. Perhaps LinkCache can become wiki-aware in the future.
124 $this->logger->info(
125 'cross-wiki page reference',
126 [
127 'page-wiki' => $page->getWikiId(),
128 'page-reference' => $this->titleFormatter->getFullText( $page )
129 ]
130 );
131 return null;
132 }
133
134 if ( $page instanceof PageIdentity && !$page->canExist() ) {
135 // Non-proper page, perhaps a special page or interwiki link or relative section link.
136 $this->logger->warning(
137 'non-proper page reference: {page-reference}',
138 [ 'page-reference' => $this->titleFormatter->getFullText( $page ) ]
139 );
140 return null;
141 }
142
143 if ( $page instanceof LinkTarget
144 && ( $page->isExternal() || $page->getText() === '' || $page->getNamespace() < 0 )
145 ) {
146 // Interwiki link or relative section link. These do not have a page ID, so they
147 // can neither be "good" nor "bad" in the sense of this class.
148 $this->logger->warning(
149 'link to non-proper page: {page-link}',
150 [ 'page-link' => $this->titleFormatter->getFullText( $page ) ]
151 );
152 return null;
153 }
154
155 return $this->titleFormatter->getPrefixedDBkey( $page );
156 }
157
167 public function getGoodLinkID( $page ) {
168 $key = $this->getCacheKey( $page, true );
169 if ( $key === null ) {
170 return 0;
171 }
172
173 $entry = $this->entries->get( $key );
174 if ( !$entry ) {
175 return 0;
176 }
177
178 $row = $entry[self::ROW];
179
180 return $row ? (int)$row->page_id : 0;
181 }
182
195 public function getGoodLinkFieldObj( $page, string $field ) {
196 $key = $this->getCacheKey( $page );
197 if ( $key === null ) {
198 return null;
199 }
200
201 $entry = $this->entries->get( $key );
202 if ( !$entry ) {
203 return null;
204 }
205
206 $row = $entry[self::ROW];
207 if ( !$row ) {
208 return null;
209 }
210
211 switch ( $field ) {
212 case 'id':
213 return (int)$row->page_id;
214 case 'length':
215 return (int)$row->page_len;
216 case 'redirect':
217 return (int)$row->page_is_redirect;
218 case 'revision':
219 return (int)$row->page_latest;
220 case 'model':
221 return !empty( $row->page_content_model )
222 ? (string)$row->page_content_model
223 : null;
224 case 'lang':
225 return !empty( $row->page_lang )
226 ? (string)$row->page_lang
227 : null;
228 default:
229 throw new InvalidArgumentException( "Unknown field: $field" );
230 }
231 }
232
242 public function isBadLink( $page ) {
243 $key = $this->getCacheKey( $page, true );
244 if ( $key === null ) {
245 return false;
246 }
247
248 $entry = $this->entries->get( $key );
249
250 return ( $entry && !$entry[self::ROW] );
251 }
252
269 public function addGoodLinkObjFromRow(
270 $page,
271 stdClass $row,
272 int $queryFlags = IDBAccessObject::READ_NORMAL
273 ) {
274 $key = $this->getCacheKey( $page );
275 if ( $key === null ) {
276 return;
277 }
278
279 foreach ( self::getSelectFields() as $field ) {
280 if ( !property_exists( $row, $field ) ) {
281 throw new InvalidArgumentException( "Missing field: $field" );
282 }
283 }
284
285 $this->entries->set( $key, [ self::ROW => $row, self::FLAGS => $queryFlags ] );
286 }
287
302 public function addBadLinkObj( $page, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
303 $key = $this->getCacheKey( $page );
304 if ( $key === null ) {
305 return;
306 }
307
308 $this->entries->set( $key, [ self::ROW => null, self::FLAGS => $queryFlags ] );
309 }
310
319 public function clearBadLink( $page ) {
320 $key = $this->getCacheKey( $page, true );
321 if ( $key === null ) {
322 return;
323 }
324
325 $entry = $this->entries->get( $key );
326 if ( $entry && !$entry[self::ROW] ) {
327 $this->entries->clear( $key );
328 }
329 }
330
339 public function clearLink( $page ) {
340 $key = $this->getCacheKey( $page );
341 if ( $key !== null ) {
342 $this->entries->clear( $key );
343 }
344 }
345
352 public static function getSelectFields() {
353 $pageLanguageUseDB = MediaWikiServices::getInstance()->getMainConfig()
355
356 $fields = array_merge(
358 [
359 'page_len',
360 'page_content_model',
361 ]
362 );
363
364 if ( $pageLanguageUseDB ) {
365 $fields[] = 'page_lang';
366 }
367
368 return $fields;
369 }
370
385 public function addLinkObj( $page, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
386 $row = $this->getGoodLinkRow(
387 $page->getNamespace(),
388 $page->getDBkey(),
389 [ $this, 'fetchPageRow' ],
390 $queryFlags
391 );
392
393 return $row ? (int)$row->page_id : 0;
394 }
395
404 private function getGoodLinkRowInternal(
405 TitleValue $link,
406 callable $fetchCallback = null,
407 int $queryFlags = IDBAccessObject::READ_NORMAL
408 ): array {
409 $callerShouldAddGoodLink = false;
410
411 $key = $this->getCacheKey( $link );
412 if ( $key === null ) {
413 return [ $callerShouldAddGoodLink, null ];
414 }
415
416 $ns = $link->getNamespace();
417 $dbkey = $link->getDBkey();
418
419 $entry = $this->entries->get( $key );
420 if ( $entry && $entry[self::FLAGS] >= $queryFlags ) {
421 return [ $callerShouldAddGoodLink, $entry[self::ROW] ?: null ];
422 }
423
424 if ( !$fetchCallback ) {
425 return [ $callerShouldAddGoodLink, null ];
426 }
427
428 $callerShouldAddGoodLink = true;
429
430 $wanCacheKey = $this->getPersistentCacheKey( $link );
431 if ( $wanCacheKey !== null && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) {
432 // Some pages are often transcluded heavily, so use persistent caching
433 $row = $this->wanCache->getWithSetCallback(
434 $wanCacheKey,
435 WANObjectCache::TTL_DAY,
436 function ( $curValue, &$ttl, array &$setOpts ) use ( $fetchCallback, $ns, $dbkey ) {
437 $dbr = $this->loadBalancer->getConnectionRef( ILoadBalancer::DB_REPLICA );
438 $setOpts += Database::getCacheSetOptions( $dbr );
439
440 $row = $fetchCallback( $dbr, $ns, $dbkey, [] );
441 $mtime = $row ? (int)wfTimestamp( TS_UNIX, $row->page_touched ) : false;
442 $ttl = $this->wanCache->adaptiveTTL( $mtime, $ttl );
443
444 return $row;
445 }
446 );
447 } else {
448 // No persistent caching needed, but we can still use the callback.
449 [ $mode, $options ] = DBAccessObjectUtils::getDBOptions( $queryFlags );
450 $dbr = $this->loadBalancer->getConnectionRef( $mode );
451 $row = $fetchCallback( $dbr, $ns, $dbkey, $options );
452 }
453
454 return [ $callerShouldAddGoodLink, $row ?: null ];
455 }
456
471 public function getGoodLinkRow(
472 int $ns,
473 string $dbkey,
474 callable $fetchCallback = null,
475 int $queryFlags = IDBAccessObject::READ_NORMAL
476 ): ?stdClass {
477 $link = TitleValue::tryNew( $ns, $dbkey );
478 if ( $link === null ) {
479 return null;
480 }
481
482 [ $shouldAddGoodLink, $row ] = $this->getGoodLinkRowInternal(
483 $link,
484 $fetchCallback,
485 $queryFlags
486 );
487
488 if ( $row ) {
489 if ( $shouldAddGoodLink ) {
490 try {
491 $this->addGoodLinkObjFromRow( $link, $row, $queryFlags );
492 } catch ( InvalidArgumentException $e ) {
493 // a field is missing from $row; maybe we used a cache?; invalidate it and try again
494 $this->invalidateTitle( $link );
495 [ , $row ] = $this->getGoodLinkRowInternal(
496 $link,
497 $fetchCallback,
498 $queryFlags
499 );
500 $this->addGoodLinkObjFromRow( $link, $row, $queryFlags );
501 }
502 }
503 } else {
504 $this->addBadLinkObj( $link );
505 }
506
507 return $row ?: null;
508 }
509
514 private function getPersistentCacheKey( $page ) {
515 // if no key can be derived, the page isn't cacheable
516 if ( $this->getCacheKey( $page ) === null || !$this->usePersistentCache( $page ) ) {
517 return null;
518 }
519
520 return $this->wanCache->makeKey(
521 'page',
522 $page->getNamespace(),
523 sha1( $page->getDBkey()
524 ) );
525 }
526
531 private function usePersistentCache( $pageOrNamespace ) {
532 $ns = is_int( $pageOrNamespace ) ? $pageOrNamespace : $pageOrNamespace->getNamespace();
533 if ( in_array( $ns, [ NS_TEMPLATE, NS_FILE, NS_CATEGORY, NS_MEDIAWIKI ] ) ) {
534 return true;
535 }
536 // Focus on transcluded pages more than the main content
537 if ( $this->nsInfo->isContent( $ns ) ) {
538 return false;
539 }
540 // Non-talk extension namespaces (e.g. NS_MODULE)
541 return ( $ns >= 100 && $this->nsInfo->isSubject( $ns ) );
542 }
543
551 private function fetchPageRow( IDatabase $db, int $ns, string $dbkey, $options = [] ) {
552 $queryBuilder = $db->newSelectQueryBuilder()
553 ->select( self::getSelectFields() )
554 ->from( 'page' )
555 ->where( [ 'page_namespace' => $ns, 'page_title' => $dbkey ] )
556 ->options( $options );
557
558 return $queryBuilder->caller( __METHOD__ )->fetchRow();
559 }
560
568 public function invalidateTitle( $page ) {
569 $wanCacheKey = $this->getPersistentCacheKey( $page );
570 if ( $wanCacheKey !== null ) {
571 $this->wanCache->delete( $wanCacheKey );
572 }
573
574 $this->clearLink( $page );
575 }
576
580 public function clear() {
581 $this->entries->clear();
582 }
583}
584
586class_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.
Helper class for DAO classes.
static getDBOptions( $bitfield)
Get an appropriate DB index and options.
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:53
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:81
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:98
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.