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