changeset 423:3fe60148f75c

Fixing #203; use Redis for who's online function.
author Brian Neal <bgneal@gmail.com>
date Sat, 23 Apr 2011 19:19:38 +0000
parents 6309814cd6f7
children 8df6e9edac22
files gpp/core/management/commands/clean_last_visit.py gpp/core/management/commands/max_users.py gpp/core/middleware.py gpp/core/models.py gpp/core/templatetags/core_tags.py gpp/core/whos_online.py
diffstat 6 files changed, 160 insertions(+), 97 deletions(-) [+]
line wrap: on
line diff
--- a/gpp/core/management/commands/clean_last_visit.py	Thu Apr 21 02:23:34 2011 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,27 +0,0 @@
-"""
-clean_last_visit is a custom manage.py command.
-It is intended to be called from a cron job to clean out old user and anonymous
-last visit records.
-
-"""
-import datetime
-
-from django.core.management.base import NoArgsCommand
-
-from core.models import UserLastVisit, AnonLastVisit
-
-USER_LV_AGE = datetime.timedelta(weeks=4)
-ANON_LV_AGE = datetime.timedelta(days=1)
-
-
-class Command(NoArgsCommand):
-    help = "Run periodically to clean out old last visit records."
-
-    def handle_noargs(self, **options):
-
-        now = datetime.datetime.now()
-        user_cut_off = now - USER_LV_AGE
-        anon_cut_off = now - ANON_LV_AGE
-
-        UserLastVisit.objects.filter(last_visit__lte=user_cut_off).delete()
-        AnonLastVisit.objects.filter(last_visit__lte=anon_cut_off).delete()
--- a/gpp/core/management/commands/max_users.py	Thu Apr 21 02:23:34 2011 +0000
+++ b/gpp/core/management/commands/max_users.py	Sat Apr 23 19:19:38 2011 +0000
@@ -7,7 +7,8 @@
 
 from django.core.management.base import NoArgsCommand
 
-from core.models import UserLastVisit, AnonLastVisit, Statistic
+from core.models import Statistic
+from core.whos_online import get_users_online, get_visitors_online, tick
 
 
 class Command(NoArgsCommand):
@@ -16,10 +17,9 @@
     def handle_noargs(self, **options):
 
         now = datetime.datetime.now()
-        cut_off = now - datetime.timedelta(minutes=15)
 
-        users = UserLastVisit.objects.filter(last_visit__gte=cut_off).count()
-        guests = AnonLastVisit.objects.filter(last_visit__gte=cut_off).count()
+        users = len(get_users_online())
+        guests = len(get_visitors_online())
 
         updated = False
         try:
@@ -42,3 +42,6 @@
 
         if updated:
             stat.save()
+
+        # "tick" the who's online data collector
+        tick()
--- a/gpp/core/middleware.py	Thu Apr 21 02:23:34 2011 +0000
+++ b/gpp/core/middleware.py	Sat Apr 23 19:19:38 2011 +0000
@@ -6,9 +6,8 @@
 from django.contrib.auth import logout
 from django.conf import settings
 
-from core.models import UserLastVisit
-from core.models import AnonLastVisit
 from core.functions import get_ip
+from core.whos_online import report_user, report_visitor
 
 
 class InactiveUserMiddleware(object):
@@ -26,7 +25,8 @@
 
 
 ONLINE_COOKIE = 'sg101_online'  # online cookie name
-ONLINE_TIMEOUT = 10 * 60        # online cookie lifetime in seconds
+ONLINE_TIMEOUT = 5 * 60         # online cookie lifetime in seconds
+
 
 class WhosOnline(object):
     """
@@ -50,40 +50,23 @@
 
         if request.user.is_authenticated():
             if request.COOKIES.get(ONLINE_COOKIE) is None:
-                # update the last seen timestamp
-                try:
-                    ulv = UserLastVisit.objects.get(user=request.user)
-                except UserLastVisit.DoesNotExist:
-                    ulv = UserLastVisit(user=request.user)
+                # report that we've seen the user
+                report_user(request.user.username)
 
-                ulv.last_visit = datetime.datetime.now()
-                ulv.save()
-
-                # set a cookie to expire in 10 minutes or so
+                # set a cookie to expire
                 response.set_cookie(ONLINE_COOKIE, '1', max_age=ONLINE_TIMEOUT)
         else:
             if request.COOKIES.get(settings.CSRF_COOKIE_NAME) is not None:
                 # We have a non-authenticated user that has cookies enabled. This
                 # means we can track them.
                 if request.COOKIES.get(ONLINE_COOKIE) is None:
-                    # update the timestamp for this anonymous visitor
+                    # see if we can get the IP address
                     ip = get_ip(request)
                     if ip:
-                        try:
-                            alv = AnonLastVisit.objects.get(ip=ip)
-                        except AnonLastVisit.DoesNotExist:
-                            alv = AnonLastVisit(ip=ip)
+                        # report that we've seen this visitor
+                        report_visitor(ip)
 
-                        alv.last_visit = datetime.datetime.now()
-
-                        # There is a race condition and sometimes another thread
-                        # saves a record before we do; just log this if it happens.
-                        try:
-                            alv.save()
-                        except IntegrityError:
-                            logging.exception('WhosOnline.process_response')
-
-                        # set a cookie to expire in 10 minutes or so
+                        # set a cookie to expire
                         response.set_cookie(ONLINE_COOKIE, '1', max_age=ONLINE_TIMEOUT)
 
         return response
--- a/gpp/core/models.py	Thu Apr 21 02:23:34 2011 +0000
+++ b/gpp/core/models.py	Sat Apr 23 19:19:38 2011 +0000
@@ -7,24 +7,6 @@
 from django.contrib.auth.models import User
 
 
-class UserLastVisit(models.Model):
-    """
-    This model represents timestamps indicating a user's last visit to the
-    site.
-    """
-    user = models.ForeignKey(User, unique=True)
-    last_visit = models.DateTimeField(db_index=True)
-
-
-class AnonLastVisit(models.Model):
-    """
-    This model represents timestamps for the last visit from non-authenticated
-    users.
-    """
-    ip = models.CharField(max_length=16, db_index=True, unique=True)
-    last_visit = models.DateTimeField(db_index=True)
-
-
 class Statistic(models.Model):
     """
     This model keeps track of site statistics. Currently, the only statistic
@@ -37,6 +19,6 @@
     max_anon_users_date = models.DateTimeField()
 
     def __unicode__(self):
-        return u'%d users on %s' % (self.max_users, 
+        return u'%d users on %s' % (self.max_users,
                 self.max_users_date.strftime('%Y-%m-%d %H:%M:%S'))
 
--- a/gpp/core/templatetags/core_tags.py	Thu Apr 21 02:23:34 2011 +0000
+++ b/gpp/core/templatetags/core_tags.py	Sat Apr 23 19:19:38 2011 +0000
@@ -9,7 +9,7 @@
 
 import repoze.timeago
 
-from core.models import UserLastVisit, AnonLastVisit
+from core.whos_online import get_users_online, get_visitors_online
 from bio.models import UserProfile
 
 
@@ -37,26 +37,15 @@
     """
     Displays a list of who is online.
     """
-    info = cache.get('whos_online')
-    if info:
-        return info
+    users = get_users_online()
+    visitors = get_visitors_online()
 
-    now = datetime.datetime.now()
-    cutoff = now - datetime.timedelta(minutes=10, seconds=5)
-
-    info = {}
-    users = UserLastVisit.objects.filter(
-            last_visit__gte=cutoff).values_list('user__username', flat=True)
-    num_users = len(users)
-    info['num_users'] = num_users
-    info['users'] = sorted(users)
-
-    num_guests = AnonLastVisit.objects.filter(last_visit__gte=cutoff).count()
-    info['num_guests'] = num_guests
-    info['total'] = num_users + num_guests
-
-    cache.set('whos_online', info, 60)
-    return info
+    return {
+        'num_users': len(users),
+        'users': sorted(users),
+        'num_guests': len(visitors),
+        'total': len(users) + len(visitors),
+    }
 
 
 # A somewhat ugly hack until we decide if we should be using UTC time
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/core/whos_online.py	Sat Apr 23 19:19:38 2011 +0000
@@ -0,0 +1,133 @@
+"""
+This module keeps track of who is online. We maintain records for both
+authenticated users ("users") and non-authenticated visitors ("visitors").
+"""
+import logging
+
+from django.conf import settings
+import redis
+
+# 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.
+
+# Redis connection and database settings
+
+HOST = getattr(settings, 'WHOS_ONLINE_REDIS_HOST', 'localhost')
+PORT = getattr(settings, 'WHOS_ONLINE_REDIS_PORT', 6379)
+DB = getattr(settings, 'WHOS_ONLINE_REDIS_DB', 0)
+
+# Redis key names:
+USER_CURRENT_KEY = "wo_user_current"
+USER_OLD_KEY = "wo_user_old"
+USER_KEYS = [USER_CURRENT_KEY, USER_OLD_KEY]
+
+VISITOR_CURRENT_KEY = "wo_visitor_current"
+VISITOR_OLD_KEY = "wo_visitor_old"
+VISITOR_KEYS = [VISITOR_CURRENT_KEY, VISITOR_OLD_KEY]
+
+
+# Logging: we don't want a Redis malfunction to bring down the site. So we
+# catch all Redis exceptions, log them, and press on.
+logger = logging.getLogger(__name__)
+
+
+def _get_connection():
+    """
+    Create and return a Redis connection. Returns None on failure.
+    """
+    try:
+        conn = redis.Redis(host=HOST, port=PORT, db=DB)
+        return conn
+    except redis.RedisError, e:
+        logger.error(e)
+
+    return None
+
+
+def report_user(username):
+    """
+    Call this function when a user has been seen. The username will be added to
+    the current set.
+    """
+    conn = _get_connection()
+    if conn:
+        try:
+            conn.sadd(USER_CURRENT_KEY, username)
+        except redis.RedisError, e:
+            logger.error(e)
+
+
+def report_visitor(ip):
+    """
+    Call this function when a visitor has been seen. The IP address will be
+    added the current set.
+    """
+    conn = _get_connection()
+    if conn:
+        try:
+            conn.sadd(VISITOR_CURRENT_KEY, ip)
+        except redis.RedisError, e:
+            logger.error(e)
+
+
+def get_users_online():
+    """
+    Returns a set of user names 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(USER_KEYS)
+        except redis.RedisError, e:
+            logger.error(e)
+
+    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)
+    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)