Rather than using ASP.NET session state, you could instead use caching to achieve a similar result. You can even use it with a multiple tenant application if you like so each tenant has its own sessions. You can respond to cache timeouts using callback methods, which are more reliable than using session state events.
The downside is that it won't scale to multiple servers unless you come up with a distributed cache solution. You could use azure distributed caching to resolve this, or there are other options.
Here is what a session caching solution would typically look like:
public class ThreadSafeCache
{
public shared ThreadSafeCache()
{
if (cache == null)
{
cache = System.Runtime.Caching.MemoryCache.Default;
}
if (syncLock == null)
{
syncLock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
}
}
private shared System.Runtime.Caching.ObjectCache cache;
private shared ReaderWriterLockSlim syncLock;
public shared bool Contains(string key)
{
syncLock.EnterReadLock();
try
{
return this.cache.Contains(key);
}
finally
{
syncLock.ExitReadLock();
}
}
public shared object GetOrAdd(string key, Func<object> loadFunction, Action<CacheEntryRemovedArguments> callbackFunction)
{
// Get or add an item to the cache
object item = null;
syncLock.EnterReadLock();
try
{
item = cache.Get(key);
}
finally
{
syncLock.ExitReadLock();
}
if (item == null)
{
syncLock.EnterWriteLock();
try
{
// Lazy lock pattern - need to check again after
// the lock to ensure only 1 thread makes it through
if (item == null)
{
// Get the item
item = loadFunction();
var policy = new CacheItemPolicy();
// Set the cache expiration (from the last access).
policy.SlidingExpiration = TimeSpan.FromMinutes(30);
// Setting priority to not removable ensures an
// app pool recycle doesn't unload the item, but a timeout will.
policy.Priority = CacheItemPriority.NotRemovable;
// Setup expiration callback.
policy.RemovedCallback = callbackFunction;
cache.Add(key, item, policy);
}
}
finally
{
synclock.ExitWriteLock();
}
}
return item;
}
public shared void Remove(string key)
{
syncLock.EnterWriteLock();
try
{
this.cache.Remove(key);
}
finally
{
syncLock.ExitWriteLock();
}
}
}
Then you would use it like:
var sessionID = "1234"; // string from cookie or string that is stored in ASP.NET session state
var tenantID = "2"; // identifier for the specific tenant within the application
var key = tenantID + "_" + sessionID;
ThreadSafeCache.GetOrAdd(key, LoadItem, CacheItemRemoved);
private object LoadItem()
{
// TODO: Load the item (from wherever you need to load it from)
return item;
}
private void CacheItemRemoved(CacheEntryRemovedArguments arguments)
{
// Respond here when the cache expires
}
The trick is to ensure your cache key is built up of both the user's session ID (from a cookie) and the ID of the application.
Note that I haven't tested this, so it may need some tweaking to get it working.