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:
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 tokens — modules/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 tokens — modules/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:
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 locking —
Medallion.Threading.Redisprovides anIDistributedLockProvider(RedisDistributedSynchronizationProvider), registered as a singleton alongside ABP'sAbpDistributedLockingModule(ConfigureDistributedLocking):
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) Set → Get-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 /Defaultconnection) is not second-level cached. Opt individual queries out with.NotCacheable(). AppCache(Redis): stale by design whenRefreshInBackgroundis on. Tag invalidation is generation-based — old entries linger in Redis until TTL.- Redis tokens:
doc-view/excel-export-downloadentries 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;OnConfiguringresolves it lazily and only whenLazyServiceProvider != null. AppCachekeys depend on request state. Query string andActiveOrgIdsare part of the key by default. Caching a method that varies by something not in the key (and not inkeyParts) will serve wrong results across requests — pass it viakeyParts.AppCacherefresh lock is best-effort, not an atomic set-if-absent (see the admonition in section 2). Use the MedallionIDistributedLockProviderwhen you need real mutual exclusion.VersionTrackerand the EF second-level cache are different things. Don't expect changing a master-data row to "invalidate" the EF cache viaVersionTracker; the EF interceptor handles its own table-based eviction, whileVersionTrackeris 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
ConnectionMultiplexerfromRedis:Configuration. - Default Redis config is local-only.
appsettings.jsonships127.0.0.1; real environments injectRedis:Configurationvia Aspire ({redis}) or environment configuration. See Configuration reference.
Related pages¶
- Entity Framework Core —
HubDbContext, the interceptor stack,DbContextPool(256),.NotCacheable() - Background Jobs — Hangfire jobs/workers, the Excel export pipeline that feeds
ExcelExportDownloadCache - .NET Aspire Integration — Redis provisioning and connection-string injection
- OData & Filtering —
CurrentFilterContextProvider/ActiveOrgIds(the org dimension ofAppCachekeys) - Configuration reference —
Redis:Configuration, connection strings - Layered Architecture — where the host and Hub modules sit