includes/page/DeletePage.php
<?php
namespace MediaWiki\Page;
use BadMethodCallException;
use BagOStuff;
use ChangeTags;
use Content;
use DeletePageJob;
use Exception;
use IDBAccessObject;
use JobQueueGroup;
use LogicException;
use ManualLogEntry;
use MediaWiki\Cache\BacklinkCacheFactory;
use MediaWiki\CommentStore\CommentStore;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Deferred\DeferrableUpdate;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Deferred\LinksUpdate\LinksDeletionUpdate;
use MediaWiki\Deferred\LinksUpdate\LinksUpdate;
use MediaWiki\Deferred\SearchUpdate;
use MediaWiki\Deferred\SiteStatsUpdate;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Language\RawMessage;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\ResourceLoader\WikiModule;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Status\Status;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\User\UserFactory;
use StatusValue;
use Wikimedia\IPUtils;
use Wikimedia\Message\ITextFormatter;
use Wikimedia\Message\MessageValue;
use Wikimedia\Rdbms\LBFactory;
use Wikimedia\RequestTimeout\TimeoutException;
use WikiPage;
/**
* Backend logic for performing a page delete action.
*
* @since 1.37
*/
class DeletePage {
/**
* @internal For use by PageCommandFactory
*/
public const CONSTRUCTOR_OPTIONS = [
MainConfigNames::DeleteRevisionsBatchSize,
MainConfigNames::DeleteRevisionsLimit,
];
/**
* Constants used for the return value of getSuccessfulDeletionsIDs() and deletionsWereScheduled()
*/
public const PAGE_BASE = 'base';
public const PAGE_TALK = 'talk';
/** @var HookRunner */
private $hookRunner;
/** @var RevisionStore */
private $revisionStore;
/** @var LBFactory */
private $lbFactory;
/** @var JobQueueGroup */
private $jobQueueGroup;
/** @var CommentStore */
private $commentStore;
/** @var ServiceOptions */
private $options;
/** @var BagOStuff */
private $recentDeletesCache;
/** @var string */
private $localWikiID;
/** @var string */
private $webRequestID;
/** @var UserFactory */
private $userFactory;
/** @var BacklinkCacheFactory */
private $backlinkCacheFactory;
/** @var WikiPageFactory */
private $wikiPageFactory;
/** @var NamespaceInfo */
private $namespaceInfo;
/** @var ITextFormatter */
private $contLangMsgTextFormatter;
/** @var bool */
private $isDeletePageUnitTest = false;
/** @var WikiPage */
private $page;
/** @var Authority */
private $deleter;
/** @var bool */
private $suppress = false;
/** @var string[] */
private $tags = [];
/** @var string */
private $logSubtype = 'delete';
/** @var bool */
private $forceImmediate = false;
/** @var WikiPage|null If not null, it means that we have to delete it. */
private $associatedTalk;
/** @var string|array */
private $legacyHookErrors = '';
/** @var bool */
private $mergeLegacyHookErrors = true;
/**
* @var array<int|null>|null Keys are the self::PAGE_* constants. Values are null if the deletion couldn't happen
* (e.g. due to lacking perms) or was scheduled. PAGE_TALK is only set when deleting the associated talk.
*/
private $successfulDeletionsIDs;
/**
* @var array<bool|null>|null Keys are the self::PAGE_* constants. Values are null if the deletion couldn't happen
* (e.g. due to lacking perms). PAGE_TALK is only set when deleting the associated talk.
*/
private $wasScheduled;
/** @var bool Whether a deletion was attempted */
private $attemptedDeletion = false;
/**
* @internal Create via the PageDeleteFactory service.
* @param HookContainer $hookContainer
* @param RevisionStore $revisionStore
* @param LBFactory $lbFactory
* @param JobQueueGroup $jobQueueGroup
* @param CommentStore $commentStore
* @param ServiceOptions $serviceOptions
* @param BagOStuff $recentDeletesCache
* @param string $localWikiID
* @param string $webRequestID
* @param WikiPageFactory $wikiPageFactory
* @param UserFactory $userFactory
* @param BacklinkCacheFactory $backlinkCacheFactory
* @param NamespaceInfo $namespaceInfo
* @param ITextFormatter $contLangMsgTextFormatter
* @param ProperPageIdentity $page
* @param Authority $deleter
*/
public function __construct(
HookContainer $hookContainer,
RevisionStore $revisionStore,
LBFactory $lbFactory,
JobQueueGroup $jobQueueGroup,
CommentStore $commentStore,
ServiceOptions $serviceOptions,
BagOStuff $recentDeletesCache,
string $localWikiID,
string $webRequestID,
WikiPageFactory $wikiPageFactory,
UserFactory $userFactory,
BacklinkCacheFactory $backlinkCacheFactory,
NamespaceInfo $namespaceInfo,
ITextFormatter $contLangMsgTextFormatter,
ProperPageIdentity $page,
Authority $deleter
) {
$this->hookRunner = new HookRunner( $hookContainer );
$this->revisionStore = $revisionStore;
$this->lbFactory = $lbFactory;
$this->jobQueueGroup = $jobQueueGroup;
$this->commentStore = $commentStore;
$serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->options = $serviceOptions;
$this->recentDeletesCache = $recentDeletesCache;
$this->localWikiID = $localWikiID;
$this->webRequestID = $webRequestID;
$this->wikiPageFactory = $wikiPageFactory;
$this->userFactory = $userFactory;
$this->backlinkCacheFactory = $backlinkCacheFactory;
$this->namespaceInfo = $namespaceInfo;
$this->contLangMsgTextFormatter = $contLangMsgTextFormatter;
$this->page = $wikiPageFactory->newFromTitle( $page );
$this->deleter = $deleter;
}
/**
* @internal BC method for use by WikiPage::doDeleteArticleReal only.
* @return array|string
*/
public function getLegacyHookErrors() {
return $this->legacyHookErrors;
}
/**
* @internal BC method for use by WikiPage::doDeleteArticleReal only.
* @return self
*/
public function keepLegacyHookErrorsSeparate(): self {
$this->mergeLegacyHookErrors = false;
return $this;
}
/**
* If true, suppress all revisions and log the deletion in the suppression log instead of
* the deletion log.
*
* @param bool $suppress
* @return self For chaining
*/
public function setSuppress( bool $suppress ): self {
$this->suppress = $suppress;
return $this;
}
/**
* Change tags to apply to the deletion action
*
* @param string[] $tags
* @return self For chaining
*/
public function setTags( array $tags ): self {
$this->tags = $tags;
return $this;
}
/**
* Set a specific log subtype for the deletion log entry.
*
* @param string $logSubtype
* @return self For chaining
*/
public function setLogSubtype( string $logSubtype ): self {
$this->logSubtype = $logSubtype;
return $this;
}
/**
* If false, allows deleting over time via the job queue
*
* @param bool $forceImmediate
* @return self For chaining
*/
public function forceImmediate( bool $forceImmediate ): self {
$this->forceImmediate = $forceImmediate;
return $this;
}
/**
* Tests whether it's probably possible to delete the associated talk page. This checks the replica,
* so it may not see the latest master change, and is useful e.g. for building the UI.
*
* @return StatusValue
*/
public function canProbablyDeleteAssociatedTalk(): StatusValue {
if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
return StatusValue::newFatal( 'delete-error-associated-alreadytalk' );
}
// FIXME NamespaceInfo should work with PageIdentity
$talkPage = $this->wikiPageFactory->newFromLinkTarget(
$this->namespaceInfo->getTalkPage( $this->page->getTitle() )
);
if ( !$talkPage->exists() ) {
return StatusValue::newFatal( 'delete-error-associated-doesnotexist' );
}
return StatusValue::newGood();
}
/**
* If set to true and the page has a talk page, delete that one too. Callers should call
* canProbablyDeleteAssociatedTalk first to make sure this is a valid operation. Note that the checks
* here are laxer than those in canProbablyDeleteAssociatedTalk. In particular, this doesn't check
* whether the page exists as that may be subject to race condition, and it's checked later on (in deleteInternal,
* using latest data) anyway.
*
* @param bool $delete
* @return self For chaining
* @throws BadMethodCallException If $delete is true and the given page is not a talk page.
*/
public function setDeleteAssociatedTalk( bool $delete ): self {
if ( !$delete ) {
$this->associatedTalk = null;
return $this;
}
if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
throw new BadMethodCallException( "Cannot delete associated talk page of a talk page! ($this->page)" );
}
// FIXME NamespaceInfo should work with PageIdentity
$this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
$this->namespaceInfo->getTalkPage( $this->page->getTitle() )
);
return $this;
}
/**
* @internal FIXME: Hack used when running the DeletePage unit test to disable some legacy code.
* @codeCoverageIgnore
* @param bool $test
*/
public function setIsDeletePageUnitTest( bool $test ): void {
if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
throw new LogicException( __METHOD__ . ' can only be used in tests!' );
}
$this->isDeletePageUnitTest = $test;
}
/**
* Called before attempting a deletion, allows the result getters to be used
* @internal The only external caller allowed is DeletePageJob.
* @return self
*/
public function setDeletionAttempted(): self {
$this->attemptedDeletion = true;
$this->successfulDeletionsIDs = [ self::PAGE_BASE => null ];
$this->wasScheduled = [ self::PAGE_BASE => null ];
if ( $this->associatedTalk ) {
$this->successfulDeletionsIDs[self::PAGE_TALK] = null;
$this->wasScheduled[self::PAGE_TALK] = null;
}
return $this;
}
/**
* Asserts that a deletion operation was attempted
* @throws BadMethodCallException
*/
private function assertDeletionAttempted(): void {
if ( !$this->attemptedDeletion ) {
throw new BadMethodCallException( 'No deletion was attempted' );
}
}
/**
* @return int[] Array of log IDs of successful deletions
* @throws BadMethodCallException If no deletions were attempted
*/
public function getSuccessfulDeletionsIDs(): array {
$this->assertDeletionAttempted();
return $this->successfulDeletionsIDs;
}
/**
* @return bool[] Whether the deletions were scheduled
* @throws BadMethodCallException If no deletions were attempted
*/
public function deletionsWereScheduled(): array {
$this->assertDeletionAttempted();
return $this->wasScheduled;
}
/**
* Same as deleteUnsafe, but checks permissions.
*
* @param string $reason
* @return StatusValue
*/
public function deleteIfAllowed( string $reason ): StatusValue {
$this->setDeletionAttempted();
$status = $this->authorizeDeletion();
if ( !$status->isGood() ) {
return $status;
}
return $this->deleteUnsafe( $reason );
}
/**
* @return PermissionStatus
*/
private function authorizeDeletion(): PermissionStatus {
$status = PermissionStatus::newEmpty();
$this->deleter->authorizeWrite( 'delete', $this->page, $status );
if ( $this->associatedTalk ) {
$this->deleter->authorizeWrite( 'delete', $this->associatedTalk, $status );
}
if ( !$this->deleter->isAllowed( 'bigdelete' ) && $this->isBigDeletion() ) {
$status->fatal(
'delete-toomanyrevisions',
Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) )
);
}
if ( $this->tags ) {
$status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->deleter ) );
}
return $status;
}
/**
* @return bool
*/
private function isBigDeletion(): bool {
$revLimit = $this->options->get( MainConfigNames::DeleteRevisionsLimit );
if ( !$revLimit ) {
return false;
}
$dbr = $this->lbFactory->getReplicaDatabase();
$revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
if ( $this->associatedTalk ) {
$revCount += $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
}
return $revCount > $revLimit;
}
/**
* Determines if this deletion would be batched (executed over time by the job queue)
* or not (completed in the same request as the delete call).
*
* It is unlikely but possible that an edit from another request could push the page over the
* batching threshold after this function is called, but before the caller acts upon the
* return value. Callers must decide for themselves how to deal with this. $safetyMargin
* is provided as an unreliable but situationally useful help for some common cases.
*
* @param int $safetyMargin Added to the revision count when checking for batching
* @return bool True if deletion would be batched, false otherwise
*/
public function isBatchedDelete( int $safetyMargin = 0 ): bool {
$dbr = $this->lbFactory->getReplicaDatabase();
$revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
$revCount += $safetyMargin;
if ( $revCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ) ) {
return true;
} elseif ( !$this->associatedTalk ) {
return false;
}
$talkRevCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
$talkRevCount += $safetyMargin;
return $talkRevCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
}
/**
* Back-end article deletion: deletes the article with database consistency, writes logs, purges caches.
* @note This method doesn't check user permissions. Use deleteIfAllowed for that.
*
* @param string $reason Delete reason for deletion log
* @return Status Status object:
* - If successful (or scheduled), a good Status
* - If a page couldn't be deleted because it wasn't found, a Status with a non-fatal 'cannotdelete' error.
* - A fatal Status otherwise.
*/
public function deleteUnsafe( string $reason ): Status {
$this->setDeletionAttempted();
$origReason = $reason;
$hookStatus = $this->runPreDeleteHooks( $this->page, $reason );
if ( !$hookStatus->isGood() ) {
return $hookStatus;
}
if ( $this->associatedTalk ) {
$talkReason = $this->contLangMsgTextFormatter->format(
MessageValue::new( 'delete-talk-summary-prefix' )->plaintextParams( $origReason )
);
$talkHookStatus = $this->runPreDeleteHooks( $this->associatedTalk, $talkReason );
if ( !$talkHookStatus->isGood() ) {
return $talkHookStatus;
}
}
$status = $this->deleteInternal( $this->page, self::PAGE_BASE, $reason );
if ( !$this->associatedTalk || !$status->isGood() ) {
return $status;
}
// NOTE: If the page deletion above failed because the page is no longer there (e.g. race condition) we'll
// still try to delete the talk page, since it was the user's intention anyway.
// @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable talkReason is set when used
// @phan-suppress-next-line PhanTypeMismatchArgumentNullable talkReason is set when used
$status->merge( $this->deleteInternal( $this->associatedTalk, self::PAGE_TALK, $talkReason ) );
return $status;
}
/**
* @param WikiPage $page
* @param string &$reason
* @return Status
*/
private function runPreDeleteHooks( WikiPage $page, string &$reason ): Status {
$status = Status::newGood();
$legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
if ( !$this->hookRunner->onArticleDelete(
$page, $legacyDeleter, $reason, $this->legacyHookErrors, $status, $this->suppress )
) {
if ( $this->mergeLegacyHookErrors && $this->legacyHookErrors !== '' ) {
if ( is_string( $this->legacyHookErrors ) ) {
$this->legacyHookErrors = [ $this->legacyHookErrors ];
}
foreach ( $this->legacyHookErrors as $legacyError ) {
$status->fatal( new RawMessage( $legacyError ) );
}
}
if ( $status->isOK() ) {
// Hook aborted but didn't set a fatal status
$status->fatal( 'delete-hook-aborted' );
}
return $status;
}
// Use a new Status in case a hook handler put something here without aborting.
$status = Status::newGood();
$hookRes = $this->hookRunner->onPageDelete( $page, $this->deleter, $reason, $status, $this->suppress );
if ( !$hookRes && !$status->isGood() ) {
// Note: as per the PageDeleteHook documentation, `return false` is ignored if $status is good.
return $status;
}
return Status::newGood();
}
/**
* @internal The only external caller allowed is DeletePageJob.
* Back-end article deletion
*
* Only invokes batching via the job queue if necessary per DeleteRevisionsBatchSize.
* Deletions can often be completed inline without involving the job queue.
*
* Potentially called many times per deletion operation for pages with many revisions.
* @param WikiPage $page
* @param string $pageRole
* @param string $reason
* @param string|null $webRequestId
* @return Status
*/
public function deleteInternal(
WikiPage $page,
string $pageRole,
string $reason,
?string $webRequestId = null
): Status {
$title = $page->getTitle();
$status = Status::newGood();
$dbw = $this->lbFactory->getPrimaryDatabase();
$dbw->startAtomic( __METHOD__ );
$page->loadPageData( IDBAccessObject::READ_LATEST );
$id = $page->getId();
// T98706: lock the page from various other updates but avoid using
// IDBAccessObject::READ_LOCKING as that will carry over the FOR UPDATE to
// the revisions queries (which also JOIN on user). Only lock the page
// row and CAS check on page_latest to see if the trx snapshot matches.
$lockedLatest = $page->lockAndGetLatest();
if ( $id === 0 || $page->getLatest() !== $lockedLatest ) {
$dbw->endAtomic( __METHOD__ );
// Page not there or trx snapshot is stale
$status->error( 'cannotdelete', wfEscapeWikiText( $title->getPrefixedText() ) );
return $status;
}
// At this point we are now committed to returning an OK
// status unless some DB query error or other exception comes up.
// This way callers don't have to call rollback() if $status is bad
// unless they actually try to catch exceptions (which is rare).
// we need to remember the old content so we can use it to generate all deletion updates.
$revisionRecord = $page->getRevisionRecord();
if ( !$revisionRecord ) {
throw new LogicException( "No revisions for $page?" );
}
try {
$content = $page->getContent( RevisionRecord::RAW );
} catch ( TimeoutException $e ) {
throw $e;
} catch ( Exception $ex ) {
wfLogWarning( __METHOD__ . ': failed to load content during deletion! '
. $ex->getMessage() );
$content = null;
}
// Archive revisions. In immediate mode, archive all revisions. Otherwise, archive
// one batch of revisions and defer archival of any others to the job queue.
$explictTrxLogged = false;
while ( true ) {
$done = $this->archiveRevisions( $page, $id );
if ( $done || !$this->forceImmediate ) {
break;
}
$dbw->endAtomic( __METHOD__ );
if ( $dbw->explicitTrxActive() ) {
// Explicit transactions may never happen here in practice. Log to be sure.
if ( !$explictTrxLogged ) {
$explictTrxLogged = true;
LoggerFactory::getInstance( 'wfDebug' )->debug(
'explicit transaction active in ' . __METHOD__ . ' while deleting {title}', [
'title' => $title->getText(),
] );
}
continue;
}
if ( $dbw->trxLevel() ) {
$dbw->commit( __METHOD__ );
}
$this->lbFactory->waitForReplication();
$dbw->startAtomic( __METHOD__ );
}
if ( !$done ) {
$dbw->endAtomic( __METHOD__ );
$jobParams = [
'namespace' => $title->getNamespace(),
'title' => $title->getDBkey(),
'wikiPageId' => $id,
'requestId' => $webRequestId ?? $this->webRequestID,
'reason' => $reason,
'suppress' => $this->suppress,
'userId' => $this->deleter->getUser()->getId(),
'tags' => json_encode( $this->tags ),
'logsubtype' => $this->logSubtype,
'pageRole' => $pageRole,
];
$job = new DeletePageJob( $jobParams );
$this->jobQueueGroup->push( $job );
$this->wasScheduled[$pageRole] = true;
return $status;
}
$this->wasScheduled[$pageRole] = false;
// Get archivedRevisionCount by db query, because there's no better alternative.
// Jobs cannot pass a count of archived revisions to the next job, because additional
// deletion operations can be started while the first is running. Jobs from each
// gracefully interleave, but would not know about each other's count. Deduplication
// in the job queue to avoid simultaneous deletion operations would add overhead.
// Number of archived revisions cannot be known beforehand, because edits can be made
// while deletion operations are being processed, changing the number of archivals.
$archivedRevisionCount = $dbw->newSelectQueryBuilder()
->select( '*' )
->from( 'archive' )
->where( [
'ar_namespace' => $title->getNamespace(),
'ar_title' => $title->getDBkey(),
'ar_page_id' => $id
] )
->caller( __METHOD__ )->fetchRowCount();
// Look up the redirect target before deleting the page to avoid inconsistent state (T348881).
// The cloning business below is specifically to allow hook handlers to check the redirect
// status before the deletion (see I715046dc8157047aff4d5bd03ea6b5a47aee58bb).
$page->getRedirectTarget();
// Clone the title and wikiPage, so we have the information we need when
// we log and run the ArticleDeleteComplete hook.
$logTitle = clone $title;
$wikiPageBeforeDelete = clone $page;
// Now that it's safely backed up, delete it
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'page' )
->where( [ 'page_id' => $id ] )
->caller( __METHOD__ )->execute();
// Log the deletion, if the page was suppressed, put it in the suppression log instead
$logtype = $this->suppress ? 'suppress' : 'delete';
$logEntry = new ManualLogEntry( $logtype, $this->logSubtype );
$logEntry->setPerformer( $this->deleter->getUser() );
$logEntry->setTarget( $logTitle );
$logEntry->setComment( $reason );
$logEntry->addTags( $this->tags );
if ( !$this->isDeletePageUnitTest ) {
// TODO: Remove conditional once ManualLogEntry is servicified (T253717)
$logid = $logEntry->insert();
$dbw->onTransactionPreCommitOrIdle(
static function () use ( $logEntry, $logid ) {
// T58776: avoid deadlocks (especially from FileDeleteForm)
$logEntry->publish( $logid );
},
__METHOD__
);
} else {
$logid = 42;
}
$dbw->endAtomic( __METHOD__ );
$this->doDeleteUpdates( $page, $revisionRecord );
$legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
$this->hookRunner->onArticleDeleteComplete(
$wikiPageBeforeDelete,
$legacyDeleter,
$reason,
$id,
$content,
$logEntry,
$archivedRevisionCount
);
$this->hookRunner->onPageDeleteComplete(
$wikiPageBeforeDelete,
$this->deleter,
$reason,
$id,
$revisionRecord,
$logEntry,
$archivedRevisionCount
);
$this->successfulDeletionsIDs[$pageRole] = $logid;
// Show log excerpt on 404 pages rather than just a link
$key = $this->recentDeletesCache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
$this->recentDeletesCache->set( $key, 1, BagOStuff::TTL_DAY );
return $status;
}
/**
* Archives revisions as part of page deletion.
*
* @param WikiPage $page
* @param int $id
* @return bool
*/
private function archiveRevisions( WikiPage $page, int $id ): bool {
// Given the lock above, we can be confident in the title and page ID values
$namespace = $page->getTitle()->getNamespace();
$dbKey = $page->getTitle()->getDBkey();
$dbw = $this->lbFactory->getPrimaryDatabase();
$revQuery = $this->revisionStore->getQueryInfo();
$bitfield = false;
// Bitfields to further suppress the content
if ( $this->suppress ) {
$bitfield = RevisionRecord::SUPPRESSED_ALL;
$revQuery['fields'] = array_diff( $revQuery['fields'], [ 'rev_deleted' ] );
}
// For now, shunt the revision data into the archive table.
// Text is *not* removed from the text table; bulk storage
// is left intact to avoid breaking block-compression or
// immutable storage schemes.
// In the future, we may keep revisions and mark them with
// the rev_deleted field, which is reserved for this purpose.
// Lock rows in `revision` and its temp tables, but not any others.
// Note array_intersect() preserves keys from the first arg, and we're
// assuming $revQuery has `revision` primary and isn't using subtables
// for anything we care about.
$lockQuery = $revQuery;
$lockQuery['tables'] = array_intersect(
$revQuery['tables'],
[ 'revision', 'revision_comment_temp' ]
);
unset( $lockQuery['fields'] );
$dbw->newSelectQueryBuilder()
->queryInfo( $lockQuery )
->where( [ 'rev_page' => $id ] )
->forUpdate()
->caller( __METHOD__ )
->acquireRowLocks();
$deleteBatchSize = $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
// Get as many of the page revisions as we are allowed to. The +1 lets us recognize the
// unusual case where there were exactly $deleteBatchSize revisions remaining.
$res = $dbw->newSelectQueryBuilder()
->queryInfo( $revQuery )
->where( [ 'rev_page' => $id ] )
->orderBy( [ 'rev_timestamp', 'rev_id' ] )
->limit( $deleteBatchSize + 1 )
->caller( __METHOD__ )
->fetchResultSet();
// Build their equivalent archive rows
$rowsInsert = [];
$revids = [];
/** @var int[] $ipRevIds Revision IDs of edits that were made by IPs */
$ipRevIds = [];
$done = true;
foreach ( $res as $row ) {
if ( count( $revids ) >= $deleteBatchSize ) {
$done = false;
break;
}
$comment = $this->commentStore->getComment( 'rev_comment', $row );
$rowInsert = [
'ar_namespace' => $namespace,
'ar_title' => $dbKey,
'ar_actor' => $row->rev_actor,
'ar_timestamp' => $row->rev_timestamp,
'ar_minor_edit' => $row->rev_minor_edit,
'ar_rev_id' => $row->rev_id,
'ar_parent_id' => $row->rev_parent_id,
'ar_len' => $row->rev_len,
'ar_page_id' => $id,
'ar_deleted' => $this->suppress ? $bitfield : $row->rev_deleted,
'ar_sha1' => $row->rev_sha1,
] + $this->commentStore->insert( $dbw, 'ar_comment', $comment );
$rowsInsert[] = $rowInsert;
$revids[] = $row->rev_id;
// Keep track of IP edits, so that the corresponding rows can
// be deleted in the ip_changes table.
if ( (int)$row->rev_user === 0 && IPUtils::isValid( $row->rev_user_text ) ) {
$ipRevIds[] = $row->rev_id;
}
}
if ( count( $revids ) > 0 ) {
// Copy them into the archive table
$dbw->newInsertQueryBuilder()
->insertInto( 'archive' )
->rows( $rowsInsert )
->caller( __METHOD__ )->execute();
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'revision' )
->where( [ 'rev_id' => $revids ] )
->caller( __METHOD__ )->execute();
// Also delete records from ip_changes as applicable.
if ( count( $ipRevIds ) > 0 ) {
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'ip_changes' )
->where( [ 'ipc_rev_id' => $ipRevIds ] )
->caller( __METHOD__ )->execute();
}
}
return $done;
}
/**
* Do some database updates after deletion
*
* @param WikiPage $page
* @param RevisionRecord $revRecord The current page revision at the time of
* deletion, used when determining the required updates. This may be needed because
* $page->getRevisionRecord() may already return null when the page proper was deleted.
*/
private function doDeleteUpdates( WikiPage $page, RevisionRecord $revRecord ): void {
try {
$countable = $page->isCountable();
} catch ( TimeoutException $e ) {
throw $e;
} catch ( Exception $ex ) {
// fallback for deleting broken pages for which we cannot load the content for
// some reason. Note that doDeleteArticleReal() already logged this problem.
$countable = false;
}
// Update site status
DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
[ 'edits' => 1, 'articles' => $countable ? -1 : 0, 'pages' => -1 ]
) );
// Delete pagelinks, update secondary indexes, etc
$updates = $this->getDeletionUpdates( $page, $revRecord );
foreach ( $updates as $update ) {
DeferredUpdates::addUpdate( $update );
}
// Reparse any pages transcluding this page
LinksUpdate::queueRecursiveJobsForTable(
$page->getTitle(),
'templatelinks',
'delete-page',
$this->deleter->getUser()->getName(),
$this->backlinkCacheFactory->getBacklinkCache( $page->getTitle() )
);
// Reparse any pages including this image
if ( $page->getTitle()->getNamespace() === NS_FILE ) {
LinksUpdate::queueRecursiveJobsForTable(
$page->getTitle(),
'imagelinks',
'delete-page',
$this->deleter->getUser()->getName(),
$this->backlinkCacheFactory->getBacklinkCache( $page->getTitle() )
);
}
if ( !$this->isDeletePageUnitTest ) {
// TODO Remove conditional once WikiPage::onArticleDelete is moved to a proper service
// Clear caches
WikiPage::onArticleDelete( $page->getTitle() );
}
WikiModule::invalidateModuleCache(
$page->getTitle(),
$revRecord,
null,
$this->localWikiID
);
// Reset the page object and the Title object
$page->loadFromRow( false, IDBAccessObject::READ_LATEST );
// Search engine
DeferredUpdates::addUpdate( new SearchUpdate( $page->getId(), $page->getTitle() ) );
}
/**
* Returns a list of updates to be performed when the page is deleted. The
* updates should remove any information about this page from secondary data
* stores such as links tables.
*
* @param WikiPage $page
* @param RevisionRecord $rev The revision being deleted.
* @return DeferrableUpdate[]
*/
private function getDeletionUpdates( WikiPage $page, RevisionRecord $rev ): array {
if ( $this->isDeletePageUnitTest ) {
// Hack: LinksDeletionUpdate reads from the global state in the constructor
return [];
}
$slotContent = array_map( static function ( SlotRecord $slot ) {
return $slot->getContent();
}, $rev->getSlots()->getSlots() );
$allUpdates = [ new LinksDeletionUpdate( $page ) ];
// NOTE: once Content::getDeletionUpdates() is removed, we only need the content
// model here, not the content object!
// TODO: consolidate with similar logic in DerivedPageDataUpdater::getSecondaryDataUpdates()
/** @var ?Content $content */
$content = null; // in case $slotContent is zero-length
foreach ( $slotContent as $role => $content ) {
$handler = $content->getContentHandler();
$updates = $handler->getDeletionUpdates(
$page->getTitle(),
$role
);
$allUpdates = array_merge( $allUpdates, $updates );
}
$this->hookRunner->onPageDeletionDataUpdates(
$page->getTitle(), $rev, $allUpdates );
// TODO: hard deprecate old hook in 1.33
$this->hookRunner->onWikiPageDeletionUpdates( $page, $content, $allUpdates );
return $allUpdates;
}
}