Page Menu
Home
WickedGov Phorge
Search
Configure Global Search
Log In
Files
F5958841
WatchedItemStore.php
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
53 KB
Referenced Files
None
Subscribers
None
WatchedItemStore.php
View Options
<?php
namespace
MediaWiki\Watchlist
;
use
DateInterval
;
use
JobQueueGroup
;
use
LogicException
;
use
MapCacheLRU
;
use
MediaWiki\Cache\LinkBatchFactory
;
use
MediaWiki\Config\ServiceOptions
;
use
MediaWiki\Deferred\DeferredUpdates
;
use
MediaWiki\Linker\LinkTarget
;
use
MediaWiki\MainConfigNames
;
use
MediaWiki\Page\PageIdentity
;
use
MediaWiki\Revision\RevisionLookup
;
use
MediaWiki\Title\NamespaceInfo
;
use
MediaWiki\Title\TitleValue
;
use
MediaWiki\User\UserIdentity
;
use
MediaWiki\Utils\MWTimestamp
;
use
stdClass
;
use
Wikimedia\Assert\Assert
;
use
Wikimedia\ObjectCache\BagOStuff
;
use
Wikimedia\ObjectCache\HashBagOStuff
;
use
Wikimedia\ParamValidator\TypeDef\ExpiryDef
;
use
Wikimedia\Rdbms\IDatabase
;
use
Wikimedia\Rdbms\ILBFactory
;
use
Wikimedia\Rdbms\IReadableDatabase
;
use
Wikimedia\Rdbms\IResultWrapper
;
use
Wikimedia\Rdbms\ReadOnlyMode
;
use
Wikimedia\Rdbms\SelectQueryBuilder
;
use
Wikimedia\ScopedCallback
;
use
Wikimedia\Stats\StatsFactory
;
/**
* Storage layer class for WatchedItems.
* Database interaction & caching
* TODO caching should be factored out into a CachingWatchedItemStore class
*
* @author Addshore
* @since 1.27
*/
class
WatchedItemStore
implements
WatchedItemStoreInterface
{
/**
* @internal For use by ServiceWiring
*/
public
const
CONSTRUCTOR_OPTIONS
=
[
MainConfigNames
::
UpdateRowsPerQuery
,
MainConfigNames
::
WatchlistExpiry
,
MainConfigNames
::
WatchlistExpiryMaxDuration
,
MainConfigNames
::
WatchlistPurgeRate
,
];
/**
* @var ILBFactory
*/
private
$lbFactory
;
/**
* @var JobQueueGroup
*/
private
$queueGroup
;
/**
* @var BagOStuff
*/
private
$stash
;
/**
* @var ReadOnlyMode
*/
private
$readOnlyMode
;
/**
* @var HashBagOStuff
*/
private
$cache
;
/**
* @var HashBagOStuff
*/
private
$latestUpdateCache
;
/**
* @var array[][] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
* The index is needed so that on mass changes all relevant items can be un-cached.
* For example: Clearing a users watchlist of all items or updating notification timestamps
* for all users watching a single target.
* @phan-var array<int,array<string,array<int,string>>>
*/
private
$cacheIndex
=
[];
/**
* @var callable|null
*/
private
$deferredUpdatesAddCallableUpdateCallback
;
/**
* @var int
*/
private
$updateRowsPerQuery
;
/**
* @var NamespaceInfo
*/
private
$nsInfo
;
/**
* @var RevisionLookup
*/
private
$revisionLookup
;
/**
* @var bool Correlates to $wgWatchlistExpiry feature flag.
*/
private
$expiryEnabled
;
/**
* @var LinkBatchFactory
*/
private
$linkBatchFactory
;
/** @var StatsFactory */
private
$statsFactory
;
/**
* @var string|null Maximum configured relative expiry.
*/
private
$maxExpiryDuration
;
/** @var float corresponds to $wgWatchlistPurgeRate value */
private
$watchlistPurgeRate
;
/**
* @param ServiceOptions $options
* @param ILBFactory $lbFactory
* @param JobQueueGroup $queueGroup
* @param BagOStuff $stash
* @param HashBagOStuff $cache
* @param ReadOnlyMode $readOnlyMode
* @param NamespaceInfo $nsInfo
* @param RevisionLookup $revisionLookup
* @param LinkBatchFactory $linkBatchFactory
* @param StatsFactory $statsFactory
*/
public
function
__construct
(
ServiceOptions
$options
,
ILBFactory
$lbFactory
,
JobQueueGroup
$queueGroup
,
BagOStuff
$stash
,
HashBagOStuff
$cache
,
ReadOnlyMode
$readOnlyMode
,
NamespaceInfo
$nsInfo
,
RevisionLookup
$revisionLookup
,
LinkBatchFactory
$linkBatchFactory
,
StatsFactory
$statsFactory
)
{
$options
->
assertRequiredOptions
(
self
::
CONSTRUCTOR_OPTIONS
);
$this
->
updateRowsPerQuery
=
$options
->
get
(
MainConfigNames
::
UpdateRowsPerQuery
);
$this
->
expiryEnabled
=
$options
->
get
(
MainConfigNames
::
WatchlistExpiry
);
$this
->
maxExpiryDuration
=
$options
->
get
(
MainConfigNames
::
WatchlistExpiryMaxDuration
);
$this
->
watchlistPurgeRate
=
$options
->
get
(
MainConfigNames
::
WatchlistPurgeRate
);
$this
->
lbFactory
=
$lbFactory
;
$this
->
queueGroup
=
$queueGroup
;
$this
->
stash
=
$stash
;
$this
->
cache
=
$cache
;
$this
->
readOnlyMode
=
$readOnlyMode
;
$this
->
deferredUpdatesAddCallableUpdateCallback
=
[
DeferredUpdates
::
class
,
'addCallableUpdate'
];
$this
->
nsInfo
=
$nsInfo
;
$this
->
revisionLookup
=
$revisionLookup
;
$this
->
linkBatchFactory
=
$linkBatchFactory
;
$this
->
statsFactory
=
$statsFactory
;
$this
->
latestUpdateCache
=
new
HashBagOStuff
(
[
'maxKeys'
=>
3
]
);
}
/**
* Overrides the DeferredUpdates::addCallableUpdate callback
* This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
*
* @param callable $callback
*
* @see DeferredUpdates::addCallableUpdate for callback signiture
*
* @return ScopedCallback to reset the overridden value
*/
public
function
overrideDeferredUpdatesAddCallableUpdateCallback
(
callable
$callback
)
{
if
(
!
defined
(
'MW_PHPUNIT_TEST'
)
)
{
throw
new
LogicException
(
'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
);
}
$previousValue
=
$this
->
deferredUpdatesAddCallableUpdateCallback
;
$this
->
deferredUpdatesAddCallableUpdateCallback
=
$callback
;
return
new
ScopedCallback
(
function
()
use
(
$previousValue
)
{
$this
->
deferredUpdatesAddCallableUpdateCallback
=
$previousValue
;
}
);
}
/**
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target
* @return string
*/
private
function
getCacheKey
(
UserIdentity
$user
,
$target
):
string
{
return
$this
->
cache
->
makeKey
(
(
string
)
$target
->
getNamespace
(),
$target
->
getDBkey
(),
(
string
)
$user
->
getId
()
);
}
/**
* @param WatchedItem $item
*/
private
function
cache
(
WatchedItem
$item
)
{
$user
=
$item
->
getUserIdentity
();
$target
=
$item
->
getTarget
();
$key
=
$this
->
getCacheKey
(
$user
,
$target
);
$this
->
cache
->
set
(
$key
,
$item
);
$this
->
cacheIndex
[
$target
->
getNamespace
()][
$target
->
getDBkey
()][
$user
->
getId
()]
=
$key
;
$this
->
statsFactory
->
getCounter
(
'WatchedItemStore_cache_total'
)
->
copyToStatsdAt
(
'WatchedItemStore.cache'
)
->
increment
();
}
/**
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target
*/
private
function
uncache
(
UserIdentity
$user
,
$target
)
{
$this
->
cache
->
delete
(
$this
->
getCacheKey
(
$user
,
$target
)
);
unset
(
$this
->
cacheIndex
[
$target
->
getNamespace
()][
$target
->
getDBkey
()][
$user
->
getId
()]
);
$this
->
statsFactory
->
getCounter
(
'WatchedItemStore_uncache_total'
)
->
copyToStatsdAt
(
'WatchedItemStore.uncache'
)
->
increment
();
}
/**
* @param LinkTarget|PageIdentity $target
*/
private
function
uncacheLinkTarget
(
$target
)
{
$this
->
statsFactory
->
getCounter
(
'WatchedItemStore_uncacheLinkTarget_total'
)
->
copyToStatsdAt
(
'WatchedItemStore.uncacheLinkTarget'
)
->
increment
();
if
(
!
isset
(
$this
->
cacheIndex
[
$target
->
getNamespace
()][
$target
->
getDBkey
()]
)
)
{
return
;
}
$uncacheLinkTargetItemsTotal
=
$this
->
statsFactory
->
getCounter
(
'WatchedItemStore_uncacheLinkTarget_items_total'
)
->
copyToStatsdAt
(
'WatchedItemStore.uncacheLinkTarget.items'
);
foreach
(
$this
->
cacheIndex
[
$target
->
getNamespace
()][
$target
->
getDBkey
()]
as
$key
)
{
$uncacheLinkTargetItemsTotal
->
increment
();
$this
->
cache
->
delete
(
$key
);
}
}
/**
* @param UserIdentity $user
*/
private
function
uncacheUser
(
UserIdentity
$user
)
{
$this
->
statsFactory
->
getCounter
(
'WatchedItemStore_uncacheUser_total'
)
->
copyToStatsdAt
(
'WatchedItemStore.uncacheUser'
)
->
increment
();
$uncacheUserItemsTotal
=
$this
->
statsFactory
->
getCounter
(
'WatchedItemStore_uncacheUser_items_total'
)
->
copyToStatsdAt
(
'WatchedItemStore.uncacheUser.items'
);
foreach
(
$this
->
cacheIndex
as
$dbKeyArray
)
{
foreach
(
$dbKeyArray
as
$userArray
)
{
if
(
isset
(
$userArray
[
$user
->
getId
()]
)
)
{
$uncacheUserItemsTotal
->
increment
();
$this
->
cache
->
delete
(
$userArray
[
$user
->
getId
()]
);
}
}
}
$pageSeenKey
=
$this
->
getPageSeenTimestampsKey
(
$user
);
$this
->
latestUpdateCache
->
delete
(
$pageSeenKey
);
$this
->
stash
->
delete
(
$pageSeenKey
);
}
/**
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target
*
* @return WatchedItem|false
*/
private
function
getCached
(
UserIdentity
$user
,
$target
)
{
return
$this
->
cache
->
get
(
$this
->
getCacheKey
(
$user
,
$target
)
);
}
/**
* Helper method to deduplicate logic around queries that need to be modified
* if watchlist expiration is enabled
*
* @param SelectQueryBuilder $queryBuilder
* @param IReadableDatabase $db
*/
private
function
modifyQueryBuilderForExpiry
(
SelectQueryBuilder
$queryBuilder
,
IReadableDatabase
$db
)
{
if
(
$this
->
expiryEnabled
)
{
$queryBuilder
->
where
(
$db
->
expr
(
'we_expiry'
,
'='
,
null
)->
or
(
'we_expiry'
,
'>'
,
$db
->
timestamp
()
)
);
$queryBuilder
->
leftJoin
(
'watchlist_expiry'
,
null
,
'wl_id = we_item'
);
}
}
/**
* Deletes ALL watched items for the given user when under
* $updateRowsPerQuery entries exist.
*
* @since 1.30
*
* @param UserIdentity $user
*
* @return bool true on success, false when too many items are watched
*/
public
function
clearUserWatchedItems
(
UserIdentity
$user
):
bool
{
if
(
$this
->
mustClearWatchedItemsUsingJobQueue
(
$user
)
)
{
return
false
;
}
$dbw
=
$this
->
lbFactory
->
getPrimaryDatabase
();
if
(
$this
->
expiryEnabled
)
{
$ticket
=
$this
->
lbFactory
->
getEmptyTransactionTicket
(
__METHOD__
);
// First fetch the wl_ids.
$wlIds
=
$dbw
->
newSelectQueryBuilder
()
->
select
(
'wl_id'
)
->
from
(
'watchlist'
)
->
where
(
[
'wl_user'
=>
$user
->
getId
()
]
)
->
caller
(
__METHOD__
)
->
fetchFieldValues
();
if
(
$wlIds
)
{
// Delete rows from both the watchlist and watchlist_expiry tables.
$dbw
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'watchlist'
)
->
where
(
[
'wl_id'
=>
$wlIds
]
)
->
caller
(
__METHOD__
)->
execute
();
$dbw
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'watchlist_expiry'
)
->
where
(
[
'we_item'
=>
$wlIds
]
)
->
caller
(
__METHOD__
)->
execute
();
}
$this
->
lbFactory
->
commitAndWaitForReplication
(
__METHOD__
,
$ticket
);
}
else
{
$dbw
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'watchlist'
)
->
where
(
[
'wl_user'
=>
$user
->
getId
()
]
)
->
caller
(
__METHOD__
)->
execute
();
}
$this
->
uncacheAllItemsForUser
(
$user
);
return
true
;
}
/**
* @param UserIdentity $user
* @return bool
*/
public
function
mustClearWatchedItemsUsingJobQueue
(
UserIdentity
$user
):
bool
{
return
$this
->
countWatchedItems
(
$user
)
>
$this
->
updateRowsPerQuery
;
}
/**
* @param UserIdentity $user
*/
private
function
uncacheAllItemsForUser
(
UserIdentity
$user
)
{
$userId
=
$user
->
getId
();
foreach
(
$this
->
cacheIndex
as
$ns
=>
$dbKeyIndex
)
{
foreach
(
$dbKeyIndex
as
$dbKey
=>
$userIndex
)
{
if
(
array_key_exists
(
$userId
,
$userIndex
)
)
{
$this
->
cache
->
delete
(
$userIndex
[
$userId
]
);
unset
(
$this
->
cacheIndex
[
$ns
][
$dbKey
][
$userId
]
);
}
}
}
// Cleanup empty cache keys
foreach
(
$this
->
cacheIndex
as
$ns
=>
$dbKeyIndex
)
{
foreach
(
$dbKeyIndex
as
$dbKey
=>
$userIndex
)
{
if
(
empty
(
$this
->
cacheIndex
[
$ns
][
$dbKey
]
)
)
{
unset
(
$this
->
cacheIndex
[
$ns
][
$dbKey
]
);
}
}
if
(
empty
(
$this
->
cacheIndex
[
$ns
]
)
)
{
unset
(
$this
->
cacheIndex
[
$ns
]
);
}
}
}
/**
* Queues a job that will clear the users watchlist using the Job Queue.
*
* @since 1.31
*
* @param UserIdentity $user
*/
public
function
clearUserWatchedItemsUsingJobQueue
(
UserIdentity
$user
)
{
$job
=
ClearUserWatchlistJob
::
newForUser
(
$user
,
$this
->
getMaxId
()
);
$this
->
queueGroup
->
push
(
$job
);
}
/**
* @inheritDoc
*/
public
function
maybeEnqueueWatchlistExpiryJob
():
void
{
if
(
!
$this
->
expiryEnabled
)
{
// No need to purge expired entries if there are none
return
;
}
$max
=
mt_getrandmax
();
if
(
mt_rand
(
0
,
$max
)
<
$max
*
$this
->
watchlistPurgeRate
)
{
// The higher the watchlist purge rate, the more likely we are to enqueue a job.
$this
->
queueGroup
->
lazyPush
(
new
WatchlistExpiryJob
()
);
}
}
/**
* @since 1.31
* @return int The maximum current wl_id
*/
public
function
getMaxId
():
int
{
return
(
int
)
$this
->
lbFactory
->
getReplicaDatabase
()->
newSelectQueryBuilder
()
->
select
(
'MAX(wl_id)'
)
->
from
(
'watchlist'
)
->
caller
(
__METHOD__
)
->
fetchField
();
}
/**
* @since 1.31
* @param UserIdentity $user
* @return int
*/
public
function
countWatchedItems
(
UserIdentity
$user
):
int
{
$dbr
=
$this
->
lbFactory
->
getReplicaDatabase
();
$queryBuilder
=
$dbr
->
newSelectQueryBuilder
()
->
select
(
'COUNT(*)'
)
->
from
(
'watchlist'
)
->
where
(
[
'wl_user'
=>
$user
->
getId
()
]
)
->
caller
(
__METHOD__
);
$this
->
modifyQueryBuilderForExpiry
(
$queryBuilder
,
$dbr
);
return
(
int
)
$queryBuilder
->
fetchField
();
}
/**
* @since 1.27
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return int
*/
public
function
countWatchers
(
$target
):
int
{
$dbr
=
$this
->
lbFactory
->
getReplicaDatabase
();
$queryBuilder
=
$dbr
->
newSelectQueryBuilder
()
->
select
(
'COUNT(*)'
)
->
from
(
'watchlist'
)
->
where
(
[
'wl_namespace'
=>
$target
->
getNamespace
(),
'wl_title'
=>
$target
->
getDBkey
()
]
)
->
caller
(
__METHOD__
);
$this
->
modifyQueryBuilderForExpiry
(
$queryBuilder
,
$dbr
);
return
(
int
)
$queryBuilder
->
fetchField
();
}
/**
* @since 1.27
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @param string|int $threshold
* @return int
*/
public
function
countVisitingWatchers
(
$target
,
$threshold
):
int
{
$dbr
=
$this
->
lbFactory
->
getReplicaDatabase
();
$queryBuilder
=
$dbr
->
newSelectQueryBuilder
()
->
select
(
'COUNT(*)'
)
->
from
(
'watchlist'
)
->
where
(
[
'wl_namespace'
=>
$target
->
getNamespace
(),
'wl_title'
=>
$target
->
getDBkey
(),
$dbr
->
expr
(
'wl_notificationtimestamp'
,
'>='
,
$dbr
->
timestamp
(
$threshold
)
)
->
or
(
'wl_notificationtimestamp'
,
'='
,
null
)
]
)
->
caller
(
__METHOD__
);
$this
->
modifyQueryBuilderForExpiry
(
$queryBuilder
,
$dbr
);
return
(
int
)
$queryBuilder
->
fetchField
();
}
/**
* @param UserIdentity $user
* @param LinkTarget[]|PageIdentity[] $titles deprecated passing LinkTarget[] since 1.36
* @return bool
*/
public
function
removeWatchBatchForUser
(
UserIdentity
$user
,
array
$titles
):
bool
{
if
(
!
$user
->
isRegistered
()
||
$this
->
readOnlyMode
->
isReadOnly
()
)
{
return
false
;
}
if
(
!
$titles
)
{
return
true
;
}
$rows
=
$this
->
getTitleDbKeysGroupedByNamespace
(
$titles
);
$this
->
uncacheTitlesForUser
(
$user
,
$titles
);
$dbw
=
$this
->
lbFactory
->
getPrimaryDatabase
();
$ticket
=
count
(
$titles
)
>
$this
->
updateRowsPerQuery
?
$this
->
lbFactory
->
getEmptyTransactionTicket
(
__METHOD__
)
:
null
;
$affectedRows
=
0
;
// Batch delete items per namespace.
foreach
(
$rows
as
$namespace
=>
$namespaceTitles
)
{
$rowBatches
=
array_chunk
(
$namespaceTitles
,
$this
->
updateRowsPerQuery
);
foreach
(
$rowBatches
as
$toDelete
)
{
// First fetch the wl_ids.
$wlIds
=
$dbw
->
newSelectQueryBuilder
()
->
select
(
'wl_id'
)
->
from
(
'watchlist'
)
->
where
(
[
'wl_user'
=>
$user
->
getId
(),
'wl_namespace'
=>
$namespace
,
'wl_title'
=>
$toDelete
]
)
->
caller
(
__METHOD__
)
->
fetchFieldValues
();
if
(
$wlIds
)
{
// Delete rows from both the watchlist and watchlist_expiry tables.
$dbw
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'watchlist'
)
->
where
(
[
'wl_id'
=>
$wlIds
]
)
->
caller
(
__METHOD__
)->
execute
();
$affectedRows
+=
$dbw
->
affectedRows
();
if
(
$this
->
expiryEnabled
)
{
$dbw
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'watchlist_expiry'
)
->
where
(
[
'we_item'
=>
$wlIds
]
)
->
caller
(
__METHOD__
)->
execute
();
$affectedRows
+=
$dbw
->
affectedRows
();
}
}
if
(
$ticket
)
{
$this
->
lbFactory
->
commitAndWaitForReplication
(
__METHOD__
,
$ticket
);
}
}
}
return
(
bool
)
$affectedRows
;
}
/**
* @since 1.27
* @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36
* @param array $options Supported options are:
* - 'minimumWatchers': filter for pages that have at least a minimum number of watchers
* @return array
*/
public
function
countWatchersMultiple
(
array
$targets
,
array
$options
=
[]
):
array
{
$linkTargets
=
array_map
(
static
function
(
$target
)
{
if
(
!
$target
instanceof
LinkTarget
)
{
return
new
TitleValue
(
$target
->
getNamespace
(),
$target
->
getDBkey
()
);
}
return
$target
;
},
$targets
);
$lb
=
$this
->
linkBatchFactory
->
newLinkBatch
(
$linkTargets
);
$dbr
=
$this
->
lbFactory
->
getReplicaDatabase
();
$queryBuilder
=
$dbr
->
newSelectQueryBuilder
();
$queryBuilder
->
select
(
[
'wl_title'
,
'wl_namespace'
,
'watchers'
=>
'COUNT(*)'
]
)
->
from
(
'watchlist'
)
->
where
(
[
$lb
->
constructSet
(
'wl'
,
$dbr
)
]
)
->
groupBy
(
[
'wl_namespace'
,
'wl_title'
]
)
->
caller
(
__METHOD__
);
if
(
array_key_exists
(
'minimumWatchers'
,
$options
)
)
{
$queryBuilder
->
having
(
'COUNT(*) >= '
.
(
int
)
$options
[
'minimumWatchers'
]
);
}
$this
->
modifyQueryBuilderForExpiry
(
$queryBuilder
,
$dbr
);
$res
=
$queryBuilder
->
fetchResultSet
();
$watchCounts
=
[];
foreach
(
$targets
as
$linkTarget
)
{
$watchCounts
[
$linkTarget
->
getNamespace
()][
$linkTarget
->
getDBkey
()]
=
0
;
}
foreach
(
$res
as
$row
)
{
$watchCounts
[
$row
->
wl_namespace
][
$row
->
wl_title
]
=
(
int
)
$row
->
watchers
;
}
return
$watchCounts
;
}
/**
* @since 1.27
* @param array $targetsWithVisitThresholds array of LinkTarget[]|PageIdentity[] (not type
* hinted since it annoys phan) - deprecated passing LinkTarget[] since 1.36
* @param int|null $minimumWatchers
* @return int[][] two dimensional array, first is namespace, second is database key,
* value is the number of watchers
*/
public
function
countVisitingWatchersMultiple
(
array
$targetsWithVisitThresholds
,
$minimumWatchers
=
null
):
array
{
if
(
$targetsWithVisitThresholds
===
[]
)
{
// No titles requested => no results returned
return
[];
}
$dbr
=
$this
->
lbFactory
->
getReplicaDatabase
();
$queryBuilder
=
$dbr
->
newSelectQueryBuilder
()
->
select
(
[
'wl_namespace'
,
'wl_title'
,
'watchers'
=>
'COUNT(*)'
]
)
->
from
(
'watchlist'
)
->
where
(
[
$this
->
getVisitingWatchersCondition
(
$dbr
,
$targetsWithVisitThresholds
)
]
)
->
groupBy
(
[
'wl_namespace'
,
'wl_title'
]
)
->
caller
(
__METHOD__
);
if
(
$minimumWatchers
!==
null
)
{
$queryBuilder
->
having
(
'COUNT(*) >= '
.
(
int
)
$minimumWatchers
);
}
$this
->
modifyQueryBuilderForExpiry
(
$queryBuilder
,
$dbr
);
$res
=
$queryBuilder
->
fetchResultSet
();
$watcherCounts
=
[];
foreach
(
$targetsWithVisitThresholds
as
[
$target
]
)
{
/** @var LinkTarget|PageIdentity $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 IReadableDatabase $db
* @param array $targetsWithVisitThresholds array of pairs (LinkTarget|PageIdentity,
* last visit threshold) - deprecated passing LinkTarget since 1.36
* @return string
*/
private
function
getVisitingWatchersCondition
(
IReadableDatabase
$db
,
array
$targetsWithVisitThresholds
):
string
{
$missingTargets
=
[];
$namespaceConds
=
[];
foreach
(
$targetsWithVisitThresholds
as
[
$target
,
$threshold
]
)
{
if
(
$threshold
===
null
)
{
$missingTargets
[]
=
$target
;
continue
;
}
/** @var LinkTarget|PageIdentity $target */
$namespaceConds
[
$target
->
getNamespace
()][]
=
$db
->
expr
(
'wl_title'
,
'='
,
$target
->
getDBkey
()
)
->
andExpr
(
$db
->
expr
(
'wl_notificationtimestamp'
,
'>='
,
$db
->
timestamp
(
$threshold
)
)
->
or
(
'wl_notificationtimestamp'
,
'='
,
null
)
);
}
$conds
=
[];
foreach
(
$namespaceConds
as
$namespace
=>
$pageConds
)
{
$conds
[]
=
$db
->
makeList
(
[
'wl_namespace = '
.
$namespace
,
'('
.
$db
->
makeList
(
$pageConds
,
LIST_OR
)
.
')'
],
LIST_AND
);
}
if
(
$missingTargets
)
{
$lb
=
$this
->
linkBatchFactory
->
newLinkBatch
(
$missingTargets
);
$conds
[]
=
$lb
->
constructSet
(
'wl'
,
$db
);
}
return
$db
->
makeList
(
$conds
,
LIST_OR
);
}
/**
* @since 1.27
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return WatchedItem|false
*/
public
function
getWatchedItem
(
UserIdentity
$user
,
$target
)
{
if
(
!
$user
->
isRegistered
()
)
{
return
false
;
}
$cached
=
$this
->
getCached
(
$user
,
$target
);
if
(
$cached
&&
!
$cached
->
isExpired
()
)
{
$this
->
statsFactory
->
getCounter
(
'WatchedItemStore_getWatchedItem_accesses_total'
)
->
setLabel
(
'status'
,
'hit'
)
->
copyToStatsdAt
(
'WatchedItemStore.getWatchedItem.cached'
)
->
increment
();
return
$cached
;
}
$this
->
statsFactory
->
getCounter
(
'WatchedItemStore_getWatchedItem_accesses_total'
)
->
setLabel
(
'status'
,
'miss'
)
->
copyToStatsdAt
(
'WatchedItemStore.getWatchedItem.load'
)
->
increment
();
return
$this
->
loadWatchedItem
(
$user
,
$target
);
}
/**
* @since 1.27
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return WatchedItem|false
*/
public
function
loadWatchedItem
(
UserIdentity
$user
,
$target
)
{
$item
=
$this
->
loadWatchedItemsBatch
(
$user
,
[
$target
]
);
return
$item
?
$item
[
0
]
:
false
;
}
/**
* @since 1.36
* @param UserIdentity $user
* @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36
* @return WatchedItem[]|false
*/
public
function
loadWatchedItemsBatch
(
UserIdentity
$user
,
array
$targets
)
{
// Only registered user can have a watchlist
if
(
!
$user
->
isRegistered
()
)
{
return
false
;
}
$dbr
=
$this
->
lbFactory
->
getReplicaDatabase
();
$rows
=
$this
->
fetchWatchedItems
(
$dbr
,
$user
,
[
'wl_namespace'
,
'wl_title'
,
'wl_notificationtimestamp'
],
[],
$targets
);
if
(
!
$rows
)
{
return
false
;
}
$items
=
[];
foreach
(
$rows
as
$row
)
{
// TODO: convert to PageIdentity
$target
=
new
TitleValue
(
(
int
)
$row
->
wl_namespace
,
$row
->
wl_title
);
$item
=
$this
->
getWatchedItemFromRow
(
$user
,
$target
,
$row
);
$this
->
cache
(
$item
);
$items
[]
=
$item
;
}
return
$items
;
}
/**
* @since 1.27
* @param UserIdentity $user
* @param array $options Supported options are:
* - 'forWrite': whether to use the primary database instead of a replica
* - 'sort': how to sort the titles, either SORT_ASC or SORT_DESC
* - 'sortByExpiry': whether to also sort results by expiration, with temporarily watched titles
* above titles watched indefinitely and titles expiring soonest at the top
* @return WatchedItem[]
*/
public
function
getWatchedItemsForUser
(
UserIdentity
$user
,
array
$options
=
[]
):
array
{
$options
+=
[
'forWrite'
=>
false
];
$vars
=
[
'wl_namespace'
,
'wl_title'
,
'wl_notificationtimestamp'
];
$orderBy
=
[];
if
(
$options
[
'forWrite'
]
)
{
$db
=
$this
->
lbFactory
->
getPrimaryDatabase
();
}
else
{
$db
=
$this
->
lbFactory
->
getReplicaDatabase
();
}
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'
);
$orderBy
[]
=
"wl_namespace {$options['sort']}"
;
if
(
$this
->
expiryEnabled
&&
array_key_exists
(
'sortByExpiry'
,
$options
)
&&
$options
[
'sortByExpiry'
]
)
{
// Add `wl_has_expiry` column to allow sorting by watched titles that have an expiration date first.
$vars
[
'wl_has_expiry'
]
=
$db
->
conditional
(
'we_expiry IS NULL'
,
'0'
,
'1'
);
// Display temporarily watched titles first.
// Order by expiration date, with the titles that will expire soonest at the top.
$orderBy
[]
=
"wl_has_expiry DESC"
;
$orderBy
[]
=
"we_expiry ASC"
;
}
$orderBy
[]
=
"wl_title {$options['sort']}"
;
}
$res
=
$this
->
fetchWatchedItems
(
$db
,
$user
,
$vars
,
$orderBy
);
$watchedItems
=
[];
foreach
(
$res
as
$row
)
{
// TODO: convert to PageIdentity
$target
=
new
TitleValue
(
(
int
)
$row
->
wl_namespace
,
$row
->
wl_title
);
// @todo: Should we add these to the process cache?
$watchedItems
[]
=
$this
->
getWatchedItemFromRow
(
$user
,
$target
,
$row
);
}
return
$watchedItems
;
}
/**
* Construct a new WatchedItem given a row from watchlist/watchlist_expiry.
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @param \stdClass $row
* @return WatchedItem
*/
private
function
getWatchedItemFromRow
(
UserIdentity
$user
,
$target
,
stdClass
$row
):
WatchedItem
{
return
new
WatchedItem
(
$user
,
$target
,
$this
->
getLatestNotificationTimestamp
(
$row
->
wl_notificationtimestamp
,
$user
,
$target
),
wfTimestampOrNull
(
TS_ISO_8601
,
$row
->
we_expiry
??
null
)
);
}
/**
* Fetches either a single or all watched items for the given user, or a specific set of items.
* If a $target is given, IDatabase::selectRow() is called, otherwise select().
* If $wgWatchlistExpiry is enabled, expired items are not returned.
*
* @param IReadableDatabase $db
* @param UserIdentity $user
* @param array $vars we_expiry is added when $wgWatchlistExpiry is enabled.
* @param array $orderBy array of columns
* @param LinkTarget|LinkTarget[]|PageIdentity|PageIdentity[]|null $target null if selecting all
* watched items - deprecated passing LinkTarget or LinkTarget[] since 1.36
* @return IResultWrapper|\stdClass|false
*/
private
function
fetchWatchedItems
(
IReadableDatabase
$db
,
UserIdentity
$user
,
array
$vars
,
array
$orderBy
=
[],
$target
=
null
)
{
$dbMethod
=
'select'
;
$queryBuilder
=
$db
->
newSelectQueryBuilder
()
->
select
(
$vars
)
->
from
(
'watchlist'
)
->
where
(
[
'wl_user'
=>
$user
->
getId
()
]
)
->
caller
(
__METHOD__
);
if
(
$target
)
{
if
(
$target
instanceof
LinkTarget
||
$target
instanceof
PageIdentity
)
{
$queryBuilder
->
where
(
[
'wl_namespace'
=>
$target
->
getNamespace
(),
'wl_title'
=>
$target
->
getDBkey
(),
]
);
$dbMethod
=
'selectRow'
;
}
else
{
$titleConds
=
[];
foreach
(
$target
as
$linkTarget
)
{
$titleConds
[]
=
$db
->
makeList
(
[
'wl_namespace'
=>
$linkTarget
->
getNamespace
(),
'wl_title'
=>
$linkTarget
->
getDBkey
(),
],
$db
::
LIST_AND
);
}
$queryBuilder
->
where
(
$db
->
makeList
(
$titleConds
,
$db
::
LIST_OR
)
);
}
}
$this
->
modifyQueryBuilderForExpiry
(
$queryBuilder
,
$db
);
if
(
$this
->
expiryEnabled
)
{
$queryBuilder
->
field
(
'we_expiry'
);
}
if
(
$orderBy
)
{
$queryBuilder
->
orderBy
(
$orderBy
);
}
if
(
$dbMethod
==
'selectRow'
)
{
return
$queryBuilder
->
fetchRow
();
}
return
$queryBuilder
->
fetchResultSet
();
}
/**
* @since 1.27
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return bool
*/
public
function
isWatched
(
UserIdentity
$user
,
$target
):
bool
{
return
(
bool
)
$this
->
getWatchedItem
(
$user
,
$target
);
}
/**
* Check if the user is temporarily watching the page.
* @since 1.35
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return bool
*/
public
function
isTempWatched
(
UserIdentity
$user
,
$target
):
bool
{
$item
=
$this
->
getWatchedItem
(
$user
,
$target
);
return
$item
&&
$item
->
getExpiry
();
}
/**
* @since 1.27
* @param UserIdentity $user
* @param LinkTarget[] $targets
* @return (string|null|false)[][] two dimensional array, first is namespace, second is database key,
* value is the notification timestamp or null, or false if not available
*/
public
function
getNotificationTimestampsBatch
(
UserIdentity
$user
,
array
$targets
):
array
{
$timestamps
=
[];
foreach
(
$targets
as
$target
)
{
$timestamps
[
$target
->
getNamespace
()][
$target
->
getDBkey
()]
=
false
;
}
if
(
!
$user
->
isRegistered
()
)
{
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
->
lbFactory
->
getReplicaDatabase
();
$lb
=
$this
->
linkBatchFactory
->
newLinkBatch
(
$targetsToLoad
);
$res
=
$dbr
->
newSelectQueryBuilder
()
->
select
(
[
'wl_namespace'
,
'wl_title'
,
'wl_notificationtimestamp'
]
)
->
from
(
'watchlist'
)
->
where
(
[
$lb
->
constructSet
(
'wl'
,
$dbr
),
'wl_user'
=>
$user
->
getId
(),
]
)
->
caller
(
__METHOD__
)
->
fetchResultSet
();
foreach
(
$res
as
$row
)
{
// TODO: convert to PageIdentity
$target
=
new
TitleValue
(
(
int
)
$row
->
wl_namespace
,
$row
->
wl_title
);
$timestamps
[
$row
->
wl_namespace
][
$row
->
wl_title
]
=
$this
->
getLatestNotificationTimestamp
(
$row
->
wl_notificationtimestamp
,
$user
,
$target
);
}
return
$timestamps
;
}
/**
* @since 1.27 Method added.
* @since 1.35 Accepts $expiry parameter.
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @param string|null $expiry Optional expiry in any format acceptable to wfTimestamp().
* null will not create an expiry, or leave it unchanged should one already exist.
*/
public
function
addWatch
(
UserIdentity
$user
,
$target
,
?
string
$expiry
=
null
)
{
$this
->
addWatchBatchForUser
(
$user
,
[
$target
],
$expiry
);
if
(
$this
->
expiryEnabled
&&
!
$expiry
)
{
// When re-watching a page with a null $expiry, any existing expiry is left unchanged.
// However we must re-fetch the preexisting expiry or else the cached WatchedItem will
// incorrectly have a null expiry. Note that loadWatchedItem() does the caching.
// See T259379
$this
->
loadWatchedItem
(
$user
,
$target
);
}
else
{
// Create a new WatchedItem and add it to the process cache.
// In this case we don't need to re-fetch the expiry.
$expiry
=
ExpiryDef
::
normalizeUsingMaxExpiry
(
$expiry
,
$this
->
maxExpiryDuration
,
TS_ISO_8601
);
$item
=
new
WatchedItem
(
$user
,
$target
,
null
,
$expiry
);
$this
->
cache
(
$item
);
}
}
/**
* Add multiple items to the user's watchlist.
* If you know you're adding a single page (and/or its talk page) use self::addWatch(),
* since it will add the WatchedItem to the process cache.
*
* @since 1.27 Method added.
* @since 1.35 Accepts $expiry parameter.
* @param UserIdentity $user
* @param LinkTarget[] $targets
* @param string|null $expiry Optional expiry in a format acceptable to wfTimestamp(),
* null will not create expiries, or leave them unchanged should they already exist.
* @return bool Whether database transactions were performed.
*/
public
function
addWatchBatchForUser
(
UserIdentity
$user
,
array
$targets
,
?
string
$expiry
=
null
):
bool
{
// Only registered user can have a watchlist
if
(
!
$user
->
isRegistered
()
||
$this
->
readOnlyMode
->
isReadOnly
()
)
{
return
false
;
}
if
(
!
$targets
)
{
return
true
;
}
$expiry
=
ExpiryDef
::
normalizeUsingMaxExpiry
(
$expiry
,
$this
->
maxExpiryDuration
,
TS_ISO_8601
);
$rows
=
[];
foreach
(
$targets
as
$target
)
{
$rows
[]
=
[
'wl_user'
=>
$user
->
getId
(),
'wl_namespace'
=>
$target
->
getNamespace
(),
'wl_title'
=>
$target
->
getDBkey
(),
'wl_notificationtimestamp'
=>
null
,
];
$this
->
uncache
(
$user
,
$target
);
}
$dbw
=
$this
->
lbFactory
->
getPrimaryDatabase
();
$ticket
=
count
(
$targets
)
>
$this
->
updateRowsPerQuery
?
$this
->
lbFactory
->
getEmptyTransactionTicket
(
__METHOD__
)
:
null
;
$affectedRows
=
0
;
$rowBatches
=
array_chunk
(
$rows
,
$this
->
updateRowsPerQuery
);
foreach
(
$rowBatches
as
$toInsert
)
{
// Use INSERT IGNORE to avoid overwriting the notification timestamp
// if there's already an entry for this page
$dbw
->
newInsertQueryBuilder
()
->
insertInto
(
'watchlist'
)
->
ignore
()
->
rows
(
$toInsert
)
->
caller
(
__METHOD__
)->
execute
();
$affectedRows
+=
$dbw
->
affectedRows
();
if
(
$this
->
expiryEnabled
)
{
$affectedRows
+=
$this
->
updateOrDeleteExpiries
(
$dbw
,
$user
->
getId
(),
$toInsert
,
$expiry
);
}
if
(
$ticket
)
{
$this
->
lbFactory
->
commitAndWaitForReplication
(
__METHOD__
,
$ticket
);
}
}
return
(
bool
)
$affectedRows
;
}
/**
* Insert/update expiries, or delete them if the expiry is 'infinity'.
*
* @param IDatabase $dbw
* @param int $userId
* @param array $rows
* @param string|null $expiry
* @return int Number of affected rows.
*/
private
function
updateOrDeleteExpiries
(
IDatabase
$dbw
,
int
$userId
,
array
$rows
,
?
string
$expiry
=
null
):
int
{
if
(
!
$expiry
)
{
// if expiry is null (shouldn't change), 0 rows affected.
return
0
;
}
// Build the giant `(...) OR (...)` part to be used with WHERE.
$conds
=
[];
foreach
(
$rows
as
$row
)
{
$conds
[]
=
$dbw
->
makeList
(
[
'wl_user'
=>
$userId
,
'wl_namespace'
=>
$row
[
'wl_namespace'
],
'wl_title'
=>
$row
[
'wl_title'
]
],
$dbw
::
LIST_AND
);
}
$cond
=
$dbw
->
makeList
(
$conds
,
$dbw
::
LIST_OR
);
if
(
wfIsInfinity
(
$expiry
)
)
{
// Rows should be deleted rather than updated.
$dbw
->
deleteJoin
(
'watchlist_expiry'
,
'watchlist'
,
'we_item'
,
'wl_id'
,
[
$cond
],
__METHOD__
);
return
$dbw
->
affectedRows
();
}
return
$this
->
updateExpiries
(
$dbw
,
$expiry
,
$cond
);
}
/**
* Update the expiries for items found with the given $cond.
* @param IDatabase $dbw
* @param string $expiry
* @param string $cond
* @return int Number of affected rows.
*/
private
function
updateExpiries
(
IDatabase
$dbw
,
string
$expiry
,
string
$cond
):
int
{
// First fetch the wl_ids from the watchlist table.
// We'd prefer to do a INSERT/SELECT in the same query with IDatabase::insertSelect(),
// but it doesn't allow us to use the "ON DUPLICATE KEY UPDATE" clause.
$wlIds
=
$dbw
->
newSelectQueryBuilder
()
->
select
(
'wl_id'
)
->
from
(
'watchlist'
)
->
where
(
$cond
)
->
caller
(
__METHOD__
)
->
fetchFieldValues
();
if
(
!
$wlIds
)
{
return
0
;
}
$expiry
=
$dbw
->
timestamp
(
$expiry
);
$weRows
=
[];
foreach
(
$wlIds
as
$wlId
)
{
$weRows
[]
=
[
'we_item'
=>
$wlId
,
'we_expiry'
=>
$expiry
];
}
// Insert into watchlist_expiry, updating the expiry for duplicate rows.
$dbw
->
newInsertQueryBuilder
()
->
insertInto
(
'watchlist_expiry'
)
->
rows
(
$weRows
)
->
onDuplicateKeyUpdate
()
->
uniqueIndexFields
(
[
'we_item'
]
)
->
set
(
[
'we_expiry'
=>
$expiry
]
)
->
caller
(
__METHOD__
)->
execute
();
return
$dbw
->
affectedRows
();
}
/**
* @since 1.27
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return bool
*/
public
function
removeWatch
(
UserIdentity
$user
,
$target
):
bool
{
return
$this
->
removeWatchBatchForUser
(
$user
,
[
$target
]
);
}
/**
* Set the "last viewed" timestamps for certain titles on a user's watchlist.
*
* If the $targets parameter is omitted or set to [], this method simply wraps
* resetAllNotificationTimestampsForUser(), and in that case you should instead call that method
* directly; support for omitting $targets is for backwards compatibility.
*
* If $targets is omitted or set to [], timestamps will be updated for every title on the user's
* watchlist, and this will be done through a DeferredUpdate. If $targets is a non-empty array,
* only the specified titles will be updated, and this will be done immediately (not deferred).
*
* @since 1.27
* @param UserIdentity $user
* @param string|int $timestamp Value to set the "last viewed" timestamp to (null to clear)
* @param LinkTarget[] $targets Titles to set the timestamp for; [] means the entire watchlist
* @return bool
*/
public
function
setNotificationTimestampsForUser
(
UserIdentity
$user
,
$timestamp
,
array
$targets
=
[]
):
bool
{
// Only registered user can have a watchlist
if
(
!
$user
->
isRegistered
()
||
$this
->
readOnlyMode
->
isReadOnly
()
)
{
return
false
;
}
if
(
!
$targets
)
{
// Backwards compatibility
$this
->
resetAllNotificationTimestampsForUser
(
$user
,
$timestamp
);
return
true
;
}
$rows
=
$this
->
getTitleDbKeysGroupedByNamespace
(
$targets
);
$dbw
=
$this
->
lbFactory
->
getPrimaryDatabase
();
if
(
$timestamp
!==
null
)
{
$timestamp
=
$dbw
->
timestamp
(
$timestamp
);
}
$ticket
=
$this
->
lbFactory
->
getEmptyTransactionTicket
(
__METHOD__
);
$affectedSinceWait
=
0
;
// Batch update items per namespace
foreach
(
$rows
as
$namespace
=>
$namespaceTitles
)
{
$rowBatches
=
array_chunk
(
$namespaceTitles
,
$this
->
updateRowsPerQuery
);
foreach
(
$rowBatches
as
$toUpdate
)
{
// First fetch the wl_ids.
$wlIds
=
$dbw
->
newSelectQueryBuilder
()
->
select
(
'wl_id'
)
->
from
(
'watchlist'
)
->
where
(
[
'wl_user'
=>
$user
->
getId
(),
'wl_namespace'
=>
$namespace
,
'wl_title'
=>
$toUpdate
]
)
->
caller
(
__METHOD__
)
->
fetchFieldValues
();
if
(
$wlIds
)
{
$wlIds
=
array_map
(
'intval'
,
$wlIds
);
$dbw
->
newUpdateQueryBuilder
()
->
update
(
'watchlist'
)
->
set
(
[
'wl_notificationtimestamp'
=>
$timestamp
]
)
->
where
(
[
'wl_id'
=>
$wlIds
]
)
->
caller
(
__METHOD__
)->
execute
();
$affectedSinceWait
+=
$dbw
->
affectedRows
();
// Wait for replication every time we've touched updateRowsPerQuery rows
if
(
$affectedSinceWait
>=
$this
->
updateRowsPerQuery
)
{
$this
->
lbFactory
->
commitAndWaitForReplication
(
__METHOD__
,
$ticket
);
$affectedSinceWait
=
0
;
}
}
}
}
$this
->
uncacheUser
(
$user
);
return
true
;
}
/**
* @param string|null $timestamp
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @return bool|string|null
*/
public
function
getLatestNotificationTimestamp
(
$timestamp
,
UserIdentity
$user
,
$target
)
{
$timestamp
=
wfTimestampOrNull
(
TS_MW
,
$timestamp
);
if
(
$timestamp
===
null
)
{
return
null
;
// no notification
}
$seenTimestamps
=
$this
->
getPageSeenTimestamps
(
$user
);
if
(
$seenTimestamps
)
{
$seenKey
=
$this
->
getPageSeenKey
(
$target
);
if
(
isset
(
$seenTimestamps
[
$seenKey
]
)
&&
$seenTimestamps
[
$seenKey
]
>=
$timestamp
)
{
// If a reset job did not yet run, then the "seen" timestamp will be higher
return
null
;
}
}
return
$timestamp
;
}
/**
* Schedule a DeferredUpdate that sets all of the "last viewed" timestamps for a given user
* to the same value.
* @param UserIdentity $user
* @param string|int|null $timestamp Value to set all timestamps to, null to clear them
*/
public
function
resetAllNotificationTimestampsForUser
(
UserIdentity
$user
,
$timestamp
=
null
)
{
// Only registered user can have a watchlist
if
(
!
$user
->
isRegistered
()
)
{
return
;
}
// If the page is watched by the user (or may be watched), update the timestamp
$job
=
new
ClearWatchlistNotificationsJob
(
[
'userId'
=>
$user
->
getId
(),
'timestamp'
=>
$timestamp
,
'casTime'
=>
time
()
]
);
// Try to run this post-send
// Calls DeferredUpdates::addCallableUpdate in normal operation
call_user_func
(
$this
->
deferredUpdatesAddCallableUpdateCallback
,
static
function
()
use
(
$job
)
{
$job
->
run
();
}
);
}
/**
* Update wl_notificationtimestamp for all watching users except the editor
* @since 1.27
* @param UserIdentity $editor
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
* @param string|int $timestamp
* @return int[]
*/
public
function
updateNotificationTimestamp
(
UserIdentity
$editor
,
$target
,
$timestamp
):
array
{
$dbw
=
$this
->
lbFactory
->
getPrimaryDatabase
();
$queryBuilder
=
$dbw
->
newSelectQueryBuilder
()
->
select
(
[
'wl_id'
,
'wl_user'
]
)
->
from
(
'watchlist'
)
->
where
(
[
'wl_user != '
.
$editor
->
getId
(),
'wl_namespace'
=>
$target
->
getNamespace
(),
'wl_title'
=>
$target
->
getDBkey
(),
'wl_notificationtimestamp'
=>
null
,
]
)
->
caller
(
__METHOD__
);
$this
->
modifyQueryBuilderForExpiry
(
$queryBuilder
,
$dbw
);
$res
=
$queryBuilder
->
fetchResultSet
();
$watchers
=
[];
$wlIds
=
[];
foreach
(
$res
as
$row
)
{
$watchers
[]
=
(
int
)
$row
->
wl_user
;
$wlIds
[]
=
(
int
)
$row
->
wl_id
;
}
if
(
$wlIds
)
{
$fname
=
__METHOD__
;
// Try to run this post-send
// Calls DeferredUpdates::addCallableUpdate in normal operation
call_user_func
(
$this
->
deferredUpdatesAddCallableUpdateCallback
,
function
()
use
(
$timestamp
,
$wlIds
,
$target
,
$fname
)
{
$dbw
=
$this
->
lbFactory
->
getPrimaryDatabase
();
$ticket
=
$this
->
lbFactory
->
getEmptyTransactionTicket
(
$fname
);
$wlIdsChunks
=
array_chunk
(
$wlIds
,
$this
->
updateRowsPerQuery
);
foreach
(
$wlIdsChunks
as
$wlIdsChunk
)
{
$dbw
->
newUpdateQueryBuilder
()
->
update
(
'watchlist'
)
->
set
(
[
'wl_notificationtimestamp'
=>
$dbw
->
timestamp
(
$timestamp
)
]
)
->
where
(
[
'wl_id'
=>
$wlIdsChunk
]
)
->
caller
(
$fname
)->
execute
();
if
(
count
(
$wlIdsChunks
)
>
1
)
{
$this
->
lbFactory
->
commitAndWaitForReplication
(
$fname
,
$ticket
);
}
}
$this
->
uncacheLinkTarget
(
$target
);
},
DeferredUpdates
::
POSTSEND
,
$dbw
);
}
return
$watchers
;
}
/**
* @since 1.27
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.36
* @param string $force
* @param int $oldid
* @return bool
*/
public
function
resetNotificationTimestamp
(
UserIdentity
$user
,
$title
,
$force
=
''
,
$oldid
=
0
):
bool
{
$time
=
time
();
// Only registered user can have a watchlist
if
(
!
$user
->
isRegistered
()
||
$this
->
readOnlyMode
->
isReadOnly
()
)
{
return
false
;
}
$item
=
null
;
if
(
$force
!=
'force'
)
{
$item
=
$this
->
getWatchedItem
(
$user
,
$title
);
if
(
!
$item
||
$item
->
getNotificationTimestamp
()
===
null
)
{
return
false
;
}
}
// Get the timestamp (TS_MW) of this revision to track the latest one seen
$id
=
$oldid
;
$seenTime
=
null
;
if
(
!
$id
)
{
$latestRev
=
$this
->
revisionLookup
->
getRevisionByTitle
(
$title
);
if
(
$latestRev
)
{
$id
=
$latestRev
->
getId
();
// Save a DB query
$seenTime
=
$latestRev
->
getTimestamp
();
}
}
if
(
$seenTime
===
null
)
{
// @phan-suppress-next-line PhanTypeMismatchArgumentNullable getId does not return null here
$seenTime
=
$this
->
revisionLookup
->
getTimestampFromId
(
$id
);
}
// Mark the item as read immediately in lightweight storage
$this
->
stash
->
merge
(
$this
->
getPageSeenTimestampsKey
(
$user
),
function
(
$cache
,
$key
,
$current
)
use
(
$title
,
$seenTime
)
{
if
(
!
$current
)
{
$value
=
new
MapCacheLRU
(
300
);
}
elseif
(
is_array
(
$current
)
)
{
$value
=
MapCacheLRU
::
newFromArray
(
$current
,
300
);
}
else
{
// Backwards compatibility for T282105
$value
=
$current
;
}
$subKey
=
$this
->
getPageSeenKey
(
$title
);
if
(
$seenTime
>
$value
->
get
(
$subKey
)
)
{
// Revision is newer than the last one seen
$value
->
set
(
$subKey
,
$seenTime
);
$this
->
latestUpdateCache
->
set
(
$key
,
$value
->
toArray
(),
BagOStuff
::
TTL_PROC_LONG
);
}
elseif
(
$seenTime
===
false
)
{
// Revision does not exist
$value
->
set
(
$subKey
,
wfTimestamp
(
TS_MW
)
);
$this
->
latestUpdateCache
->
set
(
$key
,
$value
->
toArray
(),
BagOStuff
::
TTL_PROC_LONG
);
}
else
{
return
false
;
// nothing to update
}
return
$value
->
toArray
();
},
BagOStuff
::
TTL_HOUR
);
// If the page is watched by the user (or may be watched), update the timestamp
// ActivityUpdateJob accepts both LinkTarget and PageReference
$job
=
new
ActivityUpdateJob
(
$title
,
[
'type'
=>
'updateWatchlistNotification'
,
'userid'
=>
$user
->
getId
(),
'notifTime'
=>
$this
->
getNotificationTimestamp
(
$user
,
$title
,
$item
,
$force
,
$oldid
),
'curTime'
=>
$time
]
);
// Try to enqueue this post-send
$this
->
queueGroup
->
lazyPush
(
$job
);
$this
->
uncache
(
$user
,
$title
);
return
true
;
}
/**
* @param UserIdentity $user
* @return array|null The map contains prefixed title keys and TS_MW values
*/
private
function
getPageSeenTimestamps
(
UserIdentity
$user
)
{
$key
=
$this
->
getPageSeenTimestampsKey
(
$user
);
$cache
=
$this
->
latestUpdateCache
->
getWithSetCallback
(
$key
,
BagOStuff
::
TTL_PROC_LONG
,
function
()
use
(
$key
)
{
return
$this
->
stash
->
get
(
$key
)
?:
null
;
}
);
// Backwards compatibility for T282105
if
(
$cache
instanceof
MapCacheLRU
)
{
$cache
=
$cache
->
toArray
();
}
return
$cache
;
}
/**
* @param UserIdentity $user
* @return string
*/
private
function
getPageSeenTimestampsKey
(
UserIdentity
$user
):
string
{
return
$this
->
stash
->
makeGlobalKey
(
'watchlist-recent-updates'
,
$this
->
lbFactory
->
getLocalDomainID
(),
$user
->
getId
()
);
}
/**
* @param LinkTarget|PageIdentity $target
* @return string
*/
private
function
getPageSeenKey
(
$target
):
string
{
return
"{$target->getNamespace()}:{$target->getDBkey()}"
;
}
/**
* @param UserIdentity $user
* @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.36
* @param WatchedItem|null $item
* @param string $force
* @param int|false $oldid The ID of the last revision that the user viewed
* @return string|null|false
*/
private
function
getNotificationTimestamp
(
UserIdentity
$user
,
$title
,
$item
,
$force
,
$oldid
)
{
if
(
!
$oldid
)
{
// No oldid given, assuming latest revision; clear the timestamp.
return
null
;
}
$oldRev
=
$this
->
revisionLookup
->
getRevisionById
(
$oldid
);
if
(
!
$oldRev
)
{
// Oldid given but does not exist (probably deleted)
return
false
;
}
$nextRev
=
$this
->
revisionLookup
->
getNextRevision
(
$oldRev
);
if
(
!
$nextRev
)
{
// Oldid given and is the latest revision for this title; clear the timestamp.
return
null
;
}
$item
??=
$this
->
loadWatchedItem
(
$user
,
$title
);
if
(
!
$item
)
{
// This can only happen if $force is enabled.
return
null
;
}
// Oldid given and isn't the latest; update the timestamp.
// This will result in no further notification emails being sent!
$notificationTimestamp
=
$this
->
revisionLookup
->
getTimestampFromId
(
$oldid
);
// @FIXME: this should use getTimestamp() for consistency with updates on new edits
// $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
// We need to go one second to the future because of various strict comparisons
// throughout the codebase
$ts
=
new
MWTimestamp
(
$notificationTimestamp
);
$ts
->
timestamp
->
add
(
new
DateInterval
(
'PT1S'
)
);
$notificationTimestamp
=
$ts
->
getTimestamp
(
TS_MW
);
if
(
$notificationTimestamp
<
$item
->
getNotificationTimestamp
()
)
{
if
(
$force
!=
'force'
)
{
return
false
;
}
else
{
// This is a little silly…
return
$item
->
getNotificationTimestamp
();
}
}
return
$notificationTimestamp
;
}
/**
* @since 1.27
* @param UserIdentity $user
* @param int|null $unreadLimit
* @return int|bool
*/
public
function
countUnreadNotifications
(
UserIdentity
$user
,
$unreadLimit
=
null
)
{
$queryBuilder
=
$this
->
lbFactory
->
getReplicaDatabase
()->
newSelectQueryBuilder
()
->
select
(
'1'
)
->
from
(
'watchlist'
)
->
where
(
[
'wl_user'
=>
$user
->
getId
(),
'wl_notificationtimestamp IS NOT NULL'
]
)
->
caller
(
__METHOD__
);
if
(
$unreadLimit
!==
null
)
{
$unreadLimit
=
(
int
)
$unreadLimit
;
$queryBuilder
->
limit
(
$unreadLimit
);
}
$rowCount
=
$queryBuilder
->
fetchRowCount
();
if
(
$unreadLimit
===
null
)
{
return
$rowCount
;
}
if
(
$rowCount
>=
$unreadLimit
)
{
return
true
;
}
return
$rowCount
;
}
/**
* @since 1.27
* @param LinkTarget|PageIdentity $oldTarget deprecated passing LinkTarget since 1.36
* @param LinkTarget|PageIdentity $newTarget deprecated passing LinkTarget since 1.36
*/
public
function
duplicateAllAssociatedEntries
(
$oldTarget
,
$newTarget
)
{
// Duplicate first the subject page, then the talk page
// TODO: convert to PageIdentity
$this
->
duplicateEntry
(
new
TitleValue
(
$this
->
nsInfo
->
getSubject
(
$oldTarget
->
getNamespace
()
),
$oldTarget
->
getDBkey
()
),
new
TitleValue
(
$this
->
nsInfo
->
getSubject
(
$newTarget
->
getNamespace
()
),
$newTarget
->
getDBkey
()
)
);
$this
->
duplicateEntry
(
new
TitleValue
(
$this
->
nsInfo
->
getTalk
(
$oldTarget
->
getNamespace
()
),
$oldTarget
->
getDBkey
()
),
new
TitleValue
(
$this
->
nsInfo
->
getTalk
(
$newTarget
->
getNamespace
()
),
$newTarget
->
getDBkey
()
)
);
}
/**
* @since 1.27
* @param LinkTarget|PageIdentity $oldTarget deprecated passing LinkTarget since 1.36
* @param LinkTarget|PageIdentity $newTarget deprecated passing LinkTarget since 1.36
*/
public
function
duplicateEntry
(
$oldTarget
,
$newTarget
)
{
$dbw
=
$this
->
lbFactory
->
getPrimaryDatabase
();
$result
=
$this
->
fetchWatchedItemsForPage
(
$dbw
,
$oldTarget
);
$newNamespace
=
$newTarget
->
getNamespace
();
$newDBkey
=
$newTarget
->
getDBkey
();
# Construct array to replace into the watchlist
$values
=
[];
$expiries
=
[];
foreach
(
$result
as
$row
)
{
$values
[]
=
[
'wl_user'
=>
$row
->
wl_user
,
'wl_namespace'
=>
$newNamespace
,
'wl_title'
=>
$newDBkey
,
'wl_notificationtimestamp'
=>
$row
->
wl_notificationtimestamp
,
];
if
(
$this
->
expiryEnabled
&&
$row
->
we_expiry
)
{
$expiries
[
$row
->
wl_user
]
=
$row
->
we_expiry
;
}
}
if
(
!
$values
)
{
return
;
}
// Perform a replace on the watchlist table rows.
// Note that multi-row replace is very efficient for MySQL but may be inefficient for
// some other DBMSes, mostly due to poor simulation by us.
$dbw
->
newReplaceQueryBuilder
()
->
replaceInto
(
'watchlist'
)
->
uniqueIndexFields
(
[
'wl_user'
,
'wl_namespace'
,
'wl_title'
]
)
->
rows
(
$values
)
->
caller
(
__METHOD__
)->
execute
();
if
(
$this
->
expiryEnabled
)
{
$this
->
updateExpiriesAfterMove
(
$dbw
,
$expiries
,
$newNamespace
,
$newDBkey
);
}
}
/**
* @param IReadableDatabase $dbr
* @param LinkTarget|PageIdentity $target
* @return IResultWrapper
*/
private
function
fetchWatchedItemsForPage
(
IReadableDatabase
$dbr
,
$target
):
IResultWrapper
{
$queryBuilder
=
$dbr
->
newSelectQueryBuilder
()
->
select
(
[
'wl_user'
,
'wl_notificationtimestamp'
]
)
->
from
(
'watchlist'
)
->
where
(
[
'wl_namespace'
=>
$target
->
getNamespace
(),
'wl_title'
=>
$target
->
getDBkey
(),
]
)
->
caller
(
__METHOD__
)
->
forUpdate
();
if
(
$this
->
expiryEnabled
)
{
$queryBuilder
->
leftJoin
(
'watchlist_expiry'
,
null
,
[
'wl_id = we_item'
]
)
->
field
(
'we_expiry'
);
}
return
$queryBuilder
->
fetchResultSet
();
}
/**
* @param IDatabase $dbw
* @param array $expiries
* @param int $namespace
* @param string $dbKey
*/
private
function
updateExpiriesAfterMove
(
IDatabase
$dbw
,
array
$expiries
,
int
$namespace
,
string
$dbKey
):
void
{
DeferredUpdates
::
addCallableUpdate
(
function
(
$fname
)
use
(
$dbw
,
$expiries
,
$namespace
,
$dbKey
)
{
// First fetch new wl_ids.
$res
=
$dbw
->
newSelectQueryBuilder
()
->
select
(
[
'wl_user'
,
'wl_id'
]
)
->
from
(
'watchlist'
)
->
where
(
[
'wl_namespace'
=>
$namespace
,
'wl_title'
=>
$dbKey
,
]
)
->
caller
(
$fname
)
->
fetchResultSet
();
// Build new array to INSERT into multiple rows at once.
$expiryData
=
[];
foreach
(
$res
as
$row
)
{
if
(
!
empty
(
$expiries
[
$row
->
wl_user
]
)
)
{
$expiryData
[]
=
[
'we_item'
=>
$row
->
wl_id
,
'we_expiry'
=>
$expiries
[
$row
->
wl_user
],
];
}
}
// Batch the insertions.
$batches
=
array_chunk
(
$expiryData
,
$this
->
updateRowsPerQuery
);
foreach
(
$batches
as
$toInsert
)
{
$dbw
->
newReplaceQueryBuilder
()
->
replaceInto
(
'watchlist_expiry'
)
->
uniqueIndexFields
(
[
'we_item'
]
)
->
rows
(
$toInsert
)
->
caller
(
$fname
)
->
execute
();
}
},
DeferredUpdates
::
POSTSEND
,
$dbw
);
}
/**
* @param LinkTarget[]|PageIdentity[] $titles
* @return array
*/
private
function
getTitleDbKeysGroupedByNamespace
(
array
$titles
)
{
$rows
=
[];
foreach
(
$titles
as
$title
)
{
// Group titles by namespace.
$rows
[
$title
->
getNamespace
()
][]
=
$title
->
getDBkey
();
}
return
$rows
;
}
/**
* @param UserIdentity $user
* @param LinkTarget[]|PageIdentity[] $titles
*/
private
function
uncacheTitlesForUser
(
UserIdentity
$user
,
array
$titles
)
{
foreach
(
$titles
as
$title
)
{
$this
->
uncache
(
$user
,
$title
);
}
}
/**
* @inheritDoc
*/
public
function
countExpired
():
int
{
$dbr
=
$this
->
lbFactory
->
getReplicaDatabase
();
return
$dbr
->
newSelectQueryBuilder
()
->
select
(
'*'
)
->
from
(
'watchlist_expiry'
)
->
where
(
$dbr
->
expr
(
'we_expiry'
,
'<='
,
$dbr
->
timestamp
()
)
)
->
caller
(
__METHOD__
)
->
fetchRowCount
();
}
/**
* @inheritDoc
*/
public
function
removeExpired
(
int
$limit
,
bool
$deleteOrphans
=
false
):
void
{
$dbr
=
$this
->
lbFactory
->
getReplicaDatabase
();
$dbw
=
$this
->
lbFactory
->
getPrimaryDatabase
();
$ticket
=
$this
->
lbFactory
->
getEmptyTransactionTicket
(
__METHOD__
);
// Get a batch of watchlist IDs to delete.
$toDelete
=
$dbr
->
newSelectQueryBuilder
()
->
select
(
'we_item'
)
->
from
(
'watchlist_expiry'
)
->
where
(
$dbr
->
expr
(
'we_expiry'
,
'<='
,
$dbr
->
timestamp
()
)
)
->
limit
(
$limit
)
->
caller
(
__METHOD__
)
->
fetchFieldValues
();
if
(
count
(
$toDelete
)
>
0
)
{
// Delete them from the watchlist and watchlist_expiry table.
$dbw
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'watchlist'
)
->
where
(
[
'wl_id'
=>
$toDelete
]
)
->
caller
(
__METHOD__
)->
execute
();
$dbw
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'watchlist_expiry'
)
->
where
(
[
'we_item'
=>
$toDelete
]
)
->
caller
(
__METHOD__
)->
execute
();
}
// Also delete any orphaned or null-expiry watchlist_expiry rows
// (they should not exist, but might because not everywhere knows about the expiry table yet).
if
(
$deleteOrphans
)
{
$expiryToDelete
=
$dbr
->
newSelectQueryBuilder
()
->
select
(
'we_item'
)
->
from
(
'watchlist_expiry'
)
->
leftJoin
(
'watchlist'
,
null
,
'wl_id = we_item'
)
->
where
(
$dbr
->
makeList
(
[
'wl_id'
=>
null
,
'we_expiry'
=>
null
],
$dbr
::
LIST_OR
)
)
->
caller
(
__METHOD__
)
->
fetchFieldValues
();
if
(
count
(
$expiryToDelete
)
>
0
)
{
$dbw
->
newDeleteQueryBuilder
()
->
deleteFrom
(
'watchlist_expiry'
)
->
where
(
[
'we_item'
=>
$expiryToDelete
]
)
->
caller
(
__METHOD__
)->
execute
();
}
}
$this
->
lbFactory
->
commitAndWaitForReplication
(
__METHOD__
,
$ticket
);
}
}
/** @deprecated class alias since 1.43 */
class_alias
(
WatchedItemStore
::
class
,
'WatchedItemStore'
);
File Metadata
Details
Attached
Mime Type
text/x-php
Expires
Sat, Oct 3, 06:03 (2 w, 4 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
58/c2/9680b2ca09ecd379e2843b683c0a
Default Alt Text
WatchedItemStore.php (53 KB)
Attached To
Mode
rMWPROD MediaWiki Production
Attached
Detach File
Event Timeline
Log In to Comment