<?php
+use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
use Wikimedia\Assert\Assert;
/**
*
* @since 1.27
*/
-class WatchedItemStore {
+class WatchedItemStore implements StatsdAwareInterface {
+
+ const SORT_DESC = 'DESC';
+ const SORT_ASC = 'ASC';
/**
* @var LoadBalancer
*/
private $revisionGetTimestampFromIdCallback;
+ /**
+ * @var StatsdDataFactoryInterface
+ */
+ private $stats;
+
/**
* @var self|null
*/
) {
$this->loadBalancer = $loadBalancer;
$this->cache = $cache;
+ $this->stats = new NullStatsdDataFactory();
$this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
$this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
}
+ public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
+ $this->stats = $stats;
+ }
+
/**
* Overrides the DeferredUpdates::addCallableUpdate callback
* This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
wfGetLB(),
new HashBagOStuff( [ 'maxKeys' => 100 ] )
);
+ self::$instance->setStatsdDataFactory( RequestContext::getMain()->getStats() );
}
return self::$instance;
}
$key = $this->getCacheKey( $user, $target );
$this->cache->set( $key, $item );
$this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
+ $this->stats->increment( 'WatchedItemStore.cache' );
}
private function uncache( User $user, LinkTarget $target ) {
$this->cache->delete( $this->getCacheKey( $user, $target ) );
unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
+ $this->stats->increment( 'WatchedItemStore.uncache' );
}
private function uncacheLinkTarget( LinkTarget $target ) {
if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
return;
}
+ $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
+ $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
$this->cache->delete( $key );
}
}
return $watchCounts;
}
+ /**
+ * Number of watchers of each page who have visited recent edits to that page
+ *
+ * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
+ * $threshold is:
+ * - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
+ * - null if $target doesn't exist
+ * @param int|null $minimumWatchers
+ * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
+ * where $watchers is an int:
+ * - if the page exists, number of users watching who have visited the page recently
+ * - if the page doesn't exist, number of users that have the page on their watchlist
+ * - 0 means there are no visiting watchers or their number is below the minimumWatchers
+ * option (if passed).
+ */
+ public function countVisitingWatchersMultiple(
+ array $targetsWithVisitThresholds,
+ $minimumWatchers = null
+ ) {
+ $dbr = $this->getConnection( DB_SLAVE );
+
+ $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
+
+ $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
+ if ( $minimumWatchers !== null ) {
+ $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
+ }
+ $res = $dbr->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
+ $conds,
+ __METHOD__,
+ $dbOptions
+ );
+
+ $this->reuseConnection( $dbr );
+
+ $watcherCounts = [];
+ foreach ( $targetsWithVisitThresholds as list( $target ) ) {
+ /* @var LinkTarget $target */
+ $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
+ }
+
+ foreach ( $res as $row ) {
+ $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
+ }
+
+ return $watcherCounts;
+ }
+
+ /**
+ * Generates condition for the query used in a batch count visiting watchers.
+ *
+ * @param IDatabase $db
+ * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
+ * @return string
+ */
+ private function getVisitingWatchersCondition(
+ IDatabase $db,
+ array $targetsWithVisitThresholds
+ ) {
+ $missingTargets = [];
+ $namespaceConds = [];
+ foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
+ if ( $threshold === null ) {
+ $missingTargets[] = $target;
+ continue;
+ }
+ /* @var LinkTarget $target */
+ $namespaceConds[$target->getNamespace()][] = $db->makeList( [
+ 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
+ $db->makeList( [
+ 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
+ 'wl_notificationtimestamp IS NULL'
+ ], LIST_OR )
+ ], LIST_AND );
+ }
+
+ $conds = [];
+ foreach ( $namespaceConds as $namespace => $pageConds ) {
+ $conds[] = $db->makeList( [
+ 'wl_namespace = ' . $namespace,
+ '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
+ ], LIST_AND );
+ }
+
+ if ( $missingTargets ) {
+ $lb = new LinkBatch( $missingTargets );
+ $conds[] = $lb->constructSet( 'wl', $db );
+ }
+
+ return $db->makeList( $conds, LIST_OR );
+ }
+
/**
* Get an item (may be cached)
*
$cached = $this->getCached( $user, $target );
if ( $cached ) {
+ $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
return $cached;
}
+ $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
return $this->loadWatchedItem( $user, $target );
}
return $item;
}
+ /**
+ * @param User $user
+ * @param array $options Allowed keys:
+ * 'forWrite' => bool defaults to false
+ * 'sort' => string optional sorting by namespace ID and title
+ * one of the self::SORT_* constants
+ *
+ * @return WatchedItem[]
+ */
+ public function getWatchedItemsForUser( User $user, array $options = [] ) {
+ $options += [ 'forWrite' => false ];
+
+ $dbOptions = [];
+ if ( array_key_exists( 'sort', $options ) ) {
+ Assert::parameter(
+ ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
+ '$options[\'sort\']',
+ 'must be SORT_ASC or SORT_DESC'
+ );
+ $dbOptions['ORDER BY'] = [
+ "wl_namespace {$options['sort']}",
+ "wl_title {$options['sort']}"
+ ];
+ }
+ $db = $this->getConnection( $options['forWrite'] ? DB_MASTER : DB_SLAVE );
+
+ $res = $db->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [ 'wl_user' => $user->getId() ],
+ __METHOD__,
+ $dbOptions
+ );
+ $this->reuseConnection( $db );
+
+ $watchedItems = [];
+ foreach ( $res as $row ) {
+ // todo these could all be cached at some point?
+ $watchedItems[] = new WatchedItem(
+ $user,
+ new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
+ $row->wl_notificationtimestamp
+ );
+ }
+
+ return $watchedItems;
+ }
+
/**
* Must be called separately for Subject & Talk namespaces
*
return (bool)$this->getWatchedItem( $user, $target );
}
+ /**
+ * @param User $user
+ * @param LinkTarget[] $targets
+ *
+ * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
+ * where $timestamp is:
+ * - string|null value of wl_notificationtimestamp,
+ * - false if $target is not watched by $user.
+ */
+ public function getNotificationTimestampsBatch( User $user, array $targets ) {
+ $timestamps = [];
+ foreach ( $targets as $target ) {
+ $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
+ }
+
+ if ( $user->isAnon() ) {
+ return $timestamps;
+ }
+
+ $targetsToLoad = [];
+ foreach ( $targets as $target ) {
+ $cachedItem = $this->getCached( $user, $target );
+ if ( $cachedItem ) {
+ $timestamps[$target->getNamespace()][$target->getDBkey()] =
+ $cachedItem->getNotificationTimestamp();
+ } else {
+ $targetsToLoad[] = $target;
+ }
+ }
+
+ if ( !$targetsToLoad ) {
+ return $timestamps;
+ }
+
+ $dbr = $this->getConnection( DB_SLAVE );
+
+ $lb = new LinkBatch( $targetsToLoad );
+ $res = $dbr->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
+ [
+ $lb->constructSet( 'wl', $dbr ),
+ 'wl_user' => $user->getId(),
+ ],
+ __METHOD__
+ );
+ $this->reuseConnection( $dbr );
+
+ foreach ( $res as $row ) {
+ $timestamps[(int)$row->wl_namespace][$row->wl_title] = $row->wl_notificationtimestamp;
+ }
+
+ return $timestamps;
+ }
+
/**
* Must be called separately for Subject & Talk namespaces
*
* @param LinkTarget $target
*/
public function addWatch( User $user, LinkTarget $target ) {
- $this->addWatchBatch( [ [ $user, $target ] ] );
+ $this->addWatchBatchForUser( $user, [ $target ] );
}
/**
- * @param array[] $userTargetCombinations array of arrays containing [0] => User [1] => LinkTarget
+ * @param User $user
+ * @param LinkTarget[] $targets
*
* @return bool success
*/
- public function addWatchBatch( array $userTargetCombinations ) {
+ public function addWatchBatchForUser( User $user, array $targets ) {
if ( $this->loadBalancer->getReadOnlyReason() !== false ) {
return false;
}
+ // Only loggedin user can have a watchlist
+ if ( $user->isAnon() ) {
+ return false;
+ }
+
+ if ( !$targets ) {
+ return true;
+ }
$rows = [];
- foreach ( $userTargetCombinations as list( $user, $target ) ) {
- /**
- * @var User $user
- * @var LinkTarget $target
- */
-
- // Only loggedin user can have a watchlist
- if ( $user->isAnon() ) {
- continue;
- }
+ foreach ( $targets as $target ) {
$rows[] = [
'wl_user' => $user->getId(),
'wl_namespace' => $target->getNamespace(),
$this->uncache( $user, $target );
}
- if ( !$rows ) {
- return false;
- }
-
$dbw = $this->getConnection( DB_MASTER );
foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
// Use INSERT IGNORE to avoid overwriting the notification timestamp