Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.47% covered (warning)
82.47%
480 / 582
66.67% covered (warning)
66.67%
36 / 54
CRAP
0.00% covered (danger)
0.00%
0 / 1
DerivedPageDataUpdater
82.47% covered (warning)
82.47%
480 / 582
66.67% covered (warning)
66.67%
36 / 54
418.48
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCause
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getCause
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 doTransition
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 assertTransition
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 isReusableFor
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
24.33
 setForceEmptyRevision
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 setArticleCountMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRcWatchCategoryMembership
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWikiPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 pageExisted
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getParentRevision
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 grabCurrentRevision
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 isContentPrepared
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isUpdatePrepared
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getPageId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isContentDeleted
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getRawSlot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRawContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContentHandler
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 usePrimary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isCountable
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 isRedirect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 revisionIsRedirect
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 prepareContent
82.98% covered (warning)
82.98%
78 / 94
0.00% covered (danger)
0.00%
0 / 1
20.78
 getRevision
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRenderedRevision
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 assertHasPageState
20.00% covered (danger)
20.00%
1 / 5
0.00% covered (danger)
0.00%
0 / 1
4.05
 assertPrepared
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
3.69
 assertHasRevision
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
3.69
 isCreation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isChange
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 wasRedirect
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 getSlots
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionSlotsUpdate
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getTouchedSlotRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModifiedSlotRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRemovedSlotRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepareUpdate
68.69% covered (warning)
68.69%
68 / 99
0.00% covered (danger)
0.00%
0 / 1
52.07
 getPreparedEdit
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 getSlotParserOutput
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getParserOutputForMetaData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCanonicalParserOutput
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCanonicalParserOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSecondaryDataUpdates
95.35% covered (success)
95.35%
41 / 43
0.00% covered (danger)
0.00%
0 / 1
8
 shouldGenerateHTMLOnEdit
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 doUpdates
69.66% covered (warning)
69.66%
62 / 89
0.00% covered (danger)
0.00%
0 / 1
66.28
 triggerParserCacheUpdate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 maybeEnqueueRevertedTagUpdateJob
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 doSecondaryDataUpdates
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
4.05
 doParserCacheUpdate
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
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
21namespace MediaWiki\Storage;
22
23use CategoryMembershipChangeJob;
24use Content;
25use ContentHandler;
26use IDBAccessObject;
27use InvalidArgumentException;
28use JobQueueGroup;
29use Language;
30use LogicException;
31use MediaWiki\Config\ServiceOptions;
32use MediaWiki\Content\IContentHandlerFactory;
33use MediaWiki\Content\Transform\ContentTransformer;
34use MediaWiki\Deferred\DeferrableUpdate;
35use MediaWiki\Deferred\DeferredUpdates;
36use MediaWiki\Deferred\LinksUpdate\LinksUpdate;
37use MediaWiki\Deferred\SearchUpdate;
38use MediaWiki\Deferred\SiteStatsUpdate;
39use MediaWiki\Edit\PreparedEdit;
40use MediaWiki\HookContainer\HookContainer;
41use MediaWiki\HookContainer\HookRunner;
42use MediaWiki\MainConfigNames;
43use MediaWiki\Page\PageIdentity;
44use MediaWiki\Page\ParserOutputAccess;
45use MediaWiki\Parser\ParserOutput;
46use MediaWiki\Permissions\PermissionManager;
47use MediaWiki\ResourceLoader as RL;
48use MediaWiki\Revision\MutableRevisionRecord;
49use MediaWiki\Revision\RenderedRevision;
50use MediaWiki\Revision\RevisionRecord;
51use MediaWiki\Revision\RevisionRenderer;
52use MediaWiki\Revision\RevisionSlots;
53use MediaWiki\Revision\RevisionStore;
54use MediaWiki\Revision\SlotRecord;
55use MediaWiki\Revision\SlotRoleRegistry;
56use MediaWiki\Title\Title;
57use MediaWiki\User\TalkPageNotificationManager;
58use MediaWiki\User\User;
59use MediaWiki\User\UserIdentity;
60use MediaWiki\User\UserNameUtils;
61use MediaWiki\Utils\MWTimestamp;
62use MessageCache;
63use MWUnknownContentModelException;
64use ParserCache;
65use ParserOptions;
66use ParsoidCachePrewarmJob;
67use Psr\Log\LoggerAwareInterface;
68use Psr\Log\LoggerInterface;
69use Psr\Log\NullLogger;
70use RefreshSecondaryDataUpdate;
71use RevertedTagUpdateJob;
72use WANObjectCache;
73use Wikimedia\Assert\Assert;
74use Wikimedia\Rdbms\ILBFactory;
75use WikiPage;
76
77/**
78 * A handle for managing updates for derived page data on edit, import, purge, etc.
79 *
80 * @note Avoid direct usage of DerivedPageDataUpdater.
81 *
82 * @todo Define interfaces for the different use cases of DerivedPageDataUpdater, particularly
83 * providing access to post-PST content and ParserOutput to callbacks during revision creation,
84 * which currently use WikiPage::prepareContentForEdit, and allowing updates to be triggered on
85 * purge, import, and undeletion, which currently use WikiPage::doEditUpdates() and
86 * Content::getSecondaryDataUpdates().
87 *
88 * DerivedPageDataUpdater instances are designed to be cached inside a WikiPage instance,
89 * and re-used by callback code over the course of an update operation. It's a stepping stone
90 * on the way to a more complete refactoring of WikiPage.
91 *
92 * When using a DerivedPageDataUpdater, the following life cycle must be observed:
93 * grabCurrentRevision (optional), prepareContent (optional), prepareUpdate (required
94 * for doUpdates). getCanonicalParserOutput, getSlots, and getSecondaryDataUpdates
95 * require prepareContent or prepareUpdate to have been called first, to initialize the
96 * DerivedPageDataUpdater.
97 *
98 * MCR migration note: this replaces the relevant methods in WikiPage, and covers the use cases
99 * of PreparedEdit.
100 *
101 * @see docs/pageupdater.md for more information.
102 *
103 * @internal
104 * @since 1.32
105 * @ingroup Page
106 */
107class DerivedPageDataUpdater implements LoggerAwareInterface, PreparedUpdate {
108
109    /**
110     * @var UserIdentity|null
111     */
112    private $user = null;
113
114    /**
115     * @var WikiPage
116     */
117    private $wikiPage;
118
119    /**
120     * @var ParserCache
121     */
122    private $parserCache;
123
124    /**
125     * @var RevisionStore
126     */
127    private $revisionStore;
128
129    /**
130     * @var Language
131     */
132    private $contLang;
133
134    /**
135     * @var JobQueueGroup
136     */
137    private $jobQueueGroup;
138
139    /**
140     * @var MessageCache
141     */
142    private $messageCache;
143
144    /**
145     * @var ILBFactory
146     */
147    private $loadbalancerFactory;
148
149    /**
150     * @var HookRunner
151     */
152    private $hookRunner;
153
154    /**
155     * @var LoggerInterface
156     */
157    private $logger;
158
159    /**
160     * @var string see $wgArticleCountMethod
161     */
162    private $articleCountMethod;
163
164    /**
165     * @var bool see $wgRCWatchCategoryMembership
166     */
167    private $rcWatchCategoryMembership = false;
168
169    /**
170     * Stores (most of) the $options parameter of prepareUpdate().
171     * @see prepareUpdate()
172     *
173     * @phpcs:ignore Generic.Files.LineLength
174     * @phan-var array{changed:bool,created:bool,moved:bool,restored:bool,oldrevision:null|RevisionRecord,triggeringUser:null|UserIdentity,oldredirect:bool|null|string,oldcountable:bool|null|string,causeAction:null|string,causeAgent:null|string,editResult:null|EditResult,approved:bool}
175     */
176    private $options = [
177        'changed' => true,
178        // newrev is true if prepareUpdate is handling the creation of a new revision,
179        // as opposed to a null edit or a forced update.
180        'newrev' => false,
181        'created' => false,
182        'moved' => false,
183        'oldtitle' => null,
184        'restored' => false,
185        'oldrevision' => null,
186        'oldcountable' => null,
187        'oldredirect' => null,
188        'triggeringUser' => null,
189        // causeAction/causeAgent default to 'unknown' but that's handled where it's read,
190        // to make the life of prepareUpdate() callers easier.
191        'causeAction' => null,
192        'causeAgent' => null,
193        'editResult' => null,
194        'approved' => false
195    ];
196
197    /**
198     * The state of the relevant row in page table before the edit.
199     * This is determined by the first call to grabCurrentRevision, prepareContent,
200     * or prepareUpdate (so it is only accessible in 'knows-current' or a later stage).
201     * If pageState was not initialized when prepareUpdate() is called, prepareUpdate() will
202     * attempt to emulate the state of the page table before the edit.
203     *
204     * Contains the following fields:
205     * - oldRevision (RevisionRecord|null): the revision that was current before the change
206     *   associated with this update. Might not be set, use getParentRevision().
207     * - oldId (int|null): the id of the above revision. 0 if there is no such revision (the change
208     *   was about creating a new page); null if not known (that should not happen).
209     * - oldIsRedirect (bool|null): whether the page was a redirect before the change. Lazy-loaded,
210     *   can be null; use wasRedirect() instead of direct access.
211     * - oldCountable (bool|null): whether the page was countable before the change (or null
212     *   if we don't have that information)
213     *
214     * @var array
215     */
216    private $pageState = null;
217
218    /**
219     * @var RevisionSlotsUpdate|null
220     */
221    private $slotsUpdate = null;
222
223    /**
224     * @var RevisionRecord|null
225     */
226    private $parentRevision = null;
227
228    /**
229     * @var RevisionRecord|null
230     */
231    private $revision = null;
232
233    /**
234     * @var RenderedRevision
235     */
236    private $renderedRevision = null;
237
238    /**
239     * @var RevisionRenderer
240     */
241    private $revisionRenderer;
242
243    /** @var SlotRoleRegistry */
244    private $slotRoleRegistry;
245
246    /**
247     * @var bool Whether null-edits create a revision.
248     */
249    private $forceEmptyRevision = false;
250
251    /**
252     * A stage identifier for managing the life cycle of this instance.
253     * Possible stages are 'new', 'knows-current', 'has-content', 'has-revision', and 'done'.
254     *
255     * @see docs/pageupdater.md for documentation of the life cycle.
256     *
257     * @var string
258     */
259    private $stage = 'new';
260
261    /**
262     * Transition table for managing the life cycle of DerivedPageDateUpdater instances.
263     *
264     * XXX: Overkill. This is a linear order, we could just count. Names are nice though,
265     * and constants are also overkill...
266     *
267     * @see docs/pageupdater.md for documentation of the life cycle.
268     *
269     * @var array[]
270     */
271    private const TRANSITIONS = [
272        'new' => [
273            'new' => true,
274            'knows-current' => true,
275            'has-content' => true,
276            'has-revision' => true,
277        ],
278        'knows-current' => [
279            'knows-current' => true,
280            'has-content' => true,
281            'has-revision' => true,
282        ],
283        'has-content' => [
284            'has-content' => true,
285            'has-revision' => true,
286        ],
287        'has-revision' => [
288            'has-revision' => true,
289            'done' => true,
290        ],
291    ];
292
293    /** @var IContentHandlerFactory */
294    private $contentHandlerFactory;
295
296    /** @var EditResultCache */
297    private $editResultCache;
298
299    /** @var UserNameUtils */
300    private $userNameUtils;
301
302    /** @var ContentTransformer */
303    private $contentTransformer;
304
305    /** @var PageEditStash */
306    private $pageEditStash;
307
308    /** @var TalkPageNotificationManager */
309    private $talkPageNotificationManager;
310
311    /** @var WANObjectCache */
312    private $mainWANObjectCache;
313
314    /** @var PermissionManager */
315    private $permissionManager;
316
317    /** @var bool */
318    private $warmParsoidParserCache;
319
320    /**
321     * @param ServiceOptions $options
322     * @param WikiPage $wikiPage
323     * @param RevisionStore $revisionStore
324     * @param RevisionRenderer $revisionRenderer
325     * @param SlotRoleRegistry $slotRoleRegistry
326     * @param ParserCache $parserCache
327     * @param JobQueueGroup $jobQueueGroup
328     * @param MessageCache $messageCache
329     * @param Language $contLang
330     * @param ILBFactory $loadbalancerFactory
331     * @param IContentHandlerFactory $contentHandlerFactory
332     * @param HookContainer $hookContainer
333     * @param EditResultCache $editResultCache
334     * @param UserNameUtils $userNameUtils
335     * @param ContentTransformer $contentTransformer
336     * @param PageEditStash $pageEditStash
337     * @param TalkPageNotificationManager $talkPageNotificationManager
338     * @param WANObjectCache $mainWANObjectCache
339     * @param PermissionManager $permissionManager
340     */
341    public function __construct(
342        ServiceOptions $options,
343        WikiPage $wikiPage,
344        RevisionStore $revisionStore,
345        RevisionRenderer $revisionRenderer,
346        SlotRoleRegistry $slotRoleRegistry,
347        ParserCache $parserCache,
348        JobQueueGroup $jobQueueGroup,
349        MessageCache $messageCache,
350        Language $contLang,
351        ILBFactory $loadbalancerFactory,
352        IContentHandlerFactory $contentHandlerFactory,
353        HookContainer $hookContainer,
354        EditResultCache $editResultCache,
355        UserNameUtils $userNameUtils,
356        ContentTransformer $contentTransformer,
357        PageEditStash $pageEditStash,
358        TalkPageNotificationManager $talkPageNotificationManager,
359        WANObjectCache $mainWANObjectCache,
360        PermissionManager $permissionManager
361    ) {
362        $this->wikiPage = $wikiPage;
363
364        $this->parserCache = $parserCache;
365        $this->revisionStore = $revisionStore;
366        $this->revisionRenderer = $revisionRenderer;
367        $this->slotRoleRegistry = $slotRoleRegistry;
368        $this->jobQueueGroup = $jobQueueGroup;
369        $this->messageCache = $messageCache;
370        $this->contLang = $contLang;
371        // XXX only needed for waiting for replicas to catch up; there should be a narrower
372        // interface for that.
373        $this->loadbalancerFactory = $loadbalancerFactory;
374        $this->contentHandlerFactory = $contentHandlerFactory;
375        $this->hookRunner = new HookRunner( $hookContainer );
376        $this->editResultCache = $editResultCache;
377        $this->userNameUtils = $userNameUtils;
378        $this->contentTransformer = $contentTransformer;
379        $this->pageEditStash = $pageEditStash;
380        $this->talkPageNotificationManager = $talkPageNotificationManager;
381        $this->mainWANObjectCache = $mainWANObjectCache;
382        $this->permissionManager = $permissionManager;
383
384        $this->logger = new NullLogger();
385        $this->warmParsoidParserCache = $options
386            ->get( MainConfigNames::ParsoidCacheConfig )['WarmParsoidParserCache'];
387    }
388
389    public function setLogger( LoggerInterface $logger ) {
390        $this->logger = $logger;
391    }
392
393    /**
394     * Set the cause action and cause agent, for logging and debugging.
395     * If $causeAction or $causeAgent is null, any previously set value is preserved.
396     *
397     * @param ?string $causeAction
398     * @param ?string $causeAgent
399     *
400     * @return void
401     */
402    public function setCause( ?string $causeAction, ?string $causeAgent ) {
403        if ( $causeAction ) {
404            $this->options['causeAction'] = $causeAction;
405        }
406
407        if ( $causeAgent ) {
408            $this->options['causeAgent'] = $causeAgent;
409        }
410    }
411
412    /**
413     * @return string[] [ $causeAction, $causeAgent ]
414     */
415    private function getCause(): array {
416        return [
417            $this->options['causeAction'] ?? 'unknown',
418            $this->options['causeAgent'] ?? 'unknown',
419        ];
420    }
421
422    /**
423     * Transition function for managing the life cycle of this instances.
424     *
425     * @see docs/pageupdater.md for documentation of the life cycle.
426     *
427     * @param string $newStage
428     * @return string the previous stage
429     *
430     * @throws LogicException If a transition to the given stage is not possible in the current
431     *         stage.
432     */
433    private function doTransition( $newStage ) {
434        $this->assertTransition( $newStage );
435
436        $oldStage = $this->stage;
437        $this->stage = $newStage;
438
439        return $oldStage;
440    }
441
442    /**
443     * Asserts that a transition to the given stage is possible, without performing it.
444     *
445     * @see docs/pageupdater.md for documentation of the life cycle.
446     *
447     * @param string $newStage
448     *
449     * @throws LogicException If this instance is not in the expected stage
450     */
451    private function assertTransition( $newStage ) {
452        if ( empty( self::TRANSITIONS[$this->stage][$newStage] ) ) {
453            throw new LogicException( "Cannot transition from {$this->stage} to $newStage" );
454        }
455    }
456
457    /**
458     * Checks whether this DerivedPageDataUpdater can be re-used for running updates targeting
459     * the given revision.
460     *
461     * @param UserIdentity|null $user The user creating the revision in question
462     * @param RevisionRecord|null $revision New revision (after save, if already saved)
463     * @param RevisionSlotsUpdate|null $slotsUpdate New content (before PST)
464     * @param null|int $parentId Parent revision of the edit (use 0 for page creation)
465     *
466     * @return bool
467     */
468    public function isReusableFor(
469        UserIdentity $user = null,
470        RevisionRecord $revision = null,
471        RevisionSlotsUpdate $slotsUpdate = null,
472        $parentId = null
473    ) {
474        if ( $revision
475            && $parentId
476            && $revision->getParentId() !== $parentId
477        ) {
478            throw new InvalidArgumentException( '$parentId should match the parent of $revision' );
479        }
480
481        // NOTE: For null revisions, $user may be different from $this->revision->getUser
482        // and also from $revision->getUser.
483        // But $user should always match $this->user.
484        if ( $user && $this->user && $user->getName() !== $this->user->getName() ) {
485            return false;
486        }
487
488        if ( $revision && $this->revision && $this->revision->getId()
489            && $this->revision->getId() !== $revision->getId()
490        ) {
491            return false;
492        }
493
494        if ( $this->pageState
495            && $revision
496            && $revision->getParentId() !== null
497            && $this->pageState['oldId'] !== $revision->getParentId()
498        ) {
499            return false;
500        }
501
502        if ( $this->pageState
503            && $parentId !== null
504            && $this->pageState['oldId'] !== $parentId
505        ) {
506            return false;
507        }
508
509        // NOTE: this check is the primary reason for having the $this->slotsUpdate field!
510        if ( $this->slotsUpdate
511            && $slotsUpdate
512            && !$this->slotsUpdate->hasSameUpdates( $slotsUpdate )
513        ) {
514            return false;
515        }
516
517        if ( $revision
518            && $this->revision
519            && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
520        ) {
521            return false;
522        }
523
524        return true;
525    }
526
527    /**
528     * Set whether null-edits should create a revision. Enabling this allows the creation of dummy
529     * revisions ("null revisions") to mark events such as renaming in the page history.
530     *
531     * Must not be called once prepareContent() or prepareUpdate() have been called.
532     *
533     * @since 1.38
534     * @see PageUpdater setForceEmptyRevision
535     *
536     * @param bool $forceEmptyRevision
537     */
538    public function setForceEmptyRevision( bool $forceEmptyRevision ) {
539        if ( $this->revision ) {
540            throw new LogicException( 'prepareContent() or prepareUpdate() was already called.' );
541        }
542
543        $this->forceEmptyRevision = $forceEmptyRevision;
544    }
545
546    /**
547     * @param string $articleCountMethod "any" or "link".
548     * @see $wgArticleCountMethod
549     */
550    public function setArticleCountMethod( $articleCountMethod ) {
551        $this->articleCountMethod = $articleCountMethod;
552    }
553
554    /**
555     * @param bool $rcWatchCategoryMembership
556     * @see $wgRCWatchCategoryMembership
557     */
558    public function setRcWatchCategoryMembership( $rcWatchCategoryMembership ) {
559        $this->rcWatchCategoryMembership = $rcWatchCategoryMembership;
560    }
561
562    /**
563     * @return Title
564     */
565    private function getTitle() {
566        // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
567        return $this->wikiPage->getTitle();
568    }
569
570    /**
571     * @return WikiPage
572     */
573    private function getWikiPage() {
574        // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
575        return $this->wikiPage;
576    }
577
578    /**
579     * Returns the page being updated.
580     * @since 1.37
581     * @return PageIdentity
582     */
583    public function getPage(): PageIdentity {
584        return $this->getTitle();
585    }
586
587    /**
588     * Determines whether the page being edited already existed.
589     * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
590     *
591     * @return bool
592     * @throws LogicException if called before grabCurrentRevision
593     */
594    public function pageExisted() {
595        $this->assertHasPageState( __METHOD__ );
596
597        return $this->pageState['oldId'] > 0;
598    }
599
600    /**
601     * Returns the parent revision of the new revision wrapped by this update.
602     * If the update is a null-edit, this will return the parent of the current (and new) revision.
603     * This will return null if the revision wrapped by this update created the page.
604     * Only defined after calling prepareContent() or prepareUpdate()!
605     *
606     * @return RevisionRecord|null the parent revision of the new revision, or null if
607     *         the update created the page.
608     */
609    private function getParentRevision() {
610        $this->assertPrepared( __METHOD__ );
611
612        if ( $this->parentRevision ) {
613            return $this->parentRevision;
614        }
615
616        if ( !$this->pageState['oldId'] ) {
617            // If there was no current revision, there is no parent revision,
618            // since the page didn't exist.
619            return null;
620        }
621
622        $oldId = $this->revision->getParentId();
623        $flags = $this->usePrimary() ? IDBAccessObject::READ_LATEST : 0;
624        $this->parentRevision = $oldId
625            ? $this->revisionStore->getRevisionById( $oldId, $flags )
626            : null;
627
628        return $this->parentRevision;
629    }
630
631    /**
632     * Returns the revision that was the page's current revision when grabCurrentRevision()
633     * was first called.
634     *
635     * During an edit, that revision will act as the logical parent of the new revision.
636     *
637     * Some updates are performed based on the difference between the database state at the
638     * moment this method is first called, and the state after the edit.
639     *
640     * @see docs/pageupdater.md for more information on when thie method can and should be called.
641     *
642     * @note After prepareUpdate() was called, grabCurrentRevision() will throw an exception
643     * to avoid confusion, since the page's current revision is then the new revision after
644     * the edit, which was presumably passed to prepareUpdate() as the $revision parameter.
645     * Use getParentRevision() instead to access the revision that is the parent of the
646     * new revision.
647     *
648     * @return RevisionRecord|null the page's current revision, or null if the page does not
649     * yet exist.
650     */
651    public function grabCurrentRevision() {
652        if ( $this->pageState ) {
653            return $this->pageState['oldRevision'];
654        }
655
656        $this->assertTransition( 'knows-current' );
657
658        // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
659        $wikiPage = $this->getWikiPage();
660
661        // Do not call WikiPage::clear(), since the caller may already have caused page data
662        // to be loaded with SELECT FOR UPDATE. Just assert it's loaded now.
663        $wikiPage->loadPageData( IDBAccessObject::READ_LATEST );
664        $current = $wikiPage->getRevisionRecord();
665
666        $this->pageState = [
667            'oldRevision' => $current,
668            'oldId' => $current ? $current->getId() : 0,
669            'oldIsRedirect' => $wikiPage->isRedirect(), // NOTE: uses page table
670            'oldCountable' => $wikiPage->isCountable(), // NOTE: uses pagelinks table
671        ];
672
673        $this->doTransition( 'knows-current' );
674
675        return $this->pageState['oldRevision'];
676    }
677
678    /**
679     * Whether prepareUpdate() or prepareContent() have been called on this instance.
680     *
681     * @return bool
682     */
683    public function isContentPrepared() {
684        return $this->revision !== null;
685    }
686
687    /**
688     * Whether prepareUpdate() has been called on this instance.
689     *
690     * @note will also return null in case of a null-edit!
691     *
692     * @return bool
693     */
694    public function isUpdatePrepared() {
695        return $this->revision !== null && $this->revision->getId() !== null;
696    }
697
698    /**
699     * @return int
700     */
701    private function getPageId() {
702        // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
703        return $this->wikiPage->getId();
704    }
705
706    /**
707     * Whether the content is deleted and thus not visible to the public.
708     *
709     * @return bool
710     */
711    public function isContentDeleted() {
712        if ( $this->revision ) {
713            return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
714        } else {
715            // If the content has not been saved yet, it cannot have been deleted yet.
716            return false;
717        }
718    }
719
720    /**
721     * Returns the slot, modified or inherited, after PST, with no audience checks applied.
722     *
723     * @param string $role slot role name
724     *
725     * @throws PageUpdateException If the slot is neither set for update nor inherited from the
726     *        parent revision.
727     * @return SlotRecord
728     */
729    public function getRawSlot( $role ) {
730        return $this->getSlots()->getSlot( $role );
731    }
732
733    /**
734     * Returns the content of the given slot, with no audience checks.
735     *
736     * @throws PageUpdateException If the slot is neither set for update nor inherited from the
737     *        parent revision.
738     * @param string $role slot role name
739     * @return Content
740     */
741    public function getRawContent( string $role ): Content {
742        return $this->getRawSlot( $role )->getContent();
743    }
744
745    /**
746     * @param string $role slot role name
747     * @return ContentHandler
748     * @throws MWUnknownContentModelException
749     */
750    private function getContentHandler( $role ): ContentHandler {
751        return $this->contentHandlerFactory
752            ->getContentHandler( $this->getRawSlot( $role )->getModel() );
753    }
754
755    private function usePrimary() {
756        // TODO: can we just set a flag to true in prepareContent()?
757        return $this->wikiPage->wasLoadedFrom( IDBAccessObject::READ_LATEST );
758    }
759
760    /**
761     * @return bool
762     */
763    public function isCountable(): bool {
764        // NOTE: Keep in sync with WikiPage::isCountable.
765
766        if ( !$this->getTitle()->isContentPage() ) {
767            return false;
768        }
769
770        if ( $this->isContentDeleted() ) {
771            // This should be irrelevant: countability only applies to the current revision,
772            // and the current revision is never suppressed.
773            return false;
774        }
775
776        if ( $this->isRedirect() ) {
777            return false;
778        }
779
780        $hasLinks = null;
781
782        if ( $this->articleCountMethod === 'link' ) {
783            // NOTE: it would be more appropriate to determine for each slot separately
784            // whether it has links, and use that information with that slot's
785            // isCountable() method. However, that would break parity with
786            // WikiPage::isCountable, which uses the pagelinks table to determine
787            // whether the current revision has links.
788            $hasLinks = (bool)count( $this->getParserOutputForMetaData()->getLinks() );
789        }
790
791        foreach ( $this->getSlots()->getSlotRoles() as $role ) {
792            $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
793            if ( $roleHandler->supportsArticleCount() ) {
794                $content = $this->getRawContent( $role );
795
796                if ( $content->isCountable( $hasLinks ) ) {
797                    return true;
798                }
799            }
800        }
801
802        return false;
803    }
804
805    /**
806     * @return bool
807     */
808    public function isRedirect(): bool {
809        // NOTE: main slot determines redirect status
810        // TODO: MCR: this should be controlled by a PageTypeHandler
811        $mainContent = $this->getRawContent( SlotRecord::MAIN );
812
813        return $mainContent->isRedirect();
814    }
815
816    /**
817     * @param RevisionRecord $rev
818     *
819     * @return bool
820     */
821    private function revisionIsRedirect( RevisionRecord $rev ) {
822        // NOTE: main slot determines redirect status
823        $mainContent = $rev->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
824
825        return $mainContent->isRedirect();
826    }
827
828    /**
829     * Prepare updates based on an update which has not yet been saved.
830     *
831     * This may be used to create derived data that is needed when creating a new revision;
832     * particularly, this makes available the slots of the new revision via the getSlots()
833     * method, after applying PST and slot inheritance.
834     *
835     * The derived data prepared for revision creation may then later be re-used by doUpdates(),
836     * without the need to re-calculate.
837     *
838     * @see docs/pageupdater.md for more information on when thie method can and should be called.
839     *
840     * @note Calling this method more than once with the same $slotsUpdate
841     * has no effect. Calling this method multiple times with different content will cause
842     * an exception.
843     *
844     * @note Calling this method after prepareUpdate() has been called will cause an exception.
845     *
846     * @param UserIdentity $user The user to act as context for pre-save transformation (PST).
847     * @param RevisionSlotsUpdate $slotsUpdate The new content of the slots to be updated
848     *        by this edit, before PST.
849     * @param bool $useStash Whether to use stashed ParserOutput
850     */
851    public function prepareContent(
852        UserIdentity $user,
853        RevisionSlotsUpdate $slotsUpdate,
854        $useStash = true
855    ) {
856        if ( $this->slotsUpdate ) {
857            if ( !$this->user ) {
858                throw new LogicException(
859                    'Unexpected state: $this->slotsUpdate was initialized, '
860                    . 'but $this->user was not.'
861                );
862            }
863
864            if ( $this->user->getName() !== $user->getName() ) {
865                throw new LogicException( 'Can\'t call prepareContent() again for different user! '
866                    . 'Expected ' . $this->user->getName() . ', got ' . $user->getName()
867                );
868            }
869
870            if ( !$this->slotsUpdate->hasSameUpdates( $slotsUpdate ) ) {
871                throw new LogicException(
872                    'Can\'t call prepareContent() again with different slot content!'
873                );
874            }
875
876            return; // prepareContent() already done, nothing to do
877        }
878
879        $this->assertTransition( 'has-content' );
880
881        $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
882        $title = $this->getTitle();
883
884        $parentRevision = $this->grabCurrentRevision();
885
886        // The edit may have already been prepared via api.php?action=stashedit
887        $stashedEdit = false;
888
889        // TODO: MCR: allow output for all slots to be stashed.
890        if ( $useStash && $slotsUpdate->isModifiedSlot( SlotRecord::MAIN ) ) {
891            $stashedEdit = $this->pageEditStash->checkCache(
892                $title,
893                $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent(),
894                $user
895            );
896        }
897
898        $userPopts = ParserOptions::newFromUserAndLang( $user, $this->contLang );
899        $userPopts->setRenderReason( $this->options['causeAgent'] ?? 'unknown' );
900
901        $this->hookRunner->onArticlePrepareTextForEdit( $wikiPage, $userPopts );
902
903        $this->user = $user;
904        $this->slotsUpdate = $slotsUpdate;
905
906        if ( $parentRevision ) {
907            $this->revision = MutableRevisionRecord::newFromParentRevision( $parentRevision );
908        } else {
909            $this->revision = new MutableRevisionRecord( $title );
910        }
911
912        // NOTE: user and timestamp must be set, so they can be used for
913        // {{subst:REVISIONUSER}} and {{subst:REVISIONTIMESTAMP}} in PST!
914        $this->revision->setTimestamp( MWTimestamp::now( TS_MW ) );
915        $this->revision->setUser( $user );
916
917        // Set up ParserOptions to operate on the new revision
918        $oldCallback = $userPopts->getCurrentRevisionRecordCallback();
919        $userPopts->setCurrentRevisionRecordCallback(
920            function ( Title $parserTitle, $parser = null ) use ( $title, $oldCallback ) {
921                if ( $parserTitle->equals( $title ) ) {
922                    return $this->revision;
923                } else {
924                    return call_user_func( $oldCallback, $parserTitle, $parser );
925                }
926            }
927        );
928
929        $pstContentSlots = $this->revision->getSlots();
930
931        foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
932            $slot = $slotsUpdate->getModifiedSlot( $role );
933
934            if ( $slot->isInherited() ) {
935                // No PST for inherited slots! Note that "modified" slots may still be inherited
936                // from an earlier version, e.g. for rollbacks.
937                $pstSlot = $slot;
938            } elseif ( $role === SlotRecord::MAIN && $stashedEdit ) {
939                // TODO: MCR: allow PST content for all slots to be stashed.
940                $pstSlot = SlotRecord::newUnsaved( $role, $stashedEdit->pstContent );
941            } else {
942                $pstContent = $this->contentTransformer->preSaveTransform(
943                    $slot->getContent(),
944                    $title,
945                    $user,
946                    $userPopts
947                );
948
949                $pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
950            }
951
952            $pstContentSlots->setSlot( $pstSlot );
953        }
954
955        foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
956            $pstContentSlots->removeSlot( $role );
957        }
958
959        $this->options['created'] = ( $parentRevision === null );
960        $this->options['changed'] = ( $parentRevision === null
961            || !$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
962
963        $this->doTransition( 'has-content' );
964
965        if ( !$this->options['changed'] ) {
966            if ( $this->forceEmptyRevision ) {
967                // dummy revision, inherit all slots
968                foreach ( $parentRevision->getSlotRoles() as $role ) {
969                    $this->revision->inheritSlot( $parentRevision->getSlot( $role ) );
970                }
971            } else {
972                // null-edit, the new revision *is* the old revision.
973
974                // TODO: move this into MutableRevisionRecord
975                $this->revision->setId( $parentRevision->getId() );
976                $this->revision->setTimestamp( $parentRevision->getTimestamp() );
977                $this->revision->setPageId( $parentRevision->getPageId() );
978                $this->revision->setParentId( $parentRevision->getParentId() );
979                $this->revision->setUser( $parentRevision->getUser( RevisionRecord::RAW ) );
980                $this->revision->setComment( $parentRevision->getComment( RevisionRecord::RAW ) );
981                $this->revision->setMinorEdit( $parentRevision->isMinor() );
982                $this->revision->setVisibility( $parentRevision->getVisibility() );
983
984                // prepareUpdate() is redundant for null-edits (but not for dummy revisions)
985                $this->doTransition( 'has-revision' );
986            }
987        } else {
988            $this->parentRevision = $parentRevision;
989        }
990
991        $renderHints = [ 'use-master' => $this->usePrimary(), 'audience' => RevisionRecord::RAW ];
992
993        if ( $stashedEdit ) {
994            /** @var ParserOutput $output */
995            $output = $stashedEdit->output;
996            // TODO: this should happen when stashing the ParserOutput, not now!
997            $output->setCacheTime( $stashedEdit->timestamp );
998
999            $renderHints['known-revision-output'] = $output;
1000
1001            $this->logger->debug( __METHOD__ . ': using stashed edit output...' );
1002        }
1003
1004        $renderHints['generate-html'] = $this->shouldGenerateHTMLOnEdit();
1005
1006        [ $causeAction, ] = $this->getCause();
1007        $renderHints['causeAction'] = $causeAction;
1008
1009            // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
1010        // NOTE: the revision is either new or current, so we can bypass audience checks.
1011        $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
1012            $this->revision,
1013            null,
1014            null,
1015            $renderHints
1016        );
1017    }
1018
1019    /**
1020     * Returns the update's target revision - that is, the revision that will be the current
1021     * revision after the update.
1022     *
1023     * @note Callers must treat the returned RevisionRecord's content as immutable, even
1024     * if it is a MutableRevisionRecord instance. Other aspects of a MutableRevisionRecord
1025     * returned from here, such as the user or the comment, may be changed, but may not
1026     * be reflected in ParserOutput until after prepareUpdate() has been called.
1027     *
1028     * @todo This is currently used by PageUpdater::makeNewRevision() to construct an unsaved
1029     * MutableRevisionRecord instance. Introduce something like an UnsavedRevisionFactory service
1030     * for that purpose instead!
1031     *
1032     * @return RevisionRecord
1033     */
1034    public function getRevision(): RevisionRecord {
1035        $this->assertPrepared( __METHOD__ );
1036        return $this->revision;
1037    }
1038
1039    /**
1040     * @return RenderedRevision
1041     */
1042    public function getRenderedRevision(): RenderedRevision {
1043        $this->assertPrepared( __METHOD__ );
1044
1045        return $this->renderedRevision;
1046    }
1047
1048    private function assertHasPageState( $method ) {
1049        if ( !$this->pageState ) {
1050            throw new LogicException(
1051                'Must call grabCurrentRevision() or prepareContent() '
1052                . 'or prepareUpdate() before calling ' . $method
1053            );
1054        }
1055    }
1056
1057    private function assertPrepared( $method ) {
1058        if ( !$this->revision ) {
1059            throw new LogicException(
1060                'Must call prepareContent() or prepareUpdate() before calling ' . $method
1061            );
1062        }
1063    }
1064
1065    private function assertHasRevision( $method ) {
1066        if ( !$this->revision->getId() ) {
1067            throw new LogicException(
1068                'Must call prepareUpdate() before calling ' . $method
1069            );
1070        }
1071    }
1072
1073    /**
1074     * Whether the edit creates the page.
1075     *
1076     * @return bool
1077     */
1078    public function isCreation() {
1079        $this->assertPrepared( __METHOD__ );
1080        return $this->options['created'];
1081    }
1082
1083    /**
1084     * Whether the content of the current revision after the edit is different from the content of the
1085     * current revision before the edit. This will return false for a null-edit (no revision created),
1086     * as well as for a dummy revision (a "null-revision" that has the same content as its parent).
1087     *
1088     * @warning at present, dummy revision would return false after prepareContent(),
1089     * but true after prepareUpdate()!
1090     *
1091     * @todo This should probably be fixed.
1092     *
1093     * @return bool
1094     */
1095    public function isChange() {
1096        $this->assertPrepared( __METHOD__ );
1097        return $this->options['changed'];
1098    }
1099
1100    /**
1101     * Whether the page was a redirect before the edit.
1102     *
1103     * @return bool
1104     */
1105    public function wasRedirect() {
1106        $this->assertHasPageState( __METHOD__ );
1107
1108        if ( $this->pageState['oldIsRedirect'] === null ) {
1109            /** @var RevisionRecord $rev */
1110            $rev = $this->pageState['oldRevision'];
1111            if ( $rev ) {
1112                $this->pageState['oldIsRedirect'] = $this->revisionIsRedirect( $rev );
1113            } else {
1114                $this->pageState['oldIsRedirect'] = false;
1115            }
1116        }
1117
1118        return $this->pageState['oldIsRedirect'];
1119    }
1120
1121    /**
1122     * Returns the slots of the target revision, after PST.
1123     *
1124     * @note Callers must treat the returned RevisionSlots instance as immutable, even
1125     * if it is a MutableRevisionSlots instance.
1126     *
1127     * @return RevisionSlots
1128     */
1129    public function getSlots() {
1130        $this->assertPrepared( __METHOD__ );
1131        return $this->revision->getSlots();
1132    }
1133
1134    /**
1135     * Returns the RevisionSlotsUpdate for this updater.
1136     *
1137     * @return RevisionSlotsUpdate
1138     */
1139    private function getRevisionSlotsUpdate() {
1140        $this->assertPrepared( __METHOD__ );
1141
1142        if ( !$this->slotsUpdate ) {
1143            $old = $this->getParentRevision();
1144            $this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots(
1145                $this->revision->getSlots(),
1146                $old ? $old->getSlots() : null
1147            );
1148        }
1149        return $this->slotsUpdate;
1150    }
1151
1152    /**
1153     * Returns the role names of the slots touched by the new revision,
1154     * including removed roles.
1155     *
1156     * @return string[]
1157     */
1158    public function getTouchedSlotRoles() {
1159        return $this->getRevisionSlotsUpdate()->getTouchedRoles();
1160    }
1161
1162    /**
1163     * Returns the role names of the slots modified by the new revision,
1164     * not including removed roles.
1165     *
1166     * @return string[]
1167     */
1168    public function getModifiedSlotRoles(): array {
1169        return $this->getRevisionSlotsUpdate()->getModifiedRoles();
1170    }
1171
1172    /**
1173     * Returns the role names of the slots removed by the new revision.
1174     *
1175     * @return string[]
1176     */
1177    public function getRemovedSlotRoles(): array {
1178        return $this->getRevisionSlotsUpdate()->getRemovedRoles();
1179    }
1180
1181    /**
1182     * Prepare derived data updates targeting the given RevisionRecord.
1183     *
1184     * Calling this method requires the given revision to be present in the database.
1185     * This may be right after a new revision has been created, or when re-generating
1186     * derived data e.g. in ApiPurge, RefreshLinksJob, and the refreshLinks
1187     * script.
1188     *
1189     * @see docs/pageupdater.md for more information on when thie method can and should be called.
1190     *
1191     * @note Calling this method more than once with the same revision has no effect.
1192     * $options are only used for the first call. Calling this method multiple times with
1193     * different revisions will cause an exception.
1194     *
1195     * @note If grabCurrentRevision() (or prepareContent()) has been called before
1196     * calling this method, $revision->getParentRevision() has to refer to the revision that
1197     * was the current revision at the time grabCurrentRevision() was called.
1198     *
1199     * @param RevisionRecord $revision
1200     * @param array $options Array of options, following indexes are used:
1201     * - changed: bool, whether the revision changed the content (default true)
1202     * - created: bool, whether the revision created the page (default false)
1203     * - moved: bool, whether the page was moved (default false)
1204     * - oldtitle: PageIdentity, if the page was moved this is the source title (default null)
1205     * - restored: bool, whether the page was undeleted (default false)
1206     * - oldrevision: RevisionRecord object for the pre-update revision (default null)
1207     * - triggeringUser: The user triggering the update (UserIdentity, defaults to the
1208     *   user who created the revision)
1209     * - oldredirect: bool, null, or string 'no-change' (default null):
1210     *    - bool: whether the page was counted as a redirect before that
1211     *      revision, only used in changed is true and created is false
1212     *    - null or 'no-change': don't update the redirect status.
1213     * - oldcountable: bool, null, or string 'no-change' (default null):
1214     *    - bool: whether the page was counted as an article before that
1215     *      revision, only used in changed is true and created is false
1216     *    - null: if created is false, don't update the article count; if created
1217     *      is true, do update the article count
1218     *    - 'no-change': don't update the article count, ever
1219     *    When set to null, pageState['oldCountable'] will be used instead if available.
1220     *  - causeAction: an arbitrary string identifying the reason for the update.
1221     *    See DataUpdate::getCauseAction(). (default 'unknown')
1222     *  - causeAgent: name of the user who caused the update. See DataUpdate::getCauseAgent().
1223     *    (string, default 'unknown')
1224     *  - known-revision-output: a combined canonical ParserOutput for the revision, perhaps
1225     *    from some cache. The caller is responsible for ensuring that the ParserOutput indeed
1226     *    matched the $rev and $options. This mechanism is intended as a temporary stop-gap,
1227     *    for the time until caches have been changed to store RenderedRevision states instead
1228     *    of ParserOutput objects. (default: null) (since 1.33)
1229     *  - editResult: EditResult object created during the update. Required to perform reverted
1230     *    tag update using RevertedTagUpdateJob. (default: null) (since 1.36)
1231     *  - approved: whether the edit is somehow "approved" and the RevertedTagUpdateJob should
1232     *    be scheduled right away. Required only if EditResult::isRevert() is true. (boolean,
1233     *    default: false) (since 1.36)
1234     */
1235    public function prepareUpdate( RevisionRecord $revision, array $options = [] ) {
1236        Assert::parameter(
1237            !isset( $options['oldrevision'] )
1238            || $options['oldrevision'] instanceof RevisionRecord,
1239            '$options["oldrevision"]',
1240            'must be a RevisionRecord'
1241        );
1242        Assert::parameter(
1243            !isset( $options['triggeringUser'] )
1244            || $options['triggeringUser'] instanceof UserIdentity,
1245            '$options["triggeringUser"]',
1246            'must be a UserIdentity'
1247        );
1248        Assert::parameter(
1249            !isset( $options['editResult'] )
1250            || $options['editResult'] instanceof EditResult,
1251            '$options["editResult"]',
1252            'must be an EditResult'
1253        );
1254
1255        if ( !$revision->getId() ) {
1256            throw new InvalidArgumentException(
1257                'Revision must have an ID set for it to be used with prepareUpdate()!'
1258            );
1259        }
1260
1261        if ( $this->revision && $this->revision->getId() ) {
1262            if ( $this->revision->getId() === $revision->getId() ) {
1263                return; // nothing to do!
1264            } else {
1265                throw new LogicException(
1266                    'Trying to re-use DerivedPageDataUpdater with revision '
1267                    . $revision->getId()
1268                    . ', but it\'s already bound to revision '
1269                    . $this->revision->getId()
1270                );
1271            }
1272        }
1273
1274        if ( $this->revision
1275            && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
1276        ) {
1277            throw new LogicException(
1278                'The revision provided has mismatching content!'
1279            );
1280        }
1281
1282        // Override fields defined in $this->options with values from $options.
1283        $this->options = array_intersect_key( $options, $this->options ) + $this->options;
1284
1285        if ( $this->revision ) {
1286            $oldId = $this->pageState['oldId'] ?? 0;
1287            $this->options['newrev'] = ( $revision->getId() !== $oldId );
1288        } elseif ( isset( $this->options['oldrevision'] ) ) {
1289            /** @var RevisionRecord $oldRev */
1290            $oldRev = $this->options['oldrevision'];
1291            $oldId = $oldRev->getId();
1292            $this->options['newrev'] = ( $revision->getId() !== $oldId );
1293        } else {
1294            $oldId = $revision->getParentId();
1295        }
1296
1297        if ( $oldId !== null ) {
1298            // XXX: what if $options['changed'] disagrees?
1299            // MovePage creates a dummy revision with changed = false!
1300            // We may want to explicitly distinguish between "no new revision" (null-edit)
1301            // and "new revision without new content" (dummy revision).
1302
1303            if ( $oldId === $revision->getParentId() ) {
1304                // NOTE: this may still be a NullRevision!
1305                // New revision!
1306                $this->options['changed'] = true;
1307            } elseif ( $oldId === $revision->getId() ) {
1308                // Null-edit!
1309                $this->options['changed'] = false;
1310            } else {
1311                // This indicates that calling code has given us the wrong RevisionRecord object
1312                throw new LogicException(
1313                    'The RevisionRecord mismatches old revision ID: '
1314                    . 'Old ID is ' . $oldId
1315                    . ', parent ID is ' . $revision->getParentId()
1316                    . ', revision ID is ' . $revision->getId()
1317                );
1318            }
1319        }
1320
1321        // If prepareContent() was used to generate the PST content (which is indicated by
1322        // $this->slotsUpdate being set), and this is not a null-edit, then the given
1323        // revision must have the acting user as the revision author. Otherwise, user
1324        // signatures generated by PST would mismatch the user in the revision record.
1325        if ( $this->user !== null && $this->options['changed'] && $this->slotsUpdate ) {
1326            $user = $revision->getUser();
1327            if ( !$this->user->equals( $user ) ) {
1328                throw new LogicException(
1329                    'The RevisionRecord provided has a mismatching actor: expected '
1330                    . $this->user->getName()
1331                    . ', got '
1332                    . $user->getName()
1333                );
1334            }
1335        }
1336
1337        // If $this->pageState was not yet initialized by grabCurrentRevision or prepareContent,
1338        // emulate the state of the page table before the edit, as good as we can.
1339        if ( !$this->pageState ) {
1340            $this->pageState = [
1341                'oldIsRedirect' => isset( $this->options['oldredirect'] )
1342                    && is_bool( $this->options['oldredirect'] )
1343                        ? $this->options['oldredirect']
1344                        : null,
1345                'oldCountable' => isset( $this->options['oldcountable'] )
1346                    && is_bool( $this->options['oldcountable'] )
1347                        ? $this->options['oldcountable']
1348                        : null,
1349            ];
1350
1351            if ( $this->options['changed'] ) {
1352                // The edit created a new revision
1353                $this->pageState['oldId'] = $revision->getParentId();
1354                // Old revision is null if this is a page creation
1355                $this->pageState['oldRevision'] = $this->options['oldrevision'] ?? null;
1356            } else {
1357                // This is a null-edit, so the old revision IS the new revision!
1358                $this->pageState['oldId'] = $revision->getId();
1359                $this->pageState['oldRevision'] = $revision;
1360            }
1361        }
1362
1363        // "created" is forced here
1364        $this->options['created'] = ( $this->options['created'] ||
1365                        ( $this->pageState['oldId'] === 0 ) );
1366
1367        $this->revision = $revision;
1368
1369        $this->doTransition( 'has-revision' );
1370
1371        // NOTE: in case we have a User object, don't override with a UserIdentity.
1372        // We already checked that $revision->getUser() matches $this->user;
1373        if ( !$this->user ) {
1374            $this->user = $revision->getUser( RevisionRecord::RAW );
1375        }
1376
1377        // Prune any output that depends on the revision ID.
1378        if ( $this->renderedRevision ) {
1379            $this->renderedRevision->updateRevision( $revision );
1380        } else {
1381            [ $causeAction, ] = $this->getCause();
1382            // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
1383            // NOTE: the revision is either new or current, so we can bypass audience checks.
1384            $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
1385                $this->revision,
1386                null,
1387                null,
1388                [
1389                    'use-master' => $this->usePrimary(),
1390                    'audience' => RevisionRecord::RAW,
1391                    'known-revision-output' => $options['known-revision-output'] ?? null,
1392                    'causeAction' => $causeAction
1393                ]
1394            );
1395
1396            // XXX: Since we presumably are dealing with the current revision,
1397            // we could try to get the ParserOutput from the parser cache.
1398        }
1399
1400        // TODO: optionally get ParserOutput from the ParserCache here.
1401        // Move the logic used by RefreshLinksJob here!
1402    }
1403
1404    /**
1405     * @deprecated This only exists for B/C, use the getters on DerivedPageDataUpdater directly!
1406     * @return PreparedEdit
1407     */
1408    public function getPreparedEdit() {
1409        $this->assertPrepared( __METHOD__ );
1410
1411        $slotsUpdate = $this->getRevisionSlotsUpdate();
1412        $preparedEdit = new PreparedEdit();
1413
1414        $preparedEdit->popts = $this->getCanonicalParserOptions();
1415        $preparedEdit->parserOutputCallback = [ $this, 'getCanonicalParserOutput' ];
1416        $preparedEdit->pstContent = $this->revision->getContent( SlotRecord::MAIN );
1417        $preparedEdit->newContent =
1418            $slotsUpdate->isModifiedSlot( SlotRecord::MAIN )
1419            ? $slotsUpdate->getModifiedSlot( SlotRecord::MAIN )->getContent()
1420            : $this->revision->getContent( SlotRecord::MAIN ); // XXX: can we just remove this?
1421        $preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision
1422        $preparedEdit->revid = $this->revision ? $this->revision->getId() : null;
1423        $preparedEdit->format = $preparedEdit->pstContent->getDefaultFormat();
1424
1425        return $preparedEdit;
1426    }
1427
1428    /**
1429     * @param string $role
1430     * @param bool $generateHtml
1431     * @return ParserOutput
1432     */
1433    public function getSlotParserOutput( $role, $generateHtml = true ) {
1434        return $this->getRenderedRevision()->getSlotParserOutput(
1435            $role,
1436            [ 'generate-html' => $generateHtml ]
1437        );
1438    }
1439
1440    /**
1441     * @since 1.37
1442     * @return ParserOutput
1443     */
1444    public function getParserOutputForMetaData(): ParserOutput {
1445        return $this->getRenderedRevision()->getRevisionParserOutput( [ 'generate-html' => false ] );
1446    }
1447
1448    /**
1449     * @inheritDoc
1450     * @return ParserOutput
1451     */
1452    public function getCanonicalParserOutput(): ParserOutput {
1453        return $this->getRenderedRevision()->getRevisionParserOutput();
1454    }
1455
1456    /**
1457     * @return ParserOptions
1458     */
1459    public function getCanonicalParserOptions(): ParserOptions {
1460        return $this->getRenderedRevision()->getOptions();
1461    }
1462
1463    /**
1464     * @param bool $recursive
1465     *
1466     * @return DeferrableUpdate[]
1467     */
1468    public function getSecondaryDataUpdates( $recursive = false ) {
1469        if ( $this->isContentDeleted() ) {
1470            // This shouldn't happen, since the current content is always public,
1471            // and DataUpdates are only needed for current content.
1472            return [];
1473        }
1474
1475        $wikiPage = $this->getWikiPage();
1476        $wikiPage->loadPageData( IDBAccessObject::READ_LATEST );
1477        if ( !$wikiPage->exists() ) {
1478            // page deleted while deferring the update
1479            return [];
1480        }
1481
1482        $title = $wikiPage->getTitle();
1483        $allUpdates = [];
1484        $parserOutput = $this->shouldGenerateHTMLOnEdit() ?
1485            $this->getCanonicalParserOutput() : $this->getParserOutputForMetaData();
1486
1487        // Construct a LinksUpdate for the combined canonical output.
1488        $linksUpdate = new LinksUpdate(
1489            $title,
1490            $parserOutput,
1491            $recursive,
1492            // Redirect target may have changed if the page is or was a redirect.
1493            // (We can't check if it was definitely changed without additional queries.)
1494            $this->isRedirect() || $this->wasRedirect()
1495        );
1496        if ( $this->options['moved'] ) {
1497            // @phan-suppress-next-line PhanTypeMismatchArgument Oldtitle is set along with moved
1498            $linksUpdate->setMoveDetails( $this->options['oldtitle'] );
1499        }
1500
1501        $allUpdates[] = $linksUpdate;
1502        // NOTE: Run updates for all slots, not just the modified slots! Otherwise,
1503        // info for an inherited slot may end up being removed. This is also needed
1504        // to ensure that purges are effective.
1505        $renderedRevision = $this->getRenderedRevision();
1506
1507        foreach ( $this->getSlots()->getSlotRoles() as $role ) {
1508            $slot = $this->getRawSlot( $role );
1509            $content = $slot->getContent();
1510            $handler = $content->getContentHandler();
1511
1512            $updates = $handler->getSecondaryDataUpdates(
1513                $title,
1514                $content,
1515                $role,
1516                $renderedRevision
1517            );
1518
1519            $allUpdates = array_merge( $allUpdates, $updates );
1520        }
1521
1522        // XXX: if a slot was removed by an earlier edit, but deletion updates failed to run at
1523        // that time, we don't know for which slots to run deletion updates when purging a page.
1524        // We'd have to examine the entire history of the page to determine that. Perhaps there
1525        // could be a "try extra hard" mode for that case that would run a DB query to find all
1526        // roles/models ever used on the page. On the other hand, removing slots should be quite
1527        // rare, so perhaps this isn't worth the trouble.
1528
1529        // TODO: consolidate with similar logic in WikiPage::getDeletionUpdates()
1530        $parentRevision = $this->getParentRevision();
1531        foreach ( $this->getRemovedSlotRoles() as $role ) {
1532            // HACK: we should get the content model of the removed slot from a SlotRoleHandler!
1533            // For now, find the slot in the parent revision - if the slot was removed, it should
1534            // always exist in the parent revision.
1535            $parentSlot = $parentRevision->getSlot( $role, RevisionRecord::RAW );
1536            $content = $parentSlot->getContent();
1537            $handler = $content->getContentHandler();
1538
1539            $updates = $handler->getDeletionUpdates(
1540                $title,
1541                $role
1542            );
1543
1544            $allUpdates = array_merge( $allUpdates, $updates );
1545        }
1546
1547        // TODO: hard deprecate SecondaryDataUpdates in favor of RevisionDataUpdates in 1.33!
1548        $this->hookRunner->onRevisionDataUpdates( $title, $renderedRevision, $allUpdates );
1549
1550        return $allUpdates;
1551    }
1552
1553    /**
1554     * @return bool true if at least one of slots require rendering HTML on edit, false otherwise.
1555     *              This is needed for example in populating ParserCache.
1556     */
1557    private function shouldGenerateHTMLOnEdit(): bool {
1558        foreach ( $this->getSlots()->getSlotRoles() as $role ) {
1559            $slot = $this->getRawSlot( $role );
1560            $contentHandler = $this->contentHandlerFactory->getContentHandler( $slot->getModel() );
1561            if ( $contentHandler->generateHTMLOnEdit() ) {
1562                return true;
1563            }
1564        }
1565        return false;
1566    }
1567
1568    /**
1569     * Do standard updates after page edit, purge, or import.
1570     * Update links tables, site stats, search index, title cache, message cache, etc.
1571     * Purges pages that depend on this page when appropriate.
1572     * With a 10% chance, triggers pruning the recent changes table.
1573     *
1574     * @note prepareUpdate() must be called before calling this method!
1575     *
1576     * MCR migration note: this replaces WikiPage::doEditUpdates.
1577     */
1578    public function doUpdates() {
1579        $this->assertTransition( 'done' );
1580
1581        // TODO: move logic into a PageEventEmitter service
1582
1583        $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
1584
1585        if ( $this->shouldGenerateHTMLOnEdit() ) {
1586            $this->triggerParserCacheUpdate();
1587        }
1588
1589        $this->doSecondaryDataUpdates( [
1590            // T52785 do not update any other pages on a null edit
1591            'recursive' => $this->options['changed'],
1592            // Defer the getCanonicalParserOutput() call made by getSecondaryDataUpdates()
1593            'defer' => DeferredUpdates::POSTSEND
1594        ] );
1595
1596        // TODO: MCR: check if *any* changed slot supports categories!
1597        if ( $this->rcWatchCategoryMembership
1598            && $this->getContentHandler( SlotRecord::MAIN )->supportsCategories() === true
1599            && ( $this->options['changed'] || $this->options['created'] )
1600            && !$this->options['restored']
1601        ) {
1602            // Note: jobs are pushed after deferred updates, so the job should be able to see
1603            // the recent change entry (also done via deferred updates) and carry over any
1604            // bot/deletion/IP flags, ect.
1605            $this->jobQueueGroup->lazyPush(
1606                CategoryMembershipChangeJob::newSpec(
1607                    $this->getTitle(),
1608                    $this->revision->getTimestamp()
1609                )
1610            );
1611        }
1612
1613        $id = $this->getPageId();
1614        $title = $this->getTitle();
1615        $shortTitle = $title->getDBkey();
1616
1617        if ( !$title->exists() ) {
1618            wfDebug( __METHOD__ . ": Page doesn't exist any more, bailing out" );
1619
1620            $this->doTransition( 'done' );
1621            return;
1622        }
1623
1624        DeferredUpdates::addCallableUpdate( function () {
1625            if (
1626                $this->options['oldcountable'] === 'no-change' ||
1627                ( !$this->options['changed'] && !$this->options['moved'] )
1628            ) {
1629                $good = 0;
1630            } elseif ( $this->options['created'] ) {
1631                $good = (int)$this->isCountable();
1632            } elseif ( $this->options['oldcountable'] !== null ) {
1633                $good = (int)$this->isCountable()
1634                    - (int)$this->options['oldcountable'];
1635            } elseif ( isset( $this->pageState['oldCountable'] ) ) {
1636                $good = (int)$this->isCountable()
1637                    - (int)$this->pageState['oldCountable'];
1638            } else {
1639                $good = 0;
1640            }
1641            $edits = $this->options['changed'] ? 1 : 0;
1642            $pages = $this->options['created'] ? 1 : 0;
1643
1644            DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
1645                [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ]
1646            ) );
1647        } );
1648
1649        // TODO: make search infrastructure aware of slots!
1650        $mainSlot = $this->revision->getSlot( SlotRecord::MAIN );
1651        if ( !$mainSlot->isInherited() && !$this->isContentDeleted() ) {
1652            DeferredUpdates::addUpdate( new SearchUpdate( $id, $title, $mainSlot->getContent() ) );
1653        }
1654
1655        // If this is another user's talk page, update newtalk.
1656        // Don't do this if $options['changed'] = false (null-edits) nor if
1657        // it's a minor edit and the user making the edit doesn't generate notifications for those.
1658        // TODO: the permission check should be performed by the callers, see T276181.
1659        if ( $this->options['changed']
1660            && $title->getNamespace() === NS_USER_TALK
1661            && $title->getText() != $this->user->getName()
1662            && !( $this->revision->isMinor() && $this->permissionManager
1663                ->userHasRight( $this->user, 'nominornewtalk' )
1664            )
1665        ) {
1666            $recipient = User::newFromName( $shortTitle, false );
1667            if ( !$recipient ) {
1668                wfDebug( __METHOD__ . ": invalid username" );
1669            } else {
1670                // Allow extensions to prevent user notification
1671                // when a new message is added to their talk page
1672                // TODO: replace legacy hook!  Use a listener on PageEventEmitter instead!
1673                if ( $this->hookRunner->onArticleEditUpdateNewTalk( $wikiPage, $recipient ) ) {
1674                    $revRecord = $this->revision;
1675                    if ( $this->userNameUtils->isIP( $shortTitle ) ) {
1676                        // An anonymous user
1677                        $this->talkPageNotificationManager->setUserHasNewMessages( $recipient, $revRecord );
1678                    } elseif ( $recipient->isRegistered() ) {
1679                        $this->talkPageNotificationManager->setUserHasNewMessages( $recipient, $revRecord );
1680                    } else {
1681                        wfDebug( __METHOD__ . ": don't need to notify a nonexistent user" );
1682                    }
1683                }
1684            }
1685        }
1686
1687        if ( $title->getNamespace() === NS_MEDIAWIKI
1688            && $this->getRevisionSlotsUpdate()->isModifiedSlot( SlotRecord::MAIN )
1689        ) {
1690            $mainContent = $this->isContentDeleted() ? null : $this->getRawContent( SlotRecord::MAIN );
1691
1692            $this->messageCache->updateMessageOverride( $title, $mainContent );
1693        }
1694
1695        // TODO: move onArticleCreate and onArticleEdit into a PageEventEmitter service
1696        if ( $this->options['created'] ) {
1697            WikiPage::onArticleCreate( $title, $this->isRedirect() );
1698        } elseif ( $this->options['changed'] ) { // T52785
1699            WikiPage::onArticleEdit(
1700                $title,
1701                $this->revision,
1702                $this->getTouchedSlotRoles(),
1703                // Redirect target may have changed if the page is or was a redirect.
1704                // (We can't check if it was definitely changed without additional queries.)
1705                $this->isRedirect() || $this->wasRedirect()
1706            );
1707        } elseif ( $this->options['restored'] ) {
1708            $this->mainWANObjectCache->touchCheckKey(
1709                "DerivedPageDataUpdater:restore:page:$id"
1710            );
1711        }
1712
1713        $oldRevisionRecord = $this->getParentRevision();
1714
1715        // TODO: In the wiring, register a listener for this on the new PageEventEmitter
1716        RL\WikiModule::invalidateModuleCache(
1717            $title,
1718            $oldRevisionRecord,
1719            $this->revision,
1720            $this->loadbalancerFactory->getLocalDomainID()
1721        );
1722
1723        // Schedule a deferred update for marking reverted edits if applicable.
1724        $this->maybeEnqueueRevertedTagUpdateJob();
1725
1726        $this->doTransition( 'done' );
1727    }
1728
1729    private function triggerParserCacheUpdate() {
1730        $this->assertHasRevision( __METHOD__ );
1731
1732        $userParserOptions = ParserOptions::newFromUser( $this->user );
1733
1734        // Decide whether to save the final canonical parser output based on the fact that
1735        // users are typically redirected to viewing pages right after they edit those pages.
1736        // Due to vary-revision-id, getting/saving that output here might require a reparse.
1737        if ( $userParserOptions->matchesForCacheKey( $this->getCanonicalParserOptions() ) ) {
1738            // Whether getting the final output requires a reparse or not, the user will
1739            // need canonical output anyway, since that is what their parser options use.
1740            // A reparse now at least has the benefit of various warm process caches.
1741            $this->doParserCacheUpdate();
1742        } else {
1743            // If the user does not have canonical parse options, then don't risk another parse
1744            // to make output they cannot use on the page refresh that typically occurs after
1745            // editing. Doing the parser output save post-send will still benefit *other* users.
1746            DeferredUpdates::addCallableUpdate( function () {
1747                $this->doParserCacheUpdate();
1748            } );
1749        }
1750    }
1751
1752    /**
1753     * If the edit was a revert and it is considered "approved", enqueues the
1754     * RevertedTagUpdateJob for it. If the edit is not yet approved, the EditResult is
1755     * persisted in cache for later use.
1756     */
1757    private function maybeEnqueueRevertedTagUpdateJob() {
1758        if ( $this->options['editResult'] === null ) {
1759            return;
1760        }
1761
1762        $editResult = $this->options['editResult'];
1763        if ( !$editResult->isRevert() ) {
1764            return;
1765        }
1766
1767        if ( $this->options['approved'] ) {
1768            // Enqueue the job
1769            $this->jobQueueGroup->lazyPush(
1770                RevertedTagUpdateJob::newSpec(
1771                    $this->revision->getId(),
1772                    $this->options['editResult']
1773                )
1774            );
1775        } else {
1776            // Cache EditResult for later use
1777            $this->editResultCache->set(
1778                $this->revision->getId(),
1779                $this->options['editResult']
1780            );
1781        }
1782    }
1783
1784    /**
1785     * Do secondary data updates (e.g. updating link tables) or schedule them as deferred updates
1786     *
1787     * @note This does not update the parser cache. Use doParserCacheUpdate() for that.
1788     * @note Application logic should use Wikipage::doSecondaryDataUpdates instead.
1789     *
1790     * @param array $options
1791     *   - recursive: make the update recursive, i.e. also update pages which transclude the
1792     *     current page or otherwise depend on it (default: false)
1793     *   - defer: one of the DeferredUpdates constants, or false to run immediately after waiting
1794     *     for replication of the changes from the SecondaryDataUpdates hooks (default: false)
1795     *   - freshness: used with 'defer'; forces an update if the last update was before the given timestamp,
1796     *     even if the page and its dependencies didn't change since then (TS_MW; default: false)
1797     * @since 1.32
1798     */
1799    public function doSecondaryDataUpdates( array $options = [] ) {
1800        $this->assertHasRevision( __METHOD__ );
1801        $options += [ 'recursive' => false, 'defer' => false, 'freshness' => false ];
1802        $deferValues = [ false, DeferredUpdates::PRESEND, DeferredUpdates::POSTSEND ];
1803        if ( !in_array( $options['defer'], $deferValues, true ) ) {
1804            throw new InvalidArgumentException( 'Invalid value for defer: ' . $options['defer'] );
1805        }
1806
1807        $triggeringUser = $this->options['triggeringUser'] ?? $this->user;
1808        [ $causeAction, $causeAgent ] = $this->getCause();
1809        if ( isset( $options['known-revision-output'] ) ) {
1810            $this->getRenderedRevision()->setRevisionParserOutput( $options['known-revision-output'] );
1811        }
1812
1813        // Bundle all of the data updates into a single deferred update wrapper so that
1814        // any failure will cause at most one refreshLinks job to be enqueued by
1815        // DeferredUpdates::doUpdates(). This is hard to do when there are many separate
1816        // updates that are not defined as being related.
1817        $update = new RefreshSecondaryDataUpdate(
1818            $this->loadbalancerFactory,
1819            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Already checked
1820            $triggeringUser,
1821            $this->wikiPage,
1822            $this->revision,
1823            $this,
1824            [ 'recursive' => $options['recursive'], 'freshness' => $options['freshness'] ]
1825        );
1826        $update->setCause( $causeAction, $causeAgent );
1827
1828        if ( $options['defer'] === false ) {
1829            DeferredUpdates::attemptUpdate( $update );
1830        } else {
1831            DeferredUpdates::addUpdate( $update, $options['defer'] );
1832        }
1833    }
1834
1835    /**
1836     * Causes parser cache entries to be updated.
1837     *
1838     * @note This does not update links tables. Use doSecondaryDataUpdates() for that.
1839     * @note Application logic should use Wikipage::updateParserCache instead.
1840     */
1841    public function doParserCacheUpdate() {
1842        $this->assertHasRevision( __METHOD__ );
1843
1844        $wikiPage = $this->getWikiPage(); // TODO: ParserCache should accept a RevisionRecord instead
1845
1846        // NOTE: this may trigger the first parsing of the new content after an edit (when not
1847        // using pre-generated stashed output).
1848        // XXX: we may want to use the PoolCounter here. This would perhaps allow the initial parse
1849        // to be performed post-send. The client could already follow a HTTP redirect to the
1850        // page view, but would then have to wait for a response until rendering is complete.
1851        $output = $this->getCanonicalParserOutput();
1852
1853        // Save it to the parser cache. Use the revision timestamp in the case of a
1854        // freshly saved edit, as that matches page_touched and a mismatch would trigger an
1855        // unnecessary reparse.
1856        $timestamp = $this->options['newrev'] ? $this->revision->getTimestamp()
1857            : $output->getCacheTime();
1858        $this->parserCache->save(
1859            $output, $wikiPage, $this->getCanonicalParserOptions(),
1860            $timestamp, $this->revision->getId()
1861        );
1862
1863        // If we enable cache warming with parsoid outputs, let's do it at the same
1864        // time we're populating the parser cache with pre-generated HTML.
1865        // Use OPT_FORCE_PARSE to avoid a useless cache lookup.
1866        if ( $this->warmParsoidParserCache ) {
1867            $cacheWarmingParams = $this->getCause();
1868            $cacheWarmingParams['options'] = ParserOutputAccess::OPT_FORCE_PARSE;
1869
1870            $this->jobQueueGroup->lazyPush(
1871                ParsoidCachePrewarmJob::newSpec(
1872                    $this->revision->getId(),
1873                    $wikiPage->toPageRecord(),
1874                    $cacheWarmingParams
1875                )
1876            );
1877        }
1878    }
1879
1880}