Bodu.Globalization.Calendar.Caching - Getting started
Unfamiliar with terms like decorator, cache unit, resource version, refresh-ahead, warm-up, or storage failure policy? Read Core concepts first.
Install
dotnet add package Bodu.Globalization.Calendar.Caching
# Optional durable backends:
dotnet add package Bodu.Globalization.Calendar.Caching.Sqlite
dotnet add package Bodu.Globalization.Calendar.Caching.Distributed
Targets net8.0. Depends on:
Bodu.Globalization.Calendar.Caching-Bodu.Core,Bodu.Globalization.Calendar(the service contract it decorates),Bodu.Text.Toml(the TOML file cache), andMicrosoft.Extensions.Configuration.Abstractions/.Configuration.Binder/.DependencyInjection.Abstractions/.Hosting.Abstractions/.Logging.Abstractions/.Options/.Options.ConfigurationExtensions, all at the .NET 8.0 LTS line.Bodu.Globalization.Calendar.Caching.Sqlite- the core caching package plusMicrosoft.Data.Sqlite.Bodu.Globalization.Calendar.Caching.Distributed- the core caching package plusMicrosoft.Extensions.Caching.AbstractionsandMicrosoft.Extensions.Caching.StackExchangeRedis.
The samples below resolve the wrapped service from a regional data pack (Bodu.Globalization.Calendar.Americas /
.AsiaPacific); any INotableDateService works in its place.
Minimal samples
Decorate a service with the in-memory cache
using Bodu.Globalization.Calendar;
using Bodu.Globalization.Calendar.Caching;
INotableDateService engine = AmericasCalendarData.CreateService("US");
using var calendar = new CachingNotableDateService(
engine,
new InMemoryNotableDateCache(),
new NotableDateCachingOptions { Ttl = TimeSpan.FromDays(7) });
// First call computes the whole of 2026 through the engine and caches it.
IReadOnlyList<NotableDate> year = calendar.Resolve(2026, "US");
// Same civil year: served from the cache, clipped to the day.
IReadOnlyList<NotableDate> independenceDay = calendar.Resolve(new DateOnly(2026, 7, 4), "US");
// A filter is applied after assembly, so it shares the cached year.
IReadOnlyList<NotableDate> publicHolidays = calendar.Resolve(
2026, "US", NotableDateFilter.ForCategory(NotableDateCategory.PublicHoliday));
CachingNotableDateService implements INotableDateService, so the by-year Resolve(year, territory) extension
and the single-day, range, and filtered overloads all work unchanged. The in-memory cache starts empty on every
process start; the entries expire by the options' time-to-live and by resource version like every other backend.
Persist to TOML files
using Bodu.Globalization.Calendar;
using Bodu.Globalization.Calendar.Caching;
var cache = new TomlNotableDateCache(new FileNotableDateCacheOptions
{
CacheDirectory = "/var/cache/notable-dates", // one <TERRITORY>.toml file per territory
ValidateStorageOnStart = true, // create the directory now; throw if that fails
});
using var calendar = new CachingNotableDateService(
AmericasCalendarData.CreateService("US"),
cache,
new NotableDateCachingOptions { ResourceVersion = "us-holidays-2026.1" },
ownsCache: true); // dispose the cache with the decorator
IReadOnlyList<NotableDate> year = calendar.Resolve(2026, "US"); // written to /var/cache/notable-dates/US.toml
Leave CacheDirectory as null to use a bodu-notable-dates folder under the system temporary path. Swap
TomlNotableDateCache for JsonNotableDateCache to write JSON with the same options and layout. Without a resource
provider the fixed ResourceVersion keys every entry - bump it after a data update so stale years recompute.
Invalidate on reload with a resource provider
using Bodu.Globalization.Calendar;
using Bodu.Globalization.Calendar.Caching;
var resourceProvider = new MutableNotableDateResourceProvider(AsiaPacificCalendarData.LoadResource("AU"));
using var calendar = new CachingNotableDateService(
new ReloadableNotableDateService(resourceProvider),
new InMemoryNotableDateCache(),
new NotableDateCachingOptions(),
versionSource: resourceProvider);
_ = calendar.Resolve(2026, "AU-NSW"); // computes and caches
resourceProvider.Reload(AsiaPacificCalendarData.LoadResource("AU"));
_ = calendar.Resolve(2026, "AU-NSW"); // version changed: recomputes
The decorator derives the version token from the provider's current resource and a reload generation, so a reload invalidates every cached year on the next query - even when the reloaded resource carries the same identifier.
Register under dependency injection
AddCachedNotableDateService decorates the INotableDateService already in the container, so it must come after
the service registration (it throws InvalidOperationException otherwise). Consumers keep injecting
INotableDateService. Passing an IConfiguration binds
NotableDateCachingOptions from the Calendar:NotableDateCache section:
using Bodu.Globalization.Calendar;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddNotableDateService(AmericasCalendarData.LoadResource("US"));
builder.Services.AddCachedNotableDateService(builder.Configuration);
Every bindable key of the section, with its default:
{
"Calendar": {
"NotableDateCache": {
"Ttl": "30.00:00:00",
"TtlJitter": 0.0,
"RefreshAheadFraction": 0.0,
"ResourceVersion": null,
"CacheDirectory": null,
"CacheHitLogLevel": "Information",
"CacheMissLogLevel": "Information"
}
}
}
Ttl is a TimeSpan in the standard d.hh:mm:ss form; TtlJitter and RefreshAheadFraction are fractions in
[0, 1); the two log levels are Microsoft.Extensions.Logging.LogLevel names. The options are validated at host
start (ValidateOnStart), so a non-positive Ttl or an out-of-range fraction fails startup rather than the first
request. Without a cacheFactory the registration builds a TomlNotableDateCache under CacheDirectory and owns
it; the code-only overload takes the same configure and cacheFactory arguments without configuration binding:
builder.Services.AddCachedNotableDateService(
configure: o =>
{
o.Ttl = TimeSpan.FromDays(7);
o.RefreshAheadFraction = 0.75;
},
cacheFactory: _ => new InMemoryNotableDateCache());
When the wrapped service was registered with AddReloadableNotableDateService, the decorator picks up the
container's INotableDateResourceProvider automatically and a Reload(...)
invalidates the cache. A registered TimeProvider and ILoggerFactory are used when present.
Use the SQLite backend
AddSqliteNotableDateCache registers a singleton SqliteNotableDateCache
as INotableDateCache and binds SqliteNotableDateCacheOptions from
Calendar:NotableDateCache:Sqlite. It does not change what AddCachedNotableDateService builds by default, so hand
the registered cache to the decorator through cacheFactory:
using Bodu.Globalization.Calendar;
using Bodu.Globalization.Calendar.Caching;
using Microsoft.Extensions.DependencyInjection;
builder.Services.AddNotableDateService(AmericasCalendarData.LoadResource("US"));
builder.Services.AddSqliteNotableDateCache(builder.Configuration);
builder.Services.AddCachedNotableDateService(
builder.Configuration,
cacheFactory: sp => sp.GetRequiredService<INotableDateCache>());
{
"Calendar": {
"NotableDateCache": {
"Ttl": "30.00:00:00",
"Sqlite": {
"DatabaseFilePath": "/var/cache/notable-dates.db",
"ConnectionString": null,
"UseWriteAheadLogging": true,
"BusyTimeout": "00:00:05",
"ThrowOnStorageFailure": false,
"ValidateStorageOnStart": true
}
}
}
}
Either DatabaseFilePath or a full ConnectionString is required (the connection string wins when both are set -
use it for a shared in-memory database such as Data Source=holidays;Mode=Memory;Cache=Shared). With
ValidateStorageOnStart the registration opens and initializes the database during ValidateOnStart, so an
unwritable path fails the host start. The container disposes the cache - and its keep-alive connection - on
shutdown.
Use Redis (or any IDistributedCache)
AddRedisNotableDateCache registers the Redis IDistributedCache and a
DistributedNotableDateCache over it in one call;
AddDistributedNotableDateCache registers only the notable-date cache over whatever IDistributedCache the
container already has. Both bind DistributedNotableDateCacheOptions from
Calendar:NotableDateCache:Distributed:
using Bodu.Globalization.Calendar;
using Bodu.Globalization.Calendar.Caching;
using Microsoft.Extensions.DependencyInjection;
builder.Services.AddNotableDateService(AmericasCalendarData.LoadResource("US"));
builder.Services.AddRedisNotableDateCache(
redis => redis.Configuration = "localhost:6379",
builder.Configuration);
builder.Services.AddCachedNotableDateService(
builder.Configuration,
cacheFactory: sp => sp.GetRequiredService<INotableDateCache>());
{
"Calendar": {
"NotableDateCache": {
"Distributed": {
"KeyPrefix": "payroll:",
"EntryExpirationMargin": "01:00:00",
"ThrowOnStorageFailure": false,
"ValidateStorageOnStart": true
}
}
}
}
Keys take the form <KeyPrefix>notable-dates:<TERRITORY>, so several logical caches can share one Redis instance.
Each written territory blob is stamped with a server-side lifetime of the time-to-live plus EntryExpirationMargin
(default one hour), so a territory that stops being queried self-evicts; set the margin to null to write without
server-side expiry. For a non-Redis store, register it yourself and call AddDistributedNotableDateCache():
builder.Services.AddDistributedMemoryCache(); // or AddDistributedSqlServerCache, …
builder.Services.AddDistributedNotableDateCache(configure: o => o.KeyPrefix = "payroll:");
Warm the cache at startup
using Bodu.Globalization.Calendar;
builder.Services.AddNotableDateService(AmericasCalendarData.LoadResource("US"));
builder.Services.AddCachedNotableDateService(builder.Configuration);
builder.Services.AddNotableDateCacheWarmup(builder.Configuration);
{
"Calendar": {
"NotableDateCacheWarmup": {
"Territories": [ "US", "US-CA", "US-NY" ],
"YearsBehind": 0,
"YearsAhead": 1,
"FirstYear": null,
"LastYear": null
}
}
}
The hosted service runs after the host has started and never blocks it: each territory is warmed over the span
(current UTC year − YearsBehind … current year + YearsAhead, or the pinned FirstYear / LastYear) through the
normal read-through path, a failing territory is logged and skipped, and shutdown cancels the run between
territories. Territories must be non-empty or startup validation fails. Register it after
AddCachedNotableDateService; otherwise the run finds a non-caching service, logs a warning, and no-ops. The same
work is available in code as calendar.Warm(new[] { "US", "US-CA" }, 2026, 2027), which returns the number of
territories warmed.
Implement a custom cache
Any store can back the decorator by implementing INotableDateCache. The
contract has three obligations beyond the signatures: serve an entry only while it is fresh under the supplied
ttl / asOf and matches the requested version, preserve the order of an entry's occurrences, and degrade to an
empty read or a Failed write rather than throwing on a storage fault. This sketch keeps whole entries in a
dictionary and prunes a territory's superseded versions on write:
using System.Collections.Concurrent;
using Bodu.Globalization.Calendar.Caching;
public sealed class DictionaryNotableDateCache : INotableDateCache
{
private readonly ConcurrentDictionary<(string Territory, int Year), NotableDateCacheEntry> _entries = new();
public NotableDateCacheEntry? GetYear(string territory, int year, string resourceVersion, TimeSpan ttl, DateTimeOffset asOf)
{
ArgumentNullException.ThrowIfNull(territory);
ArgumentNullException.ThrowIfNull(resourceVersion);
return _entries.TryGetValue((Normalize(territory), year), out NotableDateCacheEntry? entry)
&& string.Equals(entry.ResourceVersion, resourceVersion, StringComparison.Ordinal)
&& entry.IsFresh(asOf, ttl)
? entry
: null;
}
public NotableDateCacheWriteStatus StoreYear(NotableDateCacheEntry entry, TimeSpan ttl, DateTimeOffset asOf)
{
ArgumentNullException.ThrowIfNull(entry);
string territory = Normalize(entry.Territory);
// A new resource version supersedes every year cached for the territory under the old one.
foreach (KeyValuePair<(string Territory, int Year), NotableDateCacheEntry> existing in _entries)
{
if (existing.Key.Territory == territory
&& !string.Equals(existing.Value.ResourceVersion, entry.ResourceVersion, StringComparison.Ordinal))
{
_entries.TryRemove(existing.Key, out _);
}
}
_entries[(territory, entry.Year)] = entry;
return NotableDateCacheWriteStatus.Stored;
}
public void Clear() =>
_entries.Clear();
private static string Normalize(string territory) =>
territory.Trim().ToUpperInvariant();
}
Plug it in exactly like a shipped backend - new CachingNotableDateService(engine, new DictionaryNotableDateCache(), options) or AddCachedNotableDateService(cacheFactory: _ => new DictionaryNotableDateCache()). The shipped
backends additionally derive from NotableDateCacheBase<TOptions>, which supplies
the merge-and-prune, validity, and per-territory locking; that storage seam is opened to the companion SQLite and
distributed packages, so a third-party backend implements the interface directly as above.
Where to go next
- Core concepts - vocabulary refresher.
- Introduction - package family, type map, and scenario index.
- Caching notable dates guide - freshness tuning, observability, troubleshooting.
- Calendar dependency injection - the registrations the decorator wraps, including the reloadable and keyed forms.
- Bodu.Globalization.Calendar.Caching API reference - full type-by-type docs.
- Runnable samples - offline calendar sample projects you can
dotnet run.