Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.03% |
287 / 302 |
|
75.00% |
9 / 12 |
CRAP | |
0.00% |
0 / 1 |
ParserOutputAccess | |
95.03% |
287 / 302 |
|
75.00% |
9 / 12 |
88 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
shouldUseCache | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
8 | |||
getCachedParserOutput | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
16 | |||
getFallbackOutputForLatest | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
6 | |||
getParserOutput | |
100.00% |
65 / 65 |
|
100.00% |
1 / 1 |
15 | |||
renderRevision | |
100.00% |
49 / 49 |
|
100.00% |
1 / 1 |
16 | |||
checkPreconditions | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
10 | |||
newPoolWork | |
93.94% |
62 / 66 |
|
0.00% |
0 / 1 |
8.01 | |||
getPrimaryCache | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
getSecondaryCache | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
startOperationSpan | |
23.08% |
3 / 13 |
|
0.00% |
0 / 1 |
7.10 | |||
clearLocalCache | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | namespace MediaWiki\Page; |
21 | |
22 | use InvalidArgumentException; |
23 | use MediaWiki\MainConfigNames; |
24 | use MediaWiki\MediaWikiServices; |
25 | use MediaWiki\Parser\ParserCache; |
26 | use MediaWiki\Parser\ParserCacheFactory; |
27 | use MediaWiki\Parser\ParserOptions; |
28 | use MediaWiki\Parser\ParserOutput; |
29 | use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter; |
30 | use MediaWiki\Parser\RevisionOutputCache; |
31 | use MediaWiki\PoolCounter\PoolCounterFactory; |
32 | use MediaWiki\PoolCounter\PoolCounterWork; |
33 | use MediaWiki\PoolCounter\PoolCounterWorkViaCallback; |
34 | use MediaWiki\Revision\RevisionLookup; |
35 | use MediaWiki\Revision\RevisionRecord; |
36 | use MediaWiki\Revision\RevisionRenderer; |
37 | use MediaWiki\Status\Status; |
38 | use MediaWiki\Title\TitleFormatter; |
39 | use MediaWiki\WikiMap\WikiMap; |
40 | use Psr\Log\LoggerInterface; |
41 | use Wikimedia\Assert\Assert; |
42 | use Wikimedia\MapCacheLRU\MapCacheLRU; |
43 | use Wikimedia\Parsoid\Parsoid; |
44 | use Wikimedia\Rdbms\ChronologyProtector; |
45 | use Wikimedia\Stats\StatsFactory; |
46 | use Wikimedia\Telemetry\SpanInterface; |
47 | use Wikimedia\Telemetry\TracerInterface; |
48 | |
49 | /** |
50 | * Service for getting rendered output of a given page. |
51 | * |
52 | * This is a high level service, encapsulating concerns like caching |
53 | * and stampede protection via PoolCounter. |
54 | * |
55 | * @since 1.36 |
56 | * @ingroup Page |
57 | */ |
58 | class ParserOutputAccess { |
59 | |
60 | /** @internal */ |
61 | public const PARSOID_PCACHE_NAME = 'parsoid-' . ParserCacheFactory::DEFAULT_NAME; |
62 | |
63 | /** @internal */ |
64 | public const PARSOID_RCACHE_NAME = 'parsoid-' . ParserCacheFactory::DEFAULT_RCACHE_NAME; |
65 | |
66 | /** |
67 | * @var int Do not check the cache before parsing (force parse) |
68 | */ |
69 | public const OPT_NO_CHECK_CACHE = 1; |
70 | |
71 | /** @var int Alias for NO_CHECK_CACHE */ |
72 | public const OPT_FORCE_PARSE = self::OPT_NO_CHECK_CACHE; |
73 | |
74 | /** |
75 | * @var int Do not update the cache after parsing. |
76 | */ |
77 | public const OPT_NO_UPDATE_CACHE = 2; |
78 | |
79 | /** |
80 | * @var int Bypass audience check for deleted/suppressed revisions. |
81 | * The caller is responsible for ensuring that unauthorized access is prevented. |
82 | * If not set, output generation will fail if the revision is not public. |
83 | */ |
84 | public const OPT_NO_AUDIENCE_CHECK = 4; |
85 | |
86 | /** |
87 | * @var int Do not check the cache before parsing, |
88 | * and do not update the cache after parsing (not cacheable). |
89 | */ |
90 | public const OPT_NO_CACHE = self::OPT_NO_UPDATE_CACHE | self::OPT_NO_CHECK_CACHE; |
91 | |
92 | /** |
93 | * @var int Do perform an opportunistic LinksUpdate on cache miss |
94 | * @since 1.41 |
95 | */ |
96 | public const OPT_LINKS_UPDATE = 8; |
97 | |
98 | /** |
99 | * Apply page view semantics. This relaxes some guarantees, specifically: |
100 | * - Use PoolCounter for stampede protection, causing the request to |
101 | * block until another process has finished rendering the content. |
102 | * - Allow stale parser output to be returned to prevent long waits for |
103 | * slow renders. |
104 | * - Allow cacheable placeholder output to be returned when PoolCounter |
105 | * fails to obtain a lock. See the PoolCounterConf setting for details. |
106 | * |
107 | * @see Bug T352837 |
108 | * @since 1.42 |
109 | */ |
110 | public const OPT_FOR_ARTICLE_VIEW = 16; |
111 | |
112 | /** |
113 | * @var int Ignore the profile version of the result from the cache. |
114 | * Otherwise, if it's not Parsoid's default, it will be invalidated. |
115 | */ |
116 | public const OPT_IGNORE_PROFILE_VERSION = 128; |
117 | |
118 | /** @var string Do not read or write any cache */ |
119 | private const CACHE_NONE = 'none'; |
120 | |
121 | /** @var string Use primary cache */ |
122 | private const CACHE_PRIMARY = 'primary'; |
123 | |
124 | /** @var string Use secondary cache */ |
125 | private const CACHE_SECONDARY = 'secondary'; |
126 | |
127 | /** |
128 | * In cases that an extension tries to get the same ParserOutput of |
129 | * the page right after it was parsed (T301310). |
130 | * @var MapCacheLRU<string,ParserOutput> |
131 | */ |
132 | private MapCacheLRU $localCache; |
133 | |
134 | private ParserCacheFactory $parserCacheFactory; |
135 | private RevisionLookup $revisionLookup; |
136 | private RevisionRenderer $revisionRenderer; |
137 | private StatsFactory $statsFactory; |
138 | private ChronologyProtector $chronologyProtector; |
139 | private LoggerInterface $logger; |
140 | private WikiPageFactory $wikiPageFactory; |
141 | private TitleFormatter $titleFormatter; |
142 | private TracerInterface $tracer; |
143 | private PoolCounterFactory $poolCounterFactory; |
144 | |
145 | public function __construct( |
146 | ParserCacheFactory $parserCacheFactory, |
147 | RevisionLookup $revisionLookup, |
148 | RevisionRenderer $revisionRenderer, |
149 | StatsFactory $statsFactory, |
150 | ChronologyProtector $chronologyProtector, |
151 | LoggerInterface $logger, |
152 | WikiPageFactory $wikiPageFactory, |
153 | TitleFormatter $titleFormatter, |
154 | TracerInterface $tracer, |
155 | PoolCounterFactory $poolCounterFactory |
156 | ) { |
157 | $this->parserCacheFactory = $parserCacheFactory; |
158 | $this->revisionLookup = $revisionLookup; |
159 | $this->revisionRenderer = $revisionRenderer; |
160 | $this->statsFactory = $statsFactory; |
161 | $this->chronologyProtector = $chronologyProtector; |
162 | $this->logger = $logger; |
163 | $this->wikiPageFactory = $wikiPageFactory; |
164 | $this->titleFormatter = $titleFormatter; |
165 | $this->tracer = $tracer; |
166 | $this->poolCounterFactory = $poolCounterFactory; |
167 | |
168 | $this->localCache = new MapCacheLRU( 10 ); |
169 | } |
170 | |
171 | /** |
172 | * Use a cache? |
173 | * |
174 | * @param PageRecord $page |
175 | * @param RevisionRecord|null $rev |
176 | * |
177 | * @return string One of the CACHE_XXX constants. |
178 | */ |
179 | private function shouldUseCache( |
180 | PageRecord $page, |
181 | ?RevisionRecord $rev |
182 | ) { |
183 | if ( $rev && !$rev->getId() ) { |
184 | // The revision isn't from the database, so the output can't safely be cached. |
185 | return self::CACHE_NONE; |
186 | } |
187 | |
188 | // NOTE: Keep in sync with ParserWikiPage::shouldCheckParserCache(). |
189 | // NOTE: when we allow caching of old revisions in the future, |
190 | // we must not allow caching of deleted revisions. |
191 | |
192 | $wikiPage = $this->wikiPageFactory->newFromTitle( $page ); |
193 | if ( !$page->exists() || !$wikiPage->getContentHandler()->isParserCacheSupported() ) { |
194 | return self::CACHE_NONE; |
195 | } |
196 | |
197 | $isOld = $rev && $rev->getId() !== $page->getLatest(); |
198 | if ( !$isOld ) { |
199 | return self::CACHE_PRIMARY; |
200 | } |
201 | |
202 | if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_PUBLIC ) ) { |
203 | // deleted/suppressed revision |
204 | return self::CACHE_NONE; |
205 | } |
206 | |
207 | return self::CACHE_SECONDARY; |
208 | } |
209 | |
210 | /** |
211 | * Get the rendered output for the given page if it is present in the cache. |
212 | * |
213 | * @param PageRecord $page |
214 | * @param ParserOptions $parserOptions |
215 | * @param RevisionRecord|null $revision |
216 | * @param int $options Bitfield using the OPT_XXX constants |
217 | * @return ParserOutput|null |
218 | */ |
219 | public function getCachedParserOutput( |
220 | PageRecord $page, |
221 | ParserOptions $parserOptions, |
222 | ?RevisionRecord $revision = null, |
223 | int $options = 0 |
224 | ): ?ParserOutput { |
225 | $span = $this->startOperationSpan( __FUNCTION__, $page, $revision ); |
226 | $isOld = $revision && $revision->getId() !== $page->getLatest(); |
227 | $useCache = $this->shouldUseCache( $page, $revision ); |
228 | $primaryCache = $this->getPrimaryCache( $parserOptions ); |
229 | $classCacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions ); |
230 | |
231 | if ( $useCache === self::CACHE_PRIMARY ) { |
232 | if ( $this->localCache->hasField( $classCacheKey, $page->getLatest() ) && !$isOld ) { |
233 | return $this->localCache->getField( $classCacheKey, $page->getLatest() ); |
234 | } |
235 | $output = $primaryCache->get( $page, $parserOptions ); |
236 | } elseif ( $useCache === self::CACHE_SECONDARY && $revision ) { |
237 | $secondaryCache = $this->getSecondaryCache( $parserOptions ); |
238 | $output = $secondaryCache->get( $revision, $parserOptions ); |
239 | } else { |
240 | $output = null; |
241 | } |
242 | |
243 | $statType = $statReason = $output ? 'hit' : 'miss'; |
244 | |
245 | if ( |
246 | $output && !( $options & self::OPT_IGNORE_PROFILE_VERSION ) && |
247 | $parserOptions->getUseParsoid() |
248 | ) { |
249 | $pageBundleData = $output->getExtensionData( |
250 | PageBundleParserOutputConverter::PARSOID_PAGE_BUNDLE_KEY |
251 | ); |
252 | // T333606: Force a reparse if the version coming from cache is not the default |
253 | $cachedVersion = $pageBundleData['version'] ?? null; |
254 | if ( |
255 | $cachedVersion !== null && // T325137: BadContentModel, no sense in reparsing |
256 | $cachedVersion !== Parsoid::defaultHTMLVersion() |
257 | ) { |
258 | $statType = 'miss'; |
259 | $statReason = 'obsolete'; |
260 | $output = null; |
261 | } |
262 | } |
263 | |
264 | if ( $output && !$isOld ) { |
265 | $this->localCache->setField( $classCacheKey, $page->getLatest(), $output ); |
266 | } |
267 | |
268 | $this->statsFactory |
269 | ->getCounter( 'parseroutputaccess_cache_total' ) |
270 | ->setLabel( 'cache', $useCache ) |
271 | ->setLabel( 'reason', $statReason ) |
272 | ->setLabel( 'type', $statType ) |
273 | ->copyToStatsdAt( "ParserOutputAccess.Cache.$useCache.$statReason" ) |
274 | ->increment(); |
275 | |
276 | return $output ?: null; // convert false to null |
277 | } |
278 | |
279 | /** |
280 | * Fallback for use with PoolCounterWork. |
281 | * Returns stale cached output if appropriate. |
282 | * |
283 | * @return Status|false |
284 | */ |
285 | private function getFallbackOutputForLatest( |
286 | PageRecord $page, |
287 | ParserOptions $parserOptions, |
288 | string $workKey, |
289 | bool $fast |
290 | ) { |
291 | $parserOutput = $this->getPrimaryCache( $parserOptions ) |
292 | ->getDirty( $page, $parserOptions ); |
293 | |
294 | if ( !$parserOutput ) { |
295 | $this->logger->info( 'dirty missing' ); |
296 | return false; |
297 | } |
298 | |
299 | if ( $fast ) { |
300 | // If this user recently made DB changes, then don't eagerly serve stale output, |
301 | // so that users generally see their own edits after page save. |
302 | // |
303 | // If PoolCounter is overloaded, we may end up here a second time (with fast=false), |
304 | // in which case we will serve a stale fallback then. |
305 | // |
306 | // Note that CP reports anything in the last 10 seconds from the same client, |
307 | // including to other pages and other databases, so we bias towards avoiding |
308 | // fast-stale responses for several seconds after saving an edit. |
309 | if ( $this->chronologyProtector->getTouched() ) { |
310 | $this->logger->info( |
311 | 'declining fast-fallback to stale output since ChronologyProtector ' . |
312 | 'reports the client recently made changes', |
313 | [ 'workKey' => $workKey ] |
314 | ); |
315 | // Forget this ParserOutput -- we will request it again if |
316 | // necessary in slow mode. There might be a newer entry |
317 | // available by that time. |
318 | return false; |
319 | } |
320 | } |
321 | |
322 | $this->logger->info( $fast ? 'fast dirty output' : 'dirty output', [ 'workKey' => $workKey ] ); |
323 | |
324 | $status = Status::newGood( $parserOutput ); |
325 | $status->warning( 'view-pool-dirty-output' ); |
326 | $status->warning( $fast ? 'view-pool-contention' : 'view-pool-overload' ); |
327 | return $status; |
328 | } |
329 | |
330 | /** |
331 | * Returns the rendered output for the given page. |
332 | * Caching and concurrency control is applied. |
333 | * |
334 | * @param PageRecord $page |
335 | * @param ParserOptions $parserOptions |
336 | * @param RevisionRecord|null $revision |
337 | * @param int $options Bitfield using the OPT_XXX constants |
338 | * |
339 | * @return Status containing a ParserOutput if no error occurred. |
340 | * Well known errors and warnings include the following messages: |
341 | * - 'view-pool-dirty-output' (warning) The output is dirty (from a stale cache entry). |
342 | * - 'view-pool-contention' (warning) Dirty output was returned immediately instead of |
343 | * waiting to acquire a work lock (when "fast stale" mode is enabled in PoolCounter). |
344 | * - 'view-pool-timeout' (warning) Dirty output was returned after failing to acquire |
345 | * a work lock (got QUEUE_FULL or TIMEOUT from PoolCounter). |
346 | * - 'pool-queuefull' (error) unable to acquire work lock, and no cached content found. |
347 | * - 'pool-timeout' (error) unable to acquire work lock, and no cached content found. |
348 | * - 'pool-servererror' (error) PoolCounterWork failed due to a lock service error. |
349 | * - 'pool-unknownerror' (error) PoolCounterWork failed for an unknown reason. |
350 | * - 'nopagetext' (error) The page does not exist |
351 | */ |
352 | public function getParserOutput( |
353 | PageRecord $page, |
354 | ParserOptions $parserOptions, |
355 | ?RevisionRecord $revision = null, |
356 | int $options = 0 |
357 | ): Status { |
358 | $span = $this->startOperationSpan( __FUNCTION__, $page, $revision ); |
359 | $error = $this->checkPreconditions( $page, $revision, $options ); |
360 | if ( $error ) { |
361 | $this->statsFactory |
362 | ->getCounter( 'parseroutputaccess_case' ) |
363 | ->setLabel( 'case', 'error' ) |
364 | ->copyToStatsdAt( 'ParserOutputAccess.Case.error' ) |
365 | ->increment(); |
366 | return $error; |
367 | } |
368 | |
369 | $isOld = $revision && $revision->getId() !== $page->getLatest(); |
370 | if ( $isOld ) { |
371 | $this->statsFactory |
372 | ->getCounter( 'parseroutputaccess_case' ) |
373 | ->setLabel( 'case', 'old' ) |
374 | ->copyToStatsdAt( 'ParserOutputAccess.Case.old' ) |
375 | ->increment(); |
376 | } else { |
377 | $this->statsFactory |
378 | ->getCounter( 'parseroutputaccess_case' ) |
379 | ->setLabel( 'case', 'current' ) |
380 | ->copyToStatsdAt( 'ParserOutputAccess.Case.current' ) |
381 | ->increment(); |
382 | } |
383 | |
384 | if ( !( $options & self::OPT_NO_CHECK_CACHE ) ) { |
385 | $output = $this->getCachedParserOutput( $page, $parserOptions, $revision ); |
386 | if ( $output ) { |
387 | return Status::newGood( $output ); |
388 | } |
389 | } |
390 | |
391 | if ( !$revision ) { |
392 | $revId = $page->getLatest(); |
393 | $revision = $revId ? $this->revisionLookup->getRevisionById( $revId ) : null; |
394 | |
395 | if ( !$revision ) { |
396 | $this->statsFactory |
397 | ->getCounter( 'parseroutputaccess_status' ) |
398 | ->setLabel( 'status', 'norev' ) |
399 | ->copyToStatsdAt( "ParserOutputAccess.Status.norev" ) |
400 | ->increment(); |
401 | return Status::newFatal( 'missing-revision', $revId ); |
402 | } |
403 | } |
404 | |
405 | if ( $options & self::OPT_FOR_ARTICLE_VIEW ) { |
406 | $work = $this->newPoolWork( $page, $parserOptions, $revision, $options ); |
407 | /** @var Status $status */ |
408 | $status = $work->execute(); |
409 | } else { |
410 | // XXX: we could try harder to reuse a cache lookup above to |
411 | // provide the $previous argument here |
412 | $this->statsFactory->getCounter( 'parseroutputaccess_render_total' ) |
413 | ->setLabel( 'pool', 'none' ) |
414 | ->setLabel( 'cache', self::CACHE_NONE ) |
415 | ->copyToStatsdAt( 'ParserOutputAccess.PoolWork.None' ) |
416 | ->increment(); |
417 | |
418 | $status = $this->renderRevision( $page, $parserOptions, $revision, $options, null ); |
419 | } |
420 | |
421 | $output = $status->getValue(); |
422 | Assert::postcondition( $output || !$status->isOK(), 'Inconsistent status' ); |
423 | |
424 | if ( $output && !$isOld ) { |
425 | $primaryCache = $this->getPrimaryCache( $parserOptions ); |
426 | $classCacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions ); |
427 | $this->localCache->setField( $classCacheKey, $page->getLatest(), $output ); |
428 | } |
429 | |
430 | if ( $status->isGood() ) { |
431 | $this->statsFactory->getCounter( 'parseroutputaccess_status' ) |
432 | ->setLabel( 'status', 'good' ) |
433 | ->copyToStatsdAt( 'ParserOutputAccess.Status.good' ) |
434 | ->increment(); |
435 | } elseif ( $status->isOK() ) { |
436 | $this->statsFactory->getCounter( 'parseroutputaccess_status' ) |
437 | ->setLabel( 'status', 'ok' ) |
438 | ->copyToStatsdAt( 'ParserOutputAccess.Status.ok' ) |
439 | ->increment(); |
440 | } else { |
441 | $this->statsFactory->getCounter( 'parseroutputaccess_status' ) |
442 | ->setLabel( 'status', 'error' ) |
443 | ->copyToStatsdAt( 'ParserOutputAccess.Status.error' ) |
444 | ->increment(); |
445 | } |
446 | |
447 | return $status; |
448 | } |
449 | |
450 | /** |
451 | * Render the given revision. |
452 | * |
453 | * This method will update the parser cache if appropriate, and will |
454 | * trigger a links update if OPT_LINKS_UPDATE is set. |
455 | * |
456 | * This method does not perform access checks, and will not load content |
457 | * from caches. The caller is assumed to have taken care of that. |
458 | * |
459 | * Where possible, pass in a $previousOutput, which will prevent an |
460 | * unnecessary double-lookup in the cache. |
461 | * |
462 | * @see PoolWorkArticleView::renderRevision |
463 | */ |
464 | private function renderRevision( |
465 | PageRecord $page, |
466 | ParserOptions $parserOptions, |
467 | RevisionRecord $revision, |
468 | int $options, |
469 | ?ParserOutput $previousOutput = null |
470 | ): Status { |
471 | $span = $this->startOperationSpan( __FUNCTION__, $page, $revision ); |
472 | |
473 | $isCurrent = $revision->getId() === $page->getLatest(); |
474 | $useCache = $this->shouldUseCache( $page, $revision ); |
475 | |
476 | // T371713: Temporary statistics collection code to determine |
477 | // feasibility of Parsoid selective update |
478 | $sampleRate = MediaWikiServices::getInstance()->getMainConfig()->get( |
479 | MainConfigNames::ParsoidSelectiveUpdateSampleRate |
480 | ); |
481 | $doSample = ( $sampleRate && mt_rand( 1, $sampleRate ) === 1 ); |
482 | |
483 | if ( $previousOutput === null && ( $doSample || $parserOptions->getUseParsoid() ) ) { |
484 | // If $useCache === self::CACHE_SECONDARY we could potentially |
485 | // try to reuse the parse of $revision-1 from the secondary cache, |
486 | // but it is likely those template transclusions are out of date. |
487 | // Try to reuse the template transclusions from the most recent |
488 | // parse, which are more likely to reflect the current template. |
489 | if ( !( $options & self::OPT_NO_CHECK_CACHE ) ) { |
490 | $previousOutput = $this->getPrimaryCache( $parserOptions )->getDirty( $page, $parserOptions ) ?: null; |
491 | } |
492 | } |
493 | |
494 | $renderedRev = $this->revisionRenderer->getRenderedRevision( |
495 | $revision, |
496 | $parserOptions, |
497 | null, |
498 | [ |
499 | 'audience' => RevisionRecord::RAW, |
500 | 'previous-output' => $previousOutput, |
501 | ] |
502 | ); |
503 | |
504 | $output = $renderedRev->getRevisionParserOutput(); |
505 | |
506 | if ( $doSample ) { |
507 | $labels = [ |
508 | 'source' => 'ParserOutputAccess', |
509 | 'type' => $previousOutput === null ? 'full' : 'selective', |
510 | 'reason' => $parserOptions->getRenderReason(), |
511 | 'parser' => $parserOptions->getUseParsoid() ? 'parsoid' : 'legacy', |
512 | 'opportunistic' => 'false', |
513 | 'wiki' => WikiMap::getCurrentWikiId(), |
514 | 'model' => $revision->getMainContentModel(), |
515 | ]; |
516 | $this->statsFactory |
517 | ->getCounter( 'ParserCache_selective_total' ) |
518 | ->setLabels( $labels ) |
519 | ->increment(); |
520 | $this->statsFactory |
521 | ->getCounter( 'ParserCache_selective_cpu_seconds' ) |
522 | ->setLabels( $labels ) |
523 | ->incrementBy( $output->getTimeProfile( 'cpu' ) ); |
524 | } |
525 | |
526 | if ( !( $options & self::OPT_NO_UPDATE_CACHE ) && $output->isCacheable() ) { |
527 | if ( $useCache === self::CACHE_PRIMARY ) { |
528 | $primaryCache = $this->getPrimaryCache( $parserOptions ); |
529 | $primaryCache->save( $output, $page, $parserOptions ); |
530 | } elseif ( $useCache === self::CACHE_SECONDARY ) { |
531 | $secondaryCache = $this->getSecondaryCache( $parserOptions ); |
532 | $secondaryCache->save( $output, $revision, $parserOptions ); |
533 | } |
534 | } |
535 | |
536 | if ( $isCurrent && ( $options & self::OPT_LINKS_UPDATE ) ) { |
537 | $this->wikiPageFactory->newFromTitle( $page ) |
538 | ->triggerOpportunisticLinksUpdate( $output ); |
539 | } |
540 | |
541 | return Status::newGood( $output ); |
542 | } |
543 | |
544 | /** |
545 | * @param PageRecord $page |
546 | * @param RevisionRecord|null $revision |
547 | * @param int $options |
548 | * |
549 | * @return Status|null |
550 | */ |
551 | private function checkPreconditions( |
552 | PageRecord $page, |
553 | ?RevisionRecord $revision = null, |
554 | int $options = 0 |
555 | ): ?Status { |
556 | if ( !$page->exists() ) { |
557 | return Status::newFatal( 'nopagetext' ); |
558 | } |
559 | |
560 | if ( !( $options & self::OPT_NO_UPDATE_CACHE ) && $revision && !$revision->getId() ) { |
561 | throw new InvalidArgumentException( |
562 | 'The revision does not have a known ID. Use OPT_NO_CACHE.' |
563 | ); |
564 | } |
565 | |
566 | if ( $revision && $revision->getPageId() !== $page->getId() ) { |
567 | throw new InvalidArgumentException( |
568 | 'The revision does not belong to the given page.' |
569 | ); |
570 | } |
571 | |
572 | if ( $revision && !( $options & self::OPT_NO_AUDIENCE_CHECK ) ) { |
573 | // NOTE: If per-user checks are desired, the caller should perform them and |
574 | // then set OPT_NO_AUDIENCE_CHECK if they passed. |
575 | if ( !$revision->audienceCan( RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_PUBLIC ) ) { |
576 | return Status::newFatal( |
577 | 'missing-revision-permission', |
578 | $revision->getId(), |
579 | $revision->getTimestamp(), |
580 | $this->titleFormatter->getPrefixedDBkey( $page ) |
581 | ); |
582 | } |
583 | } |
584 | |
585 | return null; |
586 | } |
587 | |
588 | protected function newPoolWork( |
589 | PageRecord $page, |
590 | ParserOptions $parserOptions, |
591 | RevisionRecord $revision, |
592 | int $options |
593 | ): PoolCounterWork { |
594 | // Default behavior (no caching) |
595 | $callbacks = [ |
596 | 'doWork' => function () use ( $page, $parserOptions, $revision, $options ) { |
597 | return $this->renderRevision( |
598 | $page, |
599 | $parserOptions, |
600 | $revision, |
601 | $options |
602 | ); |
603 | }, |
604 | 'doCachedWork' => static function () { |
605 | // uncached |
606 | return false; |
607 | }, |
608 | 'fallback' => static function ( $fast ) { |
609 | // no fallback |
610 | return false; |
611 | }, |
612 | 'error' => static function ( $status ) { |
613 | return $status; |
614 | } |
615 | ]; |
616 | |
617 | $useCache = $this->shouldUseCache( $page, $revision ); |
618 | |
619 | $statCacheLabelLegacy = [ |
620 | self::CACHE_PRIMARY => 'Current', |
621 | self::CACHE_SECONDARY => 'Old', |
622 | ][$useCache] ?? 'Uncached'; |
623 | |
624 | $this->statsFactory->getCounter( 'parseroutputaccess_render_total' ) |
625 | ->setLabel( 'pool', 'articleview' ) |
626 | ->setLabel( 'cache', $useCache ) |
627 | ->copyToStatsdAt( "ParserOutputAccess.PoolWork.$statCacheLabelLegacy" ) |
628 | ->increment(); |
629 | |
630 | switch ( $useCache ) { |
631 | case self::CACHE_PRIMARY: |
632 | $primaryCache = $this->getPrimaryCache( $parserOptions ); |
633 | $parserCacheMetadata = $primaryCache->getMetadata( $page ); |
634 | $cacheKey = $primaryCache->makeParserOutputKey( $page, $parserOptions, |
635 | $parserCacheMetadata ? $parserCacheMetadata->getUsedOptions() : null |
636 | ); |
637 | |
638 | $workKey = $cacheKey . ':revid:' . $revision->getId(); |
639 | |
640 | $callbacks['doCachedWork'] = |
641 | function () use ( $primaryCache, $page, $parserOptions ) { |
642 | $parserOutput = $primaryCache->get( $page, $parserOptions ); |
643 | |
644 | $poolType = 'ArticleView'; |
645 | $status = ( $parserOutput ? 'hit' : 'miss' ); |
646 | $this->logger->debug( "Render for pool $poolType got a $status from ParserCache" ); |
647 | |
648 | return $parserOutput ? Status::newGood( $parserOutput ) : false; |
649 | }; |
650 | |
651 | $callbacks['fallback'] = |
652 | function ( $fast ) use ( $page, $parserOptions, $workKey ) { |
653 | return $this->getFallbackOutputForLatest( |
654 | $page, $parserOptions, $workKey, $fast |
655 | ); |
656 | }; |
657 | |
658 | break; |
659 | |
660 | case self::CACHE_SECONDARY: |
661 | $secondaryCache = $this->getSecondaryCache( $parserOptions ); |
662 | $workKey = $secondaryCache->makeParserOutputKey( $revision, $parserOptions ); |
663 | |
664 | $callbacks['doCachedWork'] = |
665 | static function () use ( $secondaryCache, $revision, $parserOptions ) { |
666 | $parserOutput = $secondaryCache->get( $revision, $parserOptions ); |
667 | |
668 | return $parserOutput ? Status::newGood( $parserOutput ) : false; |
669 | }; |
670 | |
671 | break; |
672 | |
673 | default: |
674 | $secondaryCache = $this->getSecondaryCache( $parserOptions ); |
675 | $workKey = $secondaryCache->makeParserOutputKeyOptionalRevId( $revision, $parserOptions ); |
676 | } |
677 | |
678 | $pool = $this->poolCounterFactory->create( 'ArticleView', $workKey ); |
679 | return new PoolCounterWorkViaCallback( |
680 | $pool, |
681 | $workKey, |
682 | $callbacks |
683 | ); |
684 | } |
685 | |
686 | private function getPrimaryCache( ParserOptions $pOpts ): ParserCache { |
687 | if ( $pOpts->getUseParsoid() ) { |
688 | return $this->parserCacheFactory->getParserCache( |
689 | self::PARSOID_PCACHE_NAME |
690 | ); |
691 | } |
692 | |
693 | return $this->parserCacheFactory->getParserCache( |
694 | ParserCacheFactory::DEFAULT_NAME |
695 | ); |
696 | } |
697 | |
698 | private function getSecondaryCache( ParserOptions $pOpts ): RevisionOutputCache { |
699 | if ( $pOpts->getUseParsoid() ) { |
700 | return $this->parserCacheFactory->getRevisionOutputCache( |
701 | self::PARSOID_RCACHE_NAME |
702 | ); |
703 | } |
704 | |
705 | return $this->parserCacheFactory->getRevisionOutputCache( |
706 | ParserCacheFactory::DEFAULT_RCACHE_NAME |
707 | ); |
708 | } |
709 | |
710 | private function startOperationSpan( |
711 | string $opName, |
712 | PageRecord $page, |
713 | ?RevisionRecord $revision = null |
714 | ): SpanInterface { |
715 | $span = $this->tracer->createSpan( "ParserOutputAccess::$opName" ); |
716 | if ( $span->getContext()->isSampled() ) { |
717 | $span->setAttributes( [ |
718 | 'org.wikimedia.parser.page' => $page->__toString(), |
719 | 'org.wikimedia.parser.page.id' => $page->getId(), |
720 | 'org.wikimedia.parser.page.wiki' => $page->getWikiId(), |
721 | ] ); |
722 | if ( $revision ) { |
723 | $span->setAttributes( [ |
724 | 'org.wikimedia.parser.revision.id' => $revision->getId(), |
725 | 'org.wikimedia.parser.revision.parent_id' => $revision->getParentId(), |
726 | ] ); |
727 | } |
728 | } |
729 | return $span->start()->activate(); |
730 | } |
731 | |
732 | /** |
733 | * Clear the local cache |
734 | * @since 1.45 |
735 | */ |
736 | public function clearLocalCache() { |
737 | $this->localCache->clear(); |
738 | } |
739 | } |