1<?php
2/**
3 * Cache for outputs of the PHP parser
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Cache Parser
22 */
23
24use MediaWiki\HookContainer\HookContainer;
25use MediaWiki\HookContainer\HookRunner;
26use MediaWiki\Json\JsonCodec;
27use MediaWiki\Page\PageRecord;
28use MediaWiki\Page\WikiPageFactory;
29use MediaWiki\Parser\ParserCacheMetadata;
30use Psr\Log\LoggerInterface;
31
32/**
33 * Cache for ParserOutput objects corresponding to the latest page revisions.
34 *
35 * The ParserCache is a two-tiered cache backed by BagOStuff which supports
36 * varying the stored content on the values of ParserOptions used during
37 * a page parse.
38 *
39 * First tier is keyed by the page ID and stores ParserCacheMetadata, which
40 * contains information about cache expiration and the list of ParserOptions
41 * used during the parse of the page. For example, if only 'dateformat' and
42 * 'userlang' options were accessed by the parser when producing output for the
43 * page, array [ 'dateformat', 'userlang' ] will be stored in the metadata cache.
44 * This means none of the other existing options had any effect on the output.
45 *
46 * The second tier of the cache contains ParserOutput objects. The key for the
47 * second tier is constructed from the page ID and values of those ParserOptions
48 * used during a page parse which affected the output. Upon cache lookup, the list
49 * of used option names is retrieved from tier 1 cache, and only the values of
50 * those options are hashed together with the page ID to produce a key, while
51 * the rest of the options are ignored. Following the example above where
52 * only [ 'dateformat', 'userlang' ] options changed the parser output for a
53 * page, the key will look like 'page_id!dateformat=default:userlang=ru'.
54 * Thus any cache lookup with dateformat=default and userlang=ru will hit the
55 * same cache entry regardless of the values of the rest of the options, since they
56 * were not accessed during a parse and thus did not change the output.
57 *
58 * @see ParserOutput::recordOption()
59 * @see ParserOutput::getUsedOptions()
60 * @see ParserOptions::allCacheVaryingOptions()
61 * @ingroup Cache Parser
62 */
63class ParserCache {
64	/**
65	 * Constants for self::getKey()
66	 * @since 1.30
67	 * @since 1.36 the constants were made public
68	 */
69
70	/** Use only current data */
71	public const USE_CURRENT_ONLY = 0;
72
73	/** Use expired data if current data is unavailable */
74	public const USE_EXPIRED = 1;
75
76	/** Use expired data or data from different revisions if current data is unavailable */
77	public const USE_OUTDATED = 2;
78
79	/**
80	 * Use expired data and data from different revisions, and if all else
81	 * fails vary on all variable options
82	 */
83	private const USE_ANYTHING = 3;
84
85	/** @var string The name of this ParserCache. Used as a root of the cache key. */
86	private $name;
87
88	/** @var BagOStuff */
89	private $cache;
90
91	/**
92	 * Anything cached prior to this is invalidated
93	 *
94	 * @var string
95	 */
96	private $cacheEpoch;
97
98	/** @var HookRunner */
99	private $hookRunner;
100
101	/** @var JsonCodec */
102	private $jsonCodec;
103
104	/** @var IBufferingStatsdDataFactory */
105	private $stats;
106
107	/** @var LoggerInterface */
108	private $logger;
109
110	/** @var TitleFactory */
111	private $titleFactory;
112
113	/** @var WikiPageFactory */
114	private $wikiPageFactory;
115
116	/**
117	 * @note Temporary feature flag, remove before 1.36 is released.
118	 * @var bool
119	 */
120	private $writeJson = false;
121
122	/**
123	 * @note Temporary feature flag, remove before 1.36 is released.
124	 * @var bool
125	 */
126	private $readJson = false;
127
128	/**
129	 * Setup a cache pathway with a given back-end storage mechanism.
130	 *
131	 * This class use an invalidation strategy that is compatible with
132	 * MultiWriteBagOStuff in async replication mode.
133	 *
134	 * @param string $name
135	 * @param BagOStuff $cache
136	 * @param string $cacheEpoch Anything before this timestamp is invalidated
137	 * @param HookContainer $hookContainer
138	 * @param JsonCodec $jsonCodec
139	 * @param IBufferingStatsdDataFactory $stats
140	 * @param LoggerInterface $logger
141	 * @param TitleFactory $titleFactory
142	 * @param WikiPageFactory $wikiPageFactory
143	 * @param bool $useJson Temporary feature flag, remove before 1.36 is released.
144	 */
145	public function __construct(
146		string $name,
147		BagOStuff $cache,
148		string $cacheEpoch,
149		HookContainer $hookContainer,
150		JsonCodec $jsonCodec,
151		IBufferingStatsdDataFactory $stats,
152		LoggerInterface $logger,
153		TitleFactory $titleFactory,
154		WikiPageFactory $wikiPageFactory,
155		$useJson = false
156	) {
157		if ( !$cache instanceof EmptyBagOStuff && !$cache instanceof CachedBagOStuff ) {
158			// It seems on some page views, the same entry is retreived twice from the ParserCache.
159			// This shouldn't happen but use a process-cache and log duplicate fetches to mitigate
160			// this and figure out why. (T269593)
161			$cache = new CachedBagOStuff( $cache, [
162				'logger' => $logger,
163				'asyncHandler' => [ DeferredUpdates::class, 'addCallableUpdate' ],
164				'reportDupes' => true,
165				// Each ParserCache entry uses 2 keys, one for metadata and one for parser output.
166				// So, cache at most 4 different parser outputs in memory. The number was chosen ad hoc.
167				'maxKeys' => 8
168			] );
169		}
170		$this->name = $name;
171		$this->cache = $cache;
172		$this->cacheEpoch = $cacheEpoch;
173		$this->hookRunner = new HookRunner( $hookContainer );
174		$this->jsonCodec = $jsonCodec;
175		$this->stats = $stats;
176		$this->logger = $logger;
177		$this->titleFactory = $titleFactory;
178		$this->wikiPageFactory = $wikiPageFactory;
179		$this->readJson = $useJson;
180		$this->writeJson = $useJson;
181	}
182
183	/**
184	 * @param PageRecord $page
185	 * @since 1.28
186	 */
187	public function deleteOptionsKey( PageRecord $page ) {
188		$page->assertWiki( PageRecord::LOCAL );
189		$this->cache->delete( $this->makeMetadataKey( $page ) );
190	}
191
192	/**
193	 * Provides an E-Tag suitable for the whole page. Note that $page
194	 * is just the main wikitext. The E-Tag has to be unique to the whole
195	 * page, even if the article itself is the same, so it uses the
196	 * complete set of user options. We don't want to use the preference
197	 * of a different user on a message just because it wasn't used in
198	 * $page. For example give a Chinese interface to a user with
199	 * English preferences. That's why we take into account *all* user
200	 * options. (r70809 CR)
201	 *
202	 * @deprecated since 1.36
203	 * @param PageRecord $page
204	 * @param ParserOptions $popts
205	 * @return string
206	 */
207	public function getETag( PageRecord $page, $popts ) {
208		wfDeprecated( __METHOD__, '1.36' );
209		$page->assertWiki( PageRecord::LOCAL );
210		return 'W/"' . $this->makeParserOutputKey( $page, $popts )
211			. "--" . $page->getTouched() . '"';
212	}
213
214	/**
215	 * Retrieve the ParserOutput from ParserCache, even if it's outdated.
216	 * @param PageRecord $page
217	 * @param ParserOptions $popts
218	 * @return ParserOutput|bool False on failure
219	 */
220	public function getDirty( PageRecord $page, $popts ) {
221		$page->assertWiki( PageRecord::LOCAL );
222		$value = $this->get( $page, $popts, true );
223		return is_object( $value ) ? $value : false;
224	}
225
226	/**
227	 * @param PageRecord $page
228	 * @param string $metricSuffix
229	 */
230	private function incrementStats( PageRecord $page, $metricSuffix ) {
231		$wikiPage = $this->wikiPageFactory->newFromTitle( $page );
232		$contentModel = str_replace( '.', '_', $wikiPage->getContentModel() );
233		$metricSuffix = str_replace( '.', '_', $metricSuffix );
234		$this->stats->increment( "{$this->name}.{$contentModel}.{$metricSuffix}" );
235	}
236
237	/**
238	 * Generates a key for caching the given page considering
239	 * the given parser options.
240	 *
241	 * @note Which parser options influence the cache key
242	 * is controlled via ParserOutput::recordOption() or
243	 * ParserOptions::addExtraKey().
244	 *
245	 * @note Used by Article to provide a unique id for the PoolCounter.
246	 * It would be preferable to have this code in get()
247	 * instead of having Article looking in our internals.
248	 *
249	 * @param PageRecord $page
250	 * @param ParserOptions $popts
251	 * @param int|bool $useOutdated One of the USE constants. For backwards
252	 *  compatibility, boolean false is treated as USE_CURRENT_ONLY and
253	 *  boolean true is treated as USE_ANYTHING.
254	 * @return bool|mixed|string
255	 * @since 1.30 Changed $useOutdated to an int and added the non-boolean values
256	 * @deprecated 1.36 Use ::getMetadata and ::makeParserOutputKey methods instead.
257	 */
258	public function getKey( PageRecord $page, $popts, $useOutdated = self::USE_ANYTHING ) {
259		wfDeprecated( __METHOD__, '1.36' );
260		$page->assertWiki( PageRecord::LOCAL );
261
262		if ( is_bool( $useOutdated ) ) {
263			$useOutdated = $useOutdated ? self::USE_ANYTHING : self::USE_CURRENT_ONLY;
264		}
265
266		if ( $popts instanceof User ) {
267			$this->logger->warning(
268				"Use of outdated prototype ParserCache::getKey( &\$wikiPage, &\$user )\n"
269			);
270			$popts = ParserOptions::newFromUser( $popts );
271		}
272
273		$metadata = $this->getMetadata( $page, $useOutdated );
274		if ( !$metadata ) {
275			if ( $useOutdated < self::USE_ANYTHING ) {
276				return false;
277			}
278			$usedOptions = ParserOptions::allCacheVaryingOptions();
279		} else {
280			$usedOptions = $metadata->getUsedOptions();
281		}
282
283		return $this->makeParserOutputKey( $page, $popts, $usedOptions );
284	}
285
286	/**
287	 * Returns the ParserCache metadata about the given page
288	 * considering the given options.
289	 *
290	 * @note Which parser options influence the cache key
291	 * is controlled via ParserOutput::recordOption() or
292	 * ParserOptions::addExtraKey().
293	 *
294	 * @param PageRecord $page
295	 * @param int $staleConstraint one of the self::USE_ constants
296	 * @return ParserCacheMetadata|null
297	 * @since 1.36
298	 */
299	public function getMetadata(
300		PageRecord $page,
301		int $staleConstraint = self::USE_ANYTHING
302	): ?ParserCacheMetadata {
303		$page->assertWiki( PageRecord::LOCAL );
304
305		$pageKey = $this->makeMetadataKey( $page );
306		$metadata = $this->cache->get(
307			$pageKey,
308			BagOStuff::READ_VERIFIED
309		);
310
311		// NOTE: If the value wasn't serialized to JSON when being stored,
312		//       we may already have a ParserOutput object here. This used
313		//       to be the default behavior before 1.36. We need to retain
314		//       support so we can handle cached objects after an update
315		//       from an earlier revision.
316		// NOTE: Support for reading string values from the cache must be
317		//       deployed a while before starting to write JSON to the cache,
318		//       in case we have to revert either change.
319		if ( is_string( $metadata ) && $this->readJson ) {
320			$metadata = $this->restoreFromJson( $metadata, $pageKey, CacheTime::class );
321		}
322
323		if ( !$metadata instanceof CacheTime ) {
324			$this->incrementStats( $page, 'miss.unserialize' );
325			return null;
326		}
327
328		if ( $this->checkExpired( $metadata, $page, $staleConstraint, 'metadata' ) ) {
329			return null;
330		}
331
332		if ( $this->checkOutdated( $metadata, $page, $staleConstraint, 'metadata' ) ) {
333			return null;
334		}
335
336		$this->logger->debug( 'Parser cache options found', [ 'name' => $this->name ] );
337		return $metadata;
338	}
339
340	/**
341	 * @param PageRecord $page
342	 * @return string
343	 */
344	private function makeMetadataKey( PageRecord $page ): string {
345		return $this->cache->makeKey( $this->name, 'idoptions', $page->getId( PageRecord::LOCAL ) );
346	}
347
348	/**
349	 * Get a key that will be used by the ParserCache to store the content
350	 * for a given page considering the given options and the array of
351	 * used options.
352	 *
353	 * @warning The exact format of the key is considered internal and is subject
354	 * to change, thus should not be used as storage or long-term caching key.
355	 * This is intended to be used for logging or keying something transient.
356	 *
357	 * @param PageRecord $page
358	 * @param ParserOptions $options
359	 * @param array|null $usedOptions Defaults to all cache verying options.
360	 * @return string
361	 * @internal
362	 * @since 1.36
363	 */
364	public function makeParserOutputKey(
365		PageRecord $page,
366		ParserOptions $options,
367		array $usedOptions = null
368	): string {
369		global $wgRequest;
370		$usedOptions = $usedOptions ?? ParserOptions::allCacheVaryingOptions();
371
372		// idhash seem to mean 'page id' + 'rendering hash' (r3710)
373		$pageid = $page->getId( PageRecord::LOCAL );
374		// TODO: remove the split T263581
375		$renderkey = (int)( $wgRequest->getVal( 'action' ) == 'render' );
376		$title = $this->titleFactory->castFromPageIdentity( $page );
377		$hash = $options->optionsHash( $usedOptions, $title );
378
379		return $this->cache->makeKey( $this->name, 'idhash', "{$pageid}-{$renderkey}!{$hash}" );
380	}
381
382	/**
383	 * Retrieve the ParserOutput from ParserCache.
384	 * false if not found or outdated.
385	 *
386	 * @param PageRecord $page
387	 * @param ParserOptions $popts
388	 * @param bool $useOutdated (default false)
389	 *
390	 * @return ParserOutput|bool False on failure
391	 */
392	public function get( PageRecord $page, $popts, $useOutdated = false ) {
393		$page->assertWiki( PageRecord::LOCAL );
394
395		if ( !$page->exists() ) {
396			$this->incrementStats( $page, 'miss.nonexistent' );
397			return false;
398		}
399
400		if ( $page->isRedirect() ) {
401			// It's a redirect now
402			$this->incrementStats( $page, 'miss.redirect' );
403			return false;
404		}
405
406		$staleConstraint = $useOutdated ? self::USE_OUTDATED : self::USE_CURRENT_ONLY;
407		$parserOutputMetadata = $this->getMetadata( $page, $staleConstraint );
408		if ( !$parserOutputMetadata ) {
409			return false;
410		}
411
412		if ( !$popts->isSafeToCache( $parserOutputMetadata->getUsedOptions() ) ) {
413			$this->incrementStats( $page, 'miss.unsafe' );
414			return false;
415		}
416
417		$parserOutputKey = $this->makeParserOutputKey(
418			$page,
419			$popts,
420			$parserOutputMetadata->getUsedOptions()
421		);
422
423		$value = $this->cache->get( $parserOutputKey, BagOStuff::READ_VERIFIED );
424		if ( $value === false ) {
425			$this->incrementStats( $page, "miss.absent" );
426			$this->logger->debug( 'ParserOutput cache miss', [ 'name' => $this->name ] );
427			return false;
428		}
429
430		// NOTE: If the value wasn't serialized to JSON when being stored,
431		//       we may already have a ParserOutput object here. This used
432		//       to be the default behavior before 1.36. We need to retain
433		//       support so we can handle cached objects after an update
434		//       from an earlier revision.
435		// NOTE: Support for reading string values from the cache must be
436		//       deployed a while before starting to write JSON to the cache,
437		//       in case we have to revert either change.
438		if ( is_string( $value ) && $this->readJson ) {
439			$value = $this->restoreFromJson( $value, $parserOutputKey, ParserOutput::class );
440		}
441
442		if ( !$value instanceof ParserOutput ) {
443			$this->incrementStats( $page, 'miss.unserialize' );
444			return false;
445		}
446
447		if ( $this->checkExpired( $value, $page, $staleConstraint, 'output' ) ) {
448			return false;
449		}
450
451		if ( $this->checkOutdated( $value, $page, $staleConstraint, 'output' ) ) {
452			return false;
453		}
454
455		$wikiPage = $this->wikiPageFactory->newFromTitle( $page );
456		if ( $this->hookRunner->onRejectParserCacheValue( $value, $wikiPage, $popts ) === false ) {
457			$this->incrementStats( $page, 'miss.rejected' );
458			$this->logger->debug( 'key valid, but rejected by RejectParserCacheValue hook handler',
459				[ 'name' => $this->name ] );
460			return false;
461		}
462
463		$this->logger->debug( 'ParserOutput cache found', [ 'name' => $this->name ] );
464		$this->incrementStats( $page, 'hit' );
465		return $value;
466	}
467
468	/**
469	 * @param ParserOutput $parserOutput
470	 * @param PageRecord $page
471	 * @param ParserOptions $popts
472	 * @param string|null $cacheTime TS_MW timestamp when the cache was generated
473	 * @param int|null $revId Revision ID that was parsed
474	 */
475	public function save(
476		ParserOutput $parserOutput,
477		PageRecord $page,
478		$popts,
479		$cacheTime = null,
480		$revId = null
481	) {
482		$page->assertWiki( PageRecord::LOCAL );
483
484		if ( !$parserOutput->hasText() ) {
485			throw new InvalidArgumentException( 'Attempt to cache a ParserOutput with no text set!' );
486		}
487
488		$expire = $parserOutput->getCacheExpiry();
489
490		if ( !$popts->isSafeToCache( $parserOutput->getUsedOptions() ) ) {
491			$this->logger->debug(
492				'Parser options are not safe to cache and has not been saved',
493				[ 'name' => $this->name ]
494			);
495			$this->incrementStats( $page, 'save.unsafe' );
496			return;
497		}
498
499		if ( $expire <= 0 ) {
500			$this->logger->debug(
501				'Parser output was marked as uncacheable and has not been saved',
502				[ 'name' => $this->name ]
503			);
504			$this->incrementStats( $page, 'save.uncacheable' );
505			return;
506		}
507
508		if ( $this->cache instanceof EmptyBagOStuff ) {
509			return;
510		}
511
512		$cacheTime = $cacheTime ?: wfTimestampNow();
513		$revId = $revId ?: $page->getLatest( PageRecord::LOCAL );
514
515		$metadata = new CacheTime;
516		$metadata->recordOptions( $parserOutput->getUsedOptions() );
517		$metadata->updateCacheExpiry( $expire );
518
519		$metadata->setCacheTime( $cacheTime );
520		$parserOutput->setCacheTime( $cacheTime );
521		$metadata->setCacheRevisionId( $revId );
522		$parserOutput->setCacheRevisionId( $revId );
523
524		$parserOutputKey = $this->makeParserOutputKey(
525			$page,
526			$popts,
527			$metadata->getUsedOptions()
528		);
529
530		$msg = "Saved in parser cache with key $parserOutputKey" .
531			" and timestamp $cacheTime" .
532			" and revision id $revId.";
533		if ( $this->writeJson ) {
534			$msg .= " Serialized with JSON.";
535		} else {
536			$msg .= " Serialized with PHP.";
537		}
538		$parserOutput->addCacheMessage( $msg );
539
540		$pageKey = $this->makeMetadataKey( $page );
541
542		if ( $this->writeJson ) {
543			$parserOutputData = $this->encodeAsJson( $parserOutput, $parserOutputKey );
544			$metadataData = $this->encodeAsJson( $metadata, $pageKey );
545		} else {
546			// rely on implicit PHP serialization in the cache
547			$parserOutputData = $parserOutput;
548			$metadataData = $metadata;
549		}
550
551		if ( !$parserOutputData || !$metadataData ) {
552			$this->logger->warning(
553				'Parser output failed to serialize and was not saved',
554				[ 'name' => $this->name ]
555			);
556			$this->incrementStats( $page, 'save.nonserializable' );
557			return;
558		}
559
560		// Save the parser output
561		$this->cache->set(
562			$parserOutputKey,
563			$parserOutputData,
564			$expire,
565			BagOStuff::WRITE_ALLOW_SEGMENTS
566		);
567
568		// ...and its pointer
569		$this->cache->set( $pageKey, $metadataData, $expire );
570
571		$title = $this->titleFactory->castFromPageIdentity( $page );
572		$this->hookRunner->onParserCacheSaveComplete( $this, $parserOutput, $title, $popts, $revId );
573
574		$this->logger->debug( 'Saved in parser cache', [
575			'name' => $this->name,
576			'key' => $parserOutputKey,
577			'cache_time' => $cacheTime,
578			'rev_id' => $revId
579		] );
580		$this->incrementStats( $page, 'save.success' );
581	}
582
583	/**
584	 * Get the backend BagOStuff instance that
585	 * powers the parser cache
586	 *
587	 * @since 1.30
588	 * @internal
589	 * @return BagOStuff
590	 */
591	public function getCacheStorage() {
592		return $this->cache;
593	}
594
595	/**
596	 * Check if $entry expired for $page given the $staleConstraint
597	 * when fetching from $cacheTier.
598	 * @param CacheTime $entry
599	 * @param PageRecord $page
600	 * @param int $staleConstraint One of USE_* constants.
601	 * @param string $cacheTier
602	 * @return bool
603	 */
604	private function checkExpired(
605		CacheTime $entry,
606		PageRecord $page,
607		int $staleConstraint,
608		string $cacheTier
609	): bool {
610		if ( $staleConstraint < self::USE_EXPIRED && $entry->expired( $page->getTouched() ) ) {
611			$this->incrementStats( $page, "miss.expired" );
612			$this->logger->debug( "{$cacheTier} key expired", [
613				'name' => $this->name,
614				'touched' => $page->getTouched(),
615				'epoch' => $this->cacheEpoch,
616				'cache_time' => $entry->getCacheTime()
617			] );
618			return true;
619		}
620		return false;
621	}
622
623	/**
624	 * Check if $entry belongs to the latest revision of $page
625	 * given $staleConstraint when fetched from $cacheTier.
626	 * @param CacheTime $entry
627	 * @param PageRecord $page
628	 * @param int $staleConstraint One of USE_* constants.
629	 * @param string $cacheTier
630	 * @return bool
631	 */
632	private function checkOutdated(
633		CacheTime $entry,
634		PageRecord $page,
635		int $staleConstraint,
636		string $cacheTier
637	): bool {
638		$latestRevId = $page->getLatest( PageRecord::LOCAL );
639		if ( $staleConstraint < self::USE_OUTDATED && $entry->isDifferentRevision( $latestRevId ) ) {
640			$this->incrementStats( $page, "miss.revid" );
641			$this->logger->debug( "{$cacheTier} key is for an old revision", [
642				'name' => $this->name,
643				'rev_id' => $latestRevId,
644				'cached_rev_id' => $entry->getCacheRevisionId()
645			] );
646			return true;
647		}
648		return false;
649	}
650
651	/**
652	 * @note setter for temporary feature flags, for use in testing.
653	 * @internal
654	 * @param bool $readJson
655	 * @param bool $writeJson
656	 */
657	public function setJsonSupport( bool $readJson, bool $writeJson ): void {
658		$this->readJson = $readJson;
659		$this->writeJson = $writeJson;
660	}
661
662	/**
663	 * @param string $jsonData
664	 * @param string $key
665	 * @param string $expectedClass
666	 * @return CacheTime|ParserOutput|null
667	 */
668	private function restoreFromJson( string $jsonData, string $key, string $expectedClass ) {
669		try {
670			/** @var CacheTime $obj */
671			$obj = $this->jsonCodec->unserialize( $jsonData, $expectedClass );
672			return $obj;
673		} catch ( InvalidArgumentException $e ) {
674			$this->logger->error( "Unable to unserialize JSON", [
675				'name' => $this->name,
676				'cache_key' => $key,
677				'message' => $e->getMessage()
678			] );
679			return null;
680		}
681	}
682
683	/**
684	 * @param CacheTime $obj
685	 * @param string $key
686	 * @return string|null
687	 */
688	private function encodeAsJson( CacheTime $obj, string $key ) {
689		try {
690			return $this->jsonCodec->serialize( $obj );
691		} catch ( InvalidArgumentException $e ) {
692			$this->logger->error( "Unable to serialize JSON", [
693				'name' => $this->name,
694				'cache_key' => $key,
695				'message' => $e->getMessage(),
696			] );
697			return null;
698		}
699	}
700}
701