Monday, May 24, 2010

Using Caching on an Object Data Source and Making it Unique Per User

I am a fan of the Object Data Source control especially when binding objects to controls such as a GridView for ASP.NET web forms. Not a 'huge' fan, but a fan none the less. I find its ability to organize binding methods and events consistently makes it a decent option for binding, along with its ability to present the objects wired up in a strongly typed manner during configuration.

One of the nicest features of the ODS is its ability to implement caching. The ODS control will call it’s wired up '.Select()' method on the object upon binding. For (2) main reasons I can see how caching can be useful.

1. Use ODS caching to prevent multiple users pulling the 'exact' same static data. Caching is an application level store, so once ODS data is stored in the Page's cache, it can be shared to all client's. Why make 20 calls to the database amongst 20 clients for the identical data? This is a good candidate for caching.

2. Use ODS caching when the user is pulling back several records (i.e. 500-1000) records to be displayed in a Gridview, but don’t want the .Select()method called every time the Grid is paged. Essentially getting all 1000 records on each paging command, but yet only displaying maybe 10 at a time on the screen. With caching, all 1000 are pulled back the 1st time, but subsequent paging calls in the bound GridView only have to access the cache for the data and not call the database again. Another good candidate for caching.

If you only need caching for the purpose of the situation described in #1 above then you can stop and are done. This is the way it appears Microsoft designed the caching in the ODS, and really the only thing you need to do is set 'EnableCaching="True"' on the ODS control. It was well designed for the scope of sharing the same data via the cache for multiple clients running the ASP.NET web application.

But what about #2 above. The tricky part is the Cache object is an Application level store shared amongst all clients. What if you still wanted to implement caching for an individual user that requested the 1000 records via some search criteria, but yet don't want that same 1000 record pulled for a different user attempting to pull data based on another criteria. This is exactly what will happen with the default behavior. User2 would get User1's records because the cache is shared by the same ODS between applications. This is easy to test by opening 2 browsers on the same or separate machines. Have User1 do a custom search that populates a GridView, and then have User2 do the same but with different criteria. Upon User1 paging the GridView after User2's results are displayed, User1 will see User2's results. It sure would be nice if there was a "MakeCachingVaryByUser" True/False option on the ODS but it does not exist.

So we need a work around so that we can still leverage caching but make it unique per user. Initially I thought this might be as easy as assigning a unique value to the "CacheKeyDependency" value at runtime server side and hoping data stored in the cache would be based on that unique key for the user, but that did not work. Upon doing testing of this scenario I came to find out the ODS control has some undocumented idiosyncrasies that make it cumbersome to work with at times. The 1st of these was determining that no matter what value I tried to set for the 'CacheKeyDependency' property (or any ODS cache related property) at runtime say in Page_Load() would not stick. Only the value assigned in the page's source was used by the ODS and continually referred to. I tested this by placing a label on a page and writing to it the value of the "CacheKeyDependency" and "CacheDuration". On Page_Load these values would display whatever was assigned in the Page's source. Upon overwriting them, you could verify for a moment the new values were indeed assigned, but upon any subsequent postback to the server, the ODS reverted back to its default values assigned in the source. Well then I thought, I will just store the values in ViewState or in a HiddenField and continually overwrite the ODS properties on all postbacks, right?? Nice try, but still didn’t work. It kept pointing me to the fact that whatever was assigned in the source is what the ODS was married to, and wasn’t going to allow any changes.

So my next step was to determine, "How can I somehow assign a dynamic value to those properties in the page's source, so that a unique cache allocation will be created per user?" This is where the solution comes into play. I must also stop and mention what combination of properties make a 'unique' combination for the ODS according to the MSDN documentation. Let's take a look:

"A unique cache entry is created for every combination of the CacheDuration, CacheExpirationPolicy, TypeName, SelectMethod, and SelectParameters properties."

Ok, between my users CacheExpirationPolicy, TypeName, SelectMethod, and SelectParameters (the actual parameters not the values of the parameters) are always going to be the same. The one that can change... 'CacheDuration'. This specifies a time in seconds before the cache is expired and the ODS will call the select method again. Since caching to me is a 'helpful' attribute of the app, and not a requirement, I do not care too much about this value as long as something reasonable is assigned. And wait! Is the "CacheKeyDependency" value not used to create a unique combination?? That's correct it is not. But we are still going to dynamically create the value as we are the "CacheDuration" to ensure a unique client combination. And assigning the "CacheKeyDependency" to a unique value actually bypassed some odd behavior of calling the .Select() method each time a separate client made a new search, so the combination of both being dynamically created makes up the solution.

Now to the code. Let's begin with the source behind the controls. We need to use inline server tags that actually are used for DataBound controls. We can specify some TimeSpan attributes like 'TimeOfDay' or 'Ticks' to generate some unique values. We use the String.Format function with a placeholder, passing in the TimeSpan method chosen. Foe the "CacheKeyDependency" that actual type is string, so it can be long and we don’t have to worry too much about size. We will use Now.Ticks() for this. The cache duration is an Integer value in seconds so we don’t want it to surpass the bounds; for this we will use Now.TimeOfDay.Milliseconds().

EnableCaching="true"
CacheExpirationPolicy="Sliding"
CacheKeyDependency='<%# String.Format("{0}", Now.Ticks()) %>'
CacheDuration='<%# String.Format("{0}", Now.TimeOfDay.Milliseconds()) %>'
Now in order for the above to resolve to their actual values, we must call the .DataBind() method on the ODS control. This actually ends up serving a dual purpose as it will ensure the .Select() method is called each time the page initially loads, and it will resolve our server code into usable values. If we didn't call .DataBind, we would end up showing the string literal from the property assignment which is no good. Here is the code that needs to be placed in the Page_Load method:

If Not IsPostBack Then

'Call .DataBind() on the Page so the Properties on the ODS below that have serverside
'code associated to the 'CacheKeyDependency' & 'CacheDuration' properties will resolve.
Me.odsItems.DataBind()

'Expire the cache entry programmatically by expiring the key. The key can be expired by using the
'Cache.Remove method with the current CacheKeyDependency value as the parameter.
'When the cache item is removed, all the cached data that is dependent on the key is expired.
'This will force a fresh pull by calling the ODS's wired up Select() method again.
Cache.Remove(Me.odsItems.CacheKeyDependency)
'It's imperative that the ODS 'CacheKeyDependency' value exists in the Page's cache,
'otherwise the data cached by the ODS control will be immediately evicted from the data cache
'each time its added. It is added implicitly in the line below if it does not already exist.

'Using the key value reference of the ODS in the Page's cache, assign an arbritarty new value.
'This process expires the current cache assosiated with the ODS which will force new data to be pulled.
'If this is not done, subsiquient databound control events or page interactions will pull data
'from cache to populate the bound control as opposed to requesting new data.
Cache(Me.odsItems.CacheKeyDependency) = DateTime.Now

'Store both values in hidden field controls so subsiquient server calls can reload the same values.
'This will need to be done, because the inline server code property attributes only resolve when Page.DataBind() is called.
'This way we do it once above, and then just reload the values later.
Me.hfODSCacheKey.Value = Me.odsItems.CacheKeyDependency
Me.hfODSCacheDuration.Value = Me.odsItems.CacheDuration.ToString()

End If


'The 'CacheKeyDependency' & 'CacheDuration' values MUST be reset on all server calls so the default
'databound value will be used as assigned on the ODS directly. Since the Page is only
'databound in initial PageLoad, these values can just be reassigned from their corresponding HiddenField values.
'This MUST be done or the cache would revert to the default literal value from the control which will not
'match because it uses inline server-side code to generate dynamic values so the cache essentially is user specefic.
Me.odsItems.CacheDuration = Me.hfODSCacheDuration.Value
Me.odsItems.CacheKeyDependency = Me.hfODSCacheKey.Value
After the databind, we evict the cache using the newly assigned values to make sure that one it exists in the Page's cache, and two that there is nothing assigned (or actually an arbitrary DateTime value). Next we assign (2) hidden field controls (or ViewState - just needs to be page specific; don't use Session for these values) the values of the generated "CacheKeyDependency" and the "CacheDuration". This will be pulled on subsequent post backs and reassigned to the ODS. But wait a minute??? I know what you are thinking - you said reassigning the ODS properties with custom values didn't work, right? Yes, but these values match the values initially assigned in the source to the ODS, as opposed to overwriting the values with newly created ones. This is that undocumented feature that we are attempting to adhere to.

Lastly, notice the code that will reassign the "CacheKeyDependency" & "CacheDuration" properties on the ODS on each postback. This ensures that the ODS is placing data in the proper cache location, and it must be done or the literal value from the server tags would be used and we will lose reference to our data. The important piece here is that the initial value assigned in source to the ODS is continually reassigned.

At this point that's all the code you need! I recommend if doing this on several pages that you refactor the code above into a 'Shared' Utility UI method. You may end up needing to pass in the Page, Cache, ODS, and ViewState objects in order to refactor to a centralized method outside the page. You can do this later after everything is working well and tested.

The last piece of code will allow you to forcibly evict the cache. Why would you want to do this? If you had a GridView based on search criteria, and a new search was made. In this case you don’t want the default behavior of grabbing data from the cache; you want new data pulled. There are several other reasons you may want to force a fresh data pull, so inject the code below where needed.

'Expire the current cache assosiated with the ODS which will force new data to be pulled.
'If this is not done, this process would not yield new results as the ODS would
'continue to pull existing data from the cache; in our case we want new data.
PageCache.Remove(Me.odsItems.CacheKeyDependency)
PageCache(Me.odsItems.CacheKeyDependency) = DateTime.Now
The (2) lines above are another good candidate for code refactoring into a centralized Shared UI method (i.e. Utilities.ClearODSCache, etc.). Once again you might need to pass in the ODS control and PageCache objects in order to refactor outside the page.

So in just a few lines of code we have modified the caching offered by the IDS control to be user specific. This way we get the performance and caching attributes provided by caching, but the low level scope we needed. There are other 'big picture' ways to probably solve this same issue. For example, don't use the IDS at all, and just persist the Object source for DataBinding in Session and constantly access it there as opposed to going to the Database. Another option might be to siphon 'e.Result' in the 'Completed' event of an ODS and store that in Session. Then on subsequent calls to the '.Selecting()' method, check to see if an object exists as the DataSource already and cancel the operation. There are probably others too, but the ones mentioned here have pitfalls within, by using larger scale techniques to solve a specific issue. I prefer the method of this post because it is specific to addressing how to modify the caching ability to be unique per user. If you would like to learn more about how native caching works for the ODS control, please view the link below.

Caching Data with the ObjectDataSource

4 comments:

  1. Nice Post, various questions was cleared to me. I have a problem to solve. In your post, I see that the cache is used only if the page is not postback. My situation is: always use the cache, and if I click on a refresh button, then the GridView shows updated data from database. Is that possible?

    ReplyDelete
  2. Point of failure (I think). So we are generating a unique duration based on milliseconds, so it is quite possible for another user exactly 1 minute later will generate the exact same duration, therefore it should hit the same cache right for both users?

    ReplyDelete
  3. Thanks! This helped a lot. I ran into the exact same problem playing with the CacheKeyDependency. Making CacheDuration unique solved it. Instead of using DateTime, I used a static number (60 seconds base + userid). In my situation, userid wouldn't get too big so it should be ok

    ReplyDelete
  4. Great post. Helped solve what I need to do. Setting unique duration wouldn't work because our user ids can get big. We instead set a new select parameter "userid" and made sure the SelectData method took userid as a parameter and then pass in current user id

    ReplyDelete