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 24namespace MediaWiki\Parser; 25 26use CacheTime; 27use IBufferingStatsdDataFactory; 28use InvalidArgumentException; 29use MediaWiki\Json\JsonCodec; 30use MediaWiki\Revision\RevisionRecord; 31use MWTimestamp; 32use ParserOptions; 33use ParserOutput; 34use Psr\Log\LoggerInterface; 35use WANObjectCache; 36 37/** 38 * Cache for ParserOutput objects. 39 * The cache is split per ParserOptions. 40 * 41 * @since 1.36 42 * @ingroup Cache Parser 43 */ 44class RevisionOutputCache { 45 46 /** @var string The name of this cache. Used as a root of the cache key. */ 47 private $name; 48 49 /** @var WANObjectCache */ 50 private $cache; 51 52 /** 53 * Anything cached prior to this is invalidated 54 * 55 * @var string 56 */ 57 private $cacheEpoch; 58 59 /** 60 * Expiry time for cache entries. 61 * 62 * @var string 63 */ 64 private $cacheExpiry; 65 66 /** @var JsonCodec */ 67 private $jsonCodec; 68 69 /** @var IBufferingStatsdDataFactory */ 70 private $stats; 71 72 /** @var LoggerInterface */ 73 private $logger; 74 75 /** 76 * @param string $name 77 * @param WANObjectCache $cache 78 * @param int $cacheExpiry Expiry for ParserOutput in $cache. 79 * @param string $cacheEpoch Anything before this timestamp is invalidated 80 * @param JsonCodec $jsonCodec 81 * @param IBufferingStatsdDataFactory $stats 82 * @param LoggerInterface $logger 83 */ 84 public function __construct( 85 string $name, 86 WANObjectCache $cache, 87 int $cacheExpiry, 88 string $cacheEpoch, 89 JsonCodec $jsonCodec, 90 IBufferingStatsdDataFactory $stats, 91 LoggerInterface $logger 92 ) { 93 $this->name = $name; 94 $this->cache = $cache; 95 $this->cacheExpiry = $cacheExpiry; 96 $this->cacheEpoch = $cacheEpoch; 97 $this->jsonCodec = $jsonCodec; 98 $this->stats = $stats; 99 $this->logger = $logger; 100 } 101 102 /** 103 * @param RevisionRecord $revision 104 * @param string $metricSuffix 105 */ 106 private function incrementStats( RevisionRecord $revision, string $metricSuffix ) { 107 $metricSuffix = str_replace( '.', '_', $metricSuffix ); 108 $this->stats->increment( "RevisionOutputCache.{$this->name}.{$metricSuffix}" ); 109 } 110 111 /** 112 * Get a key that will be used by this cache to store the content 113 * for a given page considering the given options and the array of 114 * used options. 115 * 116 * @warning The exact format of the key is considered internal and is subject 117 * to change, thus should not be used as storage or long-term caching key. 118 * This is intended to be used for logging or keying something transient. 119 * 120 * @param RevisionRecord $revision 121 * @param ParserOptions $options 122 * @param array|null $usedOptions currently ignored 123 * @return string 124 * @internal 125 */ 126 public function makeParserOutputKey( 127 RevisionRecord $revision, 128 ParserOptions $options, 129 array $usedOptions = null 130 ): string { 131 $usedOptions = ParserOptions::allCacheVaryingOptions(); 132 133 $revId = $revision->getId(); 134 $hash = $options->optionsHash( $usedOptions ); 135 136 return $this->cache->makeKey( $this->name, $revId, $hash ); 137 } 138 139 /** 140 * Retrieve the ParserOutput from cache. 141 * false if not found or outdated. 142 * 143 * @param RevisionRecord $revision 144 * @param ParserOptions $parserOptions 145 * 146 * @return ParserOutput|bool False on failure 147 */ 148 public function get( RevisionRecord $revision, ParserOptions $parserOptions ) { 149 if ( $this->cacheExpiry <= 0 ) { 150 // disabled 151 return false; 152 } 153 154 if ( !$parserOptions->isSafeToCache() ) { 155 $this->incrementStats( $revision, 'miss.unsafe' ); 156 return false; 157 } 158 159 $cacheKey = $this->makeParserOutputKey( $revision, $parserOptions ); 160 $json = $this->cache->get( $cacheKey ); 161 162 if ( $json === false ) { 163 $this->incrementStats( $revision, 'miss.absent' ); 164 return false; 165 } 166 167 $output = $this->restoreFromJson( $json, $cacheKey, ParserOutput::class ); 168 if ( $output === null ) { 169 $this->incrementStats( $revision, 'miss.unserialize' ); 170 return false; 171 } 172 173 $cacheTime = (int)MWTimestamp::convert( TS_UNIX, $output->getCacheTime() ); 174 $expiryTime = (int)MWTimestamp::convert( TS_UNIX, $this->cacheEpoch ); 175 $expiryTime = max( $expiryTime, (int)MWTimestamp::now( TS_UNIX ) - $this->cacheExpiry ); 176 177 if ( $cacheTime < $expiryTime ) { 178 $this->incrementStats( $revision, 'miss.expired' ); 179 return false; 180 } 181 182 $this->logger->debug( 'output cache hit' ); 183 $this->incrementStats( $revision, 'hit' ); 184 return $output; 185 } 186 187 /** 188 * @param ParserOutput $output 189 * @param RevisionRecord $revision 190 * @param ParserOptions $parserOptions 191 * @param string|null $cacheTime TS_MW timestamp when the output was generated 192 */ 193 public function save( 194 ParserOutput $output, 195 RevisionRecord $revision, 196 ParserOptions $parserOptions, 197 string $cacheTime = null 198 ) { 199 if ( !$output->hasText() ) { 200 throw new InvalidArgumentException( 'Attempt to cache a ParserOutput with no text set!' ); 201 } 202 203 if ( $this->cacheExpiry <= 0 ) { 204 // disabled 205 return; 206 } 207 208 $cacheKey = $this->makeParserOutputKey( $revision, $parserOptions ); 209 210 $output->setCacheTime( $cacheTime ?: wfTimestampNow() ); 211 $output->setCacheRevisionId( $revision->getId() ); 212 213 // Save the timestamp so that we don't have to load the revision row on view 214 $output->setTimestamp( $revision->getTimestamp() ); 215 216 $msg = "Saved in RevisionOutputCache with key $cacheKey" . 217 " and timestamp $cacheTime" . 218 " and revision id {$revision->getId()}."; 219 220 $output->addCacheMessage( $msg ); 221 222 // The ParserOutput might be dynamic and have been marked uncacheable by the parser. 223 $output->updateCacheExpiry( $this->cacheExpiry ); 224 225 $expiry = $output->getCacheExpiry(); 226 if ( $expiry <= 0 ) { 227 $this->incrementStats( $revision, 'save.uncacheable' ); 228 return; 229 } 230 231 if ( !$parserOptions->isSafeToCache() ) { 232 $this->incrementStats( $revision, 'save.unsafe' ); 233 return; 234 } 235 236 $json = $this->encodeAsJson( $output, $cacheKey ); 237 if ( $json === null ) { 238 $this->incrementStats( $revision, 'save.nonserializable' ); 239 return; 240 } 241 242 $this->cache->set( $cacheKey, $json, $expiry ); 243 $this->incrementStats( $revision, 'save.success' ); 244 } 245 246 /** 247 * @param string $jsonData 248 * @param string $key 249 * @param string $expectedClass 250 * @return CacheTime|ParserOutput|null 251 */ 252 private function restoreFromJson( string $jsonData, string $key, string $expectedClass ) { 253 try { 254 /** @var CacheTime $obj */ 255 $obj = $this->jsonCodec->unserialize( $jsonData, $expectedClass ); 256 return $obj; 257 } catch ( InvalidArgumentException $e ) { 258 $this->logger->error( 'Unable to unserialize JSON', [ 259 'name' => $this->name, 260 'cache_key' => $key, 261 'message' => $e->getMessage() 262 ] ); 263 return null; 264 } 265 } 266 267 /** 268 * @param CacheTime $obj 269 * @param string $key 270 * @return string|null 271 */ 272 private function encodeAsJson( CacheTime $obj, string $key ) { 273 try { 274 return $this->jsonCodec->serialize( $obj ); 275 } catch ( InvalidArgumentException $e ) { 276 $this->logger->error( 'Unable to serialize JSON', [ 277 'name' => $this->name, 278 'cache_key' => $key, 279 'message' => $e->getMessage(), 280 ] ); 281 return null; 282 } 283 } 284} 285