Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 1 | # Copyright 2016 The Chromium Authors |
| 2 | # Use of this source code is governed by a BSD-style license that can be |
| 3 | # found in the LICENSE file. |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 4 | |
| 5 | """A set of functions that provide persistence for stars. |
| 6 | |
| 7 | Stars can be on users, projects, or issues. |
| 8 | """ |
| 9 | from __future__ import print_function |
| 10 | from __future__ import division |
| 11 | from __future__ import absolute_import |
| 12 | |
| 13 | import logging |
| 14 | |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 15 | import time |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 16 | from features import filterrules_helpers |
| 17 | from framework import sql |
| 18 | from services import caches |
| 19 | |
| 20 | |
| 21 | USERSTAR_TABLE_NAME = 'UserStar' |
| 22 | PROJECTSTAR_TABLE_NAME = 'ProjectStar' |
| 23 | ISSUESTAR_TABLE_NAME = 'IssueStar' |
| 24 | HOTLISTSTAR_TABLE_NAME = 'HotlistStar' |
| 25 | |
| 26 | # TODO(jrobbins): Consider adding memcache here if performance testing shows |
| 27 | # that stars are a bottleneck. Keep in mind that issue star counts are |
| 28 | # already denormalized and stored in the Issue, which is cached in memcache. |
| 29 | |
| 30 | |
| 31 | class AbstractStarService(object): |
| 32 | """The persistence layer for any kind of star data.""" |
| 33 | |
| 34 | def __init__(self, cache_manager, tbl, item_col, user_col, cache_kind): |
| 35 | """Constructor. |
| 36 | |
| 37 | Args: |
| 38 | cache_manager: local cache with distributed invalidation. |
| 39 | tbl: SQL table that stores star data. |
| 40 | item_col: string SQL column name that holds int item IDs. |
| 41 | user_col: string SQL column name that holds int user IDs |
| 42 | of the user who starred the item. |
| 43 | cache_kind: string saying the kind of RAM cache. |
| 44 | """ |
| 45 | self.tbl = tbl |
| 46 | self.item_col = item_col |
| 47 | self.user_col = user_col |
| 48 | |
| 49 | # Items starred by users, keyed by user who did the starring. |
| 50 | self.star_cache = caches.RamCache(cache_manager, 'user') |
| 51 | # Users that starred an item, keyed by item ID. |
| 52 | self.starrer_cache = caches.RamCache(cache_manager, cache_kind) |
| 53 | # Counts of the users that starred an item, keyed by item ID. |
| 54 | self.star_count_cache = caches.RamCache(cache_manager, cache_kind) |
| 55 | |
| 56 | def ExpungeStars(self, cnxn, item_id, commit=True, limit=None): |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 57 | """Wipes an item's stars from the system. |
| 58 | |
| 59 | Args: |
| 60 | cnxn: connection to SQL database. |
| 61 | item_id: ID of the item that's starred. ie: an issue, project, etc |
| 62 | commit: whether to commit the change. |
| 63 | limit: max stars to delete for performance reasons. |
| 64 | """ |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 65 | self.tbl.Delete( |
| 66 | cnxn, commit=commit, limit=limit, **{self.item_col: item_id}) |
| 67 | |
| 68 | def ExpungeStarsByUsers(self, cnxn, user_ids, limit=None): |
| 69 | """Wipes a user's stars from the system. |
| 70 | This method will not commit the operation. This method will |
| 71 | not make changes to in-memory data. |
| 72 | """ |
| 73 | self.tbl.Delete(cnxn, user_id=user_ids, commit=False, limit=limit) |
| 74 | |
| 75 | def LookupItemStarrers(self, cnxn, item_id): |
| 76 | """Returns list of users having stars on the specified item.""" |
| 77 | starrer_list_dict = self.LookupItemsStarrers(cnxn, [item_id]) |
| 78 | return starrer_list_dict[item_id] |
| 79 | |
| 80 | def LookupItemsStarrers(self, cnxn, items_ids): |
| 81 | """Returns {item_id: [uid, ...]} of users who starred these items.""" |
| 82 | starrer_list_dict, missed_ids = self.starrer_cache.GetAll(items_ids) |
| 83 | |
| 84 | if missed_ids: |
| 85 | rows = self.tbl.Select( |
| 86 | cnxn, cols=[self.item_col, self.user_col], |
| 87 | **{self.item_col: missed_ids}) |
| 88 | # Ensure that every requested item_id has an entry so that even |
| 89 | # zero-star items get cached. |
| 90 | retrieved_starrers = {item_id: [] for item_id in missed_ids} |
| 91 | for item_id, starrer_id in rows: |
| 92 | retrieved_starrers[item_id].append(starrer_id) |
| 93 | starrer_list_dict.update(retrieved_starrers) |
| 94 | self.starrer_cache.CacheAll(retrieved_starrers) |
| 95 | |
| 96 | return starrer_list_dict |
| 97 | |
| 98 | def LookupStarredItemIDs(self, cnxn, starrer_user_id): |
| 99 | """Returns list of item IDs that were starred by the specified user.""" |
| 100 | if not starrer_user_id: |
| 101 | return [] # Anon user cannot star anything. |
| 102 | |
| 103 | cached_item_ids = self.star_cache.GetItem(starrer_user_id) |
| 104 | if cached_item_ids is not None: |
| 105 | return cached_item_ids |
| 106 | |
| 107 | rows = self.tbl.Select(cnxn, cols=[self.item_col], user_id=starrer_user_id) |
| 108 | starred_ids = [row[0] for row in rows] |
| 109 | self.star_cache.CacheItem(starrer_user_id, starred_ids) |
| 110 | return starred_ids |
| 111 | |
| 112 | def IsItemStarredBy(self, cnxn, item_id, starrer_user_id): |
| 113 | """Return True if the given issue is starred by the given user.""" |
| 114 | starred_ids = self.LookupStarredItemIDs(cnxn, starrer_user_id) |
| 115 | return item_id in starred_ids |
| 116 | |
| 117 | def CountItemStars(self, cnxn, item_id): |
| 118 | """Returns the number of stars on the specified item.""" |
| 119 | count_dict = self.CountItemsStars(cnxn, [item_id]) |
| 120 | return count_dict.get(item_id, 0) |
| 121 | |
| 122 | def CountItemsStars(self, cnxn, item_ids): |
| 123 | """Get a dict {item_id: count} for the given items.""" |
| 124 | item_count_dict, missed_ids = self.star_count_cache.GetAll(item_ids) |
| 125 | |
| 126 | if missed_ids: |
| 127 | rows = self.tbl.Select( |
| 128 | cnxn, cols=[self.item_col, 'COUNT(%s)' % self.user_col], |
| 129 | group_by=[self.item_col], |
| 130 | **{self.item_col: missed_ids}) |
| 131 | # Ensure that every requested item_id has an entry so that even |
| 132 | # zero-star items get cached. |
| 133 | retrieved_counts = {item_id: 0 for item_id in missed_ids} |
| 134 | retrieved_counts.update(rows) |
| 135 | item_count_dict.update(retrieved_counts) |
| 136 | self.star_count_cache.CacheAll(retrieved_counts) |
| 137 | |
| 138 | return item_count_dict |
| 139 | |
| 140 | def _SetStarsBatch( |
| 141 | self, cnxn, item_id, starrer_user_ids, starred, commit=True): |
| 142 | """Sets or unsets stars for the specified item and users.""" |
| 143 | if starred: |
| 144 | rows = [(item_id, user_id) for user_id in starrer_user_ids] |
| 145 | self.tbl.InsertRows( |
| 146 | cnxn, [self.item_col, self.user_col], rows, ignore=True, |
| 147 | commit=commit) |
| 148 | else: |
| 149 | self.tbl.Delete( |
| 150 | cnxn, commit=commit, |
| 151 | **{self.item_col: item_id, self.user_col: starrer_user_ids}) |
| 152 | |
| 153 | self.star_cache.InvalidateKeys(cnxn, starrer_user_ids) |
| 154 | self.starrer_cache.Invalidate(cnxn, item_id) |
| 155 | self.star_count_cache.Invalidate(cnxn, item_id) |
| 156 | |
| 157 | def SetStarsBatch( |
| 158 | self, cnxn, item_id, starrer_user_ids, starred, commit=True): |
| 159 | """Sets or unsets stars for the specified item and users.""" |
| 160 | self._SetStarsBatch( |
| 161 | cnxn, item_id, starrer_user_ids, starred, commit=commit) |
| 162 | |
| 163 | def SetStar(self, cnxn, item_id, starrer_user_id, starred): |
| 164 | """Sets or unsets a star for the specified item and user.""" |
| 165 | self._SetStarsBatch(cnxn, item_id, [starrer_user_id], starred) |
| 166 | |
| 167 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 168 | class UserStarService(AbstractStarService): |
| 169 | """Star service for stars on users.""" |
| 170 | |
| 171 | def __init__(self, cache_manager): |
| 172 | tbl = sql.SQLTableManager(USERSTAR_TABLE_NAME) |
| 173 | super(UserStarService, self).__init__( |
| 174 | cache_manager, tbl, 'starred_user_id', 'user_id', 'user') |
| 175 | |
| 176 | |
| 177 | class ProjectStarService(AbstractStarService): |
| 178 | """Star service for stars on projects.""" |
| 179 | |
| 180 | def __init__(self, cache_manager): |
| 181 | tbl = sql.SQLTableManager(PROJECTSTAR_TABLE_NAME) |
| 182 | super(ProjectStarService, self).__init__( |
| 183 | cache_manager, tbl, 'project_id', 'user_id', 'project') |
| 184 | |
| 185 | |
| 186 | class HotlistStarService(AbstractStarService): |
| 187 | """Star service for stars on hotlists.""" |
| 188 | |
| 189 | def __init__(self, cache_manager): |
| 190 | tbl = sql.SQLTableManager(HOTLISTSTAR_TABLE_NAME) |
| 191 | super(HotlistStarService, self).__init__( |
| 192 | cache_manager, tbl, 'hotlist_id', 'user_id', 'hotlist') |
| 193 | |
| 194 | |
| 195 | class IssueStarService(AbstractStarService): |
| 196 | """Star service for stars on issues.""" |
| 197 | |
| 198 | def __init__(self, cache_manager): |
| 199 | tbl = sql.SQLTableManager(ISSUESTAR_TABLE_NAME) |
| 200 | super(IssueStarService, self).__init__( |
| 201 | cache_manager, tbl, 'issue_id', 'user_id', 'issue') |
| 202 | |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 203 | # HACK. Usually Monorail SQL table references should stay in their |
| 204 | # respective service layer class. But for performance reasons, it's better |
| 205 | # for us to directly query the Issue table here. |
| 206 | self.issue_tbl = sql.SQLTableManager('Issue') |
| 207 | |
| 208 | def ExpungeStarsByUsers(self, cnxn, user_ids, limit=None): |
| 209 | """Wipes a user's stars from the system. |
| 210 | |
| 211 | Ensure that issue metadata is updated on expunging. |
| 212 | |
| 213 | Args: |
| 214 | cnxn: connection to SQL database. |
| 215 | services: connections to persistence layer. |
| 216 | user_ids: users to delete stars for. |
| 217 | limit: max stars to delete for performance reasons. |
| 218 | """ |
| 219 | # TODO(zhangtiff): update star_count for updated issues. This is tricky |
| 220 | # because star_count needs to be recomputd for each issue, so this likely |
| 221 | # requires a task queue. |
| 222 | |
| 223 | timestamp = int(time.time()) |
| 224 | |
| 225 | shard_id = sql.RandomShardID() |
| 226 | issue_id_rows = self.tbl.Select( |
| 227 | cnxn, |
| 228 | cols=['IssueStar.issue_id'], |
| 229 | user_id=user_ids, |
| 230 | shard_id=shard_id, |
| 231 | limit=limit) |
| 232 | |
| 233 | super(IssueStarService, self).ExpungeStarsByUsers( |
| 234 | cnxn, user_ids, limit=limit) |
| 235 | issue_ids = [row[0] for row in issue_id_rows] |
| 236 | if issue_ids: |
| 237 | self.issue_tbl.Update( |
| 238 | cnxn, {'migration_modified': timestamp}, |
| 239 | id=issue_ids, |
| 240 | commit=False, |
| 241 | limit=limit) |
| 242 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 243 | # pylint: disable=arguments-differ |
| 244 | def SetStar( |
| 245 | self, cnxn, services, config, issue_id, starrer_user_id, starred): |
| 246 | """Add or remove a star on the given issue for the given user. |
| 247 | |
| 248 | Args: |
| 249 | cnxn: connection to SQL database. |
| 250 | services: connections to persistence layer. |
| 251 | config: ProjectIssueConfig PB for the project containing the issue. |
| 252 | issue_id: integer global ID of an issue. |
| 253 | starrer_user_id: user ID of the user who starred the issue. |
| 254 | starred: boolean True for adding a star, False when removing one. |
| 255 | """ |
| 256 | self.SetStarsBatch( |
| 257 | cnxn, services, config, issue_id, [starrer_user_id], starred) |
| 258 | |
| 259 | # pylint: disable=arguments-differ |
| 260 | def SetStarsBatch( |
| 261 | self, cnxn, services, config, issue_id, starrer_user_ids, starred): |
| 262 | """Add or remove a star on the given issue for the given users. |
| 263 | |
| 264 | Args: |
| 265 | cnxn: connection to SQL database. |
| 266 | services: connections to persistence layer. |
| 267 | config: ProjectIssueConfig PB for the project containing the issue. |
| 268 | issue_id: integer global ID of an issue. |
| 269 | starrer_user_id: user ID of the user who starred the issue. |
| 270 | starred: boolean True for adding a star, False when removing one. |
| 271 | """ |
| 272 | logging.info( |
| 273 | 'SetStarsBatch:%r, %r, %r', issue_id, starrer_user_ids, starred) |
| 274 | super(IssueStarService, self).SetStarsBatch( |
| 275 | cnxn, issue_id, starrer_user_ids, starred) |
| 276 | |
| 277 | # Because we will modify issues, load from DB rather than cache. |
| 278 | issue = services.issue.GetIssue(cnxn, issue_id, use_cache=False) |
| 279 | issue.star_count = self.CountItemStars(cnxn, issue_id) |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 280 | issue.migration_modified_timestamp = int(time.time()) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 281 | filterrules_helpers.ApplyFilterRules(cnxn, services, issue, config) |
| 282 | # Note: only star_count could change due to the starring, but any |
| 283 | # field could have changed as a result of filter rules. |
| 284 | services.issue.UpdateIssue(cnxn, issue) |
| 285 | |
| 286 | self.star_cache.InvalidateKeys(cnxn, starrer_user_ids) |
| 287 | self.starrer_cache.Invalidate(cnxn, issue_id) |
| 288 | |
| 289 | # TODO(crbug.com/monorail/8098): This method should replace SetStarsBatch. |
| 290 | # New code should be calling SetStarsBatch_SkipIssueUpdate. |
| 291 | # SetStarsBatch, does issue.star_count updating that should be done |
| 292 | # in the business logic layer instead. E.g. We can create a |
| 293 | # WorkEnv.BatchSetStars() that includes the star_count updating work. |
| 294 | def SetStarsBatch_SkipIssueUpdate( |
| 295 | self, cnxn, issue_id, starrer_user_ids, starred, commit=True): |
| 296 | # type: (MonorailConnection, int, Sequence[int], bool, Optional[bool]) |
| 297 | # -> None |
| 298 | """Add or remove a star on the given issue for the given users. |
| 299 | |
| 300 | Note: unlike SetStarsBatch above, does not make any updates to the |
| 301 | the issue itself e.g. updating issue.star_count. |
| 302 | |
| 303 | """ |
| 304 | logging.info( |
| 305 | 'SetStarsBatch:%r, %r, %r', issue_id, starrer_user_ids, starred) |
| 306 | super(IssueStarService, self).SetStarsBatch( |
| 307 | cnxn, issue_id, starrer_user_ids, starred, commit=commit) |
| 308 | |
| 309 | self.star_cache.InvalidateKeys(cnxn, starrer_user_ids) |
| 310 | self.starrer_cache.Invalidate(cnxn, issue_id) |