Donut Caching with ASP.NET MVC and Partial Requests

Output caching in ASP.NET MVC is usually done via the built-in OutputCacheAttribute, an attribute that marks an action as cacheable and relies on the default ASP.NET caching module. This means that the caching is usually very efficient and doesn't go through the MVC framework at all in case of a cache-hit.

Oooh, donut! by mhaithaca on Flickr

Donut caching, i.e. returning a cached page with a part that is not cached and rendered at each request, can be done in some ways. Usually they use features of the WebForms engine in ASP.NET, for instance using the Substitution control or explicitly caching UserControls. A nicer method is the one suggested by Maarten Balliauw, which requires manual caching and embedding the "donut holes" as strings in the cached page.

Starting from this idea, I wrote a donut caching method for my blogging engine BABiL that should be able to work with every view engine, integrates with MVC ActionResults (no change required on the views) and partial requests.

The Cache attribute

For starters, let's define a custom attribute that enables manual caching. It roughly does what OutputCacheAttribute already does, but it allows us to extend it to allow partial caching and more.

public class CacheAttribute : ActionFilterAttribute {
	public CacheAttribute(bool enable, int durationInSeconds, CacheItemPriority priority) {
		IsEnabled = enable;
		DurationInSeconds = durationInSeconds;
		Priority = priority;

		if (IsEnabled) {
			//Hook cache to request
			var context = HttpContext.Current;
			if (context == null) throw new InvalidOperationException("Cannot cache outside of Http request.");

			//Do not hook if another CacheAttribute is already stored
			//This means we are handling a partial request and should not replace the original cache attribute
			//This also prevents us from having nested partial requests...
			if (context.Items.Contains(CacheAttributeKey)) return;
			
			context.Items[CacheAttributeKey] = this;
		}

		//Must be the first response stream filter (compression attribute must have a higher Order number)
		Order = 0;
	}

	public bool IsEnabled { get; set; }

	public int DurationInSeconds { get; set; }

	const int SecondsInHour = 3600;

	public double DurationInHours {
		get {
			return (DurationInSeconds / SecondsInHour);
		}
		set {
			DurationInSeconds = (int)(value * SecondsInHour);
		}
	}

	public CacheItemPriority Priority { get; set; }

	string _cacheKey = null;
	PassthroughStreamFilter _filter = null;

	public override void OnActionExecuting(ActionExecutingContext filterContext) {
		if (!IsEnabled) return;

		//Check cache and return if needed
		_cacheKey = ComputeCacheKey(filterContext);
		var cacheItem = filterContext.HttpContext.Cache.Get(_cacheKey) as CacheItem;
		if (cacheItem != null && cacheItem.Content.Length > 0) {
			//Has cached result
			filterContext.Result = new DonutCacheResult {
				CachedItem = cacheItem
			};
			return;
		}

		//Hook passthrough filter to response
		var response = filterContext.HttpContext.Response;
		response.Filter = _filter = new PassthroughStreamFilter(response.Filter);

		//Add caching information
		response.Cache.SetCacheability(HttpCacheability.Public);
		response.Cache.SetLastModified(DateTime.UtcNow);
		response.Cache.SetExpires(DateTime.UtcNow.AddSeconds(DurationInSeconds));
	}

	public override void OnResultExecuted(ResultExecutedContext filterContext) {
		if (!IsEnabled) return;

		//Didn't cache, return directly
		if (_filter == null)
			return;

		//Get contents
		var response = filterContext.HttpContext.Response;
		response.Flush();
		var output = _filter.GetRawContents();

		//Store in cache
		var item = new CacheItem(output, _partialRequests) {
			OutputEncoding = response.ContentEncoding,
			OutputEncodingType = response.ContentType,
			OutputCharset = response.Charset,
			CachedOn = DateTime.UtcNow,
			ValidUntil = DateTime.UtcNow.AddSeconds(DurationInSeconds)
		};
		filterContext.HttpContext.Cache.Insert(_cacheKey, item, null,
			DateTime.Now.Add(TimeSpan.FromSeconds(DurationInSeconds)), Cache.NoSlidingExpiration,
			Priority, null);
	}

	const string CacheAttributeKey = "CacheAttribute_KEY";
	
	/// <summary>
	/// Computes the hash key used when caching the action's result.
	/// </summary>
	/// <remarks>Should yield a different result for each action.</remarks>
	protected string ComputeCacheKey(ActionExecutingContext filterContext) {
		StringBuilder keyBuilder = new StringBuilder();
		foreach (var pair in filterContext.RouteData.Values) {
			if (pair.Value == null) continue;
			keyBuilder.AppendFormat("rd{0}_{1}_", pair.Key.GetHashCode(), pair.Value.GetHashCode());
		}
		foreach (var pair in filterContext.ActionParameters) {
			if (pair.Value == null) continue;
			keyBuilder.AppendFormat("ap{0}_{1}_", pair.Key.GetHashCode(), pair.Value.GetHashCode());
		}
		return keyBuilder.ToString();
	}

	#region Partial Requests
	
	IDictionary<long, RouteValueDictionary> _partialRequests = new Dictionary<long, RouteValueDictionary>();
	RouteValueDictionary _currentPartialRequest = null;
	long _currentPartialRequestBegin;

	/// <summary>
	/// Marks the begin of a partial request. Following output will *not* be cached and the ignored
	/// output region will be specially processed in order to allow "donut caching" on subsequent requests.
	/// </summary>
	public static void PartialRequestStart(RouteValueDictionary partialRequestData) {
		//Ignore partial requests of out of context and when not caching
		var context = HttpContext.Current;
		if (context == null) return;

		var cache = context.Items[CacheAttributeKey] as CacheAttribute;
		if (cache == null) return;

		cache.InternalPartialRequestStart(partialRequestData);
	}

	private void InternalPartialRequestStart(RouteValueDictionary partialRequestData) {
		if (!IsEnabled) return;

		if (_filter == null)
			throw new InvalidOperationException("Cannot begin nested partial request without a PassthroughFilter.");

		if (_currentPartialRequest != null)
			throw new InvalidOperationException("Cannot nest partial requests.");

		//Flush response to get current output offset
		HttpContext.Current.Response.Flush();
		_currentPartialRequestBegin = _filter.Length;
		_currentPartialRequest = partialRequestData;

		//Set filter to "do not store" mode (do not keep the partial request's output in memory)
		_filter.Store = false;
	}

	/// <summary>
	/// Marks the end of a partial request. Following output will be cached again.
	/// </summary>
	public static void PartialRequestEnd() {
		var context = HttpContext.Current;
		if (context == null) return;

		var cache = context.Items[CacheAttributeKey] as CacheAttribute;
		if (cache == null) return;

		cache.InternalPartialRequestEnd();
	}

	private void InternalPartialRequestEnd() {
		if (!IsEnabled) return;

		if (_filter == null)
			throw new InvalidOperationException("Cannot end nested partial request on null PassthroughFilter.");

		if (_currentPartialRequest == null)
			throw new InvalidOperationException("No partial request to end.");

		//Flush and store
		HttpContext.Current.Response.Flush();

		_partialRequests.Add(_currentPartialRequestBegin, _currentPartialRequest);
		_currentPartialRequest = null;

		//Restore storing of output
		_filter.Store = true;
	}
	
	#endregion
}

This attribute must be the first to apply a filter to the http response stream. The OnActionExecuting override checks whether the method to which the attribute has been applied (with the same route data variables) is already in the cache. If so, the cached data is returned directly inside a DonutCacheResult object. Otherwise the response stream is wrapped inside a special PassthroughStreamFilter which simply keeps a local copy of all the response content. When the request has been processed, the contents of the filter are stored in the ASP.NET cache.

The whole process looks like this:

The output caching schema.

As you can see, the CacheAttribute ensures that either the original view or the cached version are returned on the response stream. Other attributes continue to work as usual: authentication needs to be applied before caching (to prevent cached authorization). Compression, if applied after caching, needs no changes in code.

Partial requests

Ok, so now to the interesting stuff. Partial requests are "full" requests processed by the MVC framework (Controller->Result->View) that are embedded inside normal web requests. They can be used as a clean way to render repeated "widgets" in your views without having to taint either your controller code (the controller must prepare more ViewData than that needed by the single request) or your views (the view must query for data in order to render some HTML).

Partial requests can be instantiated directly inside your controller (and then passed on to the view) or can be created inside a Page/MasterPage. However, when the partial request is invoked the MVC framework processed it like a standard request and returns a View, whose output will then be embedded in the invoking page. My implementation looks like this:

public class PartialRequest {

	RouteValueDictionary _routeValues;

	public RouteValueDictionary RouteValues {
		get { return _routeValues; }
		private set { _routeValues = value; }
	}

	protected PartialRequest() {
	}

	public PartialRequest(string controller, string action) {
		RouteValues = new RouteValueDictionary {
			{ "controller", controller },
			{ "action", action }
		};
	}

	public PartialRequest(RouteValueDictionary routeValues) {
		RouteValues = routeValues;
	}

	public PartialRequest(object routeValues) {
		RouteValues = new RouteValueDictionary(routeValues);
	}

	/// <summary>Invokes the partial request with donut caching.</summary>
	public virtual void DonutInvoke(ControllerContext context) {
		CacheAttribute.PartialRequestStart(RouteValues);
		Invoke(context);
		CacheAttribute.PartialRequestEnd();
	}

	public const string PartialRequestKey = "__PartialRequest_KEY";

	/// <summary>Invokes the partial request.</summary>
	public virtual void Invoke(ControllerContext context) {
		if (context.HttpContext.IsPartialRequest())
			throw new InvalidOperationException("Cannot nest partial requests.");

		try {
			RouteData rd = new RouteData(context.RouteData.Route, context.RouteData.RouteHandler);
			foreach (KeyValuePair<string, object> pair in RouteValues)
				rd.Values.Add(pair.Key, pair.Value);

			//Mark request as partial
			HttpContext.Current.Items[PartialRequestKey] = this;

			IHttpHandler handler = new MvcHandler(new RequestContext(context.HttpContext, rd));
			handler.ProcessRequest(HttpContext.Current);
		}
		finally {
			HttpContext.Current.Items.Remove(PartialRequestKey);
		}
	}

}

The code is quite simple: a partial request simply keeps all route data needed to execute and then instantiates a MvcHandler to execute it and render the resulting view to the http response stream.

When the DonutInvoke() method is called, the request tells the cache attribute that a partial request that needs special handling is beginning (see the code above for the CacheAttribute class). The attribute class marks the start of the section internally and tells the response filter to stop caching the output. When the partial request is terminated, we tell the CacheAttribute to begin caching again.

Fill the gaps in the cached page

When the page output gets stored in the cache there will be gaps for each partial request with donut caching. Those gaps are stored in the CacheItem class, which actually wraps the raw cached content and several additional informations needed to correctly output the cached page if requested:

public class CacheItem {

	public CacheItem(byte[] content, IDictionary<long, RouteValueDictionary> subPoints) {
		_content = content;

		if (subPoints != null && subPoints.Count != 0) {
			_substitutionPoints = (from p in subPoints
								   orderby p.Key descending
								   select p).ToArray();
		}

		OutputEncoding = Encoding.UTF8;
	}

	byte[] _content;
	public byte[] Content {
		get { return _content; }
	}

	KeyValuePair<long, RouteValueDictionary>[] _substitutionPoints;
	public bool HasSubstitutionPoints {
		get { return (_substitutionPoints != null && _substitutionPoints.Length > 0); }
	}

	/// <summary>
	/// Gets the substitution points and the relative partial request in the cached item.
	/// May be null if no substitution points are present. Points are ordered by descending position (reverse order).
	/// </summary>
	public KeyValuePair<long, RouteValueDictionary>[] SubstitutionPoints {
		get {
			return _substitutionPoints;
		}
	}

	public Encoding OutputEncoding { get; set; }
	public string OutputEncodingType { get; set; }
	public string OutputCharset { get; set; }
	public DateTime CachedOn { get; set; }
	public DateTime ValidUntil { get; set; }

}

Each partial request and its route data is stored as a "substitution point" in the CacheItem. When an action is found in the cache its partial requests need to be executed again and inserted in the output stream (this is done in inverse order, from bottom to top, to simplify inserting):

public class DonutCacheResult : ActionResult {

	public CacheItem CachedItem { get; set; }

	public override void ExecuteResult(ControllerContext context) {
		if (CachedItem == null)
			throw new NullReferenceException("No cached item.");

		var response = context.HttpContext.Response;

		//Init response encoding
		response.ContentEncoding = CachedItem.OutputEncoding;
		response.ContentType = CachedItem.OutputEncodingType;
		response.Charset = CachedItem.OutputCharset;

		//Add caching data
		response.Cache.SetCacheability(HttpCacheability.Public);
		response.Cache.SetLastModified(CachedItem.CachedOn);
		response.Cache.SetExpires(CachedItem.ValidUntil);

		long lastPos = 0;
		if (CachedItem.SubstitutionPoints != null) {
			foreach (var subPoint in CachedItem.SubstitutionPoints) {
				//Write original content before substitution point
				long nextPos = subPoint.Key;
				response.OutputStream.Write(CachedItem.Content, (int)lastPos, (int)(nextPos - lastPos));
				lastPos = nextPos;

				//Make partial request
				var req = new PartialRequest(subPoint.Value);
				req.Invoke(context);
			}
		}

		//Write trailing original content
		response.OutputStream.Write(CachedItem.Content, (int)lastPos, (int)(CachedItem.Content.Length - lastPos));
	}
}

Download

You can download the full source code (which contains the PassthroughStreamFilter and a bunch of other useful classes for handling partial requests).

This solution has been working fine in my tests, let me know what you think or if you find any errors.  :)