Smart Query Caching in C#: Auto-Invalidate on Database Changes

Leave a Comment

A useful manual for maintaining cache freshness with distributed invalidation patterns (MemoryCache / Redis), version stamps, and SqlDependency.

Why is caching database-aware?
Performance is improved and database I/O is reduced when query results are cached. Serving outdated data poses a danger. The solution entails linking basic database modifications to cache invalidation.

  • reduced DB load and delay.
  • Freshness can be predicted using automatic invalidation.
  • expands from distributed programs to single instances.

Patterns at a glance

  • SqlDependency: push notifications from SQL Server for supported queries.
  • Version-Stamps: compare a “last updated” token; refresh only when changed.
  • Event-Driven: clear caches when your app writes to the DB.
  • Redis Pub/Sub: broadcast invalidations across instances.


1. SQL Server Query Notifications with SqlDependency
Requirements: SQL Server Service Broker enabled; query must follow notification rules (no SELECT *, no temp tables, etc.). Suitable for small-to-medium workloads.

// Install: System.Data.SqlClient (for SqlDependency)
// Add at startup (once per process):
SqlDependency.Start(connectionString);

// Service
public sealed class ProductsQueryCache
{
    private readonly IMemoryCache _cache;
    private readonly string _cs;

    public ProductsQueryCache(IMemoryCache cache, string connectionString)
    {
        _cache = cache;
        _cs = connectionString;
    }

    public IReadOnlyList<ProductDto> GetTopProducts()
    {
        const string cacheKey = "products:top";
        if (_cache.TryGetValue(cacheKey, out IReadOnlyList<ProductDto> cached))
            return cached;

        using var conn = new SqlConnection(_cs);
        using var cmd = new SqlCommand(@"
            SELECT p.Id, p.Name, p.Price
            FROM dbo.Products AS p
            WHERE p.IsActive = 1
            ORDER BY p.Sales DESC;", conn);

        // Wire dependency BEFORE executing:
        var dep = new SqlDependency(cmd);
        dep.OnChange += (s, e) => _cache.Remove(cacheKey);

        conn.Open();
        using var rdr = cmd.ExecuteReader();

        var items = new List<ProductDto>();
        while (rdr.Read())
        {
            items.Add(new ProductDto
            {
                Id = rdr.GetGuid(0),
                Name = rdr.GetString(1),
                Price = rdr.GetDecimal(2)
            });
        }

        // Cache with sensible absolute cap as a fallback
        _cache.Set(cacheKey, items, TimeSpan.FromMinutes(30));
        return items;
    }
}

// On shutdown:
SqlDependency.Stop(connectionString);

public record ProductDto
{
    public Guid Id { get; init; }
    public string Name { get; init; } = "";
    public decimal Price { get; init; }
}


Tip: Wrap each dependent query in a small service. Re-execute the query on cache miss to re-subscribe after an invalidation.

2. Version-Stamp (Polling-Light) Strategy

Maintain a single monotonic version (e.g., DATETIME2 or ROWVERSION) per entity or aggregate. The cache stores both the data and the version; if the DB version changes, refresh the cache.

-- One row per entity/aggregate
CREATE TABLE dbo.CacheVersion
(
  EntityName   sysname       NOT NULL PRIMARY KEY,
  LastUpdated  DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME()
);

-- Keep it up to date (example trigger)
CREATE OR ALTER TRIGGER dbo.trg_Products_VersionBump
ON dbo.Products
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
  SET NOCOUNT ON;
  MERGE dbo.CacheVersion AS cv
  USING (SELECT N'Products' AS EntityName) AS s
  ON cv.EntityName = s.EntityName
  WHEN MATCHED THEN
     UPDATE SET LastUpdated = SYSUTCDATETIME()
  WHEN NOT MATCHED THEN
     INSERT(EntityName, LastUpdated) VALUES (s.EntityName, SYSUTCDATETIME());
END;

public sealed class VersionedCache
{
    private readonly IMemoryCache _cache;
    private readonly string _cs;

    public VersionedCache(IMemoryCache cache, string connectionString)
    {
        _cache = cache;
        _cs = connectionString;
    }

    public async Task<IReadOnlyList<ProductDto>> GetTopProductsAsync(CancellationToken ct = default)
    {
        const string key = "products:top";
        var dbVersion = await GetVersionAsync("Products", ct);

        if (_cache.TryGetValue<(DateTime version, IReadOnlyList<ProductDto> data)>(
            key, out var cached) && cached.version == dbVersion)
        {
            return cached.data;
        }

        var data = await LoadFromDbAsync(ct);
        _cache.Set(key, (dbVersion, data), TimeSpan.FromMinutes(30));
        return data;
    }

    private async Task<DateTime> GetVersionAsync(string entity, CancellationToken ct)
    {
        const string sql = "SELECT LastUpdated FROM dbo.CacheVersion WHERE EntityName = @e";
        await using var cn = new SqlConnection(_cs);
        await using var cmd = new SqlCommand(sql, cn);
        cmd.Parameters.AddWithValue("@e", entity);
        await cn.OpenAsync(ct);
        var val = await cmd.ExecuteScalarAsync(ct);
        return (val == null || val == DBNull.Value) ? DateTime.MinValue : (DateTime)val;
    }

    private async Task<IReadOnlyList<ProductDto>> LoadFromDbAsync(CancellationToken ct)
    {
        const string sql = @"
            SELECT TOP (50) Id, Name, Price
            FROM dbo.Products
            WHERE IsActive = 1
            ORDER BY Sales DESC;";

        await using var cn = new SqlConnection(_cs);
        await using var cmd = new SqlCommand(sql, cn);
        await cn.OpenAsync(ct);
        await using var rdr = await cmd.ExecuteReaderAsync(ct);

        var list = new List<ProductDto>();
        while (await rdr.ReadAsync(ct))
        {
            list.Add(new ProductDto{
                Id = rdr.GetGuid(0),
                Name = rdr.GetString(1),
                Price = rdr.GetDecimal(2)
            });
        }
        return list;
    }
}


Suitable for: cross-DB compatibility, complex queries, and large deployments. Overhead is a tiny metadata read per cache check.

3. Event-Driven Invalidation (Application Layer)
When all writes go through your app, clear relevant cache keys immediately after INSERT/UPDATE/DELETE. No polling, no DB features needed.

public interface ICacheBus
{
    void Invalidate(params string[] keys);
}

public sealed class MemoryCacheBus(IMemoryCache cache) : ICacheBus
{
    public void Invalidate(params string[] keys)
    {
        foreach (var k in keys) cache.Remove(k);
    }
}

public sealed class ProductsService
{
    private readonly string _cs;
    private readonly ICacheBus _bus;

    public ProductsService(string cs, ICacheBus bus)
    {
        _cs = cs; _bus = bus;
    }

    public async Task UpdatePriceAsync(Guid id, decimal price, CancellationToken ct)
    {
        const string sql = "UPDATE dbo.Products SET Price=@p WHERE Id=@id";
        await using var cn = new SqlConnection(_cs);
        await using var cmd = new SqlCommand(sql, cn);
        cmd.Parameters.AddWithValue("@p", price);
        cmd.Parameters.AddWithValue("@id", id);
        await cn.OpenAsync(ct);
        await cmd.ExecuteNonQueryAsync(ct);

        // Immediately invalidate related keys:
        _bus.Invalidate("products:top", $"product:{id}");
    }
}


Note. If other systems write to the DB, pair this with Version-Stamps or database triggers to catch external changes.

4. Distributed Cache + Redis Pub/Sub
In multi-instance setups, store data in Redis and broadcast invalidation events with Pub/Sub so all instances drop stale entries simultaneously.

// Using StackExchange.Redis
public sealed class RedisInvalidator
{
    private readonly IConnectionMultiplexer _mux;
    private const string Channel = "cache:invalidate";

    public RedisInvalidator(IConnectionMultiplexer mux) { _mux = mux; }

    public void Publish(params string[] keys)
    {
        var sub = _mux.GetSubscriber();
        sub.Publish(Channel, string.Join(",", keys));
    }

    public void Subscribe(IMemoryCache localCache)
    {
        var sub = _mux.GetSubscriber();
        sub.Subscribe(Channel, (_, msg) =>
        {
            foreach (var key in msg.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries))
                localCache.Remove(key);
        });
    }
}

Combine this with Version-Stamps for safety: even if a node misses a message (rare), the next read detects version mismatch and refreshes.

5. Drop-in Cache Wrapper (Interface + DI)

A small abstraction to centralize caching and invalidation logic.
public interface IQueryCache
{
    Task<T> GetOrCreateAsync<T>(string key, Func<CancellationToken, Task<T>> factory, TimeSpan ttl,
        CancellationToken ct = default);
    void Remove(params string[] keys);
}

public sealed class DefaultQueryCache(IMemoryCache cache) : IQueryCache
{
    public async Task<T> GetOrCreateAsync<T>(string key, Func<CancellationToken, Task<T>> factory, TimeSpan ttl,
        CancellationToken ct = default)
    {
        if (cache.TryGetValue(key, out T value)) return value;
        value = await factory(ct);
        cache.Set(key, value, ttl);
        return value;
    }

    public void Remove(params string[] keys)
    {
        foreach (var k in keys) cache.Remove(k);
    }
}

// Usage inside a repository:
public sealed class ProductsReadModel(IQueryCache qc, string cs)
{
    public Task<IReadOnlyList<ProductDto>> GetTopAsync(CancellationToken ct)
        => qc.GetOrCreateAsync("products:top", _ => Load(ct), TimeSpan.FromMinutes(30), ct);

    private async Task<IReadOnlyList<ProductDto>> Load(CancellationToken ct)
    {
        // ... DB query as shown earlier ...
        return new List<ProductDto>();
    }
}


Best Practices & Pitfalls

  • Pick a key strategy: prefix by aggregate (products:*) so you can invalidate in groups.
  • Set a safety TTL: even with perfect invalidation, It protects you from edge cases.
  • Cache shape matters: cache the final DTO you serve, not raw rows.
  • Avoid “stampedes”: add jitter to TTL or use a single-flight mutex on rebuild.
  • Warm hot paths on app start if latency spikes matter.
  • Measure: track hit ratio, rebuild latency, DB load before/after.
  • SqlDependency limits: ensure Service Broker is on; keep queries notification-compatible.

Windows Hosting Recommendation

HostForLIFEASP.NET receives Spotlight standing advantage award for providing recommended, cheap and fast ecommerce Hosting including the latest Magento. From the leading technology company, Microsoft. All the servers are equipped with the newest Windows Server 2022 R2, SQL Server 2022, ASP.NET Core 7.0.10 , ASP.NET MVC, Silverlight 5, WebMatrix and Visual Studio Lightswitch. Security and performance are at the core of their Magento hosting operations to confirm every website and/or application hosted on their servers is highly secured and performs at optimum level. mutually of the European ASP.NET hosting suppliers, HostForLIFE guarantees 99.9% uptime and fast loading speed. From €3.49/month , HostForLIFE provides you with unlimited disk space, unlimited domains, unlimited bandwidth,etc, for your website hosting needs.
 
https://hostforlifeasp.net/
Previous PostOlder Post Home

0 comments:

Post a Comment