Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.03% covered (success)
95.03%
287 / 302
75.00% covered (warning)
75.00%
9 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ParserOutputAccess
95.03% covered (success)
95.03%
287 / 302
75.00% covered (warning)
75.00%
9 / 12
88
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
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
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
16
 getFallbackOutputForLatest
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 getParserOutput
100.00% covered (success)
100.00%
65 / 65
100.00% covered (success)
100.00%
1 / 1
15
 renderRevision
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
16
 checkPreconditions
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
10
 newPoolWork
93.94% covered (success)
93.94%
62 / 66
0.00% covered (danger)
0.00%
0 / 1
8.01
 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
 startOperationSpan
23.08% covered (danger)
23.08%
3 / 13
0.00% covered (danger)
0.00%
0 / 1
7.10
 clearLocalCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 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 InvalidArgumentException;
23use MediaWiki\MainConfigNames;
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Parser\ParserCache;
26use MediaWiki\Parser\ParserCacheFactory;
27use MediaWiki\Parser\ParserOptions;
28use MediaWiki\Parser\ParserOutput;
29use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
30use MediaWiki\Parser\RevisionOutputCache;
31use MediaWiki\PoolCounter\PoolCounterFactory;
32use MediaWiki\PoolCounter\PoolCounterWork;
33use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
34use MediaWiki\Revision\RevisionLookup;
35use MediaWiki\Revision\RevisionRecord;
36use MediaWiki\Revision\RevisionRenderer;
37use MediaWiki\Status\Status;
38use MediaWiki\Title\TitleFormatter;
39use MediaWiki\WikiMap\WikiMap;
40use Psr\Log\LoggerInterface;
41use Wikimedia\Assert\Assert;
42use Wikimedia\MapCacheLRU\MapCacheLRU;
43use Wikimedia\Parsoid\Parsoid;
44use Wikimedia\Rdbms\ChronologyProtector;
45use Wikimedia\Stats\StatsFactory;
46use Wikimedia\Telemetry\SpanInterface;
47use Wikimedia\Telemetry\TracerInterface;
48
49/**
50 * Service for getting rendered output of a given page.
51 *
52 * This is a high level service, encapsulating concerns like caching
53 * and stampede protection via PoolCounter.
54 *
55 * @since 1.36
56 * @ingroup Page
57 */
58class ParserOutputAccess {
59
60    /** @internal */
61    public const PARSOID_PCACHE_NAME = 'parsoid-' . ParserCacheFactory::DEFAULT_NAME;
62
63    /** @internal */
64    public const PARSOID_RCACHE_NAME = 'parsoid-' . ParserCacheFactory::DEFAULT_RCACHE_NAME;
65
66    /**
67     * @var int Do not check the cache before parsing (force parse)
68     */
69    public const OPT_NO_CHECK_CACHE = 1;
70
71    /** @var int Alias for NO_CHECK_CACHE */
72    public const OPT_FORCE_PARSE = self::OPT_NO_CHECK_CACHE;
73
74    /**
75     * @var int Do not update the cache after parsing.
76     */
77    public const OPT_NO_UPDATE_CACHE = 2;
78
79    /**
80     * @var int Bypass audience check for deleted/suppressed revisions.
81     *      The caller is responsible for ensuring that unauthorized access is prevented.
82     *      If not set, output generation will fail if the revision is not public.
83     */
84    public const OPT_NO_AUDIENCE_CHECK = 4;
85
86    /**
87     * @var int Do not check the cache before parsing,
88     *      and do not update the cache after parsing (not cacheable).
89     */
90    public const OPT_NO_CACHE = self::OPT_NO_UPDATE_CACHE | self::OPT_NO_CHECK_CACHE;
91
92    /**
93     * @var int Do perform an opportunistic LinksUpdate on cache miss
94     * @since 1.41
95     */
96    public const OPT_LINKS_UPDATE = 8;
97
98    /**
99     * Apply page view semantics. This relaxes some guarantees, specifically:
100     * - Use PoolCounter for stampede protection, causing the request to
101     *   block until another process has finished rendering the content.
102     * - Allow stale parser output to be returned to prevent long waits for
103     *   slow renders.
104     * - Allow cacheable placeholder output to be returned when PoolCounter
105     *   fails to obtain a lock. See the PoolCounterConf setting for details.
106     *
107     * @see Bug T352837
108     * @since 1.42
109     */
110    public const OPT_FOR_ARTICLE_VIEW = 16;
111
112    /**
113     * @var int Ignore the profile version of the result from the cache.
114     *      Otherwise, if it's not Parsoid's default, it will be invalidated.
115     */
116    public const OPT_IGNORE_PROFILE_VERSION = 128;
117
118    /** @var string Do not read or write any cache */
119    private const CACHE_NONE = 'none';
120
121    /** @var string Use primary cache */
122    private const CACHE_PRIMARY = 'primary';
123
124    /** @var string Use secondary cache */
125    private const CACHE_SECONDARY = 'secondary';
126
127    /**
128     * In cases that an extension tries to get the same ParserOutput of
129     * the page right after it was parsed (T301310).
130     * @var MapCacheLRU<string,ParserOutput>
131     */
132    private MapCacheLRU $localCache;
133
134    private ParserCacheFactory $parserCacheFactory;
135    private RevisionLookup $revisionLookup;
136    private RevisionRenderer $revisionRenderer;
137    private StatsFactory $statsFactory;
138    private ChronologyProtector $chronologyProtector;
139    private LoggerInterface $logger;
140    private WikiPageFactory $wikiPageFactory;
141    private TitleFormatter $titleFormatter;
142    private TracerInterface $tracer;
143    private PoolCounterFactory $poolCounterFactory;
144
145    public function __construct(
146        ParserCacheFactory $parserCacheFactory,
147        RevisionLookup $revisionLookup,
148        RevisionRenderer $revisionRenderer,
149        StatsFactory $statsFactory,
150        ChronologyProtector $chronologyProtector,
151        LoggerInterface $logger,
152        WikiPageFactory $wikiPageFactory,
153        TitleFormatter $titleFormatter,
154        TracerInterface $tracer,
155        PoolCounterFactory $poolCounterFactory
156    ) {
157        $this->parserCacheFactory = $parserCacheFactory;
158        $this->revisionLookup = $revisionLookup;
159        $this->revisionRenderer = $revisionRenderer;
160        $this->statsFactory = $statsFactory;
161        $this->chronologyProtector = $chronologyProtector;
162        $this->logger = $logger;
163        $this->wikiPageFactory = $wikiPageFactory;
164        $this->titleFormatter = $titleFormatter;
165        $this->tracer = $tracer;
166        $this->poolCounterFactory = $poolCounterFactory;
167
168        $this->localCache = new MapCacheLRU( 10 );
169    }
170
171    /**
172     * Use a cache?
173     *
174     * @param PageRecord $page
175     * @param RevisionRecord|null $rev
176     *
177     * @return string One of the CACHE_XXX constants.
178     */
179    private function shouldUseCache(
180        PageRecord $page,
181        ?RevisionRecord $rev
182    ) {
183        if ( $rev && !$rev->getId() ) {
184            // The revision isn't from the database, so the output can't safely be cached.
185            return self::CACHE_NONE;
186        }
187
188        // NOTE: Keep in sync with ParserWikiPage::shouldCheckParserCache().
189        // NOTE: when we allow caching of old revisions in the future,
190        //       we must not allow caching of deleted revisions.
191
192        $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
193        if ( !$page->exists() || !$wikiPage->getContentHandler()->isParserCacheSupported() ) {
194            return self::CACHE_NONE;
195        }
196
197        $isOld = $rev && $rev->getId() !== $page->getLatest();
198        if ( !$isOld ) {
199            return self::CACHE_PRIMARY;
200        }
201
202        if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_PUBLIC ) ) {
203            // deleted/suppressed revision
204            return self::CACHE_NONE;
205        }
206
207        return self::CACHE_SECONDARY;
208    }
209
210    /**
211     * Get the rendered output for the given page if it is present in the cache.
212     *
213     * @param PageRecord $page
214     * @param ParserOptions $parserOptions
215     * @param RevisionRecord|null $revision
216     * @param int $options Bitfield using the OPT_XXX constants
217     * @return ParserOutput|null
218     */
219    public function getCachedParserOutput(
220        PageRecord $page,
221        ParserOptions $parserOptions,
222        ?RevisionRecord $revision = null,
223        int $options = 0
224    ): ?ParserOutput {
225        $span = $this->startOperationSpan( __FUNCTION__, $page, $revision );
226        $isOld = $revision && $revision->getId() !== $page->getLatest();
227        $useCache = $this->shouldUseCache( $page, $revision );
228        $primaryCache = $this->getPrimaryCache( $parserOptions );
229        $classCacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions );
230
231        if ( $useCache === self::CACHE_PRIMARY ) {
232            if ( $this->localCache->hasField( $classCacheKey, $page->getLatest() ) && !$isOld ) {
233                return $this->localCache->getField( $classCacheKey, $page->getLatest() );
234            }
235            $output = $primaryCache->get( $page, $parserOptions );
236        } elseif ( $useCache === self::CACHE_SECONDARY && $revision ) {
237            $secondaryCache = $this->getSecondaryCache( $parserOptions );
238            $output = $secondaryCache->get( $revision, $parserOptions );
239        } else {
240            $output = null;
241        }
242
243        $statType = $statReason = $output ? 'hit' : 'miss';
244
245        if (
246            $output && !( $options & self::OPT_IGNORE_PROFILE_VERSION ) &&
247            $parserOptions->getUseParsoid()
248        ) {
249            $pageBundleData = $output->getExtensionData(
250                PageBundleParserOutputConverter::PARSOID_PAGE_BUNDLE_KEY
251            );
252            // T333606: Force a reparse if the version coming from cache is not the default
253            $cachedVersion = $pageBundleData['version'] ?? null;
254            if (
255                $cachedVersion !== null && // T325137: BadContentModel, no sense in reparsing
256                $cachedVersion !== Parsoid::defaultHTMLVersion()
257            ) {
258                $statType = 'miss';
259                $statReason = 'obsolete';
260                $output = null;
261            }
262        }
263
264        if ( $output && !$isOld ) {
265            $this->localCache->setField( $classCacheKey, $page->getLatest(), $output );
266        }
267
268        $this->statsFactory
269            ->getCounter( 'parseroutputaccess_cache_total' )
270            ->setLabel( 'cache', $useCache )
271            ->setLabel( 'reason', $statReason )
272            ->setLabel( 'type', $statType )
273            ->copyToStatsdAt( "ParserOutputAccess.Cache.$useCache.$statReason" )
274            ->increment();
275
276        return $output ?: null; // convert false to null
277    }
278
279    /**
280     * Fallback for use with PoolCounterWork.
281     * Returns stale cached output if appropriate.
282     *
283     * @return Status|false
284     */
285    private function getFallbackOutputForLatest(
286        PageRecord $page,
287        ParserOptions $parserOptions,
288        string $workKey,
289        bool $fast
290    ) {
291        $parserOutput = $this->getPrimaryCache( $parserOptions )
292            ->getDirty( $page, $parserOptions );
293
294        if ( !$parserOutput ) {
295            $this->logger->info( 'dirty missing' );
296            return false;
297        }
298
299        if ( $fast ) {
300            // If this user recently made DB changes, then don't eagerly serve stale output,
301            // so that users generally see their own edits after page save.
302            //
303            // If PoolCounter is overloaded, we may end up here a second time (with fast=false),
304            // in which case we will serve a stale fallback then.
305            //
306            // Note that CP reports anything in the last 10 seconds from the same client,
307            // including to other pages and other databases, so we bias towards avoiding
308            // fast-stale responses for several seconds after saving an edit.
309            if ( $this->chronologyProtector->getTouched() ) {
310                $this->logger->info(
311                    'declining fast-fallback to stale output since ChronologyProtector ' .
312                    'reports the client recently made changes',
313                    [ 'workKey' => $workKey ]
314                );
315                // Forget this ParserOutput -- we will request it again if
316                // necessary in slow mode. There might be a newer entry
317                // available by that time.
318                return false;
319            }
320        }
321
322        $this->logger->info( $fast ? 'fast dirty output' : 'dirty output', [ 'workKey' => $workKey ] );
323
324        $status = Status::newGood( $parserOutput );
325        $status->warning( 'view-pool-dirty-output' );
326        $status->warning( $fast ? 'view-pool-contention' : 'view-pool-overload' );
327        return $status;
328    }
329
330    /**
331     * Returns the rendered output for the given page.
332     * Caching and concurrency control is applied.
333     *
334     * @param PageRecord $page
335     * @param ParserOptions $parserOptions
336     * @param RevisionRecord|null $revision
337     * @param int $options Bitfield using the OPT_XXX constants
338     *
339     * @return Status containing a ParserOutput if no error occurred.
340     *         Well known errors and warnings include the following messages:
341     *         - 'view-pool-dirty-output' (warning) The output is dirty (from a stale cache entry).
342     *         - 'view-pool-contention' (warning) Dirty output was returned immediately instead of
343     *           waiting to acquire a work lock (when "fast stale" mode is enabled in PoolCounter).
344     *         - 'view-pool-timeout' (warning) Dirty output was returned after failing to acquire
345     *           a work lock (got QUEUE_FULL or TIMEOUT from PoolCounter).
346     *         - 'pool-queuefull' (error) unable to acquire work lock, and no cached content found.
347     *         - 'pool-timeout' (error) unable to acquire work lock, and no cached content found.
348     *         - 'pool-servererror' (error) PoolCounterWork failed due to a lock service error.
349     *         - 'pool-unknownerror' (error) PoolCounterWork failed for an unknown reason.
350     *         - 'nopagetext' (error) The page does not exist
351     */
352    public function getParserOutput(
353        PageRecord $page,
354        ParserOptions $parserOptions,
355        ?RevisionRecord $revision = null,
356        int $options = 0
357    ): Status {
358        $span = $this->startOperationSpan( __FUNCTION__, $page, $revision );
359        $error = $this->checkPreconditions( $page, $revision, $options );
360        if ( $error ) {
361            $this->statsFactory
362                ->getCounter( 'parseroutputaccess_case' )
363                ->setLabel( 'case', 'error' )
364                ->copyToStatsdAt( 'ParserOutputAccess.Case.error' )
365                ->increment();
366            return $error;
367        }
368
369        $isOld = $revision && $revision->getId() !== $page->getLatest();
370        if ( $isOld ) {
371            $this->statsFactory
372                ->getCounter( 'parseroutputaccess_case' )
373                ->setLabel( 'case', 'old' )
374                ->copyToStatsdAt( 'ParserOutputAccess.Case.old' )
375                ->increment();
376        } else {
377            $this->statsFactory
378                ->getCounter( 'parseroutputaccess_case' )
379                ->setLabel( 'case', 'current' )
380                ->copyToStatsdAt( 'ParserOutputAccess.Case.current' )
381                ->increment();
382        }
383
384        if ( !( $options & self::OPT_NO_CHECK_CACHE ) ) {
385            $output = $this->getCachedParserOutput( $page, $parserOptions, $revision );
386            if ( $output ) {
387                return Status::newGood( $output );
388            }
389        }
390
391        if ( !$revision ) {
392            $revId = $page->getLatest();
393            $revision = $revId ? $this->revisionLookup->getRevisionById( $revId ) : null;
394
395            if ( !$revision ) {
396                $this->statsFactory
397                    ->getCounter( 'parseroutputaccess_status' )
398                    ->setLabel( 'status', 'norev' )
399                    ->copyToStatsdAt( "ParserOutputAccess.Status.norev" )
400                    ->increment();
401                return Status::newFatal( 'missing-revision', $revId );
402            }
403        }
404
405        if ( $options & self::OPT_FOR_ARTICLE_VIEW ) {
406            $work = $this->newPoolWork( $page, $parserOptions, $revision, $options );
407            /** @var Status $status */
408            $status = $work->execute();
409        } else {
410            // XXX: we could try harder to reuse a cache lookup above to
411            // provide the $previous argument here
412            $this->statsFactory->getCounter( 'parseroutputaccess_render_total' )
413                ->setLabel( 'pool', 'none' )
414                ->setLabel( 'cache', self::CACHE_NONE )
415                ->copyToStatsdAt( 'ParserOutputAccess.PoolWork.None' )
416                ->increment();
417
418            $status = $this->renderRevision( $page, $parserOptions, $revision, $options, null );
419        }
420
421        $output = $status->getValue();
422        Assert::postcondition( $output || !$status->isOK(), 'Inconsistent status' );
423
424        if ( $output && !$isOld ) {
425            $primaryCache = $this->getPrimaryCache( $parserOptions );
426            $classCacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions );
427            $this->localCache->setField( $classCacheKey, $page->getLatest(), $output );
428        }
429
430        if ( $status->isGood() ) {
431            $this->statsFactory->getCounter( 'parseroutputaccess_status' )
432                ->setLabel( 'status', 'good' )
433                ->copyToStatsdAt( 'ParserOutputAccess.Status.good' )
434                ->increment();
435        } elseif ( $status->isOK() ) {
436            $this->statsFactory->getCounter( 'parseroutputaccess_status' )
437                ->setLabel( 'status', 'ok' )
438                ->copyToStatsdAt( 'ParserOutputAccess.Status.ok' )
439                ->increment();
440        } else {
441            $this->statsFactory->getCounter( 'parseroutputaccess_status' )
442                ->setLabel( 'status', 'error' )
443                ->copyToStatsdAt( 'ParserOutputAccess.Status.error' )
444                ->increment();
445        }
446
447        return $status;
448    }
449
450    /**
451     * Render the given revision.
452     *
453     * This method will update the parser cache if appropriate, and will
454     * trigger a links update if OPT_LINKS_UPDATE is set.
455     *
456     * This method does not perform access checks, and will not load content
457     * from caches. The caller is assumed to have taken care of that.
458     *
459     * Where possible, pass in a $previousOutput, which will prevent an
460     * unnecessary double-lookup in the cache.
461     *
462     * @see PoolWorkArticleView::renderRevision
463     */
464    private function renderRevision(
465        PageRecord $page,
466        ParserOptions $parserOptions,
467        RevisionRecord $revision,
468        int $options,
469        ?ParserOutput $previousOutput = null
470    ): Status {
471        $span = $this->startOperationSpan( __FUNCTION__, $page, $revision );
472
473        $isCurrent = $revision->getId() === $page->getLatest();
474        $useCache = $this->shouldUseCache( $page, $revision );
475
476        // T371713: Temporary statistics collection code to determine
477        // feasibility of Parsoid selective update
478        $sampleRate = MediaWikiServices::getInstance()->getMainConfig()->get(
479            MainConfigNames::ParsoidSelectiveUpdateSampleRate
480        );
481        $doSample = ( $sampleRate && mt_rand( 1, $sampleRate ) === 1 );
482
483        if ( $previousOutput === null && ( $doSample || $parserOptions->getUseParsoid() ) ) {
484            // If $useCache === self::CACHE_SECONDARY we could potentially
485            // try to reuse the parse of $revision-1 from the secondary cache,
486            // but it is likely those template transclusions are out of date.
487            // Try to reuse the template transclusions from the most recent
488            // parse, which are more likely to reflect the current template.
489            if ( !( $options & self::OPT_NO_CHECK_CACHE ) ) {
490                $previousOutput = $this->getPrimaryCache( $parserOptions )->getDirty( $page, $parserOptions ) ?: null;
491            }
492        }
493
494        $renderedRev = $this->revisionRenderer->getRenderedRevision(
495            $revision,
496            $parserOptions,
497            null,
498            [
499                'audience' => RevisionRecord::RAW,
500                'previous-output' => $previousOutput,
501            ]
502        );
503
504        $output = $renderedRev->getRevisionParserOutput();
505
506        if ( $doSample ) {
507            $labels = [
508                'source' => 'ParserOutputAccess',
509                'type' => $previousOutput === null ? 'full' : 'selective',
510                'reason' => $parserOptions->getRenderReason(),
511                'parser' => $parserOptions->getUseParsoid() ? 'parsoid' : 'legacy',
512                'opportunistic' => 'false',
513                'wiki' => WikiMap::getCurrentWikiId(),
514                'model' => $revision->getMainContentModel(),
515            ];
516            $this->statsFactory
517                ->getCounter( 'ParserCache_selective_total' )
518                ->setLabels( $labels )
519                ->increment();
520            $this->statsFactory
521                ->getCounter( 'ParserCache_selective_cpu_seconds' )
522                ->setLabels( $labels )
523                ->incrementBy( $output->getTimeProfile( 'cpu' ) );
524        }
525
526        if ( !( $options & self::OPT_NO_UPDATE_CACHE ) && $output->isCacheable() ) {
527            if ( $useCache === self::CACHE_PRIMARY ) {
528                $primaryCache = $this->getPrimaryCache( $parserOptions );
529                $primaryCache->save( $output, $page, $parserOptions );
530            } elseif ( $useCache === self::CACHE_SECONDARY ) {
531                $secondaryCache = $this->getSecondaryCache( $parserOptions );
532                $secondaryCache->save( $output, $revision, $parserOptions );
533            }
534        }
535
536        if ( $isCurrent && ( $options & self::OPT_LINKS_UPDATE ) ) {
537            $this->wikiPageFactory->newFromTitle( $page )
538                ->triggerOpportunisticLinksUpdate( $output );
539        }
540
541        return Status::newGood( $output );
542    }
543
544    /**
545     * @param PageRecord $page
546     * @param RevisionRecord|null $revision
547     * @param int $options
548     *
549     * @return Status|null
550     */
551    private function checkPreconditions(
552        PageRecord $page,
553        ?RevisionRecord $revision = null,
554        int $options = 0
555    ): ?Status {
556        if ( !$page->exists() ) {
557            return Status::newFatal( 'nopagetext' );
558        }
559
560        if ( !( $options & self::OPT_NO_UPDATE_CACHE ) && $revision && !$revision->getId() ) {
561            throw new InvalidArgumentException(
562                'The revision does not have a known ID. Use OPT_NO_CACHE.'
563            );
564        }
565
566        if ( $revision && $revision->getPageId() !== $page->getId() ) {
567            throw new InvalidArgumentException(
568                'The revision does not belong to the given page.'
569            );
570        }
571
572        if ( $revision && !( $options & self::OPT_NO_AUDIENCE_CHECK ) ) {
573            // NOTE: If per-user checks are desired, the caller should perform them and
574            //       then set OPT_NO_AUDIENCE_CHECK if they passed.
575            if ( !$revision->audienceCan( RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_PUBLIC ) ) {
576                return Status::newFatal(
577                    'missing-revision-permission',
578                    $revision->getId(),
579                    $revision->getTimestamp(),
580                    $this->titleFormatter->getPrefixedDBkey( $page )
581                );
582            }
583        }
584
585        return null;
586    }
587
588    protected function newPoolWork(
589        PageRecord $page,
590        ParserOptions $parserOptions,
591        RevisionRecord $revision,
592        int $options
593    ): PoolCounterWork {
594        // Default behavior (no caching)
595        $callbacks = [
596            'doWork' => function () use ( $page, $parserOptions, $revision, $options ) {
597                return $this->renderRevision(
598                    $page,
599                    $parserOptions,
600                    $revision,
601                    $options
602                );
603            },
604            'doCachedWork' => static function () {
605                // uncached
606                return false;
607            },
608            'fallback' => static function ( $fast ) {
609                // no fallback
610                return false;
611            },
612            'error' => static function ( $status ) {
613                return $status;
614            }
615        ];
616
617        $useCache = $this->shouldUseCache( $page, $revision );
618
619        $statCacheLabelLegacy = [
620            self::CACHE_PRIMARY => 'Current',
621            self::CACHE_SECONDARY => 'Old',
622        ][$useCache] ?? 'Uncached';
623
624        $this->statsFactory->getCounter( 'parseroutputaccess_render_total' )
625            ->setLabel( 'pool', 'articleview' )
626            ->setLabel( 'cache', $useCache )
627            ->copyToStatsdAt( "ParserOutputAccess.PoolWork.$statCacheLabelLegacy" )
628            ->increment();
629
630        switch ( $useCache ) {
631            case self::CACHE_PRIMARY:
632                $primaryCache = $this->getPrimaryCache( $parserOptions );
633                $parserCacheMetadata = $primaryCache->getMetadata( $page );
634                $cacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions,
635                    $parserCacheMetadata ? $parserCacheMetadata->getUsedOptions() : null
636                );
637
638                $workKey = $cacheKey . ':revid:' . $revision->getId();
639
640                $callbacks['doCachedWork'] =
641                    function () use ( $primaryCache, $page, $parserOptions ) {
642                        $parserOutput = $primaryCache->get( $page, $parserOptions );
643
644                        $poolType = 'ArticleView';
645                        $status = ( $parserOutput ? 'hit' : 'miss' );
646                        $this->logger->debug( "Render for pool $poolType got a $status from ParserCache" );
647
648                        return $parserOutput ? Status::newGood( $parserOutput ) : false;
649                    };
650
651                $callbacks['fallback'] =
652                    function ( $fast ) use ( $page, $parserOptions, $workKey ) {
653                        return $this->getFallbackOutputForLatest(
654                            $page, $parserOptions, $workKey, $fast
655                        );
656                    };
657
658                break;
659
660            case self::CACHE_SECONDARY:
661                $secondaryCache = $this->getSecondaryCache( $parserOptions );
662                $workKey = $secondaryCache->makeParserOutputKey( $revision, $parserOptions );
663
664                $callbacks['doCachedWork'] =
665                    static function () use ( $secondaryCache, $revision, $parserOptions ) {
666                        $parserOutput = $secondaryCache->get( $revision, $parserOptions );
667
668                        return $parserOutput ? Status::newGood( $parserOutput ) : false;
669                    };
670
671                break;
672
673            default:
674                $secondaryCache = $this->getSecondaryCache( $parserOptions );
675                $workKey = $secondaryCache->makeParserOutputKeyOptionalRevId( $revision, $parserOptions );
676        }
677
678        $pool = $this->poolCounterFactory->create( 'ArticleView', $workKey );
679        return new PoolCounterWorkViaCallback(
680            $pool,
681            $workKey,
682            $callbacks
683        );
684    }
685
686    private function getPrimaryCache( ParserOptions $pOpts ): ParserCache {
687        if ( $pOpts->getUseParsoid() ) {
688            return $this->parserCacheFactory->getParserCache(
689                self::PARSOID_PCACHE_NAME
690            );
691        }
692
693        return $this->parserCacheFactory->getParserCache(
694            ParserCacheFactory::DEFAULT_NAME
695        );
696    }
697
698    private function getSecondaryCache( ParserOptions $pOpts ): RevisionOutputCache {
699        if ( $pOpts->getUseParsoid() ) {
700            return $this->parserCacheFactory->getRevisionOutputCache(
701                self::PARSOID_RCACHE_NAME
702            );
703        }
704
705        return $this->parserCacheFactory->getRevisionOutputCache(
706            ParserCacheFactory::DEFAULT_RCACHE_NAME
707        );
708    }
709
710    private function startOperationSpan(
711        string $opName,
712        PageRecord $page,
713        ?RevisionRecord $revision = null
714    ): SpanInterface {
715        $span = $this->tracer->createSpan( "ParserOutputAccess::$opName" );
716        if ( $span->getContext()->isSampled() ) {
717            $span->setAttributes( [
718                'org.wikimedia.parser.page' => $page->__toString(),
719                'org.wikimedia.parser.page.id' => $page->getId(),
720                'org.wikimedia.parser.page.wiki' => $page->getWikiId(),
721            ] );
722            if ( $revision ) {
723                $span->setAttributes( [
724                    'org.wikimedia.parser.revision.id' => $revision->getId(),
725                    'org.wikimedia.parser.revision.parent_id' => $revision->getParentId(),
726                ] );
727            }
728        }
729        return $span->start()->activate();
730    }
731
732    /**
733     * Clear the local cache
734     * @since 1.45
735     */
736    public function clearLocalCache() {
737        $this->localCache->clear();
738    }
739}