Avoid event insertion if possible

Why:
* On wikis with lots of bot activity like Wikidata, there is a large
  volume of edits which can potentially create an article-linked
  notification. These notifications are now actually rarely sent
  because they are disabled for bots (T318523). However, the event
  record is always inserted into the database, with no reference to
  it, bloating the database.

What:
* Do not unconditionally insert an event into the database when
  Event::create is called. Pass it to downstream calls and have
  it inserted when it's clear it will actually be needed (i.e.,
  a notification is definitely going to be created).
* Pass the event's payload to the job queue instead of requiring
  its ID. Introduce Event::newFromArray, which unlike ::loadFromRow
  handles ::toDbArray values that haven't been inserted into
  the database yet.
* Introduce Event::acquireId which ensures the event has been
  inserted prior to returning its ID as well as it does not get
  re-inserted.

Bug: T221258
Change-Id: I8b9a99a197d6af2845d85d9e35c6703640f70b91
This commit is contained in:
Matěj Suchánek 2024-09-22 11:42:51 +02:00
parent 7776cde84c
commit 4ae63d1b4d
9 changed files with 204 additions and 25 deletions

View file

@ -150,7 +150,7 @@ class NotificationController {
}
$hookRunner->onEchoGetNotificationTypes( $user, $event, $userNotifyTypes );
// types such as web, email, etc
// types such as web, email, etc.
foreach ( $userNotifyTypes as $type ) {
self::doNotification( $event, $user, $type );
}
@ -191,9 +191,9 @@ class NotificationController {
if ( !$rev ) {
$logger = LoggerFactory::getInstance( 'Echo' );
$logger->debug(
'Notifying for event {eventId}. Revision \'{revId}\' not found.',
'Notifying for event type \'{eventType}\'. Revision \'{revId}\' not found.',
[
'eventId' => $event->getId(),
'eventType' => $event->getType(),
'revId' => $revId,
]
);
@ -253,7 +253,10 @@ class NotificationController {
$rootJobSignature = $event->getExtraParam( 'rootJobSignature' );
$rootJobTimestamp = $event->getExtraParam( 'rootJobTimestamp' );
return [ 'eventId' => $event->getId() ]
$params = $event->getId()
? [ 'eventId' => $event->getId() ]
: [ 'eventData' => $event->toDbArray() ];
return $params
+ ( $delay ? [ 'jobReleaseTimestamp' => (int)wfTimestamp() + $delay ] : [] )
+ ( $rootJobSignature ? [ 'rootJobSignature' => $rootJobSignature ] : [] )
+ ( $rootJobTimestamp ? [ 'rootJobTimestamp' => $rootJobTimestamp ] : [] );

View file

@ -5,6 +5,7 @@ namespace MediaWiki\Extension\Notifications\Jobs;
use Job;
use MediaWiki\Extension\Notifications\Controller\NotificationController;
use MediaWiki\Extension\Notifications\Mapper\EventMapper;
use MediaWiki\Extension\Notifications\Model\Event;
use MediaWiki\Title\Title;
class NotificationJob extends Job {
@ -15,8 +16,12 @@ class NotificationJob extends Job {
}
public function run() {
$eventMapper = new EventMapper();
$event = $eventMapper->fetchById( $this->params['eventId'], true );
if ( isset( $this->params['eventId'] ) ) {
$eventMapper = new EventMapper();
$event = $eventMapper->fetchById( $this->params['eventId'], true );
} else {
$event = Event::newFromArray( $this->params['eventData'] );
}
NotificationController::notify( $event, false );
return true;

View file

@ -84,6 +84,9 @@ class Event extends AbstractEntity implements Bundleable {
*/
protected $deleted = 0;
/** For use in tests */
public static bool $alwaysInsert = false;
/**
* You should not call the constructor.
* Instead, use one of the factory functions:
@ -198,19 +201,14 @@ class Event extends AbstractEntity implements Bundleable {
}
// @Todo - Database insert logic should not be inside the model
$obj->insert();
$hookRunner->onEventInsertComplete( $obj );
if ( self::$alwaysInsert ) {
$obj->insert();
}
global $wgEchoUseJobQueue;
NotificationController::notify( $obj, $wgEchoUseJobQueue );
$stats = $services->getStatsdDataFactory();
$type = $info['type'];
$stats->increment( 'echo.event.all' );
$stats->increment( "echo.event.$type" );
return $obj;
}
@ -250,6 +248,34 @@ class Event extends AbstractEntity implements Bundleable {
return $data;
}
/**
* Creates an Event from an array. The array should be output from ::toDbArray().
*
* @param array $data
* @return self
*/
public static function newFromArray( $data ) {
$obj = new self();
if ( isset( $data['event_id'] ) ) {
$obj->id = $data['event_id'];
}
$obj->type = $data['event_type'];
$obj->variant = $data['event_variant'];
$obj->extra = $data['event_extra'] ? unserialize( $data['event_extra'] ) : [];
if ( isset( $data['event_page_id'] ) ) {
$obj->pageId = $data['event_page_id'];
}
$obj->deleted = $data['event_deleted'];
if ( $data['event_agent_id'] ?? 0 ) {
$obj->agent = User::newFromId( $data['event_agent_id'] );
} elseif ( isset( $data['event_agent_ip'] ) ) {
$obj->agent = User::newFromName( $data['event_agent_ip'], false );
}
return $obj;
}
/**
* Check whether the echo event is an enabled event
* @return bool
@ -259,6 +285,19 @@ class Event extends AbstractEntity implements Bundleable {
return isset( $wgEchoNotifications[$this->getType()] );
}
/**
* Insert this event into the database if it hasn't been yet, and return its id.
* Subsequent calls on this instance will not cause repeated insertion
* and will always return the same id.
* @return int
*/
public function acquireId() {
if ( !$this->id ) {
$this->insert();
}
return $this->id;
}
/**
* Inserts the object into the database.
*/
@ -276,6 +315,15 @@ class Event extends AbstractEntity implements Bundleable {
}
}
}
$services = MediaWikiServices::getInstance();
$hookRunner = new HookRunner( $services->getHookContainer() );
$hookRunner->onEventInsertComplete( $this );
$stats = $services->getStatsdDataFactory();
$type = $this->getType();
$stats->increment( 'echo.event.all' );
$stats->increment( "echo.event.$type" );
}
/**

View file

@ -129,6 +129,7 @@ class Notification extends AbstractEntity implements Bundleable {
}
);
$this->event->acquireId();
$notifMapper->insert( $this );
if ( $this->event->getCategory() === 'edit-user-talk' ) {

View file

@ -81,7 +81,7 @@ class Notifier {
}
}
} elseif ( $event->getExtraParam( 'noemail' ) ) {
// Could be set for API triggered notifications were email is not
// Could be set for API triggered notifications where email is not
// requested in API request params
return false;
}
@ -114,14 +114,15 @@ class Notifier {
$bundleHash = md5( $bundleString );
}
// email digest notification ( weekly or daily )
// email digest notification (weekly or daily)
if ( $wgEchoEnableEmailBatch && $userOptionsLookup->getOption( $user, 'echo-email-frequency' ) > 0 ) {
// always create a unique event hash for those events don't support bundling
// this is mainly for group by
$eventId = $event->acquireId();
if ( !$bundleHash ) {
$bundleHash = md5( $event->getType() . '-' . $event->getId() );
$bundleHash = md5( $event->getType() . '-' . $eventId );
}
EmailBatch::addToQueue( $user->getId(), $event->getId(), $priority, $bundleHash );
EmailBatch::addToQueue( $user->getId(), $eventId, $priority, $bundleHash );
return true;
}

View file

@ -33,7 +33,6 @@ class PushNotifier {
$params = [ 'centralId' => $centralId ];
// below params are only needed for debug logging (T255068)
if ( $event !== null ) {
$params['eventId'] = $event->getId();
$params['eventType'] = $event->getType();
if ( $event->getAgent() !== null ) {
$params['agent'] = $event->getAgent()->getId();

View file

@ -266,8 +266,13 @@ class NotificationControllerTest extends MediaWikiIntegrationTestCase {
->method( 'getTitle' )
->willReturn( Title::newFromText( 'test-title' ) );
$event->expects( $this->once() )
->method( 'getId' )
->willReturn( 42 );
->method( 'toDbArray' )
->willReturn( [
'event_type' => 'test',
'event_extra' => [
'extra-key' => 'extra'
],
] );
NotificationController::enqueueEvent( $event );
$jobQueueGroup = $this->getServiceContainer()->getJobQueueGroup();
$queues = $jobQueueGroup->getQueuesWithJobs();
@ -275,7 +280,15 @@ class NotificationControllerTest extends MediaWikiIntegrationTestCase {
$this->assertEquals( 'EchoNotificationJob', $queues[0] );
$job = $jobQueueGroup->pop( 'EchoNotificationJob' );
$this->assertEquals( 'Test-title', $job->params[ 'title' ] );
$this->assertEquals( 42, $job->params[ 'eventId' ] );
$this->assertEquals(
[
'event_type' => 'test',
'event_extra' => [
'extra-key' => 'extra'
],
],
$job->params[ 'eventData' ]
);
}
public function testNotSupportedDelay() {
@ -311,12 +324,26 @@ class NotificationControllerTest extends MediaWikiIntegrationTestCase {
[ 'rootJobTimestamp', null, $rootJobTimestamp ]
] );
$event->expects( $this->once() )
->method( 'getId' )
->willReturn( 42 );
->method( 'toDbArray' )
->willReturn( [
'event_type' => 'test',
'event_extra' => [
'delay' => 10,
'rootJobSignature' => 'test-signature',
'rootJobTimestamp' => $rootJobTimestamp,
],
] );
$params = NotificationController::getEventParams( $event );
$expectedParams = [
'eventId' => 42,
'eventData' => [
'event_type' => 'test',
'event_extra' => [
'delay' => 10,
'rootJobSignature' => 'test-signature',
'rootJobTimestamp' => $rootJobTimestamp,
],
],
'rootJobSignature' => 'test-signature',
'rootJobTimestamp' => $rootJobTimestamp,
'jobReleaseTimestamp' => 10

View file

@ -9,12 +9,20 @@ use MediaWiki\Tests\Api\ApiTestCase;
* @group medium
*/
class TalkPageFunctionalTest extends ApiTestCase {
protected function tearDown(): void {
Event::$alwaysInsert = false;
parent::tearDown();
}
/**
* Creates and updates a user talk page a few times to ensure proper events are
* created.
* @covers \MediaWiki\Extension\Notifications\DiscussionParser
*/
public function testAddCommentsToTalkPage() {
Event::$alwaysInsert = true;
$editor = $this->getTestSysop()->getUser();
$talkTitle = $this->getTestSysop()->getUser()->getTalkPage();
$talkPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $talkTitle );

View file

@ -0,0 +1,87 @@
<?php
use MediaWiki\Extension\Notifications\Mapper\EventMapper;
use MediaWiki\Extension\Notifications\Model\Event;
use MediaWiki\User\UserIdentityValue;
/**
* @covers \MediaWiki\Extension\Notifications\Model\Event
* @covers \MediaWiki\Extension\Notifications\Controller\NotificationController
* @covers \MediaWiki\Extension\Notifications\Jobs\NotificationJob
* @group Database
*/
class EventIntegrationTest extends MediaWikiIntegrationTestCase {
public function testEventInsertionImmediate() {
$this->clearHook( 'BeforeEchoEventInsert' );
$this->overrideConfigValue( 'EchoUseJobQueue', false );
$user = $this->getTestUser()->getUser();
$event = Event::create( [
'type' => 'welcome',
'agent' => $user,
'extra' => [ 'key' => 'value' ]
] );
$eventId = $event->getId();
$this->assertNotFalse( $eventId );
$this->assertSame( $eventId, $event->acquireId() );
$this->assertSelect(
'echo_event',
[ 'count' => 'COUNT(*)' ],
[ 'event_type' => 'welcome' ],
[ [ '1' ] ]
);
}
public function testEventNotInserted() {
$this->clearHook( 'BeforeEchoEventInsert' );
$this->overrideConfigValue( 'EchoUseJobQueue', false );
$event = Event::create( [
'type' => 'welcome',
// anons cannot be notified
'agent' => UserIdentityValue::newAnonymous( '1.2.3.4' ),
'extra' => [ 'key' => 'value' ]
] );
$this->assertFalse( $event->getId() );
$this->assertSelect(
'echo_event',
[ 'count' => 'COUNT(*)' ],
[ 'event_type' => 'welcome' ],
[ [ '0' ] ]
);
}
public function testEventInsertionDeferred() {
$this->clearHook( 'BeforeEchoEventInsert' );
$this->clearHook( 'PageSaveComplete' );
$this->overrideConfigValue( 'EchoUseJobQueue', true );
$user = $this->getTestUser()->getUser();
$title = $this->getExistingTestPage()->getTitle();
$this->runJobs();
$event = Event::create( [
'type' => 'welcome',
'agent' => $user,
'title' => $title,
'extra' => [ 'key' => 'value' ]
] );
$this->assertFalse( $event->getId() );
unset( $event );
$jobQueueGroup = $this->getServiceContainer()->getJobQueueGroup();
$queues = $jobQueueGroup->getQueuesWithJobs();
$this->assertSame( [ 'EchoNotificationJob' ], $queues );
$job = $jobQueueGroup->pop( 'EchoNotificationJob' );
$job->run();
$eventMapper = new EventMapper();
$events = $eventMapper->fetchByPage( $title->getArticleID() );
$this->assertCount( 1, $events );
[ $event ] = $events;
$this->assertSame( 'welcome', $event->getType() );
$this->assertTrue( $user->equals( $event->getAgent() ) );
}
}