Using ASP.net Output Caching with WCF Data Services

We all know hitting the database is an expensive operation, adding the cost of serialization on top of that means that caching the output makes even more sense. The fact that WCF Data Services is built on top of the ASP.net platform means you can utilize all of its power to help you build a better service. This post examines the ASP.net output caching and how one would use it on WCF Data Service.

Cache Variation and Static Service Caching

ASP.net applications has the ability to cache the generated output on the server side. When a next matching request comes in, the output will then be delivered straight from the cache, rather than invoking the handler (calling data service). Note that this behavior applies to GET requests only. Note the word “matching” requests. How we match an request to a cached item is essential to delivering correct data to the customers. For a static (non-updating, we’ll take a look at caching for writable services too) WCF data service endpoint, the output will change depending on the URI path, all of the query parameters, and headers as well (accept, charset, etc.). ASP.net output caching has this notion of “VaryBy…”, which essentially means how we match incoming requests to items in the cache table (of course, non-matching items are added to the table). MSDN has an article that discusses asp.net caching in detail, so I won’t repeat what the parameters are here.

Let’s setup our example using a standard Northwind service over Entity Framework, everything is very standard at this point:

 namespace DataServiceCache 
{ 
    [ServiceBehavior(IncludeExceptionDetailInFaults=true)] 
    public class NorthwindService : DataService<NorthwindEntities> 
    { 
        public static void InitializeService(DataServiceConfiguration config) 
        { 
            config.DataServiceBehavior.MaxProtocolVersion = DataServiceProtocolVersion.V3; 
            config.SetEntitySetAccessRule("*", EntitySetRights.All); 
            config.SetServiceOperationAccessRule("*", ServiceOperationRights.All); 
            config.UseVerboseErrors = true; 
        } 
    } 
}

Next, we override the OnStartProcessingRequest method to set the cache policy:

 protected override void OnStartProcessingRequest(ProcessRequestArgs args) 
{ 
    base.OnStartProcessingRequest(args);

    HttpContext context = HttpContext.Current;    // set cache policy to this page 
    HttpCachePolicy cachePolicy = HttpContext.Current.Response.Cache;

    // server&private: server and client side cache only 
    cachePolicy.SetCacheability(HttpCacheability.ServerAndPrivate);

    // default cache expire: never 
    cachePolicy.SetExpires(DateTime.MaxValue);

    // cached output depends on: accept, charset, encoding, and all parameters (like $filter, etc) 
    cachePolicy.VaryByHeaders["Accept"] = true; 
    cachePolicy.VaryByHeaders["Accept-Charset"] = true; 
    cachePolicy.VaryByHeaders["Accept-Encoding"] = true; 
    cachePolicy.VaryByParams["*"] = true;

    cachePolicy.SetValidUntilExpires(true); 
}

We assume that this service is static here, (never changes shape), so we set the expire date to never expire (although ASP.net should auto adjust this back to expire in 1 year). Fire up the service now to test the cache – one indicator of whether the feed is cached is by examining the “Updated” timestamp. For debugging and testing purposes, let’s add a call count to OnStartProcessingRequest to make sure data service is never called for subsequent requests:

 private const string processedRequestCount = "ProcessedRequestCount";

protected override void OnStartProcessingRequest(ProcessRequestArgs args) 
{ 
    if (HttpContext.Current.Application.Get(processedRequestCount) == null) 
    { 
        HttpContext.Current.Application.Set(processedRequestCount, 1); 
    } 
    else 
    { 
        int count = (int)HttpContext.Current.Application.Get(processedRequestCount); 
        HttpContext.Current.Application.Set(processedRequestCount, count + 1); 
    }

    base.OnStartProcessingRequest(args); 
    … 
}

We can retrieve this count via service operation. Note how we can control the service operation variation individually to “no cache”, this will cause the requests to /GetProcessedCount to always go through data service.

 [WebGet] 
public int GetProcessedCount() 
{ 
    HttpContext.Current.Response.Cache.SetCacheability(HttpCacheability.NoCache);

    int count = Convert.ToInt32(HttpContext.Current.Application.Get(processedRequestCount));

    HttpContext.Current.Application.Set(processedRequestCount, 0); 
    return count; 
}

Next, let’s quickly write a client –side app to test this:

 var context = new NorthwindEntities(new Uri("https://localhost:85/NorthwindService.svc"));

int callCount = context.Execute(new Uri("https://localhost:85/NorthwindService.svc/GetProcessedCount")).Single();

Console.WriteLine("Initial call count {0}", callCount);

IQueryable[] queries = new IQueryable[] { 
    context.CreateQuery("Customers").Skip(10).Take(10), 
    context.CreateQuery("Customers") , 
    context.CreateQuery("Orders").Skip(10).Take(10) , 
    context.CreateQuery("Orders") , 
    context.CreateQuery("Customers").Expand("Orders"), 
};

foreach (var q in queries) ((DataServiceQuery)q).Execute(); 
foreach (var q in queries) ((DataServiceQuery)q).Execute(); 
foreach (var q in queries) ((DataServiceQuery)q).Execute();

callCount = context.Execute(new Uri("https://localhost:85/NorthwindService.svc/GetProcessedCount")).Single(); 
Console.WriteLine("Call count after batch querying {0}", callCount);

The output is:

Initial call count 1
Call count after batch querying 6

As you can see, invoking the query twice will not hit the data service twice. The second request is served directly from the cache. Also, ASP.net is smart enough to cache a different page for each of the entity set, and for each variation of the parameter.

Cache Dependency and Writable Service Caching

So far we’ve looked up caching for static services. Not many services in the world are truly static, which makes caching kind of pointless if you cannot expire an cached item dynamically. The ASP.net output cache uses the idea of “Dependencies”, namely, an output cache item can take dependency on a data cache item – something you have full control over. When the latter one expires, the output cache will automatically expire too. This gives us a way to signal when we should clear the cache, and enables service writers to fine tune their expiration policy for better performances.

The basic idea is this, we can separate responses into cache-able groups, insert a dummy item into the data cache for each of the group, and then setup dependencies accordingly. Whenever we see a verb that’s not a GET, that means change has happened to the data source. We can then decide which group to refresh by expiring the corresponding item in the data cache. Let’s take the simplest approach for our example here – we expire ALL items related to the service whenever an update happens.

 // group key 
private const string cacheDependencyItemKey = "DataServiceCacheItem";

protected override void OnStartProcessingRequest(ProcessRequestArgs args) 
{ 
    if (HttpContext.Current.Application.Get(processedRequestCount) == null) 
    { 
        HttpContext.Current.Application.Set(processedRequestCount, 1); 
    } 
    else 
    { 
        int count = (int)HttpContext.Current.Application.Get(processedRequestCount); 
        HttpContext.Current.Application.Set(processedRequestCount, count + 1); 
    }

    base.OnStartProcessingRequest(args);

    HttpContext context = HttpContext.Current;

    if (context.Request.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase) || 
        context.Request.HttpMethod.Equals("MERGE", StringComparison.OrdinalIgnoreCase) || 
        context.Request.HttpMethod.Equals("PUT", StringComparison.OrdinalIgnoreCase) || 
        context.Request.HttpMethod.Equals("DELETE", StringComparison.OrdinalIgnoreCase)) 
    { 
        // if we are making changes to this service, expire all caches 
        context.Cache.Remove(cacheDependencyItemKey); 
    } 
    else 
    { 
        Debug.Assert(context.Request.HttpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase)); 
                
        // set cache policy to this page 
        HttpCachePolicy cachePolicy = HttpContext.Current.Response.Cache;

        // server&private: server and client side cache only 
        cachePolicy.SetCacheability(HttpCacheability.ServerAndPrivate);

        // default cache expire: never 
        cachePolicy.SetExpires(DateTime.MaxValue);

        // cached output depends on: accept, charset, encoding, and all parameters (like $filter, etc) 
        cachePolicy.VaryByHeaders["Accept"] = true; 
        cachePolicy.VaryByHeaders["Accept-Charset"] = true; 
        cachePolicy.VaryByHeaders["Accept-Encoding"] = true; 
        cachePolicy.VaryByParams["*"] = true;

        cachePolicy.SetValidUntilExpires(true);

        // output cache has dependency on the data service cache item 
        context.Response.AddCacheItemDependency(cacheDependencyItemKey); 
    }

    if (context.Cache.Get(cacheDependencyItemKey) == null) 
    { 
        // what the cache item value is doesn't really matter 
        context.Cache.Insert(cacheDependencyItemKey, "Item"); 
    } 
}

We can update the client-side application to test this:

 var context = new NorthwindEntities(new Uri("https://localhost:85/NorthwindService.svc"));

var cust = context.CreateQuery("Customers").Take(1).FirstOrDefault(); 
context.UpdateObject(cust); 
context.SaveChanges();

int callCount = context.Execute(new Uri("https://localhost:85/NorthwindService.svc/GetProcessedCount")).Single();

Console.WriteLine("Initial call count {0}", callCount);

IQueryable[] queries = new IQueryable[] { 
    context.CreateQuery("Customers").Skip(10).Take(10), 
    context.CreateQuery("Customers") , 
    context.CreateQuery("Orders").Skip(10).Take(10) , 
    context.CreateQuery("Orders") , 
    context.CreateQuery("Customers").Expand("Orders"), 
};

foreach (var q in queries) ((DataServiceQuery)q).Execute();

callCount = context.Execute(new Uri("https://localhost:85/NorthwindService.svc/GetProcessedCount")).Single();

Console.WriteLine("Call count after query {0}", callCount);

foreach (var q in queries) ((DataServiceQuery)q).Execute(); 
foreach (var q in queries) ((DataServiceQuery)q).Execute(); 
foreach (var q in queries) ((DataServiceQuery)q).Execute();

callCount = context.Execute(new Uri("https://localhost:85/NorthwindService.svc/GetProcessedCount")).Single(); 
Console.WriteLine("Call count after batch querying {0}", callCount);

context.UpdateObject(cust); 
context.SaveChanges();

foreach (var q in queries) ((DataServiceQuery)q).Execute(); 
foreach (var q in queries) ((DataServiceQuery)q).Execute(); 
foreach (var q in queries) ((DataServiceQuery)q).Execute();

callCount = context.Execute(new Uri("https://localhost:85/NorthwindService.svc/GetProcessedCount")).Single(); 
Console.WriteLine("Call count after update and querying {0}", callCount);

And the output is:

Initial call count 3 <—1 update, 1 get, 1 service op
Call count after query 6
Call count after batch querying 1
Call count after update and querying 7

As you can see, requests will always go through data services after an update has occurred. In this example we grouped all items into one big group, that’s probably not the optimal solution for most people. An entity-set based grouping should be sufficient, and you can implement this with Query and Change interceptors.

Total Cache Control: The OutputCacheProvider

Still not satisfied with the level of control you have? You can choose to implement the abstract class OutputCacheProvider, and hook it up via a simple config file change. Note that if you go this route, then you cannot add output page dependencies to data cache items. You’ll have to implement the dependency logic inside your provider. I won’t go into details here but the MSDN documentation and this article should help if you decided to go this route.