Merge "docs: Fix typo"
[mediawiki.git] / includes / page / DeletePage.php
blob8563d5d3b8e50d63364684fa0ec13d4375c3f309
1 <?php
3 namespace MediaWiki\Page;
5 use BadMethodCallException;
6 use ChangeTags;
7 use DeletePageJob;
8 use Exception;
9 use JobQueueGroup;
10 use LogicException;
11 use ManualLogEntry;
12 use MediaWiki\Cache\BacklinkCacheFactory;
13 use MediaWiki\CommentStore\CommentStore;
14 use MediaWiki\Config\ServiceOptions;
15 use MediaWiki\Content\Content;
16 use MediaWiki\Deferred\DeferrableUpdate;
17 use MediaWiki\Deferred\DeferredUpdates;
18 use MediaWiki\Deferred\LinksUpdate\LinksDeletionUpdate;
19 use MediaWiki\Deferred\LinksUpdate\LinksUpdate;
20 use MediaWiki\Deferred\SiteStatsUpdate;
21 use MediaWiki\HookContainer\HookContainer;
22 use MediaWiki\HookContainer\HookRunner;
23 use MediaWiki\Language\RawMessage;
24 use MediaWiki\MainConfigNames;
25 use MediaWiki\Message\Message;
26 use MediaWiki\Permissions\Authority;
27 use MediaWiki\Permissions\PermissionStatus;
28 use MediaWiki\ResourceLoader\WikiModule;
29 use MediaWiki\Revision\RevisionRecord;
30 use MediaWiki\Revision\RevisionStore;
31 use MediaWiki\Revision\SlotRecord;
32 use MediaWiki\Search\SearchUpdate;
33 use MediaWiki\Status\Status;
34 use MediaWiki\Title\NamespaceInfo;
35 use MediaWiki\Title\Title;
36 use MediaWiki\User\UserFactory;
37 use StatusValue;
38 use Wikimedia\IPUtils;
39 use Wikimedia\Message\ITextFormatter;
40 use Wikimedia\Message\MessageValue;
41 use Wikimedia\ObjectCache\BagOStuff;
42 use Wikimedia\Rdbms\IDBAccessObject;
43 use Wikimedia\Rdbms\LBFactory;
44 use Wikimedia\RequestTimeout\TimeoutException;
45 use WikiPage;
47 /**
48 * Backend logic for performing a page delete action.
50 * @since 1.37
52 class DeletePage {
53 /**
54 * @internal For use by PageCommandFactory
56 public const CONSTRUCTOR_OPTIONS = [
57 MainConfigNames::DeleteRevisionsBatchSize,
58 MainConfigNames::DeleteRevisionsLimit,
61 /**
62 * Constants used for the return value of getSuccessfulDeletionsIDs() and deletionsWereScheduled()
64 public const PAGE_BASE = 'base';
65 public const PAGE_TALK = 'talk';
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;
80 /** @var string|array */
81 private $legacyHookErrors = '';
82 /** @var bool */
83 private $mergeLegacyHookErrors = true;
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.
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.
94 private $wasScheduled;
95 /** @var bool Whether a deletion was attempted */
96 private $attemptedDeletion = false;
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;
117 * @internal Create via the PageDeleteFactory service.
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
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;
154 $this->page = $wikiPageFactory->newFromTitle( $page );
155 $this->deleter = $deleter;
156 $this->redirectStore = $redirectStore;
160 * @internal BC method for use by WikiPage::doDeleteArticleReal only.
161 * @return array|string
163 public function getLegacyHookErrors() {
164 return $this->legacyHookErrors;
168 * @internal BC method for use by WikiPage::doDeleteArticleReal only.
169 * @return self
171 public function keepLegacyHookErrorsSeparate(): self {
172 $this->mergeLegacyHookErrors = false;
173 return $this;
177 * If true, suppress all revisions and log the deletion in the suppression log instead of
178 * the deletion log.
180 * @param bool $suppress
181 * @return self For chaining
183 public function setSuppress( bool $suppress ): self {
184 $this->suppress = $suppress;
185 return $this;
189 * Change tags to apply to the deletion action
191 * @param string[] $tags
192 * @return self For chaining
194 public function setTags( array $tags ): self {
195 $this->tags = $tags;
196 return $this;
200 * Set a specific log subtype for the deletion log entry.
202 * @param string $logSubtype
203 * @return self For chaining
205 public function setLogSubtype( string $logSubtype ): self {
206 $this->logSubtype = $logSubtype;
207 return $this;
211 * If false, allows deleting over time via the job queue
213 * @param bool $forceImmediate
214 * @return self For chaining
216 public function forceImmediate( bool $forceImmediate ): self {
217 $this->forceImmediate = $forceImmediate;
218 return $this;
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.
225 public function canProbablyDeleteAssociatedTalk(): StatusValue {
226 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
227 return StatusValue::newFatal( 'delete-error-associated-alreadytalk' );
229 // FIXME NamespaceInfo should work with PageIdentity
230 $talkPage = $this->wikiPageFactory->newFromLinkTarget(
231 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
233 if ( !$talkPage->exists() ) {
234 return StatusValue::newFatal( 'delete-error-associated-doesnotexist' );
236 return StatusValue::newGood();
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.
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.
250 public function setDeleteAssociatedTalk( bool $delete ): self {
251 if ( !$delete ) {
252 $this->associatedTalk = null;
253 return $this;
256 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
257 throw new BadMethodCallException( "Cannot delete associated talk page of a talk page! ($this->page)" );
259 // FIXME NamespaceInfo should work with PageIdentity
260 $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
261 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
263 return $this;
267 * @internal FIXME: Hack used when running the DeletePage unit test to disable some legacy code.
268 * @codeCoverageIgnore
269 * @param bool $test
271 public function setIsDeletePageUnitTest( bool $test ): void {
272 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
273 throw new LogicException( __METHOD__ . ' can only be used in tests!' );
275 $this->isDeletePageUnitTest = $test;
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
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;
291 return $this;
295 * Asserts that a deletion operation was attempted
296 * @throws BadMethodCallException
298 private function assertDeletionAttempted(): void {
299 if ( !$this->attemptedDeletion ) {
300 throw new BadMethodCallException( 'No deletion was attempted' );
305 * @return int[] Array of log IDs of successful deletions
306 * @throws BadMethodCallException If no deletions were attempted
308 public function getSuccessfulDeletionsIDs(): array {
309 $this->assertDeletionAttempted();
310 return $this->successfulDeletionsIDs;
314 * @return bool[] Whether the deletions were scheduled
315 * @throws BadMethodCallException If no deletions were attempted
317 public function deletionsWereScheduled(): array {
318 $this->assertDeletionAttempted();
319 return $this->wasScheduled;
323 * Same as deleteUnsafe, but checks permissions.
325 * @param string $reason
326 * @return StatusValue
328 public function deleteIfAllowed( string $reason ): StatusValue {
329 $this->setDeletionAttempted();
330 $status = $this->authorizeDeletion();
331 if ( !$status->isGood() ) {
332 return $status;
335 return $this->deleteUnsafe( $reason );
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 );
344 if ( !$this->deleter->isAllowed( 'bigdelete' ) && $this->isBigDeletion() ) {
345 $status->fatal(
346 'delete-toomanyrevisions',
347 Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) )
350 if ( $this->tags ) {
351 $status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->deleter ) );
353 return $status;
356 private function isBigDeletion(): bool {
357 $revLimit = $this->options->get( MainConfigNames::DeleteRevisionsLimit );
358 if ( !$revLimit ) {
359 return false;
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() );
368 return $revCount > $revLimit;
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).
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.
380 * @param int $safetyMargin Added to the revision count when checking for batching
381 * @return bool True if deletion would be batched, false otherwise
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;
388 if ( $revCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ) ) {
389 return true;
390 } elseif ( !$this->associatedTalk ) {
391 return false;
394 $talkRevCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
395 $talkRevCount += $safetyMargin;
397 return $talkRevCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
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.
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.
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;
417 if ( $this->associatedTalk ) {
418 $talkReason = $this->contLangMsgTextFormatter->format(
419 MessageValue::new( 'delete-talk-summary-prefix' )->plaintextParams( $origReason )
421 $talkHookStatus = $this->runPreDeleteHooks( $this->associatedTalk, $talkReason );
422 if ( !$talkHookStatus->isGood() ) {
423 return $talkHookStatus;
427 $status = $this->deleteInternal( $this->page, self::PAGE_BASE, $reason );
428 if ( !$this->associatedTalk || !$status->isGood() ) {
429 return $status;
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;
440 * @param WikiPage $page
441 * @param string &$reason
442 * @return Status
444 private function runPreDeleteHooks( WikiPage $page, string &$reason ): Status {
445 $status = Status::newGood();
447 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
448 if ( !$this->hookRunner->onArticleDelete(
449 $page, $legacyDeleter, $reason, $this->legacyHookErrors, $status, $this->suppress )
451 if ( $this->mergeLegacyHookErrors && $this->legacyHookErrors !== '' ) {
452 if ( is_string( $this->legacyHookErrors ) ) {
453 $this->legacyHookErrors = [ $this->legacyHookErrors ];
455 foreach ( $this->legacyHookErrors as $legacyError ) {
456 $status->fatal( new RawMessage( $legacyError ) );
459 if ( $status->isOK() ) {
460 // Hook aborted but didn't set a fatal status
461 $status->fatal( 'delete-hook-aborted' );
463 return $status;
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;
473 return Status::newGood();
477 * @internal The only external caller allowed is DeletePageJob.
478 * Back-end article deletion
480 * Only invokes batching via the job queue if necessary per DeleteRevisionsBatchSize.
481 * Deletions can often be completed inline without involving the job queue.
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
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();
501 $dbw = $this->lbFactory->getPrimaryDatabase();
502 $dbw->startAtomic( __METHOD__ );
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;
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).
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?" );
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() );
536 $content = null;
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;
546 $dbw->endAtomic( __METHOD__ );
547 $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
548 $dbw->startAtomic( __METHOD__ );
551 if ( !$done ) {
552 $dbw->endAtomic( __METHOD__ );
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,
567 $job = new DeletePageJob( $jobParams );
568 $this->jobQueueGroup->push( $job );
569 $this->wasScheduled[$pageRole] = true;
570 return $status;
572 $this->wasScheduled[$pageRole] = false;
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
589 ->caller( __METHOD__ )->fetchRowCount();
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;
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();
606 // Log the deletion, if the page was suppressed, put it in the suppression log instead
607 $logtype = $this->suppress ? 'suppress' : 'delete';
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();
618 $dbw->onTransactionPreCommitOrIdle(
619 static function () use ( $logEntry, $logid ) {
620 // T58776: avoid deadlocks (especially from FileDeleteForm)
621 $logEntry->publish( $logid );
623 __METHOD__
625 } else {
626 $logid = 42;
629 $dbw->endAtomic( __METHOD__ );
631 $this->doDeleteUpdates( $page, $revisionRecord );
633 // Reset the page object and the Title object
634 $page->loadFromRow( false, IDBAccessObject::READ_LATEST );
636 // Make sure there are no cached title instances that refer to the same page.
637 Title::clearCaches();
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
649 $this->hookRunner->onPageDeleteComplete(
650 $wikiPageBeforeDelete,
651 $this->deleter,
652 $reason,
653 $id,
654 $revisionRecord,
655 $logEntry,
656 $archivedRevisionCount
658 $this->successfulDeletionsIDs[$pageRole] = $logid;
660 // Clear any cached redirect status for the now-deleted page.
661 $this->redirectStore->clearCache( $page );
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 );
667 return $status;
671 * Archives revisions as part of page deletion.
673 * @param WikiPage $page
674 * @param int $id
675 * @return bool
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();
682 $dbw = $this->lbFactory->getPrimaryDatabase();
684 $revQuery = $this->revisionStore->getQueryInfo();
685 $bitfield = false;
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' ] );
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.
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' ]
709 unset( $lockQuery['fields'] );
710 $dbw->newSelectQueryBuilder()
711 ->queryInfo( $lockQuery )
712 ->where( [ 'rev_page' => $id ] )
713 ->forUpdate()
714 ->caller( __METHOD__ )
715 ->acquireRowLocks();
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();
728 // Build their equivalent archive rows
729 $rowsInsert = [];
730 $revids = [];
732 /** @var int[] $ipRevIds Revision IDs of edits that were made by IPs */
733 $ipRevIds = [];
735 $done = true;
736 foreach ( $res as $row ) {
737 if ( count( $revids ) >= $deleteBatchSize ) {
738 $done = false;
739 break;
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 );
757 $rowsInsert[] = $rowInsert;
758 $revids[] = $row->rev_id;
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;
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();
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();
787 return $done;
791 * Do some database updates after deletion
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.
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;
809 // Update site status
810 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
811 [ 'edits' => 1, 'articles' => $countable ? -1 : 0, 'pages' => -1 ]
812 ) );
814 // Delete pagelinks, update secondary indexes, etc
815 $updates = $this->getDeletionUpdates( $page, $revRecord );
816 foreach ( $updates as $update ) {
817 DeferredUpdates::addUpdate( $update );
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() )
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() )
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() );
845 WikiModule::invalidateModuleCache(
846 $page->getTitle(),
847 $revRecord,
848 null,
849 $this->localWikiID
852 // Reset the page object and the Title object
853 $page->loadFromRow( false, IDBAccessObject::READ_LATEST );
855 // Search engine
856 DeferredUpdates::addUpdate( new SearchUpdate( $page->getId(), $page->getTitle() ) );
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.
864 * @param WikiPage $page
865 * @param RevisionRecord $rev The revision being deleted.
866 * @return DeferrableUpdate[]
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 [];
873 $slotContent = array_map( static function ( SlotRecord $slot ) {
874 return $slot->getContent();
875 }, $rev->getSlots()->getSlots() );
877 $allUpdates = [ new LinksDeletionUpdate( $page ) ];
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();
887 $updates = $handler->getDeletionUpdates(
888 $page->getTitle(),
889 $role
892 $allUpdates = array_merge( $allUpdates, $updates );
895 $this->hookRunner->onPageDeletionDataUpdates(
896 $page->getTitle(), $rev, $allUpdates );
898 // TODO: hard deprecate old hook in 1.33
899 $this->hookRunner->onWikiPageDeletionUpdates( $page, $content, $allUpdates );
900 return $allUpdates;