Bodu.Globalization.Calendar.Caching
Bodu.Globalization.Calendar.Caching puts a read-through cache in front of the
Bodu.Globalization.Calendar notable-date engine. Its centerpiece,
CachingNotableDateService, is a decorator: it implements the same
INotableDateService contract as the engine, wraps any existing service, and
serves each requested civil year from a cache instead of recomputing it. The engine itself stays a pure computer
that knows nothing of caching. Part of the Globalization & Calendars
topic.
The core package ships the decorator, the storage contract, and three backends - in-memory, and one TOML or JSON
file per territory. Two add-on packages supply durable, shareable storage over the same contract: a SQLite database
and any IDistributedCache (Redis included). All three packages register through IServiceCollection extensions
declared in the Bodu.Globalization.Calendar namespace, so a single using Bodu.Globalization.Calendar; brings
AddCachedNotableDateService, AddSqliteNotableDateCache, AddDistributedNotableDateCache,
AddRedisNotableDateCache, and AddNotableDateCacheWarmup into scope.
The package family
| Package | Storage | Survives restart | Shared across processes | Pick it when |
|---|---|---|---|---|
Bodu.Globalization.Calendar.Caching |
Process memory (InMemoryNotableDateCache), or one TOML / JSON file per territory (TomlNotableDateCache / JsonNotableDateCache) | In-memory: no. Files: yes | Files: same machine only | A single process, or a single machine whose cache directory can be a local folder. The default DI registration uses the TOML file cache. |
Bodu.Globalization.Calendar.Caching.Sqlite |
One SQLite database file (SqliteNotableDateCache) | Yes | Same machine (WAL-enabled, busy-timeout aware) | Many territories on one machine, where one keyed row per year beats re-parsing a whole territory file on every lookup. |
Bodu.Globalization.Calendar.Caching.Distributed |
Any IDistributedCache - Redis, SQL Server, or the in-memory distributed cache (DistributedNotableDateCache) |
Yes | Yes | Several application instances that should share one warm cache. AddRedisNotableDateCache wires Redis and the cache in one call. |
Every backend applies the same freshness, validity, version-matching, and merge rules, so switching backends never changes what the decorator serves - only where it lives.
Core mental model
INotableDateService (engine) INotableDateCache (storage)
▲ ▲
│ miss: Resolve(whole civil year) │ GetYear / StoreYear / Clear
│ │
└────────── CachingNotableDateService ─────┘
▲
│ Resolve(date | range, territory[, filter])
consumer
The decorator
CachingNotableDateService takes the wrapped service, an
INotableDateCache, and a
NotableDateCachingOptions. Because it implements
INotableDateService, it drops in anywhere the engine is consumed - a hand-built NotableDateService, a data pack's
CreateService(...), or a ReloadableNotableDateService. The filtered
Resolve overloads apply the NotableDateFilter after the cached result is
assembled, exactly as the engine does, so a filter never participates in the cache key. GetSupportedTerritories
and GetSupportedCalendars delegate straight to the wrapped service.
The cache unit: one territory, one civil year
The engine resolves per Gregorian year, so a whole civil year for one territory is the reusable unit. Every
query is answered per year: a range is decomposed into the years it spans, each year is served from the cache when a
fresh, version-matching entry exists (or recomputed whole and written back), and the assembled occurrences are
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 - the Resolve(year, territory) extension shape - is served as the cached list
itself with no copying. Concurrent cold misses for the same year coalesce onto one computation instead of stampeding
the engine.
Territory keys are normalized case-insensitively (us and US share an entry); a subdivision (AU-NSW) and its
parent (AU) are distinct keys, matching the engine's own resolution.
Freshness: time-to-live and resource version
A cached year stays fresh under two independent triggers, both evaluated by the cache on every call:
- Time-to-live.
Ttl(default 30 days) expires an entry a fixed duration after it was computed - a coarse safety net, because resolution is deterministic for a given resource. OptionalTtlJitterspreads per-territory expiries, and optionalRefreshAheadFractionturns an aged hit into a served-now, recomputed-in-the-background entry so a hot territory never surfaces a miss. - Resource version. Every entry is keyed by a version token. When the decorator observes an
INotableDateResourceProvider (registered automatically by
AddReloadableNotableDateService), the token is derived from the resource identity and a reload generation, so aReload(...)invalidates every cached year on the next query regardless of the time-to-live. Without a provider, the fixedResourceVersionfrom the options is used - bump it after a data update.
The storage contract
INotableDateCache has three members:
| Member | Contract |
|---|---|
GetYear(territory, year, resourceVersion, ttl, asOf) |
Returns the NotableDateCacheEntry for that territory, year, and version only while it is fresh at asOf; otherwise null. |
StoreYear(entry, ttl, asOf) |
Merges a computed year into the territory's entries (most recent wins per year), prunes stale and superseded-version entries, and reports a NotableDateCacheWriteStatus - Stored, Skipped (a deliberate no-op cache), or Failed (a swallowed storage error; nothing persisted). |
Clear() |
Removes every cached entry, best-effort. |
Two rules bind every implementation: ordering - an entry's occurrences round-trip in the order supplied (the
engine's date-then-identity order), so the decorator assembles ranges without re-sorting - and resilience - a
storage fault surfaces as an empty read or a skipped write, never as an exception that breaks date resolution
(unless ThrowOnStorageFailure is set). Argument validation always throws.
Headline types
Service, contract, and entries
| Type | Purpose |
|---|---|
| CachingNotableDateService | The decorator. Constructor (inner, cache, options, versionSource?, timeProvider?, loggerFactory?, ownsCache); the INotableDateService surface plus Warm(territories, firstYear, lastYear) to pre-pay year computations. Disposable - disposes the cache only when ownsCache is true. |
| INotableDateCache | The storage contract: GetYear, StoreYear, Clear. |
| NotableDateCacheEntry | The cache unit: Territory, Year, ResourceVersion, the ordered Occurrences, and ComputedAtUtc; IsFresh(asOf, ttl) evaluates the time-to-live. An empty occurrence list is a valid, cacheable result. |
| NotableDateCacheWriteStatus | Stored / Skipped / Failed - the outcome of a StoreYear. |
Options
| Type | Purpose |
|---|---|
| NotableDateCachingOptions | Decorator options: Ttl (30 days), TtlJitter (0), RefreshAheadFraction (0), ResourceVersion (null → a built-in token), CacheDirectory (null → bodu-notable-dates under the temp path; used by the default file cache), CacheHitLogLevel / CacheMissLogLevel (Information). Validate() throws; TryValidate(out error) is what the DI registration wires into ValidateOnStart. |
| NotableDateCacheOptions | The storage-agnostic base every backend's options derive from: ThrowOnStorageFailure (false - rethrow IOException / UnauthorizedAccessException instead of degrading) and ValidateStorageOnStart (false - probe the store at construction or host start). |
| FileNotableDateCacheOptions | Adds CacheDirectory for the TOML and JSON file caches. |
| SqliteNotableDateCacheOptions | DatabaseFilePath or a full ConnectionString (the latter wins; at least one is required), UseWriteAheadLogging (true), BusyTimeout (5 s). |
| DistributedNotableDateCacheOptions | KeyPrefix (null; keys are <prefix>notable-dates:<TERRITORY>) and EntryExpirationMargin (1 hour - added to the time-to-live as each blob's server-side absolute expiration; null disables server-side expiry). |
| NotableDateCacheWarmupOptions | Startup warm-up: Territories (required), a rolling window of YearsBehind (0) / YearsAhead (1) around the current UTC year, or pinned FirstYear / LastYear. |
Backends
| Type | Purpose |
|---|---|
| NotableDateCacheBase<TOptions> | The storage-agnostic mechanism behind every shipped backend: territory normalization, read-time freshness and version filtering, write-time merge-and-prune under a per-territory lock. Derived types persist only a territory's entry list. |
| FileNotableDateCacheBase | The file mechanism: one file per territory under CacheDirectory, atomic temp-and-move writes, a last-write-time parse memo, rate-limited degradation warnings. |
| InMemoryNotableDateCache | Process memory; empty on every start. |
| TomlNotableDateCache / JsonNotableDateCache | One <TERRITORY>.toml / .json file per territory; malformed content reads as empty and is repaired by the next write. |
| SqliteNotableDateCache | One notable_dates table keyed by (territory, year, version); a single-row GetYear; a keep-alive connection for the instance lifetime. Disposable. |
| DistributedNotableDateCache | One JSON blob per territory in any IDistributedCache; Clear removes only the keys this instance wrote. |
| NullNotableDateCache | NullNotableDateCache.Instance - stores nothing; every write reports Skipped. |
On-disk schema
The file caches and the distributed blob share one document shape; the SQLite backend stores the same occurrence rows as a JSON blob per year.
| Type | Purpose |
|---|---|
| NotableDateCacheFile | The root: Territory, an Entries array, and a flat Occurrences array. |
| NotableDateCacheYearRow | One row per cached year - Year, Version, ComputedAtUtc - present even for a year that yielded no occurrences. |
| NotableDateCacheOccurrenceRow | One flat row per occurrence, carrying its Year and Version plus every NotableDate field (Date, ActualDate, IsObserved, the ResourceId / NotableDateId / RuleId identity, DisplayName, TerritoryCode, Category, Priority, DurationDays, IsNonWorkingDay, Tags, AdjustmentPolicyId, AdjustmentReason). |
Dependency injection
All registrations live in the Bodu.Globalization.Calendar namespace.
| Method (static class) | Registers |
|---|---|
AddCachedNotableDateService(configure?, cacheFactory?) and AddCachedNotableDateService(configuration, sectionName = "Calendar:NotableDateCache", configure?, cacheFactory?) (NotableDateCachingExtensions) |
Decorates the already-registered INotableDateService in place: the previous registration becomes the inner service, consumers keep injecting INotableDateService. Without a cacheFactory a TomlNotableDateCache under CacheDirectory is created and owned. Throws InvalidOperationException when no service is registered. |
AddSqliteNotableDateCache(configuration?, sectionName = "Calendar:NotableDateCache:Sqlite", configure?) (SqliteNotableDateCacheExtensions) |
A singleton SqliteNotableDateCache, also exposed as INotableDateCache; validates options (and, with ValidateStorageOnStart, the database) at host start. |
AddDistributedNotableDateCache(configuration?, sectionName = "Calendar:NotableDateCache:Distributed", configure?) and AddRedisNotableDateCache(configureRedis, configuration?, sectionName, configure?) (DistributedNotableDateCacheExtensions) |
A singleton DistributedNotableDateCache over the container's IDistributedCache; the Redis form first registers the Redis cache. |
AddNotableDateCacheWarmup(configuration?, sectionName = "Calendar:NotableDateCacheWarmup", configure?) (NotableDateCacheWarmupExtensions) |
A hosted service that warms the configured territories after the host starts, without blocking it. |
Important
The SQLite and distributed registrations add an INotableDateCache to the container; they do not change what
AddCachedNotableDateService builds by default. Hand the registered cache to the decorator explicitly:
AddCachedNotableDateService(cacheFactory: sp => sp.GetRequiredService<INotableDateCache>()). See
Getting started.
Common scenarios
| Scenario | Reach for |
|---|---|
| Wrap a service in code, no files | new CachingNotableDateService(engine, new InMemoryNotableDateCache(), new NotableDateCachingOptions()) |
| Persist across restarts on one machine | new TomlNotableDateCache(new FileNotableDateCacheOptions { CacheDirectory = … }) or the SQLite backend |
| Decorate the DI-registered service | services.AddCachedNotableDateService() after AddNotableDateService(...) |
Bind options from appsettings.json |
services.AddCachedNotableDateService(configuration) - section Calendar:NotableDateCache |
| Invalidate automatically on reload | Register with AddReloadableNotableDateService; the decorator observes the resource provider |
| Invalidate after a data update without a provider | Bump NotableDateCachingOptions.ResourceVersion |
| Share one warm cache across instances | AddRedisNotableDateCache(...) + AddCachedNotableDateService(cacheFactory: …) |
| Never pay the first-request computation | AddNotableDateCacheWarmup(...), or service.Warm(territories, firstYear, lastYear) |
| Fail the host when the store is broken | ValidateStorageOnStart = true (and ThrowOnStorageFailure = true for run-time faults) |
| Disable caching without changing wiring | cacheFactory: _ => NullNotableDateCache.Instance |
Where to go next
- Core concepts - vocabulary: decorator vs service, cache key and unit, hit / miss / refresh, TTL vs resource version, warm-up, write status, storage failure policy, backend classes, observability, thread safety and lifetime.
- Getting started - install and minimal samples: in-memory, TOML file, DI with
appsettings.json, SQLite, Redis, warm-up, a customINotableDateCache. - Caching notable dates guide - worked patterns, freshness tuning, observability, troubleshooting.
- Calendar dependency injection and the DependencyInjection package - the registrations the caching decorator wraps.
- Bodu.Globalization.Calendar.Caching API reference - full type-by-type docs.
- Runnable samples - offline calendar sample projects.
- Globalization & Calendars topic - the runtime with its companion packages and data packs.