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