Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.67% covered (success)
90.67%
243 / 268
82.35% covered (warning)
82.35%
14 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
ParserCache
91.01% covered (success)
91.01%
243 / 267
82.35% covered (warning)
82.35%
14 / 17
61.53
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
 setFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleteOptionsKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getDirty
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getContentModelFromPage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 incrementStats
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 incrementRenderReasonStats
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getMetadata
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 makeMetadataKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeParserOutputKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
11
 save
86.84% covered (warning)
86.84%
99 / 114
0.00% covered (danger)
0.00%
0 / 1
17.66
 getCacheStorage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkExpired
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 checkOutdated
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 restoreFromJson
56.25% covered (warning)
56.25%
9 / 16
0.00% covered (danger)
0.00%
0 / 1
3.75
 convertForCache
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
3.03
1<?php
2/**
3 * Cache for outputs of the PHP parser
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup Cache Parser
8 */
9
10namespace MediaWiki\Parser;
11
12use Exception;
13use InvalidArgumentException;
14use JsonException;
15use MediaWiki\HookContainer\HookContainer;
16use MediaWiki\HookContainer\HookRunner;
17use MediaWiki\Json\JsonCodec;
18use MediaWiki\Page\PageRecord;
19use MediaWiki\Page\WikiPageFactory;
20use MediaWiki\Title\TitleFactory;
21use Psr\Log\LoggerInterface;
22use Wikimedia\ObjectCache\BagOStuff;
23use Wikimedia\ObjectCache\EmptyBagOStuff;
24use Wikimedia\ObjectCache\HashBagOStuff;
25use Wikimedia\Stats\StatsFactory;
26use Wikimedia\UUID\GlobalIdGenerator;
27
28/**
29 * Cache for ParserOutput objects corresponding to the latest page revisions.
30 *
31 * The ParserCache is a two-tiered cache backed by BagOStuff which supports
32 * varying the stored content on the values of ParserOptions used during
33 * a page parse.
34 *
35 * First tier is keyed by the page ID and stores ParserCacheMetadata, which
36 * contains information about cache expiration and the list of ParserOptions
37 * used during the parse of the page. For example, if only 'dateformat' and
38 * 'userlang' options were accessed by the parser when producing output for the
39 * page, array [ 'dateformat', 'userlang' ] will be stored in the metadata cache.
40 * This means none of the other existing options had any effect on the output.
41 *
42 * The second tier of the cache contains ParserOutput objects. The key for the
43 * second tier is constructed from the page ID and values of those ParserOptions
44 * used during a page parse which affected the output. Upon cache lookup, the list
45 * of used option names is retrieved from tier 1 cache, and only the values of
46 * those options are hashed together with the page ID to produce a key, while
47 * the rest of the options are ignored. Following the example above where
48 * only [ 'dateformat', 'userlang' ] options changed the parser output for a
49 * page, the key will look like 'page_id!dateformat=default:userlang=ru'.
50 * Thus any cache lookup with dateformat=default and userlang=ru will hit the
51 * same cache entry regardless of the values of the rest of the options, since they
52 * were not accessed during a parse and thus did not change the output.
53 *
54 * @see ParserOutput::recordOption()
55 * @see ParserOutput::getUsedOptions()
56 * @see ParserOptions::allCacheVaryingOptions()
57 * @ingroup Cache Parser
58 */
59class ParserCache {
60    /**
61     * Constants for self::getKey()
62     * @since 1.30
63     * @since 1.36 the constants were made public
64     */
65
66    /** Use only current data */
67    public const USE_CURRENT_ONLY = 0;
68
69    /** Use expired data if current data is unavailable */
70    public const USE_EXPIRED = 1;
71
72    /** Use expired data or data from different revisions if current data is unavailable */
73    public const USE_OUTDATED = 2;
74
75    /**
76     * Use expired data and data from different revisions, and if all else
77     * fails vary on all variable options
78     */
79    private const USE_ANYTHING = 3;
80
81    /** @var string The name of this ParserCache. Used as a root of the cache key. */
82    private $name;
83
84    /** @var BagOStuff */
85    private $cache;
86
87    /**
88     * Anything cached prior to this is invalidated
89     *
90     * @var string
91     */
92    private $cacheEpoch;
93
94    /** @var HookRunner */
95    private $hookRunner;
96
97    /** @var JsonCodec */
98    private $jsonCodec;
99
100    /** @var StatsFactory */
101    private $stats;
102
103    /** @var LoggerInterface */
104    private $logger;
105
106    /** @var TitleFactory */
107    private $titleFactory;
108
109    /** @var WikiPageFactory */
110    private $wikiPageFactory;
111
112    private ?ParserCacheFilter $filter = null;
113
114    private GlobalIdGenerator $globalIdGenerator;
115
116    /**
117     * @var BagOStuff small in-process cache to store metadata.
118     * It's needed multiple times during the request, for example
119     * to build a PoolWorkArticleView key, and then to fetch the
120     * actual ParserCache entry.
121     */
122    private $metadataProcCache;
123
124    /**
125     * Setup a cache pathway with a given back-end storage mechanism.
126     *
127     * This class use an invalidation strategy that is compatible with
128     * MultiWriteBagOStuff in async replication mode.
129     *
130     * @param string $name
131     * @param BagOStuff $cache
132     * @param string $cacheEpoch Anything before this timestamp is invalidated
133     * @param HookContainer $hookContainer
134     * @param JsonCodec $jsonCodec
135     * @param StatsFactory $stats
136     * @param LoggerInterface $logger
137     * @param TitleFactory $titleFactory
138     * @param WikiPageFactory $wikiPageFactory
139     * @param GlobalIdGenerator $globalIdGenerator
140     */
141    public function __construct(
142        string $name,
143        BagOStuff $cache,
144        string $cacheEpoch,
145        HookContainer $hookContainer,
146        JsonCodec $jsonCodec,
147        StatsFactory $stats,
148        LoggerInterface $logger,
149        TitleFactory $titleFactory,
150        WikiPageFactory $wikiPageFactory,
151        GlobalIdGenerator $globalIdGenerator
152    ) {
153        $this->name = $name;
154        $this->cache = $cache;
155        $this->cacheEpoch = $cacheEpoch;
156        $this->hookRunner = new HookRunner( $hookContainer );
157        $this->jsonCodec = $jsonCodec;
158        $this->stats = $stats;
159        $this->logger = $logger;
160        $this->titleFactory = $titleFactory;
161        $this->wikiPageFactory = $wikiPageFactory;
162        $this->globalIdGenerator = $globalIdGenerator;
163        $this->metadataProcCache = new HashBagOStuff( [ 'maxKeys' => 2 ] );
164    }
165
166    /**
167     * @since 1.41
168     * @param ParserCacheFilter $filter
169     */
170    public function setFilter( ParserCacheFilter $filter ): void {
171        $this->filter = $filter;
172    }
173
174    /**
175     * @param PageRecord $page
176     * @since 1.28
177     */
178    public function deleteOptionsKey( PageRecord $page ) {
179        $page->assertWiki( PageRecord::LOCAL );
180        $key = $this->makeMetadataKey( $page );
181        $this->metadataProcCache->delete( $key );
182        $this->cache->delete( $key );
183    }
184
185    /**
186     * Retrieve the ParserOutput from ParserCache, even if it's outdated.
187     * @param PageRecord $page
188     * @param ParserOptions $popts
189     * @return ParserOutput|false
190     */
191    public function getDirty( PageRecord $page, $popts ) {
192        $page->assertWiki( PageRecord::LOCAL );
193        $value = $this->get( $page, $popts, true );
194        return is_object( $value ) ? $value : false;
195    }
196
197    /**
198     * @param PageRecord $page
199     * @return string
200     */
201    private function getContentModelFromPage( PageRecord $page ) {
202        $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
203        return str_replace( '.', '_', $wikiPage->getContentModel() );
204    }
205
206    /**
207     * @param PageRecord $page
208     * @param string $status
209     * @param string|null $reason
210     */
211    private function incrementStats( PageRecord $page, $status, $reason = null ) {
212        $contentModel = $this->getContentModelFromPage( $page );
213
214        $this->stats->getCounter( 'ParserCache_operation_total' )
215            ->setLabel( 'name', $this->name )
216            ->setLabel( 'contentModel', $contentModel )
217            ->setLabel( 'status', $status )
218            ->setLabel( 'reason', $reason ?: 'n/a' )
219            ->increment();
220    }
221
222    /**
223     * @param PageRecord $page
224     * @param string $renderReason
225     */
226    private function incrementRenderReasonStats( PageRecord $page, $renderReason ) {
227        $contentModel = $this->getContentModelFromPage( $page );
228        $renderReason = preg_replace( '/\W+/', '_', $renderReason );
229
230        $this->stats->getCounter( 'ParserCache_render_total' )
231            ->setLabel( 'name', $this->name )
232            ->setLabel( 'contentModel', $contentModel )
233            ->setLabel( 'reason', $renderReason )
234            ->increment();
235    }
236
237    /**
238     * Returns the ParserCache metadata about the given page
239     * considering the given options.
240     *
241     * @note Which parser options influence the cache key
242     * is controlled via ParserOutput::recordOption() or
243     * ParserOptions::addExtraKey().
244     *
245     * @param PageRecord $page
246     * @param int $staleConstraint one of the self::USE_ constants
247     * @return ParserCacheMetadata|null
248     * @since 1.36
249     */
250    public function getMetadata(
251        PageRecord $page,
252        int $staleConstraint = self::USE_ANYTHING
253    ): ?ParserCacheMetadata {
254        $page->assertWiki( PageRecord::LOCAL );
255
256        $pageKey = $this->makeMetadataKey( $page );
257        $metadata = $this->metadataProcCache->get( $pageKey );
258        if ( !$metadata ) {
259            $metadata = $this->cache->get(
260                $pageKey,
261                BagOStuff::READ_VERIFIED
262            );
263        }
264
265        if ( $metadata === false ) {
266            // We don't currently remove contents from the cache for capacity,
267            // so hitting this case usually means the cache expiry has passed.
268            $this->incrementStats( $page, 'miss', 'absent_metadata' );
269            $this->logger->debug( 'ParserOutput metadata cache miss', [ 'name' => $this->name ] );
270            return null;
271        } else {
272            // Ensure this cache hit is present in the in-process cache (T277829)
273            $this->metadataProcCache->set( $pageKey, $metadata );
274        }
275
276        // NOTE: If the value wasn't serialized to JSON when being stored,
277        //       we may already have a ParserOutput object here. This used
278        //       to be the default behavior before 1.36. We need to retain
279        //       support so we can handle cached objects after an update
280        //       from an earlier revision.
281        // NOTE: Support for reading string values from the cache must be
282        //       deployed a while before starting to write JSON to the cache,
283        //       in case we have to revert either change.
284        if ( is_string( $metadata ) ) {
285            $metadata = $this->restoreFromJson( $metadata, $pageKey, CacheTime::class );
286        }
287
288        if ( !$metadata instanceof CacheTime ) {
289            // This should never happen, and is grounds for a rollback.
290            $this->incrementStats( $page, 'miss', 'unserialize' );
291            return null;
292        }
293
294        if ( $this->checkExpired( $metadata, $page, $staleConstraint, 'metadata' ) ) {
295            return null;
296        }
297
298        if ( $this->checkOutdated( $metadata, $page, $staleConstraint, 'metadata' ) ) {
299            return null;
300        }
301
302        $this->logger->debug( 'Parser cache options found', [ 'name' => $this->name ] );
303        return $metadata;
304    }
305
306    private function makeMetadataKey( PageRecord $page ): string {
307        return $this->cache->makeKey( $this->name, $page->getId( PageRecord::LOCAL ), '|#|', 'idoptions' );
308    }
309
310    /**
311     * Get a key that will be used by the ParserCache to store the content
312     * for a given page considering the given options and the array of
313     * used options.
314     *
315     * @warning The exact format of the key is considered internal and is subject
316     * to change, thus should not be used as storage or long-term caching key.
317     * This is intended to be used for logging or keying something transient.
318     *
319     * @param PageRecord $page
320     * @param ParserOptions $options
321     * @param array|null $usedOptions Defaults to all cache varying options.
322     * @return string
323     * @internal
324     * @since 1.36
325     */
326    public function makeParserOutputKey(
327        PageRecord $page,
328        ParserOptions $options,
329        ?array $usedOptions = null
330    ): string {
331        $usedOptions ??= ParserOptions::allCacheVaryingOptions( $options->getPostproc() );
332        $title = $this->titleFactory->newFromPageIdentity( $page );
333        $hash = $options->optionsHash( $usedOptions, $title );
334        // idhash seem to mean 'page id' + 'rendering hash' (r3710)
335        return $this->cache->makeKey( $this->name, $page->getId( PageRecord::LOCAL ), '|#|', 'idhash', $hash );
336    }
337
338    /**
339     * Retrieve the ParserOutput from ParserCache.
340     * false if not found or outdated.
341     *
342     * @param PageRecord $page
343     * @param ParserOptions $popts
344     * @param bool $useOutdated (default false)
345     *
346     * @return ParserOutput|false
347     */
348    public function get( PageRecord $page, $popts, $useOutdated = false ) {
349        $page->assertWiki( PageRecord::LOCAL );
350
351        if ( !$page->exists() ) {
352            $this->incrementStats( $page, 'miss', 'nonexistent' );
353            return false;
354        }
355
356        $staleConstraint = $useOutdated ? self::USE_OUTDATED : self::USE_CURRENT_ONLY;
357        $parserOutputMetadata = $this->getMetadata( $page, $staleConstraint );
358        if ( !$parserOutputMetadata ) {
359            return false;
360        }
361
362        if ( !$popts->isSafeToCache( $parserOutputMetadata->getUsedOptions() ) ) {
363            $this->incrementStats( $page, 'miss', 'unsafe' );
364            return false;
365        }
366
367        $parserOutputKey = $this->makeParserOutputKey(
368            $page,
369            $popts,
370            $parserOutputMetadata->getUsedOptions()
371        );
372
373        $value = $this->cache->get( $parserOutputKey, BagOStuff::READ_VERIFIED );
374        if ( $value === false ) {
375            // We don't currently remove contents from the cache for capacity,
376            // and when the cache expiry passes absent_metadata will usually
377            // be reached before this case. This is usually a new set of
378            // cacheable parser options (user language, etc) being used for
379            // a page which already exists in some other form in the cache
380            // (metadata already present).
381            $this->incrementStats( $page, 'miss', 'absent' );
382            $this->logger->debug( 'ParserOutput cache miss', [ 'name' => $this->name ] );
383            return false;
384        }
385
386        // NOTE: If the value wasn't serialized to JSON when being stored,
387        //       we may already have a ParserOutput object here. This used
388        //       to be the default behavior before 1.36. We need to retain
389        //       support so we can handle cached objects after an update
390        //       from an earlier revision.
391        // NOTE: Support for reading string values from the cache must be
392        //       deployed a while before starting to write JSON to the cache,
393        //       in case we have to revert either change.
394        if ( is_string( $value ) ) {
395            $value = $this->restoreFromJson( $value, $parserOutputKey, ParserOutput::class );
396        }
397
398        if ( !$value instanceof ParserOutput ) {
399            // This should never happen, and is grounds for a rollback.
400            $this->incrementStats( $page, 'miss', 'unserialize' );
401            return false;
402        }
403
404        if ( $this->checkExpired( $value, $page, $staleConstraint, 'output' ) ) {
405            return false;
406        }
407
408        if ( $this->checkOutdated( $value, $page, $staleConstraint, 'output' ) ) {
409            return false;
410        }
411
412        $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
413        if ( $this->hookRunner->onRejectParserCacheValue( $value, $wikiPage, $popts ) === false ) {
414            // The cached entry is unusable due to a registered hook.
415            // This is typically used to rollback bad entries created by
416            // a botched deploy; see T249595.
417            $this->incrementStats( $page, 'miss', 'rejected' );
418            $this->logger->debug( 'key valid, but rejected by RejectParserCacheValue hook handler',
419                [ 'name' => $this->name ] );
420            return false;
421        }
422
423        $this->logger->debug( 'ParserOutput cache found', [ 'name' => $this->name ] );
424        $this->incrementStats( $page, 'hit' );
425        return $value;
426    }
427
428    /**
429     * @param ParserOutput $parserOutput
430     * @param PageRecord $page
431     * @param ParserOptions $popts
432     * @param string|null $cacheTime TS::MW timestamp when the cache was generated
433     * @param int|null $revId Revision ID that was parsed
434     */
435    public function save(
436        ParserOutput $parserOutput,
437        PageRecord $page,
438        $popts,
439        $cacheTime = null,
440        $revId = null
441    ) {
442        $page->assertWiki( PageRecord::LOCAL );
443
444        // T350538: Eventually we'll warn if the $cacheTime and $revId
445        // parameters are non-null here, since we *should* be getting
446        // them from the ParserOutput.
447        if ( $revId !== null && $revId !== $parserOutput->getCacheRevisionId() ) {
448            $this->logger->warning(
449                'Inconsistent revision ID',
450                [
451                    'name' => $this->name,
452                    'reason' => $popts->getRenderReason(),
453                    'revid1' => $revId,
454                    'revid2' => $parserOutput->getCacheRevisionId(),
455                ]
456            );
457        }
458
459        if ( !$parserOutput->hasText() ) {
460            throw new InvalidArgumentException( 'Attempt to cache a ParserOutput with no text set!' );
461        }
462
463        $expire = $parserOutput->getCacheExpiry();
464
465        if ( !$popts->isSafeToCache( $parserOutput->getUsedOptions() ) ) {
466            $this->logger->debug(
467                'Parser options are not safe to cache and has not been saved',
468                [ 'name' => $this->name ]
469            );
470            $this->incrementStats( $page, 'save', 'unsafe' );
471            return;
472        }
473
474        if ( $expire <= 0 ) {
475            $this->logger->debug(
476                'Parser output was marked as uncacheable and has not been saved',
477                [ 'name' => $this->name ]
478            );
479            $this->incrementStats( $page, 'save', 'uncacheable' );
480            return;
481        }
482
483        if ( $this->filter && !$this->filter->shouldCache( $parserOutput, $page, $popts ) ) {
484            $this->logger->debug(
485                'Parser output was filtered and has not been saved',
486                [ 'name' => $this->name ]
487            );
488            $this->incrementStats( $page, 'save', 'filtered' );
489
490            // TODO: In this case, we still want to cache in RevisionOutputCache (T350669).
491            return;
492        }
493
494        if ( $this->cache instanceof EmptyBagOStuff ) {
495            return;
496        }
497
498        // Ensure cache properties are set in the ParserOutput
499        // T350538: These should be turned into assertions that the
500        // properties are already present.
501        if ( $cacheTime ) {
502            $parserOutput->setCacheTime( $cacheTime );
503        } else {
504            if ( !$parserOutput->hasCacheTime() ) {
505                $this->logger->warning(
506                    'No cache time set',
507                    [
508                        'name' => $this->name,
509                        'reason' => $popts->getRenderReason(),
510                    ]
511                );
512            }
513            $cacheTime = $parserOutput->getCacheTime();
514        }
515
516        if ( $revId ) {
517            $parserOutput->setCacheRevisionId( $revId );
518        } elseif ( $parserOutput->getCacheRevisionId() ) {
519            $revId = $parserOutput->getCacheRevisionId();
520        } else {
521            $revId = $page->getLatest( PageRecord::LOCAL );
522            $parserOutput->setCacheRevisionId( $revId );
523        }
524        if ( !$revId ) {
525            $this->logger->warning(
526                'Parser output cannot be saved if the revision ID is not known',
527                [ 'name' => $this->name ]
528            );
529            $this->incrementStats( $page, 'save', 'norevid' );
530            return;
531        }
532
533        if ( !$parserOutput->getRenderId() ) {
534            $this->logger->warning(
535                'Parser output missing render ID',
536                [
537                    'name' => $this->name,
538                    'reason' => $popts->getRenderReason(),
539                ]
540            );
541            $parserOutput->setRenderId( $this->globalIdGenerator->newUUIDv1() );
542        }
543
544        // Transfer cache properties to the cache metadata
545        $metadata = new CacheTime;
546        $metadata->recordOptions( $parserOutput->getUsedOptions() );
547        $metadata->updateCacheExpiry( $expire, $parserOutput->getCacheExpirySource() );
548        $metadata->setCacheTime( $cacheTime );
549        $metadata->setCacheRevisionId( $revId );
550
551        $parserOutputKey = $this->makeParserOutputKey(
552            $page,
553            $popts,
554            $metadata->getUsedOptions()
555        );
556
557        $msg = "Saved in parser cache with key $parserOutputKey" .
558            " and timestamp $cacheTime" .
559            " and revision id $revId.";
560
561        $reason = $popts->getRenderReason();
562        $msg .= " Rendering was triggered because: $reason";
563
564        $parserOutput->addCacheMessage( $msg );
565
566        $pageKey = $this->makeMetadataKey( $page );
567
568        $parserOutputData = $this->convertForCache( $parserOutput, $parserOutputKey );
569        $metadataData = $this->convertForCache( $metadata, $pageKey );
570
571        if ( !$parserOutputData || !$metadataData ) {
572            $this->logger->warning(
573                'Parser output failed to serialize and was not saved',
574                [ 'name' => $this->name ]
575            );
576            $this->incrementStats( $page, 'save', 'nonserializable' );
577            return;
578        }
579
580        // Save the parser output
581        $this->cache->set(
582            $parserOutputKey,
583            $parserOutputData,
584            $expire,
585            BagOStuff::WRITE_ALLOW_SEGMENTS
586        );
587
588        // ...and its pointer to the local cache.
589        $this->metadataProcCache->set( $pageKey, $metadataData, $expire );
590        // ...and to the global cache.
591        $this->cache->set( $pageKey, $metadataData, $expire );
592
593        $title = $this->titleFactory->newFromPageIdentity( $page );
594        $this->hookRunner->onParserCacheSaveComplete( $this, $parserOutput, $title, $popts, $revId );
595
596        $this->logger->debug( 'Saved in parser cache', [
597            'name' => $this->name,
598            'key' => $parserOutputKey,
599            'cache_time' => $cacheTime,
600            'rev_id' => $revId
601        ] );
602        $this->incrementStats( $page, 'save', 'success' );
603        $this->incrementRenderReasonStats( $page, $popts->getRenderReason() );
604    }
605
606    /**
607     * Get the backend BagOStuff instance that
608     * powers the parser cache
609     *
610     * @since 1.30
611     * @internal
612     * @return BagOStuff
613     */
614    public function getCacheStorage() {
615        return $this->cache;
616    }
617
618    /**
619     * Check if $entry expired for $page given the $staleConstraint
620     * when fetching from $cacheTier.
621     * @param CacheTime $entry
622     * @param PageRecord $page
623     * @param int $staleConstraint One of USE_* constants.
624     * @param string $cacheTier
625     * @return bool
626     */
627    private function checkExpired(
628        CacheTime $entry,
629        PageRecord $page,
630        int $staleConstraint,
631        string $cacheTier
632    ): bool {
633        if ( $staleConstraint < self::USE_EXPIRED && $entry->expired( $page->getTouched() ) ) {
634            // A template affecting the page has been edited, forcing a miss.
635            // (CacheTime::expired() also checks page_touched; if the
636            // cache expiry time had passed no entry would have been
637            // returned and we would not reach here. The cache epoch
638            // and "uncacheable" flags are also checked by
639            // CacheTime::expired() but these will be corner cases.)
640            $this->incrementStats( $page, 'miss', 'expired' );
641            $this->logger->debug( "{$cacheTier} key expired", [
642                'name' => $this->name,
643                'touched' => $page->getTouched(),
644                'epoch' => $this->cacheEpoch,
645                'cache_time' => $entry->getCacheTime()
646            ] );
647            return true;
648        }
649        return false;
650    }
651
652    /**
653     * Check if $entry belongs to the latest revision of $page
654     * given $staleConstraint when fetched from $cacheTier.
655     * @param CacheTime $entry
656     * @param PageRecord $page
657     * @param int $staleConstraint One of USE_* constants.
658     * @param string $cacheTier
659     * @return bool
660     */
661    private function checkOutdated(
662        CacheTime $entry,
663        PageRecord $page,
664        int $staleConstraint,
665        string $cacheTier
666    ): bool {
667        $latestRevId = $page->getLatest( PageRecord::LOCAL );
668        if ( $staleConstraint < self::USE_OUTDATED && $entry->isDifferentRevision( $latestRevId ) ) {
669            // The page itself has been edited (new revision), forcing a miss.
670            $this->incrementStats( $page, 'miss', 'revid' );
671            $this->logger->debug( "{$cacheTier} key is for an old revision", [
672                'name' => $this->name,
673                'rev_id' => $latestRevId,
674                'cached_rev_id' => $entry->getCacheRevisionId()
675            ] );
676            return true;
677        }
678        return false;
679    }
680
681    /**
682     * @param string $jsonData
683     * @param string $key
684     * @param string $expectedClass
685     * @return CacheTime|ParserOutput|null
686     */
687    private function restoreFromJson( string $jsonData, string $key, string $expectedClass ) {
688        try {
689            /** @var CacheTime $obj */
690            $obj = $this->jsonCodec->deserialize( $jsonData, $expectedClass );
691            return $obj;
692        } catch ( JsonException $e ) {
693            $this->logger->error( "Unable to deserialize JSON", [
694                'name' => $this->name,
695                'cache_key' => $key,
696                'ex_message' => $e->getMessage()
697            ] );
698            return null;
699        } catch ( Exception $e ) {
700            $this->logger->error( "Unexpected failure during cache load", [
701                'name' => $this->name,
702                'cache_key' => $key,
703                'ex_message' => $e->getMessage()
704            ] );
705            return null;
706        }
707    }
708
709    /**
710     * @param CacheTime $obj
711     * @param string $key
712     * @return string|null
713     */
714    protected function convertForCache( CacheTime $obj, string $key ) {
715        try {
716            return $this->jsonCodec->serialize( $obj );
717        } catch ( JsonException $e ) {
718            // Try to collect some additional debugging information, but
719            // wrap this in a try block to ensure we don't make the problem
720            // worse.
721            try {
722                $details = $this->jsonCodec->detectNonSerializableData( $obj, true );
723            } catch ( \Throwable $t ) {
724                $details = $t->getMessage();
725            }
726            $this->logger->error( "Unable to serialize JSON", [
727                'name' => $this->name,
728                'cache_key' => $key,
729                'ex_message' => $e->getMessage(),
730                'details' => $details,
731                'trace' => $e->getTraceAsString(),
732            ] );
733            return null;
734        }
735    }
736}
737
738/** @deprecated class alias since 1.43 */
739class_alias( ParserCache::class, 'ParserCache' );