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