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