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    /**
125     * In cases that an extension tries to get the same ParserOutput of
126     * the page right after it was parsed (T301310).
127     * @var MapCacheLRU<string,ParserOutput>
128     */
129    private MapCacheLRU $localCache;
130
131    private ParserCacheFactory $parserCacheFactory;
132    private RevisionLookup $revisionLookup;
133    private RevisionRenderer $revisionRenderer;
134    private IBufferingStatsdDataFactory $statsDataFactory;
135    private ILBFactory $lbFactory;
136    private ChronologyProtector $chronologyProtector;
137    private LoggerSpi $loggerSpi;
138    private WikiPageFactory $wikiPageFactory;
139    private TitleFormatter $titleFormatter;
140
141    public function __construct(
142        ParserCacheFactory $parserCacheFactory,
143        RevisionLookup $revisionLookup,
144        RevisionRenderer $revisionRenderer,
145        IBufferingStatsdDataFactory $statsDataFactory,
146        ILBFactory $lbFactory,
147        ChronologyProtector $chronologyProtector,
148        LoggerSpi $loggerSpi,
149        WikiPageFactory $wikiPageFactory,
150        TitleFormatter $titleFormatter
151    ) {
152        $this->parserCacheFactory = $parserCacheFactory;
153        $this->revisionLookup = $revisionLookup;
154        $this->revisionRenderer = $revisionRenderer;
155        $this->statsDataFactory = $statsDataFactory;
156        $this->lbFactory = $lbFactory;
157        $this->chronologyProtector = $chronologyProtector;
158        $this->loggerSpi = $loggerSpi;
159        $this->wikiPageFactory = $wikiPageFactory;
160        $this->titleFormatter = $titleFormatter;
161
162        $this->localCache = new MapCacheLRU( 10 );
163    }
164
165    /**
166     * Use a cache?
167     *
168     * @param PageRecord $page
169     * @param RevisionRecord|null $rev
170     *
171     * @return string One of the CACHE_XXX constants.
172     */
173    private function shouldUseCache(
174        PageRecord $page,
175        ?RevisionRecord $rev
176    ) {
177        if ( $rev && !$rev->getId() ) {
178            // The revision isn't from the database, so the output can't safely be cached.
179            return self::CACHE_NONE;
180        }
181
182        // NOTE: Keep in sync with ParserWikiPage::shouldCheckParserCache().
183        // NOTE: when we allow caching of old revisions in the future,
184        //       we must not allow caching of deleted revisions.
185
186        $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
187        if ( !$page->exists() || !$wikiPage->getContentHandler()->isParserCacheSupported() ) {
188            return self::CACHE_NONE;
189        }
190
191        $isOld = $rev && $rev->getId() !== $page->getLatest();
192        if ( !$isOld ) {
193            return self::CACHE_PRIMARY;
194        }
195
196        if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_PUBLIC ) ) {
197            // deleted/suppressed revision
198            return self::CACHE_NONE;
199        }
200
201        return self::CACHE_SECONDARY;
202    }
203
204    /**
205     * Returns the rendered output for the given page if it is present in the cache.
206     *
207     * @param PageRecord $page
208     * @param ParserOptions $parserOptions
209     * @param RevisionRecord|null $revision
210     * @param int $options Bitfield using the OPT_XXX constants
211     *
212     * @return ParserOutput|null
213     */
214    public function getCachedParserOutput(
215        PageRecord $page,
216        ParserOptions $parserOptions,
217        ?RevisionRecord $revision = null,
218        int $options = 0
219    ): ?ParserOutput {
220        $isOld = $revision && $revision->getId() !== $page->getLatest();
221        $useCache = $this->shouldUseCache( $page, $revision );
222        $primaryCache = $this->getPrimaryCache( $parserOptions );
223        $classCacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions );
224
225        if ( $useCache === self::CACHE_PRIMARY ) {
226            if ( $this->localCache->hasField( $classCacheKey, $page->getLatest() ) && !$isOld ) {
227                return $this->localCache->getField( $classCacheKey, $page->getLatest() );
228            }
229            $output = $primaryCache->get( $page, $parserOptions );
230        } elseif ( $useCache === self::CACHE_SECONDARY && $revision ) {
231            $secondaryCache = $this->getSecondaryCache( $parserOptions );
232            $output = $secondaryCache->get( $revision, $parserOptions );
233        } else {
234            $output = null;
235        }
236
237        $notHitReason = 'miss';
238        if (
239            $output && !( $options & self::OPT_IGNORE_PROFILE_VERSION ) &&
240            $parserOptions->getUseParsoid()
241        ) {
242            $pageBundleData = $output->getExtensionData(
243                PageBundleParserOutputConverter::PARSOID_PAGE_BUNDLE_KEY
244            );
245            // T333606: Force a reparse if the version coming from cache is not the default
246            $cachedVersion = $pageBundleData['version'] ?? null;
247            if (
248                $cachedVersion !== null && // T325137: BadContentModel, no sense in reparsing
249                $cachedVersion !== Parsoid::defaultHTMLVersion()
250            ) {
251                $notHitReason = 'obsolete';
252                $output = null;
253            }
254        }
255
256        if ( $output && !$isOld ) {
257            $this->localCache->setField( $classCacheKey, $page->getLatest(), $output );
258        }
259
260        if ( $output ) {
261            $this->statsDataFactory->increment( "ParserOutputAccess.Cache.$useCache.hit" );
262        } else {
263            $this->statsDataFactory->increment( "ParserOutputAccess.Cache.$useCache.$notHitReason" );
264        }
265
266        return $output ?: null; // convert false to null
267    }
268
269    /**
270     * Returns the rendered output for the given page.
271     * Caching and concurrency control is applied.
272     *
273     * @param PageRecord $page
274     * @param ParserOptions $parserOptions
275     * @param RevisionRecord|null $revision
276     * @param int $options Bitfield using the OPT_XXX constants
277     *
278     * @return Status containing a ParserOutput if no error occurred.
279     *         Well known errors and warnings include the following messages:
280     *         - 'view-pool-dirty-output' (warning) The output is dirty (from a stale cache entry).
281     *         - 'view-pool-contention' (warning) Dirty output was returned immediately instead of
282     *           waiting to acquire a work lock (when "fast stale" mode is enabled in PoolCounter).
283     *         - 'view-pool-timeout' (warning) Dirty output was returned after failing to acquire
284     *           a work lock (got QUEUE_FULL or TIMEOUT from PoolCounter).
285     *         - 'pool-queuefull' (error) unable to acquire work lock, and no cached content found.
286     *         - 'pool-timeout' (error) unable to acquire work lock, and no cached content found.
287     *         - 'pool-servererror' (error) PoolCounterWork failed due to a lock service error.
288     *         - 'pool-unknownerror' (error) PoolCounterWork failed for an unknown reason.
289     *         - 'nopagetext' (error) The page does not exist
290     */
291    public function getParserOutput(
292        PageRecord $page,
293        ParserOptions $parserOptions,
294        ?RevisionRecord $revision = null,
295        int $options = 0
296    ): Status {
297        $error = $this->checkPreconditions( $page, $revision, $options );
298        if ( $error ) {
299            $this->statsDataFactory->increment( "ParserOutputAccess.Case.error" );
300            return $error;
301        }
302
303        $isOld = $revision && $revision->getId() !== $page->getLatest();
304        if ( $isOld ) {
305            $this->statsDataFactory->increment( 'ParserOutputAccess.Case.old' );
306        } else {
307            $this->statsDataFactory->increment( 'ParserOutputAccess.Case.current' );
308        }
309
310        if ( !( $options & self::OPT_NO_CHECK_CACHE ) ) {
311            $output = $this->getCachedParserOutput( $page, $parserOptions, $revision );
312            if ( $output ) {
313                return Status::newGood( $output );
314            }
315        }
316
317        if ( !$revision ) {
318            $revId = $page->getLatest();
319            $revision = $revId ? $this->revisionLookup->getRevisionById( $revId ) : null;
320
321            if ( !$revision ) {
322                $this->statsDataFactory->increment( "ParserOutputAccess.Status.norev" );
323                return Status::newFatal( 'missing-revision', $revId );
324            }
325        }
326
327        if ( $options & self::OPT_FOR_ARTICLE_VIEW ) {
328            $work = $this->newPoolWorkArticleView( $page, $parserOptions, $revision, $options );
329            /** @var Status $status */
330            $status = $work->execute();
331        } else {
332            $status = $this->renderRevision( $page, $parserOptions, $revision, $options );
333        }
334
335        $output = $status->getValue();
336        Assert::postcondition( $output || !$status->isOK(), 'Inconsistent status' );
337
338        if ( $output && !$isOld ) {
339            $primaryCache = $this->getPrimaryCache( $parserOptions );
340            $classCacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions );
341            $this->localCache->setField( $classCacheKey, $page->getLatest(), $output );
342        }
343
344        if ( $status->isGood() ) {
345            $this->statsDataFactory->increment( 'ParserOutputAccess.Status.good' );
346        } elseif ( $status->isOK() ) {
347            $this->statsDataFactory->increment( 'ParserOutputAccess.Status.ok' );
348        } else {
349            $this->statsDataFactory->increment( 'ParserOutputAccess.Status.error' );
350        }
351
352        return $status;
353    }
354
355    /**
356     * Render the given revision.
357     *
358     * This method will update the parser cache if appropriate, and will
359     * trigger a links update if OPT_LINKS_UPDATE is set.
360     *
361     * This method does not perform access checks, and will not load content
362     * from caches. The caller is assumed to have taken care of that.
363     *
364     * @see PoolWorkArticleView::renderRevision
365     */
366    private function renderRevision(
367        PageRecord $page,
368        ParserOptions $parserOptions,
369        RevisionRecord $revision,
370        int $options
371    ): Status {
372        $this->statsDataFactory->increment( 'ParserOutputAccess.PoolWork.None' );
373
374        $renderedRev = $this->revisionRenderer->getRenderedRevision(
375            $revision,
376            $parserOptions,
377            null,
378            [ 'audience' => RevisionRecord::RAW ]
379        );
380
381        $output = $renderedRev->getRevisionParserOutput();
382
383        if ( !( $options & self::OPT_NO_UPDATE_CACHE ) && $output->isCacheable() ) {
384            $useCache = $this->shouldUseCache( $page, $revision );
385
386            if ( $useCache === self::CACHE_PRIMARY ) {
387                $primaryCache = $this->getPrimaryCache( $parserOptions );
388                $primaryCache->save( $output, $page, $parserOptions );
389            } elseif ( $useCache === self::CACHE_SECONDARY ) {
390                $secondaryCache = $this->getSecondaryCache( $parserOptions );
391                $secondaryCache->save( $output, $revision, $parserOptions );
392            }
393        }
394
395        if ( $options & self::OPT_LINKS_UPDATE ) {
396            $this->wikiPageFactory->newFromTitle( $page )
397                ->triggerOpportunisticLinksUpdate( $output );
398        }
399
400        return Status::newGood( $output );
401    }
402
403    /**
404     * @param PageRecord $page
405     * @param RevisionRecord|null $revision
406     * @param int $options
407     *
408     * @return Status|null
409     */
410    private function checkPreconditions(
411        PageRecord $page,
412        ?RevisionRecord $revision = null,
413        int $options = 0
414    ): ?Status {
415        if ( !$page->exists() ) {
416            return Status::newFatal( 'nopagetext' );
417        }
418
419        if ( !( $options & self::OPT_NO_UPDATE_CACHE ) && $revision && !$revision->getId() ) {
420            throw new InvalidArgumentException(
421                'The revision does not have a known ID. Use OPT_NO_CACHE.'
422            );
423        }
424
425        if ( $revision && $revision->getPageId() !== $page->getId() ) {
426            throw new InvalidArgumentException(
427                'The revision does not belong to the given page.'
428            );
429        }
430
431        if ( $revision && !( $options & self::OPT_NO_AUDIENCE_CHECK ) ) {
432            // NOTE: If per-user checks are desired, the caller should perform them and
433            //       then set OPT_NO_AUDIENCE_CHECK if they passed.
434            if ( !$revision->audienceCan( RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_PUBLIC ) ) {
435                return Status::newFatal(
436                    'missing-revision-permission',
437                    $revision->getId(),
438                    $revision->getTimestamp(),
439                    $this->titleFormatter->getPrefixedDBkey( $page )
440                );
441            }
442        }
443
444        return null;
445    }
446
447    /**
448     * @param PageRecord $page
449     * @param ParserOptions $parserOptions
450     * @param RevisionRecord $revision
451     * @param int $options
452     *
453     * @return PoolCounterWork
454     */
455    protected function newPoolWorkArticleView(
456        PageRecord $page,
457        ParserOptions $parserOptions,
458        RevisionRecord $revision,
459        int $options
460    ): PoolCounterWork {
461        $useCache = $this->shouldUseCache( $page, $revision );
462
463        switch ( $useCache ) {
464            case self::CACHE_PRIMARY:
465                $this->statsDataFactory->increment( 'ParserOutputAccess.PoolWork.Current' );
466                $primaryCache = $this->getPrimaryCache( $parserOptions );
467                $parserCacheMetadata = $primaryCache->getMetadata( $page );
468                $cacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions,
469                    $parserCacheMetadata ? $parserCacheMetadata->getUsedOptions() : null
470                );
471
472                $workKey = $cacheKey . ':revid:' . $revision->getId();
473
474                return new PoolWorkArticleViewCurrent(
475                    $workKey,
476                    $page,
477                    $revision,
478                    $parserOptions,
479                    $this->revisionRenderer,
480                    $primaryCache,
481                    $this->lbFactory,
482                    $this->chronologyProtector,
483                    $this->loggerSpi,
484                    $this->wikiPageFactory,
485                    !( $options & self::OPT_NO_UPDATE_CACHE ),
486                    (bool)( $options & self::OPT_LINKS_UPDATE )
487                );
488
489            case self::CACHE_SECONDARY:
490                $this->statsDataFactory->increment( 'ParserOutputAccess.PoolWork.Old' );
491                $secondaryCache = $this->getSecondaryCache( $parserOptions );
492                $workKey = $secondaryCache->makeParserOutputKey( $revision, $parserOptions );
493                return new PoolWorkArticleViewOld(
494                    $workKey,
495                    $secondaryCache,
496                    $revision,
497                    $parserOptions,
498                    $this->revisionRenderer,
499                    $this->loggerSpi
500                );
501
502            default:
503                $this->statsDataFactory->increment( 'ParserOutputAccess.PoolWork.Uncached' );
504                $secondaryCache = $this->getSecondaryCache( $parserOptions );
505                $workKey = $secondaryCache->makeParserOutputKeyOptionalRevId( $revision, $parserOptions );
506                return new PoolWorkArticleView(
507                    $workKey,
508                    $revision,
509                    $parserOptions,
510                    $this->revisionRenderer,
511                    $this->loggerSpi
512                );
513        }
514
515        // unreachable
516    }
517
518    private function getPrimaryCache( ParserOptions $pOpts ): ParserCache {
519        if ( $pOpts->getUseParsoid() ) {
520            return $this->parserCacheFactory->getParserCache(
521                self::PARSOID_PCACHE_NAME
522            );
523        }
524
525        return $this->parserCacheFactory->getParserCache(
526            ParserCacheFactory::DEFAULT_NAME
527        );
528    }
529
530    private function getSecondaryCache( ParserOptions $pOpts ): RevisionOutputCache {
531        if ( $pOpts->getUseParsoid() ) {
532            return $this->parserCacheFactory->getRevisionOutputCache(
533                self::PARSOID_RCACHE_NAME
534            );
535        }
536
537        return $this->parserCacheFactory->getRevisionOutputCache(
538            ParserCacheFactory::DEFAULT_RCACHE_NAME
539        );
540    }
541
542}