Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.22% covered (warning)
79.22%
286 / 361
47.83% covered (danger)
47.83%
11 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
DeletePage
79.22% covered (warning)
79.22%
286 / 361
47.83% covered (danger)
47.83%
11 / 23
139.83
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 getLegacyHookErrors
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 keepLegacyHookErrorsSeparate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setSuppress
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setTags
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setLogSubtype
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 forceImmediate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 canProbablyDeleteAssociatedTalk
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 setDeleteAssociatedTalk
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 setIsDeletePageUnitTest
n/a
0 / 0
n/a
0 / 0
2
 setDeletionAttempted
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
2.09
 assertDeletionAttempted
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getSuccessfulDeletionsIDs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deletionsWereScheduled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deleteIfAllowed
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 authorizeDeletion
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 isBigDeletion
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 isBatchedDelete
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 deleteUnsafe
47.06% covered (danger)
47.06%
8 / 17
0.00% covered (danger)
0.00%
0 / 1
11.34
 runPreDeleteHooks
47.06% covered (danger)
47.06%
8 / 17
0.00% covered (danger)
0.00%
0 / 1
21.02
 deleteInternal
88.98% covered (warning)
88.98%
105 / 118
0.00% covered (danger)
0.00%
0 / 1
13.23
 archiveRevisions
92.65% covered (success)
92.65%
63 / 68
0.00% covered (danger)
0.00%
0 / 1
9.03
 doDeleteUpdates
60.71% covered (warning)
60.71%
17 / 28
0.00% covered (danger)
0.00%
0 / 1
9.97
 getDeletionUpdates
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\Page;
4
5use BadMethodCallException;
6use Exception;
7use LogicException;
8use MediaWiki\Cache\BacklinkCacheFactory;
9use MediaWiki\ChangeTags\ChangeTags;
10use MediaWiki\CommentStore\CommentStore;
11use MediaWiki\Config\ServiceOptions;
12use MediaWiki\Content\Content;
13use MediaWiki\Deferred\DeferrableUpdate;
14use MediaWiki\Deferred\DeferredUpdates;
15use MediaWiki\Deferred\LinksUpdate\LinksDeletionUpdate;
16use MediaWiki\Deferred\LinksUpdate\LinksUpdate;
17use MediaWiki\Deferred\SiteStatsUpdate;
18use MediaWiki\DomainEvent\DomainEventDispatcher;
19use MediaWiki\HookContainer\HookContainer;
20use MediaWiki\HookContainer\HookRunner;
21use MediaWiki\JobQueue\JobQueueGroup;
22use MediaWiki\Language\RawMessage;
23use MediaWiki\Logging\ManualLogEntry;
24use MediaWiki\MainConfigNames;
25use MediaWiki\Message\Message;
26use MediaWiki\Page\Event\PageDeletedEvent;
27use MediaWiki\Permissions\Authority;
28use MediaWiki\Permissions\PermissionStatus;
29use MediaWiki\Revision\RevisionRecord;
30use MediaWiki\Revision\RevisionStore;
31use MediaWiki\Revision\SlotRecord;
32use MediaWiki\Status\Status;
33use MediaWiki\Title\NamespaceInfo;
34use MediaWiki\Title\Title;
35use MediaWiki\User\UserFactory;
36use StatusValue;
37use Wikimedia\IPUtils;
38use Wikimedia\Message\ITextFormatter;
39use Wikimedia\Message\MessageValue;
40use Wikimedia\ObjectCache\BagOStuff;
41use Wikimedia\Rdbms\IDBAccessObject;
42use Wikimedia\Rdbms\LBFactory;
43use Wikimedia\RequestTimeout\TimeoutException;
44
45/**
46 * Backend logic for performing a page delete action.
47 *
48 * @since 1.37
49 * @ingroup Page
50 */
51class DeletePage {
52    /**
53     * @internal For use by PageCommandFactory
54     */
55    public const CONSTRUCTOR_OPTIONS = [
56        MainConfigNames::DeleteRevisionsBatchSize,
57        MainConfigNames::DeleteRevisionsLimit,
58    ];
59
60    /**
61     * Constants used for the return value of getSuccessfulDeletionsIDs() and deletionsWereScheduled()
62     */
63    public const PAGE_BASE = 'base';
64    public const PAGE_TALK = 'talk';
65
66    /** @var bool */
67    private $isDeletePageUnitTest = false;
68    /** @var bool */
69    private $suppress = false;
70    /** @var string[] */
71    private $tags = [];
72    /** @var string */
73    private $logSubtype = 'delete';
74    /** @var bool */
75    private $forceImmediate = false;
76    /** @var WikiPage|null If not null, it means that we have to delete it. */
77    private $associatedTalk;
78
79    /** @var string|array */
80    private $legacyHookErrors = '';
81    /** @var bool */
82    private $mergeLegacyHookErrors = true;
83
84    /**
85     * @var array<int|null>|null Keys are the self::PAGE_* constants. Values are null if the deletion couldn't happen
86     * (e.g. due to lacking perms) or was scheduled. PAGE_TALK is only set when deleting the associated talk.
87     */
88    private $successfulDeletionsIDs;
89    /**
90     * @var array<bool|null>|null Keys are the self::PAGE_* constants. Values are null if the deletion couldn't happen
91     * (e.g. due to lacking perms). PAGE_TALK is only set when deleting the associated talk.
92     */
93    private $wasScheduled;
94    /** @var bool Whether a deletion was attempted */
95    private $attemptedDeletion = false;
96
97    private HookRunner $hookRunner;
98    private DomainEventDispatcher $eventDispatcher;
99    private RevisionStore $revisionStore;
100    private LBFactory $lbFactory;
101    private JobQueueGroup $jobQueueGroup;
102    private CommentStore $commentStore;
103    private ServiceOptions $options;
104    private BagOStuff $recentDeletesCache;
105    private string $localWikiID;
106    private string $webRequestID;
107    private WikiPageFactory $wikiPageFactory;
108    private UserFactory $userFactory;
109    private BacklinkCacheFactory $backlinkCacheFactory;
110    private NamespaceInfo $namespaceInfo;
111    private ITextFormatter $contLangMsgTextFormatter;
112    private RedirectStore $redirectStore;
113    private WikiPage $page;
114    private Authority $deleter;
115
116    /**
117     * @internal Create via the PageDeleteFactory service.
118     */
119    public function __construct(
120        HookContainer $hookContainer,
121        DomainEventDispatcher $eventDispatcher,
122        RevisionStore $revisionStore,
123        LBFactory $lbFactory,
124        JobQueueGroup $jobQueueGroup,
125        CommentStore $commentStore,
126        ServiceOptions $serviceOptions,
127        BagOStuff $recentDeletesCache,
128        string $localWikiID,
129        string $webRequestID,
130        WikiPageFactory $wikiPageFactory,
131        UserFactory $userFactory,
132        BacklinkCacheFactory $backlinkCacheFactory,
133        NamespaceInfo $namespaceInfo,
134        ITextFormatter $contLangMsgTextFormatter,
135        RedirectStore $redirectStore,
136        ProperPageIdentity $page,
137        Authority $deleter
138    ) {
139        $this->hookRunner = new HookRunner( $hookContainer );
140        $this->eventDispatcher = $eventDispatcher;
141        $this->revisionStore = $revisionStore;
142        $this->lbFactory = $lbFactory;
143        $this->jobQueueGroup = $jobQueueGroup;
144        $this->commentStore = $commentStore;
145        $serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
146        $this->options = $serviceOptions;
147        $this->recentDeletesCache = $recentDeletesCache;
148        $this->localWikiID = $localWikiID;
149        $this->webRequestID = $webRequestID;
150        $this->wikiPageFactory = $wikiPageFactory;
151        $this->userFactory = $userFactory;
152        $this->backlinkCacheFactory = $backlinkCacheFactory;
153        $this->namespaceInfo = $namespaceInfo;
154        $this->contLangMsgTextFormatter = $contLangMsgTextFormatter;
155
156        $this->page = $wikiPageFactory->newFromTitle( $page );
157        $this->deleter = $deleter;
158        $this->redirectStore = $redirectStore;
159    }
160
161    /**
162     * @internal BC method for use by WikiPage::doDeleteArticleReal only.
163     * @return array|string
164     */
165    public function getLegacyHookErrors() {
166        return $this->legacyHookErrors;
167    }
168
169    /**
170     * @internal BC method for use by WikiPage::doDeleteArticleReal only.
171     * @return self
172     */
173    public function keepLegacyHookErrorsSeparate(): self {
174        $this->mergeLegacyHookErrors = false;
175        return $this;
176    }
177
178    /**
179     * If true, suppress all revisions and log the deletion in the suppression log instead of
180     * the deletion log.
181     *
182     * @param bool $suppress
183     * @return self For chaining
184     */
185    public function setSuppress( bool $suppress ): self {
186        $this->suppress = $suppress;
187        return $this;
188    }
189
190    /**
191     * Change tags to apply to the deletion action
192     *
193     * @param string[] $tags
194     * @return self For chaining
195     */
196    public function setTags( array $tags ): self {
197        $this->tags = $tags;
198        return $this;
199    }
200
201    /**
202     * Set a specific log subtype for the deletion log entry.
203     *
204     * @param string $logSubtype
205     * @return self For chaining
206     */
207    public function setLogSubtype( string $logSubtype ): self {
208        $this->logSubtype = $logSubtype;
209        return $this;
210    }
211
212    /**
213     * If false, allows deleting over time via the job queue
214     *
215     * @param bool $forceImmediate
216     * @return self For chaining
217     */
218    public function forceImmediate( bool $forceImmediate ): self {
219        $this->forceImmediate = $forceImmediate;
220        return $this;
221    }
222
223    /**
224     * Tests whether it's probably possible to delete the associated talk page. This checks the replica,
225     * so it may not see the latest master change, and is useful e.g. for building the UI.
226     */
227    public function canProbablyDeleteAssociatedTalk(): StatusValue {
228        if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
229            return StatusValue::newFatal( 'delete-error-associated-alreadytalk' );
230        }
231        // FIXME NamespaceInfo should work with PageIdentity
232        $talkPage = $this->wikiPageFactory->newFromLinkTarget(
233            $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
234        );
235        if ( !$talkPage->exists() ) {
236            return StatusValue::newFatal( 'delete-error-associated-doesnotexist' );
237        }
238        return StatusValue::newGood();
239    }
240
241    /**
242     * If set to true and the page has a talk page, delete that one too. Callers should call
243     * canProbablyDeleteAssociatedTalk first to make sure this is a valid operation. Note that the checks
244     * here are laxer than those in canProbablyDeleteAssociatedTalk. In particular, this doesn't check
245     * whether the page exists as that may be subject to race condition, and it's checked later on (in deleteInternal,
246     * using latest data) anyway.
247     *
248     * @param bool $delete
249     * @return self For chaining
250     * @throws BadMethodCallException If $delete is true and the given page is not a talk page.
251     */
252    public function setDeleteAssociatedTalk( bool $delete ): self {
253        if ( !$delete ) {
254            $this->associatedTalk = null;
255            return $this;
256        }
257
258        if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
259            throw new BadMethodCallException( "Cannot delete associated talk page of a talk page! ($this->page)" );
260        }
261        // FIXME NamespaceInfo should work with PageIdentity
262        $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
263            $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
264        );
265        return $this;
266    }
267
268    /**
269     * @internal FIXME: Hack used when running the DeletePage unit test to disable some legacy code.
270     * @codeCoverageIgnore
271     * @param bool $test
272     */
273    public function setIsDeletePageUnitTest( bool $test ): void {
274        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
275            throw new LogicException( __METHOD__ . ' can only be used in tests!' );
276        }
277        $this->isDeletePageUnitTest = $test;
278    }
279
280    /**
281     * Called before attempting a deletion, allows the result getters to be used
282     * @internal The only external caller allowed is DeletePageJob.
283     * @return self
284     */
285    public function setDeletionAttempted(): self {
286        $this->attemptedDeletion = true;
287        $this->successfulDeletionsIDs = [ self::PAGE_BASE => null ];
288        $this->wasScheduled = [ self::PAGE_BASE => null ];
289        if ( $this->associatedTalk ) {
290            $this->successfulDeletionsIDs[self::PAGE_TALK] = null;
291            $this->wasScheduled[self::PAGE_TALK] = null;
292        }
293        return $this;
294    }
295
296    /**
297     * Asserts that a deletion operation was attempted
298     * @throws BadMethodCallException
299     */
300    private function assertDeletionAttempted(): void {
301        if ( !$this->attemptedDeletion ) {
302            throw new BadMethodCallException( 'No deletion was attempted' );
303        }
304    }
305
306    /**
307     * @return int[] Array of log IDs of successful deletions
308     * @throws BadMethodCallException If no deletions were attempted
309     */
310    public function getSuccessfulDeletionsIDs(): array {
311        $this->assertDeletionAttempted();
312        return $this->successfulDeletionsIDs;
313    }
314
315    /**
316     * @return bool[] Whether the deletions were scheduled
317     * @throws BadMethodCallException If no deletions were attempted
318     */
319    public function deletionsWereScheduled(): array {
320        $this->assertDeletionAttempted();
321        return $this->wasScheduled;
322    }
323
324    /**
325     * Same as deleteUnsafe, but checks permissions.
326     *
327     * @param string $reason
328     * @return StatusValue<never>
329     */
330    public function deleteIfAllowed( string $reason ): StatusValue {
331        $this->setDeletionAttempted();
332        $status = $this->authorizeDeletion();
333        if ( !$status->isGood() ) {
334            return $status;
335        }
336
337        return $this->deleteUnsafe( $reason );
338    }
339
340    private function authorizeDeletion(): PermissionStatus {
341        $status = PermissionStatus::newEmpty();
342        $this->deleter->authorizeWrite( 'delete', $this->page, $status );
343        if ( $this->associatedTalk ) {
344            $this->deleter->authorizeWrite( 'delete', $this->associatedTalk, $status );
345        }
346        if ( !$this->deleter->isAllowed( 'bigdelete' ) && $this->isBigDeletion() ) {
347            $status->fatal(
348                'delete-toomanyrevisions',
349                Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) )
350            );
351        }
352        if ( $this->tags ) {
353            $status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->deleter ) );
354        }
355        return $status;
356    }
357
358    private function isBigDeletion(): bool {
359        $revLimit = $this->options->get( MainConfigNames::DeleteRevisionsLimit );
360        if ( !$revLimit ) {
361            return false;
362        }
363
364        $dbr = $this->lbFactory->getReplicaDatabase();
365        $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
366        if ( $this->associatedTalk ) {
367            $revCount += $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
368        }
369
370        return $revCount > $revLimit;
371    }
372
373    /**
374     * Determines if this deletion would be batched (executed over time by the job queue)
375     * or not (completed in the same request as the delete call).
376     *
377     * It is unlikely but possible that an edit from another request could push the page over the
378     * batching threshold after this function is called, but before the caller acts upon the
379     * return value. Callers must decide for themselves how to deal with this. $safetyMargin
380     * is provided as an unreliable but situationally useful help for some common cases.
381     *
382     * @param int $safetyMargin Added to the revision count when checking for batching
383     * @return bool True if deletion would be batched, false otherwise
384     */
385    public function isBatchedDelete( int $safetyMargin = 0 ): bool {
386        $dbr = $this->lbFactory->getReplicaDatabase();
387        $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
388        $revCount += $safetyMargin;
389
390        if ( $revCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ) ) {
391            return true;
392        } elseif ( !$this->associatedTalk ) {
393            return false;
394        }
395
396        $talkRevCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
397        $talkRevCount += $safetyMargin;
398
399        return $talkRevCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
400    }
401
402    /**
403     * Back-end article deletion: deletes the article with database consistency, writes logs, purges caches.
404     * @note This method doesn't check user permissions. Use deleteIfAllowed for that.
405     *
406     * @param string $reason Delete reason for deletion log
407     * @return Status<never> Status object:
408     *   - If successful (or scheduled), a good Status with no value
409     *   - If a page couldn't be deleted because it wasn't found, a Status with a non-fatal 'cannotdelete' error.
410     *   - A fatal Status otherwise.
411     */
412    public function deleteUnsafe( string $reason ): Status {
413        $this->setDeletionAttempted();
414        $origReason = $reason;
415        $hookStatus = $this->runPreDeleteHooks( $this->page, $reason );
416        if ( !$hookStatus->isGood() ) {
417            return $hookStatus;
418        }
419        if ( $this->associatedTalk ) {
420            $talkReason = $this->contLangMsgTextFormatter->format(
421                MessageValue::new( 'delete-talk-summary-prefix' )->plaintextParams( $origReason )
422            );
423            $talkHookStatus = $this->runPreDeleteHooks( $this->associatedTalk, $talkReason );
424            if ( !$talkHookStatus->isGood() ) {
425                return $talkHookStatus;
426            }
427        }
428
429        $status = $this->deleteInternal( $this->page, self::PAGE_BASE, $reason );
430        if ( !$this->associatedTalk || !$status->isGood() ) {
431            return $status;
432        }
433        // NOTE: If the page deletion above failed because the page is no longer there (e.g. race condition) we'll
434        // still try to delete the talk page, since it was the user's intention anyway.
435        // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable talkReason is set when used
436        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable talkReason is set when used
437        $status->merge( $this->deleteInternal( $this->associatedTalk, self::PAGE_TALK, $talkReason ) );
438        return $status;
439    }
440
441    /**
442     * @param WikiPage $page
443     * @param string &$reason
444     * @return Status<never>
445     */
446    private function runPreDeleteHooks( WikiPage $page, string &$reason ): Status {
447        $status = Status::newGood();
448
449        $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
450        if ( !$this->hookRunner->onArticleDelete(
451            $page, $legacyDeleter, $reason, $this->legacyHookErrors, $status, $this->suppress )
452        ) {
453            if ( $this->mergeLegacyHookErrors && $this->legacyHookErrors !== '' ) {
454                if ( is_string( $this->legacyHookErrors ) ) {
455                    $this->legacyHookErrors = [ $this->legacyHookErrors ];
456                }
457                foreach ( $this->legacyHookErrors as $legacyError ) {
458                    $status->fatal( new RawMessage( $legacyError ) );
459                }
460            }
461            if ( $status->isOK() ) {
462                // Hook aborted but didn't set a fatal status
463                $status->fatal( 'delete-hook-aborted' );
464            }
465            return $status;
466        }
467
468        // Use a new Status in case a hook handler put something here without aborting.
469        $status = Status::newGood();
470        $hookRes = $this->hookRunner->onPageDelete( $page, $this->deleter, $reason, $status, $this->suppress );
471        if ( !$hookRes && !$status->isGood() ) {
472            // Note: as per the PageDeleteHook documentation, `return false` is ignored if $status is good.
473            return $status;
474        }
475        return Status::newGood();
476    }
477
478    /**
479     * @internal The only external caller allowed is DeletePageJob.
480     * Back-end article deletion
481     *
482     * Only invokes batching via the job queue if necessary per DeleteRevisionsBatchSize.
483     * Deletions can often be completed inline without involving the job queue.
484     *
485     * Potentially called many times per deletion operation for pages with many revisions.
486     * @param WikiPage $page
487     * @param string $pageRole
488     * @param string $reason
489     * @param string|null $webRequestId
490     * @param mixed|null $ticket Result of ILBFactory::getEmptyTransactionTicket() or null
491     * @return Status<never>
492     */
493    public function deleteInternal(
494        WikiPage $page,
495        string $pageRole,
496        string $reason,
497        ?string $webRequestId = null,
498        $ticket = null
499    ): Status {
500        $title = $page->getTitle();
501        $status = Status::newGood();
502
503        $dbw = $this->lbFactory->getPrimaryDatabase();
504        $dbw->startAtomic( __METHOD__ );
505
506        $page->loadPageData( IDBAccessObject::READ_LATEST );
507        $id = $page->getId();
508        // T98706: lock the page from various other updates but avoid using
509        // IDBAccessObject::READ_LOCKING as that will carry over the FOR UPDATE to
510        // the revisions queries (which also JOIN on user). Only lock the page
511        // row and CAS check on page_latest to see if the trx snapshot matches.
512        $lockedLatest = $page->lockAndGetLatest();
513        if ( $id === 0 || $page->getLatest() !== $lockedLatest ) {
514            $dbw->endAtomic( __METHOD__ );
515            // Page not there or trx snapshot is stale
516            $status->error( 'cannotdelete', wfEscapeWikiText( $title->getPrefixedText() ) );
517            return $status;
518        }
519
520        // At this point we are now committed to returning an OK
521        // status unless some DB query error or other exception comes up.
522        // This way callers don't have to call rollback() if $status is bad
523        // unless they actually try to catch exceptions (which is rare).
524
525        // we need to remember the old content so we can use it to generate all deletion updates.
526        $revisionRecord = $page->getRevisionRecord();
527        if ( !$revisionRecord ) {
528            throw new LogicException( "No revisions for $page?" );
529        }
530        try {
531            $content = $page->getContent( RevisionRecord::RAW );
532        } catch ( TimeoutException $e ) {
533            throw $e;
534        } catch ( Exception $ex ) {
535            wfLogWarning( __METHOD__ . ': failed to load content during deletion! '
536                . $ex->getMessage() );
537
538            $content = null;
539        }
540
541        // Archive revisions.  In immediate mode, archive all revisions.  Otherwise, archive
542        // one batch of revisions and defer archival of any others to the job queue.
543        while ( true ) {
544            $done = $this->archiveRevisions( $page, $id );
545            if ( $done || !$this->forceImmediate ) {
546                break;
547            }
548            $dbw->endAtomic( __METHOD__ );
549            $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
550            $dbw->startAtomic( __METHOD__ );
551        }
552
553        if ( !$done ) {
554            $dbw->endAtomic( __METHOD__ );
555
556            $jobParams = [
557                'namespace' => $title->getNamespace(),
558                'title' => $title->getDBkey(),
559                'wikiPageId' => $id,
560                'requestId' => $webRequestId ?? $this->webRequestID,
561                'reason' => $reason,
562                'suppress' => $this->suppress,
563                'userId' => $this->deleter->getUser()->getId(),
564                'tags' => json_encode( $this->tags ),
565                'logsubtype' => $this->logSubtype,
566                'pageRole' => $pageRole,
567            ];
568
569            $job = new DeletePageJob( $jobParams );
570            $this->jobQueueGroup->push( $job );
571            $this->wasScheduled[$pageRole] = true;
572            return $status;
573        }
574        $this->wasScheduled[$pageRole] = false;
575
576        // Get archivedRevisionCount by db query, because there's no better alternative.
577        // Jobs cannot pass a count of archived revisions to the next job, because additional
578        // deletion operations can be started while the first is running.  Jobs from each
579        // gracefully interleave, but would not know about each other's count.  Deduplication
580        // in the job queue to avoid simultaneous deletion operations would add overhead.
581        // Number of archived revisions cannot be known beforehand, because edits can be made
582        // while deletion operations are being processed, changing the number of archivals.
583        $archivedRevisionCount = $dbw->newSelectQueryBuilder()
584            ->select( '*' )
585            ->from( 'archive' )
586            ->where( [
587                'ar_namespace' => $title->getNamespace(),
588                'ar_title' => $title->getDBkey(),
589                'ar_page_id' => $id
590            ] )
591            ->caller( __METHOD__ )->fetchRowCount();
592
593        // Look up the redirect target before deleting the page to avoid inconsistent state (T348881).
594        // The cloning business below is specifically to allow hook handlers to check the redirect
595        // status before the deletion (see I715046dc8157047aff4d5bd03ea6b5a47aee58bb).
596        $page->getRedirectTarget();
597        // Clone the title and wikiPage, so we have the information we need when
598        // we log and run the ArticleDeleteComplete hook.
599        $logTitle = clone $title;
600        $wikiPageBeforeDelete = clone $page;
601        $pageBeforeDelete = $page->toPageRecord();
602
603        // Now that it's safely backed up, delete it
604        $dbw->newDeleteQueryBuilder()
605            ->deleteFrom( 'page' )
606            ->where( [ 'page_id' => $id ] )
607            ->caller( __METHOD__ )->execute();
608
609        // Log the deletion, if the page was suppressed, put it in the suppression log instead
610        $logtype = $this->suppress ? 'suppress' : 'delete';
611
612        $logEntry = new ManualLogEntry( $logtype, $this->logSubtype );
613        $logEntry->setPerformer( $this->deleter->getUser() );
614        $logEntry->setTarget( $logTitle );
615        $logEntry->setComment( $reason );
616        $logEntry->addTags( $this->tags );
617        if ( !$this->isDeletePageUnitTest ) {
618            // TODO: Remove conditional once ManualLogEntry is servicified (T253717)
619            $logid = $logEntry->insert();
620
621            $dbw->onTransactionPreCommitOrIdle(
622                static function () use ( $logEntry, $logid ) {
623                    // T58776: avoid deadlocks (especially from FileDeleteForm)
624                    $logEntry->publish( $logid );
625                },
626                __METHOD__
627            );
628        } else {
629            $logid = 42;
630        }
631
632        $this->eventDispatcher->dispatch( new PageDeletedEvent(
633            $pageBeforeDelete,
634            $revisionRecord,
635            $this->deleter->getUser(),
636            $this->tags,
637            [ PageDeletedEvent::FLAG_SUPPRESSED => $this->suppress ],
638            $logEntry->getTimestamp(),
639            $reason,
640            $archivedRevisionCount,
641            $pageBeforeDelete->isRedirect() ? $this->redirectStore->getRedirectTarget( $page ) : null
642        ), $this->lbFactory );
643
644        $dbw->endAtomic( __METHOD__ );
645
646        $this->doDeleteUpdates( $wikiPageBeforeDelete, $revisionRecord );
647
648        // Reset the page object and the Title object
649        $page->loadFromRow( false, IDBAccessObject::READ_LATEST );
650
651        // Make sure there are no cached title instances that refer to the same page.
652        Title::clearCaches();
653
654        $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
655        $this->hookRunner->onArticleDeleteComplete(
656            $wikiPageBeforeDelete,
657            $legacyDeleter,
658            $reason,
659            $id,
660            $content,
661            $logEntry,
662            $archivedRevisionCount
663        );
664        $this->hookRunner->onPageDeleteComplete(
665            $pageBeforeDelete,
666            $this->deleter,
667            $reason,
668            $id,
669            $revisionRecord,
670            $logEntry,
671            $archivedRevisionCount
672        );
673        $this->successfulDeletionsIDs[$pageRole] = $logid;
674
675        // Clear any cached redirect status for the now-deleted page.
676        $this->redirectStore->clearCache( $page );
677
678        // Show log excerpt on 404 pages rather than just a link
679        $key = $this->recentDeletesCache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
680        $this->recentDeletesCache->set( $key, 1, BagOStuff::TTL_DAY );
681
682        return $status;
683    }
684
685    /**
686     * Archives revisions as part of page deletion.
687     *
688     * @param WikiPage $page
689     * @param int $id
690     * @return bool
691     */
692    private function archiveRevisions( WikiPage $page, int $id ): bool {
693        // Given the lock above, we can be confident in the title and page ID values
694        $namespace = $page->getTitle()->getNamespace();
695        $dbKey = $page->getTitle()->getDBkey();
696
697        $dbw = $this->lbFactory->getPrimaryDatabase();
698
699        $revQuery = $this->revisionStore->getQueryInfo();
700        $bitfield = false;
701
702        // Bitfields to further suppress the content
703        if ( $this->suppress ) {
704            $bitfield = RevisionRecord::SUPPRESSED_ALL;
705            $revQuery['fields'] = array_diff( $revQuery['fields'], [ 'rev_deleted' ] );
706        }
707
708        // For now, shunt the revision data into the archive table.
709        // Text is *not* removed from the text table; bulk storage
710        // is left intact to avoid breaking block-compression or
711        // immutable storage schemes.
712        // In the future, we may keep revisions and mark them with
713        // the rev_deleted field, which is reserved for this purpose.
714
715        // Lock rows in `revision` and its temp tables, but not any others.
716        // Note array_intersect() preserves keys from the first arg, and we're
717        // assuming $revQuery has `revision` primary and isn't using subtables
718        // for anything we care about.
719        $lockQuery = $revQuery;
720        $lockQuery['tables'] = array_intersect(
721            $revQuery['tables'],
722            [ 'revision', 'revision_comment_temp' ]
723        );
724        unset( $lockQuery['fields'] );
725        $dbw->newSelectQueryBuilder()
726            ->queryInfo( $lockQuery )
727            ->where( [ 'rev_page' => $id ] )
728            ->forUpdate()
729            ->caller( __METHOD__ )
730            ->acquireRowLocks();
731
732        $deleteBatchSize = $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
733        // Get as many of the page revisions as we are allowed to.  The +1 lets us recognize the
734        // unusual case where there were exactly $deleteBatchSize revisions remaining.
735        $res = $dbw->newSelectQueryBuilder()
736            ->queryInfo( $revQuery )
737            ->where( [ 'rev_page' => $id ] )
738            ->orderBy( [ 'rev_timestamp', 'rev_id' ] )
739            ->limit( $deleteBatchSize + 1 )
740            ->caller( __METHOD__ )
741            ->fetchResultSet();
742
743        // Build their equivalent archive rows
744        $rowsInsert = [];
745        $revids = [];
746
747        /** @var int[] $ipRevIds Revision IDs of edits that were made by IPs */
748        $ipRevIds = [];
749
750        $done = true;
751        foreach ( $res as $row ) {
752            if ( count( $revids ) >= $deleteBatchSize ) {
753                $done = false;
754                break;
755            }
756
757            $comment = $this->commentStore->getComment( 'rev_comment', $row );
758            $rowInsert = [
759                    'ar_namespace'  => $namespace,
760                    'ar_title'      => $dbKey,
761                    'ar_actor'      => $row->rev_actor,
762                    'ar_timestamp'  => $row->rev_timestamp,
763                    'ar_minor_edit' => $row->rev_minor_edit,
764                    'ar_rev_id'     => $row->rev_id,
765                    'ar_parent_id'  => $row->rev_parent_id,
766                    'ar_len'        => $row->rev_len,
767                    'ar_page_id'    => $id,
768                    'ar_deleted'    => $this->suppress ? $bitfield : $row->rev_deleted,
769                ] + $this->commentStore->insert( $dbw, 'ar_comment', $comment );
770
771            $rowsInsert[] = $rowInsert;
772            $revids[] = $row->rev_id;
773
774            // Keep track of IP edits, so that the corresponding rows can
775            // be deleted in the ip_changes table.
776            if ( (int)$row->rev_user === 0 && IPUtils::isValid( $row->rev_user_text ) ) {
777                $ipRevIds[] = $row->rev_id;
778            }
779        }
780
781        if ( count( $revids ) > 0 ) {
782            // Copy them into the archive table
783            $dbw->newInsertQueryBuilder()
784                ->insertInto( 'archive' )
785                ->rows( $rowsInsert )
786                ->caller( __METHOD__ )->execute();
787
788            $dbw->newDeleteQueryBuilder()
789                ->deleteFrom( 'revision' )
790                ->where( [ 'rev_id' => $revids ] )
791                ->caller( __METHOD__ )->execute();
792            // Also delete records from ip_changes as applicable.
793            if ( count( $ipRevIds ) > 0 ) {
794                $dbw->newDeleteQueryBuilder()
795                    ->deleteFrom( 'ip_changes' )
796                    ->where( [ 'ipc_rev_id' => $ipRevIds ] )
797                    ->caller( __METHOD__ )->execute();
798            }
799        }
800
801        return $done;
802    }
803
804    /**
805     * Do some database updates after deletion
806     *
807     * @param WikiPage $pageBeforeDelete
808     * @param RevisionRecord $revRecord The current page revision at the time of
809     *   deletion, used when determining the required updates. This may be needed because
810     *   $page->getRevisionRecord() may already return null when the page proper was deleted.
811     */
812    private function doDeleteUpdates( WikiPage $pageBeforeDelete, RevisionRecord $revRecord ): void {
813        try {
814            $countable = $pageBeforeDelete->isCountable();
815        } catch ( TimeoutException $e ) {
816            throw $e;
817        } catch ( Exception ) {
818            // fallback for deleting broken pages for which we cannot load the content for
819            // some reason. Note that doDeleteArticleReal() already logged this problem.
820            $countable = false;
821        }
822
823        // Update site status
824        // TODO: Move to ChangeTrackingEventIngress,
825        //       see https://gerrit.wikimedia.org/r/c/mediawiki/core/+/1099177
826        DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
827            [ 'edits' => 1, 'articles' => $countable ? -1 : 0, 'pages' => -1 ]
828        ) );
829
830        // Delete pagelinks, update secondary indexes, etc
831        $updates = $this->getDeletionUpdates( $pageBeforeDelete, $revRecord );
832        foreach ( $updates as $update ) {
833            DeferredUpdates::addUpdate( $update );
834        }
835
836        // Reparse any pages transcluding this page
837        LinksUpdate::queueRecursiveJobsForTable(
838            $pageBeforeDelete->getTitle(),
839            'templatelinks',
840            'delete-page',
841            $this->deleter->getUser()->getName(),
842            $this->backlinkCacheFactory->getBacklinkCache( $pageBeforeDelete->getTitle() )
843        );
844        // Reparse any pages including this image
845        if ( $pageBeforeDelete->getTitle()->getNamespace() === NS_FILE ) {
846            LinksUpdate::queueRecursiveJobsForTable(
847                $pageBeforeDelete->getTitle(),
848                'imagelinks',
849                'delete-page',
850                $this->deleter->getUser()->getName(),
851                $this->backlinkCacheFactory->getBacklinkCache( $pageBeforeDelete->getTitle() )
852            );
853        }
854
855        if ( !$this->isDeletePageUnitTest ) {
856            // TODO Remove conditional once WikiPage::onArticleDelete is moved to a proper service
857            // Clear caches
858            WikiPage::onArticleDelete( $pageBeforeDelete->getTitle() );
859        }
860    }
861
862    /**
863     * Returns a list of updates to be performed when the page is deleted. The
864     * updates should remove any information about this page from secondary data
865     * stores such as links tables.
866     *
867     * @param WikiPage $page
868     * @param RevisionRecord $rev The revision being deleted.
869     * @return DeferrableUpdate[]
870     */
871    private function getDeletionUpdates( WikiPage $page, RevisionRecord $rev ): array {
872        if ( $this->isDeletePageUnitTest ) {
873            // Hack: LinksDeletionUpdate reads from the global state in the constructor
874            return [];
875        }
876        $slotContent = array_map( static function ( SlotRecord $slot ) {
877            return $slot->getContent();
878        }, $rev->getSlots()->getSlots() );
879
880        $allUpdates = [ new LinksDeletionUpdate( $page ) ];
881
882        // NOTE: once Content::getDeletionUpdates() is removed, we only need the content
883        // model here, not the content object!
884        // TODO: consolidate with similar logic in DerivedPageDataUpdater::getSecondaryDataUpdates()
885        /** @var ?Content $content */
886        $content = null; // in case $slotContent is zero-length
887        foreach ( $slotContent as $role => $content ) {
888            $handler = $content->getContentHandler();
889
890            $updates = $handler->getDeletionUpdates(
891                $page->getTitle(),
892                $role
893            );
894
895            $allUpdates = array_merge( $allUpdates, $updates );
896        }
897
898        $this->hookRunner->onPageDeletionDataUpdates(
899            $page->getTitle(), $rev, $allUpdates );
900
901        // TODO: hard deprecate old hook in 1.33
902        $this->hookRunner->onWikiPageDeletionUpdates( $page, $content, $allUpdates );
903        return $allUpdates;
904    }
905}