Friday, May 23, 2008

Caching in asp.net

I've been using a simple caching system on the project i'm currently working on for some time. But i realized some limitation of this system.

public object GetItemFromCache(int id)
{
    object o = Cache["item(" + id.ToString() + ")"];
    if (o == null)
    {
        o = GetItemFromDatabase(id);
        Cache.Insert("item(" + id.ToString() + ")",
            o,
                    CreateSqlDep("Bruker"), 
            DateTime.Now.AddMinutes(2), 
            Cache.NoSlidingExpiration,
            CacheItemPriority.Default, 
            null);
    }
    return o;
}

I wanted a more standardized way to define the data being cached. The data I cache are of 2 main types, data about certain users that is accessed many times for a limited time span, and data accessed a lot by all users. This data is either dependent on changes in the database or its not that important that the data is up to date.

The second type of data can be expensive to create, it comes for web services and heavy database queries. A problem with the caching method above is we can have situations where several threads request the data at the same time, doesn't find it and queries the database at the same time. I don't want this to happen, it can be resolved in two ways, either wait on the first thread to get the data, or show the old data until the new is available. And if we store the old data, we might as well automatically fetch updateed data when the cache expires.

Here is the system i came up with. I was a bit inspired by "Implementing Generic Caching", but i found this approach a bit limiting to what objects you could cache. I instead used a different system where we define catchable objects.

public class CachedItemMetadata
{
    public string BaseKey { get; set; }
    public ObjectLoader Loader { get; set; }
    public bool AutoReload { get; set; }
    public int AbsoluteExpiryMinutes { get; set; }
    public int SlidingExpiryMinutes { get; set; }
    public string[] TableDependency { get; set; }
}
 
public delegate object ObjectLoader(object[] param);
 
public class CachingService
{
    private class CachedItem
    {
        public Dictionary<string, object> Items { get; set; }
        public Dictionary<string, object> OldItems { get; set; }
        public Dictionary<string, Mutex> Locks { get; set; }
        public Dictionary<string, object[]> Params { get; set; }
        public CachedItemMetadata Metadata { get; set; }
        public CachedItem(CachedItemMetadata i)
        {
            Items = new Dictionary<string, object>();
            OldItems = new Dictionary<string, object>();
            Locks = new Dictionary<string, Mutex>();
            Params = new Dictionary<string, object[]>();
            Metadata = i;
        }
    }
 
    private static Dictionary<string, CachedItem> items;
 
    static CachingService()
    {
        items = new Dictionary<string, CachedItem>();
        Init();
    }
 
    protected static void Init()
    {
        Add(new CachedItemMetadata()
        {
            AbsoluteExpiryMinutes = 10,
            AutoReload = true,
            BaseKey = "CalculatedStats",
            TableDependency = null,
            SlidingExpiryMinutes = 0,
            Loader = delegate(object[] param)
            {
                return Databroker.CalculateStatistics(
                    DateTime.Now.AddHours(-(int)param[0]), 
                    DateTime.Now);
            },
        });
    }
 
    public static void Add(CachedItemMetadata item)
    {
        items.Add(item.BaseKey, new CachedItem(item));
    }
 
    public static string CreateFullKey(string key, object[] param)
    {
        System.Text.StringBuilder sb = new System.Text.StringBuilder();
        sb.Append(key);
        sb.Append('(');
        bool isFirst = true;
        if (param != null)
        {
            foreach (object o in param)
            {
                if (!isFirst)
                    sb.Append(',');
                else
                    isFirst = false;
                sb.Append(o.ToString());
            }
        }
        sb.Append(')');
        return sb.ToString();
    }
 
    public static T Get<T>(string key, object[] param) where T : class
    {
        object obj = Get(key, param);
        return obj as T;
    }
 
    public static object Get(string key, object[] param)
    {
        Cache objCache = HttpContext.Current.Cache;
        string fullKey = CreateFullKey(key, param);
        object obj = objCache[fullKey];
        if (obj == null && items.ContainsKey(key))
        {
            CachedItem objItemInfo = items[key];
 
            // check that the lock exists
            if (!objItemInfo.Locks.ContainsKey(fullKey))
            {
                lock (objItemInfo.Locks)
                {
                    if (!objItemInfo.Locks.ContainsKey(fullKey))
                        objItemInfo.Locks.Add(fullKey, new Mutex());
                }
            }
 
            Mutex objLock = objItemInfo.Locks[fullKey];
            bool didLock = objLock.WaitOne(0, false);
            if (!didLock)
            {
                // if autoreloaded item try and see if we can return the old item
                if (objItemInfo.Metadata.AutoReload 
                    && objItemInfo.OldItems.ContainsKey(fullKey))
                {
                    obj = objItemInfo.OldItems[fullKey];
                    if (obj != null)
                        return obj;
                }
                // wait for load to complete
                if (objLock.WaitOne())
                {
                    objLock.ReleaseMutex();
                    return objItemInfo.Items[fullKey];
                }
                else
                    return null;
            }
            else
            {
                obj = objItemInfo.Metadata.Loader(param);
 
                CacheDependency dep = null;
                TimeSpan sliding = Cache.NoSlidingExpiration;
                DateTime abs = Cache.NoAbsoluteExpiration;
 
                if (objItemInfo.Metadata.TableDependency != null)
                    dep = CreateSqlDependency(objItemInfo.Metadata.TableDependency);
                if (objItemInfo.Metadata.SlidingExpiryMinutes > 0)
                    sliding = new TimeSpan(0, objItemInfo.Metadata.SlidingExpiryMinutes, 0);
                if (objItemInfo.Metadata.AbsoluteExpiryMinutes > 0)
                    abs = DateTime.Now.AddMinutes(objItemInfo.Metadata.AbsoluteExpiryMinutes);
 
                objCache.Insert(fullKey,
                    obj,
                    dep,
                    abs,
                    sliding,
                    CacheItemPriority.Normal,
                    new CacheItemRemovedCallback(delegate(string key2, object o, 
                        CacheItemRemovedReason reason)
                    {
                        ItemRemoved(objItemInfo, key2, o, reason);
                    }));
 
                objItemInfo.Items[fullKey] = obj;
                objItemInfo.Params[fullKey] = param;
 
                objLock.ReleaseMutex();
 
                objItemInfo.OldItems.Remove(fullKey);
            }
        }
        return obj;
    }
 
    private static void ItemRemoved(CachedItem item, string key, 
        object o, CacheItemRemovedReason reason)
    {
        if (item.Metadata.AutoReload)
        {
            item.OldItems[key] = o;
        }
        item.Items.Remove(key);
 
        if (item.Metadata.AutoReload && item.Params.ContainsKey(key))
        {
            Get(item.Metadata.BaseKey, item.Params[key]);
        }
    }
}