Skip to content

Caching

Cargonerds caches at three distinct levels, each with a different backing store, scope and invalidation model. They are easy to confuse, so the most important thing to take away first is which is which:

Layer Backing store Scope TTL Invalidation
ABP distributed cache (IDistributedCache) Redis (AbpCachingStackExchangeRedisModule) App-wide, shared across all host instances per-entry explicit (RemoveAsync, generation bump)
AppCache helper (Hub) sits on top of IDistributedCache → Redis App-wide feature-driven (default 15 min) generation-based tags + TTL
EF Core second-level cache (EFCoreSecondLevelCacheInterceptor) in-memory (per process) HubDbContext only 1 min absolute automatic (interceptor watches writes)

The two big ones are unrelated

The "Redis distributed cache" (IDistributedCache, shared) and the "Hub second-level cache" (EFCoreSecondLevelCacheInterceptor, in-memory) are different technologies serving different purposes. The second-level cache is not Redis-backed — in a multi-instance deployment each process keeps its own 1-minute EF cache and can serve up-to-1-minute-stale rows independently.

These are wired up almost entirely in two ABP module classes — the API host module and the Hub EF Core module. See Layered Architecture for where those modules sit.


1. Redis distributed cache (ABP IDistributedCache)

ABP exposes a distributed cache abstraction (IDistributedCache / the typed IDistributedCache<T>). The implementation is chosen by module dependency: the API host (Cargonerds.HttpApi.Host) and the auth server (Cargonerds.AuthServer) both depend on AbpCachingStackExchangeRedisModule, so IDistributedCache is served by Redis app-wide.

src/Cargonerds.HttpApi.Host/CargonerdsHttpApiHostModule.cs:

[DependsOn(
    // ...
    typeof(AbpCachingStackExchangeRedisModule),
    typeof(AbpDistributedLockingModule),
    // ...
)]
public class CargonerdsHttpApiHostModule : AbpModule

ABP's Redis cache module reads the standard Redis configuration section by convention. src/Cargonerds.HttpApi.Host/appsettings.json:

"Redis": {
  "Configuration": "127.0.0.1"
}

Under .NET Aspire the value is replaced by the injected connection string (appsettings.aspire.json: "Configuration": "{redis}"). See .NET Aspire Integration for how the placeholder is resolved.

Cache key prefix

ConfigureCache customizes AbpDistributedCacheOptions.KeyPrefix so that environments sharing a Redis instance don't collide:

private void ConfigureCache(IConfiguration configuration)
{
    Configure<AbpDistributedCacheOptions>(options =>
    {
        options.KeyPrefix = "Cargonerds:";
        if (configuration.GetAzureEnvironment() is string azureEnv)
        {
            options.KeyPrefix += $"{azureEnv}:";
        }
    });
}

So every ABP cache key becomes Cargonerds:<azureEnv>:<configured key> (the <azureEnv> segment is only added when GetAzureEnvironment() returns a value).

ABP cache items add their own prefix too

ABP's strongly-typed IDistributedCache<TCacheItem> derives a per-type key prefix from the cache-item class on top of KeyPrefix. The raw IDistributedCache.SetStringAsync(key, ...) calls used by the token caches below get only the KeyPrefix plus the literal key the code passes (doc-view:..., excel-export-download:..., CacheGen:...).

Who uses the Redis cache directly

Consumer Key TTL Notes
AppCache (Hub) AppCache:<Type>:<member>:<SHA-256> feature-driven generic GetOrAddAsync helper — see section 2
DocumentAppService.CreateViewTokenByIdAsync doc-view:<guid:N> 5 min anonymous, time-boxed document download token
ExcelExportDownloadCache excel-export-download:<guid:N> 5 min single-use handoff of a built Excel file
ServiceBusNotificationConsumer (subscription-name key) caches a generated Service Bus subscription GUID

Document view tokensmodules/hub/src/Hub.Application/Services/DocumentAppService.cs mints a short-lived token so a blob can be fetched anonymously (GetStreamByTokenAsync is [AllowAnonymous]):

var token = Guid.NewGuid().ToString("N");
await cache.SetStringAsync(
    $"doc-view:{token}",
    JsonSerializer.Serialize(tokenData, TokenJsonOptions),
    new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }
);

Excel download tokensmodules/hub/src/Hub.Application/Services/Excel/ExcelExportDownloadCache.cs is a single-use store for a fully-built Excel file (read then RemoveAsync, 5-minute TTL). It uses a hand-rolled binary wire format [4-byte BE filename length][filename UTF-8][file bytes] rather than IDistributedCache<T> to avoid the JSON + base64 round-trip on ~30 MB payloads:

private static string CacheKey(Guid token) => $"excel-export-download:{token:N}";

Redis beyond the ABP cache abstraction

Redis is also used outside IDistributedCache, via a raw StackExchange.Redis.ConnectionMultiplexer built directly from Redis:Configuration:

  • Data Protection key ring — outside Development, ASP.NET Core Data Protection keys are persisted to Redis so every instance shares the same keys (ConfigureDataProtection):

    var dataProtectionBuilder = context.Services.AddDataProtection().SetApplicationName("Cargonerds");
    if (!hostingEnvironment.IsDevelopment())
    {
        var redis = ConnectionMultiplexer.Connect(configuration["Redis:Configuration"]!);
        dataProtectionBuilder.PersistKeysToStackExchangeRedis(redis, "Cargonerds-Protection-Keys");
    }
    
  • Distributed lockingMedallion.Threading.Redis provides an IDistributedLockProvider (RedisDistributedSynchronizationProvider), registered as a singleton alongside ABP's AbpDistributedLockingModule (ConfigureDistributedLocking):

    context.Services.AddSingleton<IDistributedLockProvider>(_ =>
    {
        var connection = ConnectionMultiplexer.Connect(configuration["Redis:Configuration"]!);
        return new RedisDistributedSynchronizationProvider(connection.GetDatabase());
    });
    

Both blocks are skipped when AbpStudioAnalyzeHelper.IsInAnalyzeMode is true, so ABP CLI / build-time analysis does not try to open a Redis connection.

Duplicated across host modules

The same Data-Protection-to-Redis and distributed-locking setup is repeated in src/Cargonerds.AuthServer/CargonerdsAuthServerModule.cs (and the Web.Public host), each building its own ConnectionMultiplexer from Redis:Configuration.

Topology

flowchart LR
    subgraph hosts[Host processes]
        API[HttpApi.Host]
        Auth[AuthServer]
        Pub[Web.Public]
    end
    API -->|IDistributedCache| R[(Redis)]
    Auth -->|IDistributedCache| R
    API -->|AppCache GetOrAddAsync| R
    API -->|doc-view / excel tokens| R
    API -->|Data Protection keys| R
    Auth -->|Data Protection keys| R
    Pub -->|Data Protection keys| R
    API -->|Distributed locks| R
    Auth -->|Distributed locks| R

Provisioning

The Aspire AppHost (src/Cargonerds.AppHost/Program.cs) provisions Redis as an Azure Redis resource (AddAzureRedis(...).WithAccessKeyAuthentication().RunAsContainer(...)), running locally as the spark-redis container with RedisInsight and Redis Commander UIs attached for inspection, and a persistent data volume. See .NET Aspire Integration.


2. The AppCache helper (Hub)

modules/hub/src/Hub.Application/AppCache.cs (AppCache : IAppCache, ITransientDependency) is a generic GetOrAddAsync wrapper on top of IDistributedCache. It adds key building, JSON envelopes, generation-based tag invalidation and stale-while-revalidate background refresh. Because it sits on IDistributedCache, it inherits the Redis backend and the Cargonerds: key prefix.

Key building

Keys are derived from the value type, the calling method ([CallerMemberName]), serialized key-parts, the current request query string, the active org IDs and a tag generation counter, all hashed with SHA-256:

private static string BuildKey(Type t, string member, object? keyParts)
{
    var raw = $"{t.FullName}|{member}|{JsonSerializer.Serialize(keyParts, JsonOptions)}";
    var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
    return $"AppCache:{t.Name}:{member}:{Convert.ToHexString(hash)}";
}

The query string and org IDs are folded into the key only when CacheOptions.AddRequestQueryAsKey / AddCurrentOrganizationsAsKey are set (both default true). Including ActiveOrgIds (from CurrentFilterContextProvider) is what makes per-organization results safely cacheable — see OData & Filtering for the org-scoping model.

Storage envelope

Values are wrapped in a CacheEnvelope<T> (Value + CreatedUtc) and serialized with ReferenceHandler.Preserve. A deserialization failure is logged and treated as a cache miss, not an error:

sealed class CacheEnvelope<T>
{
    public required T Value { get; init; }
    public required DateTimeOffset CreatedUtc { get; init; }
}

TTLs come from ABP Features

Default cache options are read from ABP Features (HubFeatures.Cache.* in modules/hub/src/Hub.Domain.Shared/Features/HubFeatures.cs), so they are configurable per feature scope without redeploying:

_cacheOptions ??= new CacheOptions()
{
    Tag = tagName,
    AddRequestQueryAsKey = true,
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(
        Convert.ToInt32(
            await featureChecker.GetOrNullAsync(HubFeatures.Cache.AbsoluteExpirationRelativeToNowInMinutes) ?? "15"
        )
    ),
    RefreshInBackground = await featureChecker.IsEnabledAsync(HubFeatures.Cache.RefreshInBackground),
    RefreshAfter = TimeSpan.FromMinutes(
        Convert.ToInt32(await featureChecker.GetOrNullAsync(HubFeatures.Cache.RefreshAfterInMinutes) ?? "2")
    ),
    RefreshLockTtl = TimeSpan.FromSeconds(10),
};
Feature key Default Meaning
Hub.Cache.AbsoluteExpirationRelativeToNowInMinutes 15 entry lifetime
Hub.Cache.RefreshAfterInMinutes 2 age after which a background refresh is triggered
Hub.Cache.RefreshInBackground (boolean feature) enable stale-while-revalidate
Hub.Cache.Enabled (boolean feature) gate for caching

When no CacheOptions is passed and the typed defaults are not requested, a hard-coded fallback of 10 minutes absolute applies (DefaultOptions()).

Stale-while-revalidate

If RefreshInBackground is on and the cached envelope is older than RefreshAfter, AppCache returns the stale value immediately and recomputes in the background. A Redis-based refresh lock (<key>:refresh) guards against a cache stampede; the work runs detached on a BackgroundExecutor with the request context captured:

if (options is { RefreshInBackground: true, RefreshAfter: { } refreshAfter }
    && (DateTimeOffset.UtcNow - env.CreatedUtc) >= refreshAfter)
{
    var lockKey = key + ":refresh";
    var token = Guid.NewGuid().ToString("N");
    if (await TryAcquireRefreshLock(lockKey, token, options.GetEffectiveRefreshLockTtl(), ct))
    {
        _ = backgroundExecutor.ExecuteDetachedWithCapturedRequestAsync(/* recompute + SetEnvelope */);
    }
}
return env.Value;

The refresh lock is not atomic

TryAcquireRefreshLock does a Get → (if empty) SetGet-back check rather than an atomic set-if-absent. Two callers racing in the same instant can both acquire it. For the true mutual-exclusion path the codebase uses the Redis IDistributedLockProvider (Medallion) described in section 1, not this lightweight refresh guard.

Tag-based invalidation (generation bump)

InvalidateByTagAsync does not delete keys. It writes a new generation value under CacheGen:<tag>; because the generation is part of every key built for that tag, bumping it makes all previously-cached keys for the tag unreachable. The orphaned entries simply expire by TTL:

public async Task InvalidateByTagAsync(string tag, CancellationToken ct = default)
{
    var key = $"CacheGen:{tag}";
    var newGen = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
    await cache.SetStringAsync(key, newGen, ct);
}

This avoids any SCAN/KEYS over Redis, at the cost of leaving dead entries in the store until they expire.

Usage example

modules/hub/src/Hub.Application/Services/Dashboard/DashboardAppService.cs caches dashboard aggregates, keyed by the request DTO, using the feature-driven defaults:

return await cache.GetOrAddAsync(
    factory: (sp, _) =>
    {
        var service = sp.GetRequiredService<DashboardAppService>();
        return service.MilestoneEventUpdatesCore(request);
    },
    keyParts: new { request },
    options: await cache.GetConfiguredDefaultCacheOptionsAsync()
);

CalculatedShipmentAppService (GetShipmentInsights) uses the same pattern. The factory receives a fresh DI scope so the cached computation runs cleanly even when populated from a background refresh.


3. EF Core second-level cache (Hub, 1-min TTL)

The Hub data layer adds query-result caching with EFCoreSecondLevelCacheInterceptor (EFCoreSecondLevelCacheInterceptor + .MemoryCache, v5.3.9, see Directory.Packages.props). It is configured only for HubDbContext, in modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/HubEntityFrameworkCoreModule.cs:

context.Services.AddEFSecondLevelCache(options =>
{
    options
        .UseMemoryCacheProvider()
        .ConfigureLogging(true)
        .UseCacheKeyPrefix("EF_")
        // Fallback on db if the caching provider fails.
        .UseDbCallsIfCachingProviderIsDown(TimeSpan.FromMinutes(1));

    options.CacheAllQueries(CacheExpirationMode.Absolute, TimeSpan.FromMinutes(1));
});
Setting Value Effect
Provider UseMemoryCacheProvider() in-memory, per process — not Redis
Policy CacheAllQueries(Absolute, 1 min) every Hub query is cached for 1 minute by default
Key prefix EF_ distinguishes EF cache entries
Fallback UseDbCallsIfCachingProviderIsDown(1 min) hits the DB directly for 1 min if the cache provider faults

In-memory, not Redis

The EF second-level cache uses the in-memory provider. It caches Hub query results for 1 minute (absolute) and falls back to the database if the cache provider is unavailable. Because it is per-process, each host instance has an independent EF cache.

Where the interceptor is attached

The SecondLevelCacheInterceptor (registered internally by AddEFSecondLevelCache) is attached twice — once on the pooled context registration and once in HubDbContext.OnConfiguring:

// HubEntityFrameworkCoreModule — pooled registration
context.Services.AddDbContextPool<HubDbContext>(
    (sp, opts) =>
    {
        opts.UseSqlServer(cs, sql =>
        {
            sql.EnableRetryOnFailure();
            sql.CommandTimeout((int)TimeSpan.FromMinutes(3).TotalSeconds);
        });
        opts.AddInterceptors(
            sp.GetRequiredService<SecondLevelCacheInterceptor>(),
            sp.GetRequiredService<CommandLoggingInterceptor>(),
            sp.GetRequiredService<FacetJoinInterceptor>(),
            sp.GetRequiredService<ArithAbortConnectionInterceptor>(),
            sp.GetRequiredService<RecompileCommandInterceptor>()
        );
    },
    poolSize: 256
);
// HubDbContext.OnConfiguring
if (LazyServiceProvider == null) return;
optionsBuilder.AddInterceptors(
    LazyServiceProvider.LazyGetRequiredService<SecondLevelCacheInterceptor>(),
    LazyServiceProvider.LazyGetRequiredService<CommandLoggingInterceptor>(),
    LazyServiceProvider.LazyGetRequiredService<FacetJoinInterceptor>(),
    new IgnoreFrozenEntityInterceptor()
);

The HubDbContext is pooled (poolSize: 256) over SQL Server with EnableRetryOnFailure() and a 3-minute command timeout. See Entity Framework Core for the full context, interceptor and pooling details.

Invalidation

EFCoreSecondLevelCacheInterceptor invalidates automatically: when a query writes to a table, the interceptor evicts cache entries whose queries touched the same tables. There is no manual invalidation call for the second-level cache itself.

Opting a query out

A query can bypass the second-level cache with .NotCacheable() — used where freshness matters more than the 1-minute window, e.g. live quote status in modules/pricing/src/Pricing.Application/Quotation/QuotationAppService.cs:

var status = await queryable
    .Where(q => q.Id == quotationId)
    .NotCacheable()
    .Select(q => q.Status)
    .FirstOrDefaultAsync(CancellationToken);

4. VersionTracker — a separate, domain-level cache-version table

Distinct from the EF second-level cache, the Hub maintains a VersionTracker table (modules/hub/src/Hub.Domain/Entities/VersionTracker/VersionTracker.cs) used to signal that reference / master-data entities have changed. This is a domain mechanism, not part of the EFCoreSecondLevelCacheInterceptor.

Entities opt in by implementing the marker interfaces in modules/hub/src/Hub.Domain.Shared/IHubCacheableEntity.cs:

public interface IHubCacheable;
public interface IHubCacheableEntity : IHubCacheable { }
public interface IHubCacheableEntityChild<T> : IHubCacheable where T : IHubCacheableEntity { }

These are implemented by the code/master-data entities (e.g. Country, Currency, Port, ContainerType, ChargeCode, …). On SaveChanges, HubDbContext.UpdateVersionTrackers() finds the modified cacheable types and bumps DateUpdatedUtc for the matching VersionTracker row:

foreach (Type entityType in modifiedCacheableEntityTypes)
{
    VersionTracker? existingVersionTracker = versionTrackers.FirstOrDefault(v => v.Type == entityType.Name);
    if (existingVersionTracker is null)
    {
        throw new UnexpectedNullException(
            $"Unable to find version tracker of type {entityType.Name}. " +
            "Ensure that the hub has the correct corresponding entity first");
    }
    Attach(existingVersionTracker);
    existingVersionTracker.DateUpdatedUtc = DateTimeOffset.UtcNow;
}

A missing VersionTracker row throws

If a modified IHubCacheable type has no corresponding VersionTracker row, the save throws UnexpectedNullException. The Type column is intentionally a string (not System.Type) so that when the Hub adds a cacheable entity that the Spark host doesn't know about, Spark-side writes don't fail on an unknown type.


Gotchas

Three caches, three failure modes

  • Second-level cache (in-memory, Hub-only): everything in the Hub is cached for 60 s by default (CacheAllQueries, Absolute 1 min). Stale reads of up to 1 minute are expected; CargonerdsDbContext (Identity / CmsKit / Settings / Default connection) is not second-level cached. Opt individual queries out with .NotCacheable().
  • AppCache (Redis): stale by design when RefreshInBackground is on. Tag invalidation is generation-based — old entries linger in Redis until TTL.
  • Redis tokens: doc-view / excel-export-download entries are short-lived (5 min) and, for Excel, single-use.
  • The second-level cache interceptor is registered twice (pooled registration and OnConfiguring). It works because the interceptor is idempotent, but it is non-obvious; OnConfiguring resolves it lazily and only when LazyServiceProvider != null.
  • AppCache keys depend on request state. Query string and ActiveOrgIds are part of the key by default. Caching a method that varies by something not in the key (and not in keyParts) will serve wrong results across requests — pass it via keyParts.
  • AppCache refresh lock is best-effort, not an atomic set-if-absent (see the admonition in section 2). Use the Medallion IDistributedLockProvider when you need real mutual exclusion.
  • VersionTracker and the EF second-level cache are different things. Don't expect changing a master-data row to "invalidate" the EF cache via VersionTracker; the EF interceptor handles its own table-based eviction, while VersionTracker is a domain signal consumed elsewhere.
  • Redis connection config is duplicated across the HttpApi.Host, AuthServer and Web.Public host modules for Data Protection + distributed locking, each building its own ConnectionMultiplexer from Redis:Configuration.
  • Default Redis config is local-only. appsettings.json ships 127.0.0.1; real environments inject Redis:Configuration via Aspire ({redis}) or environment configuration. See Configuration reference.