Code Highlighting

Friday, September 5, 2014

OutputCache on User Controls - VaryByControl

A question on stackoverflow prompted me to research this. Initially a code sample was included, showing how this user was trying to set up cache variation by property value. The VaryByControl parameter was set to the name of this property. This appeared to work for property values entered in markup, but failed if the property was set to a variable: the control bound to the property only ever showed the first value is was bound to.

I set up a test project to attempt to reproduce the problem, and saw identical behavior. So far so good.

<!-- the aspx page, the Number property is set to a random number -->
<%@ Register Src="~/UserControls/Test.ascx" TagPrefix="uc1" TagName="Test" %>

<asp:Content runat="server" ID="BodyContent" ContentPlaceHolderID="MainContent">
    <uc1:Test runat="server" ID="Test1" Number="1" />
    <uc1:Test runat="server" ID="Test2" Number="2" />
    <uc1:Test runat="server" ID="Test3" Number="<%# Number %>" />

    <asp:Label runat="server" Text="<%# Number %>"></asp:Label>
</asp:Content>

<!-- the user control -->
<%@ OutputCache Duration="180" VaryByControl="Number" VaryByParam="None" Shared="true" %>

<h1><%# Number  %></h1>


Detective hats everyone! Let's investigate how outputcache for user controls works!

First the code generation for user controls. An attribute is applied to the generated class that contains the caching parameters:

    [System.Web.UI.PartialCachingAttribute(180, null, "Number", null, null, true, ProviderName = null)]
    public class usercontrols_test_ascx : global::TestOutputCache.UserControls.Test

The parser for the page checks user controls for the caching attribute and - if present - creates a PartialCachingControl, passes it the caching parameters, control ID and a control builder delegate:

    System.Web.UI.StaticPartialCachingControl.BuildCachedControl(@__ctrl, "Test1", "50114342", 180, null, "Number", null, null, new System.Web.UI.BuildMethod(this.@__BuildControlTest1), null);

Inside the PartialCachingControl, a hash is calculated based on the variation parameters, and the cache is checked for the hash. A cache miss results in the delegate being called, and the results getting stored in cache.
Here is the relevant portion of the hash calculation routine:

        if (cachedVary._varyByControls != null) {

            // Prepend them with a prefix to make them fully qualified
            string prefix;
            if (NamingContainer == Page) {
                // No prefix if it's the page
                prefix = String.Empty;
            }
            else {
                prefix = NamingContainer.UniqueID;
                Debug.Assert(!String.IsNullOrEmpty(prefix));
                prefix += IdSeparator;
            }

            prefix += _ctrlID + IdSeparator;

            // Add all the relative vary params and their values to the hash code
            foreach (string varyByParam in cachedVary._varyByControls) {

                string temp = prefix + varyByParam.Trim();
                combinedHashCode.AddCaseInsensitiveString(temp);
                string val = reqValCollection[temp];
                if (val != null)
                    combinedHashCode.AddObject(reqValCollection[temp]);
            }
        }


Variable "reqValCollection" is a dictionary of POST values (if there are any) or GET values.
And the first question presents itself, because the VaryByControl only varies by POST values corresponding with the controls specified. The property value cannot matter: it is never read.

In fact; when deciding to server the cached markup or not, the control has not been created yet, and may not ever be. That's the entire point of the output caching. Obviously variation by Property value doesn't make sense.

So why does it ever work?

Back to the test project to confirm hypothesis by setting VaryByControl to something else.
<%@ OutputCache Duration="180" VaryByControl="PorkPies" VaryByParam="None" Shared="true" %>

<h1><%# Number  %></h1>

Yup. Everything still works identically to how it worked before. The value of VaryByControl does not matter, as long as there is a value. Dropping the VaryByControls entirely shows normal behavior: one single cached version being served for each request.

Smells like a bug! Lets see if we can find it!

In fact, if you look up to the code I posted, the bug is right there. The "temp" variable contains the unique id of the user control. I imagine this field is added to prevent a cache collision for two controls - both specified in VaryByControl - with identical values. But it is added to the combinedHashCode even before it is verified to have a value in reqValCollection. It still works because the HashCodeCombiner class generates a different hash for the same objects added in a different order - it does not simply xor everything and calls it a day.
That means User Control OutPutCache has a built-in variation for:

  • Control ID, and
  • NamingContainer ID
You can activate it by setting VaryByControl to any value whatsoever. I would argue that the correct code looks like this:

            // Add all the relative vary params and their values to the hash code
            foreach (string varyByParam in cachedVary._varyByControls) {

                string temp = prefix + varyByParam.Trim();
                string val = reqValCollection[temp];
                if (val != null){
                    combinedHashCode.AddCaseInsensitiveString(varyByParam.Trim());
                    combinedHashCode.AddObject(reqValCollection[temp]);
                }
            }

Perhaps there is a good reason to include the ID and naming container in the cache key, but it does not make sense to me.

I don't expect Microsoft will ever fix this bug; it would break a multitude of sites in very ugly ways. I'm guessing they've found it before, and decided to leave well enough alone.