Custom In-Memory Cache in ASP.NET Core

How to Create Custom In-Memory Cache in ASP.NET Core Web API

In this article, I will discuss How to Create a Custom In-Memory Cache in ASP.NET Core Web API Applications and how to do it with custom endpoints. We will be working with the same application that we created in our previous article, where we discussed How to Implement In-Memory Caching in the ASP.NET Core Web API Application.

Custom In-Memory Cache in ASP.NET Core Web API?

In-memory caching in ASP.NET Core is a fast and efficient way to store data directly in the application’s memory. By default, the built-in IMemoryCache in ASP.NET Core offers basic caching operations like setting and getting values. However, it does not provide a mechanism for enumerating all keys or selectively removing multiple cached entries. Customizing in-memory caching in ASP.NET Core Web API is necessary to enhance cache management and provide better control over cached data.

When to Customize In-Memory Caching in ASP.NET Core Web API:

In large or more complex applications, Customization is particularly useful when we need to:

  • Retrieve All Cached Keys and Values: This provides a real-time view of what’s currently stored in the cache. Monitoring the cache’s content allows developers to ensure that the data served is valid and spot potential issues before they escalate. For example, if an expected key-value pair isn’t present, it could indicate that the caching logic isn’t functioning as intended or that the data source fails to populate the cache.
  • Retrieve a Specific Cache Entry by Key: When troubleshooting or verifying specific data points, you may want to inspect a single cache entry. This helps confirm whether the cached value is correct or the data needs updating. For example, if a user reports outdated information on the website, you can quickly check if the cached entry is causing the issue.
  • Clear All Cache Entries: Certain scenarios, like a large data update, scheduled maintenance, or an unexpected issue, may require invalidating all cached data. This ensures that fresh data is fetched from the source, restoring consistency and preventing outdated or inaccurate information from being served to users.
  • Clear a Specific Cache Entry by Key: Sometimes, only a specific piece of data becomes invalid or requires updating. Instead of wiping out the entire cache, you can remove the affected entry. This targeted approach helps maintain overall application performance while ensuring the correctness of individual data points.

Security Considerations: Exposing cache details through endpoints can pose a security risk. Always secure these endpoints with proper authentication and authorization mechanisms to prevent unauthorized access. Only authorized administrators or systems should be able to inspect or manipulate cache entries.

How to Create a Custom In-Memory Cache in ASP.NET Core Web API

By default, the IMemoryCache interface does not support listing all cache keys. To address this limitation, we can create a CacheManager service that wraps IMemoryCache and uses a thread-safe concurrent collection (ConcurrentDictionary) to track cache keys.

First, create a folder named Services in the Project root directory. Then, create a class file named CacheManager.cs within the Services folder and copy and paste the following code. The CacheManager class is a custom caching service that wraps the built-in IMemoryCache interface. The following code is self-explained, so please read the comment lines for a better understanding.

using Microsoft.Extensions.Caching.Memory;
using System.Collections.Concurrent;

namespace InMemoryCachingDemo.Services
{
    public class CacheManager
    {
        // We hold an instance of IMemoryCache to perform actual caching operations.
        private readonly IMemoryCache _cache;

        // We use a thread-safe ConcurrentDictionary to track cache keys.
        private readonly ConcurrentDictionary<string, bool> _cacheKeys;

        // The constructor receives an IMemoryCache from DI.
        public CacheManager(IMemoryCache cache)
        {
            _cache = cache;
            _cacheKeys = new ConcurrentDictionary<string, bool>();
        }

        // Adds a cache entry and tracks its key in our ConcurrentDictionary.
        // options can define expiration strategies, priority, etc.
        public void Set<T>(string key, T value, MemoryCacheEntryOptions options)
        {
            // Store the item in the IMemoryCache with the specified options
            _cache.Set(key, value, options);

            // Track the key in our dictionary (the bool value here is unused, but needed by ConcurrentDictionary)
            _cacheKeys.TryAdd(key, true);
        }

        // Attempts to retrieve a cache entry.
        // If the key exists in the IMemoryCache, returns true along with the value.
        // Otherwise, removes it from our dictionary.
        public bool TryGetValue<T>(string key, out T? value)
        {
            if (_cache.TryGetValue(key, out value))
            {
                return true;
            }

            // If not found in the cache, remove from the dictionary
            _cacheKeys.TryRemove(key, out _);
            value = default;
            return false;
        }

        // Removes a cache entry from both IMemoryCache and our dictionary.
        public void Remove(string key)
        {
            _cache.Remove(key);
            _cacheKeys.TryRemove(key, out _);
        }

        // Returns all currently known (tracked) cache keys.
        // Note: This might include keys that recently expired, so you may want to
        // re-check each key in IMemoryCache if you want only actively stored ones.
        public List<string> GetAllKeys()
        {
            return _cacheKeys.Keys.ToList();
        }

        // Clears all cache entries from IMemoryCache and resets our dictionary.
        public void Clear()
        {
            foreach (var key in _cacheKeys.Keys)
            {
                _cache.Remove(key);
            }

            _cacheKeys.Clear();
        }
    }
}
Key Points of the Custom CacheManager Service:
  • Tracking Cache Keys: A Concurrent Dictionary maintains a thread-safe collection of cache keys. This ensures that key management is robust, even under concurrent access.
  • Managing Cache Entries: The Set, TryGetValue, and Remove methods handle caching operations and keep the tracking dictionary in sync. For example, when a key is no longer found in the cache, it is automatically removed from the key collection.
  • Retrieving All Keys: The GetAllKeys method allows us to list all active keys, allowing for straightforward cache state monitoring.
  • Clearing Cache: The Clear method removes all cache entries and resets the tracked key collection, providing a convenient way to reset the cache state completely.
Registering the Cache Manager, In-Memory Caching Service:

The Program.cs file is responsible for configuring the application startup. In the Program.cs file:

  • Add the CacheManager service as a singleton to ensure that cache keys are consistently tracked application-wide or globally.
  • Ensure IMemoryCache is also registered.
  • Add any other dependencies, such as repositories or DbContext services.

So, please modify the Program class as follows:

using InMemoryCachingDemo.Data;
using InMemoryCachingDemo.Repository;
using InMemoryCachingDemo.Services;
using Microsoft.EntityFrameworkCore;

namespace InMemoryCachingDemo
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Configure DbContext (using SQL Server) in the DI container
            builder.Services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

            // Enable In-Memory Caching
            builder.Services.AddMemoryCache();

            // Register the Repository
            builder.Services.AddScoped<LocationRepository>();

            // Register the custom CacheManager as a Singleton
            // because it should manage keys and cache entries globally within the application.
            builder.Services.AddSingleton<CacheManager>();

            // Add controllers and other necessary services
            builder.Services.AddControllers();
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            var app = builder.Build();

            // Configure the HTTP request pipeline
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();
            app.UseAuthorization();

            app.MapControllers();

            app.Run();
        }
    }
}
Code Explanation:
  • AddDbContext: Configures the database context for SQL Server.
  • AddMemoryCache: Registers the in-memory cache service.
  • AddSingleton<CacheManager>: Ensures that a single instance of CacheManager is used throughout the application. CacheManager holds a collection of keys that must remain consistent throughout the entire application lifetime. Thus, a singleton ensures there is only one instance managing the cache.
  • AddScoped<LocationRepository>: Registered the LocationRepository service as a scoped service as it will deal with the database.
Modifying the AppSettings.json File:

Please modify the appsettings.json file as follows to include caching settings, specifically for cache expiration durations. Adding a CacheSettings section in appsettings.json allows us to centralize the configuration for absolute and sliding expiration durations. By reading these values from the configuration, we make the cache’s behavior adjustable without code changes, which can help fine-tune performance over time.

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=LAPTOP-6P5NK25R\\SQLSERVER2022DEV;Database=LocationDB;Trusted_Connection=True;TrustServerCertificate=True;"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "CacheSettings": {
    "CacheAbsoluteDurationMinutes": 30,
    "CacheSlidingDurationMinutes": 40
  },
  "AllowedHosts": "*"
}
Explanation:
  • CacheAbsoluteDurationMinutes: This is the duration in minutes for the absolute expiration of cache entries (the item expires after this fixed duration).
  • CacheSlidingDurationMinutes: The duration in minutes for the sliding expiration of cache entries (the timer resets whenever the cached item is accessed).
Enhancing the Location Repository with Cache Manager Integration

Now, we need to modify the Location Repository class.

  • Use the custom CacheManager service instead of directly using IMemoryCache.
  • Read cache expiration settings from configuration.
  • Fetches data from the database and caches it with appropriate expiration and priority settings.

So, please modify the LocationRepository class as follows. The following code is self-explained, so please read the comment lines for a better understanding.

using InMemoryCachingDemo.Data;
using InMemoryCachingDemo.Models;
using InMemoryCachingDemo.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;

namespace InMemoryCachingDemo.Repository
{
    public class LocationRepository
    {
        // Database context for data operations.
        private readonly ApplicationDbContext _context;

        // Custom cache manager to handle caching.
        private readonly CacheManager _cache;

        // Cache expiration durations.
        private readonly int _CacheAbsoluteDurationMinutes;
        private readonly int _CacheSlidingDurationMinutes;

        // Configuration for reading settings from appsettings.json.
        private readonly IConfiguration _configuration;

        // Constructor to inject dependencies and read cache settings.
        public LocationRepository(ApplicationDbContext context, 
                                  CacheManager cache, 
                                  IConfiguration configuration)
        {
            _context = context;
            _cache = cache;
            _configuration = configuration;
            // Read the cache expiration durations with default fallbacks.
            _CacheAbsoluteDurationMinutes = _configuration.GetValue<int?>("CacheSettings:CacheAbsoluteDurationMinutes") ?? 30;
            _CacheSlidingDurationMinutes = _configuration.GetValue<int?>("CacheSettings:CacheSlidingDurationMinutes") ?? 30;
        }

        // Retrieves a list of countries with caching.
        // If not found in cache, data is fetched from the database.
        public async Task<List<Country>> GetCountriesAsync()
        {
            var cacheKey = "Countries";

            // Attempt to retrieve cached countries.
            if (!_cache.TryGetValue(cacheKey, out List<Country>? countries))
            {
                // Fetch countries from the database.
                countries = await _context.Countries.AsNoTracking().ToListAsync();

                // Set cache entry options with high priority.
                var cacheEntryOptions = new MemoryCacheEntryOptions()
                    .SetPriority(CacheItemPriority.High); // Countries are considered critical data.

                // Cache the countries without explicit expiration.
                _cache.Set(cacheKey, countries, cacheEntryOptions);
            }

            return countries ?? new List<Country>();
        }

        // Removes the countries cache entry.
        // This is useful after any data modification.
        public void RemoveCountriesFromCache()
        {
            var cacheKey = "Countries";
            _cache.Remove(cacheKey);
        }

        // Adds a new country to the database and clears the corresponding cache.
        public async Task AddCountry(Country country)
        {
            _context.Countries.Add(country);
            await _context.SaveChangesAsync();

            // Clear cache so that subsequent reads get fresh data.
            RemoveCountriesFromCache();
        }

        // Updates an existing country and clears the corresponding cache.
        public async Task UpdateCountry(Country updatedCountry)
        {
            _context.Countries.Update(updatedCountry);
            await _context.SaveChangesAsync();

            // Clear cache after update.
            RemoveCountriesFromCache();
        }

        // Retrieves states for a given country with sliding expiration caching.
        public async Task<List<State>> GetStatesAsync(int countryId)
        {
            string cacheKey = $"States_{countryId}";

            if (!_cache.TryGetValue(cacheKey, out List<State>? states))
            {
                // Fetch states from the database for the specified country.
                states = await _context.States.Where(s => s.CountryId == countryId)
                                              .AsNoTracking().ToListAsync();

                // Set cache entry options with sliding expiration.
                var cacheEntryOptions = new MemoryCacheEntryOptions()
                    .SetSlidingExpiration(TimeSpan.FromMinutes(_CacheSlidingDurationMinutes))
                    .SetPriority(CacheItemPriority.Normal);

                _cache.Set(cacheKey, states, cacheEntryOptions);
            }

            return states ?? new List<State>();
        }

        // Retrieves cities for a given state with absolute expiration caching.
        public async Task<List<City>> GetCitiesAsync(int stateId)
        {
            string cacheKey = $"Cities_{stateId}";

            if (!_cache.TryGetValue(cacheKey, out List<City>? cities))
            {
                // Fetch cities from the database for the specified state.
                cities = await _context.Cities.Where(c => c.StateId == stateId)
                                              .AsNoTracking().ToListAsync();

                // Set cache entry options with absolute expiration.
                var cacheEntryOptions = new MemoryCacheEntryOptions()
                    .SetAbsoluteExpiration(TimeSpan.FromMinutes(_CacheAbsoluteDurationMinutes))
                    .SetPriority(CacheItemPriority.Low);

                _cache.Set(cacheKey, cities, cacheEntryOptions);
            }

            return cities ?? new List<City>();
        }
    }
}
Key Enhancements:
  • Cache Key Uniqueness: Each dataset (countries, states, cities) has a unique cache key.
  • Dynamic Expiration Settings: Expiration durations are read from configuration, allowing flexibility.
Creating Cache Management Controller:

We will create a separate controller named CacheController to handle the cache management functionalities. This controller provides endpoints to manage and inspect the in-memory cache. It allows you to retrieve all cache entries, get a specific cache entry, clear all entries, or remove a single cache entry.:

  • Get all cached keys and values
  • Get a specific key-value pair
  • Clear all cache entries
  • Clear a specific cache entry

These endpoints should be secured (e.g., using [Authorize] or role-based checks) to prevent unauthorized users from viewing or invalidating cache entries. So, create an API Empty controller named CacheController within the Controllers folder and copy and paste the following code. The following example code is also self-explained, so please read the comment lines for a better understanding.

using InMemoryCachingDemo.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;

namespace InMemoryCachingDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class CacheController : ControllerBase
    {
        // Service to manage cache keys and operations.
        private readonly CacheManager _cacheManager;

        // Direct reference to IMemoryCache for retrieving cached values.
        private readonly IMemoryCache _memoryCache;

        // Constructor with dependency injection.
        public CacheController(CacheManager cacheManager, IMemoryCache memoryCache)
        {
            _cacheManager = cacheManager;
            _memoryCache = memoryCache;
        }

        // Retrieves all active cache entries with their keys and values.
        // GET /api/cache/all
        [HttpGet("All")]
        public IActionResult GetAllCacheEntries()
        {
            var cacheEntries = new List<object>();

            // Get all tracked cache keys.
            var keys = _cacheManager.GetAllKeys();

            // Iterate through each key to retrieve its cached value.
            foreach (var key in keys)
            {
                if (_memoryCache.TryGetValue(key, out object? value))
                {
                    // Add the key-value pair to the result list.
                    cacheEntries.Add(new { Key = key, Value = value });
                }
            }

            return Ok(cacheEntries);
        }

        // Retrieves a specific key's value
        // GET /api/cache/{key}
        [HttpGet("{key}")]
        public IActionResult GetCacheEntry(string key)
        {
            // Attempt to get the value from IMemoryCache
            if (_memoryCache.TryGetValue(key, out object? value))
            {
                return Ok(new { Key = key, Value = value });
            }

            return NotFound(new { Message = $"Cache key '{key}' not found." });
        }

        // Clears ALL cache entries
        // DELETE /api/cache/clearall
        [HttpDelete("ClearAll")]
        public IActionResult ClearAllCaches()
        {
            _cacheManager.Clear();
            return Ok(new { Message = "All cache entries have been cleared." });
        }

        // Clears a specific cache entry
        // DELETE /api/cache/{key}
        [HttpDelete("{key}")]
        public IActionResult ClearCacheByKey(string key)
        {
            // Check if the key is being tracked
            if (_cacheManager.GetAllKeys().Contains(key))
            {
                _cacheManager.Remove(key);
                return Ok(new { Message = $"Cache entry '{key}' has been cleared." });
            }
            else
            {
                return NotFound(new { Message = $"Cache key '{key}' not found." });
            }
        }
    }
}
Controller Actions:
  • Get All Cache Entries: Iterate through tracked keys and return key-value pairs. This helps administrators understand what’s currently cached.
  • Get Cache Entry by Key: Fetch and return a specific entry if it exists, or return a not-found response if the key is missing. This aids in quick troubleshooting.
  • Clear All Caches and Clear by Key: Enable bulk invalidation of cache or targeted clearing of individual entries. This flexibility allows for both major resets and precise cache management.

Note: There are no changes in the Models, DbContext and Location Controller. Now, run the application and it should work as expected.

What are the limitations of In-memory Caching in ASP.NET Core?

In-memory caching in ASP.NET Core provides fast and efficient data storage within the application’s memory. However, it comes with certain limitations that may make it not suitable for specific scenarios, particularly in large-scale or distributed applications. The following are the key limitations of in-memory caching:

  • Limited to a Single Server: In-memory caching is stored on the local server’s memory. If you have a load-balanced or distributed environment, each instance has its own cache. This can lead to inconsistent data and more cache misses because each server node maintains a separate cache.
  • Memory Constraints: The amount of memory available for caching is limited by the server’s physical memory. If you store too many or too large objects in the cache, you risk high memory usage or even an OutOfMemoryException. You should define proper eviction strategies (expiration, priority, etc.) to avoid memory pressure.
  • Data Loss on Application Restart: Because the cache is stored in volatile memory (RAM), all cached data is lost when the application restarts, recycles, or crashes. This can be problematic for caching important data that takes time to regenerate.
  • No Built-in Enumeration or Diagnostics: The IMemoryCache API does not provide built-in mechanisms for monitoring or enumerating cache entries, making it difficult to track what is currently stored in the cache. We need to implement custom key-tracking mechanisms.

A distributed cache like Redis, SQL Server Distributed Cache, or NCache is often more appropriate for large-scale or distributed scenarios.

Do I need to implement Asynchronous In-Memory Caching in ASP.NET Core?

Generally, No. In-memory operations are extremely fast and do not involve external I/O. Asynchronous patterns are beneficial when waiting on database calls, API requests, disk I/O, or network operations. For reading and writing data from RAM, a synchronous approach is typically sufficient because:

  • The overhead of async/await would not provide a benefit for pure in-memory operations.
  • IMemoryCache is already optimized for concurrency and speed.

In the next article, I will discuss Implementing Distributed Redis Cache in an ASP.NET Core Web API Application with Examples. In this article, I explain how to create a custom in-memory cache in ASP.NET Core Web API to perform Get, Set, GetAll, Remove, and Clear operations with examples. I hope you enjoy this How to Create a Custom In-Memory Cache in ASP.NET Core Web API article.

Leave a Reply

Your email address will not be published. Required fields are marked *