# Copyright (C) 1998,1999,2000 by the Free Software Foundation, Inc.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software 
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.

""" Track pending confirmation of subscriptions.

Pending().new(stuff...) places an item's data in the db, returning its cookie.
Pending().confirmed(cookie) returns a tuple for the data, removing the item
from the db.  It returns None if the cookie is not registered.
"""

import os 
import marshal
import time
import whrandom
import mm_cfg
import LockFile

DB_PATH = os.path.join(mm_cfg.DATA_DIR, "pending_subscriptions.db")
LOCK_PATH = os.path.join(mm_cfg.LOCK_DIR, "pending_subscriptions.lock")
PENDING_REQUEST_LIFE = mm_cfg.PENDING_REQUEST_LIFE
# Something's probably wedged if we hit this.
DB_LOCK_TIMEOUT = 30
# Cull stale items from the db on save, after enough time since the last one:
CULL_INTERVAL = (mm_cfg.PENDING_REQUEST_LIFE / 10)

class Pending:
    """Db interface for tracking pending confirmations, using random cookies.

      .new(stuff...) places an item's data in the db, returning its cookie.
      .confirmed(cookie) returns a tuple for the data, removing item from db.

    The db is occasionally culled for stale items during saves."""
    # The db is a marshalled dict with two kinds of entries; a bunch of:
    #   cookie: (content..., timestamp)
    # and just one:
    #   LAST_CULL_KEY: next_cull_due_time
    # Dbs lacking the LAST_CULL_KEY are culled, at which point the cull key 
    # is added.
    LAST_CULL_KEY = "lastculltime"
    def __init__(self,
               db_path = DB_PATH,
               lock_path = LOCK_PATH,
               item_life = PENDING_REQUEST_LIFE,
               cull_interval = CULL_INTERVAL,
               db_lock_timeout = DB_LOCK_TIMEOUT):
        self.item_life = item_life
        self.db_path = db_path
        self.__lock = LockFile.LockFile(lock_path)
        self.cull_interval = cull_interval
        self.db_lock_timeout = db_lock_timeout
    def new(self, *content):
        """Create a new entry in the pending db, returning cookie for it."""
        now = int(time.time())
        db = self.__load()
        # Generate cookie between 1e5 and 1e6 and not already in the db.
        while 1:
            newcookie = int(whrandom.random() * 1e6)
            if newcookie >= 1e5 and not db.has_key(newcookie):
                break
        db[newcookie] = content + (now,) # Tack on timestamp.
        self.__save(db)
        return newcookie
    def confirmed(self, cookie):
        "Return entry for cookie, removing it from db, or None if not found."
        content = None
        got = None
        db = self.__load()
        try:
            if db.has_key(cookie):
                content = db[cookie][0:-1] # Strip off timestamp.
                got = 1
                del db[cookie]
        finally:
            if got:
                self.__save(db)
            else:
                self.__release_lock()
        return content
    def __load(self):
        "Return db as dict, returning an empty one if db not yet existant."
        self.__assert_lock(self.db_lock_timeout)
        try:
            fp = open(self.db_path,"r" )
            return marshal.load(fp)
        except IOError:
            # Not yet existing Initialize a fresh one:
            return {self.LAST_CULL_KEY: int(time.time())}
    def __save(self, db):
        """Marshal dict db to file - the exception is propagated on failure.
        Cull stale items from the db, if that hasn't been done in a while."""
        if not self.__lock.locked():
            raise LockFile.NotLockedError
        # Cull if its been a while (or if cull key is missing, ie, old
        # version - which will be reformed to new format by cull).
        if (db.get(self.LAST_CULL_KEY, 0)
            < int(time.time()) - self.cull_interval):
            self.__cull_db(db)
        fp = open(self.db_path, "w") 
        marshal.dump(db, fp) 
        fp.close()
        self.__release_lock()
    def __assert_lock(self, timeout):
        """Get the lock if not already acquired, or happily just keep it.

        Raises TimeOutError if unable to get lock within timeout."""
        try:
            self.__lock.lock(timeout)
        except LockFile.AlreadyLockedError:
            pass
    def __release_lock(self):
        self.__lock.unlock()
    def __cull_db(self, db):
        """Remove old items from db and revise last-culled timestamp."""
        now = int(time.time())
        too_old = now - self.item_life
        cullkey = self.LAST_CULL_KEY
        for k, v in db.items():
            if k == cullkey:
                continue
            if v[-1] < too_old:
                del db[k]
        # Register time after which a new cull is due:
        db[self.LAST_CULL_KEY] = now
