3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
20 namespace MediaWiki\Page
;
22 use InvalidArgumentException
;
24 use MediaWiki\Logger\Spi
as LoggerSpi
;
25 use MediaWiki\MainConfigNames
;
26 use MediaWiki\MediaWikiServices
;
27 use MediaWiki\Parser\ParserCache
;
28 use MediaWiki\Parser\ParserCacheFactory
;
29 use MediaWiki\Parser\ParserOptions
;
30 use MediaWiki\Parser\ParserOutput
;
31 use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter
;
32 use MediaWiki\Parser\RevisionOutputCache
;
33 use MediaWiki\PoolCounter\PoolCounterWork
;
34 use MediaWiki\PoolCounter\PoolWorkArticleView
;
35 use MediaWiki\PoolCounter\PoolWorkArticleViewCurrent
;
36 use MediaWiki\PoolCounter\PoolWorkArticleViewOld
;
37 use MediaWiki\Revision\RevisionLookup
;
38 use MediaWiki\Revision\RevisionRecord
;
39 use MediaWiki\Revision\RevisionRenderer
;
40 use MediaWiki\Revision\SlotRecord
;
41 use MediaWiki\Status\Status
;
42 use MediaWiki\Title\TitleFormatter
;
43 use MediaWiki\WikiMap\WikiMap
;
44 use Wikimedia\Assert\Assert
;
45 use Wikimedia\Parsoid\Parsoid
;
46 use Wikimedia\Rdbms\ChronologyProtector
;
47 use Wikimedia\Rdbms\ILBFactory
;
48 use Wikimedia\Stats\StatsFactory
;
49 use Wikimedia\Telemetry\SpanInterface
;
50 use Wikimedia\Telemetry\TracerInterface
;
53 * Service for getting rendered output of a given page.
55 * This is a high level service, encapsulating concerns like caching
56 * and stampede protection via PoolCounter.
61 class ParserOutputAccess
{
64 public const PARSOID_PCACHE_NAME
= 'parsoid-' . ParserCacheFactory
::DEFAULT_NAME
;
67 public const PARSOID_RCACHE_NAME
= 'parsoid-' . ParserCacheFactory
::DEFAULT_RCACHE_NAME
;
70 * @var int Do not check the cache before parsing (force parse)
72 public const OPT_NO_CHECK_CACHE
= 1;
74 /** @var int Alias for NO_CHECK_CACHE */
75 public const OPT_FORCE_PARSE
= self
::OPT_NO_CHECK_CACHE
;
78 * @var int Do not update the cache after parsing.
80 public const OPT_NO_UPDATE_CACHE
= 2;
83 * @var int Bypass audience check for deleted/suppressed revisions.
84 * The caller is responsible for ensuring that unauthorized access is prevented.
85 * If not set, output generation will fail if the revision is not public.
87 public const OPT_NO_AUDIENCE_CHECK
= 4;
90 * @var int Do not check the cache before parsing,
91 * and do not update the cache after parsing (not cacheable).
93 public const OPT_NO_CACHE
= self
::OPT_NO_UPDATE_CACHE | self
::OPT_NO_CHECK_CACHE
;
96 * @var int Do perform an opportunistic LinksUpdate on cache miss
99 public const OPT_LINKS_UPDATE
= 8;
102 * Apply page view semantics. This relaxes some guarantees, specifically:
103 * - Use PoolCounter for stampede protection, causing the request to
104 * block until another process has finished rendering the content.
105 * - Allow stale parser output to be returned to prevent long waits for
107 * - Allow cacheable placeholder output to be returned when PoolCounter
108 * fails to obtain a lock. See the PoolCounterConf setting for details.
113 public const OPT_FOR_ARTICLE_VIEW
= 16;
116 * @var int Ignore the profile version of the result from the cache.
117 * Otherwise, if it's not Parsoid's default, it will be invalidated.
119 public const OPT_IGNORE_PROFILE_VERSION
= 128;
121 /** @var string Do not read or write any cache */
122 private const CACHE_NONE
= 'none';
124 /** @var string Use primary cache */
125 private const CACHE_PRIMARY
= 'primary';
127 /** @var string Use secondary cache */
128 private const CACHE_SECONDARY
= 'secondary';
131 * In cases that an extension tries to get the same ParserOutput of
132 * the page right after it was parsed (T301310).
133 * @var MapCacheLRU<string,ParserOutput>
135 private MapCacheLRU
$localCache;
137 private ParserCacheFactory
$parserCacheFactory;
138 private RevisionLookup
$revisionLookup;
139 private RevisionRenderer
$revisionRenderer;
140 private StatsFactory
$statsFactory;
141 private ILBFactory
$lbFactory;
142 private ChronologyProtector
$chronologyProtector;
143 private LoggerSpi
$loggerSpi;
144 private WikiPageFactory
$wikiPageFactory;
145 private TitleFormatter
$titleFormatter;
146 private TracerInterface
$tracer;
148 public function __construct(
149 ParserCacheFactory
$parserCacheFactory,
150 RevisionLookup
$revisionLookup,
151 RevisionRenderer
$revisionRenderer,
152 StatsFactory
$statsFactory,
153 ILBFactory
$lbFactory,
154 ChronologyProtector
$chronologyProtector,
155 LoggerSpi
$loggerSpi,
156 WikiPageFactory
$wikiPageFactory,
157 TitleFormatter
$titleFormatter,
158 TracerInterface
$tracer
160 $this->parserCacheFactory
= $parserCacheFactory;
161 $this->revisionLookup
= $revisionLookup;
162 $this->revisionRenderer
= $revisionRenderer;
163 $this->statsFactory
= $statsFactory;
164 $this->lbFactory
= $lbFactory;
165 $this->chronologyProtector
= $chronologyProtector;
166 $this->loggerSpi
= $loggerSpi;
167 $this->wikiPageFactory
= $wikiPageFactory;
168 $this->titleFormatter
= $titleFormatter;
169 $this->tracer
= $tracer;
171 $this->localCache
= new MapCacheLRU( 10 );
177 * @param PageRecord $page
178 * @param RevisionRecord|null $rev
180 * @return string One of the CACHE_XXX constants.
182 private function shouldUseCache(
186 if ( $rev && !$rev->getId() ) {
187 // The revision isn't from the database, so the output can't safely be cached.
188 return self
::CACHE_NONE
;
191 // NOTE: Keep in sync with ParserWikiPage::shouldCheckParserCache().
192 // NOTE: when we allow caching of old revisions in the future,
193 // we must not allow caching of deleted revisions.
195 $wikiPage = $this->wikiPageFactory
->newFromTitle( $page );
196 if ( !$page->exists() ||
!$wikiPage->getContentHandler()->isParserCacheSupported() ) {
197 return self
::CACHE_NONE
;
200 $isOld = $rev && $rev->getId() !== $page->getLatest();
202 return self
::CACHE_PRIMARY
;
205 if ( !$rev->audienceCan( RevisionRecord
::DELETED_TEXT
, RevisionRecord
::FOR_PUBLIC
) ) {
206 // deleted/suppressed revision
207 return self
::CACHE_NONE
;
210 return self
::CACHE_SECONDARY
;
214 * Returns the rendered output for the given page if it is present in the cache.
216 * @param PageRecord $page
217 * @param ParserOptions $parserOptions
218 * @param RevisionRecord|null $revision
219 * @param int $options Bitfield using the OPT_XXX constants
221 * @return ParserOutput|null
223 public function getCachedParserOutput(
225 ParserOptions
$parserOptions,
226 ?RevisionRecord
$revision = null,
229 $span = $this->startOperationSpan( __FUNCTION__
, $page, $revision );
230 $isOld = $revision && $revision->getId() !== $page->getLatest();
231 $useCache = $this->shouldUseCache( $page, $revision );
232 $primaryCache = $this->getPrimaryCache( $parserOptions );
233 $classCacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions );
235 if ( $useCache === self
::CACHE_PRIMARY
) {
236 if ( $this->localCache
->hasField( $classCacheKey, $page->getLatest() ) && !$isOld ) {
237 return $this->localCache
->getField( $classCacheKey, $page->getLatest() );
239 $output = $primaryCache->get( $page, $parserOptions );
240 } elseif ( $useCache === self
::CACHE_SECONDARY
&& $revision ) {
241 $secondaryCache = $this->getSecondaryCache( $parserOptions );
242 $output = $secondaryCache->get( $revision, $parserOptions );
247 $notHitReason = 'miss';
249 $output && !( $options & self
::OPT_IGNORE_PROFILE_VERSION
) &&
250 $parserOptions->getUseParsoid()
252 $pageBundleData = $output->getExtensionData(
253 PageBundleParserOutputConverter
::PARSOID_PAGE_BUNDLE_KEY
255 // T333606: Force a reparse if the version coming from cache is not the default
256 $cachedVersion = $pageBundleData['version'] ??
null;
258 $cachedVersion !== null && // T325137: BadContentModel, no sense in reparsing
259 $cachedVersion !== Parsoid
::defaultHTMLVersion()
261 $notHitReason = 'obsolete';
266 if ( $output && !$isOld ) {
267 $this->localCache
->setField( $classCacheKey, $page->getLatest(), $output );
272 ->getCounter( 'parseroutputaccess_cache' )
273 ->setLabel( 'cache', $useCache )
274 ->setLabel( 'reason', 'hit' )
275 ->setLabel( 'type', 'hit' )
276 ->copyToStatsdAt( "ParserOutputAccess.Cache.$useCache.hit" )
280 ->getCounter( 'parseroutputaccess_cache' )
281 ->setLabel( 'reason', $notHitReason )
282 ->setLabel( 'cache', $useCache )
283 ->setLabel( 'type', 'miss' )
284 ->copyToStatsdAt( "ParserOutputAccess.Cache.$useCache.$notHitReason" )
288 return $output ?
: null; // convert false to null
292 * Returns the rendered output for the given page.
293 * Caching and concurrency control is applied.
295 * @param PageRecord $page
296 * @param ParserOptions $parserOptions
297 * @param RevisionRecord|null $revision
298 * @param int $options Bitfield using the OPT_XXX constants
300 * @return Status containing a ParserOutput if no error occurred.
301 * Well known errors and warnings include the following messages:
302 * - 'view-pool-dirty-output' (warning) The output is dirty (from a stale cache entry).
303 * - 'view-pool-contention' (warning) Dirty output was returned immediately instead of
304 * waiting to acquire a work lock (when "fast stale" mode is enabled in PoolCounter).
305 * - 'view-pool-timeout' (warning) Dirty output was returned after failing to acquire
306 * a work lock (got QUEUE_FULL or TIMEOUT from PoolCounter).
307 * - 'pool-queuefull' (error) unable to acquire work lock, and no cached content found.
308 * - 'pool-timeout' (error) unable to acquire work lock, and no cached content found.
309 * - 'pool-servererror' (error) PoolCounterWork failed due to a lock service error.
310 * - 'pool-unknownerror' (error) PoolCounterWork failed for an unknown reason.
311 * - 'nopagetext' (error) The page does not exist
313 public function getParserOutput(
315 ParserOptions
$parserOptions,
316 ?RevisionRecord
$revision = null,
319 $span = $this->startOperationSpan( __FUNCTION__
, $page, $revision );
320 $error = $this->checkPreconditions( $page, $revision, $options );
323 ->getCounter( 'parseroutputaccess_case' )
324 ->setLabel( 'case', 'error' )
325 ->copyToStatsdAt( 'ParserOutputAccess.Case.error' )
330 $isOld = $revision && $revision->getId() !== $page->getLatest();
333 ->getCounter( 'parseroutputaccess_case' )
334 ->setLabel( 'case', 'old' )
335 ->copyToStatsdAt( 'ParserOutputAccess.Case.old' )
339 ->getCounter( 'parseroutputaccess_case' )
340 ->setLabel( 'case', 'current' )
341 ->copyToStatsdAt( 'ParserOutputAccess.Case.current' )
345 if ( !( $options & self
::OPT_NO_CHECK_CACHE
) ) {
346 $output = $this->getCachedParserOutput( $page, $parserOptions, $revision );
348 return Status
::newGood( $output );
353 $revId = $page->getLatest();
354 $revision = $revId ?
$this->revisionLookup
->getRevisionById( $revId ) : null;
358 ->getCounter( 'parseroutputaccess_status' )
359 ->setLabel( 'status', 'norev' )
360 ->copyToStatsdAt( "ParserOutputAccess.Status.norev" )
362 return Status
::newFatal( 'missing-revision', $revId );
366 if ( $options & self
::OPT_FOR_ARTICLE_VIEW
) {
367 $work = $this->newPoolWorkArticleView( $page, $parserOptions, $revision, $options );
368 /** @var Status $status */
369 $status = $work->execute();
371 // XXX: we could try harder to reuse a cache lookup above to
372 // provide the $previous argument here
373 $status = $this->renderRevision( $page, $parserOptions, $revision, $options, null );
376 $output = $status->getValue();
377 Assert
::postcondition( $output ||
!$status->isOK(), 'Inconsistent status' );
379 if ( $output && !$isOld ) {
380 $primaryCache = $this->getPrimaryCache( $parserOptions );
381 $classCacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions );
382 $this->localCache
->setField( $classCacheKey, $page->getLatest(), $output );
385 if ( $status->isGood() ) {
386 $this->statsFactory
->getCounter( 'parseroutputaccess_status' )
387 ->setLabel( 'status', 'good' )
388 ->copyToStatsdAt( 'ParserOutputAccess.Status.good' )
390 } elseif ( $status->isOK() ) {
391 $this->statsFactory
->getCounter( 'parseroutputaccess_status' )
392 ->setLabel( 'status', 'ok' )
393 ->copyToStatsdAt( 'ParserOutputAccess.Status.ok' )
396 $this->statsFactory
->getCounter( 'parseroutputaccess_status' )
397 ->setLabel( 'status', 'error' )
398 ->copyToStatsdAt( 'ParserOutputAccess.Status.error' )
406 * Render the given revision.
408 * This method will update the parser cache if appropriate, and will
409 * trigger a links update if OPT_LINKS_UPDATE is set.
411 * This method does not perform access checks, and will not load content
412 * from caches. The caller is assumed to have taken care of that.
414 * Where possible, pass in a $previousOutput, which will prevent an
415 * unnecessary double-lookup in the cache.
417 * @see PoolWorkArticleView::renderRevision
419 private function renderRevision(
421 ParserOptions
$parserOptions,
422 RevisionRecord
$revision,
424 ?ParserOutput
$previousOutput = null
426 $span = $this->startOperationSpan( __FUNCTION__
, $page, $revision );
427 $this->statsFactory
->getCounter( 'parseroutputaccess_poolwork' )
428 ->copyToStatsdAt( 'ParserOutputAccess.PoolWork.None' )
429 ->setLabel( 'cache', self
::CACHE_NONE
)
432 $useCache = $this->shouldUseCache( $page, $revision );
434 // T371713: Temporary statistics collection code to determine
435 // feasibility of Parsoid selective update
436 $sampleRate = MediaWikiServices
::getInstance()->getMainConfig()->get(
437 MainConfigNames
::ParsoidSelectiveUpdateSampleRate
439 $doSample = ( $sampleRate && mt_rand( 1, $sampleRate ) === 1 );
441 if ( $previousOutput === null && ( $doSample ||
$parserOptions->getUseParsoid() ) ) {
442 // If $useCache === self::CACHE_SECONDARY we could potentially
443 // try to reuse the parse of $revision-1 from the secondary cache,
444 // but it is likely those template transclusions are out of date.
445 // Try to reuse the template transclusions from the most recent
446 // parse, which are more likely to reflect the current template.
447 if ( !( $options & self
::OPT_NO_CHECK_CACHE
) ) {
448 $previousOutput = $this->getPrimaryCache( $parserOptions )->getDirty( $page, $parserOptions ) ?
: null;
452 $renderedRev = $this->revisionRenderer
->getRenderedRevision(
457 'audience' => RevisionRecord
::RAW
,
458 'previous-output' => $previousOutput,
462 $output = $renderedRev->getRevisionParserOutput();
465 $content = $revision->getContent( SlotRecord
::MAIN
);
467 'source' => 'ParserOutputAccess',
468 'type' => $previousOutput === null ?
'full' : 'selective',
469 'reason' => $parserOptions->getRenderReason(),
470 'parser' => $parserOptions->getUseParsoid() ?
'parsoid' : 'legacy',
471 'opportunistic' => 'false',
472 'wiki' => WikiMap
::getCurrentWikiId(),
473 'model' => $content ?
$content->getModel() : 'unknown',
476 ->getCounter( 'ParserCache_selective_total' )
477 ->setLabels( $labels )
480 ->getCounter( 'ParserCache_selective_cpu_seconds' )
481 ->setLabels( $labels )
482 ->incrementBy( $output->getTimeProfile( 'cpu' ) );
485 if ( !( $options & self
::OPT_NO_UPDATE_CACHE
) && $output->isCacheable() ) {
486 if ( $useCache === self
::CACHE_PRIMARY
) {
487 $primaryCache = $this->getPrimaryCache( $parserOptions );
488 $primaryCache->save( $output, $page, $parserOptions );
489 } elseif ( $useCache === self
::CACHE_SECONDARY
) {
490 $secondaryCache = $this->getSecondaryCache( $parserOptions );
491 $secondaryCache->save( $output, $revision, $parserOptions );
495 if ( $options & self
::OPT_LINKS_UPDATE
) {
496 $this->wikiPageFactory
->newFromTitle( $page )
497 ->triggerOpportunisticLinksUpdate( $output );
500 return Status
::newGood( $output );
504 * @param PageRecord $page
505 * @param RevisionRecord|null $revision
506 * @param int $options
508 * @return Status|null
510 private function checkPreconditions(
512 ?RevisionRecord
$revision = null,
515 if ( !$page->exists() ) {
516 return Status
::newFatal( 'nopagetext' );
519 if ( !( $options & self
::OPT_NO_UPDATE_CACHE
) && $revision && !$revision->getId() ) {
520 throw new InvalidArgumentException(
521 'The revision does not have a known ID. Use OPT_NO_CACHE.'
525 if ( $revision && $revision->getPageId() !== $page->getId() ) {
526 throw new InvalidArgumentException(
527 'The revision does not belong to the given page.'
531 if ( $revision && !( $options & self
::OPT_NO_AUDIENCE_CHECK
) ) {
532 // NOTE: If per-user checks are desired, the caller should perform them and
533 // then set OPT_NO_AUDIENCE_CHECK if they passed.
534 if ( !$revision->audienceCan( RevisionRecord
::DELETED_TEXT
, RevisionRecord
::FOR_PUBLIC
) ) {
535 return Status
::newFatal(
536 'missing-revision-permission',
538 $revision->getTimestamp(),
539 $this->titleFormatter
->getPrefixedDBkey( $page )
548 * @param PageRecord $page
549 * @param ParserOptions $parserOptions
550 * @param RevisionRecord $revision
551 * @param int $options
553 * @return PoolCounterWork
555 protected function newPoolWorkArticleView(
557 ParserOptions
$parserOptions,
558 RevisionRecord
$revision,
561 $useCache = $this->shouldUseCache( $page, $revision );
563 switch ( $useCache ) {
564 case self
::CACHE_PRIMARY
:
565 $this->statsFactory
->getCounter( 'parseroutputaccess_poolwork' )
566 ->setLabel( 'cache', self
::CACHE_PRIMARY
)
567 ->copyToStatsdAt( 'ParserOutputAccess.PoolWork.Current' )
569 $primaryCache = $this->getPrimaryCache( $parserOptions );
570 $parserCacheMetadata = $primaryCache->getMetadata( $page );
571 $cacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions,
572 $parserCacheMetadata ?
$parserCacheMetadata->getUsedOptions() : null
575 $workKey = $cacheKey . ':revid:' . $revision->getId();
577 return new PoolWorkArticleViewCurrent(
582 $this->revisionRenderer
,
585 $this->chronologyProtector
,
587 $this->wikiPageFactory
,
588 !( $options & self
::OPT_NO_UPDATE_CACHE
),
589 (bool)( $options & self
::OPT_LINKS_UPDATE
)
592 case self
::CACHE_SECONDARY
:
593 $this->statsFactory
->getCounter( 'parseroutputaccess_poolwork' )
594 ->setLabel( 'cache', self
::CACHE_SECONDARY
)
595 ->copyToStatsdAt( 'ParserOutputAccess.PoolWork.Old' )
597 $secondaryCache = $this->getSecondaryCache( $parserOptions );
598 $workKey = $secondaryCache->makeParserOutputKey( $revision, $parserOptions );
599 return new PoolWorkArticleViewOld(
604 $this->revisionRenderer
,
609 $this->statsFactory
->getCounter( 'parseroutputaccess_poolwork' )
610 ->setLabel( 'cache', self
::CACHE_NONE
)
611 ->copyToStatsdAt( 'ParserOutputAccess.PoolWork.Uncached' )
613 $secondaryCache = $this->getSecondaryCache( $parserOptions );
614 $workKey = $secondaryCache->makeParserOutputKeyOptionalRevId( $revision, $parserOptions );
615 return new PoolWorkArticleView(
619 $this->revisionRenderer
,
627 private function getPrimaryCache( ParserOptions
$pOpts ): ParserCache
{
628 if ( $pOpts->getUseParsoid() ) {
629 return $this->parserCacheFactory
->getParserCache(
630 self
::PARSOID_PCACHE_NAME
634 return $this->parserCacheFactory
->getParserCache(
635 ParserCacheFactory
::DEFAULT_NAME
639 private function getSecondaryCache( ParserOptions
$pOpts ): RevisionOutputCache
{
640 if ( $pOpts->getUseParsoid() ) {
641 return $this->parserCacheFactory
->getRevisionOutputCache(
642 self
::PARSOID_RCACHE_NAME
646 return $this->parserCacheFactory
->getRevisionOutputCache(
647 ParserCacheFactory
::DEFAULT_RCACHE_NAME
651 private function startOperationSpan(
654 ?RevisionRecord
$revision = null
656 $span = $this->tracer
->createSpan( "ParserOutputAccess::$opName" );
657 if ( $span->getContext()->isSampled() ) {
658 $span->setAttributes( [
659 'org.wikimedia.parser.page' => $page->__toString(),
660 'org.wikimedia.parser.page.id' => $page->getId(),
661 'org.wikimedia.parser.page.wiki' => $page->getWikiId(),
664 $span->setAttributes( [
665 'org.wikimedia.parser.revision.id' => $revision->getId(),
666 'org.wikimedia.parser.revision.parent_id' => $revision->getParentId(),
670 return $span->start()->activate();