MediaWiki 1.44.0
ParserOutputAccess.php
Go to the documentation of this file.
1<?php
20namespace MediaWiki\Page;
21
22use InvalidArgumentException;
23use MediaWiki\Logger\Spi as LoggerSpi;
44use Wikimedia\Assert\Assert;
46use Wikimedia\Parsoid\Parsoid;
52
63
65 public const PARSOID_PCACHE_NAME = 'parsoid-' . ParserCacheFactory::DEFAULT_NAME;
66
68 public const PARSOID_RCACHE_NAME = 'parsoid-' . ParserCacheFactory::DEFAULT_RCACHE_NAME;
69
73 public const OPT_NO_CHECK_CACHE = 1;
74
76 public const OPT_FORCE_PARSE = self::OPT_NO_CHECK_CACHE;
77
81 public const OPT_NO_UPDATE_CACHE = 2;
82
88 public const OPT_NO_AUDIENCE_CHECK = 4;
89
94 public const OPT_NO_CACHE = self::OPT_NO_UPDATE_CACHE | self::OPT_NO_CHECK_CACHE;
95
100 public const OPT_LINKS_UPDATE = 8;
101
114 public const OPT_FOR_ARTICLE_VIEW = 16;
115
120 public const OPT_IGNORE_PROFILE_VERSION = 128;
121
123 private const CACHE_NONE = 'none';
124
126 private const CACHE_PRIMARY = 'primary';
127
129 private const CACHE_SECONDARY = 'secondary';
130
136 private MapCacheLRU $localCache;
137
138 private ParserCacheFactory $parserCacheFactory;
139 private RevisionLookup $revisionLookup;
140 private RevisionRenderer $revisionRenderer;
141 private StatsFactory $statsFactory;
142 private ILBFactory $lbFactory;
143 private ChronologyProtector $chronologyProtector;
144 private LoggerSpi $loggerSpi;
145 private WikiPageFactory $wikiPageFactory;
146 private TitleFormatter $titleFormatter;
147 private TracerInterface $tracer;
148 private PoolCounterFactory $poolCounterFactory;
149
150 public function __construct(
151 ParserCacheFactory $parserCacheFactory,
152 RevisionLookup $revisionLookup,
153 RevisionRenderer $revisionRenderer,
154 StatsFactory $statsFactory,
155 ILBFactory $lbFactory,
156 ChronologyProtector $chronologyProtector,
157 LoggerSpi $loggerSpi,
158 WikiPageFactory $wikiPageFactory,
159 TitleFormatter $titleFormatter,
160 TracerInterface $tracer,
161 PoolCounterFactory $poolCounterFactory
162 ) {
163 $this->parserCacheFactory = $parserCacheFactory;
164 $this->revisionLookup = $revisionLookup;
165 $this->revisionRenderer = $revisionRenderer;
166 $this->statsFactory = $statsFactory;
167 $this->lbFactory = $lbFactory;
168 $this->chronologyProtector = $chronologyProtector;
169 $this->loggerSpi = $loggerSpi;
170 $this->wikiPageFactory = $wikiPageFactory;
171 $this->titleFormatter = $titleFormatter;
172 $this->tracer = $tracer;
173 $this->poolCounterFactory = $poolCounterFactory;
174
175 $this->localCache = new MapCacheLRU( 10 );
176 }
177
186 private function shouldUseCache(
187 PageRecord $page,
188 ?RevisionRecord $rev
189 ) {
190 if ( $rev && !$rev->getId() ) {
191 // The revision isn't from the database, so the output can't safely be cached.
192 return self::CACHE_NONE;
193 }
194
195 // NOTE: Keep in sync with ParserWikiPage::shouldCheckParserCache().
196 // NOTE: when we allow caching of old revisions in the future,
197 // we must not allow caching of deleted revisions.
198
199 $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
200 if ( !$page->exists() || !$wikiPage->getContentHandler()->isParserCacheSupported() ) {
201 return self::CACHE_NONE;
202 }
203
204 $isOld = $rev && $rev->getId() !== $page->getLatest();
205 if ( !$isOld ) {
206 return self::CACHE_PRIMARY;
207 }
208
209 if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_PUBLIC ) ) {
210 // deleted/suppressed revision
211 return self::CACHE_NONE;
212 }
213
214 return self::CACHE_SECONDARY;
215 }
216
226 public function getCachedParserOutput(
227 PageRecord $page,
228 ParserOptions $parserOptions,
229 ?RevisionRecord $revision = null,
230 int $options = 0
231 ): ?ParserOutput {
232 $span = $this->startOperationSpan( __FUNCTION__, $page, $revision );
233 $isOld = $revision && $revision->getId() !== $page->getLatest();
234 $useCache = $this->shouldUseCache( $page, $revision );
235 $primaryCache = $this->getPrimaryCache( $parserOptions );
236 $classCacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions );
237
238 if ( $useCache === self::CACHE_PRIMARY ) {
239 if ( $this->localCache->hasField( $classCacheKey, $page->getLatest() ) && !$isOld ) {
240 return $this->localCache->getField( $classCacheKey, $page->getLatest() );
241 }
242 $output = $primaryCache->get( $page, $parserOptions );
243 } elseif ( $useCache === self::CACHE_SECONDARY && $revision ) {
244 $secondaryCache = $this->getSecondaryCache( $parserOptions );
245 $output = $secondaryCache->get( $revision, $parserOptions );
246 } else {
247 $output = null;
248 }
249
250 $statType = $statReason = $output ? 'hit' : 'miss';
251
252 if (
253 $output && !( $options & self::OPT_IGNORE_PROFILE_VERSION ) &&
254 $parserOptions->getUseParsoid()
255 ) {
256 $pageBundleData = $output->getExtensionData(
257 PageBundleParserOutputConverter::PARSOID_PAGE_BUNDLE_KEY
258 );
259 // T333606: Force a reparse if the version coming from cache is not the default
260 $cachedVersion = $pageBundleData['version'] ?? null;
261 if (
262 $cachedVersion !== null && // T325137: BadContentModel, no sense in reparsing
263 $cachedVersion !== Parsoid::defaultHTMLVersion()
264 ) {
265 $statType = 'miss';
266 $statReason = 'obsolete';
267 $output = null;
268 }
269 }
270
271 if ( $output && !$isOld ) {
272 $this->localCache->setField( $classCacheKey, $page->getLatest(), $output );
273 }
274
275 $this->statsFactory
276 ->getCounter( 'parseroutputaccess_cache_total' )
277 ->setLabel( 'cache', $useCache )
278 ->setLabel( 'reason', $statReason )
279 ->setLabel( 'type', $statType )
280 ->copyToStatsdAt( "ParserOutputAccess.Cache.$useCache.$statReason" )
281 ->increment();
282
283 return $output ?: null; // convert false to null
284 }
285
308 public function getParserOutput(
309 PageRecord $page,
310 ParserOptions $parserOptions,
311 ?RevisionRecord $revision = null,
312 int $options = 0
313 ): Status {
314 $span = $this->startOperationSpan( __FUNCTION__, $page, $revision );
315 $error = $this->checkPreconditions( $page, $revision, $options );
316 if ( $error ) {
317 $this->statsFactory
318 ->getCounter( 'parseroutputaccess_case' )
319 ->setLabel( 'case', 'error' )
320 ->copyToStatsdAt( 'ParserOutputAccess.Case.error' )
321 ->increment();
322 return $error;
323 }
324
325 $isOld = $revision && $revision->getId() !== $page->getLatest();
326 if ( $isOld ) {
327 $this->statsFactory
328 ->getCounter( 'parseroutputaccess_case' )
329 ->setLabel( 'case', 'old' )
330 ->copyToStatsdAt( 'ParserOutputAccess.Case.old' )
331 ->increment();
332 } else {
333 $this->statsFactory
334 ->getCounter( 'parseroutputaccess_case' )
335 ->setLabel( 'case', 'current' )
336 ->copyToStatsdAt( 'ParserOutputAccess.Case.current' )
337 ->increment();
338 }
339
340 if ( !( $options & self::OPT_NO_CHECK_CACHE ) ) {
341 $output = $this->getCachedParserOutput( $page, $parserOptions, $revision );
342 if ( $output ) {
343 return Status::newGood( $output );
344 }
345 }
346
347 if ( !$revision ) {
348 $revId = $page->getLatest();
349 $revision = $revId ? $this->revisionLookup->getRevisionById( $revId ) : null;
350
351 if ( !$revision ) {
352 $this->statsFactory
353 ->getCounter( 'parseroutputaccess_status' )
354 ->setLabel( 'status', 'norev' )
355 ->copyToStatsdAt( "ParserOutputAccess.Status.norev" )
356 ->increment();
357 return Status::newFatal( 'missing-revision', $revId );
358 }
359 }
360
361 if ( $options & self::OPT_FOR_ARTICLE_VIEW ) {
362 $work = $this->newPoolWorkArticleView( $page, $parserOptions, $revision, $options );
364 $status = $work->execute();
365 } else {
366 // XXX: we could try harder to reuse a cache lookup above to
367 // provide the $previous argument here
368 $status = $this->renderRevision( $page, $parserOptions, $revision, $options, null );
369 }
370
371 $output = $status->getValue();
372 Assert::postcondition( $output || !$status->isOK(), 'Inconsistent status' );
373
374 if ( $output && !$isOld ) {
375 $primaryCache = $this->getPrimaryCache( $parserOptions );
376 $classCacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions );
377 $this->localCache->setField( $classCacheKey, $page->getLatest(), $output );
378 }
379
380 if ( $status->isGood() ) {
381 $this->statsFactory->getCounter( 'parseroutputaccess_status' )
382 ->setLabel( 'status', 'good' )
383 ->copyToStatsdAt( 'ParserOutputAccess.Status.good' )
384 ->increment();
385 } elseif ( $status->isOK() ) {
386 $this->statsFactory->getCounter( 'parseroutputaccess_status' )
387 ->setLabel( 'status', 'ok' )
388 ->copyToStatsdAt( 'ParserOutputAccess.Status.ok' )
389 ->increment();
390 } else {
391 $this->statsFactory->getCounter( 'parseroutputaccess_status' )
392 ->setLabel( 'status', 'error' )
393 ->copyToStatsdAt( 'ParserOutputAccess.Status.error' )
394 ->increment();
395 }
396
397 return $status;
398 }
399
414 private function renderRevision(
415 PageRecord $page,
416 ParserOptions $parserOptions,
417 RevisionRecord $revision,
418 int $options,
419 ?ParserOutput $previousOutput = null
420 ): Status {
421 $span = $this->startOperationSpan( __FUNCTION__, $page, $revision );
422 $this->statsFactory->getCounter( 'parseroutputaccess_render_total' )
423 ->setLabel( 'pool', 'none' )
424 ->setLabel( 'cache', self::CACHE_NONE )
425 ->copyToStatsdAt( 'ParserOutputAccess.PoolWork.None' )
426 ->increment();
427
428 $useCache = $this->shouldUseCache( $page, $revision );
429
430 // T371713: Temporary statistics collection code to determine
431 // feasibility of Parsoid selective update
432 $sampleRate = MediaWikiServices::getInstance()->getMainConfig()->get(
433 MainConfigNames::ParsoidSelectiveUpdateSampleRate
434 );
435 $doSample = ( $sampleRate && mt_rand( 1, $sampleRate ) === 1 );
436
437 if ( $previousOutput === null && ( $doSample || $parserOptions->getUseParsoid() ) ) {
438 // If $useCache === self::CACHE_SECONDARY we could potentially
439 // try to reuse the parse of $revision-1 from the secondary cache,
440 // but it is likely those template transclusions are out of date.
441 // Try to reuse the template transclusions from the most recent
442 // parse, which are more likely to reflect the current template.
443 if ( !( $options & self::OPT_NO_CHECK_CACHE ) ) {
444 $previousOutput = $this->getPrimaryCache( $parserOptions )->getDirty( $page, $parserOptions ) ?: null;
445 }
446 }
447
448 $renderedRev = $this->revisionRenderer->getRenderedRevision(
449 $revision,
450 $parserOptions,
451 null,
452 [
453 'audience' => RevisionRecord::RAW,
454 'previous-output' => $previousOutput,
455 ]
456 );
457
458 $output = $renderedRev->getRevisionParserOutput();
459
460 if ( $doSample ) {
461 $content = $revision->getContent( SlotRecord::MAIN );
462 $labels = [
463 'source' => 'ParserOutputAccess',
464 'type' => $previousOutput === null ? 'full' : 'selective',
465 'reason' => $parserOptions->getRenderReason(),
466 'parser' => $parserOptions->getUseParsoid() ? 'parsoid' : 'legacy',
467 'opportunistic' => 'false',
468 'wiki' => WikiMap::getCurrentWikiId(),
469 'model' => $content ? $content->getModel() : 'unknown',
470 ];
471 $this->statsFactory
472 ->getCounter( 'ParserCache_selective_total' )
473 ->setLabels( $labels )
474 ->increment();
475 $this->statsFactory
476 ->getCounter( 'ParserCache_selective_cpu_seconds' )
477 ->setLabels( $labels )
478 ->incrementBy( $output->getTimeProfile( 'cpu' ) );
479 }
480
481 if ( !( $options & self::OPT_NO_UPDATE_CACHE ) && $output->isCacheable() ) {
482 if ( $useCache === self::CACHE_PRIMARY ) {
483 $primaryCache = $this->getPrimaryCache( $parserOptions );
484 $primaryCache->save( $output, $page, $parserOptions );
485 } elseif ( $useCache === self::CACHE_SECONDARY ) {
486 $secondaryCache = $this->getSecondaryCache( $parserOptions );
487 $secondaryCache->save( $output, $revision, $parserOptions );
488 }
489 }
490
491 if ( $options & self::OPT_LINKS_UPDATE ) {
492 $this->wikiPageFactory->newFromTitle( $page )
493 ->triggerOpportunisticLinksUpdate( $output );
494 }
495
496 return Status::newGood( $output );
497 }
498
506 private function checkPreconditions(
507 PageRecord $page,
508 ?RevisionRecord $revision = null,
509 int $options = 0
510 ): ?Status {
511 if ( !$page->exists() ) {
512 return Status::newFatal( 'nopagetext' );
513 }
514
515 if ( !( $options & self::OPT_NO_UPDATE_CACHE ) && $revision && !$revision->getId() ) {
516 throw new InvalidArgumentException(
517 'The revision does not have a known ID. Use OPT_NO_CACHE.'
518 );
519 }
520
521 if ( $revision && $revision->getPageId() !== $page->getId() ) {
522 throw new InvalidArgumentException(
523 'The revision does not belong to the given page.'
524 );
525 }
526
527 if ( $revision && !( $options & self::OPT_NO_AUDIENCE_CHECK ) ) {
528 // NOTE: If per-user checks are desired, the caller should perform them and
529 // then set OPT_NO_AUDIENCE_CHECK if they passed.
530 if ( !$revision->audienceCan( RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_PUBLIC ) ) {
531 return Status::newFatal(
532 'missing-revision-permission',
533 $revision->getId(),
534 $revision->getTimestamp(),
535 $this->titleFormatter->getPrefixedDBkey( $page )
536 );
537 }
538 }
539
540 return null;
541 }
542
551 protected function newPoolWorkArticleView(
552 PageRecord $page,
553 ParserOptions $parserOptions,
554 RevisionRecord $revision,
555 int $options
556 ): PoolCounterWork {
557 $useCache = $this->shouldUseCache( $page, $revision );
558
559 $statCacheLabelLegacy = [
560 self::CACHE_PRIMARY => 'Current',
561 self::CACHE_SECONDARY => 'Old',
562 ][$useCache] ?? 'Uncached';
563
564 $this->statsFactory->getCounter( 'parseroutputaccess_render_total' )
565 ->setLabel( 'pool', 'articleview' )
566 ->setLabel( 'cache', $useCache )
567 ->copyToStatsdAt( "ParserOutputAccess.PoolWork.$statCacheLabelLegacy" )
568 ->increment();
569
570 switch ( $useCache ) {
571 case self::CACHE_PRIMARY:
572 $primaryCache = $this->getPrimaryCache( $parserOptions );
573 $parserCacheMetadata = $primaryCache->getMetadata( $page );
574 $cacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions,
575 $parserCacheMetadata ? $parserCacheMetadata->getUsedOptions() : null
576 );
577
578 $workKey = $cacheKey . ':revid:' . $revision->getId();
579
580 $pool = $this->poolCounterFactory->create( 'ArticleView', $workKey );
582 $pool,
583 $page,
584 $revision,
585 $parserOptions,
586 $this->revisionRenderer,
587 $primaryCache,
588 $this->lbFactory,
589 $this->chronologyProtector,
590 $this->loggerSpi,
591 $this->wikiPageFactory,
592 !( $options & self::OPT_NO_UPDATE_CACHE ),
593 (bool)( $options & self::OPT_LINKS_UPDATE )
594 );
595
596 case self::CACHE_SECONDARY:
597 $secondaryCache = $this->getSecondaryCache( $parserOptions );
598 $workKey = $secondaryCache->makeParserOutputKey( $revision, $parserOptions );
599 $pool = $this->poolCounterFactory->create( 'ArticleView', $workKey );
600 return new PoolWorkArticleViewOld(
601 $pool,
602 $secondaryCache,
603 $revision,
604 $parserOptions,
605 $this->revisionRenderer,
606 $this->loggerSpi
607 );
608
609 default:
610 // Without caching, using poolcounter is pointless
611 // The name of the metric is a bit confusing now
612 $secondaryCache = $this->getSecondaryCache( $parserOptions );
613 $workKey = $secondaryCache->makeParserOutputKeyOptionalRevId( $revision, $parserOptions );
614 $pool = $this->poolCounterFactory->create( 'ArticleView', $workKey );
615 return new PoolWorkArticleView(
616 $pool,
617 $revision,
618 $parserOptions,
619 $this->revisionRenderer,
620 $this->loggerSpi
621 );
622 }
623
624 // unreachable
625 }
626
627 private function getPrimaryCache( ParserOptions $pOpts ): ParserCache {
628 if ( $pOpts->getUseParsoid() ) {
629 return $this->parserCacheFactory->getParserCache(
630 self::PARSOID_PCACHE_NAME
631 );
632 }
633
634 return $this->parserCacheFactory->getParserCache(
635 ParserCacheFactory::DEFAULT_NAME
636 );
637 }
638
639 private function getSecondaryCache( ParserOptions $pOpts ): RevisionOutputCache {
640 if ( $pOpts->getUseParsoid() ) {
641 return $this->parserCacheFactory->getRevisionOutputCache(
642 self::PARSOID_RCACHE_NAME
643 );
644 }
645
646 return $this->parserCacheFactory->getRevisionOutputCache(
647 ParserCacheFactory::DEFAULT_RCACHE_NAME
648 );
649 }
650
651 private function startOperationSpan(
652 string $opName,
653 PageRecord $page,
654 ?RevisionRecord $revision = null
655 ): SpanInterface {
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(),
662 ] );
663 if ( $revision ) {
664 $span->setAttributes( [
665 'org.wikimedia.parser.revision.id' => $revision->getId(),
666 'org.wikimedia.parser.revision.parent_id' => $revision->getParentId(),
667 ] );
668 }
669 }
670 return $span->start()->activate();
671 }
672}
const CACHE_NONE
Definition Defines.php:87
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:82
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Service for getting rendered output of a given page.
const OPT_FOR_ARTICLE_VIEW
Apply page view semantics.
getCachedParserOutput(PageRecord $page, ParserOptions $parserOptions, ?RevisionRecord $revision=null, int $options=0)
Get the rendered output for the given page if it is present in the cache.
__construct(ParserCacheFactory $parserCacheFactory, RevisionLookup $revisionLookup, RevisionRenderer $revisionRenderer, StatsFactory $statsFactory, ILBFactory $lbFactory, ChronologyProtector $chronologyProtector, LoggerSpi $loggerSpi, WikiPageFactory $wikiPageFactory, TitleFormatter $titleFormatter, TracerInterface $tracer, PoolCounterFactory $poolCounterFactory)
newPoolWorkArticleView(PageRecord $page, ParserOptions $parserOptions, RevisionRecord $revision, int $options)
getParserOutput(PageRecord $page, ParserOptions $parserOptions, ?RevisionRecord $revision=null, int $options=0)
Returns the rendered output for the given page.
Service for creating WikiPage objects.
Cache for ParserOutput objects corresponding to the latest page revisions.
Set options of the Parser.
getUseParsoid()
Parsoid-format HTML output, or legacy wikitext parser HTML?
ParserOutput is a rendering of a Content object or a message.
Provides methods for conversion between PageBundle and ParserOutput TODO: Convert to a trait once we ...
Cache for ParserOutput objects.
Class for dealing with PoolCounters using class members.
PoolWorkArticleView for the current revision of a page, using ParserCache.
PoolWorkArticleView for an old revision of a page, using a simple cache.
PoolCounter protected work wrapping RenderedRevision->getRevisionParserOutput.
Page revision base class.
getParentId( $wikiId=self::LOCAL)
Get parent revision ID (the original previous page revision).
audienceCan( $field, $audience, ?Authority $performer=null)
Check that the given audience has access to the given field.
getId( $wikiId=self::LOCAL)
Get revision ID.
The RevisionRenderer service provides access to rendered output for revisions.
Value object representing a content slot associated with a page revision.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
A title formatter service for MediaWiki.
Tools for dealing with other locally-hosted wikis.
Definition WikiMap.php:31
Store key-value entries in a size-limited in-memory LRU cache.
Provide a given client with protection against visible database lag.
This is the primary interface for validating metrics definitions, caching defined metrics,...
Service provider interface to create \Psr\Log\LoggerInterface objects.
Definition Spi.php:64
exists()
Checks if the page currently exists.
Data record representing a page that is (or used to be, or could be) an editable page on a wiki.
getLatest( $wikiId=self::LOCAL)
The ID of the page's latest revision.
Service for looking up page revisions.
Manager of ILoadBalancer objects and, indirectly, IDatabase connections.
Represents an OpenTelemetry span, i.e.
Base interface for an OpenTelemetry tracer responsible for creating spans.