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