3 namespace MediaWiki\Page
;
5 use BadMethodCallException
;
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
;
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
;
48 * Backend logic for performing a page delete action.
54 * @internal For use by PageCommandFactory
56 public const CONSTRUCTOR_OPTIONS
= [
57 MainConfigNames
::DeleteRevisionsBatchSize
,
58 MainConfigNames
::DeleteRevisionsLimit
,
62 * Constants used for the return value of getSuccessfulDeletionsIDs() and deletionsWereScheduled()
64 public const PAGE_BASE
= 'base';
65 public const PAGE_TALK
= 'talk';
68 private $isDeletePageUnitTest = false;
70 private $suppress = false;
74 private $logSubtype = 'delete';
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 = '';
83 private $mergeLegacyHookErrors = true;
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;
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,
128 string $webRequestID,
129 WikiPageFactory
$wikiPageFactory,
130 UserFactory
$userFactory,
131 BacklinkCacheFactory
$backlinkCacheFactory,
132 NamespaceInfo
$namespaceInfo,
133 ITextFormatter
$contLangMsgTextFormatter,
134 RedirectStore
$redirectStore,
135 ProperPageIdentity
$page,
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.
171 public function keepLegacyHookErrorsSeparate(): self
{
172 $this->mergeLegacyHookErrors
= false;
177 * If true, suppress all revisions and log the deletion in the suppression log instead of
180 * @param bool $suppress
181 * @return self For chaining
183 public function setSuppress( bool $suppress ): self
{
184 $this->suppress
= $suppress;
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
{
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;
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;
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
{
252 $this->associatedTalk
= null;
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() )
267 * @internal FIXME: Hack used when running the DeletePage unit test to disable some legacy code.
268 * @codeCoverageIgnore
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.
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;
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() ) {
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() ) {
346 'delete-toomanyrevisions',
347 Message
::numParam( $this->options
->get( MainConfigNames
::DeleteRevisionsLimit
) )
351 $status->merge( ChangeTags
::canAddTagsAccompanyingChange( $this->tags
, $this->deleter
) );
356 private function isBigDeletion(): bool {
357 $revLimit = $this->options
->get( MainConfigNames
::DeleteRevisionsLimit
);
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
) ) {
390 } elseif ( !$this->associatedTalk
) {
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() ) {
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() ) {
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 ) );
440 * @param WikiPage $page
441 * @param string &$reason
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' );
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.
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
491 public function deleteInternal(
495 ?
string $webRequestId = null,
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() ) );
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?" );
529 $content = $page->getContent( RevisionRecord
::RAW
);
530 } catch ( TimeoutException
$e ) {
532 } catch ( Exception
$ex ) {
533 wfLogWarning( __METHOD__
. ': failed to load content during deletion! '
534 . $ex->getMessage() );
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.
542 $done = $this->archiveRevisions( $page, $id );
543 if ( $done ||
!$this->forceImmediate
) {
546 $dbw->endAtomic( __METHOD__
);
547 $this->lbFactory
->commitAndWaitForReplication( __METHOD__
, $ticket );
548 $dbw->startAtomic( __METHOD__
);
552 $dbw->endAtomic( __METHOD__
);
555 'namespace' => $title->getNamespace(),
556 'title' => $title->getDBkey(),
558 'requestId' => $webRequestId ??
$this->webRequestID
,
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;
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()
585 'ar_namespace' => $title->getNamespace(),
586 'ar_title' => $title->getDBkey(),
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 );
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,
647 $archivedRevisionCount
649 $this->hookRunner
->onPageDeleteComplete(
650 $wikiPageBeforeDelete,
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
);
671 * Archives revisions as part of page deletion.
673 * @param WikiPage $page
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();
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(
707 [ 'revision', 'revision_comment_temp' ]
709 unset( $lockQuery['fields'] );
710 $dbw->newSelectQueryBuilder()
711 ->queryInfo( $lockQuery )
712 ->where( [ 'rev_page' => $id ] )
714 ->caller( __METHOD__
)
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__
)
728 // Build their equivalent archive rows
732 /** @var int[] $ipRevIds Revision IDs of edits that were made by IPs */
736 foreach ( $res as $row ) {
737 if ( count( $revids ) >= $deleteBatchSize ) {
742 $comment = $this->commentStore
->getComment( 'rev_comment', $row );
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
,
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();
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
{
800 $countable = $page->isCountable();
801 } catch ( TimeoutException
$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.
809 // Update site status
810 DeferredUpdates
::addUpdate( SiteStatsUpdate
::factory(
811 [ 'edits' => 1, 'articles' => $countable ?
-1 : 0, 'pages' => -1 ]
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(
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(
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
842 WikiPage
::onArticleDelete( $page->getTitle() );
845 WikiModule
::invalidateModuleCache(
852 // Reset the page object and the Title object
853 $page->loadFromRow( false, IDBAccessObject
::READ_LATEST
);
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
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(
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 );