Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.56% covered (warning)
89.56%
163 / 182
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ParserOutputAccess
89.56% covered (warning)
89.56%
163 / 182
77.78% covered (warning)
77.78%
7 / 9
69.81
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 shouldUseCache
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 getCachedParserOutput
68.97% covered (warning)
68.97%
20 / 29
0.00% covered (danger)
0.00%
0 / 1
23.65
 getParserOutput
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
15
 renderRevision
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 checkPreconditions
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
10
 newPoolWorkArticleView
77.78% covered (warning)
77.78%
35 / 45
0.00% covered (danger)
0.00%
0 / 1
5.27
 getPrimaryCache
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getSecondaryCache
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
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.
7 *
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.
12 *
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
17 *
18 * @file
19 */
20namespace MediaWiki\Page;
21
22use IBufferingStatsdDataFactory;
23use InvalidArgumentException;
24use MapCacheLRU;
25use MediaWiki\Logger\Spi as LoggerSpi;
26use MediaWiki\Parser\ParserCacheFactory;
27use MediaWiki\Parser\ParserOutput;
28use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
29use MediaWiki\Parser\RevisionOutputCache;
30use MediaWiki\PoolCounter\PoolCounterWork;
31use MediaWiki\PoolCounter\PoolWorkArticleView;
32use MediaWiki\PoolCounter\PoolWorkArticleViewCurrent;
33use MediaWiki\PoolCounter\PoolWorkArticleViewOld;
34use MediaWiki\Revision\RevisionLookup;
35use MediaWiki\Revision\RevisionRecord;
36use MediaWiki\Revision\RevisionRenderer;
37use MediaWiki\Status\Status;
38use MediaWiki\Title\TitleFormatter;
39use ParserCache;
40use ParserOptions;
41use Wikimedia\Assert\Assert;
42use Wikimedia\Parsoid\Parsoid;
43use Wikimedia\Rdbms\ChronologyProtector;
44use Wikimedia\Rdbms\ILBFactory;
45
46/**
47 * Service for getting rendered output of a given page.
48 *
49 * This is a high level service, encapsulating concerns like caching
50 * and stampede protection via PoolCounter.
51 *
52 * @since 1.36
53 * @ingroup Page
54 */
55class ParserOutputAccess {
56
57    /** @internal */
58    public const PARSOID_PCACHE_NAME = 'parsoid-' . ParserCacheFactory::DEFAULT_NAME;
59
60    /** @internal */
61    public const PARSOID_RCACHE_NAME = 'parsoid-' . ParserCacheFactory::DEFAULT_RCACHE_NAME;
62
63    /**
64     * @var int Do not check the cache before parsing (force parse)
65     */
66    public const OPT_NO_CHECK_CACHE = 1;
67
68    /** @var int Alias for NO_CHECK_CACHE */
69    public const OPT_FORCE_PARSE = self::OPT_NO_CHECK_CACHE;
70
71    /**
72     * @var int Do not update the cache after parsing.
73     */
74    public const OPT_NO_UPDATE_CACHE = 2;
75
76    /**
77     * @var int Bypass audience check for deleted/suppressed revisions.
78     *      The caller is responsible for ensuring that unauthorized access is prevented.
79     *      If not set, output generation will fail if the revision is not public.
80     */
81    public const OPT_NO_AUDIENCE_CHECK = 4;
82
83    /**
84     * @var int Do not check the cache before parsing,
85     *      and do not update the cache after parsing (not cacheable).
86     */
87    public const OPT_NO_CACHE = self::OPT_NO_UPDATE_CACHE | self::OPT_NO_CHECK_CACHE;
88
89    /**
90     * @var int Do perform an opportunistic LinksUpdate on cache miss
91     * @since 1.41
92     */
93    public const OPT_LINKS_UPDATE = 8;
94
95    /**
96     * Apply page view semantics. This relaxes some guarantees, specifically:
97     * - Use PoolCounter for stampede protection, causing the request to
98     *   block until another process has finished rendering the content.
99     * - Allow stale parser output to be returned to prevent long waits for
100     *   slow renders.
101     * - Allow cacheable placeholder output to be returned when PoolCounter
102     *   fails to obtain a lock. See the PoolCounterConf setting for details.
103     *
104     * @see Bug T352837
105     * @since 1.42
106     */
107    public const OPT_FOR_ARTICLE_VIEW = 16;
108
109    /**
110     * @var int Ignore the profile version of the result from the cache.
111     *      Otherwise, if it's not Parsoid's default, it will be invalidated.
112     */
113    public const OPT_IGNORE_PROFILE_VERSION = 128;
114
115    /** @var string Do not read or write any cache */
116    private const CACHE_NONE = 'none';
117
118    /** @var string Use primary cache */
119    private const CACHE_PRIMARY = 'primary';
120
121    /** @var string Use secondary cache */
122    private const CACHE_SECONDARY = 'secondary';
123
124    private ParserCacheFactory $parserCacheFactory;
125
126    /**
127     * In cases that an extension tries to get the same ParserOutput of
128     * the page right after it was parsed (T301310).
129     * @var MapCacheLRU<string,ParserOutput>
130     */
131    private MapCacheLRU $localCache;
132
133    /** @var RevisionLookup */
134    private $revisionLookup;
135
136    /** @var RevisionRenderer */
137    private $revisionRenderer;
138
139    /** @var IBufferingStatsdDataFactory */
140    private $statsDataFactory;
141
142    /** @var ILBFactory */
143    private $lbFactory;
144    private ChronologyProtector $chronologyProtector;
145
146    /** @var LoggerSpi */
147    private $loggerSpi;
148
149    /** @var WikiPageFactory */
150    private $wikiPageFactory;
151
152    /** @var TitleFormatter */
153    private $titleFormatter;
154
155    /**
156     * @param ParserCacheFactory $parserCacheFactory
157     * @param RevisionLookup $revisionLookup
158     * @param RevisionRenderer $revisionRenderer
159     * @param IBufferingStatsdDataFactory $statsDataFactory
160     * @param ILBFactory $lbFactory
161     * @param ChronologyProtector $chronologyProtector
162     * @param LoggerSpi $loggerSpi
163     * @param WikiPageFactory $wikiPageFactory
164     * @param TitleFormatter $titleFormatter
165     */
166    public function __construct(
167        ParserCacheFactory $parserCacheFactory,
168        RevisionLookup $revisionLookup,
169        RevisionRenderer $revisionRenderer,
170        IBufferingStatsdDataFactory $statsDataFactory,
171        ILBFactory $lbFactory,
172        ChronologyProtector $chronologyProtector,
173        LoggerSpi $loggerSpi,
174        WikiPageFactory $wikiPageFactory,
175        TitleFormatter $titleFormatter
176    ) {
177        $this->parserCacheFactory = $parserCacheFactory;
178        $this->revisionLookup = $revisionLookup;
179        $this->revisionRenderer = $revisionRenderer;
180        $this->statsDataFactory = $statsDataFactory;
181        $this->lbFactory = $lbFactory;
182        $this->chronologyProtector = $chronologyProtector;
183        $this->loggerSpi = $loggerSpi;
184        $this->wikiPageFactory = $wikiPageFactory;
185        $this->titleFormatter = $titleFormatter;
186
187        $this->localCache = new MapCacheLRU( 10 );
188    }
189
190    /**
191     * Use a cache?
192     *
193     * @param PageRecord $page
194     * @param RevisionRecord|null $rev
195     *
196     * @return string One of the CACHE_XXX constants.
197     */
198    private function shouldUseCache(
199        PageRecord $page,
200        ?RevisionRecord $rev
201    ) {
202        if ( $rev && !$rev->getId() ) {
203            // The revision isn't from the database, so the output can't safely be cached.
204            return self::CACHE_NONE;
205        }
206
207        // NOTE: Keep in sync with ParserWikiPage::shouldCheckParserCache().
208        // NOTE: when we allow caching of old revisions in the future,
209        //       we must not allow caching of deleted revisions.
210
211        $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
212        if ( !$page->exists() || !$wikiPage->getContentHandler()->isParserCacheSupported() ) {
213            return self::CACHE_NONE;
214        }
215
216        $isOld = $rev && $rev->getId() !== $page->getLatest();
217        if ( !$isOld ) {
218            return self::CACHE_PRIMARY;
219        }
220
221        if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_PUBLIC ) ) {
222            // deleted/suppressed revision
223            return self::CACHE_NONE;
224        }
225
226        return self::CACHE_SECONDARY;
227    }
228
229    /**
230     * Returns the rendered output for the given page if it is present in the cache.
231     *
232     * @param PageRecord $page
233     * @param ParserOptions $parserOptions
234     * @param RevisionRecord|null $revision
235     * @param int $options Bitfield using the OPT_XXX constants
236     *
237     * @return ParserOutput|null
238     */
239    public function getCachedParserOutput(
240        PageRecord $page,
241        ParserOptions $parserOptions,
242        ?RevisionRecord $revision = null,
243        int $options = 0
244    ): ?ParserOutput {
245        $isOld = $revision && $revision->getId() !== $page->getLatest();
246        $useCache = $this->shouldUseCache( $page, $revision );
247        $primaryCache = $this->getPrimaryCache( $parserOptions );
248        $classCacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions );
249
250        if ( $useCache === self::CACHE_PRIMARY ) {
251            if ( $this->localCache->hasField( $classCacheKey, $page->getLatest() ) && !$isOld ) {
252                return $this->localCache->getField( $classCacheKey, $page->getLatest() );
253            }
254            $output = $primaryCache->get( $page, $parserOptions );
255        } elseif ( $useCache === self::CACHE_SECONDARY && $revision ) {
256            $secondaryCache = $this->getSecondaryCache( $parserOptions );
257            $output = $secondaryCache->get( $revision, $parserOptions );
258        } else {
259            $output = null;
260        }
261
262        $notHitReason = 'miss';
263        if (
264            $output && !( $options & self::OPT_IGNORE_PROFILE_VERSION ) &&
265            $parserOptions->getUseParsoid()
266        ) {
267            $pageBundleData = $output->getExtensionData(
268                PageBundleParserOutputConverter::PARSOID_PAGE_BUNDLE_KEY
269            );
270            // T333606: Force a reparse if the version coming from cache is not the default
271            $cachedVersion = $pageBundleData['version'] ?? null;
272            if (
273                $cachedVersion !== null && // T325137: BadContentModel, no sense in reparsing
274                $cachedVersion !== Parsoid::defaultHTMLVersion()
275            ) {
276                $notHitReason = 'obsolete';
277                $output = null;
278            }
279        }
280
281        if ( $output && !$isOld ) {
282            $this->localCache->setField( $classCacheKey, $page->getLatest(), $output );
283        }
284
285        if ( $output ) {
286            $this->statsDataFactory->increment( "ParserOutputAccess.Cache.$useCache.hit" );
287        } else {
288            $this->statsDataFactory->increment( "ParserOutputAccess.Cache.$useCache.$notHitReason" );
289        }
290
291        return $output ?: null; // convert false to null
292    }
293
294    /**
295     * Returns the rendered output for the given page.
296     * Caching and concurrency control is applied.
297     *
298     * @param PageRecord $page
299     * @param ParserOptions $parserOptions
300     * @param RevisionRecord|null $revision
301     * @param int $options Bitfield using the OPT_XXX constants
302     *
303     * @return Status containing a ParserOutput if no error occurred.
304     *         Well known errors and warnings include the following messages:
305     *         - 'view-pool-dirty-output' (warning) The output is dirty (from a stale cache entry).
306     *         - 'view-pool-contention' (warning) Dirty output was returned immediately instead of
307     *           waiting to acquire a work lock (when "fast stale" mode is enabled in PoolCounter).
308     *         - 'view-pool-timeout' (warning) Dirty output was returned after failing to acquire
309     *           a work lock (got QUEUE_FULL or TIMEOUT from PoolCounter).
310     *         - 'pool-queuefull' (error) unable to acquire work lock, and no cached content found.
311     *         - 'pool-timeout' (error) unable to acquire work lock, and no cached content found.
312     *         - 'pool-servererror' (error) PoolCounterWork failed due to a lock service error.
313     *         - 'pool-unknownerror' (error) PoolCounterWork failed for an unknown reason.
314     *         - 'nopagetext' (error) The page does not exist
315     */
316    public function getParserOutput(
317        PageRecord $page,
318        ParserOptions $parserOptions,
319        ?RevisionRecord $revision = null,
320        int $options = 0
321    ): Status {
322        $error = $this->checkPreconditions( $page, $revision, $options );
323        if ( $error ) {
324            $this->statsDataFactory->increment( "ParserOutputAccess.Case.error" );
325            return $error;
326        }
327
328        $isOld = $revision && $revision->getId() !== $page->getLatest();
329        if ( $isOld ) {
330            $this->statsDataFactory->increment( 'ParserOutputAccess.Case.old' );
331        } else {
332            $this->statsDataFactory->increment( 'ParserOutputAccess.Case.current' );
333        }
334
335        if ( !( $options & self::OPT_NO_CHECK_CACHE ) ) {
336            $output = $this->getCachedParserOutput( $page, $parserOptions, $revision );
337            if ( $output ) {
338                return Status::newGood( $output );
339            }
340        }
341
342        if ( !$revision ) {
343            $revId = $page->getLatest();
344            $revision = $revId ? $this->revisionLookup->getRevisionById( $revId ) : null;
345
346            if ( !$revision ) {
347                $this->statsDataFactory->increment( "ParserOutputAccess.Status.norev" );
348                return Status::newFatal( 'missing-revision', $revId );
349            }
350        }
351
352        if ( $options & self::OPT_FOR_ARTICLE_VIEW ) {
353            $work = $this->newPoolWorkArticleView( $page, $parserOptions, $revision, $options );
354            /** @var Status $status */
355            $status = $work->execute();
356        } else {
357            $status = $this->renderRevision( $page, $parserOptions, $revision, $options );
358        }
359
360        $output = $status->getValue();
361        Assert::postcondition( $output || !$status->isOK(), 'Inconsistent status' );
362
363        if ( $output && !$isOld ) {
364            $primaryCache = $this->getPrimaryCache( $parserOptions );
365            $classCacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions );
366            $this->localCache->setField( $classCacheKey, $page->getLatest(), $output );
367        }
368
369        if ( $status->isGood() ) {
370            $this->statsDataFactory->increment( 'ParserOutputAccess.Status.good' );
371        } elseif ( $status->isOK() ) {
372            $this->statsDataFactory->increment( 'ParserOutputAccess.Status.ok' );
373        } else {
374            $this->statsDataFactory->increment( 'ParserOutputAccess.Status.error' );
375        }
376
377        return $status;
378    }
379
380    /**
381     * Render the given revision.
382     *
383     * This method will update the parser cache if appropriate, and will
384     * trigger a links update if OPT_LINKS_UPDATE is set.
385     *
386     * This method does not perform access checks, and will not load content
387     * from caches. The caller is assumed to have taken care of that.
388     *
389     * @see PoolWorkArticleView::renderRevision
390     */
391    private function renderRevision(
392        PageRecord $page,
393        ParserOptions $parserOptions,
394        RevisionRecord $revision,
395        int $options
396    ): Status {
397        $this->statsDataFactory->increment( 'ParserOutputAccess.PoolWork.None' );
398
399        $renderedRev = $this->revisionRenderer->getRenderedRevision(
400            $revision,
401            $parserOptions,
402            null,
403            [ 'audience' => RevisionRecord::RAW ]
404        );
405
406        $output = $renderedRev->getRevisionParserOutput();
407
408        if ( !( $options & self::OPT_NO_UPDATE_CACHE ) && $output->isCacheable() ) {
409            $useCache = $this->shouldUseCache( $page, $revision );
410
411            if ( $useCache === self::CACHE_PRIMARY ) {
412                $primaryCache = $this->getPrimaryCache( $parserOptions );
413                $primaryCache->save( $output, $page, $parserOptions );
414            } elseif ( $useCache === self::CACHE_SECONDARY ) {
415                $secondaryCache = $this->getSecondaryCache( $parserOptions );
416                $secondaryCache->save( $output, $revision, $parserOptions );
417            }
418        }
419
420        if ( $options & self::OPT_LINKS_UPDATE ) {
421            $this->wikiPageFactory->newFromTitle( $page )
422                ->triggerOpportunisticLinksUpdate( $output );
423        }
424
425        return Status::newGood( $output );
426    }
427
428    /**
429     * @param PageRecord $page
430     * @param RevisionRecord|null $revision
431     * @param int $options
432     *
433     * @return Status|null
434     */
435    private function checkPreconditions(
436        PageRecord $page,
437        ?RevisionRecord $revision = null,
438        int $options = 0
439    ): ?Status {
440        if ( !$page->exists() ) {
441            return Status::newFatal( 'nopagetext' );
442        }
443
444        if ( !( $options & self::OPT_NO_UPDATE_CACHE ) && $revision && !$revision->getId() ) {
445            throw new InvalidArgumentException(
446                'The revision does not have a known ID. Use OPT_NO_CACHE.'
447            );
448        }
449
450        if ( $revision && $revision->getPageId() !== $page->getId() ) {
451            throw new InvalidArgumentException(
452                'The revision does not belong to the given page.'
453            );
454        }
455
456        if ( $revision && !( $options & self::OPT_NO_AUDIENCE_CHECK ) ) {
457            // NOTE: If per-user checks are desired, the caller should perform them and
458            //       then set OPT_NO_AUDIENCE_CHECK if they passed.
459            if ( !$revision->audienceCan( RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_PUBLIC ) ) {
460                return Status::newFatal(
461                    'missing-revision-permission',
462                    $revision->getId(),
463                    $revision->getTimestamp(),
464                    $this->titleFormatter->getPrefixedDBkey( $page )
465                );
466            }
467        }
468
469        return null;
470    }
471
472    /**
473     * @param PageRecord $page
474     * @param ParserOptions $parserOptions
475     * @param RevisionRecord $revision
476     * @param int $options
477     *
478     * @return PoolCounterWork
479     */
480    protected function newPoolWorkArticleView(
481        PageRecord $page,
482        ParserOptions $parserOptions,
483        RevisionRecord $revision,
484        int $options
485    ): PoolCounterWork {
486        $useCache = $this->shouldUseCache( $page, $revision );
487
488        switch ( $useCache ) {
489            case self::CACHE_PRIMARY:
490                $this->statsDataFactory->increment( 'ParserOutputAccess.PoolWork.Current' );
491                $primaryCache = $this->getPrimaryCache( $parserOptions );
492                $parserCacheMetadata = $primaryCache->getMetadata( $page );
493                $cacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions,
494                    $parserCacheMetadata ? $parserCacheMetadata->getUsedOptions() : null
495                );
496
497                $workKey = $cacheKey . ':revid:' . $revision->getId();
498
499                return new PoolWorkArticleViewCurrent(
500                    $workKey,
501                    $page,
502                    $revision,
503                    $parserOptions,
504                    $this->revisionRenderer,
505                    $primaryCache,
506                    $this->lbFactory,
507                    $this->chronologyProtector,
508                    $this->loggerSpi,
509                    $this->wikiPageFactory,
510                    !( $options & self::OPT_NO_UPDATE_CACHE ),
511                    (bool)( $options & self::OPT_LINKS_UPDATE )
512                );
513
514            case self::CACHE_SECONDARY:
515                $this->statsDataFactory->increment( 'ParserOutputAccess.PoolWork.Old' );
516                $secondaryCache = $this->getSecondaryCache( $parserOptions );
517                $workKey = $secondaryCache->makeParserOutputKey( $revision, $parserOptions );
518                return new PoolWorkArticleViewOld(
519                    $workKey,
520                    $secondaryCache,
521                    $revision,
522                    $parserOptions,
523                    $this->revisionRenderer,
524                    $this->loggerSpi
525                );
526
527            default:
528                $this->statsDataFactory->increment( 'ParserOutputAccess.PoolWork.Uncached' );
529                $secondaryCache = $this->getSecondaryCache( $parserOptions );
530                $workKey = $secondaryCache->makeParserOutputKeyOptionalRevId( $revision, $parserOptions );
531                return new PoolWorkArticleView(
532                    $workKey,
533                    $revision,
534                    $parserOptions,
535                    $this->revisionRenderer,
536                    $this->loggerSpi
537                );
538        }
539
540        // unreachable
541    }
542
543    private function getPrimaryCache( ParserOptions $pOpts ): ParserCache {
544        if ( $pOpts->getUseParsoid() ) {
545            return $this->parserCacheFactory->getParserCache(
546                self::PARSOID_PCACHE_NAME
547            );
548        }
549
550        return $this->parserCacheFactory->getParserCache(
551            ParserCacheFactory::DEFAULT_NAME
552        );
553    }
554
555    private function getSecondaryCache( ParserOptions $pOpts ): RevisionOutputCache {
556        if ( $pOpts->getUseParsoid() ) {
557            return $this->parserCacheFactory->getRevisionOutputCache(
558                self::PARSOID_RCACHE_NAME
559            );
560        }
561
562        return $this->parserCacheFactory->getRevisionOutputCache(
563            ParserCacheFactory::DEFAULT_RCACHE_NAME
564        );
565    }
566
567}