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