MediaWiki master
LinkCache.php
Go to the documentation of this file.
1<?php
24namespace MediaWiki\Cache;
25
26use InvalidArgumentException;
35use Psr\Log\LoggerAwareInterface;
36use Psr\Log\LoggerInterface;
37use Psr\Log\NullLogger;
38use stdClass;
41use Wikimedia\Parsoid\Core\LinkTarget;
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
94 public function setLogger( LoggerInterface $logger ) {
95 $this->logger = $logger;
96 }
97
103 private function getCacheKey( $page, $passThrough = false ) {
104 if ( is_string( $page ) ) {
105 if ( $passThrough ) {
106 return $page;
107 } else {
108 throw new InvalidArgumentException( 'They key may not be given as a string here' );
109 }
110 }
111
112 if ( is_array( $page ) ) {
113 $namespace = $page['page_namespace'];
114 $dbkey = $page['page_title'];
115 return strtr( $this->titleFormatter->formatTitle( $namespace, $dbkey ), ' ', '_' );
116 }
117
118 if ( $page instanceof PageReference && $page->getWikiId() !== PageReference::LOCAL ) {
119 // No cross-wiki support yet. Perhaps LinkCache can become wiki-aware in the future.
120 $this->logger->info(
121 'cross-wiki page reference',
122 [
123 'page-wiki' => $page->getWikiId(),
124 'page-reference' => $this->titleFormatter->getFullText( $page )
125 ]
126 );
127 return null;
128 }
129
130 if ( $page instanceof PageIdentity && !$page->canExist() ) {
131 // Non-proper page, perhaps a special page or interwiki link or relative section link.
132 $this->logger->warning(
133 'non-proper page reference: {page-reference}',
134 [ 'page-reference' => $this->titleFormatter->getFullText( $page ) ]
135 );
136 return null;
137 }
138
139 if ( $page instanceof LinkTarget
140 && ( $page->isExternal() || $page->getText() === '' || $page->getNamespace() < 0 )
141 ) {
142 // Interwiki link or relative section link. These do not have a page ID, so they
143 // can neither be "good" nor "bad" in the sense of this class.
144 $this->logger->warning(
145 'link to non-proper page: {page-link}',
146 [ 'page-link' => $this->titleFormatter->getFullText( $page ) ]
147 );
148 return null;
149 }
150
151 return $this->titleFormatter->getPrefixedDBkey( $page );
152 }
153
163 public function getGoodLinkID( $page ) {
164 $key = $this->getCacheKey( $page, true );
165 if ( $key === null ) {
166 return 0;
167 }
168
169 $entry = $this->entries->get( $key );
170 if ( !$entry ) {
171 return 0;
172 }
173
174 $row = $entry[self::ROW];
175
176 return $row ? (int)$row->page_id : 0;
177 }
178
191 public function getGoodLinkFieldObj( $page, string $field ) {
192 $key = $this->getCacheKey( $page );
193 if ( $key === null ) {
194 return null;
195 }
196
197 $entry = $this->entries->get( $key );
198 if ( !$entry ) {
199 return null;
200 }
201
202 $row = $entry[self::ROW];
203 if ( !$row ) {
204 return null;
205 }
206
207 switch ( $field ) {
208 case 'id':
209 return (int)$row->page_id;
210 case 'length':
211 return (int)$row->page_len;
212 case 'redirect':
213 return (int)$row->page_is_redirect;
214 case 'revision':
215 return (int)$row->page_latest;
216 case 'model':
217 return !empty( $row->page_content_model )
218 ? (string)$row->page_content_model
219 : null;
220 case 'lang':
221 return !empty( $row->page_lang )
222 ? (string)$row->page_lang
223 : null;
224 default:
225 throw new InvalidArgumentException( "Unknown field: $field" );
226 }
227 }
228
238 public function isBadLink( $page ) {
239 $key = $this->getCacheKey( $page, true );
240 if ( $key === null ) {
241 return false;
242 }
243
244 $entry = $this->entries->get( $key );
245
246 return ( $entry && !$entry[self::ROW] );
247 }
248
265 public function addGoodLinkObjFromRow(
266 $page,
267 stdClass $row,
268 int $queryFlags = IDBAccessObject::READ_NORMAL
269 ) {
270 $key = $this->getCacheKey( $page );
271 if ( $key === null ) {
272 return;
273 }
274
275 foreach ( self::getSelectFields() as $field ) {
276 if ( !property_exists( $row, $field ) ) {
277 throw new InvalidArgumentException( "Missing field: $field" );
278 }
279 }
280
281 $this->entries->set( $key, [ self::ROW => $row, self::FLAGS => $queryFlags ] );
282 }
283
298 public function addBadLinkObj( $page, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
299 $key = $this->getCacheKey( $page );
300 if ( $key === null ) {
301 return;
302 }
303
304 $this->entries->set( $key, [ self::ROW => null, self::FLAGS => $queryFlags ] );
305 }
306
315 public function clearBadLink( $page ) {
316 $key = $this->getCacheKey( $page, true );
317 if ( $key === null ) {
318 return;
319 }
320
321 $entry = $this->entries->get( $key );
322 if ( $entry && !$entry[self::ROW] ) {
323 $this->entries->clear( $key );
324 }
325 }
326
335 public function clearLink( $page ) {
336 $key = $this->getCacheKey( $page );
337 if ( $key !== null ) {
338 $this->entries->clear( $key );
339 }
340 }
341
348 public static function getSelectFields() {
349 $pageLanguageUseDB = MediaWikiServices::getInstance()->getMainConfig()
351
352 $fields = array_merge(
354 [
355 'page_len',
356 'page_content_model',
357 ]
358 );
359
360 if ( $pageLanguageUseDB ) {
361 $fields[] = 'page_lang';
362 }
363
364 return $fields;
365 }
366
381 public function addLinkObj( $page, int $queryFlags = IDBAccessObject::READ_NORMAL ) {
382 $row = $this->getGoodLinkRow(
383 $page->getNamespace(),
384 $page->getDBkey(),
385 [ $this, 'fetchPageRow' ],
386 $queryFlags
387 );
388
389 return $row ? (int)$row->page_id : 0;
390 }
391
400 private function getGoodLinkRowInternal(
401 TitleValue $link,
402 ?callable $fetchCallback = null,
403 int $queryFlags = IDBAccessObject::READ_NORMAL
404 ): array {
405 $callerShouldAddGoodLink = false;
406
407 $key = $this->getCacheKey( $link );
408 if ( $key === null ) {
409 return [ $callerShouldAddGoodLink, null ];
410 }
411
412 $ns = $link->getNamespace();
413 $dbkey = $link->getDBkey();
414
415 $entry = $this->entries->get( $key );
416 if ( $entry && $entry[self::FLAGS] >= $queryFlags ) {
417 return [ $callerShouldAddGoodLink, $entry[self::ROW] ?: null ];
418 }
419
420 if ( !$fetchCallback ) {
421 return [ $callerShouldAddGoodLink, null ];
422 }
423
424 $callerShouldAddGoodLink = true;
425
426 $wanCacheKey = $this->getPersistentCacheKey( $link );
427 if ( $wanCacheKey !== null && !( $queryFlags & IDBAccessObject::READ_LATEST ) ) {
428 // Some pages are often transcluded heavily, so use persistent caching
429 $row = $this->wanCache->getWithSetCallback(
430 $wanCacheKey,
431 WANObjectCache::TTL_DAY,
432 function ( $curValue, &$ttl, array &$setOpts ) use ( $fetchCallback, $ns, $dbkey ) {
433 $dbr = $this->loadBalancer->getConnection( ILoadBalancer::DB_REPLICA );
434 $setOpts += Database::getCacheSetOptions( $dbr );
435
436 $row = $fetchCallback( $dbr, $ns, $dbkey, [] );
437 $mtime = $row ? (int)wfTimestamp( TS_UNIX, $row->page_touched ) : false;
438 $ttl = $this->wanCache->adaptiveTTL( $mtime, $ttl );
439
440 return $row;
441 }
442 );
443 } else {
444 // No persistent caching needed, but we can still use the callback.
445 if ( ( $queryFlags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
446 $dbr = $this->loadBalancer->getConnection( DB_PRIMARY );
447 } else {
448 $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
449 }
450 $options = [];
451 if ( ( $queryFlags & IDBAccessObject::READ_EXCLUSIVE ) == IDBAccessObject::READ_EXCLUSIVE ) {
452 $options[] = 'FOR UPDATE';
453 } elseif ( ( $queryFlags & IDBAccessObject::READ_LOCKING ) == IDBAccessObject::READ_LOCKING ) {
454 $options[] = 'LOCK IN SHARE MODE';
455 }
456 $row = $fetchCallback( $dbr, $ns, $dbkey, $options );
457 }
458
459 return [ $callerShouldAddGoodLink, $row ?: null ];
460 }
461
476 public function getGoodLinkRow(
477 int $ns,
478 string $dbkey,
479 ?callable $fetchCallback = null,
480 int $queryFlags = IDBAccessObject::READ_NORMAL
481 ): ?stdClass {
482 $link = TitleValue::tryNew( $ns, $dbkey );
483 if ( $link === null ) {
484 return null;
485 }
486
487 [ $shouldAddGoodLink, $row ] = $this->getGoodLinkRowInternal(
488 $link,
489 $fetchCallback,
490 $queryFlags
491 );
492
493 if ( $row ) {
494 if ( $shouldAddGoodLink ) {
495 try {
496 $this->addGoodLinkObjFromRow( $link, $row, $queryFlags );
497 } catch ( InvalidArgumentException $e ) {
498 // a field is missing from $row; maybe we used a cache?; invalidate it and try again
499 $this->invalidateTitle( $link );
500 [ , $row ] = $this->getGoodLinkRowInternal(
501 $link,
502 $fetchCallback,
503 $queryFlags
504 );
505 $this->addGoodLinkObjFromRow( $link, $row, $queryFlags );
506 }
507 }
508 } else {
509 $this->addBadLinkObj( $link );
510 }
511
512 return $row ?: null;
513 }
514
519 private function getPersistentCacheKey( $page ) {
520 // if no key can be derived, the page isn't cacheable
521 if ( $this->getCacheKey( $page ) === null || !$this->usePersistentCache( $page ) ) {
522 return null;
523 }
524
525 return $this->wanCache->makeKey(
526 'page',
527 $page->getNamespace(),
528 sha1( $page->getDBkey() )
529 );
530 }
531
536 private function usePersistentCache( $pageOrNamespace ) {
537 $ns = is_int( $pageOrNamespace ) ? $pageOrNamespace : $pageOrNamespace->getNamespace();
538 if ( in_array( $ns, [ NS_TEMPLATE, NS_FILE, NS_CATEGORY, NS_MEDIAWIKI ] ) ) {
539 return true;
540 }
541 // Focus on transcluded pages more than the main content
542 if ( $this->nsInfo->isContent( $ns ) ) {
543 return false;
544 }
545 // Non-talk extension namespaces (e.g. NS_MODULE)
546 return ( $ns >= 100 && $this->nsInfo->isSubject( $ns ) );
547 }
548
556 private function fetchPageRow( IReadableDatabase $db, int $ns, string $dbkey, $options = [] ) {
557 $queryBuilder = $db->newSelectQueryBuilder()
558 ->select( self::getSelectFields() )
559 ->from( 'page' )
560 ->where( [ 'page_namespace' => $ns, 'page_title' => $dbkey ] )
561 ->options( $options );
562
563 return $queryBuilder->caller( __METHOD__ )->fetchRow();
564 }
565
573 public function invalidateTitle( $page ) {
574 $wanCacheKey = $this->getPersistentCacheKey( $page );
575 if ( $wanCacheKey !== null ) {
576 $this->wanCache->delete( $wanCacheKey );
577 }
578
579 $this->clearLink( $page );
580 }
581
585 public function clear() {
586 $this->entries->clear();
587 }
588}
589
591class_alias( LinkCache::class, 'LinkCache' );
const NS_FILE
Definition Defines.php:71
const NS_MEDIAWIKI
Definition Defines.php:73
const NS_TEMPLATE
Definition Defines.php:75
const NS_CATEGORY
Definition Defines.php:79
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
Cache for article titles (prefixed DB keys) and ids linked from one source.
Definition LinkCache.php:52
addLinkObj( $page, int $queryFlags=IDBAccessObject::READ_NORMAL)
Add a title to the link cache, return the page_id or zero if non-existent.
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:94
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.
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).
__construct(TitleFormatter $titleFormatter, WANObjectCache $cache, NamespaceInfo $nsInfo, ?ILoadBalancer $loadBalancer=null)
Definition LinkCache.php:80
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...
A title formatter service for MediaWiki.
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.
Store key-value entries in a size-limited in-memory LRU cache.
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 objects (potentially) representing an editable wiki page.
Interface for objects (potentially) representing a page that can be viewable and linked to on a wiki.
Interface for database access objects.
This class is a delegate to ILBFactory for a given database cluster.
A database connection without write operations.
getCacheKey()
Get the cache key used to store status.
const DB_REPLICA
Definition defines.php:26
const DB_PRIMARY
Definition defines.php:28