Caching notable dates
Bodu.Globalization.Calendar.Caching adds a read-through cache in front of the
notable-date service. The engine stays a pure computer that knows nothing of caching;
CachingNotableDateService
implements the same
INotableDateService contract,
so it drops in transparently wherever the service is consumed.
Concepts in one minute
- The whole civil year is the cache unit. Every query is answered per Gregorian year: a range is decomposed into the years it spans, each year is served from the cache or recomputed whole, and the result is clipped to the requested window. A later single-day query for a cached year never recomputes, and a query for exactly one whole civil year is served as the cached list itself with no copying.
- Freshness has two independent triggers. A time-to-live expires entries a fixed duration after computation (a safety net against drift), and a resource-version token invalidates every entry computed under a previous resource, so a data reload always forces a recompute regardless of the time-to-live.
- Concurrent cold misses coalesce. The first caller for a cold (territory, year) computes; concurrent callers join that single flight instead of stampeding the engine.
- Storage is pluggable and best-effort. The decorator works over any
INotableDateCache; the shipped backends degrade gracefully on storage failure - a failed read is a miss, a failed write is skipped - so a broken disk never breaks date resolution.
Quickstart
Wrap the service in the decorator over any backend. The in-memory cache needs no configuration:
INotableDateService engine = AmericasCalendarData.CreateService("US");
using var service = new CachingNotableDateService(
engine,
new InMemoryNotableDateCache(),
new NotableDateCachingOptions { Ttl = TimeSpan.FromDays(7) });
// First query computes and caches the whole of 2026; the second is a pure cache hit.
IReadOnlyList<NotableDate> july = service.Resolve(
new DateRange(new DateOnly(2026, 7, 1), new DateOnly(2026, 7, 31)), "US");
IReadOnlyList<NotableDate> newYear = service.Resolve(new DateOnly(2026, 1, 1), "US");
Under dependency injection, AddCachedNotableDateService decorates the registered
service in place, so consumers keep injecting INotableDateService and transparently
gain caching:
builder.Services.AddNotableDateService(AmericasCalendarData.LoadResource("US"));
builder.Services.AddCachedNotableDateService(configure: o => o.Ttl = TimeSpan.FromDays(7));
Cache backends
The decorator is storage-agnostic; pick the backend at the composition root:
| Backend | Storage | Survives restart | Shared across processes | Package |
|---|---|---|---|---|
InMemoryNotableDateCache |
process memory | no | no | core |
TomlNotableDateCache |
one TOML file per territory | yes | same machine | core |
JsonNotableDateCache |
one JSON file per territory | yes | same machine | core |
SqliteNotableDateCache |
one SQLite database | yes | same machine | …Caching.Sqlite |
DistributedNotableDateCache |
any IDistributedCache (e.g. Redis) |
yes | yes | …Caching.Distributed |
NullNotableDateCache |
none (always misses) | - | - | core |
Construct one by hand, or supply it through the registration's cacheFactory:
var toml = new TomlNotableDateCache(
new FileNotableDateCacheOptions { CacheDirectory = "/var/cache/notable-dates" });
var json = new JsonNotableDateCache(
new FileNotableDateCacheOptions { CacheDirectory = "/var/cache/notable-dates" });
The durable add-ons ship their own registrations. Each one adds an INotableDateCache
to the container; it does not replace the TOML file cache that
AddCachedNotableDateService builds by default. Pass the registered cache through the
cacheFactory parameter to have the decorator use it:
// SQLite: one database file, WAL-enabled, best-effort.
builder.Services.AddNotableDateService(AmericasCalendarData.LoadResource("US"));
builder.Services.AddSqliteNotableDateCache(o => o.DatabaseFilePath = "/var/cache/notable-dates.db");
builder.Services.AddCachedNotableDateService(
cacheFactory: sp => sp.GetRequiredService<INotableDateCache>());
// Distributed: over the registered IDistributedCache (AddRedisNotableDateCache wires Redis directly).
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");
builder.Services.AddDistributedNotableDateCache();
builder.Services.AddCachedNotableDateService(
cacheFactory: sp => sp.GetRequiredService<INotableDateCache>());
Every backend enforces the same ordering contract (occurrences round-trip in the service's date-then-identity order) and the same merge policy, so switching backends never changes served results - only where they live.
Freshness: time-to-live, version invalidation, jitter, and refresh-ahead
NotableDateCachingOptions
carries the freshness policy:
Ttl(default 30 days) expires an entry a fixed duration after it was computed. Notable-date resolution is deterministic for a given resource version, so this is a coarse safety net; the version trigger below handles data changes.ResourceVersionkeys every cache entry. When the decorator observes anINotableDateResourceProvider, the token is derived from the resource identity and a reload generation instead - so a reload invalidates every cached year on the next query:
var resourceProvider = new MutableNotableDateResourceProvider(AmericasCalendarData.LoadResource("US"));
using var service = new CachingNotableDateService(
AmericasCalendarData.CreateService("US"),
new InMemoryNotableDateCache(),
new NotableDateCachingOptions(),
resourceProvider);
_ = service.Resolve(new DateOnly(2026, 1, 1), "US"); // computes and caches under version 1
resourceProvider.Reload(AmericasCalendarData.LoadResource("US"));
_ = service.Resolve(new DateOnly(2026, 1, 1), "US"); // version changed: recomputes
TtlJitter(opt-in, default0) deterministically shaves up to that fraction off each territory's effective time-to-live, keyed by a stable hash of the normalized territory, so territories warmed together do not all expire - and recompute - at the same instant. Jitter only ever shortens the time-to-live.RefreshAheadFraction(opt-in, default0) turns an aged hit into a stale-while-revalidate serve: when a hit's entry is older than this fraction of the effective time-to-live, the caller is still served instantly and one background recompute of the year is scheduled. The recompute shares the single-flight guard with genuine misses, so a miss arriving mid-refresh is served the refreshed value; a failing recompute is logged (EventId 4606) and swallowed, and the next aged hit retries. Because the fraction is below1, a continuously hot territory never surfaces a miss.
var options = new NotableDateCachingOptions
{
Ttl = TimeSpan.FromDays(30),
TtlJitter = 0.1, // spread expiries by up to 10% per territory
RefreshAheadFraction = 0.75, // recompute in the background after 75% of the TTL
};
options.Validate();
On the distributed backend, EntryExpirationMargin (default one hour) additionally
stamps each territory blob with a server-side lifetime of the time-to-live plus the
margin, so a territory that stops being queried self-evicts from Redis; null disables
server-side expiry.
Warming the cache
A cold cache pays its year computations on the first user requests. Warm pre-pays
them: each territory's whole span goes through the normal read-through path, cold years
compute and cache, and already cached years cost only a cache read. A failing territory
is logged (EventId 4607) and skipped; the returned count is the territories warmed.
using var service = new CachingNotableDateService(
AmericasCalendarData.CreateService("US"),
new InMemoryNotableDateCache(),
new NotableDateCachingOptions());
int warmed = service.Warm(new[] { "US", "US-CA" }, 2026, 2028);
Under dependency injection, register the warm-up as a hosted service and let it run
when the application starts. The span defaults to a rolling window around the current
civil year (YearsBehind = 0, YearsAhead = 1), with optional fixed
FirstYear/LastYear overrides; the run never blocks or crashes the host, and it
no-ops with a logged warning when the registered service is not the caching decorator:
builder.Services.AddNotableDateService(AmericasCalendarData.LoadResource("US"));
builder.Services.AddCachedNotableDateService();
builder.Services.AddNotableDateCacheWarmup(configure: warmup =>
{
warmup.Territories.Add("US");
warmup.Territories.Add("US-CA");
warmup.YearsAhead = 2;
});
Or bind the same options from configuration (section Calendar:NotableDateCacheWarmup; the caching options themselves bind from Calendar:NotableDateCache through the AddCachedNotableDateService(IConfiguration, sectionName, …) overload):
{
"Calendar": {
"NotableDateCacheWarmup": {
"Territories": [ "US", "US-CA" ],
"YearsAhead": 2
}
}
}
Observability
Logs. The decorator logs each hit and miss at levels set on the options
(CacheHitLogLevel / CacheMissLogLevel, both defaulting to Information), and every
caching-layer message carries a stable EventId:
| EventId | Event | Level |
|---|---|---|
| 4601 / 4602 | Year served from cache / year recomputed on a miss | option-set |
| 4603 / 4604 | File-cache storage failure swallowed / corrupt cache file treated as empty | Warning |
| 4605 / 4606 | Refresh-ahead recomputed / refresh-ahead failed | option-set / Warning |
| 4607 | Warm-up territory failed and skipped | Warning |
| 4611 | SQLite storage failure swallowed | Warning |
| 4621 | Distributed storage failure swallowed | Warning |
| 4622-4625 | Startup warm-up started / completed / failed / service not caching | Information / Warning |
Storage-failure warnings are rate-limited to at most one per minute, each carrying the count suppressed since the previous warning, so a sustained outage is visible without flooding the log.
Metrics. The caching layer publishes System.Diagnostics.Metrics counters through
the process-wide meter Bodu.Globalization.Calendar.Caching (the SQLite and
distributed add-ons publish their storage-failure counters through their own meters);
with no listener attached, a counter add is a no-op branch:
| Instrument | Tags | Counts |
|---|---|---|
bodu.calendar.notable_date_cache.hits |
territory |
Years served from the cache |
bodu.calendar.notable_date_cache.misses |
territory |
Years recomputed on a miss |
bodu.calendar.notable_date_cache.coalesced_flights |
territory |
Callers that joined an in-flight computation |
bodu.calendar.notable_date_cache.refresh_ahead |
territory, outcome (success/failed) |
Background refresh-ahead recomputes |
bodu.calendar.notable_date_cache.storage_failures |
operation |
Swallowed best-effort storage failures |
Subscribe with any OpenTelemetry metrics exporter, or with an in-process
MeterListener:
using var listener = new System.Diagnostics.Metrics.MeterListener
{
InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter.Name == "Bodu.Globalization.Calendar.Caching")
l.EnableMeasurementEvents(instrument);
},
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
Console.WriteLine($"{instrument.Name} +{value}"));
listener.Start();
Troubleshooting
The cache is cold after every restart. Only the in-memory cache loses state on restart; use a file, SQLite, or distributed backend to survive restarts, and register the startup warm-up so the first requests never pay the computation.
A data update is not being picked up. With a fixed ResourceVersion, bump the
token after a data change - or register a reloadable resource provider so a
Reload invalidates the cache automatically.
The warm-up logs "service is not the caching decorator". Register
AddCachedNotableDateService before AddNotableDateCacheWarmup; the warm-up
resolves INotableDateService and can only warm the caching decorator.
I can't tell whether the cache is degrading. Watch EventId 4603 / 4611 /
4621 at Warning, or the storage_failures counter - it increments on every
swallow, outside the log rate limiting.
See also
- Working with notable dates - the service contract the cache wraps.
- Dependency injection - the wider calendar registration surface.
CachingNotableDateServiceAPI referenceNotableDateCachingOptionsAPI referenceINotableDateCacheAPI reference