Repository navigation
API for monthly_active_users table #3633
Changes from all commits
6ef983c
f9f5559
4e5ac90
d766f26
ec716a3
c21d82b
08281fe
165e067
4a6725d
00f99f7
c4ffbec
9180061
74b1d46
4ecb4bd
5e2f7b8
c0affa7
897c51d
950807d
da90337
b2aab04
5593ff6
e10830e
886be75
d08296f
e40a510
16d78be
1911c03
e54794f
312ae74
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Add ability to limit number of monthly active users on the server |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| # -*- coding: utf-8 -*- | ||
| # Copyright 2018 New Vector | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| from twisted.internet import defer | ||
|
|
||
| from synapse.util.caches.descriptors import cached | ||
|
|
||
| from ._base import SQLBaseStore | ||
|
|
||
| # Number of msec of granularity to store the monthly_active_user timestamp | ||
| # This means it is not necessary to update the table on every request | ||
| LAST_SEEN_GRANULARITY = 60 * 60 * 1000 | ||
|
|
||
|
|
||
| class MonthlyActiveUsersStore(SQLBaseStore): | ||
| def __init__(self, dbconn, hs): | ||
| super(MonthlyActiveUsersStore, self).__init__(None, hs) | ||
| self._clock = hs.get_clock() | ||
| self.hs = hs | ||
|
|
||
| def reap_monthly_active_users(self): | ||
| """ | ||
| Cleans out monthly active user table to ensure that no stale | ||
| entries exist. | ||
|
|
||
| Returns: | ||
| Deferred[] | ||
| """ | ||
| def _reap_users(txn): | ||
|
|
||
| thirty_days_ago = ( | ||
| int(self._clock.time_msec()) - (1000 * 60 * 60 * 24 * 30) | ||
| ) | ||
| # Purge stale users | ||
| sql = "DELETE FROM monthly_active_users WHERE timestamp < ?" | ||
| txn.execute(sql, (thirty_days_ago,)) | ||
|
|
||
| # If MAU user count still exceeds the MAU threshold, then delete on | ||
| # a least recently active basis. | ||
| # Note it is not possible to write this query using OFFSET due to | ||
| # incompatibilities in how sqlite and postgres support the feature. | ||
| # sqlite requires 'LIMIT -1 OFFSET ?', the LIMIT must be present | ||
| # While Postgres does not require 'LIMIT', but also does not support | ||
| # negative LIMIT values. So there is no way to write it that both can | ||
| # support | ||
| sql = """ | ||
| DELETE FROM monthly_active_users | ||
| WHERE user_id NOT IN ( | ||
| SELECT user_id FROM monthly_active_users | ||
| ORDER BY timestamp DESC | ||
| LIMIT ? | ||
| ) | ||
| """ | ||
| txn.execute(sql, (self.hs.config.max_mau_value,)) | ||
|
|
||
| yield self.runInteraction("reap_monthly_active_users", _reap_users) | ||
| # It seems poor to invalidate the whole cache, Postgres supports | ||
| # 'Returning' which would allow me to invalidate only the | ||
| # specific users, but sqlite has no way to do this and instead | ||
| # I would need to SELECT and the DELETE which without locking | ||
| # is racy. | ||
| # Have resolved to invalidate the whole cache for now and do | ||
| # something about it if and when the perf becomes significant | ||
| self._user_last_seen_monthly_active.invalidate_all() | ||
| self.get_monthly_active_count.invalidate_all() | ||
|
|
||
| @cached(num_args=0) | ||
| def get_monthly_active_count(self): | ||
| """Generates current count of monthly active users | ||
|
|
||
| Returns: | ||
| Defered[int]: Number of current monthly active users | ||
| """ | ||
|
|
||
| def _count_users(txn): | ||
| sql = "SELECT COALESCE(count(*), 0) FROM monthly_active_users" | ||
|
|
||
| txn.execute(sql) | ||
| count, = txn.fetchone() | ||
| return count | ||
| return self.runInteraction("count_users", _count_users) | ||
|
|
||
| def upsert_monthly_active_user(self, user_id): | ||
| """ | ||
| Updates or inserts monthly active user member | ||
| Arguments: | ||
| user_id (str): user to add/update | ||
| Deferred[bool]: True if a new entry was created, False if an | ||
| existing one was updated. | ||
| """ | ||
| is_insert = self._simple_upsert( | ||
| desc="upsert_monthly_active_user", | ||
| table="monthly_active_users", | ||
| keyvalues={ | ||
| "user_id": user_id, | ||
| }, | ||
| values={ | ||
| "timestamp": int(self._clock.time_msec()), | ||
| }, | ||
| lock=False, | ||
| ) | ||
| if is_insert: | ||
| self._user_last_seen_monthly_active.invalidate((user_id,)) | ||
| self.get_monthly_active_count.invalidate(()) | ||
|
|
||
| @cached(num_args=1) | ||
| def _user_last_seen_monthly_active(self, user_id): | ||
| """ | ||
| Checks if a given user is part of the monthly active user group | ||
| Arguments: | ||
| user_id (str): user to add/update | ||
| Return: | ||
| int : timestamp since last seen, None if never seen | ||
|
|
||
| """ | ||
|
|
||
| return(self._simple_select_one_onecol( | ||
| table="monthly_active_users", | ||
| keyvalues={ | ||
| "user_id": user_id, | ||
| }, | ||
| retcol="timestamp", | ||
| allow_none=True, | ||
| desc="_user_last_seen_monthly_active", | ||
| )) | ||
|
|
||
| @defer.inlineCallbacks | ||
| def populate_monthly_active_users(self, user_id): | ||
| """Checks on the state of monthly active user limits and optionally | ||
| add the user to the monthly active tables | ||
|
|
||
| Args: | ||
| user_id(str): the user_id to query | ||
| """ | ||
|
|
||
| if self.hs.config.limit_usage_by_mau: | ||
| last_seen_timestamp = yield self._user_last_seen_monthly_active(user_id) | ||
| now = self.hs.get_clock().time_msec() | ||
|
|
||
| # We want to reduce to the total number of db writes, and are happy | ||
| # to trade accuracy of timestamp in order to lighten load. This means | ||
| # We always insert new users (where MAU threshold has not been reached), | ||
| # but only update if we have not previously seen the user for | ||
| # LAST_SEEN_GRANULARITY ms | ||
| if last_seen_timestamp is None: | ||
| count = yield self.get_monthly_active_count() | ||
| if count < self.hs.config.max_mau_value: | ||
| yield self.upsert_monthly_active_user(user_id) | ||
| elif now - last_seen_timestamp > LAST_SEEN_GRANULARITY: | ||
| yield self.upsert_monthly_active_user(user_id) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you quickly add a comment to say why we're not always upserting? |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| /* Copyright 2018 New Vector Ltd | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| -- a table of monthly active users, for use where blocking based on mau limits | ||
| CREATE TABLE monthly_active_users ( | ||
| user_id TEXT NOT NULL, | ||
| -- Last time we saw the user. Not guaranteed to be accurate due to rate limiting | ||
| -- on updates, Granularity of updates governed by | ||
| -- synapse.storage.monthly_active_users.LAST_SEEN_GRANULARITY | ||
| -- Measured in ms since epoch. | ||
| timestamp BIGINT NOT NULL | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm really surprised this is called
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (it's also a problem because you can't rename columns in sqlite, so we really want to get it right the first time). |
||
| ); | ||
|
|
||
| CREATE UNIQUE INDEX monthly_active_users_users ON monthly_active_users(user_id); | ||
| CREATE INDEX monthly_active_users_time_stamp ON monthly_active_users(timestamp); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this would be |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Upserts without locks are dangerous as they're racey. Upserts with locks are bad and should be avoided.
I'd quite like to see how this is actually hooked up
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have reworked nits, and left upserts until we figure out a better way to solve this