Index: trac/db_default.py
===================================================================
--- trac/db_default.py	(revision 3238)
+++ trac/db_default.py	(working copy)
@@ -17,7 +17,7 @@
 from trac.db import Table, Column, Index
 
 # Database version identifier. Used for automatic upgrades.
-db_version = 17
+db_version = 18
 
 def __mkreports(reports):
     """Utility function used to create report data in same syntax as the
@@ -48,9 +48,13 @@
         Column('time', type='int')],
     Table('session', key=('sid', 'authenticated', 'var_name'))[
         Column('sid'),
+        Column('time', type='int'),
         Column('authenticated', type='int'),
         Column('var_name'),
-        Column('var_value')],
+        Column('var_value'),
+        Index(['sid']),
+        Index(['time']),
+        Index(['authenticated'])],
 
     # Attachments
     Table('attachment', key=('type', 'id', 'filename'))[
@@ -119,7 +123,8 @@
         Column('field'),
         Column('oldvalue'),
         Column('newvalue'),
-        Index(['ticket', 'time'])],
+        Index(['ticket']),
+        Index(['time'])],
     Table('ticket_custom', key=('ticket', 'name'))[
         Column('ticket', type='int'),
         Column('name'),
Index: trac/tests/env.py
===================================================================
--- trac/tests/env.py	(revision 3238)
+++ trac/tests/env.py	(working copy)
@@ -27,8 +27,8 @@
         """Testing env.get_known_users"""
         cursor = self.db.cursor()
         cursor.execute("INSERT INTO session "
-                       "VALUES ('123',0,'email','a@example.com')")
-        cursor.executemany("INSERT INTO session VALUES (%s,1,%s,%s)",
+                       "VALUES ('123',0,0,'email','a@example.com')")
+        cursor.executemany("INSERT INTO session VALUES (%s,0,1,%s,%s)",
                            [('tom', 'name', 'Tom'),
                             ('tom', 'email', 'tom@example.com'),
                             ('joe', 'email', 'joe@example.com'),
Index: trac/upgrades/db18.py
===================================================================
--- trac/upgrades/db18.py	(revision 0)
+++ trac/upgrades/db18.py	(revision 0)
@@ -0,0 +1,59 @@
+from trac.db import Table, Column, Index, DatabaseManager
+
+def do_upgrade(env, ver, cursor):
+    cursor.execute("CREATE TEMP TABLE session_old AS SELECT * FROM session")
+    cursor.execute("DROP TABLE session")
+    cursor.execute("CREATE TEMP TABLE ticket_change_old AS SELECT * FROM ticket_change")
+    cursor.execute("DROP TABLE ticket_change")
+
+    # A slightly more denormalized session schema where the 'last_visit' values are
+    # stored in a column for performance reasons
+    session = Table('session', key=('sid', 'authenticated', 'var_name'))[
+        Column('sid'),
+        Column('time', type='int'),
+        Column('authenticated', type='int'),
+        Column('var_name'),
+        Column('var_value'),
+        Index(['sid']),
+        Index(['time']),
+        Index(['authenticated'])]
+
+    # The old ticket_change table had a composite index on (ticket, time), this however
+    # does not help us when we need to order changes by time (for the timeline).
+    #
+    # Unfortunately this index is called "ticket_change_idx" on old environments
+    # and "ticket_change_ticket_time_idx" on newer ones. This makes it easiest to
+    # simply recreate the entire table.
+    ticket_change = Table('ticket_change', key=('ticket', 'time', 'field'))[
+        Column('ticket', type='int'),
+        Column('time', type='int'),
+        Column('author'),
+        Column('field'),
+        Column('oldvalue'),
+        Column('newvalue'),
+        Index(['ticket']),
+        Index(['time'])]
+    
+    db_connector, _ = DatabaseManager(env)._get_connector()
+    for stmt in db_connector.to_sql(session):
+        cursor.execute(stmt)
+    for stmt in db_connector.to_sql(ticket_change):
+        cursor.execute(stmt)
+
+    # Add an index to the temporary table to speed up the conversion
+    cursor.execute("CREATE INDEX session_old_sid_idx ON session_old(sid)")
+    # Insert the sessions into the new table
+    cursor.execute("INSERT INTO session (sid, time, authenticated, "
+                   "var_name, var_value) SELECT "
+                   "s.sid, s2.var_value, s.authenticated, s.var_name, s.var_value "
+                   "FROM session_old s, session_old s2 "
+                   "WHERE s.sid=s2.sid AND s2.var_name='last_visit' AND "
+                   "s.var_name <> 'last_visit'")
+
+    # Insert ticket change data into the new table
+    cursor.execute("INSERT INTO ticket_change "
+                   "(ticket, time, author, field, oldvalue, newvalue) "
+                   "SELECT ticket, time, author, field, oldvalue, newvalue "
+                   "FROM ticket_change_old")
+
+
Index: trac/web/tests/session.py
===================================================================
--- trac/web/tests/session.py	(revision 3238)
+++ trac/web/tests/session.py	(working copy)
@@ -65,7 +65,7 @@
         authenticated session when the user logs in.
         """
         cursor = self.db.cursor()
-        cursor.execute("INSERT INTO session VALUES ('123456', 0, 'foo', 'bar')")
+        cursor.execute("INSERT INTO session VALUES ('123456', 0, 0, 'foo', 'bar')")
 
         incookie = Cookie()
         incookie['trac_session'] = '123456'
@@ -103,7 +103,7 @@
         accordingly for an anonymous session.
         """
         cursor = self.db.cursor()
-        cursor.execute("INSERT INTO session VALUES ('123456', 0, 'foo', 'bar')")
+        cursor.execute("INSERT INTO session VALUES ('123456', 0, 0, 'foo', 'bar')")
 
         incookie = Cookie()
         incookie['trac_session'] = '123456'
@@ -123,7 +123,7 @@
         for an anonymous session.
         """
         cursor = self.db.cursor()
-        cursor.execute("INSERT INTO session VALUES ('123456', 0, 'foo', 'bar')")
+        cursor.execute("INSERT INTO session VALUES ('123456', 0, 0, 'foo', 'bar')")
 
         incookie = Cookie()
         incookie['trac_session'] = '123456'
@@ -143,7 +143,7 @@
         """
         cursor = self.db.cursor()
         cursor.execute("INSERT INTO session "
-                       "VALUES ('987654', 0, 'last_visit', %s)",
+                       "VALUES ('987654', %s, 0, 'foo', 'bar')",
                        (time.time() - PURGE_AGE - 3600,))
         
         # We need to modify a different session to trigger the purging
@@ -169,7 +169,7 @@
         # Make sure the session has data so that it doesn't get dropped
         cursor = self.db.cursor()
         cursor.execute("INSERT INTO session "
-                       "VALUES ('123456', 0, 'last_visit', %s)",
+                       "VALUES ('123456', %s, 0, 'foo', 'bar')",
                        (int(now - UPDATE_INTERVAL - 3600),))
 
         incookie = Cookie()
@@ -177,6 +177,7 @@
         req = Mock(authname='anonymous', base_path='/', incookie=incookie,
                    outcookie=Cookie())
         session = Session(self.env, req)
+        del session['foo']
         session.save()
 
         cursor.execute("SELECT COUNT(*) FROM session WHERE sid='123456' AND "
@@ -203,7 +204,7 @@
         accordingly for an authenticated session.
         """
         cursor = self.db.cursor()
-        cursor.execute("INSERT INTO session VALUES ('john', 1, 'foo', 'bar')")
+        cursor.execute("INSERT INTO session VALUES ('john', 0, 1, 'foo', 'bar')")
 
         req = Mock(authname='john', base_path='/', incookie=Cookie())
         session = Session(self.env, req)
@@ -220,7 +221,7 @@
         for an authenticated session.
         """
         cursor = self.db.cursor()
-        cursor.execute("INSERT INTO session VALUES ('john', 1, 'foo', 'bar')")
+        cursor.execute("INSERT INTO session VALUES ('john', 0, 1, 'foo', 'bar')")
 
         req = Mock(authname='john', base_path='/', incookie=Cookie())
         session = Session(self.env, req)
@@ -240,9 +241,8 @@
 
         # Make sure the session has data so that it doesn't get dropped
         cursor = self.db.cursor()
-        cursor.executemany("INSERT INTO session VALUES ('123456', 0, %s, %s)",
-                           [('last_visit', int(now - UPDATE_INTERVAL - 3600)),
-                            ('foo', 'bar')])
+        cursor.execute("INSERT INTO session VALUES ('123456', %s, 0, %s, %s)",
+                           (int(now - UPDATE_INTERVAL - 3600), 'foo', 'bar'))
 
         incookie = Cookie()
         incookie['trac_session'] = '123456'
@@ -254,8 +254,8 @@
 
         self.assertEqual(PURGE_AGE, outcookie['trac_session']['expires'])
 
-        cursor.execute("SELECT var_value FROM session WHERE sid='123456' AND "
-                       "authenticated=0 AND var_name='last_visit'")
+        cursor.execute("SELECT time FROM session WHERE sid='123456' AND "
+                       "authenticated=0")
         self.assertAlmostEqual(now, int(cursor.fetchone()[0]), -1)
 
 
Index: trac/web/session.py
===================================================================
--- trac/web/session.py	(revision 3238)
+++ trac/web/session.py	(working copy)
@@ -34,6 +34,7 @@
         self.env = env
         self.req = req
         self.sid = None
+        self.last_visit = 0
         self._old = {}
         if req.authname == 'anonymous':
             if not req.incookie.has_key(COOKIE_KEY):
@@ -58,17 +59,19 @@
         db = self.env.get_db_cnx()
         cursor = db.cursor()
         self.sid = sid
-        cursor.execute("SELECT var_name,var_value FROM session "
+        cursor.execute("SELECT var_name,var_value, time FROM session "
                        "WHERE sid=%s AND authenticated=%s",
                        (sid, int(authenticated)))
-        for name, value in cursor:
+        for name, value, time_ in cursor:
             self[name] = value
+            self.last_visit = int(time_)
         self._old.update(self)
 
         # Refresh the session cookie if this is the first visit since over a day
-        if not authenticated and self.has_key('last_visit'):
-            if time.time() - int(self['last_visit']) > UPDATE_INTERVAL:
+        if not authenticated and self.last_visit:
+            if time.time() - self.last_visit > UPDATE_INTERVAL:
                 self.bake_cookie()
+        #self.last_visit = time.time()
 
     def change_sid(self, new_sid):
         assert self.req.authname == 'anonymous', \
@@ -127,22 +130,6 @@
             # persist it
             return
 
-        changed = False
-        now = int(time.time())
-
-        if self.req.authname == 'anonymous':
-            # Update the session last visit time if it is over an hour old,
-            # so that session doesn't get purged
-            last_visit = int(self.get('last_visit', 0))
-            if now - last_visit > UPDATE_INTERVAL:
-                self.env.log.info("Refreshing session %s" % self.sid)
-                self['last_visit'] = now
-
-            # If the only data in the session is the last_visit time, it makes
-            # no sense to keep the session around
-            if len(self.items()) == 1:
-                del self['last_visit']
-
         db = self.env.get_db_cnx()
         cursor = db.cursor()
         authenticated = int(self.req.authname != 'anonymous')
@@ -154,9 +141,10 @@
                 self.env.log.debug('Adding variable %s with value "%s" to '
                                    'session %s' % (k, v,
                                    self.sid or self.req.authname))
-                cursor.execute("INSERT INTO session VALUES(%s,%s,%s,%s)",
-                               (self.sid, authenticated, k, v))
-                changed = True
+                cursor.execute("INSERT INTO session "
+                               "(sid,time,authenticated,var_name,var_value) "
+                               "VALUES(%s,%s,%s,%s,%s)",
+                               (self.sid, self.last_visit, authenticated, k, v))
             elif v != self._old[k]:
                 self.env.log.debug('Changing variable %s from "%s" to "%s" in '
                                    'session %s' % (k, self._old[k], v,
@@ -165,7 +153,6 @@
                                "WHERE sid=%s AND authenticated=%s "
                                "AND var_name=%s", (v, self.sid, authenticated,
                                k))
-                changed = True
 
         # Find all variables that have been deleted and also remove them from
         # the database
@@ -175,16 +162,20 @@
             cursor.execute("DELETE FROM session WHERE sid=%s AND "
                            "authenticated=%s AND var_name=%s",
                            (self.sid, authenticated, k))
-            changed = True
 
-        if changed:
+        now = int(time.time())
+        # Update the session last visit time if it is over an hour old,
+        # so that session doesn't get purged
+        if now - self.last_visit > UPDATE_INTERVAL:
+            self.last_visit = now
+            self.env.log.info("Refreshing session %s" % self.sid)
+            cursor.execute('UPDATE session SET time=%s WHERE sid=%s',
+                           (self.last_visit, self.sid))
+            
             # Purge expired sessions. We do this only when the session was
             # changed as to minimize the purging.
             mintime = now - PURGE_AGE
             self.env.log.debug('Purging old, expired, sessions.')
             cursor.execute("DELETE FROM session WHERE authenticated=0 AND "
-                           "sid IN (SELECT sid FROM session WHERE "
-                           "var_name='last_visit' AND var_value < %s)",
-                           (mintime,))
-
-            db.commit()
+                           "time < %s", (mintime,))
+        db.commit()
