Caching in OneStream using the Decorator Pattern
Overview/Use-Case
Caching allows us to store the results of long-running API calls or database queries. In this scenario, caching can significantly improve the responsiveness of XF components that rely on refreshing a Data Adapter (Think Grid View that refreshes dashboard on selection). By applying the Decorator Pattern, we can inject caching behavior into our data access logic without modifying the original implementation. This will give us better performance, reusability, and separation of concerns.
Example method in need of caching
Here we have a Method 'GetRaw()' which makes a call to an API and returns a DataTable. We will want to Cache the returned Datatable to avoid having to make multiple long-running calls.
internal sealed class ConditionDataAccess(SessionInfo sessionInfo, Guid? taskID = null) : AIStudioIntegration(sessionInfo, taskID), IRepository<Condition, string>
{
private IUserStateStorage _stateStorage = new UserStateStorage(sessionInfo);
#region IRepository Methods
public DataTable GetRaw()
{
return FetchData(AIStudioNames.Plugin.DataAdapters.Conditions);
}
}
Define User State Storage Class
We will start by defining a UserStateStorage class which will act as our caching mechanism. This will store data on a per-user, per-session basis.
using Newtonsoft.Json;
using OneStream.Shared.Common;
using OneStream.Shared.Wcf;
using System.Runtime.Versioning;
/// <summary>
/// Represents a generic way to store state for a single user.
/// </summary>
public interface IUserStateStorage
{
/// <summary>
/// Obtains the value within storage.
/// </summary>
/// <param name="key">The key of the item obtained.</param>
/// <typeparam name="T">The type of the object returned.</typeparam>
/// <returns>The object of type T in storage.</returns>
public T Get<T>(string key);
/// <summary>
/// Sets the value within storage.
/// </summary>
/// <param name="key">The key of the item stored. If an item with that key
/// exists, it will be overwritten.</param>
/// <typeparam name="T">The type of the object stored.</typeparam>
/// <param name="value">The object of type T that you are storing.</param>
public void Set<T>(string key, T value);
/// <summary>
/// Deletes the stored value.
/// </summary>
/// <param name="key">The key of the item deleted.</param>
public void Delete(string key);
}
/// <summary>
/// Represents a generic way to store state for a single user.
/// </summary>
[SupportedOSPlatform("windows")]
public class UserStateStorage
(SessionInfo sessionInfo, IBRApiState brApiState)
: IUserStateStorage
{
public UserStateStorage(SessionInfo sessionInfo)
: this(sessionInfo, new BRApiState()) {}
public virtual T Get<T>(string key)
{
var serializedObject =
brApiState
.GetUserState(sessionInfo, false, sessionInfo.ClientModuleType,
string.Empty, string.Empty, key, string.Empty)
?.TextValue ??
string.Empty;
return JsonConvert.DeserializeObject<T>(serializedObject);
}
public virtual void Set<T>(string key, T value)
{
if (value == null)
{
Delete(key);
}
else
{
var serializedObject = JsonConvert.SerializeObject(value);
brApiState.SetUserState(sessionInfo, false, sessionInfo.ClientModuleType,
string.Empty, string.Empty, key, string.Empty,
serializedObject, null);
}
}
public virtual void Delete(string key)
{
brApiState.DeleteUserState(sessionInfo, false, sessionInfo.ClientModuleType,
string.Empty, string.Empty, key, string.Empty);
}
}
Define Repository Interface(s)
We will define two interfaces. IRepositoryGet<T, K>
Will contain methods for retrieving data from a generic entity. It will it will be inherited by IRepository<T, K>
which will contain the Create/Update/Delete. Using seperate interfaces for the read and write operations will give us a better seperation of concerns and help to facilitate clearing the cache after CRUD operaions.
using System.Data;
/// <summary>
/// This interface defines retrieval methods for an entity, T.
/// </summary>
/// <typeparam name="T">The type of the entity.</typeparam>
/// <typeparam name="K">The type of the primary key.</typeparam>
internal interface IRepositoryGet<T, K>
{
public DataTable GetRaw();
public IEnumerable<T> Get();
public IEnumerable<T> Get(Func<T, bool> predicate);
public T? Get(K key);
}
/// <summary>
/// This interface defines CRUD methods for an entity, T.
/// </summary>
/// <typeparam name="T">The type of the entity.</typeparam>
/// <typeparam name="K">The type of the primary key.</typeparam>
internal interface IRepository<T, K> : IRepositoryGet<T, K>
{
/// <summary>
/// Stores the entity in the data store.
/// </summary>
/// <param name="entity">An entity to store.</param>
public void Create(T entity);
/// <summary>
/// Deletes an entity whose key matches the supplied key from the data store.
/// </summary>
/// <param name="key">The entity's key.</param>
public void Delete(K key);
/// <summary>
/// Updates an existing entity in the data store.
/// </summary>
/// <param name="entity">An entity to update. It's key will be used to find the existing entity.</param>
public void Update(T entity);
}
By standardizing data retrieval operations through this interface, we can easily apply caching via a decorator.
Caching Decorator for Get Operations
The CachedRepositoryGet<T,K>
Decorator Class wraps an existing repository to add caching functionality to the data-retrieval operations
using OneStream.Shared.Common;
using System.Data;
using System.Runtime.Versioning;
[SupportedOSPlatform("windows")]
internal class CachedRepositoryGet<T, K>(IRepositoryGet<T, K> _repository, UserStateStorage _userStateStorage) : IRepositoryGet<T, K>
{
public CachedRepositoryGet(SessionInfo sessionInfo, IRepositoryGet<T, K> repository) : this(repository, new UserStateStorage(sessionInfo)) { }
public DataTable GetRaw()
{
var cachedDataTable = _userStateStorage.Get<DataTable>($"{typeof(T).Name}.{nameof(GetRaw)}");
if (cachedDataTable != null)
{
return cachedDataTable;
}
var dataTable = _repository.GetRaw();
_userStateStorage.Set<DataTable>($"{typeof(T).Name}.{nameof(GetRaw)}", dataTable);
return dataTable;
}
}
Caching Decorator for Create/Update/Delete operations
The CachedRepository.cs will inherit from CachedRepositoryGet.CS as well as defining methods for Create/Update/Delete. Note that these methods will clear the cache after completion since the results will need to be updated after preforming a CRUD operation:
using OneStream.Shared.Common;
using System.Runtime.Versioning;
/// <summary>
/// Provides caching for an existing repository.
/// </summary>
/// <typeparam name="T">The type of the entity.</typeparam>
/// <typeparam name="K">The entity's primary key.</typeparam>
/// <param name="_repository">The repository to provide caching to.</param>
/// <param name="_userStateStorage">The storage for the cache.</param>
[SupportedOSPlatform("windows")]
internal class CachedRepository<T, K>(IRepository<T, K> _repository, IUserStateStorage _userStateStorage) : CachedRepositoryGet<T, K>(_repository, _userStateStorage), IRepository<T, K> where T : class
{
public CachedRepository(SessionInfo sessionInfo, IRepository<T, K> repository) : this(repository, new UserStateStorage(sessionInfo)) { }
/// <inheritdoc/>
public void Create(T entity)
{
_repository.Create(entity);
ClearCache();
}
/// <inheritdoc/>
public void Delete(K key)
{
_repository.Delete(key);
ClearCache();
}
/// <inheritdoc/>
public void Update(T entity)
{
_repository.Update(entity);
ClearCache();
}
}
Example Usage:
- We will initialize and use the cached repository to get the data
- The call to GetRaw() first checks if the cache is empty. If the cache is empty it retrieves data from the source and then caches it. If the cache is not empty the call will simply retrieve the cached data
using System.Data;
internal class ConditionsPageDashboard : DashboardEvent
{
private CachedRepository<Condition, string> _cachedRepository;
public ConditionsPageDashboard(DashboardEventArgs eventArgs) : base(eventArgs)
{
_cachedRepository = new CachedRepository<Condition, string>(Si, new ConditionDataAccess(Si));
}
public DataTable GetAllConditions()
{
var selectedConditionType = GetArg("ConditionType", "");
selectedConditionType = string.IsNullOrWhiteSpace(selectedConditionType) ? "All" : selectedConditionType;
ConditionDataAccess conditionDataAccess = new ConditionDataAccess(Si);
CachedRepositoryGet<Condition, string> cachedRepository = new CachedRepositoryGet<Condition, string>(Si, conditionDataAccess);
DataTable allConditionsTable = cachedRepository.GetRaw();
var conditions = allConditionsTable.AsEnumerable()
.Select(Condition.FromRow);
if (!string.Equals(selectedConditionType, "All", StringComparison.OrdinalIgnoreCase))
{
conditions = conditions.Where(c => string.Equals(c.ScanItemType, selectedConditionType, StringComparison.OrdinalIgnoreCase));
}
return conditions.ToDataTable(
nameof(Condition.Name),
nameof(Condition.IsCustom),
nameof(Condition.ScanItemType),
nameof(Condition.SeverityLevel),
nameof(Condition.Description),
nameof(Condition.NaturalLanguageDefinition),
nameof(Condition.ContextSources)
);
}
}
Example ClearCache Method:
using System.Data;
/// <summary>
/// Clears the cached values.
/// </summary>
public void ClearCache()
{
_userStateStorage.Set<DataTable>(GetRawCacheKey, null);
_userStateStorage.Set<IEnumerable<T>>(GetCacheKey, null);
var allCachedIds = _userStateStorage.Get<IEnumerable<string>>(GetByIdCachedKeys);
if (allCachedIds != null)
{
foreach (var key in allCachedIds)
{
_userStateStorage.Set<T>(key, null);
}
}
_userStateStorage.Set<IEnumerable<string>>(GetByIdCachedKeys, null);
var allCachedPredicates = _userStateStorage.Get<IEnumerable<string>>(GetByPredicateCachedKeys);
if (allCachedPredicates != null)
{
foreach (var key in allCachedPredicates)
{
_userStateStorage.Set<IEnumerable<T>>(key, null);
}
}
_userStateStorage.Set<IEnumerable<string>>(GetByPredicateCachedKeys, null);
}