Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.00% covered (warning)
79.00%
158 / 200
62.50% covered (warning)
62.50%
10 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageEditStash
79.00% covered (warning)
79.00%
158 / 200
62.50% covered (warning)
62.50%
10 / 16
83.01
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseAndCache
67.80% covered (warning)
67.80%
40 / 59
0.00% covered (danger)
0.00%
0 / 1
15.04
 checkCache
77.27% covered (warning)
77.27%
51 / 66
0.00% covered (danger)
0.00%
0 / 1
20.39
 incrCacheReadStats
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getAndWaitForStashValue
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 fetchInputText
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 stashInputText
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 lastEditTime
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getContentHash
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getStashKey
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getStashValue
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 storeStashValue
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
6.10
 pruneExcessStashedEntries
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 recentStashEntryCount
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 serializeStashInfo
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 unserializeStashInfo
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
1<?php
2declare( strict_types = 1 );
3/**
4 * @license GPL-2.0-or-later
5 * @file
6 */
7
8namespace MediaWiki\Storage;
9
10use JsonException;
11use MediaWiki\Content\Content;
12use MediaWiki\HookContainer\HookContainer;
13use MediaWiki\HookContainer\HookRunner;
14use MediaWiki\Json\JsonCodec;
15use MediaWiki\Page\PageIdentity;
16use MediaWiki\Page\WikiPage;
17use MediaWiki\Page\WikiPageFactory;
18use MediaWiki\Parser\ParserOutputFlags;
19use MediaWiki\Revision\SlotRecord;
20use MediaWiki\Storage\Hook\ParserOutputStashForEditHook;
21use MediaWiki\User\UserEditTracker;
22use MediaWiki\User\UserFactory;
23use MediaWiki\User\UserIdentity;
24use Psr\Log\LoggerInterface;
25use Wikimedia\LockManager\ILockManager;
26use Wikimedia\ObjectCache\BagOStuff;
27use Wikimedia\Rdbms\IConnectionProvider;
28use Wikimedia\Stats\StatsFactory;
29use Wikimedia\Timestamp\TimestampFormat as TS;
30
31/**
32 * Manage the pre-emptive page parsing for edits to wiki pages.
33 *
34 * This is written to by ApiStashEdit, and consumed by ApiEditPage
35 * and EditPage (via PageUpdaterFactory and DerivedPageDataUpdater).
36 *
37 * See also mediawiki.action.edit/stash.js.
38 *
39 * @since 1.34
40 * @ingroup Page
41 */
42class PageEditStash {
43    private readonly ParserOutputStashForEditHook $hookRunner;
44
45    public const ERROR_NONE = 'stashed';
46    public const ERROR_PARSE = 'error_parse';
47    public const ERROR_CACHE = 'error_cache';
48    public const ERROR_UNCACHEABLE = 'uncacheable';
49    public const ERROR_BUSY = 'busy';
50
51    public const PRESUME_FRESH_TTL_SEC = 30;
52    public const MAX_CACHE_TTL = 300; // 5 minutes
53    public const MAX_SIGNATURE_TTL = 60;
54
55    private const MAX_CACHE_RECENT = 2;
56
57    public const INITIATOR_USER = 1;
58    public const INITIATOR_JOB_OR_CLI = 2;
59
60    // Format version 2 was added in MW 1.40 but relies on PHP serialization
61    //   of ParserOutput which was last supported in MW 1.44.
62    // Format version 3 became the default in MW 1.45
63    public const CURRENT_FORMAT_VERSION = 3;
64    // Used for forward/backward compatibility; set to empty array to disable.
65    // As this is a short term stash (5 minutes) preservation
66    // across upgrades is not expected/guaranteed so long as
67    // CURRENT_FORMAT_VERSION is bumped.
68    public const OTHER_FORMAT_VERSIONS = [];
69
70    /**
71     * @param BagOStuff $cache
72     * @param IConnectionProvider $dbProvider
73     * @param LoggerInterface $logger
74     * @param StatsFactory $stats
75     * @param UserEditTracker $userEditTracker
76     * @param UserFactory $userFactory
77     * @param WikiPageFactory $wikiPageFactory
78     * @param JsonCodec $jsonCodec
79     * @param ILockManager $lockManager
80     * @param HookContainer $hookContainer
81     * @param int $initiator Class INITIATOR__* constant
82     */
83    public function __construct(
84        private BagOStuff $cache,
85        private IConnectionProvider $dbProvider,
86        private LoggerInterface $logger,
87        private StatsFactory $stats,
88        private UserEditTracker $userEditTracker,
89        private UserFactory $userFactory,
90        private WikiPageFactory $wikiPageFactory,
91        private JsonCodec $jsonCodec,
92        private ILockManager $lockManager,
93        HookContainer $hookContainer,
94        private readonly int $initiator,
95    ) {
96        $this->hookRunner = new HookRunner( $hookContainer );
97    }
98
99    /**
100     * @param PageUpdater $pageUpdater (a WikiPage instance is also supported but deprecated)
101     * @param Content $content Edit content
102     * @param UserIdentity $user
103     * @param string $summary Edit summary
104     * @return string Class ERROR_* constant
105     */
106    public function parseAndCache( $pageUpdater, Content $content, UserIdentity $user, string $summary ) {
107        $logger = $this->logger;
108
109        if ( $pageUpdater instanceof WikiPage ) {
110            wfDeprecated( __METHOD__ . ' with WikiPage instance', '1.42' );
111            $pageUpdater = $pageUpdater->newPageUpdater( $user );
112        }
113
114        $page = $pageUpdater->getPage();
115        $contentHash = $this->getContentHash( $content );
116        $key = $this->getStashKey( $page, $contentHash, $user );
117        $unlocker = $this->lockManager->scopedLock( $key );
118
119        if ( !$unlocker ) {
120            // De-duplicate requests on the same key
121            return self::ERROR_BUSY;
122        }
123
124        $cutoffTime = time() - self::PRESUME_FRESH_TTL_SEC;
125
126        // Reuse any freshly built matching edit stash cache
127        $editInfo = $this->getStashValue( $key );
128        // Forward and backward compatibility
129        // @phan-suppress-next-line PhanEmptyForeach
130        foreach ( self::OTHER_FORMAT_VERSIONS as $other_version ) {
131            if ( $editInfo !== false ) {
132                break;
133            }
134            $newKey = $this->getStashKey( $page, $contentHash, $user, $other_version );
135            $editInfo = $this->getStashValue( $newKey );
136        }
137        if ( $editInfo && (int)wfTimestamp( TS::UNIX, $editInfo->timestamp ) >= $cutoffTime ) {
138            $alreadyCached = true;
139        } else {
140            $pageUpdater->setContent( SlotRecord::MAIN, $content );
141
142            $update = $pageUpdater->prepareUpdate( EDIT_INTERNAL ); // applies pre-save transform
143            $output = $update->getCanonicalParserOutput(); // causes content to be parsed
144            $output->setCacheTime( $update->getRevision()->getTimestamp() );
145
146            // emulate a cache value that kind of looks like a PreparedEdit, for use below
147            $editInfo = new PageEditStashContents(
148                pstContent: $update->getRawContent( SlotRecord::MAIN ),
149                output:     $output,
150                timestamp:  $output->getCacheTime(),
151                edits:      $this->userEditTracker->getUserEditCount( $user ),
152            );
153
154            $alreadyCached = false;
155        }
156
157        $logContext = [ 'cachekey' => $key, 'title' => (string)$page ];
158
159        if ( $editInfo->output ) {
160            // Let extensions add ParserOutput metadata or warm other caches
161            $legacyUser = $this->userFactory->newFromUserIdentity( $user );
162            $legacyPage = $this->wikiPageFactory->newFromTitle( $page );
163            $this->hookRunner->onParserOutputStashForEdit(
164                $legacyPage, $content, $editInfo->output, $summary, $legacyUser );
165
166            if ( $alreadyCached ) {
167                $logger->debug( "Parser output for key '{cachekey}' already cached.", $logContext );
168
169                return self::ERROR_NONE;
170            }
171
172            $code = $this->storeStashValue(
173                $key,
174                $editInfo,
175                $user
176            );
177
178            if ( $code === true ) {
179                $logger->debug( "Cached parser output for key '{cachekey}'.", $logContext );
180
181                return self::ERROR_NONE;
182            } elseif ( $code === 'uncacheable' ) {
183                $logger->info(
184                    "Uncacheable parser output for key '{cachekey}' [{code}].",
185                    $logContext + [ 'code' => $code ]
186                );
187
188                return self::ERROR_UNCACHEABLE;
189            } else {
190                $logger->error(
191                    "Failed to cache parser output for key '{cachekey}'.",
192                    $logContext + [ 'code' => $code ]
193                );
194
195                return self::ERROR_CACHE;
196            }
197        }
198
199        return self::ERROR_PARSE;
200    }
201
202    /**
203     * Check that a prepared edit is in cache and still up-to-date
204     *
205     * This method blocks if the prepared edit is already being rendered,
206     * waiting until rendering finishes before doing final validity checks.
207     *
208     * The cache is rejected if template or file changes are detected.
209     * Note that foreign template or file transclusions are not checked.
210     *
211     * This returns a PageEditStashContents object with the following fields:
212     *   - pstContent: the Content after pre-save-transform
213     *   - output: the ParserOutput instance
214     *   - timestamp: the timestamp of the parse
215     *   - edits: author edit count if they are logged in or NULL otherwise
216     *
217     * @param PageIdentity $page
218     * @param Content $content
219     * @param UserIdentity $user to get parser options from
220     * @return PageEditStashContents|false Returns edit stash object or
221     *   false on cache miss
222     */
223    public function checkCache(
224        PageIdentity $page, Content $content, UserIdentity $user
225    ): PageEditStashContents|false {
226        $legacyUser = $this->userFactory->newFromUserIdentity( $user );
227        if (
228            // The context is not an HTTP POST request
229            !$legacyUser->getRequest()->wasPosted() ||
230            // The context is a CLI script or a job runner HTTP POST request
231            $this->initiator !== self::INITIATOR_USER ||
232            // The editor account is a known bot
233            $legacyUser->isBot()
234        ) {
235            // Avoid wasted queries and statsd pollution
236            return false;
237        }
238
239        $logger = $this->logger;
240
241        $contentHash = $this->getContentHash( $content );
242        $key = $this->getStashKey( $page, $contentHash, $user );
243
244        $logContext = [
245            'key' => $key,
246            'title' => (string)$page,
247            'user' => $user->getName()
248        ];
249
250        $editInfo = $this->getAndWaitForStashValue( $key );
251        // Forward and backward compatibility
252        // @phan-suppress-next-line PhanEmptyForeach
253        foreach ( self::OTHER_FORMAT_VERSIONS as $other_version ) {
254            if ( $editInfo !== false ) {
255                break;
256            }
257            $newKey = $this->getStashKey( $page, $contentHash, $user, $other_version );
258            // Not "getAndWait" because there shouldn't be anyone actively
259            // generating cache entries from other format versions, they are
260            // just left over from rollforward/rollback.
261            $editInfo = $this->getStashValue( $newKey );
262        }
263        if ( !is_object( $editInfo ) || !$editInfo->output ) {
264            $this->incrCacheReadStats( 'miss', 'no_stash', $content );
265            if ( $this->recentStashEntryCount( $user ) > 0 ) {
266                $logger->info( "Empty cache for key '{key}' but not for user.", $logContext );
267            } else {
268                $logger->debug( "Empty cache for key '{key}'.", $logContext );
269            }
270
271            return false;
272        }
273
274        $age = time() - (int)wfTimestamp( TS::UNIX, $editInfo->output->getCacheTime() );
275        $logContext['age'] = $age;
276
277        $isCacheUsable = true;
278        if ( $age <= self::PRESUME_FRESH_TTL_SEC ) {
279            // Assume nothing changed in this time
280            $this->incrCacheReadStats( 'hit', 'presumed_fresh', $content );
281            $logger->debug( "Timestamp-based cache hit for key '{key}'.", $logContext );
282        } elseif ( !$user->isRegistered() ) {
283            $lastEdit = $this->lastEditTime( $user );
284            $cacheTime = $editInfo->output->getCacheTime();
285            if ( $lastEdit < $cacheTime ) {
286                // Logged-out user made no local upload/template edits in the meantime
287                $this->incrCacheReadStats( 'hit', 'presumed_fresh', $content );
288                $logger->debug( "Edit check based cache hit for key '{key}'.", $logContext );
289            } else {
290                $isCacheUsable = false;
291                $this->incrCacheReadStats( 'miss', 'proven_stale', $content );
292                $logger->info( "Stale cache for key '{key}' due to outside edits.", $logContext );
293            }
294        } else {
295            if ( $editInfo->edits === $this->userEditTracker->getUserEditCount( $user ) ) {
296                // Logged-in user made no local upload/template edits in the meantime
297                $this->incrCacheReadStats( 'hit', 'presumed_fresh', $content );
298                $logger->debug( "Edit count based cache hit for key '{key}'.", $logContext );
299            } else {
300                $isCacheUsable = false;
301                $this->incrCacheReadStats( 'miss', 'proven_stale', $content );
302                $logger->info( "Stale cache for key '{key}'due to outside edits.", $logContext );
303            }
304        }
305
306        if ( !$isCacheUsable ) {
307            return false;
308        }
309
310        if ( $editInfo->output->getOutputFlag( ParserOutputFlags::VARY_REVISION ) ) {
311            // This can be used for the initial parse, e.g. for filters or doUserEditContent(),
312            // but a second parse will be triggered in doEditUpdates() no matter what
313            $logger->info(
314                "Cache for key '{key}' has vary-revision; post-insertion parse inevitable.",
315                $logContext
316            );
317        } else {
318            static $flagsMaybeReparse = [
319                // Similar to the above if we didn't guess the ID correctly
320                ParserOutputFlags::VARY_REVISION_ID,
321                // Similar to the above if we didn't guess the timestamp correctly
322                ParserOutputFlags::VARY_REVISION_TIMESTAMP,
323                // Similar to the above if we didn't guess the content correctly
324                ParserOutputFlags::VARY_REVISION_SHA1,
325                // Similar to the above if we didn't guess page ID correctly
326                ParserOutputFlags::VARY_PAGE_ID,
327            ];
328            foreach ( $flagsMaybeReparse as $flag ) {
329                if ( $editInfo->output->getOutputFlag( $flag ) ) {
330                    $logger->debug(
331                        "Cache for key '{key}' has {$flag->value}; post-insertion parse possible.",
332                        $logContext
333                    );
334                }
335            }
336        }
337
338        return $editInfo;
339    }
340
341    private function incrCacheReadStats( string $result, string $reason, Content $content ): void {
342        $this->stats->getCounter( "editstash_cache_checks_total" )
343            ->setLabel( 'reason', $reason )
344            ->setLabel( 'result', $result )
345            ->setLabel( 'model', $content->getModel() )
346            ->increment();
347    }
348
349    private function getAndWaitForStashValue( string $key ): PageEditStashContents|false {
350        $editInfo = $this->getStashValue( $key );
351
352        if ( !$editInfo ) {
353            $timer = $this->stats->getTiming( 'editstash_lock_wait_seconds' )
354                ->start();
355
356            // We ignore user aborts and keep parsing. Block on any prior parsing
357            // so as to use its results and make use of the time spent parsing.
358            if ( $this->lockManager->lockKey( $key, 30 ) ) {
359                $editInfo = $this->getStashValue( $key );
360                $this->lockManager->unlockKey( $key );
361            }
362
363            $timer->stop();
364        }
365
366        return $editInfo;
367    }
368
369    /**
370     * @param string $textHash
371     * @return string|false Text or false if missing
372     */
373    public function fetchInputText( string $textHash ): string|false {
374        $textKey = $this->cache->makeKey( 'stashedit', 'text', $textHash );
375
376        return $this->cache->get( $textKey );
377    }
378
379    /**
380     * @param string $text
381     * @param string $textHash
382     * @return bool Success
383     */
384    public function stashInputText( string $text, string $textHash ): bool {
385        $textKey = $this->cache->makeKey( 'stashedit', 'text', $textHash );
386
387        return $this->cache->set(
388            $textKey,
389            $text,
390            self::MAX_CACHE_TTL,
391            BagOStuff::WRITE_ALLOW_SEGMENTS
392        );
393    }
394
395    /**
396     * @param UserIdentity $user
397     * @return string|null TS::MW timestamp or null
398     */
399    private function lastEditTime( UserIdentity $user ): ?string {
400        $time = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
401            ->select( 'MAX(rc_timestamp)' )
402            ->from( 'recentchanges' )
403            ->join( 'actor', null, 'actor_id=rc_actor' )
404            ->where( [ 'actor_name' => $user->getName() ] )
405            ->caller( __METHOD__ )
406            ->fetchField();
407
408        return wfTimestampOrNull( TS::MW, $time );
409    }
410
411    /**
412     * Get hash of the content, factoring in model/format
413     *
414     * @param Content $content
415     * @return string
416     */
417    private function getContentHash( Content $content ): string {
418        return sha1( implode( "\n", [
419            $content->getModel(),
420            $content->getDefaultFormat(),
421            $content->serialize( $content->getDefaultFormat() )
422        ] ) );
423    }
424
425    /**
426     * Get the temporary prepared edit stash key for a user
427     *
428     * This key can be used for caching prepared edits provided:
429     *   - a) The $user was used for PST options
430     *   - b) The parser output was made from the PST using canonical matching options
431     *
432     * @param PageIdentity $page
433     * @param string $contentHash Result of getContentHash()
434     * @param UserIdentity $user User to get parser options from
435     * @return string
436     */
437    private function getStashKey(
438        PageIdentity $page,
439        string $contentHash,
440        UserIdentity $user,
441        int $version = self::CURRENT_FORMAT_VERSION
442    ): string {
443        return $this->cache->makeKey(
444            "stashedit-info-v{$version}",
445            md5( "{$page->getNamespace()}\n{$page->getDBkey()}" ),
446            // Account for the edit model/text
447            $contentHash,
448            // Account for user name related variables like signatures
449            md5( "{$user->getId()}\n{$user->getName()}" )
450        );
451    }
452
453    private function getStashValue( string $key ): PageEditStashContents|false {
454        $serial = $this->cache->get( $key );
455
456        return $serial === false ? false :
457            $this->unserializeStashInfo( $serial );
458    }
459
460    /**
461     * Build a value to store in memcached based on the PST content and parser output
462     *
463     * This makes a simple version of WikiPage::prepareContentForEdit() as stash info
464     *
465     * @param string $key
466     * @param PageEditStashContents $stashInfo
467     * @param UserIdentity $user
468     * @return string|true True or an error code
469     */
470    private function storeStashValue(
471        string $key,
472        PageEditStashContents $stashInfo,
473        UserIdentity $user
474    ): string|bool {
475        $parserOutput = $stashInfo->output;
476        // If an item is renewed, mind the cache TTL determined by config and parser functions.
477        // Put an upper limit on the TTL to avoid extreme template/file staleness.
478        $age = time() - (int)wfTimestamp( TS::UNIX, $parserOutput->getCacheTime() );
479        $ttl = min( $parserOutput->getCacheExpiry() - $age, self::MAX_CACHE_TTL );
480        // Avoid extremely stale user signature timestamps (T84843)
481        if ( $parserOutput->getOutputFlag( ParserOutputFlags::USER_SIGNATURE ) ) {
482            $ttl = min( $ttl, self::MAX_SIGNATURE_TTL );
483        }
484
485        if ( $ttl <= 0 ) {
486            return 'uncacheable'; // low TTL due to a tag, magic word, or signature?
487        }
488
489        // Store what is actually needed and split the output into another key (T204742)
490        $serial = $this->serializeStashInfo( $stashInfo );
491        if ( $serial === false ) {
492            return 'store_error';
493        }
494
495        $ok = $this->cache->set( $key, $serial, $ttl, BagOStuff::WRITE_ALLOW_SEGMENTS );
496        if ( $ok ) {
497            // These blobs can waste slots in low cardinality memcached slabs
498            $this->pruneExcessStashedEntries( $user, $key );
499        }
500
501        return $ok ? true : 'store_error';
502    }
503
504    /**
505     * @param UserIdentity $user
506     * @param string $newKey
507     */
508    private function pruneExcessStashedEntries( UserIdentity $user, string $newKey ): void {
509        $key = $this->cache->makeKey( 'stash-edit-recent', sha1( $user->getName() ) );
510
511        $keyList = $this->cache->get( $key ) ?: [];
512        if ( count( $keyList ) >= self::MAX_CACHE_RECENT ) {
513            $oldestKey = array_shift( $keyList );
514            $this->cache->delete( $oldestKey, BagOStuff::WRITE_ALLOW_SEGMENTS );
515        }
516
517        $keyList[] = $newKey;
518        $this->cache->set( $key, $keyList, 2 * self::MAX_CACHE_TTL );
519    }
520
521    private function recentStashEntryCount( UserIdentity $user ): int {
522        $key = $this->cache->makeKey( 'stash-edit-recent', sha1( $user->getName() ) );
523
524        return count( $this->cache->get( $key ) ?: [] );
525    }
526
527    private function serializeStashInfo( PageEditStashContents $stashInfo ): string|false {
528        try {
529            return $this->jsonCodec->serialize( $stashInfo );
530        } catch ( JsonException ) {
531            return false;
532        }
533    }
534
535    private function unserializeStashInfo( string $serial ): PageEditStashContents|false {
536        try {
537            return $this->jsonCodec->deserialize( $serial, PageEditStashContents::class );
538        } catch ( JsonException ) {
539            return false;
540        }
541    }
542}