$preparedEdit = new PreparedEdit();
$preparedEdit->popts = $this->getCanonicalParserOptions();
- $preparedEdit->output = $this->getCanonicalParserOutput();
+ $preparedEdit->parserOutputCallback = [ $this, 'getCanonicalParserOutput' ];
$preparedEdit->pstContent = $this->revision->getContent( SlotRecord::MAIN );
$preparedEdit->newContent =
$slotsUpdate->isModifiedSlot( SlotRecord::MAIN )
$legacyUser = User::newFromIdentity( $this->user );
$legacyRevision = new Revision( $this->revision );
- $this->doParserCacheUpdate();
+ $userParserOptions = ParserOptions::newFromUser( $legacyUser );
+ // Decide whether to save the final canonical parser ouput based on the fact that
+ // users are typically redirected to viewing pages right after they edit those pages.
+ // Due to vary-revision-id, getting/saving that output here might require a reparse.
+ if ( $userParserOptions->matchesForCacheKey( $this->getCanonicalParserOptions() ) ) {
+ // Whether getting the final output requires a reparse or not, the user will
+ // need canonical output anyway, since that is what their parser options use.
+ // A reparse now at least has the benefit of various warm process caches.
+ $this->doParserCacheUpdate();
+ } else {
+ // If the user does not have canonical parse options, then don't risk another parse
+ // to make output they cannot use on the page refresh that typically occurs after
+ // editing. Doing the parser output save post-send will still benefit *other* users.
+ DeferredUpdates::addCallableUpdate( function () {
+ $this->doParserCacheUpdate();
+ } );
+ }
- $this->doSecondaryDataUpdates( [
- // T52785 do not update any other pages on a null edit
- 'recursive' => $this->options['changed'],
- 'defer' => DeferredUpdates::POSTSEND,
- ] );
+ // Defer the getCannonicalParserOutput() call triggered by getSecondaryDataUpdates()
+ DeferredUpdates::addCallableUpdate( function () {
+ $this->doSecondaryDataUpdates( [
+ // T52785 do not update any other pages on a null edit
+ 'recursive' => $this->options['changed']
+ ] );
+ } );
// TODO: MCR: check if *any* changed slot supports categories!
if ( $this->rcWatchCategoryMembership
}
// TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
+ // @note: Extensions should *avoid* calling getCannonicalParserOutput() when using
+ // this hook whenever possible in order to avoid unnecessary additional parses.
$editInfo = $this->getPreparedEdit();
- Hooks::run( 'ArticleEditUpdates', [ &$wikiPage, &$editInfo, $this->options['changed'] ] );
+ Hooks::run( 'ArticleEditUpdates',
+ [ &$wikiPage, &$editInfo, $this->options['changed'] ] );
// TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$wikiPage ] ) ) {
use Content;
use ParserOptions;
+use RuntimeException;
use ParserOutput;
/**
* @since 1.30
*/
class PreparedEdit {
-
/**
* Time this prepared edit was made
*
*
* @var ParserOutput|null
*/
- public $output;
+ private $canonicalOutput;
/**
* Content that is being saved (before PST)
*/
public $oldContent;
+ /**
+ * Lazy-loading callback to get canonical ParserOutput object
+ *
+ * @var callable
+ */
+ public $parserOutputCallback;
+
+ /**
+ * @return ParserOutput Canonical parser output
+ */
+ public function getOutput() {
+ if ( !$this->canonicalOutput ) {
+ $this->canonicalOutput = call_user_func( $this->parserOutputCallback );
+ }
+
+ return $this->canonicalOutput;
+ }
+
+ /**
+ * Fetch the ParserOutput via a lazy-loaded callback (for backwards compatibility).
+ *
+ * @deprecated since 1.33
+ * @param string $name
+ * @return mixed
+ */
+ function __get( $name ) {
+ if ( $name === 'output' ) {
+ return $this->getOutput();
+ }
+
+ throw new RuntimeException( "Undefined field $name." );
+ }
}
*/
/**
- * Fetch an option, generically
+ * Fetch an option and track that is was accessed
* @since 1.30
* @param string $name Option name
* @return mixed
throw new InvalidArgumentException( "Unknown parser option $name" );
}
- if ( isset( self::$lazyOptions[$name] ) && $this->options[$name] === null ) {
- $this->options[$name] = call_user_func( self::$lazyOptions[$name], $this, $name );
- }
+ $this->lazyLoadOption( $name );
if ( !empty( self::$inCacheKey[$name] ) ) {
$this->optionUsed( $name );
}
return $this->options[$name];
}
+ /**
+ * @param string $name Lazy load option without tracking usage
+ */
+ private function lazyLoadOption( $name ) {
+ if ( isset( self::$lazyOptions[$name] ) && $this->options[$name] === null ) {
+ $this->options[$name] = call_user_func( self::$lazyOptions[$name], $this, $name );
+ }
+ }
+
/**
* Set an option, generically
* @since 1.30
* @since 1.25
*/
public function matches( ParserOptions $other ) {
- // Populate lazy options
- foreach ( self::$lazyOptions as $name => $callback ) {
- if ( $this->options[$name] === null ) {
- $this->options[$name] = call_user_func( $callback, $this, $name );
- }
- if ( $other->options[$name] === null ) {
- $other->options[$name] = call_user_func( $callback, $other, $name );
- }
- }
-
// Compare most options
$options = array_keys( $this->options );
$options = array_diff( $options, [
'enableLimitReport', // only affects HTML comments
] );
foreach ( $options as $option ) {
+ // Resolve any lazy options
+ $this->lazyLoadOption( $option );
+ $other->lazyLoadOption( $option );
+
$o1 = $this->optionToString( $this->options[$option] );
$o2 = $this->optionToString( $other->options[$option] );
if ( $o1 !== $o2 ) {
return true;
}
+ /**
+ * @param ParserOptions $other
+ * @return bool Whether the cache key relevant options match those of $other
+ * @since 1.33
+ */
+ public function matchesForCacheKey( ParserOptions $other ) {
+ foreach ( self::allCacheVaryingOptions() as $option ) {
+ // Populate any lazy options
+ $this->lazyLoadOption( $option );
+ $other->lazyLoadOption( $option );
+
+ $o1 = $this->optionToString( $this->options[$option] );
+ $o2 = $this->optionToString( $other->options[$option] );
+ if ( $o1 !== $o2 ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
/**
* Registers a callback for tracking which ParserOptions which are used.
* This is a private API with the parser.
$inCacheKey = self::allCacheVaryingOptions();
// Resolve any lazy options
- foreach ( array_intersect( $forOptions, $inCacheKey, array_keys( self::$lazyOptions ) ) as $k ) {
- if ( $this->options[$k] === null ) {
- $this->options[$k] = call_user_func( self::$lazyOptions[$k], $this, $k );
- }
+ $lazyOpts = array_intersect( $forOptions, $inCacheKey, array_keys( self::$lazyOptions ) );
+ foreach ( $lazyOpts as $k ) {
+ $this->lazyLoadOption( $k );
}
$options = $this->options;
use Wikimedia\TestingAccessWrapper;
use WikiPage;
use WikitextContent;
+use DeferredUpdates;
/**
* @group Database
/**
* @param string|Title|WikiPage $page
+ * @param RevisionRecord|null $rec
+ * @param User|null $user
*
* @return DerivedPageDataUpdater
*/
- private function getDerivedPageDataUpdater( $page, RevisionRecord $rec = null ) {
+ private function getDerivedPageDataUpdater(
+ $page, RevisionRecord $rec = null, User $user = null
+ ) {
if ( is_string( $page ) || $page instanceof Title ) {
$page = $this->getPage( $page );
}
$page = TestingAccessWrapper::newFromObject( $page );
- return $page->getDerivedDataUpdater( null, $rec );
+ return $page->getDerivedDataUpdater( $user, $rec );
}
/**
* @param WikiPage $page
* @param string|Message|CommentStoreComment $summary
* @param null|string|Content $content
+ * @param User|null $user
*
* @return RevisionRecord|null
*/
- private function createRevision( WikiPage $page, $summary, $content = null ) {
- $user = $this->getTestUser()->getUser();
+ private function createRevision( WikiPage $page, $summary, $content = null, $user = null ) {
+ $user = $user ?: $this->getTestUser()->getUser();
$comment = CommentStoreComment::newUnsavedComment( $summary );
if ( $content === null || is_string( $content ) ) {
// TODO: test category membership update (with setRcWatchCategoryMembership())
}
+ /**
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
+ */
+ public function testDoUpdatesCacheSaveDeferral_canonical() {
+ $page = $this->getPage( __METHOD__ );
+
+ // Case where user has canonical parser options
+ $content = [ 'main' => new WikitextContent( 'rev ID ver #1: {{REVISIONID}}' ) ];
+ $rev = $this->createRevision( $page, 'first', $content );
+ $pcache = MediaWikiServices::getInstance()->getParserCache();
+ $pcache->deleteOptionsKey( $page );
+
+ $this->db->startAtomic( __METHOD__ ); // let deferred updates queue up
+
+ $updater = $this->getDerivedPageDataUpdater( $page, $rev );
+ $updater->prepareUpdate( $rev, [] );
+ $updater->doUpdates();
+
+ $this->assertGreaterThan( 0, DeferredUpdates::pendingUpdatesCount(), 'Pending updates' );
+ $this->assertNotFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) );
+
+ $this->db->endAtomic( __METHOD__ ); // run deferred updates
+
+ $this->assertEquals( 0, DeferredUpdates::pendingUpdatesCount(), 'No pending updates' );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
+ */
+ public function testDoUpdatesCacheSaveDeferral_noncanonical() {
+ $page = $this->getPage( __METHOD__ );
+
+ // Case where user does not have canonical parser options
+ $user = $this->getMutableTestUser()->getUser();
+ $user->setOption(
+ 'thumbsize',
+ $user->getOption( 'thumbsize' ) + 1
+ );
+ $content = [ 'main' => new WikitextContent( 'rev ID ver #2: {{REVISIONID}}' ) ];
+ $rev = $this->createRevision( $page, 'first', $content, $user );
+ $pcache = MediaWikiServices::getInstance()->getParserCache();
+ $pcache->deleteOptionsKey( $page );
+
+ $this->db->startAtomic( __METHOD__ ); // let deferred updates queue up
+
+ $updater = $this->getDerivedPageDataUpdater( $page, $rev, $user );
+ $updater->prepareUpdate( $rev, [] );
+ $updater->doUpdates();
+
+ $this->assertGreaterThan( 1, DeferredUpdates::pendingUpdatesCount(), 'Pending updates' );
+ $this->assertFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) );
+
+ $this->db->endAtomic( __METHOD__ ); // run deferred updates
+
+ $this->assertEquals( 0, DeferredUpdates::pendingUpdatesCount(), 'No pending updates' );
+ $this->assertNotFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) );
+ }
+
/**
* @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
*/
--- /dev/null
+<?php
+
+namespace MediaWiki\Edit;
+
+use ParserOutput;
+use MediaWikiTestCase;
+
+/**
+ * @covers \MediaWiki\Edit\PreparedEdit
+ */
+class PreparedEditTest extends MediaWikiTestCase {
+ function testCallback() {
+ $output = new ParserOutput();
+ $edit = new PreparedEdit();
+ $edit->parserOutputCallback = function () {
+ return new ParserOutput();
+ };
+
+ $this->assertEquals( $output, $edit->getOutput() );
+ $this->assertEquals( $output, $edit->output );
+ }
+}
ScopedCallback::consume( $reset );
}
+ public function testMatchesForCacheKey() {
+ $cOpts = ParserOptions::newCanonical( null, 'en' );
+
+ $uOpts = ParserOptions::newFromAnon();
+ $this->assertTrue( $cOpts->matchesForCacheKey( $uOpts ) );
+
+ $user = new User();
+ $uOpts = ParserOptions::newFromUser( $user );
+ $this->assertTrue( $cOpts->matchesForCacheKey( $uOpts ) );
+
+ $user = new User();
+ $user->setOption( 'thumbsize', 251 );
+ $uOpts = ParserOptions::newFromUser( $user );
+ $this->assertFalse( $cOpts->matchesForCacheKey( $uOpts ) );
+
+ $user = new User();
+ $user->setOption( 'stubthreshold', 800 );
+ $uOpts = ParserOptions::newFromUser( $user );
+ $this->assertFalse( $cOpts->matchesForCacheKey( $uOpts ) );
+
+ $user = new User();
+ $uOpts = ParserOptions::newFromUserAndLang( $user, Language::factory( 'zh' ) );
+ $this->assertFalse( $cOpts->matchesForCacheKey( $uOpts ) );
+ }
+
public function testAllCacheVaryingOptions() {
$this->setTemporaryHook( 'ParserOptionsRegister', null );
$this->assertSame( [