Mercurial > public > sg101
diff gpp/core/whos_online.py @ 519:f72ace06658a
For #194, rework the who's online and max users functions.
author | Brian Neal <bgneal@gmail.com> |
---|---|
date | Sat, 17 Dec 2011 19:29:24 +0000 |
parents | 6f5fff924877 |
children |
line wrap: on
line diff
--- a/gpp/core/whos_online.py Fri Dec 16 01:17:35 2011 +0000 +++ b/gpp/core/whos_online.py Sat Dec 17 19:29:24 2011 +0000 @@ -2,27 +2,28 @@ This module keeps track of who is online. We maintain records for both authenticated users ("users") and non-authenticated visitors ("visitors"). """ +import datetime import logging +import time import redis from core.services import get_redis_connection +from core.models import Statistic -# Users and visitors each have 2 sets that we store in a Redis database: -# a current set and an old set. Whenever a user or visitor is seen, the -# current set is updated. At some interval, the current set is renamed -# to the old set, thus destroying it. At any given time, the union of the -# current and old sets is the "who's online" set. +# Users and visitors each have a sorted set in a Redis database. When a user or +# visitor is seen, the respective set is updated with the score of the current +# time. Periodically we remove elements by score (time) to stale out members. # Redis key names: -USER_CURRENT_KEY = "wo_user_current" -USER_OLD_KEY = "wo_user_old" -USER_KEYS = [USER_CURRENT_KEY, USER_OLD_KEY] +USER_SET_KEY = "whos_online:users" +VISITOR_SET_KEY = "whos_online:visitors" -VISITOR_CURRENT_KEY = "wo_visitor_current" -VISITOR_OLD_KEY = "wo_visitor_old" -VISITOR_KEYS = [VISITOR_CURRENT_KEY, VISITOR_OLD_KEY] +CORE_STATS_KEY = "core:stats" + +# the period over which we collect who's online stats: +MAX_AGE = datetime.timedelta(minutes=15) # Logging: we don't want a Redis malfunction to bring down the site. So we @@ -43,87 +44,196 @@ return None +def to_timestamp(dt): + """ + Turn the supplied datetime object into a UNIX timestamp integer. + + """ + return int(time.mktime(dt.timetuple())) + + +def _zadd(key, member): + """ + Adds the member to the given set key, using the current time as the score. + + """ + conn = _get_connection() + if conn: + ts = to_timestamp(datetime.datetime.now()) + try: + conn.zadd(key, ts, member) + except redis.RedisError, e: + logger.error(e) + + +def _zrangebyscore(key): + """ + Performs a zrangebyscore operation on the set given by key. + The minimum score will be a timestap equal to the current time + minus MAX_AGE. The maximum score will be a timestap equal to the + current time. + + """ + conn = _get_connection() + if conn: + now = datetime.datetime.now() + min = to_timestamp(now - MAX_AGE) + max = to_timestamp(now) + try: + return conn.zrangebyscore(key, min, max) + except redis.RedisError, e: + logger.error(e) + + return [] + + def report_user(username): """ Call this function when a user has been seen. The username will be added to - the current set. + the set of users online. + """ - conn = _get_connection() - if conn: - try: - conn.sadd(USER_CURRENT_KEY, username) - except redis.RedisError, e: - logger.error(e) + _zadd(USER_SET_KEY, username) def report_visitor(ip): """ Call this function when a visitor has been seen. The IP address will be - added the current set. + added to the set of visitors online. + + """ + _zadd(VISITOR_SET_KEY, ip) + + +def get_users_online(): + """ + Returns a list of user names from the user set. + sets. + """ + return _zrangebyscore(USER_SET_KEY) + + +def get_visitors_online(): + """ + Returns a list of visitor IP addresses from the visitor set. + """ + return _zrangebyscore(VISITOR_SET_KEY) + + +def _tick(conn): + """ + Call this function to "age out" the sets by removing old users/visitors. + It then returns a tuple of the form: + (zcard users, zcard visitors) + + """ + cutoff = to_timestamp(datetime.datetime.now() - MAX_AGE) + + try: + pipeline = conn.pipeline(transaction=False) + pipeline.zremrangebyscore(USER_SET_KEY, 0, cutoff) + pipeline.zremrangebyscore(VISITOR_SET_KEY, 0, cutoff) + pipeline.zcard(USER_SET_KEY) + pipeline.zcard(VISITOR_SET_KEY) + result = pipeline.execute() + except redis.RedisError, e: + logger.error(e) + return 0, 0 + + return result[2], result[3] + + +def max_users(): + """ + Run this function periodically to clean out the sets and to compute our max + users and max visitors statistics. + """ conn = _get_connection() + if not conn: + return + + num_users, num_visitors = _tick(conn) + now = datetime.datetime.now() + + stats = get_stats(conn) + update = False + + if stats is None: + stats = Statistic(id=1, + max_users=num_users, + max_users_date=now, + max_anon_users=num_visitors, + max_anon_users_date=now) + update = True + else: + if num_users > stats.max_users: + stats.max_users = num_users + stats.max_users_date = now + update = True + + if num_visitors > stats.max_anon_users: + stats.max_anon_users = num_visitors + stats.max_anon_users_date = now + update = True + + if update: + _save_stats_to_redis(conn, stats) + stats.save() + + +def get_stats(conn=None): + """ + This function retrieves the who's online max user stats out of Redis. If + the keys do not exist in Redis, we fall back to the database. If the stats + are not available, None is returned. + Note that if we can find stats data, it will be returned as a Statistic + object. + + """ + if conn is None: + conn = _get_connection() + + stats = None if conn: try: - conn.sadd(VISITOR_CURRENT_KEY, ip) + stats = conn.hgetall(CORE_STATS_KEY) except redis.RedisError, e: logger.error(e) + if stats: + return Statistic( + id=1, + max_users=stats['max_users'], + max_users_date=datetime.datetime.fromtimestamp( + float(stats['max_users_date'])), + max_anon_users=stats['max_anon_users'], + max_anon_users_date=datetime.datetime.fromtimestamp( + float(stats['max_anon_users_date']))) -def get_users_online(): + try: + stats = Statistic.objects.get(pk=1) + except Statistic.DoesNotExist: + return None + else: + _save_stats_to_redis(conn, stats) + return stats + + +def _save_stats_to_redis(conn, stats): """ - Returns a set of user names which is the union of the current and old - sets. + Saves the statistics to Redis. A TTL is put on the key to prevent Redis and + the database from becoming out of sync. + """ - conn = _get_connection() - if conn: - try: - # Note that keys that do not exist are considered empty sets - return conn.sunion(USER_KEYS) - except redis.RedisError, e: - logger.error(e) + fields = dict( + max_users=stats.max_users, + max_users_date=to_timestamp(stats.max_users_date), + max_anon_users=stats.max_anon_users, + max_anon_users_date=to_timestamp(stats.max_anon_users_date)) - return set() - - -def get_visitors_online(): - """ - Returns a set of visitor IP addresses which is the union of the current - and old sets. - """ - conn = _get_connection() - if conn: - try: - # Note that keys that do not exist are considered empty sets - return conn.sunion(VISITOR_KEYS) - except redis.RedisError, e: - logger.error(e) - - return set() - - -def _tick_set(conn, current, old): - """ - This function renames the set named "current" to "old". - """ - # An exception may be raised if the current key doesn't exist; if that - # happens we have to delete the old set because no one is online. try: - conn.rename(current, old) - except redis.ResponseError: - try: - del conn[old] - except redis.RedisError, e: - logger.error(e) + conn.hmset(CORE_STATS_KEY, fields) + conn.expire(CORE_STATS_KEY, 4 * 60 * 60) except redis.RedisError, e: logger.error(e) - - -def tick(): - """ - Call this function to "age out" the old sets by renaming the current sets - to the old. - """ - conn = _get_connection() - if conn: - _tick_set(conn, USER_CURRENT_KEY, USER_OLD_KEY) - _tick_set(conn, VISITOR_CURRENT_KEY, VISITOR_OLD_KEY)