WatchedItemStore.php 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282
  1. <?php
  2. use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
  3. use MediaWiki\Linker\LinkTarget;
  4. use MediaWiki\Revision\RevisionLookup;
  5. use MediaWiki\User\UserIdentity;
  6. use Wikimedia\Assert\Assert;
  7. use Wikimedia\Rdbms\IDatabase;
  8. use Wikimedia\Rdbms\ILBFactory;
  9. use Wikimedia\Rdbms\LoadBalancer;
  10. use Wikimedia\ScopedCallback;
  11. /**
  12. * Storage layer class for WatchedItems.
  13. * Database interaction & caching
  14. * TODO caching should be factored out into a CachingWatchedItemStore class
  15. *
  16. * @author Addshore
  17. * @since 1.27
  18. */
  19. class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterface {
  20. /**
  21. * @var ILBFactory
  22. */
  23. private $lbFactory;
  24. /**
  25. * @var LoadBalancer
  26. */
  27. private $loadBalancer;
  28. /**
  29. * @var JobQueueGroup
  30. */
  31. private $queueGroup;
  32. /**
  33. * @var BagOStuff
  34. */
  35. private $stash;
  36. /**
  37. * @var ReadOnlyMode
  38. */
  39. private $readOnlyMode;
  40. /**
  41. * @var HashBagOStuff
  42. */
  43. private $cache;
  44. /**
  45. * @var HashBagOStuff
  46. */
  47. private $latestUpdateCache;
  48. /**
  49. * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
  50. * The index is needed so that on mass changes all relevant items can be un-cached.
  51. * For example: Clearing a users watchlist of all items or updating notification timestamps
  52. * for all users watching a single target.
  53. */
  54. private $cacheIndex = [];
  55. /**
  56. * @var callable|null
  57. */
  58. private $deferredUpdatesAddCallableUpdateCallback;
  59. /**
  60. * @var int
  61. */
  62. private $updateRowsPerQuery;
  63. /**
  64. * @var NamespaceInfo
  65. */
  66. private $nsInfo;
  67. /**
  68. * @var RevisionLookup
  69. */
  70. private $revisionLookup;
  71. /**
  72. * @var StatsdDataFactoryInterface
  73. */
  74. private $stats;
  75. /**
  76. * @param ILBFactory $lbFactory
  77. * @param JobQueueGroup $queueGroup
  78. * @param BagOStuff $stash
  79. * @param HashBagOStuff $cache
  80. * @param ReadOnlyMode $readOnlyMode
  81. * @param int $updateRowsPerQuery
  82. * @param NamespaceInfo $nsInfo
  83. * @param RevisionLookup $revisionLookup
  84. */
  85. public function __construct(
  86. ILBFactory $lbFactory,
  87. JobQueueGroup $queueGroup,
  88. BagOStuff $stash,
  89. HashBagOStuff $cache,
  90. ReadOnlyMode $readOnlyMode,
  91. $updateRowsPerQuery,
  92. NamespaceInfo $nsInfo,
  93. RevisionLookup $revisionLookup
  94. ) {
  95. $this->lbFactory = $lbFactory;
  96. $this->loadBalancer = $lbFactory->getMainLB();
  97. $this->queueGroup = $queueGroup;
  98. $this->stash = $stash;
  99. $this->cache = $cache;
  100. $this->readOnlyMode = $readOnlyMode;
  101. $this->stats = new NullStatsdDataFactory();
  102. $this->deferredUpdatesAddCallableUpdateCallback =
  103. [ DeferredUpdates::class, 'addCallableUpdate' ];
  104. $this->updateRowsPerQuery = $updateRowsPerQuery;
  105. $this->nsInfo = $nsInfo;
  106. $this->revisionLookup = $revisionLookup;
  107. $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
  108. }
  109. /**
  110. * @param StatsdDataFactoryInterface $stats
  111. */
  112. public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
  113. $this->stats = $stats;
  114. }
  115. /**
  116. * Overrides the DeferredUpdates::addCallableUpdate callback
  117. * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
  118. *
  119. * @param callable $callback
  120. *
  121. * @see DeferredUpdates::addCallableUpdate for callback signiture
  122. *
  123. * @return ScopedCallback to reset the overridden value
  124. * @throws MWException
  125. */
  126. public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
  127. if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
  128. throw new MWException(
  129. 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
  130. );
  131. }
  132. $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
  133. $this->deferredUpdatesAddCallableUpdateCallback = $callback;
  134. return new ScopedCallback( function () use ( $previousValue ) {
  135. $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
  136. } );
  137. }
  138. private function getCacheKey( UserIdentity $user, LinkTarget $target ) {
  139. return $this->cache->makeKey(
  140. (string)$target->getNamespace(),
  141. $target->getDBkey(),
  142. (string)$user->getId()
  143. );
  144. }
  145. private function cache( WatchedItem $item ) {
  146. $user = $item->getUserIdentity();
  147. $target = $item->getLinkTarget();
  148. $key = $this->getCacheKey( $user, $target );
  149. $this->cache->set( $key, $item );
  150. $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
  151. $this->stats->increment( 'WatchedItemStore.cache' );
  152. }
  153. private function uncache( UserIdentity $user, LinkTarget $target ) {
  154. $this->cache->delete( $this->getCacheKey( $user, $target ) );
  155. unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
  156. $this->stats->increment( 'WatchedItemStore.uncache' );
  157. }
  158. private function uncacheLinkTarget( LinkTarget $target ) {
  159. $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
  160. if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
  161. return;
  162. }
  163. foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
  164. $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
  165. $this->cache->delete( $key );
  166. }
  167. }
  168. private function uncacheUser( UserIdentity $user ) {
  169. $this->stats->increment( 'WatchedItemStore.uncacheUser' );
  170. foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
  171. foreach ( $dbKeyArray as $dbKey => $userArray ) {
  172. if ( isset( $userArray[$user->getId()] ) ) {
  173. $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
  174. $this->cache->delete( $userArray[$user->getId()] );
  175. }
  176. }
  177. }
  178. $pageSeenKey = $this->getPageSeenTimestampsKey( $user );
  179. $this->latestUpdateCache->delete( $pageSeenKey );
  180. $this->stash->delete( $pageSeenKey );
  181. }
  182. /**
  183. * @param UserIdentity $user
  184. * @param LinkTarget $target
  185. *
  186. * @return WatchedItem|false
  187. */
  188. private function getCached( UserIdentity $user, LinkTarget $target ) {
  189. return $this->cache->get( $this->getCacheKey( $user, $target ) );
  190. }
  191. /**
  192. * Return an array of conditions to select or update the appropriate database
  193. * row.
  194. *
  195. * @param UserIdentity $user
  196. * @param LinkTarget $target
  197. *
  198. * @return array
  199. */
  200. private function dbCond( UserIdentity $user, LinkTarget $target ) {
  201. return [
  202. 'wl_user' => $user->getId(),
  203. 'wl_namespace' => $target->getNamespace(),
  204. 'wl_title' => $target->getDBkey(),
  205. ];
  206. }
  207. /**
  208. * @param int $dbIndex DB_MASTER or DB_REPLICA
  209. *
  210. * @return IDatabase
  211. */
  212. private function getConnectionRef( $dbIndex ) {
  213. return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
  214. }
  215. /**
  216. * Deletes ALL watched items for the given user when under
  217. * $updateRowsPerQuery entries exist.
  218. *
  219. * @since 1.30
  220. *
  221. * @param UserIdentity $user
  222. *
  223. * @return bool true on success, false when too many items are watched
  224. */
  225. public function clearUserWatchedItems( UserIdentity $user ) {
  226. if ( $this->countWatchedItems( $user ) > $this->updateRowsPerQuery ) {
  227. return false;
  228. }
  229. $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
  230. $dbw->delete(
  231. 'watchlist',
  232. [ 'wl_user' => $user->getId() ],
  233. __METHOD__
  234. );
  235. $this->uncacheAllItemsForUser( $user );
  236. return true;
  237. }
  238. private function uncacheAllItemsForUser( UserIdentity $user ) {
  239. $userId = $user->getId();
  240. foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
  241. foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
  242. if ( array_key_exists( $userId, $userIndex ) ) {
  243. $this->cache->delete( $userIndex[$userId] );
  244. unset( $this->cacheIndex[$ns][$dbKey][$userId] );
  245. }
  246. }
  247. }
  248. // Cleanup empty cache keys
  249. foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
  250. foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
  251. if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) {
  252. unset( $this->cacheIndex[$ns][$dbKey] );
  253. }
  254. }
  255. if ( empty( $this->cacheIndex[$ns] ) ) {
  256. unset( $this->cacheIndex[$ns] );
  257. }
  258. }
  259. }
  260. /**
  261. * Queues a job that will clear the users watchlist using the Job Queue.
  262. *
  263. * @since 1.31
  264. *
  265. * @param UserIdentity $user
  266. */
  267. public function clearUserWatchedItemsUsingJobQueue( UserIdentity $user ) {
  268. $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
  269. $this->queueGroup->push( $job );
  270. }
  271. /**
  272. * @since 1.31
  273. * @return int The maximum current wl_id
  274. */
  275. public function getMaxId() {
  276. $dbr = $this->getConnectionRef( DB_REPLICA );
  277. return (int)$dbr->selectField(
  278. 'watchlist',
  279. 'MAX(wl_id)',
  280. '',
  281. __METHOD__
  282. );
  283. }
  284. /**
  285. * @since 1.31
  286. * @param UserIdentity $user
  287. * @return int
  288. */
  289. public function countWatchedItems( UserIdentity $user ) {
  290. $dbr = $this->getConnectionRef( DB_REPLICA );
  291. $return = (int)$dbr->selectField(
  292. 'watchlist',
  293. 'COUNT(*)',
  294. [
  295. 'wl_user' => $user->getId()
  296. ],
  297. __METHOD__
  298. );
  299. return $return;
  300. }
  301. /**
  302. * @since 1.27
  303. * @param LinkTarget $target
  304. * @return int
  305. */
  306. public function countWatchers( LinkTarget $target ) {
  307. $dbr = $this->getConnectionRef( DB_REPLICA );
  308. $return = (int)$dbr->selectField(
  309. 'watchlist',
  310. 'COUNT(*)',
  311. [
  312. 'wl_namespace' => $target->getNamespace(),
  313. 'wl_title' => $target->getDBkey(),
  314. ],
  315. __METHOD__
  316. );
  317. return $return;
  318. }
  319. /**
  320. * @since 1.27
  321. * @param LinkTarget $target
  322. * @param string|int $threshold
  323. * @return int
  324. */
  325. public function countVisitingWatchers( LinkTarget $target, $threshold ) {
  326. $dbr = $this->getConnectionRef( DB_REPLICA );
  327. $visitingWatchers = (int)$dbr->selectField(
  328. 'watchlist',
  329. 'COUNT(*)',
  330. [
  331. 'wl_namespace' => $target->getNamespace(),
  332. 'wl_title' => $target->getDBkey(),
  333. 'wl_notificationtimestamp >= ' .
  334. $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
  335. ' OR wl_notificationtimestamp IS NULL'
  336. ],
  337. __METHOD__
  338. );
  339. return $visitingWatchers;
  340. }
  341. /**
  342. * @param UserIdentity $user
  343. * @param LinkTarget[] $titles
  344. * @return bool
  345. */
  346. public function removeWatchBatchForUser( UserIdentity $user, array $titles ) {
  347. if ( $this->readOnlyMode->isReadOnly() ) {
  348. return false;
  349. }
  350. if ( !$user->isRegistered() ) {
  351. return false;
  352. }
  353. if ( !$titles ) {
  354. return true;
  355. }
  356. $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
  357. $this->uncacheTitlesForUser( $user, $titles );
  358. $dbw = $this->getConnectionRef( DB_MASTER );
  359. $ticket = count( $titles ) > $this->updateRowsPerQuery ?
  360. $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
  361. $affectedRows = 0;
  362. // Batch delete items per namespace.
  363. foreach ( $rows as $namespace => $namespaceTitles ) {
  364. $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
  365. foreach ( $rowBatches as $toDelete ) {
  366. $dbw->delete( 'watchlist', [
  367. 'wl_user' => $user->getId(),
  368. 'wl_namespace' => $namespace,
  369. 'wl_title' => $toDelete
  370. ], __METHOD__ );
  371. $affectedRows += $dbw->affectedRows();
  372. if ( $ticket ) {
  373. $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
  374. }
  375. }
  376. }
  377. return (bool)$affectedRows;
  378. }
  379. /**
  380. * @since 1.27
  381. * @param LinkTarget[] $targets
  382. * @param array $options
  383. * @return array
  384. */
  385. public function countWatchersMultiple( array $targets, array $options = [] ) {
  386. $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
  387. $dbr = $this->getConnectionRef( DB_REPLICA );
  388. if ( array_key_exists( 'minimumWatchers', $options ) ) {
  389. $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
  390. }
  391. $lb = new LinkBatch( $targets );
  392. $res = $dbr->select(
  393. 'watchlist',
  394. [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
  395. [ $lb->constructSet( 'wl', $dbr ) ],
  396. __METHOD__,
  397. $dbOptions
  398. );
  399. $watchCounts = [];
  400. foreach ( $targets as $linkTarget ) {
  401. $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
  402. }
  403. foreach ( $res as $row ) {
  404. $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
  405. }
  406. return $watchCounts;
  407. }
  408. /**
  409. * @since 1.27
  410. * @param array $targetsWithVisitThresholds
  411. * @param int|null $minimumWatchers
  412. * @return array
  413. */
  414. public function countVisitingWatchersMultiple(
  415. array $targetsWithVisitThresholds,
  416. $minimumWatchers = null
  417. ) {
  418. if ( $targetsWithVisitThresholds === [] ) {
  419. // No titles requested => no results returned
  420. return [];
  421. }
  422. $dbr = $this->getConnectionRef( DB_REPLICA );
  423. $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
  424. $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
  425. if ( $minimumWatchers !== null ) {
  426. $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
  427. }
  428. $res = $dbr->select(
  429. 'watchlist',
  430. [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
  431. $conds,
  432. __METHOD__,
  433. $dbOptions
  434. );
  435. $watcherCounts = [];
  436. foreach ( $targetsWithVisitThresholds as list( $target ) ) {
  437. /* @var LinkTarget $target */
  438. $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
  439. }
  440. foreach ( $res as $row ) {
  441. $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
  442. }
  443. return $watcherCounts;
  444. }
  445. /**
  446. * Generates condition for the query used in a batch count visiting watchers.
  447. *
  448. * @param IDatabase $db
  449. * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
  450. * @return string
  451. */
  452. private function getVisitingWatchersCondition(
  453. IDatabase $db,
  454. array $targetsWithVisitThresholds
  455. ) {
  456. $missingTargets = [];
  457. $namespaceConds = [];
  458. foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
  459. if ( $threshold === null ) {
  460. $missingTargets[] = $target;
  461. continue;
  462. }
  463. /* @var LinkTarget $target */
  464. $namespaceConds[$target->getNamespace()][] = $db->makeList( [
  465. 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
  466. $db->makeList( [
  467. 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
  468. 'wl_notificationtimestamp IS NULL'
  469. ], LIST_OR )
  470. ], LIST_AND );
  471. }
  472. $conds = [];
  473. foreach ( $namespaceConds as $namespace => $pageConds ) {
  474. $conds[] = $db->makeList( [
  475. 'wl_namespace = ' . $namespace,
  476. '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
  477. ], LIST_AND );
  478. }
  479. if ( $missingTargets ) {
  480. $lb = new LinkBatch( $missingTargets );
  481. $conds[] = $lb->constructSet( 'wl', $db );
  482. }
  483. return $db->makeList( $conds, LIST_OR );
  484. }
  485. /**
  486. * @since 1.27
  487. * @param UserIdentity $user
  488. * @param LinkTarget $target
  489. * @return WatchedItem|false
  490. */
  491. public function getWatchedItem( UserIdentity $user, LinkTarget $target ) {
  492. if ( !$user->isRegistered() ) {
  493. return false;
  494. }
  495. $cached = $this->getCached( $user, $target );
  496. if ( $cached ) {
  497. $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
  498. return $cached;
  499. }
  500. $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
  501. return $this->loadWatchedItem( $user, $target );
  502. }
  503. /**
  504. * @since 1.27
  505. * @param UserIdentity $user
  506. * @param LinkTarget $target
  507. * @return WatchedItem|false
  508. */
  509. public function loadWatchedItem( UserIdentity $user, LinkTarget $target ) {
  510. // Only registered user can have a watchlist
  511. if ( !$user->isRegistered() ) {
  512. return false;
  513. }
  514. $dbr = $this->getConnectionRef( DB_REPLICA );
  515. $row = $dbr->selectRow(
  516. 'watchlist',
  517. 'wl_notificationtimestamp',
  518. $this->dbCond( $user, $target ),
  519. __METHOD__
  520. );
  521. if ( !$row ) {
  522. return false;
  523. }
  524. $item = new WatchedItem(
  525. $user,
  526. $target,
  527. $this->getLatestNotificationTimestamp( $row->wl_notificationtimestamp, $user, $target )
  528. );
  529. $this->cache( $item );
  530. return $item;
  531. }
  532. /**
  533. * @since 1.27
  534. * @param UserIdentity $user
  535. * @param array $options
  536. * @return WatchedItem[]
  537. */
  538. public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ) {
  539. $options += [ 'forWrite' => false ];
  540. $dbOptions = [];
  541. if ( array_key_exists( 'sort', $options ) ) {
  542. Assert::parameter(
  543. ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
  544. '$options[\'sort\']',
  545. 'must be SORT_ASC or SORT_DESC'
  546. );
  547. $dbOptions['ORDER BY'] = [
  548. "wl_namespace {$options['sort']}",
  549. "wl_title {$options['sort']}"
  550. ];
  551. }
  552. $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
  553. $res = $db->select(
  554. 'watchlist',
  555. [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
  556. [ 'wl_user' => $user->getId() ],
  557. __METHOD__,
  558. $dbOptions
  559. );
  560. $watchedItems = [];
  561. foreach ( $res as $row ) {
  562. $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
  563. // @todo: Should we add these to the process cache?
  564. $watchedItems[] = new WatchedItem(
  565. $user,
  566. $target,
  567. $this->getLatestNotificationTimestamp(
  568. $row->wl_notificationtimestamp, $user, $target )
  569. );
  570. }
  571. return $watchedItems;
  572. }
  573. /**
  574. * @since 1.27
  575. * @param UserIdentity $user
  576. * @param LinkTarget $target
  577. * @return bool
  578. */
  579. public function isWatched( UserIdentity $user, LinkTarget $target ) {
  580. return (bool)$this->getWatchedItem( $user, $target );
  581. }
  582. /**
  583. * @since 1.27
  584. * @param UserIdentity $user
  585. * @param LinkTarget[] $targets
  586. * @return array
  587. */
  588. public function getNotificationTimestampsBatch( UserIdentity $user, array $targets ) {
  589. $timestamps = [];
  590. foreach ( $targets as $target ) {
  591. $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
  592. }
  593. if ( !$user->isRegistered() ) {
  594. return $timestamps;
  595. }
  596. $targetsToLoad = [];
  597. foreach ( $targets as $target ) {
  598. $cachedItem = $this->getCached( $user, $target );
  599. if ( $cachedItem ) {
  600. $timestamps[$target->getNamespace()][$target->getDBkey()] =
  601. $cachedItem->getNotificationTimestamp();
  602. } else {
  603. $targetsToLoad[] = $target;
  604. }
  605. }
  606. if ( !$targetsToLoad ) {
  607. return $timestamps;
  608. }
  609. $dbr = $this->getConnectionRef( DB_REPLICA );
  610. $lb = new LinkBatch( $targetsToLoad );
  611. $res = $dbr->select(
  612. 'watchlist',
  613. [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
  614. [
  615. $lb->constructSet( 'wl', $dbr ),
  616. 'wl_user' => $user->getId(),
  617. ],
  618. __METHOD__
  619. );
  620. foreach ( $res as $row ) {
  621. $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
  622. $timestamps[$row->wl_namespace][$row->wl_title] =
  623. $this->getLatestNotificationTimestamp(
  624. $row->wl_notificationtimestamp, $user, $target );
  625. }
  626. return $timestamps;
  627. }
  628. /**
  629. * @since 1.27
  630. * @param UserIdentity $user
  631. * @param LinkTarget $target
  632. */
  633. public function addWatch( UserIdentity $user, LinkTarget $target ) {
  634. $this->addWatchBatchForUser( $user, [ $target ] );
  635. }
  636. /**
  637. * @since 1.27
  638. * @param UserIdentity $user
  639. * @param LinkTarget[] $targets
  640. * @return bool
  641. */
  642. public function addWatchBatchForUser( UserIdentity $user, array $targets ) {
  643. if ( $this->readOnlyMode->isReadOnly() ) {
  644. return false;
  645. }
  646. // Only registered user can have a watchlist
  647. if ( !$user->isRegistered() ) {
  648. return false;
  649. }
  650. if ( !$targets ) {
  651. return true;
  652. }
  653. $rows = [];
  654. $items = [];
  655. foreach ( $targets as $target ) {
  656. $rows[] = [
  657. 'wl_user' => $user->getId(),
  658. 'wl_namespace' => $target->getNamespace(),
  659. 'wl_title' => $target->getDBkey(),
  660. 'wl_notificationtimestamp' => null,
  661. ];
  662. $items[] = new WatchedItem(
  663. $user,
  664. $target,
  665. null
  666. );
  667. $this->uncache( $user, $target );
  668. }
  669. $dbw = $this->getConnectionRef( DB_MASTER );
  670. $ticket = count( $targets ) > $this->updateRowsPerQuery ?
  671. $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
  672. $affectedRows = 0;
  673. $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery );
  674. foreach ( $rowBatches as $toInsert ) {
  675. // Use INSERT IGNORE to avoid overwriting the notification timestamp
  676. // if there's already an entry for this page
  677. $dbw->insert( 'watchlist', $toInsert, __METHOD__, [ 'IGNORE' ] );
  678. $affectedRows += $dbw->affectedRows();
  679. if ( $ticket ) {
  680. $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
  681. }
  682. }
  683. // Update process cache to ensure skin doesn't claim that the current
  684. // page is unwatched in the response of action=watch itself (T28292).
  685. // This would otherwise be re-queried from a replica by isWatched().
  686. foreach ( $items as $item ) {
  687. $this->cache( $item );
  688. }
  689. return (bool)$affectedRows;
  690. }
  691. /**
  692. * @since 1.27
  693. * @param UserIdentity $user
  694. * @param LinkTarget $target
  695. * @return bool
  696. */
  697. public function removeWatch( UserIdentity $user, LinkTarget $target ) {
  698. return $this->removeWatchBatchForUser( $user, [ $target ] );
  699. }
  700. /**
  701. * Set the "last viewed" timestamps for certain titles on a user's watchlist.
  702. *
  703. * If the $targets parameter is omitted or set to [], this method simply wraps
  704. * resetAllNotificationTimestampsForUser(), and in that case you should instead call that method
  705. * directly; support for omitting $targets is for backwards compatibility.
  706. *
  707. * If $targets is omitted or set to [], timestamps will be updated for every title on the user's
  708. * watchlist, and this will be done through a DeferredUpdate. If $targets is a non-empty array,
  709. * only the specified titles will be updated, and this will be done immediately (not deferred).
  710. *
  711. * @since 1.27
  712. * @param UserIdentity $user
  713. * @param string|int $timestamp Value to set the "last viewed" timestamp to (null to clear)
  714. * @param LinkTarget[] $targets Titles to set the timestamp for; [] means the entire watchlist
  715. * @return bool
  716. */
  717. public function setNotificationTimestampsForUser(
  718. UserIdentity $user, $timestamp, array $targets = []
  719. ) {
  720. // Only registered user can have a watchlist
  721. if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
  722. return false;
  723. }
  724. if ( !$targets ) {
  725. // Backwards compatibility
  726. $this->resetAllNotificationTimestampsForUser( $user, $timestamp );
  727. return true;
  728. }
  729. $rows = $this->getTitleDbKeysGroupedByNamespace( $targets );
  730. $dbw = $this->getConnectionRef( DB_MASTER );
  731. if ( $timestamp !== null ) {
  732. $timestamp = $dbw->timestamp( $timestamp );
  733. }
  734. $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
  735. $affectedSinceWait = 0;
  736. // Batch update items per namespace
  737. foreach ( $rows as $namespace => $namespaceTitles ) {
  738. $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
  739. foreach ( $rowBatches as $toUpdate ) {
  740. $dbw->update(
  741. 'watchlist',
  742. [ 'wl_notificationtimestamp' => $timestamp ],
  743. [
  744. 'wl_user' => $user->getId(),
  745. 'wl_namespace' => $namespace,
  746. 'wl_title' => $toUpdate
  747. ]
  748. );
  749. $affectedSinceWait += $dbw->affectedRows();
  750. // Wait for replication every time we've touched updateRowsPerQuery rows
  751. if ( $affectedSinceWait >= $this->updateRowsPerQuery ) {
  752. $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
  753. $affectedSinceWait = 0;
  754. }
  755. }
  756. }
  757. $this->uncacheUser( $user );
  758. return true;
  759. }
  760. public function getLatestNotificationTimestamp(
  761. $timestamp, UserIdentity $user, LinkTarget $target
  762. ) {
  763. $timestamp = wfTimestampOrNull( TS_MW, $timestamp );
  764. if ( $timestamp === null ) {
  765. return null; // no notification
  766. }
  767. $seenTimestamps = $this->getPageSeenTimestamps( $user );
  768. if (
  769. $seenTimestamps &&
  770. $seenTimestamps->get( $this->getPageSeenKey( $target ) ) >= $timestamp
  771. ) {
  772. // If a reset job did not yet run, then the "seen" timestamp will be higher
  773. return null;
  774. }
  775. return $timestamp;
  776. }
  777. /**
  778. * Schedule a DeferredUpdate that sets all of the "last viewed" timestamps for a given user
  779. * to the same value.
  780. * @param UserIdentity $user
  781. * @param string|int|null $timestamp Value to set all timestamps to, null to clear them
  782. */
  783. public function resetAllNotificationTimestampsForUser( UserIdentity $user, $timestamp = null ) {
  784. // Only registered user can have a watchlist
  785. if ( !$user->isRegistered() ) {
  786. return;
  787. }
  788. // If the page is watched by the user (or may be watched), update the timestamp
  789. $job = new ClearWatchlistNotificationsJob( [
  790. 'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
  791. ] );
  792. // Try to run this post-send
  793. // Calls DeferredUpdates::addCallableUpdate in normal operation
  794. call_user_func(
  795. $this->deferredUpdatesAddCallableUpdateCallback,
  796. function () use ( $job ) {
  797. $job->run();
  798. }
  799. );
  800. }
  801. /**
  802. * @since 1.27
  803. * @param UserIdentity $editor
  804. * @param LinkTarget $target
  805. * @param string|int $timestamp
  806. * @return int[]
  807. */
  808. public function updateNotificationTimestamp(
  809. UserIdentity $editor, LinkTarget $target, $timestamp
  810. ) {
  811. $dbw = $this->getConnectionRef( DB_MASTER );
  812. $uids = $dbw->selectFieldValues(
  813. 'watchlist',
  814. 'wl_user',
  815. [
  816. 'wl_user != ' . intval( $editor->getId() ),
  817. 'wl_namespace' => $target->getNamespace(),
  818. 'wl_title' => $target->getDBkey(),
  819. 'wl_notificationtimestamp IS NULL',
  820. ],
  821. __METHOD__
  822. );
  823. $watchers = array_map( 'intval', $uids );
  824. if ( $watchers ) {
  825. // Update wl_notificationtimestamp for all watching users except the editor
  826. $fname = __METHOD__;
  827. DeferredUpdates::addCallableUpdate(
  828. function () use ( $timestamp, $watchers, $target, $fname ) {
  829. $dbw = $this->getConnectionRef( DB_MASTER );
  830. $ticket = $this->lbFactory->getEmptyTransactionTicket( $fname );
  831. $watchersChunks = array_chunk( $watchers, $this->updateRowsPerQuery );
  832. foreach ( $watchersChunks as $watchersChunk ) {
  833. $dbw->update( 'watchlist',
  834. [ /* SET */
  835. 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
  836. ], [ /* WHERE - TODO Use wl_id T130067 */
  837. 'wl_user' => $watchersChunk,
  838. 'wl_namespace' => $target->getNamespace(),
  839. 'wl_title' => $target->getDBkey(),
  840. ], $fname
  841. );
  842. if ( count( $watchersChunks ) > 1 ) {
  843. $this->lbFactory->commitAndWaitForReplication(
  844. $fname, $ticket, [ 'domain' => $dbw->getDomainID() ]
  845. );
  846. }
  847. }
  848. $this->uncacheLinkTarget( $target );
  849. },
  850. DeferredUpdates::POSTSEND,
  851. $dbw
  852. );
  853. }
  854. return $watchers;
  855. }
  856. /**
  857. * @since 1.27
  858. * @param UserIdentity $user
  859. * @param LinkTarget $title
  860. * @param string $force
  861. * @param int $oldid
  862. * @return bool
  863. */
  864. public function resetNotificationTimestamp(
  865. UserIdentity $user, LinkTarget $title, $force = '', $oldid = 0
  866. ) {
  867. $time = time();
  868. // Only registered user can have a watchlist
  869. if ( $this->readOnlyMode->isReadOnly() || !$user->isRegistered() ) {
  870. return false;
  871. }
  872. // Hook expects User and Title, not UserIdentity and LinkTarget
  873. $userObj = User::newFromId( $user->getId() );
  874. $titleObj = Title::castFromLinkTarget( $title );
  875. if ( !Hooks::run( 'BeforeResetNotificationTimestamp',
  876. [ &$userObj, &$titleObj, $force, &$oldid ] )
  877. ) {
  878. return false;
  879. }
  880. if ( !$userObj->equals( $user ) ) {
  881. $user = $userObj;
  882. }
  883. if ( !$titleObj->equals( $title ) ) {
  884. $title = $titleObj;
  885. }
  886. $item = null;
  887. if ( $force != 'force' ) {
  888. $item = $this->loadWatchedItem( $user, $title );
  889. if ( !$item || $item->getNotificationTimestamp() === null ) {
  890. return false;
  891. }
  892. }
  893. // Get the timestamp (TS_MW) of this revision to track the latest one seen
  894. $id = $oldid;
  895. $seenTime = null;
  896. if ( !$id ) {
  897. $latestRev = $this->revisionLookup->getRevisionByTitle( $title );
  898. if ( $latestRev ) {
  899. $id = $latestRev->getId();
  900. // Save a DB query
  901. $seenTime = $latestRev->getTimestamp();
  902. }
  903. }
  904. if ( $seenTime === null ) {
  905. $seenTime = $this->revisionLookup->getTimestampFromId( $id );
  906. }
  907. // Mark the item as read immediately in lightweight storage
  908. $this->stash->merge(
  909. $this->getPageSeenTimestampsKey( $user ),
  910. function ( $cache, $key, $current ) use ( $title, $seenTime ) {
  911. $value = $current ?: new MapCacheLRU( 300 );
  912. $subKey = $this->getPageSeenKey( $title );
  913. if ( $seenTime > $value->get( $subKey ) ) {
  914. // Revision is newer than the last one seen
  915. $value->set( $subKey, $seenTime );
  916. $this->latestUpdateCache->set( $key, $value, BagOStuff::TTL_PROC_LONG );
  917. } elseif ( $seenTime === false ) {
  918. // Revision does not exist
  919. $value->set( $subKey, wfTimestamp( TS_MW ) );
  920. $this->latestUpdateCache->set( $key, $value, BagOStuff::TTL_PROC_LONG );
  921. } else {
  922. return false; // nothing to update
  923. }
  924. return $value;
  925. },
  926. BagOStuff::TTL_HOUR
  927. );
  928. // If the page is watched by the user (or may be watched), update the timestamp
  929. $job = new ActivityUpdateJob(
  930. $title,
  931. [
  932. 'type' => 'updateWatchlistNotification',
  933. 'userid' => $user->getId(),
  934. 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
  935. 'curTime' => $time
  936. ]
  937. );
  938. // Try to enqueue this post-send
  939. $this->queueGroup->lazyPush( $job );
  940. $this->uncache( $user, $title );
  941. return true;
  942. }
  943. /**
  944. * @param UserIdentity $user
  945. * @return MapCacheLRU|null The map contains prefixed title keys and TS_MW values
  946. */
  947. private function getPageSeenTimestamps( UserIdentity $user ) {
  948. $key = $this->getPageSeenTimestampsKey( $user );
  949. return $this->latestUpdateCache->getWithSetCallback(
  950. $key,
  951. BagOStuff::TTL_PROC_LONG,
  952. function () use ( $key ) {
  953. return $this->stash->get( $key ) ?: null;
  954. }
  955. );
  956. }
  957. /**
  958. * @param UserIdentity $user
  959. * @return string
  960. */
  961. private function getPageSeenTimestampsKey( UserIdentity $user ) {
  962. return $this->stash->makeGlobalKey(
  963. 'watchlist-recent-updates',
  964. $this->lbFactory->getLocalDomainID(),
  965. $user->getId()
  966. );
  967. }
  968. /**
  969. * @param LinkTarget $target
  970. * @return string
  971. */
  972. private function getPageSeenKey( LinkTarget $target ) {
  973. return "{$target->getNamespace()}:{$target->getDBkey()}";
  974. }
  975. /**
  976. * @param UserIdentity $user
  977. * @param LinkTarget $title
  978. * @param WatchedItem $item
  979. * @param bool $force
  980. * @param int|bool $oldid The ID of the last revision that the user viewed
  981. * @return bool|string|null
  982. */
  983. private function getNotificationTimestamp(
  984. UserIdentity $user, LinkTarget $title, $item, $force, $oldid
  985. ) {
  986. if ( !$oldid ) {
  987. // No oldid given, assuming latest revision; clear the timestamp.
  988. return null;
  989. }
  990. $oldRev = $this->revisionLookup->getRevisionById( $oldid );
  991. if ( !$oldRev ) {
  992. // Oldid given but does not exist (probably deleted)
  993. return false;
  994. }
  995. $nextRev = $this->revisionLookup->getNextRevision( $oldRev );
  996. if ( !$nextRev ) {
  997. // Oldid given and is the latest revision for this title; clear the timestamp.
  998. return null;
  999. }
  1000. if ( $item === null ) {
  1001. $item = $this->loadWatchedItem( $user, $title );
  1002. }
  1003. if ( !$item ) {
  1004. // This can only happen if $force is enabled.
  1005. return null;
  1006. }
  1007. // Oldid given and isn't the latest; update the timestamp.
  1008. // This will result in no further notification emails being sent!
  1009. $notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid );
  1010. // @FIXME: this should use getTimestamp() for consistency with updates on new edits
  1011. // $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
  1012. // We need to go one second to the future because of various strict comparisons
  1013. // throughout the codebase
  1014. $ts = new MWTimestamp( $notificationTimestamp );
  1015. $ts->timestamp->add( new DateInterval( 'PT1S' ) );
  1016. $notificationTimestamp = $ts->getTimestamp( TS_MW );
  1017. if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
  1018. if ( $force != 'force' ) {
  1019. return false;
  1020. } else {
  1021. // This is a little silly…
  1022. return $item->getNotificationTimestamp();
  1023. }
  1024. }
  1025. return $notificationTimestamp;
  1026. }
  1027. /**
  1028. * @since 1.27
  1029. * @param UserIdentity $user
  1030. * @param int|null $unreadLimit
  1031. * @return int|bool
  1032. */
  1033. public function countUnreadNotifications( UserIdentity $user, $unreadLimit = null ) {
  1034. $dbr = $this->getConnectionRef( DB_REPLICA );
  1035. $queryOptions = [];
  1036. if ( $unreadLimit !== null ) {
  1037. $unreadLimit = (int)$unreadLimit;
  1038. $queryOptions['LIMIT'] = $unreadLimit;
  1039. }
  1040. $conds = [
  1041. 'wl_user' => $user->getId(),
  1042. 'wl_notificationtimestamp IS NOT NULL'
  1043. ];
  1044. $rowCount = $dbr->selectRowCount( 'watchlist', '1', $conds, __METHOD__, $queryOptions );
  1045. if ( $unreadLimit === null ) {
  1046. return $rowCount;
  1047. }
  1048. if ( $rowCount >= $unreadLimit ) {
  1049. return true;
  1050. }
  1051. return $rowCount;
  1052. }
  1053. /**
  1054. * @since 1.27
  1055. * @param LinkTarget $oldTarget
  1056. * @param LinkTarget $newTarget
  1057. */
  1058. public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
  1059. // Duplicate first the subject page, then the talk page
  1060. $this->duplicateEntry(
  1061. $this->nsInfo->getSubjectPage( $oldTarget ),
  1062. $this->nsInfo->getSubjectPage( $newTarget )
  1063. );
  1064. $this->duplicateEntry(
  1065. $this->nsInfo->getTalkPage( $oldTarget ),
  1066. $this->nsInfo->getTalkPage( $newTarget )
  1067. );
  1068. }
  1069. /**
  1070. * @since 1.27
  1071. * @param LinkTarget $oldTarget
  1072. * @param LinkTarget $newTarget
  1073. */
  1074. public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
  1075. $dbw = $this->getConnectionRef( DB_MASTER );
  1076. $result = $dbw->select(
  1077. 'watchlist',
  1078. [ 'wl_user', 'wl_notificationtimestamp' ],
  1079. [
  1080. 'wl_namespace' => $oldTarget->getNamespace(),
  1081. 'wl_title' => $oldTarget->getDBkey(),
  1082. ],
  1083. __METHOD__,
  1084. [ 'FOR UPDATE' ]
  1085. );
  1086. $newNamespace = $newTarget->getNamespace();
  1087. $newDBkey = $newTarget->getDBkey();
  1088. # Construct array to replace into the watchlist
  1089. $values = [];
  1090. foreach ( $result as $row ) {
  1091. $values[] = [
  1092. 'wl_user' => $row->wl_user,
  1093. 'wl_namespace' => $newNamespace,
  1094. 'wl_title' => $newDBkey,
  1095. 'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
  1096. ];
  1097. }
  1098. if ( !empty( $values ) ) {
  1099. # Perform replace
  1100. # Note that multi-row replace is very efficient for MySQL but may be inefficient for
  1101. # some other DBMSes, mostly due to poor simulation by us
  1102. $dbw->replace(
  1103. 'watchlist',
  1104. [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
  1105. $values,
  1106. __METHOD__
  1107. );
  1108. }
  1109. }
  1110. /**
  1111. * @param LinkTarget[] $titles
  1112. * @return array
  1113. */
  1114. private function getTitleDbKeysGroupedByNamespace( array $titles ) {
  1115. $rows = [];
  1116. foreach ( $titles as $title ) {
  1117. // Group titles by namespace.
  1118. $rows[ $title->getNamespace() ][] = $title->getDBkey();
  1119. }
  1120. return $rows;
  1121. }
  1122. /**
  1123. * @param UserIdentity $user
  1124. * @param LinkTarget[] $titles
  1125. */
  1126. private function uncacheTitlesForUser( UserIdentity $user, array $titles ) {
  1127. foreach ( $titles as $title ) {
  1128. $this->uncache( $user, $title );
  1129. }
  1130. }
  1131. }