diff --git a/.agent_instructions/testing.md b/.agent_instructions/testing.md index fedb1a0a..7fbd47f5 100644 --- a/.agent_instructions/testing.md +++ b/.agent_instructions/testing.md @@ -4,6 +4,7 @@ - Write developer tests using xUnit. - Name test methods in the format: When_[condition]_should_[expected_behavior]. - Name test classes `[Behavior]Tests` — the `When_` convention is for method names and file names only, never class names. For example `QueryProcessorExecuteTests`, `PipelineBuilderDecoratorTests`. +- Name the class-under-test variable after the class, not `_sut` — for example `defaultCacheKeyGenerator` for `DefaultCacheKeyGenerator`. - Prefer a test case per file. - Name test files for the test method in the file i.e. When_[condition]_should_[expected_behavior].cs - If you decide to use multiple test cases per file, for example shared complex set up, name the file after the happy path test method and the class after the shared behavior. diff --git a/Darker.Filter.slnf b/Darker.Filter.slnf index 58958100..a460923d 100644 --- a/Darker.Filter.slnf +++ b/Darker.Filter.slnf @@ -10,6 +10,7 @@ "src\\Paramore.Darker.Validation\\Paramore.Darker.Validation.csproj", "src\\Paramore.Darker.Validation.FluentValidation\\Paramore.Darker.Validation.FluentValidation.csproj", "src\\Paramore.Darker.Validation.DataAnnotations\\Paramore.Darker.Validation.DataAnnotations.csproj", + "src\\Paramore.Darker.Caching\\Paramore.Darker.Caching.csproj", "test\\Paramore.Darker.Benchmarks\\Paramore.Darker.Benchmarks.csproj", "test\\Paramore.Darker.Tests.AOT\\Paramore.Darker.Tests.AOT.csproj", "test\\Paramore.Darker.Core.Tests\\Paramore.Darker.Core.Tests.csproj", @@ -17,7 +18,8 @@ "test\\Paramore.Darker.Extensions.Diagnostics.Tests\\Paramore.Darker.Extensions.Diagnostics.Tests.csproj", "test\\Paramore.Darker.Validation.Tests\\Paramore.Darker.Validation.Tests.csproj", "test\\Paramore.Darker.Validation.FluentValidation.Tests\\Paramore.Darker.Validation.FluentValidation.Tests.csproj", - "test\\Paramore.Darker.Validation.DataAnnotations.Tests\\Paramore.Darker.Validation.DataAnnotations.Tests.csproj" + "test\\Paramore.Darker.Validation.DataAnnotations.Tests\\Paramore.Darker.Validation.DataAnnotations.Tests.csproj", + "test\\Paramore.Darker.Caching.Tests\\Paramore.Darker.Caching.Tests.csproj" ] } } \ No newline at end of file diff --git a/Darker.slnx b/Darker.slnx index aa9650cb..68fa8c1d 100644 --- a/Darker.slnx +++ b/Darker.slnx @@ -70,6 +70,12 @@ + + + + + + @@ -120,5 +126,11 @@ + + + + + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 6c0f0089..6769de2f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,9 @@ + + + diff --git a/docs/adr/0021-caching-decorator-architecture.md b/docs/adr/0021-caching-decorator-architecture.md new file mode 100644 index 00000000..82cd1c18 --- /dev/null +++ b/docs/adr/0021-caching-decorator-architecture.md @@ -0,0 +1,458 @@ +# 21. Caching Decorator Architecture + +Date: 2026-07-20 + +## Status + +Accepted + +## Context + +Darker has no first-class caching stage in its pipeline. Caching is one of the most common +cross-cutting concerns for queries: because queries are read-only and deterministic for the same +input, they are inherently cacheable, and Darker's decorator pipeline is the natural place to apply +caching transparently. Today a developer who wants caching must hand-write cache-check/populate logic +inside every handler, mixing an infrastructural concern into query logic and repeating it everywhere. + +**Parent Requirement**: [specs/014-Caching-Decorator/requirements.md](../../specs/014-Caching-Decorator/requirements.md) + +**Scope**: This ADR decides the **architecture of query caching** as a single cohesive decision: +the caching attribute (sync + async), the caching decorator (sync + async) and how it plugs into +Darker's existing decorator pipeline with short-circuit-on-hit semantics, the choice of Microsoft's +`HybridCache` as the pluggable caching abstraction, the pluggable cache-key strategy, the well-known +`IQueryContext.Bag` tag seam for externally-driven eviction, the packaging/targeting split, the DI +registration extension, and cache hit/miss metrics **derived from traces by reusing Darker's existing +metrics-from-traces subsystem (ADR 0018)** under an independent opt-out toggle. It resolves the seven +decisions recorded in the requirements (single package on `HybridCache`; sync fast-path over the async +cache; TTL-only lifetime plus Bag-key tag eviction; Darker-owned hit/miss OTel metrics via the +existing observability support with an independent toggle; net8.0/net9.0 targeting; the +negative-caching / fail-fast boundary rules; and the step-first `(int step, int expirationSeconds)` +attribute constructor). + +### Forces at play + +- **Darker uses decorators, not a handler chain** — the caching stage is a + `[QueryHandlerAttribute]` on the handler's execute method that returns a decorator *type* via + `GetDecoratorType()`; `PipelineBuilder` resolves that type from the `IQueryHandlerDecoratorFactory` + and wraps the handler. This is the same seam validation, logging, and the policies use. +- **`HybridCache` is *already* the pluggable abstraction (unlike validation)** — the validation + feature needed an abstract template-method decorator with provider subclasses (FluentValidation, + DataAnnotations) because there is no single BCL validation abstraction. Caching is different: + Microsoft's `HybridCache` (`Microsoft.Extensions.Caching.Hybrid`) is *itself* the provider-agnostic + seam, and FusionCache ships a `HybridCache` implementation. So the caching decorator is **concrete, + not abstract** — it depends only on the `HybridCache` type, and the consumer chooses the backing + implementation purely by which `HybridCache` they register in DI. No Darker-specific per-provider + package or subclass is required. *(→ FR6, FR7, Resolved Decision 1)* +- **Short-circuit / ordering is significant** — on a cache hit the handler and every inner + decorator must **not** run. That is exactly what `HybridCache.GetOrCreateAsync` gives us: its + factory (which invokes `next`) runs only on a miss. Because a hit skips everything "inside" the + cache decorator, the decorator's **`Step`** — the position it occupies in the pipeline — is + first-class, and its ordering guidance must be documented. `Step` is the mandatory base-class + constructor argument, so the attribute is step-first. *(→ FR1, FR3, NFR correctness)* +- **Decorators are closed over `IQuery`, not the concrete query** — `PipelineBuilder` + closes each decorator's open generic over `typeof(IQuery)` + (`src/Paramore.Darker/PipelineBuilder.cs:253` sync, `:404` async), so a decorator's `TQuery` type + parameter **is `IQuery` at runtime, never the concrete query type** — the same trap the + 0020 validation ADR's amendment caught. Both cache-key paths must therefore reflect on the + **runtime object**: `query is IAmCacheable` and `query.GetType().FullName`, never on `typeof(TQuery)`. +- **Attribute state flows through `GetAttributeParams()` and is applied at pipeline build** — + unlike validation (which carries no per-attribute state), the caching attribute carries + `expirationSeconds`. `PipelineBuilder` calls `decorator.InitializeFromAttributeParams(attribute.GetAttributeParams())` + while **building** the pipeline (`PipelineBuilder.cs:263`). That is the correct, honest place to + validate `expirationSeconds > 0` and fail fast — the exception surfaces at pipeline build, before + any handler runs. *(→ FR2)* +- **Fail fast (FR12)** — a query marked `[CacheableQuery]` with no `HybridCache` registered is a + configuration error, not a silent cache-bypass. Darker already has + `Paramore.Darker.Exceptions.ConfigurationException` (used by the policy and validation decorators + for exactly this "you asked for X but didn't configure it" case); the same non-positive-expiry and + null/empty-`CacheKey` failures reuse it. +- **Darker already has a metrics-from-traces subsystem — reuse it (FR10)** — Darker's observability + is not tracing-only. `Paramore.Darker.Extensions.Diagnostics` already implements ADR 0018's + metrics-from-traces pattern: a `DarkerMetricsFromTracesProcessor` (`BaseProcessor`) fires + on each span end, filters to the `paramore.darker` source, and dispatches by `ActivityKind` to + per-concern meters — `QueryMeter` for `Internal` (query) spans, `DbMeter` for `Client` (DB) spans. + Each meter is built on `IMeterFactory.Create(DarkerSemanticConventions.MeterName)` (meter name + `"paramore.darker"`, a constant that lives in **core**), records measurements filtered to a + low-cardinality allowed-tag set, and exposes `Enabled` for cheap short-circuiting; `AddDarkerInstrumentation()` + on a `MeterProviderBuilder` registers the meters and subscribes the meter name. FR10's cache hit/miss + **counters** are therefore *not* a new metrics primitive — they are a new instrument added to this + existing subsystem. The cache decorator records the outcome as a **span attribute** (the same way + every Darker metric's source data reaches the meters — via the span), and a new cache meter derives + the **counter** at span end. The counter is a genuinely distinct signal from the span attribute + (FR10's wording), and its emission is gated by its **own opt-out toggle** on + `AddDarkerInstrumentation`, independent of `InstrumentationOptions` (which gates span-attribute + *groups* on the tracing side, a different concern). *(→ FR10, Resolved Decision 4)* +- **Async-first with a sync fast-path** — Darker exposes paired sync/async decorator interfaces + (`IQueryHandlerDecorator.Execute` returning `TResult`; + `IQueryHandlerDecoratorAsync.ExecuteAsync` returning `Task`). The async + path is primary. The sync path calls `GetOrCreateAsync` and inspects the returned + `ValueTask`: returns synchronously on `IsCompletedSuccessfully` (the in-memory-hit case), + otherwise blocks via `.AsTask().GetAwaiter().GetResult()`. Correctness never depends on synchronous + completion. *(→ FR8, Resolved Decision 2)* +- **Targeting (NFR)** — `Microsoft.Extensions.Caching.Hybrid` requires net8.0+, so the caching + package targets **net8.0/net9.0 only**, deliberately not the `netstandard2.0` the Darker core + targets. Acceptable because caching is a separate, opt-in package. *(→ FR7, Resolved Decision 5)* + +### Why this is one decision, not a bolt-on + +The crux is a single control-flow seam: **`GetOrCreateAsync` *is* the short-circuit**. The factory it +invokes on a miss is precisely "the rest of the pipeline (`next`)", and the value it returns on a hit +is precisely "skip everything inside me". Everything else in this feature hangs off that one seam — +the key that indexes it (the pluggable strategy), the expiry and tag that qualify the stored entry, +the hit/miss signal derived from whether the factory ran, and the sync fast-path over the same +`ValueTask`. Splitting these into separate ADRs would obscure that they are facets of one +`GetOrCreateAsync` call. + +## Decision + +Adopt a **concrete caching decorator over Microsoft's `HybridCache` abstraction**, plugged into +Darker's existing decorator pipeline via a step-ordered attribute, that uses `GetOrCreateAsync` to +short-circuit the pipeline on a hit. Cache-key computation is delegated to a **replaceable +`ICacheKeyGenerator` role**; expiry is a required attribute argument; a tag may be supplied through a +well-known `IQueryContext.Bag` key for externally-driven eviction. Hit/miss is recorded by the +decorator as a **cache-outcome span attribute**, and Darker's existing metrics-from-traces subsystem +(ADR 0018) **derives a hit/miss counter** from it via a new cache meter, under an opt-out toggle +independent of `InstrumentationOptions`. The backing cache implementation (Microsoft `HybridCache` +↔ FusionCache) is chosen entirely by DI registration with no change to Darker code. + +### Architecture Overview + +``` +[CacheableQueryAsync(step: 1, expirationSeconds: 300)] ─ attribute on handler's ExecuteAsync + │ GetDecoratorType() → typeof(CacheableQueryDecoratorAsync<,>) (CONCRETE type) + │ GetAttributeParams() → new object[] { 300 } ──► InitializeFromAttributeParams (build-time, + ▼ validates expiry > 0, maps to Expiration) +PipelineBuilder ── resolves decorator from IQueryHandlerDecoratorFactory (closed over IQuery) + ▼ + ┌──────────────────────────────────────────────────────────────────────────────────┐ + │ CacheableQueryDecoratorAsync (Paramore.Darker.Caching) │ + │ ExecuteAsync(query, next, fallback, ct): │ + │ var cache = serviceProvider.GetService() │ + │ ?? throw new ConfigurationException(...) ◄── FR12 fail-fast │ + │ var key = keyGenerator.GenerateKey(query) ─────────────► ICacheKeyGenerator │ + │ var tags = ReadTag(Context.Bag) ─── Bag["Paramore.Darker.Caching.Tag"] + │ var ran = false; │ + │ var result = await cache.GetOrCreateAsync(key, │ + │ factory: (s,c) => { ran = true; return new(next(s.q, c)); }, │ + │ options: { Expiration = expiry }, tags, ct); ── factory runs on MISS only + │ Context.Span?.SetTag("paramore.darker.cache.outcome", ran ? "miss" : "hit"); │ + │ return result; // on HIT, next (and every inner decorator) never ran │ (only touches + └──────────────────────────────────────────────────────────────────────────────────┘ core Activity — + │ GenerateKey │ cache-outcome span attribute NO OTel dep) + ▼ ▼ + ICacheKeyGenerator ── default DefaultCacheKeyGenerator query span ends + query is IAmCacheable ? query.CacheKey (fail-fast if │ + null/empty/whitespace) ▼ (Paramore.Darker.Extensions.Diagnostics, + : query.GetType().FullName + "|" + DarkerMetricsFromTracesProcessor.OnEnd ADR 0018) + │ ActivityKind.Internal → cache meter + ▼ + IAmADarkerCacheMeter (CacheMeter) + IMeterFactory.Create("paramore.darker") + Counter "paramore.darker.cache.requests" + Add(1, {query.type, cache.outcome}) ◄── opt-out toggle on + AddDarkerInstrumentation +``` + +The cache check runs **before** `next`, so on a hit the handler and any inner decorators never run. +Placement relative to other decorators is controlled by the attribute's `Step`, exactly like every +other Darker decorator — and here that placement is semantically load-bearing, because everything +ordered "inside" the cache decorator is skipped on a hit. + +### Key Components + +**Package — `Paramore.Darker.Caching` (net8.0/net9.0), depends on `Microsoft.Extensions.Caching.Hybrid` +and the Darker core (but **not** on OpenTelemetry / `System.Diagnostics.Metrics`):** + +- **`IAmCacheable`** — a *knowing* role a query may implement to supply its own key. Exact shape, + in `namespace Paramore.Darker.Caching`: + ```csharp + public interface IAmCacheable { string CacheKey { get; } } + ``` + Getter-only, non-nullable `string`. A runtime `null`/empty/whitespace value **fails fast** with + `ConfigurationException` (the nullable annotation is compile-time only). *(→ FR4)* + +- **`CacheableQueryAttribute` / `CacheableQueryAttributeAsync`** — *interfacer/structurer* attributes + deriving from `QueryHandlerAttribute` / `QueryHandlerAttributeAsync` (mandatory `step`, no + parameterless base). Signature `(int step, int expirationSeconds)`, step-first per the + `[QueryLogging(1)]` / `[ValidateQuery(step)]` convention. `GetDecoratorType()` names the **concrete** + decorator open generic; `GetAttributeParams()` returns `new object[] { expirationSeconds }`. The + single shared well-known Bag-key constant lives here: + ```csharp + public const string CacheTag = "Paramore.Darker.Caching.Tag"; + ``` + referenced by both variants and both decorators. *(→ FR1, FR2, FR9)* + +- **`CacheableQueryDecorator` / `CacheableQueryDecoratorAsync`** — + concrete *coordinators* implementing Darker's decorator interfaces. They own the + get-or-create/short-circuit control flow, read expiry in `InitializeFromAttributeParams` + (validating `> 0`, mapping to `HybridCacheEntryOptions.Expiration`), read the optional tag from + `Context.Bag`, resolve `HybridCache` (fail-fast when absent), delegate key computation to + `ICacheKeyGenerator`, and record the hit/miss outcome onto the query span + (`Context.Span?.SetTag(DarkerSemanticConventions.CacheOutcome, …)`) — using only the core + `Activity` type, so the package takes no OTel/metrics dependency. They do **not** know how keys are + formed, how the cache serialises, or how the outcome becomes a counter — those are delegated. The + async decorator awaits `GetOrCreateAsync`; the sync decorator inspects its returned + `ValueTask` for the fast-path/blocking-fallback (consumed exactly once). *(→ FR3, FR8, FR10)* + +- **`CacheOutcome { Hit, Miss }`** — a small *information holder* enum the decorator uses internally + to name its factory-ran determination (`ran ? Miss : Hit`) rather than passing a bare `bool`, then + maps to the low-cardinality span-attribute value (`"hit"`/`"miss"`). Reveals intent; avoids + primitive obsession on a two-state flag. + +- **`ICacheKeyGenerator`** (default **`DefaultCacheKeyGenerator`**) — a *deciding/knowing* role, + injected into the decorator and **replaceable via DI without changing the decorator** (NFR + extensibility). Contract: `string GenerateKey(object query)` operating on the **runtime object**. + The default: if `query is IAmCacheable c` → return `c.CacheKey` (fail-fast on null/empty/whitespace); + otherwise return `query.GetType().FullName + "|" + `. The JSON body is **stable across runs**: properties ordered by name (ordinal), + `CultureInfo.InvariantCulture` formatting, `null` properties emitted explicitly, nested + objects/collections serialised structurally in element order. Worked example (FR5): + `GetUser(42)` ⇒ `"MyApp.Queries.GetUser|{\"UserId\":42}"`; `GetUser(43)` ⇒ a distinct body. + *(→ FR4, FR5)* + +- **`AddCaching(...)`** — an `IDarkerHandlerBuilder` *structurer* extension (mirroring + `AddJsonQueryLogging()` / `AddDefaultPolicies()` and the validation `Use*` ergonomics). It registers + the two concrete decorator open generics with Darker's decorator registry and registers + `DefaultCacheKeyGenerator` (overridable via an options callback for a custom `ICacheKeyGenerator`). + It does **not** register a `HybridCache` — the consumer registers Microsoft's or FusionCache's + implementation separately, which is exactly how the backing cache is switched with no Darker code + change. It also does **not** wire metrics: cache-metric emission is part of the observability + pipeline (`AddDarkerInstrumentation`, below), not the handler-caching registration. *(→ FR7)* + +**Core — `Paramore.Darker` (`DarkerSemanticConventions`) additions (where `MeterName`, +`QueryDurationMetricName`, and the allowed-tag sets already live):** + +- `CacheOutcome = "paramore.darker.cache.outcome"` — the span-attribute / counter-dimension key + (value `"hit"`/`"miss"`). +- `CacheRequestsMetricName = "paramore.darker.cache.requests"` — the counter instrument name. +- `CacheRequestsAllowedTags = { QueryType, CacheOutcome }` — the low-cardinality tags permitted on the + counter (mirrors `QueryDurationAllowedTags`; high-cardinality keys like `QueryId` excluded). + +**Package — `Paramore.Darker.Extensions.Diagnostics` additions (the ADR 0018 metrics subsystem):** + +- **`IAmADarkerCacheMeter`** (default **`CacheMeter`**) — a *doing* role modelled exactly on + `IAmADarkerQueryMeter`/`QueryMeter`: `CacheMeter(IMeterFactory, MeterProvider)` creates a + `Counter` via `meterFactory.Create(DarkerSemanticConventions.MeterName)` named + `CacheRequestsMetricName`; `RecordCacheOperation(Activity activity)` reads the `CacheOutcome` tag + off the span and — only when present — `Add(1, …)` with the allowed tags filtered from the span + plus the service attributes; `Enabled` exposes the counter's listener state for short-circuiting. +- **`DarkerMetricsFromTracesProcessor`** — extended so its `ActivityKind.Internal` (query span) branch + also calls `cacheMeter.RecordCacheOperation(activity)` (a no-op when the span carries no cache + outcome), and its cheap short-circuit guard includes `cacheMeter.Enabled`. +- **`AddDarkerInstrumentation`** — extended to `TryAddSingleton()` + alongside the query and DB meters, and to accept the **opt-out toggle** (e.g. + `AddDarkerInstrumentation(bool emitCacheMetrics = true)`): when disabled it registers a no-op + `IAmADarkerCacheMeter` (`Enabled == false`) so the cache counter is never recorded — the FR10 lever + for avoiding double-reporting when the underlying cache already emits equivalent metrics. This + toggle is entirely separate from `InstrumentationOptions`. + +### Technology Choices + +- **`Microsoft.Extensions.Caching.Hybrid` / `HybridCache`** — the single caching abstraction; both + Microsoft's implementation and FusionCache's `HybridCache` plug in via DI. Version pinned in + `Directory.Packages.props` (CPM) — the package is **not yet referenced anywhere in the repo**, so + adding it is a tracked prerequisite of this feature. +- **`HybridCache.GetOrCreateAsync`** — provides the atomic get-or-populate and *is* the pipeline + short-circuit; its `HybridCacheEntryOptions.Expiration` receives the mapped TTL, and its + `IEnumerable` `tags` parameter receives the wrapped Bag tag. `LocalCacheExpiration` (L1) is + left to HybridCache's default (capped at `Expiration`) — not set in v1. +- **Reuse the ADR 0018 metrics-from-traces subsystem** — `IMeterFactory`, meter name + `DarkerSemanticConventions.MeterName` (`"paramore.darker"`), the `DarkerMetricsFromTracesProcessor`, + and `AddDarkerInstrumentation`. The cache counter is a new `Counter` created through the same + `IMeterFactory.Create(MeterName)` the query/DB histograms use, following the `QueryMeter`/`DbMeter` + template. No new `Meter`, no new meter name, no metrics abstraction invented in the caching package. +- **Reuse `Paramore.Darker.Exceptions.ConfigurationException`** for all three fail-fast cases (missing + `HybridCache`, non-positive `expirationSeconds`, null/empty runtime `CacheKey`) — consistent with + the policy and validation decorators; no new exception type. +- **Primitive types at the interop boundary are a conscious, justified exception** to the + avoid-primitive-obsession principle, not an oversight: `int expirationSeconds` is dictated by C# + attribute arguments being compile-time constants (a `TimeSpan` cannot be one); `string CacheKey`, + `string CacheTag`, and `string` cache keys/`IEnumerable` tags are the exact shapes + `HybridCache.GetOrCreateAsync` / `RemoveByTagAsync` consume; the `"hit"`/`"miss"` string is the + low-cardinality span-attribute/metric-dimension value the metrics-from-traces subsystem already + works in. The design guidance explicitly permits primitives "where we need to serialize, or for + interoperability." Domain-meaningful state that is *not* an interop boundary uses expressive types + (the `CacheOutcome` enum; `IAmCacheable` as a role rather than a bare string on the query). + +### Implementation Approach + +1. Add `Microsoft.Extensions.Caching.Hybrid` (pinned) to `Directory.Packages.props`. +2. Add project `Paramore.Darker.Caching` targeting `net8.0;net9.0` with `IAmCacheable`, the two + attributes (with the shared `CacheTag` constant), the `CacheOutcome` enum, the two concrete + decorators, `ICacheKeyGenerator` + `DefaultCacheKeyGenerator`, an options type (custom key + generator), and `AddCaching(...)`. This package references the Darker core and + `Microsoft.Extensions.Caching.Hybrid` only — **no OpenTelemetry / metrics dependency**. +3. The decorator resolves `HybridCache` from `IServiceProvider` and throws `ConfigurationException` + when absent (FR12). It maps `expirationSeconds` → `HybridCacheEntryOptions.Expiration` in + `InitializeFromAttributeParams`, throwing `ConfigurationException` on a non-positive value (fail + fast at pipeline build). +4. **Hit vs miss is derived from whether the factory ran.** A local `bool` is set inside the factory + (which runs only on a miss); after `GetOrCreateAsync` returns, `true` ⇒ miss, `false` ⇒ hit. The + decorator records this as `CacheOutcome` and writes it to `Context.Span` (when non-null) as the + `paramore.darker.cache.outcome` attribute. Pass the query as `GetOrCreateAsync` state to avoid + per-call closure allocation. *(Metrics-accuracy caveat under stampede protection: see Risks.)* +5. `null` results are **not** special-cased — `GetOrCreateAsync` stores whatever the factory returns, + so a `null` is cached (negative caching) and returned as a hit within the expiry window (FR11). + Serialization failures from the chosen cache surface to the caller unswallowed (FR13). +6. The tag is read from `Context.Bag[CacheableQueryAttribute.CacheTag]`; a non-empty `string` is + wrapped as a one-element tag set and passed to `GetOrCreateAsync`; absent or non-string ⇒ stored + untagged, no throw (FR9). Tagging against an implementation without tag support still caches and + never fails the query (FR14, best-effort). +7. In **core** `DarkerSemanticConventions`, add `CacheOutcome`, `CacheRequestsMetricName`, and + `CacheRequestsAllowedTags`. In **`Paramore.Darker.Extensions.Diagnostics`**, add + `IAmADarkerCacheMeter` + `CacheMeter` (modelled on `QueryMeter`), dispatch to it from the + `ActivityKind.Internal` branch of `DarkerMetricsFromTracesProcessor` (extending the `Enabled` + short-circuit), and register it (plus the opt-out toggle) in `AddDarkerInstrumentation`. +8. **TDD tests** (net8.0 + net9.0 via `Darker.Filter.slnf`) cover, at minimum on the async variant and + the core cases on both: hit skips the handler (call-count recorder) and inner decorators; miss runs + `next` once and populates; re-run after a short expiry; `IAmCacheable` key vs default-strategy key + with the FR5 worked example (determinism + distinct-inputs); `step` ordering / inner-decorator-skip; + non-positive `expirationSeconds` ⇒ `ConfigurationException` at build; null/empty runtime `CacheKey` + ⇒ `ConfigurationException`; missing `HybridCache` ⇒ `ConfigurationException`; cached `null` returns + as a hit; serialization failure surfaces; tag applied ⇒ `RemoveByTagAsync` evicts, absent ⇒ + untagged, tag on non-supporting impl still caches; **the decorator writes the cache-outcome span + attribute, `CacheMeter` derives a hit/miss counter from it, and the `AddDarkerInstrumentation` + toggle disables that counter**; sync fast-path (`IsCompletedSuccessfully`) **and** blocking + fallback; switching the backing `HybridCache` (Microsoft ↔ FusionCache) purely via DI; opt-in + proven through `AddCaching`. +9. **End-to-end pipeline tests are mandatory.** Because `TQuery` is `IQuery` at runtime, a + decorator instantiated directly (or resolved over the concrete query type) exercises a resolution + path the pipeline never uses. Each core behaviour MUST have at least one test that drives a + `[CacheableQueryAsync]` handler through a **real `QueryProcessor`** with a registered `HybridCache`. + +## Consequences + +### Positive + +- **No provider subclassing** — because `HybridCache` is already the pluggable seam, caching needs no + abstract template-method decorator or per-provider package; the decorator is concrete and simple. + Switching Microsoft ↔ FusionCache is a pure DI choice. +- **`GetOrCreateAsync` gives correct short-circuit for free** — hit-skips-inner-decorators and + run-exactly-once-on-miss (including stampede protection) are the cache abstraction's own guarantees. + Returned *results* are always correct; the only caveat is metrics accuracy under stampede (below). +- **Metrics reuse, no new subsystem** — cache counters are one more instrument on the existing ADR + 0018 subsystem (same `IMeterFactory`, meter name, processor, and `AddDarkerInstrumentation` + registration), so operators get a consistent `paramore.darker` metrics surface and the caching + package stays free of any OTel/metrics dependency. +- **Replaceable key strategy** — extracting `ICacheKeyGenerator` keeps the decorator's control flow + independent of key formation; teams can supply a domain-specific strategy without touching Darker. +- **Opt-in and ordered** — caching is per-handler via the attribute and slots into the pipeline by + `Step`; handlers that don't opt in pay nothing. +- **Honest boundaries** — negative caching, serialization surfacing, and the three fail-fast cases are + explicit and tested rather than silent. + +### Negative + +- **Cache metrics require tracing + the metrics pipeline** — because they are metrics-from-traces + (ADR 0018), the cache counter only materialises when a query span exists (a tracer is configured) + **and** `AddDarkerInstrumentation` has registered the cache meter. This is the same property the + existing query-duration and DB metrics already have, and is the deliberate cost of reusing the + subsystem rather than emitting counters inline. Callers who want cache counts without tracing are + not served in v1 (see Alternatives). +- **Metric hit/miss counts are approximate under stampede** — see Risks; results stay correct, only + the counter can under-count misses. +- **Feature spans three packages** — core (convention constants), `Paramore.Darker.Caching` (decorator + + span attribute), and `Paramore.Darker.Extensions.Diagnostics` (counter). This is the honest shape + of "span-enrichment in the pipeline, metric-derivation in the observability package", but it does + mean the metric lives in a different assembly from the decorator that sources it. +- **Two decorator variants + the sync-over-async fast-path** — the sync path's `ValueTask` inspection + and blocking fallback are subtle and carry the usual sync-over-async deadlock caveat; both branches + must be tested. Consistent with Darker's existing sync/async duality. +- **Ordering is a footgun** — a mis-placed `Step` silently skips inner decorators (logging, retry) on + a hit. Mitigated by documentation making the cache decorator's position first-class guidance. +- **net8.0/net9.0 only** — `netstandard2.0` consumers cannot use this package and must supply their + own caching. Accepted per the targeting NFR. + +### Risks and Mitigations + +- **Risk: cache-key logic reflects on `typeof(TQuery)` (which is `IQuery`) instead of the + runtime object** — every entry would collide under one key. *Mitigation*: `ICacheKeyGenerator` takes + `object query` and uses `query.GetType()` / `query is IAmCacheable`; a determinism + distinct-inputs + test on the FR5 worked example, driven through a real `QueryProcessor`, proves it. +- **Risk: non-deterministic default key** (property-order or culture drift) ⇒ silent cache misses. + *Mitigation*: fixed ordinal property ordering, `InvariantCulture`, explicit `null`s; a + same-query-same-key-across-runs test. +- **Risk: hit/miss counter is miscounted under cache-stampede protection.** `GetOrCreateAsync` runs + the factory **once** for N concurrent callers on the same missing key. Only the caller whose factory + runs sets `ran = true` (records a miss); the joined callers observe `ran == false` and record a + **hit**, even though no entry existed when they arrived. So under a concurrent same-key miss the + miss counter is under-reported and the hit counter inflated. *Mitigation / accepted position*: this + is a **metrics-only** inaccuracy — the returned results are correct, and the decorator instance and + `ran` local are per-query so there is no cross-query corruption. It is documented as a known caveat; + `factory-ran` is treated as a best-effort hit/miss signal, not an exact oracle. (A precise + hit/miss signal would require cooperation from the cache implementation that `HybridCache` does not + expose.) +- **Risk: sync-over-async deadlock** on the blocking fallback in a sync context. *Mitigation*: the + fast-path returns synchronously for the common in-memory hit; the blocking fallback is documented as + the sync-path cost and covered by a test that forces the non-completed branch. +- **Risk: double-reported metrics** when the underlying cache already emits equivalent counters. + *Mitigation*: the `AddDarkerInstrumentation` opt-out toggle registers a no-op `IAmADarkerCacheMeter` + (`Enabled == false`), so Darker's cache counter is never recorded — independent of + `InstrumentationOptions`. +- **Risk: missing `HybridCache` silently disables caching.** *Mitigation*: fail-fast + `ConfigurationException` with a message naming the registration required. + +## Alternatives Considered + +- **Copy the validation ADR's abstract template-method decorator + provider packages.** Rejected: + validation needed it because there is no single validation abstraction; caching already has one in + `HybridCache`, so an abstract decorator and per-provider packages would be pure ceremony. FusionCache + is consumed as a `HybridCache` implementation, not a Darker package. +- **Depend directly on `IMemoryCache` / `IDistributedCache` (or a Darker-defined `ICache`).** Rejected: + `HybridCache` already unifies L1+L2, stampede protection, tagging, and serialization; reinventing + that abstraction (or forcing consumers to pick a tier) adds surface for no gain and forfeits + FusionCache interop. +- **A bespoke `Meter` / `ICacheMetrics` emitter owned by the caching package, recording hit/miss + counters inline in the decorator.** Rejected: Darker already has a metrics subsystem (ADR 0018, + `IMeterFactory` + meter name `paramore.darker` + `AddDarkerInstrumentation`), and FR10 asks for + metrics "via the existing Observability support." A parallel `Meter` (e.g. `paramore.darker.caching`) + would fragment the metrics surface, duplicate registration machinery, and drag an OTel/metrics + dependency into the otherwise-lean caching package. The one thing the inline approach buys — + cache counts **without** tracing — is not required by the requirements and is outweighed by + consistency; recorded here as the fallback if tracing-independent cache metrics are later needed. +- **Inline the cache-key logic in the decorator.** Rejected: violates the extensibility NFR (key + strategy must be replaceable without changing the decorator) and mixes a *deciding* responsibility + into the *coordinator*. +- **Gate cache metrics through `InstrumentationOptions`.** Rejected: that enum gates span-attribute + groups (a different signal); the counter needs its own on/off so it can be silenced independently to + avoid double-reporting. The toggle therefore lives on `AddDarkerInstrumentation` (the metrics + registration), not on the `[Flags]` span-attribute enum. *(Resolved Decision 4)* +- **Special-case `null` results (skip caching them).** Rejected: negative caching for the configured + TTL is standard, and fighting `GetOrCreateAsync`'s "store whatever the factory returns" contract + adds complexity for a worse cache-stampede profile. *(FR11)* +- **A Darker-provided invalidation/`RemoveByTag` API.** Rejected as out of scope for v1: lifetime is + expiry (TTL); eviction is externally driven through the well-known Bag tag + the underlying cache's + own `RemoveByTagAsync`. Darker ships no invalidation call of its own. +- **Silent default expiry.** Rejected: `expirationSeconds` is a required, compile-time-constant + argument (a `TimeSpan` cannot be an attribute argument) so every cached query has an explicit, + visible lifetime; non-positive fails fast. *(FR2)* + +## References + +- Requirements: [specs/014-Caching-Decorator/requirements.md](../../specs/014-Caching-Decorator/requirements.md) +- Linked issue: [#291 — Add caching decorator with HybridCache and FusionCache support](https://github.com/BrighterCommand/Darker/issues/291) +- Origin: [V5 discussion #273](https://github.com/BrighterCommand/Darker/discussions/273) +- Related ADRs: [0020-validation-decorator-architecture](0020-validation-decorator-architecture.md) + (the closest decorator-pattern precedent; note this feature is concrete, not template-method), + [0015-resilience-pipeline-integration](0015-resilience-pipeline-integration.md), + [0016-pipeline-attribute-memoization](0016-pipeline-attribute-memoization.md) (decorator/pipeline mechanics), + [0017-query-tracing-and-database-spans](0017-query-tracing-and-database-spans.md), + [0018-metrics-from-query-traces](0018-metrics-from-query-traces.md) (the metrics-from-traces + subsystem this feature's cache counter reuses) +- Prior art: Microsoft [`HybridCache`](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid) + and the [HybridCache proposal](https://github.com/dotnet/aspnetcore/issues/54647); + [FusionCache HybridCache support](https://github.com/ZiggyCreatures/FusionCache/blob/main/docs/MicrosoftHybridCache.md) +- Darker mechanics: `src/Paramore.Darker/QueryHandlerAttribute.cs`, + `src/Paramore.Darker/QueryHandlerAttributeAsync.cs`, + `src/Paramore.Darker/IQueryHandlerDecorator.cs`, + `src/Paramore.Darker/PipelineBuilder.cs:253` / `:263` / `:404` (closed over `IQuery`; + `InitializeFromAttributeParams` at build time), + `src/Paramore.Darker/Observability/DarkerSemanticConventions.cs` (`MeterName`, metric names, and + allowed-tag sets — where the cache convention constants are added), + `src/Paramore.Darker.Extensions.Diagnostics/Observability/QueryMeter.cs` and `DarkerMetricsFromTracesProcessor.cs` + and `src/Paramore.Darker.Extensions.Diagnostics/DarkerMetricsBuilderExtensions.cs` (the + `IMeterFactory`/processor/`AddDarkerInstrumentation` pattern the cache counter follows), + `src/Paramore.Darker.Validation.FluentValidation/FluentValidationDarkerBuilderExtensions.cs` + (the `Use*`/`Add*` DI registration template), + `src/Paramore.Darker/Observability/InstrumentationOptions.cs` (the span-attribute toggle this + feature's metrics toggle is deliberately independent of) diff --git a/specs/.current-spec b/specs/.current-spec index 5e373496..eb5fff7f 100644 --- a/specs/.current-spec +++ b/specs/.current-spec @@ -1 +1 @@ -013-validation-decorator +014-Caching-Decorator \ No newline at end of file diff --git a/specs/014-Caching-Decorator/.adr-list b/specs/014-Caching-Decorator/.adr-list new file mode 100644 index 00000000..9e3c4304 --- /dev/null +++ b/specs/014-Caching-Decorator/.adr-list @@ -0,0 +1 @@ +0021-caching-decorator-architecture.md diff --git a/specs/014-Caching-Decorator/.design-approved b/specs/014-Caching-Decorator/.design-approved new file mode 100644 index 00000000..e69de29b diff --git a/specs/014-Caching-Decorator/.issue-number b/specs/014-Caching-Decorator/.issue-number new file mode 100644 index 00000000..e6d11f62 --- /dev/null +++ b/specs/014-Caching-Decorator/.issue-number @@ -0,0 +1 @@ +291 \ No newline at end of file diff --git a/specs/014-Caching-Decorator/.requirements-approved b/specs/014-Caching-Decorator/.requirements-approved new file mode 100644 index 00000000..e69de29b diff --git a/specs/014-Caching-Decorator/.tasks-approved b/specs/014-Caching-Decorator/.tasks-approved new file mode 100644 index 00000000..e69de29b diff --git a/specs/014-Caching-Decorator/README.md b/specs/014-Caching-Decorator/README.md new file mode 100644 index 00000000..fc36c684 --- /dev/null +++ b/specs/014-Caching-Decorator/README.md @@ -0,0 +1,29 @@ +# Caching Decorator + +**Spec ID:** 014 +**Created:** 2026-07-20 +**Status:** Requirements + +## Overview + +An opt-in `[CacheableQuery]` decorator that caches query results through the Darker pipeline, built on Microsoft's `HybridCache` abstraction (FusionCache pluggable via DI). Supports sync + async pathways (sync uses an immediate-completion fast return), pluggable cache keys (`IAmCacheable` with a serialization fallback), TTL expiry plus externally-driven tag eviction via a well-known `IQueryContext.Bag` key, and configurable OpenTelemetry hit/miss signals. See `requirements.md` (linked issue [#291](https://github.com/BrighterCommand/Darker/issues/291)). + +## Status Checklist + +- [ ] **Requirements** — Define WHAT the feature must do (`/spec:requirements`) +- [ ] **Design (ADR)** — Define HOW the feature will be built (`/spec:design`) +- [ ] **Adversarial Review** — Multiple rounds of critical review (`/spec:review`) +- [ ] **Tasks** — Break the design into implementation tasks (`/spec:tasks`) +- [ ] **Implementation** — TDD implementation of tasks (`/spec:implement`) + +## Artifacts + +| Phase | File | Status | +|-------|------|--------| +| Requirements | `requirements.md` | Drafted (awaiting approval) | +| Design | `design.md` (ADR) | Not started | +| Tasks | `tasks.md` | Not started | + +## Notes + +_Add any context, links, or decisions here as the spec evolves._ diff --git a/specs/014-Caching-Decorator/ralph-tasks.md b/specs/014-Caching-Decorator/ralph-tasks.md new file mode 100644 index 00000000..246abc8e --- /dev/null +++ b/specs/014-Caching-Decorator/ralph-tasks.md @@ -0,0 +1,374 @@ +# Ralph Tasks: 014-Caching-Decorator + +> Auto-generated from the approved design for unattended TDD execution. +> Each task is self-contained with all context a fresh Claude session needs. + +## Spec Context + +- **Spec**: 014-Caching-Decorator +- **Requirements**: specs/014-Caching-Decorator/requirements.md +- **ADRs**: docs/adr/0021-caching-decorator-architecture.md + +## Tasks + +- [x] **Add Microsoft.Extensions.Caching.Hybrid to Directory.Packages.props (prerequisite)** + - **Behavior**: Central Package Management gains a pinned `Microsoft.Extensions.Caching.Hybrid` version so the new caching package and its tests can reference it. The package is not currently referenced anywhere in the repo. Pin the **concrete latest stable 9.x** patch — not a preview, not 10.x — to match the repo's net8.0/net9.0 targeting. + - **Test file**: _none — pure scaffolding (Central Package Management edit)._ + - **Test should verify**: + - No behavioral test. Correctness is proven by a clean restore/build of the solution filter. + - **Implementation files**: + - `Directory.Packages.props` - first resolve the concrete newest stable 9.x version (e.g. `dotnet package search Microsoft.Extensions.Caching.Hybrid --take 20` or nuget.org), then add `` with that exact resolved version (NOT a `9.0.x` token — a literal `x` is an invalid NuGet version and will break restore) to the existing `` of `PackageVersion` entries. + - **RALPH-VERIFY**: `dotnet build Darker.Filter.slnf -c Release` + - **References**: requirements.md (FR7, Targeting NFR, Constraints — "not yet referenced anywhere in the repo"); ADR 0021 (Implementation Approach step 1, Technology Choices); `Directory.Packages.props` (existing CPM layout) + +- [x] **Create the Paramore.Darker.Caching project (net8.0;net9.0)** + - **Behavior**: A new source project `Paramore.Darker.Caching` exists, targeting **net8.0 and net9.0 only** (deliberately NOT netstandard2.0), referencing the Darker core project and `Microsoft.Extensions.Caching.Hybrid` — and **NOT** OpenTelemetry or any metrics package. It is added to `Darker.slnx` and `Darker.Filter.slnf` so it builds with the rest of the solution. + - **Test file**: _none — pure scaffolding (new csproj + solution wiring)._ + - **Test should verify**: + - No behavioral test. Correctness is proven by the project compiling and appearing in the filtered solution build. + - **Implementation files**: + - `src/Paramore.Darker.Caching/Paramore.Darker.Caching.csproj` - `net8.0;net9.0`, `enable`, ``, `` (version comes from CPM). Model on `src/Paramore.Darker.Validation/Paramore.Darker.Validation.csproj` but without netstandard2.0. + - `Darker.slnx` - add the new project. + - `Darker.Filter.slnf` - add `src\\Paramore.Darker.Caching\\Paramore.Darker.Caching.csproj` to the projects array. + - **RALPH-VERIFY**: `dotnet build Darker.Filter.slnf -c Release` + - **References**: requirements.md (FR7, Targeting NFR, Resolved Decision 5); ADR 0021 (Key Components — "Package — `Paramore.Darker.Caching` … depends on … NOT on OpenTelemetry"); `src/Paramore.Darker.Validation/Paramore.Darker.Validation.csproj` (csproj template); `Darker.Filter.slnf` (existing project list) + +- [x] **Create the Paramore.Darker.Caching.Tests project** + - **Behavior**: A new test project `Paramore.Darker.Caching.Tests` exists (mirroring the validation feature's test-project layout), targeting net8.0 and net9.0, referencing `Paramore.Darker.Caching`, the DI extensions project, xunit.v3, and Shouldly. It is added to `Darker.Filter.slnf`. A single trivial passing test proves the project runs under `dotnet test`. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_test_project_bootstrapped_should_run.cs` + - **Test should verify**: + - A `[Fact]` named `When_test_project_bootstrapped_should_run` asserts `true.ShouldBeTrue()` so the runner and project wiring are proven. + - **Implementation files**: + - `test/Paramore.Darker.Caching.Tests/Paramore.Darker.Caching.Tests.csproj` - `net8.0;net9.0`, references to `src/Paramore.Darker.Caching`, `src/Paramore.Darker.Extensions.DependencyInjection`, and the standard test packages (model on `test/Paramore.Darker.Validation.Tests/Paramore.Darker.Validation.Tests.csproj`). + - `test/Paramore.Darker.Caching.Tests/When_test_project_bootstrapped_should_run.cs` - the trivial fact. + - `Darker.Filter.slnf` - add `test\\Paramore.Darker.Caching.Tests\\Paramore.Darker.Caching.Tests.csproj`. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_test_project_bootstrapped_should_run"` + - **References**: requirements.md (test-project placement mirrors validation); `test/Paramore.Darker.Validation.Tests/Paramore.Darker.Validation.Tests.csproj` (template); `Darker.Filter.slnf` + +- [x] **Add cache semantic-convention constants to core DarkerSemanticConventions** + - **Behavior**: The core `DarkerSemanticConventions` static class exposes the three cache constants next to `MeterName` / `QueryDurationAllowedTags`: `CacheOutcome = "paramore.darker.cache.outcome"` (span-attribute / counter-dimension key, value `"hit"`/`"miss"`), `CacheRequestsMetricName = "paramore.darker.cache.requests"` (counter instrument name), and a low-cardinality allowed-tag set `CacheRequestsAllowedTags = { QueryType, CacheOutcome }` (a `FrozenSet` on net8.0+, `HashSet` otherwise — mirroring `QueryDurationAllowedTags`). + - **Test file**: `test/Paramore.Darker.Core.Tests/When_reading_cache_semantic_conventions_should_expose_cache_names_and_tags.cs` + - **Test should verify**: + - `DarkerSemanticConventions.CacheOutcome` equals `"paramore.darker.cache.outcome"`. + - `DarkerSemanticConventions.CacheRequestsMetricName` equals `"paramore.darker.cache.requests"`. + - `CacheRequestsAllowedTags` contains exactly `QueryType` and `CacheOutcome` (and excludes high-cardinality keys like `QueryId`). + - **Implementation files**: + - `src/Paramore.Darker/Observability/DarkerSemanticConventions.cs` - add the three constants/sets in the "Meter / metric names" and "Per-instrument allowed-tag sets" regions, following the existing `#if NET8_0_OR_GREATER` FrozenSet pattern used by `QueryDurationAllowedTags`. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Core.Tests/ --filter "FullyQualifiedName~When_reading_cache_semantic_conventions_should_expose_cache_names_and_tags"` + - **References**: requirements.md (FR10); ADR 0021 (Key Components — "Core — `Paramore.Darker` (`DarkerSemanticConventions`) additions"); `src/Paramore.Darker/Observability/DarkerSemanticConventions.cs` (~line 96+, existing `MeterName` / `QueryDurationAllowedTags`); `test/Paramore.Darker.Core.Tests/When_reading_metric_semantic_conventions_should_expose_meter_and_metric_names.cs` (existing convention-test pattern) + +- [x] **DefaultCacheKeyGenerator produces the deterministic default key from query type + invariant JSON** + - **Behavior**: Introduce the `ICacheKeyGenerator` role (`string GenerateKey(object query)`, operating on the **runtime object** — never `typeof(TQuery)`), its default implementation `DefaultCacheKeyGenerator`, and the marker interface `IAmCacheable { string CacheKey { get; } }`. For a query that does **not** implement `IAmCacheable`, `GenerateKey` returns `query.GetType().FullName + "|" + `. The JSON body is stable across runs: properties ordered by name (ordinal), `CultureInfo.InvariantCulture` formatting, explicit `null` properties. Worked example (FR5): a `GetUser(int UserId)` query with `UserId = 42` yields a key ending in `|{"UserId":42}`; `UserId = 43` yields a distinct body. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_generating_default_key_should_be_deterministic_and_distinct_per_input.cs` + - **Test should verify**: + - For a test `GetUser` record/class with `UserId = 42`, the key equals `typeof(GetUser).FullName + "|{\"UserId\":42}"` (assert the `|{"UserId":42}` body exactly and that the key starts with the type's `FullName`). + - `GetUser(43)` produces a **different** key (distinct body `|{"UserId":43}`). + - Two **different query types with identical property shape and values** (e.g. `GetUser(int Id)` and `GetOrder(int Id)`, both `Id = 42`) produce **different** keys (FR5 "distinct queries … do not collide" — proven by the `Type.FullName` prefix, not merely implied). + - Calling `GenerateKey` twice on equal inputs yields the identical string (determinism across invocations). + - **Implementation files**: + - `src/Paramore.Darker.Caching/ICacheKeyGenerator.cs` - `string GenerateKey(object query)`. + - `src/Paramore.Darker.Caching/DefaultCacheKeyGenerator.cs` - reflect on `query.GetType()`; serialize public readable properties with `System.Text.Json` using a fixed ordinal property ordering and `CultureInfo.InvariantCulture`, emitting explicit nulls. + - `src/Paramore.Darker.Caching/IAmCacheable.cs` - `public interface IAmCacheable { string CacheKey { get; } }` (namespace `Paramore.Darker.Caching`). + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_generating_default_key_should_be_deterministic_and_distinct_per_input"` + - **References**: requirements.md (FR4, FR5); ADR 0021 (Key Components — `ICacheKeyGenerator` / `DefaultCacheKeyGenerator`, and Risks — "cache-key logic reflects on `typeof(TQuery)` … instead of the runtime object") + +- [x] **DefaultCacheKeyGenerator uses IAmCacheable.CacheKey when the query implements it** + - **Behavior**: When the runtime query object implements `IAmCacheable`, `DefaultCacheKeyGenerator.GenerateKey` returns the query's `CacheKey` verbatim instead of the default type+JSON strategy. Example (FR5): a query with `CacheKey => $"GetUser-{UserId}"` and `UserId = 42` yields `"GetUser-42"`. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_query_is_cacheable_should_use_its_cache_key.cs` + - **Test should verify**: + - A test query implementing `IAmCacheable` with `CacheKey => $"GetUser-{UserId}"` and `UserId = 42` produces exactly `"GetUser-42"` (not the type+JSON form). + - The returned key does not contain the type `FullName` or a `|` separator (proving the default strategy was bypassed). + - **Implementation files**: + - `src/Paramore.Darker.Caching/DefaultCacheKeyGenerator.cs` - branch: `if (query is IAmCacheable c) return c.CacheKey;` before the default strategy. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_query_is_cacheable_should_use_its_cache_key"` + - **References**: requirements.md (FR4, FR5); ADR 0021 (Key Components — `DefaultCacheKeyGenerator`, "`query is IAmCacheable c` → return `c.CacheKey`") + +- [x] **DefaultCacheKeyGenerator fails fast on a null/empty/whitespace IAmCacheable.CacheKey** + - **Behavior**: If a query implements `IAmCacheable` but its `CacheKey` returns `null`, empty, or whitespace **at runtime**, `GenerateKey` throws `Paramore.Darker.Exceptions.ConfigurationException` rather than caching under an empty/colliding key. (The non-nullable annotation on `CacheKey` is compile-time only.) + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_cacheable_key_is_null_or_whitespace_should_throw_configuration_exception.cs` + - **Test should verify**: + - A query whose `CacheKey` returns `null` causes `GenerateKey` to throw `ConfigurationException`. + - A query whose `CacheKey` returns `""` throws `ConfigurationException`. + - A query whose `CacheKey` returns `" "` (whitespace) throws `ConfigurationException`. + - **Implementation files**: + - `src/Paramore.Darker.Caching/DefaultCacheKeyGenerator.cs` - after reading `c.CacheKey`, `if (string.IsNullOrWhiteSpace(key)) throw new ConfigurationException(...)`. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_cacheable_key_is_null_or_whitespace_should_throw_configuration_exception"` + - **References**: requirements.md (FR4, Acceptance Criteria); ADR 0021 (Key Components — `IAmCacheable` "A runtime `null`/empty/whitespace value fails fast with `ConfigurationException`"); `src/Paramore.Darker/Exceptions/` (existing `ConfigurationException`) + +- [x] **Async caching decorator + AddCaching: cache hit skips the handler, miss runs it once and populates (minimal vertical slice)** + - **Behavior**: Introduce the smallest async caching pipeline that has a passing end-to-end test — **only** the constructs this test exercises (no `CacheOutcome` enum, no `CachingOptions` type yet; those arrive in the later tasks that first use them). This slice creates: `CacheableQueryAttributeAsync(int step, int expirationSeconds)` deriving from `QueryHandlerAttributeAsync` (`GetDecoratorType()` returns the concrete `typeof(CacheableQueryDecoratorAsync<,>)`, `GetAttributeParams()` returns `new object[] { expirationSeconds }`); the concrete `CacheableQueryDecoratorAsync` (constructor takes `IServiceProvider`); and a parameterless `AddCaching(this IDarkerHandlerBuilder)` extension. The decorator resolves `HybridCache` from the service provider, delegates key computation to `ICacheKeyGenerator` on the **runtime** query object, and calls `HybridCache.GetOrCreateAsync(key, factory, options)` where the factory sets a local `bool ran = true` and invokes `next`. On a **miss** the factory runs (`ran == true`) and the result is stored; on a **hit** the factory never runs and the cached result is returned without invoking `next`. (The span-attribute write and the `CacheOutcome` enum are added later; here the `ran` flag exists but its outcome is not yet recorded.) **This must be proven end-to-end through a real `QueryProcessor`** with a `[CacheableQueryAsync]`-annotated handler and a registered Microsoft `HybridCache` — a directly-instantiated decorator exercises a resolution path the pipeline never uses (see the runtime-type trap) and can go false-green. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_cached_query_executed_twice_should_run_handler_once_and_serve_hit.cs` + - **Test should verify**: + - Build a real `ServiceProvider`: `AddDarker().AddAsyncHandlers(...)/AddHandlersFromAssemblies(...).AddCaching()`, register Microsoft `HybridCache` via `services.AddHybridCache()`, and register a singleton handler-execution recorder (call-count) injected into the handler — modelled on `test/Paramore.Darker.Validation.FluentValidation.Tests/When_validated_query_executed_through_processor_should_validate.cs`. + - First `ExecuteAsync(query)` returns the handler result and the recorder shows the handler ran exactly once (a miss populated the cache). + - Second `ExecuteAsync(query)` with the same key returns the same result and the recorder count is **still 1** (a hit — the handler was not re-run). + - **Implementation files**: + - `src/Paramore.Darker.Caching/CacheableQueryAttributeAsync.cs` - `(int step, int expirationSeconds)`, step-first; `GetDecoratorType()` → `typeof(CacheableQueryDecoratorAsync<,>)`; `GetAttributeParams()` → `new object[] { expirationSeconds }`. + - `src/Paramore.Darker.Caching/CacheableQueryDecoratorAsync.cs` - concrete `IQueryHandlerDecoratorAsync`; resolve `HybridCache` + `ICacheKeyGenerator` from the injected `IServiceProvider`; `GetOrCreateAsync` with the query passed as state to avoid per-call closure allocation. + - `src/Paramore.Darker.Caching/CachingDarkerBuilderExtensions.cs` - parameterless `AddCaching(this IDarkerHandlerBuilder)`: `builder.RegisterDecorator(typeof(CacheableQueryDecoratorAsync<,>))` and register `DefaultCacheKeyGenerator` as `ICacheKeyGenerator` on `builder.Services`. (The `Action` overload is added in the later opt-in task.) + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_cached_query_executed_twice_should_run_handler_once_and_serve_hit"` + - **References**: requirements.md (FR1, FR3, FR7, FR8 async, Acceptance Criteria); ADR 0021 (Architecture Overview diagram, Decision, Implementation Approach steps 2–4, "End-to-end pipeline tests are mandatory"); `src/Paramore.Darker/PipelineBuilder.cs:404` (decorators closed over `IQuery`); `src/Paramore.Darker.Validation.FluentValidation/FluentValidationDarkerBuilderExtensions.cs` (DI registration template); `src/Paramore.Darker/Logging/QueryProcessorBuilderExtensions.cs` (`RegisterDecorator` usage); `src/Paramore.Darker.Extensions.DependencyInjection/ServiceCollectionDarkerHandlerBuilder.cs` (`RegisterDecorator`); `test/Paramore.Darker.Validation.FluentValidation.Tests/When_validated_query_executed_through_processor_should_validate.cs` (real-processor test template) + +- [x] **Expiry maps to HybridCacheEntryOptions.Expiration and the handler re-runs after it elapses** + - **Behavior**: `InitializeFromAttributeParams(new object[] { expirationSeconds })` reads `expirationSeconds` and maps it to `HybridCacheEntryOptions.Expiration = TimeSpan.FromSeconds(expirationSeconds)`, passed on every `GetOrCreateAsync` call. `LocalCacheExpiration` (L1) is left to HybridCache's default and is not set in v1. After the configured expiry elapses, a subsequent execution re-runs the handler (cache entry expired). + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_cache_entry_expires_should_rerun_handler.cs` + - **Test should verify**: + - Through a real `QueryProcessor` with a short `expirationSeconds` (e.g. 1), a first call runs the handler (recorder count 1), an immediate second call is a hit (count still 1), and after waiting slightly longer than the expiry a third call re-runs the handler (count 2). + - (If the loop environment disallows real waiting, force expiry by advancing the HybridCache's clock / using a 1-second expiry with a short real delay; keep the delay minimal.) + - **Implementation files**: + - `src/Paramore.Darker.Caching/CacheableQueryDecoratorAsync.cs` - store the mapped `HybridCacheEntryOptions` from `InitializeFromAttributeParams` and pass it to `GetOrCreateAsync`. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_cache_entry_expires_should_rerun_handler"` + - **References**: requirements.md (FR2, Acceptance Criteria — "after the configured expiry elapses, the handler re-runs"); ADR 0021 (Technology Choices — `HybridCacheEntryOptions.Expiration`, "`LocalCacheExpiration` … not set in v1"); `src/Paramore.Darker/PipelineBuilder.cs:263` (`InitializeFromAttributeParams` at build time) + +- [x] **Non-positive expirationSeconds fails fast at pipeline build** + - **Behavior**: A `[CacheableQueryAsync(step, expirationSeconds)]` with `expirationSeconds <= 0` throws `Paramore.Darker.Exceptions.ConfigurationException` inside `InitializeFromAttributeParams` — which `PipelineBuilder` invokes while **building** the pipeline (before any handler runs). There is no silent default and no caching with an undefined lifetime. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_expiration_seconds_not_positive_should_throw_at_pipeline_build.cs` + - **Test should verify**: + - Through a real `QueryProcessor`, executing a handler annotated with `expirationSeconds: 0` throws `ConfigurationException`, and the handler-execution recorder shows the handler never ran (failure is at build, before dispatch). + - A negative value (e.g. `-5`) likewise throws `ConfigurationException`. + - **Implementation files**: + - `src/Paramore.Darker.Caching/CacheableQueryDecoratorAsync.cs` - in `InitializeFromAttributeParams`, `if (expirationSeconds <= 0) throw new ConfigurationException(...)` before mapping to `Expiration`. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_expiration_seconds_not_positive_should_throw_at_pipeline_build"` + - **References**: requirements.md (FR2, Acceptance Criteria); ADR 0021 (Forces — "the correct, honest place to validate `expirationSeconds > 0` and fail fast"); `src/Paramore.Darker/PipelineBuilder.cs:263` + +- [x] **Missing HybridCache registration fails fast (FR12)** + - **Behavior**: If a query is annotated `[CacheableQueryAsync]` and `AddCaching()` is called, but **no `HybridCache` is registered in DI**, the decorator throws `Paramore.Darker.Exceptions.ConfigurationException` (naming the registration required) rather than silently bypassing the cache. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_hybrid_cache_not_registered_should_throw_configuration_exception.cs` + - **Test should verify**: + - Build a real `QueryProcessor` with `AddCaching()` but **without** `services.AddHybridCache()`; executing the cacheable query throws `ConfigurationException`. + - The exception message references the missing `HybridCache` registration. + - **Implementation files**: + - `src/Paramore.Darker.Caching/CacheableQueryDecoratorAsync.cs` - `var cache = serviceProvider.GetService() ?? throw new ConfigurationException("...register a HybridCache...");`. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_hybrid_cache_not_registered_should_throw_configuration_exception"` + - **References**: requirements.md (FR12); ADR 0021 (Forces — "Fail fast (FR12)", Architecture Overview diagram — `?? throw new ConfigurationException`) + +- [x] **IAmCacheable query caches under its own key end-to-end** + - **Behavior**: A `[CacheableQueryAsync]` handler whose query implements `IAmCacheable` caches under the query's `CacheKey` (not the default strategy) when driven through a real `QueryProcessor`, and two calls with the same `CacheKey` serve a hit. This proves the runtime `query is IAmCacheable` path works through the pipeline (where `TQuery` is `IQuery`). + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_cacheable_query_executed_through_processor_should_cache_under_its_key.cs` + - **Test should verify**: + - Two executions of an `IAmCacheable` query with the same `CacheKey` run the handler once (recorder count 1 after the second call). + - Two `IAmCacheable` queries whose `CacheKey`s differ each run the handler (distinct entries — recorder count 2), proving the key drives entry identity. + - **Implementation files**: + - _No new implementation — exercises the existing decorator + `DefaultCacheKeyGenerator` end-to-end. Add only the test double query/handler._ + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_cacheable_query_executed_through_processor_should_cache_under_its_key"` + - **References**: requirements.md (FR4, FR5, Acceptance Criteria); ADR 0021 ("End-to-end pipeline tests are mandatory", runtime-type risk); `src/Paramore.Darker/PipelineBuilder.cs:404` + +- [x] **Null/empty runtime CacheKey fails fast end-to-end through the pipeline** + - **Behavior**: When a `[CacheableQueryAsync]` query implements `IAmCacheable` but its `CacheKey` returns null/empty/whitespace at runtime, executing it through a real `QueryProcessor` surfaces `ConfigurationException` (from `DefaultCacheKeyGenerator`) and the handler does not run under an empty key. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_runtime_cache_key_is_empty_should_throw_through_processor.cs` + - **Test should verify**: + - Executing an `IAmCacheable` query whose `CacheKey` is `""` (or whitespace) through the processor throws `ConfigurationException`. + - The handler-execution recorder shows the handler never ran. + - **Implementation files**: + - _No new implementation — end-to-end coverage of the FR4 fail-fast path. Add only the test double query/handler._ + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_runtime_cache_key_is_empty_should_throw_through_processor"` + - **References**: requirements.md (FR4, Acceptance Criteria); ADR 0021 (Key Components — `IAmCacheable` fail-fast, "End-to-end pipeline tests are mandatory") + +- [x] **Step ordering: a cache hit skips an inner decorator** + - **Behavior**: The attribute's `step` orders the cache decorator relative to other decorators. When the cache decorator is ordered to wrap an inner decorator, a cache **hit** short-circuits the pipeline so the inner decorator does **not** run (only `next`, and everything inside it, is skipped). This is the load-bearing short-circuit / ordering guarantee. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_cache_hit_should_skip_inner_decorator.cs` + - **Test should verify**: + - Annotate a handler with the cache attribute plus a second, inner decorator ordered "inside" the cache decorator (via `step`), each recording its invocations via a shared recorder. + - First execution (miss) runs both the cache decorator and the inner decorator; second execution (hit) runs neither the inner decorator nor the handler (inner-decorator invocation count stays at 1, handler count stays at 1). + - **Implementation files**: + - _No new production implementation — add a simple recording inner decorator + its attribute as test doubles in the test project (model on the existing logging/validation decorator shape)._ + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_cache_hit_should_skip_inner_decorator"` + - **References**: requirements.md (FR1 step, NFR correctness/short-circuit, Acceptance Criteria — "a test with the cache decorator wrapping an inner decorator proves the inner decorator is skipped on a hit"); ADR 0021 (Forces — "Short-circuit / ordering is significant"); `src/Paramore.Darker/PipelineBuilder.cs:240` (`OrderByDescending(attr => attr.Step)`) + +- [x] **Document the step-ordering footgun on the attribute and package README** + - **Behavior**: The requirements make ordering-sensitivity a first-class **documentation** deliverable, not only a tested behavior ("Decorator ordering behaviour … demonstrated by a test **and documented**"; "correct step-ordering guidance is a first-class part of this feature's documentation"). Add developer-facing documentation that a cache **hit short-circuits the pipeline**, so any decorator ordered "inside" (a lower `step` than) the cache decorator — logging, retry, fallback — is **skipped on a hit**, and give guidance on choosing `step` accordingly. This closes the "and documented" clause left open by the step-ordering test task. + - **Test file**: _none — documentation deliverable (no behavioral test; the behavior is already proven by the step-ordering test task)._ + - **Test should verify**: + - No behavioral test. Correctness is proven by the documentation existing: XML `` on both attributes describing the short-circuit/ordering footgun, and a README section for the caching package. + - **Implementation files**: + - `src/Paramore.Darker.Caching/CacheableQueryAttributeAsync.cs` - add `` XML docs explaining that a cache hit skips everything ordered inside the cache decorator, and how `step` controls placement (mirroring the `[QueryLogging(1)]` convention). (Only the **async** attribute exists at this point in the ordering; the matching `` on the sync `CacheableQueryAttribute.cs` is added in the sync-decorator task that creates that file — do NOT reference or create the sync attribute here.) + - `src/Paramore.Darker.Caching/README.md` - new package README with an "Ordering matters" section documenting the short-circuit-on-hit footgun and recommended `step` placement relative to logging/retry/fallback. + - **RALPH-VERIFY**: `dotnet build src/Paramore.Darker.Caching/Paramore.Darker.Caching.csproj -c Release` (compiles with the async-attribute XML-doc additions; documentation task has no test filter) + - **References**: requirements.md (NFR "The ordering-sensitivity … must be documented", Additional Context "first-class part of this feature's documentation", Acceptance Criteria — "demonstrated by a test **and documented**"); ADR 0021 (Consequences — "Ordering is a footgun … Mitigated by documentation making the cache decorator's position first-class guidance"); the step-ordering test task above (the behavior this documents) + +- [x] **Null result is cached (negative caching) and returned as a hit (FR11)** + - **Behavior**: A handler that returns `null` has that `null` stored by `GetOrCreateAsync` like any value. A subsequent call within the expiry window returns the cached `null` as a **hit** without re-running the handler. The decorator does not special-case `null`. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_handler_returns_null_should_cache_null_and_serve_hit.cs` + - **Test should verify**: + - Through a real `QueryProcessor`, a handler returning `null` runs once; the first call returns `null`. + - A second call with the same key returns `null` and the recorder count is still 1 (the cached null was a hit; the handler did not re-run). + - **Implementation files**: + - _No new implementation — proves the existing `GetOrCreateAsync` flow does not special-case null. Add only the test double null-returning handler (reference type result)._ + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_handler_returns_null_should_cache_null_and_serve_hit"` + - **References**: requirements.md (FR11, Acceptance Criteria); ADR 0021 (Implementation Approach step 5, Alternatives — "Special-case null … Rejected") + +- [x] **Serialization failure surfaces to the caller unswallowed (FR13)** + - **Behavior**: If the configured `HybridCache` cannot serialize the result type on a miss, the resulting exception propagates to the caller — it is not swallowed and no result is silently returned uncached without signal. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_result_cannot_be_serialized_should_surface_exception.cs` + - **Test should verify**: + - Configure the `HybridCache` (or its serializer) so the handler's result type fails serialization on a miss (e.g. a result type / serializer combination that throws), then execute through a real `QueryProcessor`. + - The `ExecuteAsync` call throws (the serialization exception surfaces); the exception is not swallowed into a silent success. + - **Implementation files**: + - _No new implementation — confirms the decorator does not catch/swallow serializer exceptions from `GetOrCreateAsync`. Add only the test double result type / serializer setup._ + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_result_cannot_be_serialized_should_surface_exception"` + - **References**: requirements.md (FR13, Acceptance Criteria); ADR 0021 (Implementation Approach step 5 — "Serialization failures … surface to the caller unswallowed") + +- [x] **Decorator records the cache-outcome span attribute (hit/miss) using only the core Activity type** + - **Behavior**: Introduce the `CacheOutcome { Hit, Miss }` enum (first consumed here) and use it in the async decorator. After `GetOrCreateAsync` returns, the decorator derives the outcome from the local `ran` flag (`ran ? CacheOutcome.Miss : CacheOutcome.Hit`) and writes it onto the ambient query span via `Context.Span?.SetTag(DarkerSemanticConventions.CacheOutcome, "hit"|"miss")` — using only the core `System.Diagnostics.Activity` type, taking **no** OpenTelemetry/metrics dependency in the caching package. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_recording_outcome_should_set_cache_outcome_span_attribute.cs` + - **Test should verify**: + - Driving a `[CacheableQueryAsync]` handler **through a real `QueryProcessor`** with a Darker tracer registered so the pipeline populates `Context.Span` (an `ActivityListener`/`TracerProvider` subscribed to `paramore.darker` captures the span): the first execution (miss) leaves `paramore.darker.cache.outcome = "miss"` on the query span. + - A subsequent execution on the same key (hit) leaves `paramore.darker.cache.outcome = "hit"` on the query span. + - With **no** tracer configured (`Context.Span` is null), the same executions still succeed and do not throw (the `?.` guard). (Note: this is the runtime-type-trap-sensitive path, so it must go through the pipeline, not a directly-instantiated decorator — ADR Implementation Approach step 9.) + - **Implementation files**: + - `src/Paramore.Darker.Caching/CacheOutcome.cs` - `public enum CacheOutcome { Hit, Miss }`. Add an XML-doc note that this caching enum is distinct from the core `DarkerSemanticConventions.CacheOutcome` **string** constant (same name, different type/namespace — see reuse note). + - `src/Paramore.Darker.Caching/CacheableQueryDecoratorAsync.cs` - map `ran` → `CacheOutcome`, then `Context.Span?.SetTag(DarkerSemanticConventions.CacheOutcome, outcome == CacheOutcome.Hit ? "hit" : "miss")`. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_recording_outcome_should_set_cache_outcome_span_attribute"` + - **References**: requirements.md (FR10); ADR 0021 (Architecture Overview diagram — `SetTag("paramore.darker.cache.outcome", …)`, "only touches core Activity — NO OTel dep", Implementation Approach steps 4 & 9 "End-to-end pipeline tests are mandatory"); `src/Paramore.Darker/IQueryContext.cs` (`Activity? Span`); `src/Paramore.Darker/Observability/DarkerSemanticConventions.cs` (`CacheOutcome`); `src/Paramore.Darker/Observability/DarkerTracer.cs` (register the **core** `DarkerTracer` so `AddDarker` resolves `IAmADarkerTracer` and the pipeline populates `Context.Span`) + a BCL `System.Diagnostics.ActivityListener` subscribed to `paramore.darker` — **no OpenTelemetry / Diagnostics dependency in the caching test project** (do NOT pull in `AddDarkerInstrumentation`/OTel here) + +- [x] **Sync caching decorator + attribute: fast-path returns synchronously when the ValueTask is already completed** + - **Behavior**: Introduce the sync pathway: `CacheableQueryAttribute(int step, int expirationSeconds)` (deriving from `QueryHandlerAttribute`, carrying the shared `public const string CacheTag = "Paramore.Darker.Caching.Tag"`, `GetDecoratorType()` → `typeof(CacheableQueryDecorator<,>)`), and the concrete `CacheableQueryDecorator` implementing `IQueryHandlerDecorator`. Its `Execute` calls `HybridCache.GetOrCreateAsync` (factory wraps `next` in a completed `ValueTask`) and inspects the returned `ValueTask`: when `IsCompletedSuccessfully` (the expected in-memory-L1-hit case) it returns `.Result` **synchronously**, consuming the `ValueTask` exactly once. `AddCaching` is extended to also register the sync decorator open generic. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_sync_cache_value_task_completed_should_return_synchronously.cs` + - **Test should verify**: + - Through a real `QueryProcessor` (sync `Execute`), a warm cache entry (populated on a prior call) is served via the `IsCompletedSuccessfully` fast path and returns the correct cached result without re-running the handler (recorder count unchanged). + - The shared `CacheableQueryAttribute.CacheTag` constant equals `"Paramore.Darker.Caching.Tag"` and both the sync and async decorators reference the same constant. + - **Implementation files**: + - `src/Paramore.Darker.Caching/CacheableQueryAttribute.cs` - `(int step, int expirationSeconds)`; `public const string CacheTag = "Paramore.Darker.Caching.Tag"`; `GetDecoratorType()` → `typeof(CacheableQueryDecorator<,>)`; `GetAttributeParams()` → `new object[] { expirationSeconds }`. Also add the same step-ordering/short-circuit `` XML docs the async attribute carries (the sync counterpart of the ordering-documentation task), so both attributes document the footgun. + - `src/Paramore.Darker.Caching/CacheableQueryDecorator.cs` - sync decorator; `Execute` inspects the `ValueTask` from `GetOrCreateAsync`, returning `.Result` on `IsCompletedSuccessfully`; same expiry/key/span logic as the async decorator. + - `src/Paramore.Darker.Caching/CachingDarkerBuilderExtensions.cs` - also `builder.RegisterDecorator(typeof(CacheableQueryDecorator<,>))`. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_sync_cache_value_task_completed_should_return_synchronously"` + - **References**: requirements.md (FR1, FR8 sync, Resolved Decision 2, NFR async-first-with-sync-fast-path); ADR 0021 (Forces — "Async-first with a sync fast-path", Key Components — sync decorator "inspects its returned `ValueTask`"); `src/Paramore.Darker/QueryHandlerAttribute.cs`, `src/Paramore.Darker/IQueryHandlerDecorator.cs`, `src/Paramore.Darker/PipelineBuilder.cs:253` + +- [x] **Sync caching decorator blocking fallback materializes a non-completed ValueTask** + - **Behavior**: When the `ValueTask` returned by `GetOrCreateAsync` is **not** already completed (e.g. an L2/async populate on a miss), the sync `Execute` blocks via `.AsTask().GetAwaiter().GetResult()` to obtain the result. Correctness never depends on synchronous completion; the `ValueTask` is still consumed exactly once (fast-path OR fallback, never both). + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_sync_cache_value_task_not_completed_should_block_and_return.cs` + - **Test should verify**: + - Force the non-completed branch (e.g. a `HybridCache` / factory that yields asynchronously on a miss so the returned `ValueTask` is not `IsCompletedSuccessfully`), then call the sync `Execute` through a real `QueryProcessor`. + - The correct result is returned via the blocking fallback, and the handler ran exactly once (the `ValueTask` was consumed once). + - **Implementation files**: + - `src/Paramore.Darker.Caching/CacheableQueryDecorator.cs` - `else return valueTask.AsTask().GetAwaiter().GetResult();`. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_sync_cache_value_task_not_completed_should_block_and_return"` + - **References**: requirements.md (FR8, NFR async-first-with-sync-fast-path — "Both the fast-path and the blocking fallback must be covered by tests"); ADR 0021 (Risks — "sync-over-async deadlock … the blocking fallback is documented as the sync-path cost and covered by a test that forces the non-completed branch") + +- [x] **A tag supplied via the Bag key is applied to the entry and enables RemoveByTagAsync eviction (FR9)** + - **Behavior**: The decorator reads `Context.Bag[CacheableQueryAttribute.CacheTag]` (`"Paramore.Darker.Caching.Tag"`); when the value is a non-empty `string`, it wraps it as a one-element `IEnumerable` and passes it as the `tags` argument to `GetOrCreateAsync`. Application code can then evict the entry via the underlying cache's `RemoveByTagAsync(tag)`, after which the next execution is a miss and re-runs the handler. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_tag_supplied_in_bag_should_apply_and_allow_remove_by_tag.cs` + - **Test should verify**: + - Through a real `QueryProcessor` with the tag placed in `IQueryContext.Bag` under `CacheableQueryAttribute.CacheTag`, a first call populates a tagged entry and a second call is a hit (handler count 1). + - After calling `HybridCache.RemoveByTagAsync(tag)`, the next execution is a miss and re-runs the handler (count 2). + - **Implementation files**: + - `src/Paramore.Darker.Caching/CacheableQueryDecoratorAsync.cs` - read the tag from `Context.Bag`; when a non-empty string, pass `new[] { tag }` as `GetOrCreateAsync` tags. + - `src/Paramore.Darker.Caching/CacheableQueryDecorator.cs` - same tag-reading logic (shared helper referencing `CacheableQueryAttribute.CacheTag`). + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_tag_supplied_in_bag_should_apply_and_allow_remove_by_tag"` + - **References**: requirements.md (FR9, Acceptance Criteria); ADR 0021 (Key Components — shared `CacheTag` constant, Implementation Approach step 6); `src/Paramore.Darker/IQueryContext.cs` (`IDictionary Bag`) + +- [x] **Absent or non-string Bag tag value stores the entry untagged without throwing (FR9)** + - **Behavior**: When the `CacheableQueryAttribute.CacheTag` Bag key is absent, or its value is not a non-empty `string` (e.g. `null`, empty, whitespace, or a non-string object), the entry is stored **untagged** and no exception is thrown — consistent with the best-effort stance. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_bag_tag_absent_or_not_string_should_store_untagged.cs` + - **Test should verify**: + - With no `CacheTag` entry in the Bag, execution succeeds and caches (second call is a hit; no throw). + - With a non-string value under the `CacheTag` key (e.g. an `int`), execution succeeds and caches without throwing (entry is stored untagged). + - With an empty/whitespace string value, the entry is stored untagged and no throw occurs. + - **Implementation files**: + - `src/Paramore.Darker.Caching/CacheableQueryDecoratorAsync.cs` - guard: only wrap-and-pass tags when `Context.Bag.TryGetValue(...)` yields a non-empty `string`; otherwise pass no tags. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_bag_tag_absent_or_not_string_should_store_untagged"` + - **References**: requirements.md (FR9, Acceptance Criteria); ADR 0021 (Implementation Approach step 6 — "absent or non-string ⇒ stored untagged, no throw") + +- [x] **Tagging against a HybridCache implementation without tag support still caches and never fails the query (FR14)** + - **Behavior**: Supplying a tag is best-effort. If the configured `HybridCache` implementation does not support tag-based eviction, the entry is still cached (tagging becomes a no-op for eviction) and the query does not fail. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_cache_impl_lacks_tag_support_should_still_cache_query.cs` + - **Test should verify**: + - With a `HybridCache` test double whose tag handling is a no-op (or throws only on `RemoveByTagAsync`), a tagged cacheable query executes successfully through a real `QueryProcessor`: first call populates, second call is a hit (handler count 1). + - The query never surfaces a tagging-related failure. + - **Implementation files**: + - _No new production implementation — confirms tags are passed best-effort and not validated by the decorator. Add a `HybridCache` test double lacking tag-eviction support._ + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_cache_impl_lacks_tag_support_should_still_cache_query"` + - **References**: requirements.md (FR14, Acceptance Criteria); ADR 0021 (Implementation Approach step 6 — "Tagging against an implementation without tag support still caches and never fails the query, best-effort") + +- [x] **AddCaching opt-in exposes a configurable overload with a replaceable key generator** + - **Behavior**: Introduce the `CachingOptions` type (first used here) and add the `AddCaching(this IDarkerHandlerBuilder, Action configure)` overload alongside the parameterless `AddCaching()` created in the vertical-slice task. The `configure` callback (`CachingOptions`) allows supplying a custom `ICacheKeyGenerator` that replaces the default `DefaultCacheKeyGenerator` **without changing the decorator** (NFR extensibility). By this point both concrete decorator open generics are registered (async from the vertical-slice task, sync from the sync-decorator task); `AddCaching` registers no `HybridCache` and wires no metrics. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_AddCaching_called_should_register_decorators_and_key_generator.cs` + - **Test should verify**: + - After `AddDarker().AddCaching()`, the built `ServiceProvider` resolves `ICacheKeyGenerator` as `DefaultCacheKeyGenerator`, and both `CacheableQueryDecorator<,>` and `CacheableQueryDecoratorAsync<,>` are registered as decorators. + - After `AddCaching(o => o.KeyGenerator = new CustomKeyGenerator())` (a test-double generator), the resolved `ICacheKeyGenerator` is the custom instance, and a cacheable query executed end-to-end uses the custom key (proven via the custom generator's recorded call or a distinct key shape). + - **Implementation files**: + - `src/Paramore.Darker.Caching/CachingOptions.cs` - new type exposing a settable custom `ICacheKeyGenerator` (`KeyGenerator`). + - `src/Paramore.Darker.Caching/CachingDarkerBuilderExtensions.cs` - add the `Action` overload; honor `CachingOptions.KeyGenerator` (falling back to `DefaultCacheKeyGenerator` when unset) when registering `ICacheKeyGenerator`. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_AddCaching_called_should_register_decorators_and_key_generator"` + - **References**: requirements.md (FR7, NFR extensibility, Acceptance Criteria — "opt-in via that extension"); ADR 0021 (Key Components — `AddCaching`, "registers `DefaultCacheKeyGenerator` (overridable via an options callback)"); `src/Paramore.Darker.Validation.FluentValidation/FluentValidationDarkerBuilderExtensions.cs` + +- [x] **The Paramore.Darker.Caching assembly takes no OpenTelemetry/metrics dependency** + - **Behavior**: The caching package depends only on the Darker core and `Microsoft.Extensions.Caching.Hybrid`. It must not reference any OpenTelemetry assembly or `System.Diagnostics.Metrics` types — the cache outcome is recorded purely via the core `Activity` span attribute. + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_inspecting_caching_assembly_should_have_no_otel_or_metrics_dependency.cs` + - **Test should verify**: + - `typeof(CacheableQueryDecoratorAsync<,>).Assembly.GetReferencedAssemblies()` contains **no** assembly whose name starts with `OpenTelemetry`. + - It references `Microsoft.Extensions.Caching.Hybrid` and the Darker core (positive control that the guard is inspecting the right assembly). + - **Implementation files**: + - _No new production implementation — a guard test asserting dependency hygiene (model on `test/Paramore.Darker.Validation.Tests/When_inspecting_core_assembly_should_have_no_provider_dependencies.cs` if present, else a straightforward reflection assertion)._ + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_inspecting_caching_assembly_should_have_no_otel_or_metrics_dependency"` + - **References**: requirements.md (FR7, FR10); ADR 0021 (Key Components — "depends on … but NOT on OpenTelemetry / `System.Diagnostics.Metrics`") + +- [x] **Backing HybridCache can be switched to real FusionCache purely via DI (decorator unchanged)** + - **Behavior**: The cache implementation is selected entirely by DI. Registering the **real FusionCache** `HybridCache` implementation (via its Microsoft HybridCache adapter) in place of Microsoft's makes the same, unchanged caching decorator cache through FusionCache. This test uses the real FusionCache packages (test-only dependency). + - **Test file**: `test/Paramore.Darker.Caching.Tests/When_backing_cache_is_fusioncache_should_cache_via_di_switch.cs` + - **Test should verify**: + - Build a real `QueryProcessor` with `AddCaching()` and register FusionCache's `HybridCache` implementation via its DI extension (the `ZiggyCreatures.FusionCache.MicrosoftHybridCache` adapter) instead of `AddHybridCache()`. + - A cacheable query executed twice runs the handler once (a hit served by FusionCache), proving the switch required **no** change to handler or decorator code. + - **Implementation files**: + - `Directory.Packages.props` - add `` entries for `ZiggyCreatures.FusionCache` and `ZiggyCreatures.FusionCache.MicrosoftHybridCache` (latest stable compatible with net8.0/net9.0). These are **test-only** dependencies — keep them clearly separate from the production `Microsoft.Extensions.Caching.Hybrid` pin. + - `test/Paramore.Darker.Caching.Tests/Paramore.Darker.Caching.Tests.csproj` - `` the two FusionCache packages. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Caching.Tests/ --filter "FullyQualifiedName~When_backing_cache_is_fusioncache_should_cache_via_di_switch"` + - **References**: requirements.md (FR6, FR7, NFR extensibility, Acceptance Criteria — "switched … purely via DI registration"); ADR 0021 (Decision, Positive — "Switching Microsoft ↔ FusionCache is a pure DI choice"); product-owner decision 1 (use the REAL FusionCache package as a test-only dependency); `Directory.Packages.props` + +- [x] **CacheMeter derives a hit/miss counter from the cache-outcome span attribute** + - **Behavior**: In `Paramore.Darker.Extensions.Diagnostics`, add the `IAmADarkerCacheMeter` role and its default `CacheMeter`, modelled exactly on `IAmADarkerQueryMeter`/`QueryMeter`. `CacheMeter(IMeterFactory, MeterProvider)` creates a `Counter` via `meterFactory.Create(DarkerSemanticConventions.MeterName)` named `CacheRequestsMetricName`. `RecordCacheOperation(Activity activity)` reads the `CacheOutcome` tag from the span and — **only when present** — `Add(1, …)` with the `CacheRequestsAllowedTags`-filtered span tags plus the service attributes. `Enabled` exposes the counter's listener state for cheap short-circuiting. + - **Test file**: `test/Paramore.Darker.Extensions.Diagnostics.Tests/When_recording_cache_operation_should_record_counter_with_allowed_tags.cs` + - **Test should verify**: + - Given a stopped `Internal` activity carrying `paramore.darker.cache.outcome = "hit"` and `paramore.darker.querytype`, `RecordCacheOperation` records one measurement on the `paramore.darker.cache.requests` counter with the `cache.outcome` and `querytype` tags only. + - An activity **without** the `CacheOutcome` tag records nothing (no-op). + - `Enabled` is `false` when no `MeterProvider` subscribes to `paramore.darker`. + - **Implementation files**: + - `src/Paramore.Darker.Extensions.Diagnostics/Observability/IAmADarkerCacheMeter.cs` - `void RecordCacheOperation(Activity activity); bool Enabled { get; }`. + - `src/Paramore.Darker.Extensions.Diagnostics/Observability/CacheMeter.cs` - `Counter` via `IMeterFactory.Create(MeterName)`, filtering to `CacheRequestsAllowedTags`, reading the `CacheOutcome` tag; model on `QueryMeter`. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Extensions.Diagnostics.Tests/ --filter "FullyQualifiedName~When_recording_cache_operation_should_record_counter_with_allowed_tags"` + - **References**: requirements.md (FR10); ADR 0021 (Key Components — "Package — `Paramore.Darker.Extensions.Diagnostics` additions", Implementation Approach step 7); `src/Paramore.Darker.Extensions.Diagnostics/Observability/QueryMeter.cs` and `IAmADarkerQueryMeter.cs` (template); `src/Paramore.Darker/Observability/DarkerSemanticConventions.cs` (`CacheRequestsMetricName`, `CacheRequestsAllowedTags`) + +- [x] **The metrics-from-traces processor dispatches Internal spans to the cache meter** + - **Behavior**: `DarkerMetricsFromTracesProcessor` gains an `IAmADarkerCacheMeter` dependency. In its `ActivityKind.Internal` branch it also calls `cacheMeter.RecordCacheOperation(activity)` (a no-op when the span carries no cache outcome, so it coexists with `queryMeter.RecordQueryOperation`), and its cheap short-circuit guard is extended to include `cacheMeter.Enabled` (`if (!(queryMeter.Enabled || dbMeter.Enabled || cacheMeter.Enabled)) return;`). The construction site in `DarkerTracerBuilderExtensions` is updated to resolve and pass the cache meter and to include the cache meter in its registration gate. + - **Test file**: `test/Paramore.Darker.Extensions.Diagnostics.Tests/When_ending_internal_span_with_cache_outcome_should_dispatch_to_cache_meter.cs` + - **Test should verify**: + - An `Internal` span carrying `paramore.darker.cache.outcome = "miss"` routed through `OnEnd` records a measurement on `paramore.darker.cache.requests` (in addition to the query-duration histogram). + - When no meter (query, db, or cache) is enabled, `OnEnd` short-circuits and does not throw. + - **Implementation files**: + - `src/Paramore.Darker.Extensions.Diagnostics/Observability/DarkerMetricsFromTracesProcessor.cs` - add the `IAmADarkerCacheMeter` primary-constructor parameter; call it in the `Internal` branch; extend the `Enabled` guard. + - `src/Paramore.Darker.Extensions.Diagnostics/DarkerTracerBuilderExtensions.cs` - resolve `IAmADarkerCacheMeter` and pass it into the `new DarkerMetricsFromTracesProcessor(...)` (line ~46); include `IAmADarkerCacheMeter` in the meter-registration gate. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Extensions.Diagnostics.Tests/ --filter "FullyQualifiedName~When_ending_internal_span_with_cache_outcome_should_dispatch_to_cache_meter"` + - **References**: requirements.md (FR10); ADR 0021 (Key Components — `DarkerMetricsFromTracesProcessor` extension, Implementation Approach step 7); `src/Paramore.Darker.Extensions.Diagnostics/Observability/DarkerMetricsFromTracesProcessor.cs`; `src/Paramore.Darker.Extensions.Diagnostics/DarkerTracerBuilderExtensions.cs:46`; `test/Paramore.Darker.Extensions.Diagnostics.Tests/When_ending_span_through_processor_should_dispatch_to_meter_by_activity_kind.cs` (test template) + +- [x] **AddDarkerInstrumentation registers the cache meter and honors the opt-out toggle** + - **Behavior**: `AddDarkerInstrumentation` on the `MeterProviderBuilder` is extended to `TryAddSingleton()` alongside the query and DB meters, and to accept an opt-out toggle (e.g. `AddDarkerInstrumentation(bool emitCacheMetrics = true)`). When the toggle is **disabled**, it registers a no-op `IAmADarkerCacheMeter` whose `Enabled == false`, so the cache counter is never recorded. This toggle is entirely independent of `InstrumentationOptions`. + - **Test file**: `test/Paramore.Darker.Extensions.Diagnostics.Tests/When_adding_darker_instrumentation_should_register_cache_meter_with_toggle.cs` + - **Test should verify**: + - With `AddDarkerInstrumentation()` (default), the provider resolves `IAmADarkerCacheMeter` as `CacheMeter`. + - With the cache-metrics toggle disabled, the resolved `IAmADarkerCacheMeter.Enabled` is `false` (a no-op meter), and routing a cache-outcome span through the processor records nothing on `paramore.darker.cache.requests`. + - **Implementation files**: + - `src/Paramore.Darker.Extensions.Diagnostics/DarkerMetricsBuilderExtensions.cs` - add the toggle parameter; `TryAddSingleton()` when enabled, else register a no-op implementation (`Enabled == false`). + - `src/Paramore.Darker.Extensions.Diagnostics/Observability/` - a `NoOpCacheMeter` (or equivalent) implementing `IAmADarkerCacheMeter` with `Enabled == false` and a no-op `RecordCacheOperation`. + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Extensions.Diagnostics.Tests/ --filter "FullyQualifiedName~When_adding_darker_instrumentation_should_register_cache_meter_with_toggle"` + - **References**: requirements.md (FR10, Resolved Decision 4, Acceptance Criteria — "turned off via its own opt-out toggle, independent of `InstrumentationOptions`"); ADR 0021 (Key Components — `AddDarkerInstrumentation` extension, Risks — "double-reported metrics"); `src/Paramore.Darker.Extensions.Diagnostics/DarkerMetricsBuilderExtensions.cs`; `src/Paramore.Darker/Observability/InstrumentationOptions.cs` (the independent span-attribute toggle); `test/Paramore.Darker.Extensions.Diagnostics.Tests/When_adding_darker_instrumentation_to_meter_builder_should_register_meters_and_meter.cs` (test template) + +- [x] **End-to-end metrics chain: a cache miss then hit produce hit/miss counters, and the toggle disables them** + - **Behavior**: With tracing + `AddDarkerInstrumentation` (cache metrics enabled) wired around a real `QueryProcessor`, executing a cacheable query twice writes a `"miss"` then a `"hit"` cache-outcome span attribute, from which `CacheMeter` derives one miss and one hit measurement on `paramore.darker.cache.requests`. When the opt-out toggle is disabled, no cache counter is recorded even though results are unaffected. **Known accepted caveat (do NOT attempt to fix): under HybridCache stampede protection, joined concurrent callers on the same missing key observe `ran == false` and are counted as hits — a metrics-only skew; returned results stay correct.** Keep this test single-threaded so the caveat does not affect assertions. + - **Test file**: `test/Paramore.Darker.Extensions.Diagnostics.Tests/When_cached_query_executed_with_metrics_should_emit_hit_and_miss_counters.cs` + - **Test should verify**: + - Sequential first + second executions of a cacheable query (real processor + registered `HybridCache` + tracing + cache metrics) record exactly one `cache.outcome = "miss"` and one `cache.outcome = "hit"` measurement on `paramore.darker.cache.requests`. + - With the cache-metrics toggle disabled, the same sequence records **no** measurements on `paramore.darker.cache.requests` while still returning correct results. + - **Implementation files**: + - _No new production implementation — end-to-end verification wiring the caching decorator's span attribute to the diagnostics counter. The test lives in the diagnostics test project, which references both `Paramore.Darker.Caching` and `Paramore.Darker.Extensions.Diagnostics` (add the `Paramore.Darker.Caching` project reference to that test csproj if absent)._ + - **RALPH-VERIFY**: `dotnet test test/Paramore.Darker.Extensions.Diagnostics.Tests/ --filter "FullyQualifiedName~When_cached_query_executed_with_metrics_should_emit_hit_and_miss_counters"` + - **References**: requirements.md (FR10, Acceptance Criteria); ADR 0021 (Implementation Approach step 8, Risks — "hit/miss counter is miscounted under cache-stampede protection … metrics-only inaccuracy … documented as a known caveat"); `test/Paramore.Darker.Extensions.Diagnostics.Tests/When_executing_query_with_metrics_wired_should_record_query_and_db_duration_metrics.cs` (full-wiring test template) + +- [x] **Full feature builds and the whole filtered solution's tests pass on net8.0 and net9.0** + - **Behavior**: The complete feature builds and the **entire** `Darker.Filter.slnf` test suite passes on both target frameworks — not just cache-named subsets — confirming the caching package, core convention additions, and diagnostics additions integrate cleanly and introduce **no regressions** elsewhere in the solution. This is the final integration gate, so it must run the full filtered solution (both TFMs) rather than filtered project subsets. + - **Test file**: _none — whole-solution cross-framework verification (no new test)._ + - **Test should verify**: + - No new behavioral test. Correctness is proven by the full `Darker.Filter.slnf` building in Release and its complete test suite passing on net8.0 and net9.0 (the multi-targeted test projects run both TFMs by default). + - **Implementation files**: + - _No implementation — verification only. If any framework-specific gap or regression surfaces, fix it in the relevant `src/Paramore.Darker.Caching/*`, core, or diagnostics file._ + - **RALPH-VERIFY**: `dotnet build Darker.Filter.slnf -c Release && dotnet test Darker.Filter.slnf -c Release --no-build` + - **References**: requirements.md (Targeting NFR, Acceptance Criteria — "Builds and tests pass on net8.0 and net9.0 via `Darker.Filter.slnf`"); ADR 0021 (Implementation Approach step 8); `Darker.Filter.slnf` diff --git a/specs/014-Caching-Decorator/requirements.md b/specs/014-Caching-Decorator/requirements.md new file mode 100644 index 00000000..f5378883 --- /dev/null +++ b/specs/014-Caching-Decorator/requirements.md @@ -0,0 +1,114 @@ +# Requirements + +> **Note**: This document captures user requirements and needs. Technical design decisions and implementation details should be documented in an Architecture Decision Record (ADR) in `docs/adr/`. + +**Linked Issue**: [#291 — Add caching decorator with HybridCache and FusionCache support](https://github.com/BrighterCommand/Darker/issues/291) + +## Problem Statement + +As a **developer using Darker**, I would like **to cache query results declaratively via an attribute on my query handler**, so that **repeated queries with the same inputs can be served from a cache instead of re-executing the handler (and its downstream I/O), improving latency and reducing load — without hand-writing cache-check/populate logic in every handler**. + +Caching is one of the most common cross-cutting concerns for queries. Because queries are read-only and deterministic for the same input, they are inherently cacheable, and Darker's decorator pipeline is a natural place to apply caching transparently. Darker currently has no first-class caching stage in the pipeline. + +## Proposed Solution + +Provide an opt-in caching decorator that a developer applies to a query handler. When a query flows through the pipeline, the decorator computes a cache key for the query and asks the cache for the result. On a cache **hit**, the cached result is returned immediately and the rest of the pipeline (including the handler) is **not** invoked. On a cache **miss**, the decorator invokes the rest of the pipeline (`next`), stores the returned result in the cache under the computed key, and returns it. + +From the developer's perspective: + +- Annotate a handler's execute method with a `[CacheableQuery]` attribute to enable caching for that query, with configurable options such as expiry. +- Control the cache key for a query either by implementing an interface on the query (e.g. `IAmCacheable` exposing a `CacheKey`) or by relying on a default key strategy derived from the query's properties. +- Register a cache implementation in DI. Darker builds on Microsoft's `HybridCache` abstraction (in-memory + optional distributed backing), so either Microsoft's `HybridCache` or an alternative that implements it (e.g. FusionCache) can be plugged in. +- Observe cache behaviour (hits/misses) through telemetry. + +Because the decorator short-circuits the pipeline on a hit, **its position in the pipeline (step ordering) is significant**: any decorator ordered "inside" the cache decorator will not run on a cache hit. This must be documented clearly. + +## Requirements + +### Functional Requirements + +- **FR1** — Provide the caching decorator attribute in **two variants**, mirroring Darker's existing decorator convention (e.g. `ValidateQueryAttribute` / `ValidateQueryAttributeAsync`): a synchronous **`CacheableQueryAttribute`** (naming the sync decorator) and an asynchronous **`CacheableQueryAttributeAsync`** (naming the async decorator). Both derive from Darker's `QueryHandlerAttribute`, whose constructor requires a mandatory **`int step`** (there is no parameterless base) — so each variant's constructor takes `step` **first** (positional, per the existing `[QueryLogging(1)]` / `[ValidateQuery(step)]` convention) followed by the caching-specific arguments (FR2). This `step` is what places the decorator in the pipeline order the feature depends on. The well-known Bag-key constant (FR9) is a single shared public constant — `CacheableQueryAttribute.CacheTag` — referenced by both variants and both decorators. +- **FR2** — Each attribute carries the cache **expiry/time-to-live** as a **required** constructor argument, in addition to the mandatory `step` (FR1). The full signature is `CacheableQueryAttribute(int step, int expirationSeconds)` (and likewise for the async variant). Because attribute arguments must be compile-time constants (a `TimeSpan` cannot be an attribute argument), expiry is expressed as an integer count of **seconds** — e.g. `[CacheableQuery(step: 1, expirationSeconds: 300)]` — which the decorator maps to **`HybridCacheEntryOptions.Expiration`** (the overall expiration, spanning the L2/distributed tier). `LocalCacheExpiration` (the L1/in-memory tier) is left to HybridCache's default, which caps it at `Expiration`; the decorator does not set it explicitly in v1. There is **no silent default** — omitting expiry is a compile error, so every cached query has an explicit lifetime. **`expirationSeconds` must be positive**: a non-positive value fails fast with a configuration exception at pipeline build (mirroring Darker's fail-fast stance), rather than caching with an undefined lifetime. Tag-based eviction is **not** an attribute option (see FR9). +- **FR3** — Provide a `CacheableQueryDecorator` (and async variant) that: computes the cache key; on a hit returns the cached result **without** invoking `next`; on a miss invokes `next`, stores the result, and returns it — following the `GetOrCreateAsync` pattern. +- **FR4** — Cache key generation is **pluggable**: + - A query may implement **`IAmCacheable`** to supply its own key. The interface lives in the caching package (`namespace Paramore.Darker.Caching`) with the exact shape `public interface IAmCacheable { string CacheKey { get; } }` (getter-only, non-nullable `string`). If a query's `CacheKey` returns `null` or an empty/whitespace string **at runtime** (the nullable annotation is compile-time only), the decorator **fails fast** with a configuration exception rather than caching under an empty/colliding key. + - Queries that do **not** implement `IAmCacheable` fall back to a **default key strategy**: the query type's full name (`Type.FullName`) plus a **deterministic, culture-invariant JSON serialization of the query's public readable properties**, combined into a single string. Serialization must be stable across runs — property ordering fixed (e.g. by name, ordinal), `CultureInfo.InvariantCulture` for value formatting, and `null` properties emitted explicitly so present-vs-absent is distinguishable. Nested objects and collections are serialized structurally (element order as given). +- **FR5** — The cache key must account for the query **type** and all query inputs that affect the result, so that distinct queries (or distinct inputs) do not collide. *Worked example:* for `record GetUser(int UserId) : IQuery` with `UserId = 42`, the default key is `"MyApp.Queries.GetUser|{\"UserId\":42}"` (type full-name, a separator, then the invariant JSON body); `UserId = 43` yields a different body and therefore a different key. A query implementing `IAmCacheable` with `CacheKey => $"GetUser-{UserId}"` uses `"GetUser-42"` instead. +- **FR6** — Build on Microsoft's `HybridCache` as the caching abstraction so that both Microsoft's implementation and alternatives that implement `HybridCache` (notably **FusionCache**) are supported without Darker-specific provider code per implementation. +- **FR7** — The caching abstraction/decorator lives in its **own single package** (`Paramore.Darker.Caching`) depending on `Microsoft.Extensions.Caching.Hybrid`, separate from the Darker core, and is enabled via a DI registration extension consistent with Darker's other optional decorators. FusionCache is selected by the consumer registering it as the `HybridCache` implementation — no Darker-specific FusionCache package. +- **FR8** — Provide **both sync and async** pathways. + - The **async** decorator's `ExecuteAsync` (which returns `Task` per Darker's decorator contract) awaits `HybridCache.GetOrCreateAsync`, whose factory invokes `next`; it returns the awaited result. + - The **sync** decorator's `Execute` calls `HybridCache.GetOrCreateAsync` (whose factory invokes `next` synchronously wrapped in a completed `ValueTask`) and inspects the **`ValueTask` returned by `GetOrCreateAsync`**: if `IsCompletedSuccessfully` it returns `.Result` synchronously (the common in-memory-hit fast path); otherwise it materializes with `.AsTask().GetAwaiter().GetResult()`. The `ValueTask` is consumed exactly once by this sequence. +- **FR9** — Support **externally-driven tag-based eviction** without Darker exposing an invalidation API: the decorator reads a **well-known `IQueryContext.Bag` key — the public constant `CacheableQueryAttribute.CacheTag` with literal value `"Paramore.Darker.Caching.Tag"`** — to obtain a cache **tag**. `HybridCache` tags are `IEnumerable`; the well-known Bag value is a single `string`, which the decorator **wraps as a one-element tag set** and passes to `GetOrCreateAsync`. Other application code can then evict entries via the underlying cache's `RemoveByTagAsync` using the same tag. When the key is **absent, or its value is not a non-empty `string`**, the entry is stored **untagged** (no throw — consistent with the best-effort stance of FR14). Darker itself provides no explicit invalidation call in v1. +- **FR10** — Emit Darker's **own** OpenTelemetry cache **metrics** for hit and miss (counter instruments — a distinct signal from span attributes), via the existing Observability support. Emission is controlled by its **own opt-out toggle** (independent of `InstrumentationOptions`, which gates *span attribute groups* rather than metric emission) so it can be disabled to avoid double-reporting when the underlying cache (HybridCache/FusionCache) already emits equivalent metrics. +- **FR11** — **Null result handling:** a `null` result is cached like any other value (negative caching), consistent with the `GetOrCreateAsync` mechanism (FR3/FR8), which stores whatever the factory returns. On a subsequent call with the same key within the expiry window, the cached `null` is returned as a **hit** and `next` is **not** re-run; the entry re-populates only after expiry (FR2) or external tag eviction (FR9). The decorator does **not** special-case `null`. (Rationale: caching "not found" for the configured TTL is standard best practice and avoids fighting the cache abstraction.) +- **FR12** — **Missing cache registration (fail fast):** if a query is marked `[CacheableQuery]` but no `HybridCache` is registered in DI, the decorator throws a configuration exception rather than silently bypassing the cache — mirroring the validation decorator's fail-fast behaviour. +- **FR13** — **Serialization failure:** if the chosen `HybridCache` cannot serialize the result type, the resulting exception surfaces to the caller (it is not swallowed and the result is not silently returned uncached without signal). This is a configuration/serializer concern of the chosen cache, surfaced honestly. +- **FR14** — **Tag unsupported by implementation:** supplying a tag via the well-known Bag key is **best-effort**. If the configured `HybridCache` implementation does not support tag-based eviction, the entry is still cached (tagging is a no-op for eviction); this does not fail the query. + +### Non-functional Requirements + +- **Correctness / short-circuit semantics** — On a cache hit the handler and any inner decorators must not execute; on a miss the pipeline runs exactly once and the result is cached. The ordering-sensitivity of the decorator must be documented. +- **Async-first with sync fast-path** — The async path is primary. The sync path is an optimization: **when** `GetOrCreateAsync`'s `ValueTask` has already completed (the expected case for an in-memory L1 hit, though not contractually guaranteed by HybridCache), it returns the result synchronously; **otherwise** it blocks via `.AsTask().GetAwaiter().GetResult()`. Correctness never depends on synchronous completion — the blocking fallback always applies — so the sync-over-async cost (and its deadlock risk) is paid only when the `ValueTask` has not already completed. Both the fast-path and the blocking fallback must be covered by tests. +- **Extensibility** — Swapping the underlying cache (Microsoft HybridCache ↔ FusionCache) must be a DI/configuration choice requiring no changes to Darker code. Cache key strategy must be replaceable without changing the decorator. +- **Low overhead when not opted in** — Handlers that do not carry `[CacheableQuery]` incur no caching cost. +- **Consistency** — Naming, packaging, and registration ergonomics should follow Darker's existing decorator conventions (e.g. validation, logging, policies). +- **Targeting** — The new caching package targets **net8.0 and net9.0 only** — deliberately **not** `netstandard2.0` (which the Darker core and other decorator packages do target), because `Microsoft.Extensions.Caching.Hybrid` requires net8.0+. This is acceptable because caching is a separate, opt-in package: consumers on `netstandard2.0` who want caching must supply their own bespoke solution. Must build and test on net8.0 and net9.0. + +### Constraints and Assumptions + +- Origin: [V5 discussion #273](https://github.com/BrighterCommand/Darker/discussions/273); tracked by issue #291. +- Prior art / references: + - Microsoft [`HybridCache`](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid) and the [HybridCache proposal](https://github.com/dotnet/aspnetcore/issues/54647). + - [`IMemoryCache`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.caching.memory.imemorycache) / [`IDistributedCache`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.caching.distributed.idistributedcache). + - [FusionCache HybridCache support](https://github.com/ZiggyCreatures/FusionCache/blob/main/docs/MicrosoftHybridCache.md). +- Central Package Management (CPM) via `Directory.Packages.props` governs package versions. The `Microsoft.Extensions.Caching.Hybrid` package is **not yet referenced anywhere in the repo** — adding it (with a pinned version) to `Directory.Packages.props` is a tracked assumption/prerequisite of this feature, not an existing fact. +- Cache implementation is resolved from Microsoft.Extensions.DependencyInjection, consistent with Darker's DI integration. +- Assumption: caching is opt-in per handler (via attribute), not global. +- Assumption: cached results should be serializable by the chosen cache (HybridCache handles serialization of the result type). + +### Out of Scope + +- Automatic/global caching of all queries without opt-in. +- Providing Darker's own distributed cache backend or serializer — these come from the chosen `HybridCache` implementation and its configured backing store. +- Streaming-query (`IStreamQuery` / `IAsyncEnumerable`) caching. +- A Darker-provided invalidation/eviction API. v1 supports **expiry (TTL)** for cache lifetime plus **externally-driven tag eviction** (FR9); Darker exposes no `Remove`/`RemoveByTag` call of its own. +- A Darker-specific FusionCache package — FusionCache is consumed via its `HybridCache` implementation and DI registration. + +## Acceptance Criteria + +How we'll know this is working correctly (below, `[CacheableQuery]` is shorthand for "the applicable variant" — the async variant `[CacheableQueryAsync]` for async handlers, the sync `[CacheableQuery]` for sync handlers; the core hit/miss criteria are exercised on **both**, and at minimum the async variant since Darker handlers are async-first): + +- A query handler annotated with the caching attribute and backed by a registered `HybridCache`: + - on first execution runs the handler and returns its result, populating the cache; + - on a subsequent execution with the same key returns the cached result **without** re-running the handler (verifiable via a handler-execution recorder / call count); + - after expiry, re-runs the handler. +- A query implementing `IAmCacheable` uses its `CacheKey`; a query that does not falls back to the default key strategy. The default strategy is deterministic (same query ⇒ same key across runs) and distinct inputs produce distinct keys — asserted against the worked example in FR5 (`GetUser(42)` ⇒ `"MyApp.Queries.GetUser|{\"UserId\":42}"`, `GetUser(43)` ⇒ a different key). +- Both attribute variants exist — `CacheableQueryAttribute` (sync) and `CacheableQueryAttributeAsync` (async) — with the signature `(int step, int expirationSeconds)`; each maps to its respective sync/async caching decorator; the shared `CacheableQueryAttribute.CacheTag` constant is the single well-known Bag key used by both. +- The `step` argument orders the caching decorator in the pipeline: a test with the cache decorator wrapping an inner decorator proves the inner decorator is skipped on a hit, and the cache decorator's position is honoured relative to other decorators (consistent with `[QueryLogging(1)]`-style ordering). +- Expiry is a required attribute argument in seconds, mapped to `HybridCacheEntryOptions.Expiration`; after the configured expiry elapses, the handler re-runs (a test with a short expiry proves re-execution). A non-positive `expirationSeconds` fails fast with a configuration exception at pipeline build. +- A query whose `IAmCacheable.CacheKey` returns `null`/empty/whitespace at runtime fails fast with a configuration exception (does not cache under an empty key). +- The caching decorator ships as the package **`Paramore.Darker.Caching`** (net8.0/net9.0), depends on `Microsoft.Extensions.Caching.Hybrid`, and is enabled by a named DI registration extension (mirroring the validation `Use*()` / `AddDarker().Add…()` convention). A test/registration proves opt-in via that extension. +- The underlying cache can be switched between Microsoft `HybridCache` and FusionCache purely via DI registration, with no change to handler or decorator code. +- Cache hits and misses are observable as OpenTelemetry **metrics** (hit/miss counters), and that emission can be turned **off** via its own opt-out toggle (independent of `InstrumentationOptions`). +- Decorator ordering behaviour (inner decorators skipped on hit) is demonstrated by a test and documented. +- Both sync and async paths are covered, including the sync fast-path (`ValueTask.IsCompletedSuccessfully` ⇒ synchronous return) and the blocking fallback. +- A tag placed in `IQueryContext.Bag` under `CacheableQueryAttribute.CacheTag` (`"Paramore.Darker.Caching.Tag"`) is applied to the cache entry, and eviction via the underlying cache's `RemoveByTagAsync` with that tag removes the entry; absent the key, the entry is stored untagged. +- **Error/boundary behaviour is tested:** a `null` handler result **is cached** and a subsequent call within the expiry window returns the cached `null` as a hit without re-running the handler (FR11); a `[CacheableQuery]` handler with no `HybridCache` registered throws a fail-fast configuration exception (FR12); a serialization failure surfaces to the caller (FR13); a non-`string`/absent Bag value under the tag key stores the entry untagged, and tagging against an implementation without tag support still caches the entry and does not fail the query (FR9, FR14). +- Builds and tests pass on net8.0 and net9.0 via `Darker.Filter.slnf`, following the TDD workflow. + +## Resolved Decisions + +Confirmed with the product owner; these now inform the design: + +1. **Package layout** — **A single caching package** (e.g. `Paramore.Darker.Caching`) depending on `Microsoft.Extensions.Caching.Hybrid`. Both Microsoft `HybridCache` and FusionCache are supported because both implement `HybridCache`; the consumer chooses via DI. No per-provider packages. *(→ FR6, FR7)* +2. **Sync pathway** — **Sync uses the async cache path with an immediate-completion fast return.** The sync `Execute` calls `HybridCache.GetOrCreateAsync` and inspects the **`ValueTask` it returns**: on `IsCompletedSuccessfully` (the in-memory-hit case) it returns `.Result` synchronously; otherwise it falls back to `.AsTask().GetAwaiter().GetResult()`. The `ValueTask` is consumed exactly once. The `Task` return type of the async decorator's `ExecuteAsync` is irrelevant to this fast-path decision — the mechanism is entirely about the `ValueTask` from `GetOrCreateAsync`. *(→ FR8)* +3. **Invalidation** — **Expiry (TTL) only for lifetime**, supplied as a **required** attribute argument in seconds (no silent default; `TimeSpan` can't be an attribute argument). Additionally, **a cache tag may be supplied via the well-known Bag key `CacheableQueryAttribute.CacheTag` = `"Paramore.Darker.Caching.Tag"`** (value: a single `string`); the decorator applies it to the stored entry so other application code can evict using the underlying cache's `RemoveByTagAsync` with the same tag. Darker ships no invalidation API of its own. *(→ FR2, FR9)* +4. **Telemetry** — **Darker emits its own hit/miss OTel metrics (counters)** — a distinct signal type from span attributes — governed by its **own opt-out toggle**, **not** by `InstrumentationOptions` (which gates span attribute groups, a different concern). The toggle exists so emission can be disabled to avoid double-reporting when the underlying cache (HybridCache/FusionCache) already emits equivalent metrics. *(→ FR10)* +5. **Targeting** — The `Paramore.Darker.Caching` package targets **net8.0/net9.0 only** (not `netstandard2.0`), because `Microsoft.Extensions.Caching.Hybrid` requires net8.0+. Caching is a separate opt-in package, so `netstandard2.0` consumers who want caching supply their own solution. `Microsoft.Extensions.Caching.Hybrid` must be added to `Directory.Packages.props`. *(→ FR7, Targeting NFR)* +6. **Error/boundary behaviour** — `null` results **are cached** like any value (negative caching, honouring `GetOrCreateAsync`; no special-casing, FR11); a missing `HybridCache` registration fails fast (FR12); a **non-positive `expirationSeconds`** and a **null/empty runtime `IAmCacheable.CacheKey`** both fail fast with a configuration exception (FR2, FR4); serialization failures surface to the caller (FR13); a non-string/absent tag value stores the entry untagged and tags against a non-tag-supporting implementation are best-effort and never fail the query (FR9, FR14). +7. **Attribute constructor** — Both variants derive from `QueryHandlerAttribute(int step)` (step mandatory, no parameterless base), so the signature is `(int step, int expirationSeconds)`, step-first per the existing `[QueryLogging(1)]` convention. The `step` provides the pipeline ordering the feature's short-circuit semantics depend on. *(→ FR1, FR2)* + +## Additional Context + +Caching complements Darker's existing cross-cutting decorators (logging, retry/fallback policies, validation, telemetry). Because a cache hit short-circuits the pipeline, correct step-ordering guidance is a first-class part of this feature's documentation. diff --git a/specs/014-Caching-Decorator/review-design.md b/specs/014-Caching-Decorator/review-design.md new file mode 100644 index 00000000..538e223d --- /dev/null +++ b/specs/014-Caching-Decorator/review-design.md @@ -0,0 +1,53 @@ +# Review: design — 014-Caching-Decorator + +**Date**: 2026-07-21 +**Threshold**: 60 +**Verdict**: PASS (round 2) + +_Round 2 re-review of `docs/adr/0021-caching-decorator-architecture.md` after the round-1 revision. Round 1 (2026-07-20) returned NEEDS WORK with two findings — a false "no metrics primitive exists" claim (85) and an undocumented stampede hit/miss miscount (62); both are verified genuinely resolved below. Every codebase claim the rewrite makes was re-verified against source; the metrics-reuse rewrite introduced no new above-threshold problem._ + +## Verification summary + +**Metrics-from-traces reuse (the Finding-1 rewrite) — all accurate:** +- `DarkerSemanticConventions.MeterName == "paramore.darker"` lives in **core** (`src/Paramore.Darker/Observability/DarkerSemanticConventions.cs:96`); `QueryDurationMetricName` (`:99`), `QueryDurationAllowedTags` (`:125`), and `DbClientOperationDurationAllowedTags` (`:144`) all live there too. Adding `CacheOutcome`/`CacheRequestsMetricName`/`CacheRequestsAllowedTags` alongside them is consistent. +- `QueryMeter`/`DbMeter` are `(IMeterFactory meterFactory, MeterProvider meterProvider)` and build their instrument via `meterFactory.Create(DarkerSemanticConventions.MeterName)`, record `activity.TagObjects.Filter()`, and expose `Enabled` (`QueryMeter.cs:40-59`, `DbMeter.cs:40-59`). The proposed `CacheMeter` (a `Counter` created the same way, `RecordCacheOperation(Activity)`, `Enabled`) genuinely mirrors this — the only difference is `Counter` vs `Histogram`, correct since FR10 asks for a counter. +- `DarkerMetricsFromTracesProcessor` short-circuits on `if (!(queryMeter.Enabled || dbMeter.Enabled)) return;`, filters to the `paramore.darker` source, and dispatches `ActivityKind.Internal → queryMeter`, `Client → dbMeter` (`DarkerMetricsFromTracesProcessor.cs:53-72`). Adding a `cacheMeter.RecordCacheOperation` call on the `Internal` branch and `|| cacheMeter.Enabled` to the guard is feasible. +- `AddDarkerInstrumentation(this MeterProviderBuilder)` registers meters via `TryAddSingleton` and calls `AddMeter(MeterName)` (`DarkerMetricsBuilderExtensions.cs:31-42`). Registering `CacheMeter` there and adding an opt-out `bool` param is a non-breaking, consistent change. (Current signature is parameterless — the ADR correctly frames this as a proposed extension.) +- `IQueryContext.Span` exists and is typed `Activity?` in core (`IQueryContext.cs:22`); the caching package writes to it via `Context.Span?.SetTag(...)`. `Activity`/`SetTag` come from `System.Diagnostics.DiagnosticSource` (already a core dependency), **not** OpenTelemetry — so the "no OTel/metrics dependency in the caching package" claim is honest. + +**Span ordering — no problem:** `QueryProcessor` creates the query span, sets `queryContext.Span = span`, then builds+invokes the pipeline, then `EndSpan(span)` in a `finally` (`QueryProcessor.cs:112-137`). The decorator's `SetTag` during pipeline execution lands on the still-open span; `OnEnd`/`RecordCacheOperation` reads it at span end. Correct. + +**Previously-confirmed facts still hold:** `PipelineBuilder.cs:253` (sync, closes decorator over `typeof(IQuery)`), `:263` (`InitializeFromAttributeParams` at build), `:404` (async) — all verified verbatim. `QueryHandlerAttribute`/`...Async` are `abstract` with `protected (int step)` ctors and abstract `GetAttributeParams`/`GetDecoratorType`; `IQueryHandlerDecorator.Execute(TQuery, Func, Func)` + `InitializeFromAttributeParams`; `Paramore.Darker.Exceptions.ConfigurationException` (sealed); `IQueryContext.Bag` is `IDictionary`; `InstrumentationOptions` is `[Flags]`; validation `UseFluentValidation` `Use*` template — all confirmed. + +**Layering:** No circular reference. Caching → core (for `Activity`, `DarkerSemanticConventions.CacheOutcome`, decorator interfaces). Diagnostics → core (already, via `DarkerSemanticConventions`). Core depends on neither. The three-package split is clean and honestly disclosed in Consequences. + +## Prior findings status + +- **Finding 1 (false "no metrics primitive" claim): RESOLVED.** The ADR now accurately describes the ADR 0018 subsystem and reuses it (decorator writes a span attribute; `CacheMeter` in diagnostics derives the counter). Every claim it makes about that subsystem checks out against source. The bespoke-`Meter` approach is correctly demoted to Alternatives (rejected). A genuine correction, not a reword. +- **Finding 2 (stampede hit/miss miscount): RESOLVED and correctly characterized.** Documented in both Consequences ("Metric hit/miss counts are approximate under stampede") and a dedicated Risk. The characterization — metrics-only, results correct, `ran` is a per-query method local so no cross-query corruption — is accurate: decorators are created per-query, `ran` is a method local, and `GetOrCreateAsync` dedup means joiners observe `ran == false` and are counted as hits while still receiving correct results. + +## Findings + +### 1. Cache counter requires tracing to be enabled — documented consequence, not a defect (Score: 35) + +FR10 asks for a hit/miss counter with an independent opt-out toggle and says nothing about requiring tracing. Because the design reuses metrics-from-traces, the counter only materialises when a query span exists (a tracer is configured). This is a real limitation, but it is (a) inherent to "via the existing Observability support" — which FR10 and Resolved Decision 4 explicitly name, and that support *is* metrics-from-traces; (b) identical to how the existing query-duration and DB histograms already behave (both derive from spans in `QueryMeter`/`DbMeter`); and (c) honestly disclosed as the first Negative consequence and referenced in Alternatives. It does not undercut FR10 or introduce a hidden conflict. + +**Evidence**: ADR Negative consequences: "Cache metrics require tracing + the metrics pipeline ... This is the same property the existing query-duration and DB metrics already have". Verified against `QueryMeter.RecordQueryOperation(Activity)` (`QueryMeter.cs:53`), which is only ever driven from `DarkerMetricsFromTracesProcessor.OnEnd` at span end. + +**Recommendation**: None required; the trade-off is correctly documented. Optionally cross-link the Negative bullet to FR10's "existing Observability support" phrasing to make the requirement-alignment explicit. + +--- + +## Summary + +| Score Range | Count | +|-------------|-------| +| 90-100 (Critical) | 0 | +| 70-89 (High) | 0 | +| 50-69 (Medium) | 0 | +| 0-49 (Low) | 1 | + +**Total findings**: 1 +**Findings at or above threshold (60)**: 0 + +Both prior findings are genuinely and correctly resolved, every codebase claim in the rewrite was verified accurate against source, and the metrics-reuse rewrite introduced no new above-threshold problem (no false claims, no wrong file/line, no API-shape mismatch, no layering/circular-reference issue). Verdict: **PASS**. diff --git a/specs/014-Caching-Decorator/review-requirements.md b/specs/014-Caching-Decorator/review-requirements.md new file mode 100644 index 00000000..6bfe849d --- /dev/null +++ b/specs/014-Caching-Decorator/review-requirements.md @@ -0,0 +1,71 @@ +# Review: requirements — 014-Caching-Decorator + +**Date**: 2026-07-20 +**Threshold**: 60 +**Verdict**: NEEDS WORK (round 3) — all findings addressed in the subsequent revision + +_Round 3. The five round-2 fixes were all verified coherent with no new contradictions, and every codebase fact the document relies on was re-confirmed (`IQueryContext.Bag` = `IDictionary`; `InstrumentationOptions` `[Flags]` gates span attribute groups; async `ExecuteAsync` → `Task`, sync `Execute` → `TResult`; core targets `netstandard2.0;net8.0;net9.0`; validation uses two attributes + `ConfigurationException` fail-fast). One above-threshold gap surfaced on a fresh full pass._ + +## Findings + +### 1. Attribute constructor omits the mandatory `step`, contradicting the "ordering is significant" emphasis (Score: 64) + +Every Darker decorator attribute derives from `QueryHandlerAttribute`, whose only constructor is `protected QueryHandlerAttribute(int step)` — step is mandatory and there is no parameterless base. The existing convention is step-first positional (`[QueryLogging(1)]`, `[FallbackPolicy(2)]`, `[ValidateQuery(step)]`). Yet the caching doc never stated that the caching attribute takes a `step` argument, and **every** example omitted it, e.g. FR2's `[CacheableQuery(expirationSeconds: 300)]`. FR2 described expiry as "a **required** constructor argument" in the singular, implying it was the sole constructor parameter — a direct tension with the feature's headline concern that pipeline position/step ordering "is significant." + +**Evidence**: FR2 (pre-fix): "expiry is expressed as an integer count of seconds (e.g. `[CacheableQuery(expirationSeconds: 300)]`)". Verified: `src/Paramore.Darker/QueryHandlerAttribute.cs` — `protected QueryHandlerAttribute(int step)` (no parameterless ctor); `src/Paramore.Darker.Validation/ValidateQueryAttribute.cs` — `public ValidateQueryAttribute(int step) : base(step)`. + +**Recommendation**: State that the caching attribute constructor also takes the mandatory `step`, signature `(int step, int expirationSeconds)`, and update examples to `[CacheableQuery(step: 1, expirationSeconds: 300)]`. + +**Resolution**: FR1 now states both variants derive from `QueryHandlerAttribute(int step)` and take `step` first; FR2 gives the full signature `(int step, int expirationSeconds)` and the corrected example; a new AC asserts `step` ordering behaviour. ✅ + +--- + +### 2. `IAmCacheable.CacheKey` runtime null/empty is unspecified (Score: 46) + +FR4 pins the interface as getter-only non-nullable `string`, but nullable annotations are compile-time only; nothing prevents a runtime `null`/`""`. The doc did not say whether the decorator throws, falls back to the default strategy, or passes an empty key to `GetOrCreateAsync`. + +**Evidence**: FR4: `public interface IAmCacheable { string CacheKey { get; } }` (getter-only, non-nullable). No FR/AC addressed a null/empty runtime value. + +**Recommendation**: Add a rule (and AC) — fail fast or fall back to default. + +**Resolution**: FR4 now fails fast with a configuration exception on null/empty/whitespace runtime `CacheKey`; AC added. ✅ + +--- + +### 3. Zero/negative `expirationSeconds` boundary is unspecified (Score: 43) + +`[CacheableQuery(expirationSeconds: 0)]` or a negative value compiles. FR2 removed the "silent default" but did not define behaviour for a non-positive expiry mapped onto `HybridCacheEntryOptions.Expiration`. + +**Evidence**: FR2: "no silent default … every cached query has an explicit lifetime." No lower-bound validation stated. + +**Recommendation**: Reject non-positive with a configuration exception (fail-fast) + AC. + +**Resolution**: FR2 now requires positive `expirationSeconds`, failing fast at pipeline build; AC added. ✅ + +--- + +### 4. `[CacheableQuery]` shorthand in the ACs names only the sync attribute (Score: 33) + +FR1 defines two attributes, but the ACs/Problem Statement used bare `[CacheableQuery]` (literally the **sync** variant) for async-by-nature behaviours; no AC explicitly exercised the async attribute on the core hit/miss path. + +**Evidence**: ACs "A query handler annotated with `[CacheableQuery]` …". FR1 defines sync + async as distinct. + +**Recommendation**: Clarify `[CacheableQuery]` as generic shorthand, and exercise the async attribute in the core ACs. + +**Resolution**: AC preamble now defines `[CacheableQuery]` as shorthand for "the applicable variant" and requires the core hit/miss criteria on both (async at minimum). ✅ + +--- + +## Summary + +| Score Range | Count | +|-------------|-------| +| 90-100 (Critical) | 0 | +| 70-89 (High) | 0 | +| 50-69 (Medium) | 1 | +| 0-49 (Low) | 3 | + +**Total findings**: 4 +**Findings at or above threshold (60)**: 1 + +_All four findings addressed in the revision that followed this review._ diff --git a/specs/014-Caching-Decorator/review-tasks.md b/specs/014-Caching-Decorator/review-tasks.md new file mode 100644 index 00000000..b1bed577 --- /dev/null +++ b/specs/014-Caching-Decorator/review-tasks.md @@ -0,0 +1,48 @@ +# Review: tasks — 014-Caching-Decorator + +**Date**: 2026-07-21 +**Threshold**: 60 +**Verdict**: PASS + +> Round 3. Reviews the unattended `ralph-tasks.md`. Both round-2 fixes landed correctly and the full forward-reference, coverage, and grounding sweep surfaced no defects. Absence of `STOP HERE`/`/test-first` gates and docs/scaffolding tasks without behavioral tests are by design for the ralph path. + +## Findings + +_No findings at or above threshold. Two round-2 fixes verified as landed correctly; the full forward-reference, coverage, and grounding sweep surfaced no defects worth flagging (not even sub-60 nits worth recording)._ + +--- + +## Round-2 fixes verified + +1. **Forward-reference fix (round-2 #1, was ≥60) — LANDED correctly.** + - (a) The step-ordering **doc task** lists only `CacheableQueryAttributeAsync.cs` + `README.md` as implementation files. The sync file `CacheableQueryAttribute.cs` appears in that task **only in prose** ("…is added in the sync-decorator task that creates that file — do NOT reference or create the sync attribute here"), never as an edit target. + - (b) The **sync-decorator task** now folds in the `` instruction: "Also add the same step-ordering/short-circuit `` XML docs the async attribute carries … so both attributes document the footgun." + - (c) Grep confirms `CacheableQueryAttribute.cs` (sync FILE) is an implementation-file bullet **only** in the sync-decorator task that creates it. The constant `CacheableQueryAttribute.CacheTag` is consumed only in the sync task itself or the tag tasks ordered after it. No file-edit or constant-use precedes creation. + +2. **OTel-pointer fix (round-2 #2, low) — LANDED correctly.** The span-attribute task's References now point at the **core** `DarkerTracer` (`src/Paramore.Darker/Observability/DarkerTracer.cs`) + a BCL `ActivityListener` subscribed to `paramore.darker`, with an explicit "**no OpenTelemetry / Diagnostics dependency in the caching test project** (do NOT pull in `AddDarkerInstrumentation`/OTel here)." Verified coherent: `DarkerTracer : IAmADarkerTracer` lives in core and owns the `paramore.darker` ActivitySource; `QueryProcessor` construction resolves `IAmADarkerTracer` from DI, so a core-only tracer registration populates `Context.Span`. + +## Full-sweep verification notes + +- **Forward references (creation order rebuilt for every production type)** — clean: + - `CacheOutcome` **enum**: first created and first used in the span-attribute task; the vertical-slice task explicitly excludes it. The earlier `DarkerSemanticConventions.CacheOutcome` **string** constant is a distinct construct created in the core-constants task and fine to reference thereafter. + - `CachingOptions`: first created and first used in the opt-in task; vertical-slice `AddCaching` is parameterless and defers the overload. + - `ICacheKeyGenerator`/`DefaultCacheKeyGenerator`/`IAmCacheable`: created in the default-key tasks before the vertical-slice task resolves `ICacheKeyGenerator`. + - `DarkerSemanticConventions` cache constants: created in the core-constants task before the span and CacheMeter tasks consume them. + - `IAmADarkerCacheMeter`/`CacheMeter`: created in the CacheMeter task before the processor-dispatch and AddDarkerInstrumentation tasks. +- **Grounding** — every touched reference exists and is named correctly: `DarkerTracer.cs`, `DarkerSemanticConventions.cs` (`MeterName`, `QueryDurationAllowedTags` FrozenSet pattern), `IQueryContext` (`Bag`, `Activity? Span`), `PipelineBuilder` (`OrderByDescending(attr => attr.Step)`, `GetDecoratorType().MakeGenericType`, `InitializeFromAttributeParams` — the cited 240/253/263/404 are accurate/approximate), `QueryMeter`/`IAmADarkerQueryMeter`, `DarkerMetricsFromTracesProcessor` (ctor params, `Enabled` guard, `Internal` branch — task edits map exactly), `DarkerTracerBuilderExtensions` (processor construction + meter gate), `RegisterDecorator`/`Services`/`IDarkerHandlerBuilder`, and the validation/FluentValidation csproj + real-processor test templates. No wrong or nonexistent reference found. +- **Coverage** — FR1–FR14 each map to ≥1 task (FR1→vertical-slice/step-order/sync; FR2→expiry/non-positive; FR3→vertical-slice; FR4→default-key/IAmCacheable/fail-fast; FR5→default-key/distinct-types; FR6→FusionCache switch; FR7→prereq/project/opt-in/hygiene; FR8→vertical-slice/sync fast-path/blocking fallback; FR9→tag-applied/absent-or-non-string; FR10→core-constants/span/CacheMeter/processor/toggle/e2e-metrics; FR11→negative-null; FR12→missing-cache; FR13→serialization; FR14→non-supporting-impl). ADR 0021 major decisions/components each map to a task. +- **Test-first framing / granularity** — each behavioral task's Test file + concrete assertions precede its implementation files, test names follow `When_[condition]_should_[expected_behavior]`, and each RALPH-VERIFY `FullyQualifiedName~…` filter matches its Test file name. Scaffolding/doc tasks correctly carry build-command verifies with no behavioral test (by design for the ralph path). + +## Summary + +| Score Range | Count | +|-------------|-------| +| 90-100 (Critical) | 0 | +| 70-89 (High) | 0 | +| 50-69 (Medium) | 0 | +| 0-49 (Low) | 0 | + +**Total findings**: 0 +**Findings at or above threshold (60)**: 0 + +The task list is ready for implementation. diff --git a/src/Paramore.Darker.Caching/CacheOutcome.cs b/src/Paramore.Darker.Caching/CacheOutcome.cs new file mode 100644 index 00000000..2025227c --- /dev/null +++ b/src/Paramore.Darker.Caching/CacheOutcome.cs @@ -0,0 +1,53 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching; + +/// +/// Indicates whether a cache lookup resulted in a hit or a miss. +/// +/// +/// +/// Used internally by (and its +/// synchronous counterpart) to name the factory-ran determination from +/// HybridCache.GetOrCreateAsync before mapping it to the span-attribute string values +/// "hit" / "miss". +/// +/// +/// Naming note: CacheOutcome is also the name of the string constant +/// , which +/// holds the span-attribute key ("paramore.darker.cache.outcome"). These are +/// distinct identifiers: this type is an enum in namespace +/// Paramore.Darker.Caching; the constant is a string in namespace +/// Paramore.Darker.Observability. When both are in scope, use fully-qualified names to +/// avoid ambiguity. +/// +/// +public enum CacheOutcome +{ + /// The cache returned a stored result; the handler and inner decorators did not run. + Hit, + + /// No cached result existed; the handler was invoked and its result was stored. + Miss, +} diff --git a/src/Paramore.Darker.Caching/CacheTagHelper.cs b/src/Paramore.Darker.Caching/CacheTagHelper.cs new file mode 100644 index 00000000..e5200e6c --- /dev/null +++ b/src/Paramore.Darker.Caching/CacheTagHelper.cs @@ -0,0 +1,54 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Collections.Generic; + +namespace Paramore.Darker.Caching; + +/// +/// Shared helper that reads the optional cache tag from . +/// Used by both the sync and async caching decorators so there is a single source of truth +/// for the tag-extraction logic. +/// +internal static class CacheTagHelper +{ + /// + /// Reads the well-known key from + /// . When the value is a non-empty , wraps it + /// as a one-element array so it can be passed to + /// HybridCache.GetOrCreateAsync as IEnumerable<string> tags. + /// Returns when the key is absent or the value is not a non-empty string + /// (best-effort, per FR9 — no exception is thrown). + /// + /// The current query context. + /// A one-element array containing the tag, or . + internal static IEnumerable? ReadTags(IQueryContext context) + { + if (context.Bag.TryGetValue(CacheableQueryAttribute.CacheTag, out var tagValue) + && tagValue is string tag + && !string.IsNullOrWhiteSpace(tag)) + return new[] { tag }; + + return null; + } +} diff --git a/src/Paramore.Darker.Caching/CacheableQueryAttribute.cs b/src/Paramore.Darker.Caching/CacheableQueryAttribute.cs new file mode 100644 index 00000000..69f3646e --- /dev/null +++ b/src/Paramore.Darker.Caching/CacheableQueryAttribute.cs @@ -0,0 +1,118 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; + +namespace Paramore.Darker.Caching; + +/// +/// Marks a handler's synchronous execute method for caching. When present, +/// returns +/// so the pipeline resolves and wires the sync caching decorator automatically. +/// +/// +/// +/// Place this attribute on the handler's Execute method and supply a +/// to control where caching runs in the decorator pipeline (higher step +/// executes first) and an to set the cache entry lifetime. +/// +/// +/// Ordering matters — cache hits short-circuit the pipeline. +/// +/// +/// Darker's decorator pipeline runs decorators in descending step order: the decorator with the +/// highest step executes first (outermost), and the decorator with the lowest step executes last +/// (innermost, just before the handler). On a cache hit, the caching decorator returns +/// the stored result immediately without calling next. Every decorator ordered inside +/// the cache decorator — that is, every decorator whose is lower +/// than this attribute's — is therefore skipped entirely on a +/// hit. +/// +/// +/// This is a footgun if you rely on inner decorators running for every request. Use the following +/// guidance when choosing a : +/// +/// +/// To run logging, retry, or fallback on every request (hit or miss), give +/// those decorators a higher step than the cache attribute so they execute +/// outside the cache decorator. +/// +/// +/// To run logging, retry, or fallback only on a miss (i.e. only when the +/// handler actually executes), give those decorators a lower step so they execute +/// inside the cache decorator and are therefore skipped on a hit. +/// +/// +/// +/// +/// Example — cache outer, logging inner (logging is skipped on a hit): +/// +/// [CacheableQuery(step: 2, expirationSeconds: 300)] // outer — executes first +/// [QueryLogging(1)] // inner — skipped on a cache hit +/// public override MyResult Execute(MyQuery query) +/// +/// +/// +/// Example — logging outer, cache inner (logging always runs, even on a hit): +/// +/// [QueryLogging(2)] // outer — always runs +/// [CacheableQuery(step: 1, expirationSeconds: 300)] // inner — short-circuits on a hit +/// public override MyResult Execute(MyQuery query) +/// +/// +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class CacheableQueryAttribute : QueryHandlerAttribute +{ + /// + /// The canonical tag name used to identify cache-related entries. + /// Both the sync () and async + /// () decorators reference this + /// single constant so there is one source of truth for the tag value. + /// + public const string CacheTag = "Paramore.Darker.Caching.Tag"; + + private readonly int _expirationSeconds; + + /// + /// Initialises a new instance of . + /// + /// + /// The step order for this decorator in the handler pipeline. Higher values execute earlier. + /// + /// + /// The number of seconds the cached result should be retained. + /// + public CacheableQueryAttribute(int step, int expirationSeconds) : base(step) + { + _expirationSeconds = expirationSeconds; + } + + /// + /// The open-generic type. + public override Type GetDecoratorType() => typeof(CacheableQueryDecorator<,>); + + /// + /// An array containing the expiration in seconds so the decorator can build its cache options. + public override object[] GetAttributeParams() => new object[] { _expirationSeconds }; +} diff --git a/src/Paramore.Darker.Caching/CacheableQueryAttributeAsync.cs b/src/Paramore.Darker.Caching/CacheableQueryAttributeAsync.cs new file mode 100644 index 00000000..492ef19b --- /dev/null +++ b/src/Paramore.Darker.Caching/CacheableQueryAttributeAsync.cs @@ -0,0 +1,110 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; + +namespace Paramore.Darker.Caching; + +/// +/// Marks a handler's async execute method for caching. When present, +/// returns +/// so the pipeline resolves and wires the caching decorator automatically. +/// +/// +/// +/// Place this attribute on the handler's ExecuteAsync method and supply a +/// to control where caching runs in the decorator pipeline (higher step +/// executes first) and an to set the cache entry lifetime. +/// +/// +/// Ordering matters — cache hits short-circuit the pipeline. +/// +/// +/// Darker's decorator pipeline runs decorators in descending step order: the decorator with the +/// highest step executes first (outermost), and the decorator with the lowest step executes last +/// (innermost, just before the handler). On a cache hit, the caching decorator returns +/// the stored result immediately without calling next. Every decorator ordered inside +/// the cache decorator — that is, every decorator whose is lower +/// than this attribute's — is therefore skipped entirely on a +/// hit. +/// +/// +/// This is a footgun if you rely on inner decorators running for every request. Use the following +/// guidance when choosing a : +/// +/// +/// To run logging, retry, or fallback on every request (hit or miss), give +/// those decorators a higher step than the cache attribute so they execute +/// outside the cache decorator. +/// +/// +/// To run logging, retry, or fallback only on a miss (i.e. only when the +/// handler actually executes), give those decorators a lower step so they execute +/// inside the cache decorator and are therefore skipped on a hit. +/// +/// +/// +/// +/// Example — cache outer, logging inner (logging is skipped on a hit): +/// +/// [CacheableQueryAsync(step: 2, expirationSeconds: 300)] // outer — executes first +/// [QueryLoggingAsync(1)] // inner — skipped on a cache hit +/// public override Task<MyResult> ExecuteAsync(MyQuery query, CancellationToken ct = default) +/// +/// +/// +/// Example — logging outer, cache inner (logging always runs, even on a hit): +/// +/// [QueryLoggingAsync(2)] // outer — always runs +/// [CacheableQueryAsync(step: 1, expirationSeconds: 300)] // inner — short-circuits on a hit +/// public override Task<MyResult> ExecuteAsync(MyQuery query, CancellationToken ct = default) +/// +/// +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class CacheableQueryAttributeAsync : QueryHandlerAttributeAsync +{ + private readonly int _expirationSeconds; + + /// + /// Initialises a new instance of . + /// + /// + /// The step order for this decorator in the handler pipeline. Higher values execute earlier. + /// + /// + /// The number of seconds the cached result should be retained. + /// + public CacheableQueryAttributeAsync(int step, int expirationSeconds) : base(step) + { + _expirationSeconds = expirationSeconds; + } + + /// + /// The open-generic type. + public override Type GetDecoratorType() => typeof(CacheableQueryDecoratorAsync<,>); + + /// + /// An array containing the expiration in seconds so the decorator can build its cache options. + public override object[] GetAttributeParams() => new object[] { _expirationSeconds }; +} diff --git a/src/Paramore.Darker.Caching/CacheableQueryDecorator.cs b/src/Paramore.Darker.Caching/CacheableQueryDecorator.cs new file mode 100644 index 00000000..f55bac5d --- /dev/null +++ b/src/Paramore.Darker.Caching/CacheableQueryDecorator.cs @@ -0,0 +1,188 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Darker.Exceptions; +using Paramore.Darker.Observability; + +namespace Paramore.Darker.Caching; + +/// +/// Synchronous pipeline decorator that caches query results via . +/// On a cache miss the factory runs, invoking the next handler in the pipeline and +/// storing the result; on a cache hit the factory never runs and the cached value is +/// returned immediately without invoking the handler. +/// +/// +/// +/// The decorator resolves and +/// lazily from the so they are not captured at +/// construction time (which occurs per-query). +/// +/// +/// The cache key is derived from the runtime query object rather than from +/// . In the Darker pipeline every decorator is closed over +/// IQuery<TResult> (not the concrete query type), so using +/// would produce wrong keys. Always pass the runtime +/// argument to . +/// +/// +/// Async-first with a sync fast-path. This synchronous decorator calls the +/// asynchronous , which returns a +/// . On an in-memory L1 cache hit that value task is already +/// completed; we inspect and return +/// synchronously on the originating thread — no +/// thread-pool hop. When the value task is not yet complete (e.g. a distributed L2 read) we block +/// via .AsTask().GetAwaiter().GetResult(). +/// +/// +/// Deadlock safety. Before the cache call the ambient +/// is suppressed (set to ) +/// and restored in a finally. This is the sync-over-async equivalent of +/// ConfigureAwait(false): the cache's internal continuations capture the null context and +/// resume on a thread-pool thread rather than a single-threaded originating context (classic +/// ASP.NET request thread, WPF/WinForms UI thread), so the blocking wait cannot deadlock. Because +/// Execute is fully synchronous and restores the context before returning, the caller never +/// observes the suppression. +/// +/// +/// The query type, constrained to . +/// The result type produced by the query. +public sealed class CacheableQueryDecorator : IQueryHandlerDecorator + where TQuery : IQuery +{ + private readonly IServiceProvider _serviceProvider; + private int _expirationSeconds; + + /// + public IQueryContext Context { get; set; } = null!; + + /// + /// Initialises the decorator with the DI service provider used to resolve + /// and at execution time. + /// + /// The application service provider. + public CacheableQueryDecorator(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + /// + /// + /// Expects to contain a single element: the + /// expiration in seconds (as an ), as returned by + /// . + /// + public void InitializeFromAttributeParams(object[] attributeParams) + { + var expirationSeconds = (int)attributeParams[0]; + if (expirationSeconds <= 0) + throw new ConfigurationException( + $"[CacheableQueryAttribute] expirationSeconds must be a positive integer; got {expirationSeconds}. " + + "Specify a value greater than zero so every cached query has an explicit, positive lifetime."); + _expirationSeconds = expirationSeconds; + } + + /// + /// + /// Delegates to using the state overload + /// to pass and without capturing them in a + /// closure; the factory still closes over the local hit/miss flag so the outcome can be + /// recorded after the call. The factory wraps the synchronous delegate + /// in a completed . A synchronously-completed value task (in-memory L1 hit) is + /// read directly via ; otherwise the value task is + /// resolved by blocking via .AsTask().GetAwaiter().GetResult(). The ambient + /// is suppressed for the duration of the + /// call and restored in a finally so the blocking wait cannot deadlock under a + /// single-threaded context. + /// + public TResult Execute(TQuery query, Func next, Func fallback) + { + var cache = _serviceProvider.GetService() + ?? throw new ConfigurationException( + "No HybridCache is registered in the DI container. " + + "Call services.AddHybridCache() (or register a HybridCache implementation such as FusionCache) " + + "before using [CacheableQueryAttribute]."); + var keyGenerator = _serviceProvider.GetRequiredService(); + + // Use the runtime query argument — never typeof(TQuery) — to compute the key. + var key = keyGenerator.GenerateKey(query); + + var options = new HybridCacheEntryOptions + { + Expiration = TimeSpan.FromSeconds(_expirationSeconds) + }; + + var tags = CacheTagHelper.ReadTags(Context); + var ran = false; + var state = (next, query); + + // Suppress any ambient SynchronizationContext for the duration of the cache call. If a + // single-threaded context (classic ASP.NET request thread, WPF/WinForms UI thread) were + // captured by the cache's internal awaits, blocking on the result below would deadlock. + // Removing the context is the sync-over-async equivalent of ConfigureAwait(false): the + // cache read's continuations resume on a thread-pool thread, not the originating thread. + // Execute is fully synchronous and restores the context in the finally before returning, + // so the caller never observes the suppression. The original context is restored on every + // path, including exceptions. + var originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + TResult result; + try + { + var valueTask = cache.GetOrCreateAsync( + key, + state, + (s, ct) => + { + ran = true; + // Wrap the synchronous next() result in a completed ValueTask so the factory + // never blocks the thread pool. + return ValueTask.FromResult(s.next(s.query)); + }, + options, + tags: tags, + cancellationToken: CancellationToken.None); + + // Fast path: a synchronously-completed ValueTask (in-memory L1 hit) never suspended, + // so no continuation was scheduled — read the result directly on the originating + // thread with no thread-pool hop. Consume the ValueTask exactly once. + if (valueTask.IsCompletedSuccessfully) + result = valueTask.Result; + else + result = valueTask.AsTask().GetAwaiter().GetResult(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + + var outcome = ran ? CacheOutcome.Miss : CacheOutcome.Hit; + Context.Span?.SetTag(DarkerSemanticConventions.CacheOutcome, outcome == CacheOutcome.Hit ? "hit" : "miss"); + return result; + } +} diff --git a/src/Paramore.Darker.Caching/CacheableQueryDecoratorAsync.cs b/src/Paramore.Darker.Caching/CacheableQueryDecoratorAsync.cs new file mode 100644 index 00000000..c6d67a7d --- /dev/null +++ b/src/Paramore.Darker.Caching/CacheableQueryDecoratorAsync.cs @@ -0,0 +1,139 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Paramore.Darker.Exceptions; +using Paramore.Darker.Observability; + +namespace Paramore.Darker.Caching; + +/// +/// Async pipeline decorator that caches query results via . +/// On a cache miss the factory runs, invoking the next handler in the pipeline and +/// storing the result; on a cache hit the factory never runs and the cached value is +/// returned immediately without invoking the handler. +/// +/// +/// +/// The decorator resolves and +/// lazily from the so they are not captured at +/// construction time (which occurs per-query). +/// +/// +/// The cache key is derived from the runtime query object rather than from +/// . In the Darker pipeline every decorator is closed over +/// IQuery<TResult> (not the concrete query type), so using +/// would produce wrong keys. Always pass the runtime +/// argument to . +/// +/// +/// The query type, constrained to . +/// The result type produced by the query. +public sealed class CacheableQueryDecoratorAsync : IQueryHandlerDecoratorAsync + where TQuery : IQuery +{ + private readonly IServiceProvider _serviceProvider; + private int _expirationSeconds; + + /// + public IQueryContext Context { get; set; } = null!; + + /// + /// Initialises the decorator with the DI service provider used to resolve + /// and at execution time. + /// + /// The application service provider. + public CacheableQueryDecoratorAsync(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + /// + /// + /// Expects to contain a single element: the + /// expiration in seconds (as an ), as returned by + /// . + /// + public void InitializeFromAttributeParams(object[] attributeParams) + { + var expirationSeconds = (int)attributeParams[0]; + if (expirationSeconds <= 0) + throw new ConfigurationException( + $"[CacheableQueryAttributeAsync] expirationSeconds must be a positive integer; got {expirationSeconds}. " + + "Specify a value greater than zero so every cached query has an explicit, positive lifetime."); + _expirationSeconds = expirationSeconds; + } + + /// + /// + /// Delegates to using the state overload + /// to pass and without capturing them in a + /// closure. The factory still closes over the local hit/miss flag so the outcome can be + /// recorded after the call. On a miss the factory invokes and the + /// result is stored; on a hit the factory is never called and the cached value is returned. + /// + public async Task ExecuteAsync( + TQuery query, + Func> next, + Func> fallback, + CancellationToken cancellationToken = default) + { + var cache = _serviceProvider.GetService() + ?? throw new ConfigurationException( + "No HybridCache is registered in the DI container. " + + "Call services.AddHybridCache() (or register a HybridCache implementation such as FusionCache) " + + "before using [CacheableQueryAttributeAsync]."); + var keyGenerator = _serviceProvider.GetRequiredService(); + + // Use the runtime query argument — never typeof(TQuery) — to compute the key. + var key = keyGenerator.GenerateKey(query); + + var options = new HybridCacheEntryOptions + { + Expiration = TimeSpan.FromSeconds(_expirationSeconds) + }; + + var tags = CacheTagHelper.ReadTags(Context); + var ran = false; + var state = (next, query); + var result = await cache.GetOrCreateAsync( + key, + state, + async (s, ct) => + { + ran = true; + return await s.next(s.query, ct).ConfigureAwait(false); + }, + options, + tags: tags, + cancellationToken: cancellationToken).ConfigureAwait(false); + + var outcome = ran ? CacheOutcome.Miss : CacheOutcome.Hit; + Context.Span?.SetTag(DarkerSemanticConventions.CacheOutcome, outcome == CacheOutcome.Hit ? "hit" : "miss"); + return result; + } +} diff --git a/src/Paramore.Darker.Caching/CachingDarkerBuilderExtensions.cs b/src/Paramore.Darker.Caching/CachingDarkerBuilderExtensions.cs new file mode 100644 index 00000000..d3fdc355 --- /dev/null +++ b/src/Paramore.Darker.Caching/CachingDarkerBuilderExtensions.cs @@ -0,0 +1,82 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Paramore.Darker.Extensions.DependencyInjection; + +namespace Paramore.Darker.Caching; + +/// +/// Extension methods for that wire up the caching +/// decorators by registering the open-generic , +/// the open-generic , and the +/// in DI. +/// +public static class CachingDarkerBuilderExtensions +{ + /// + /// Registers the async and sync caching decorators and the default cache-key generator so that + /// handlers annotated with or + /// are automatically wrapped by the respective caching + /// decorator in the pipeline. + /// + /// The Darker handler builder. Must not be null. + /// The builder, for chaining. + public static IDarkerHandlerBuilder AddCaching(this IDarkerHandlerBuilder builder) + => builder.AddCaching(_ => { }); + + /// + /// Registers the async and sync caching decorators and the + /// so that handlers annotated with or + /// are automatically wrapped by the respective caching + /// decorator in the pipeline. The callback allows supplying a + /// custom (via ) + /// that replaces the default without changing the + /// decorator. + /// + /// The Darker handler builder. Must not be null. + /// + /// A callback that receives a instance for customisation. + /// Must not be null. + /// + /// The builder, for chaining. + public static IDarkerHandlerBuilder AddCaching( + this IDarkerHandlerBuilder builder, + Action configure) + { + var options = new CachingOptions(); + configure(options); + + builder.RegisterDecorator(typeof(CacheableQueryDecoratorAsync<,>)); + builder.RegisterDecorator(typeof(CacheableQueryDecorator<,>)); + + if (options.KeyGenerator is not null) + builder.Services.TryAddSingleton(options.KeyGenerator); + else + builder.Services.TryAddSingleton(); + + return builder; + } +} diff --git a/src/Paramore.Darker.Caching/CachingOptions.cs b/src/Paramore.Darker.Caching/CachingOptions.cs new file mode 100644 index 00000000..57148899 --- /dev/null +++ b/src/Paramore.Darker.Caching/CachingOptions.cs @@ -0,0 +1,39 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching; + +/// +/// Options for configuring the Darker caching integration via +/// . +/// +public sealed class CachingOptions +{ + /// + /// Gets or sets a custom that replaces the default + /// for computing cache keys. + /// When , the default is + /// registered instead. + /// + public ICacheKeyGenerator? KeyGenerator { get; set; } +} diff --git a/src/Paramore.Darker.Caching/DefaultCacheKeyGenerator.cs b/src/Paramore.Darker.Caching/DefaultCacheKeyGenerator.cs new file mode 100644 index 00000000..6abf8d01 --- /dev/null +++ b/src/Paramore.Darker.Caching/DefaultCacheKeyGenerator.cs @@ -0,0 +1,110 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Paramore.Darker.Exceptions; + +namespace Paramore.Darker.Caching; + +/// +/// Default cache-key strategy. Uses the query's runtime type (never a generic type parameter) +/// to form the key: "{Type.FullName}|{orderedInvariantJson}". +/// +/// The JSON body is deterministic across runs: public readable properties are serialised in +/// ordinal name order using System.Text.Json (which always uses invariant-culture number +/// formatting), with explicit null values and no whitespace. +/// +/// +/// Queries that implement use their own +/// instead; this check is applied before the default type+JSON strategy. +/// +/// +public sealed class DefaultCacheKeyGenerator : ICacheKeyGenerator +{ + /// + public string GenerateKey(object query) + { + if (query is IAmCacheable c) + { + var key = c.CacheKey; + if (string.IsNullOrWhiteSpace(key)) + throw new ConfigurationException( + $"Query '{query.GetType().FullName}' implements IAmCacheable but its CacheKey returned a null, empty, or whitespace value. Provide a non-empty CacheKey."); + return key; + } + + var type = query.GetType(); + return $"{type.FullName}|{BuildOrderedJson(query, type)}"; + } + + private static string BuildOrderedJson(object query, Type type) + { + // Reflect on the runtime type — never typeof(TQuery). + // Skip indexers: they surface as readable properties but cannot be read without an + // index argument, so prop.GetValue(query) would throw TargetParameterCountException. + // Order properties by name with ordinal comparison for stable, deterministic output. + var properties = type + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) + .OrderBy(p => p.Name, StringComparer.Ordinal); + + using var ms = new MemoryStream(); + using var writer = new Utf8JsonWriter(ms); + + writer.WriteStartObject(); + + foreach (var prop in properties) + WriteProperty(writer, prop, query); + + writer.WriteEndObject(); + writer.Flush(); + + return Encoding.UTF8.GetString(ms.ToArray()); + } + + private static void WriteProperty(Utf8JsonWriter writer, PropertyInfo prop, object query) + { + writer.WritePropertyName(prop.Name); + + var value = prop.GetValue(query); + if (value is null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the value using its runtime type rather than the declared property type. + // Serializing against the declared type drops the distinguishing data of anything held + // behind an interface or abstract base (System.Text.Json emits only the declared type's + // members — often "{}" for a marker interface), collapsing distinct values onto the same + // cache key. The runtime type serializes the concrete value in full while still writing + // boxed value types (int, bool, etc.) as JSON numbers/booleans, not objects. + // System.Text.Json always uses invariant-culture for number formatting. + JsonSerializer.Serialize(writer, value, value.GetType()); + } +} diff --git a/src/Paramore.Darker.Caching/IAmCacheable.cs b/src/Paramore.Darker.Caching/IAmCacheable.cs new file mode 100644 index 00000000..acbc3001 --- /dev/null +++ b/src/Paramore.Darker.Caching/IAmCacheable.cs @@ -0,0 +1,35 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching; + +/// +/// A query may implement this interface to supply its own cache key, bypassing the default +/// reflection-based key strategy. A runtime null, empty, or whitespace-only value +/// from fails fast with a configuration exception. +/// +public interface IAmCacheable +{ + /// The caller-supplied cache key for this query instance. + string CacheKey { get; } +} diff --git a/src/Paramore.Darker.Caching/ICacheKeyGenerator.cs b/src/Paramore.Darker.Caching/ICacheKeyGenerator.cs new file mode 100644 index 00000000..d3d817d4 --- /dev/null +++ b/src/Paramore.Darker.Caching/ICacheKeyGenerator.cs @@ -0,0 +1,36 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching; + +/// +/// Computes a cache key for a query. Operates on the runtime object — never on a generic +/// type parameter — so that the correct concrete type name is always used. +/// +public interface ICacheKeyGenerator +{ + /// Generates a cache key for the given query instance. + /// The runtime query object (never typeof(TQuery)). + /// A non-null, non-empty string that uniquely identifies the query state. + string GenerateKey(object query); +} diff --git a/src/Paramore.Darker.Caching/Paramore.Darker.Caching.csproj b/src/Paramore.Darker.Caching/Paramore.Darker.Caching.csproj new file mode 100644 index 00000000..eb20f2a0 --- /dev/null +++ b/src/Paramore.Darker.Caching/Paramore.Darker.Caching.csproj @@ -0,0 +1,14 @@ + + + net8.0;net9.0 + Query caching decorator for Darker + enable + + + + + + + + + diff --git a/src/Paramore.Darker.Caching/README.md b/src/Paramore.Darker.Caching/README.md new file mode 100644 index 00000000..c61f485d --- /dev/null +++ b/src/Paramore.Darker.Caching/README.md @@ -0,0 +1,119 @@ +# Paramore.Darker.Caching + +Query result caching for [Darker](https://github.com/BrighterCommand/Darker), built on Microsoft's +[`HybridCache`](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid) abstraction. + +Annotate a handler's `ExecuteAsync` method with `[CacheableQueryAsync]` to have Darker check the +cache before running the handler. On a **hit** the cached result is returned immediately; on a +**miss** the handler runs, its result is stored, and subsequent calls are served from the cache for +the configured lifetime. Both Microsoft's `HybridCache` and +[FusionCache](https://github.com/ZiggyCreatures/FusionCache/blob/main/docs/MicrosoftHybridCache.md) +(via its `HybridCache` implementation) are supported — the backing cache is chosen entirely by your +DI registration. + +## Quick start + +```csharp +// 1. Register Darker with caching support +services.AddDarker() + .AddHandlersFromAssemblies(typeof(MyHandler).Assembly) + .AddCaching(); // registers the async (and sync) caching decorators + +// 2. Register a HybridCache implementation (Microsoft's or FusionCache) +services.AddHybridCache(); // or: services.AddFusionCache().AsHybridCache() + +// 3. Annotate the handler +public class GetProductQueryHandler : QueryHandlerAsync +{ + [CacheableQueryAsync(step: 1, expirationSeconds: 300)] + public override Task ExecuteAsync(GetProductQuery query, + CancellationToken ct = default) + => /* ... your query logic ... */ +} +``` + +### Cache key + +By default the cache key is `"{query.GetType().FullName}|{invariant-JSON-of-public-properties}"`. +To supply your own key, implement `IAmCacheable` on the query: + +```csharp +public record GetProductQuery(int ProductId) : IQuery, IAmCacheable +{ + public string CacheKey => $"product-{ProductId}"; +} +``` + +A `null`, empty, or whitespace `CacheKey` at runtime fails fast with a `ConfigurationException`. + +### Tag-based eviction + +Place a tag in `IQueryContext.Bag` under `CacheableQueryAttribute.CacheTag` +(`"Paramore.Darker.Caching.Tag"`) before executing the query. The decorator passes it to +`HybridCache` so you can later evict the entry via the cache's own `RemoveByTagAsync`: + +```csharp +context.Bag[CacheableQueryAttribute.CacheTag] = "product-catalogue"; +var result = await queryProcessor.ExecuteAsync(query, context); +// elsewhere: +await hybridCache.RemoveByTagAsync("product-catalogue"); +``` + +## Ordering matters — cache hits short-circuit the pipeline + +Darker executes decorators in **descending step order**: the decorator with the highest `step` runs +first (outermost) and the decorator with the lowest `step` runs last (innermost, just before the +handler). On a cache **hit** the caching decorator returns immediately without calling `next`. Every +decorator ordered **inside** the cache decorator — every decorator whose `step` is **lower** than +the `[CacheableQueryAsync]` attribute's `step` — is therefore **skipped entirely on a hit**. + +This is a deliberate consequence of how `HybridCache.GetOrCreateAsync` short-circuits: its factory +(which is "the rest of the pipeline") only runs on a miss. It is also a footgun if you are not +aware of it. + +### Choosing a `step` + +| Goal | How to order | +|------|--------------| +| Logging / retry / fallback runs on **every** request (hit or miss) | Give those decorators a **higher** `step` than `[CacheableQueryAsync]` so they are **outside** it | +| Logging / retry / fallback runs **only on a miss** (only when the handler actually executes) | Give those decorators a **lower** `step` so they are **inside** the cache decorator and skipped on a hit | + +### Example — cache outer, logging inner (logging skipped on a hit) + +```csharp +[CacheableQueryAsync(step: 2, expirationSeconds: 300)] // outer — runs first; hit returns here +[QueryLoggingAsync(step: 1)] // inner — only runs on a miss +public override Task ExecuteAsync(GetProductQuery query, + CancellationToken ct = default) { ... } +``` + +When the cache has an entry for the key, execution returns after step 2; step 1 never fires. + +### Example — logging outer, cache inner (logging always runs) + +```csharp +[QueryLoggingAsync(step: 2)] // outer — always runs (both hit and miss) +[CacheableQueryAsync(step: 1, expirationSeconds: 300)] // inner — hit short-circuits here +public override Task ExecuteAsync(GetProductQuery query, + CancellationToken ct = default) { ... } +``` + +Logging records every query invocation. The cache decorator short-circuits before the handler, but +logging has already run. + +## Expiry + +`expirationSeconds` is a **required** compile-time-constant argument (no silent default). A +non-positive value fails fast at pipeline build with a `ConfigurationException`. The value maps to +`HybridCacheEntryOptions.Expiration`; the L1 in-memory expiration is left to HybridCache's default +(capped at `Expiration`). + +## Targeting + +`Paramore.Darker.Caching` targets **net8.0** and **net9.0** only, because +`Microsoft.Extensions.Caching.Hybrid` requires .NET 8+. Consumers on `netstandard2.0` who need +caching must supply their own solution. + +--- + +MIT License — Copyright © 2026 Ian Cooper diff --git a/src/Paramore.Darker.Extensions.Diagnostics/DarkerMetricsBuilderExtensions.cs b/src/Paramore.Darker.Extensions.Diagnostics/DarkerMetricsBuilderExtensions.cs index a2f9302c..a7a7c212 100644 --- a/src/Paramore.Darker.Extensions.Diagnostics/DarkerMetricsBuilderExtensions.cs +++ b/src/Paramore.Darker.Extensions.Diagnostics/DarkerMetricsBuilderExtensions.cs @@ -14,26 +14,44 @@ public static class DarkerMetricsBuilderExtensions { /// /// Adds Darker instrumentation to the . - /// Registers and as - /// singletons in the DI container and subscribes the paramore.darker + /// Registers , , and + /// (when is true) + /// as singletons in the DI container and subscribes the paramore.darker /// so that Darker metric instruments /// are collected by the OpenTelemetry SDK. /// /// The to configure. + /// + /// When true (the default), registers a that emits + /// paramore.darker.cache.requests counter measurements. Set to false to + /// register a no-op (Enabled == false) and + /// suppress cache counter emission — useful when the underlying cache (HybridCache / + /// FusionCache) already emits equivalent metrics and double-reporting should be avoided + /// (FR10, Resolved Decision 4). This toggle is independent of + /// , which gates + /// span-attribute groups rather than metric emission. + /// /// The for chaining. /// /// Mirrors BrighterMetricsBuilderExtensions.AddBrighterInstrumentation. Calling /// AddDarkerInstrumentation() on a MeterProviderBuilder is all that is needed - /// to wire Darker query and DB duration histograms into an OpenTelemetry metrics pipeline. + /// to wire Darker query, DB, and cache metrics into an OpenTelemetry metrics pipeline. /// The registered meters are singletons and can be resolved from the DI container to be - /// passed into the metrics-from-traces processor (ADR 0018). + /// passed into the metrics-from-traces processor (ADR 0021). /// - public static MeterProviderBuilder AddDarkerInstrumentation(this MeterProviderBuilder builder) + public static MeterProviderBuilder AddDarkerInstrumentation( + this MeterProviderBuilder builder, + bool emitCacheMetrics = true) { builder.ConfigureServices(services => { services.TryAddSingleton(); services.TryAddSingleton(); + + if (emitCacheMetrics) + services.TryAddSingleton(); + else + services.TryAddSingleton(new NoOpCacheMeter()); }); builder.AddMeter(DarkerSemanticConventions.MeterName); diff --git a/src/Paramore.Darker.Extensions.Diagnostics/DarkerTracerBuilderExtensions.cs b/src/Paramore.Darker.Extensions.Diagnostics/DarkerTracerBuilderExtensions.cs index 978288ad..8c60400d 100644 --- a/src/Paramore.Darker.Extensions.Diagnostics/DarkerTracerBuilderExtensions.cs +++ b/src/Paramore.Darker.Extensions.Diagnostics/DarkerTracerBuilderExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Linq; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -13,6 +14,20 @@ namespace Paramore.Darker.Extensions.Diagnostics; /// public static class DarkerTracerBuilderExtensions { + /// + /// Null-object implementation of used when the cache + /// meter has not yet been registered (e.g. before AddDarkerInstrumentation on the + /// is updated to include the cache meter in task 29). + /// Records nothing and reports Enabled = false, so the short-circuit guard + /// in eliminates all overhead. + /// + private sealed class NullCacheMeter : IAmADarkerCacheMeter + { + public static readonly NullCacheMeter Instance = new(); + public void RecordCacheOperation(Activity activity) { } + public bool Enabled => false; + } + /// /// Adds Darker instrumentation to the . /// Constructs a , registers it as a singleton @@ -46,7 +61,8 @@ public static TracerProviderBuilder AddDarkerInstrumentation(this TracerProvider builder.AddProcessor(sp => new DarkerMetricsFromTracesProcessor( sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetService() ?? NullCacheMeter.Instance)); } }); diff --git a/src/Paramore.Darker.Extensions.Diagnostics/Observability/CacheMeter.cs b/src/Paramore.Darker.Extensions.Diagnostics/Observability/CacheMeter.cs new file mode 100644 index 00000000..be538cb2 --- /dev/null +++ b/src/Paramore.Darker.Extensions.Diagnostics/Observability/CacheMeter.cs @@ -0,0 +1,64 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using OpenTelemetry.Metrics; +using Paramore.Darker.Observability; + +namespace Paramore.Darker.Extensions.Diagnostics.Observability; + +/// +/// Meter for generating cache-request count metrics from traces following OpenTelemetry's +/// metrics-from-traces pattern (ADR 0021). Owns a single +/// paramore.darker.cache.requests counter and records measurements from completed +/// cache activity spans, filtering to low-cardinality allowed tags only. +/// Only records a measurement when the paramore.darker.cache.outcome tag is present. +/// +public sealed class CacheMeter(IMeterFactory meterFactory, MeterProvider meterProvider) + : IAmADarkerCacheMeter +{ + private readonly KeyValuePair[] _serviceAttributes = meterProvider.GetServiceAttributes(); + + private readonly Counter _counter = meterFactory + .Create(DarkerSemanticConventions.MeterName) + .CreateCounter( + name: DarkerSemanticConventions.CacheRequestsMetricName, + description: "Number of cache lookups performed by Darker."); + + /// + public void RecordCacheOperation(Activity activity) + { + if (activity.GetTagItem(DarkerSemanticConventions.CacheOutcome) is null) + return; + + _counter.Add(1, + [..activity.TagObjects.Filter(DarkerSemanticConventions.CacheRequestsAllowedTags), .._serviceAttributes]); + } + + /// + public bool Enabled => _counter.Enabled; +} diff --git a/src/Paramore.Darker.Extensions.Diagnostics/Observability/DarkerMetricsFromTracesProcessor.cs b/src/Paramore.Darker.Extensions.Diagnostics/Observability/DarkerMetricsFromTracesProcessor.cs index fbf37d60..ce288eba 100644 --- a/src/Paramore.Darker.Extensions.Diagnostics/Observability/DarkerMetricsFromTracesProcessor.cs +++ b/src/Paramore.Darker.Extensions.Diagnostics/Observability/DarkerMetricsFromTracesProcessor.cs @@ -33,18 +33,20 @@ namespace Paramore.Darker.Extensions.Diagnostics.Observability; /// Generates metrics from traces following OpenTelemetry's metrics-from-traces pattern (ADR 0018). /// On each span end, filters to the paramore.darker source and dispatches to the appropriate /// meter by : (query spans) ⇒ -/// ; (DB spans) ⇒ -/// . Holds no metric state of its own. +/// and ; +/// (DB spans) ⇒ . +/// Holds no metric state of its own. /// /// -/// Short-circuits cheaply when neither meter has listeners (NFR2). The processor is added to -/// the tracer pipeline only when both meters are registered, so there is no cost when the user +/// Short-circuits cheaply when no meter has listeners (NFR2). The processor is added to +/// the tracer pipeline only when meters are registered, so there is no cost when the user /// wires only tracing. /// public sealed class DarkerMetricsFromTracesProcessor( IAmADarkerTracer tracer, IAmADarkerQueryMeter queryMeter, - IAmADarkerDbMeter dbMeter) + IAmADarkerDbMeter dbMeter, + IAmADarkerCacheMeter cacheMeter) : BaseProcessor { private readonly string _sourceName = tracer.ActivitySource.Name; @@ -52,7 +54,7 @@ public sealed class DarkerMetricsFromTracesProcessor( /// public override void OnEnd(Activity? activity) { - if (!(queryMeter.Enabled || dbMeter.Enabled)) return; + if (!(queryMeter.Enabled || dbMeter.Enabled || cacheMeter.Enabled)) return; if (activity is null) return; @@ -62,6 +64,7 @@ public override void OnEnd(Activity? activity) { case ActivityKind.Internal: queryMeter.RecordQueryOperation(activity); + cacheMeter.RecordCacheOperation(activity); break; case ActivityKind.Client: dbMeter.RecordClientOperation(activity); diff --git a/src/Paramore.Darker.Extensions.Diagnostics/Observability/IAmADarkerCacheMeter.cs b/src/Paramore.Darker.Extensions.Diagnostics/Observability/IAmADarkerCacheMeter.cs new file mode 100644 index 00000000..df4e5249 --- /dev/null +++ b/src/Paramore.Darker.Extensions.Diagnostics/Observability/IAmADarkerCacheMeter.cs @@ -0,0 +1,52 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System.Diagnostics; + +namespace Paramore.Darker.Extensions.Diagnostics.Observability; + +/// +/// Meter for generating cache-request count metrics from traces following the metrics-from-traces +/// pattern (ADR 0021). Implementations record measurements from cache activity spans onto the +/// paramore.darker.cache.requests counter. +/// +public interface IAmADarkerCacheMeter +{ + /// + /// Records a cache request from a completed activity span. + /// Only called when the paramore.darker.cache.outcome tag is present on the span; + /// when absent the call is a no-op and nothing is recorded. + /// Only low-cardinality allowed tags are forwarded as metric dimensions. + /// + /// The stopped activity representing the cache span. + void RecordCacheOperation(Activity activity); + + /// + /// Gets a value indicating whether any listeners are subscribed to the cache meter. + /// Returns false when no has + /// registered the meter, enabling cheap short-circuiting (NFR2). + /// + bool Enabled { get; } +} diff --git a/src/Paramore.Darker.Extensions.Diagnostics/Observability/NoOpCacheMeter.cs b/src/Paramore.Darker.Extensions.Diagnostics/Observability/NoOpCacheMeter.cs new file mode 100644 index 00000000..60aeac0d --- /dev/null +++ b/src/Paramore.Darker.Extensions.Diagnostics/Observability/NoOpCacheMeter.cs @@ -0,0 +1,44 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System.Diagnostics; + +namespace Paramore.Darker.Extensions.Diagnostics.Observability; + +/// +/// No-op implementation of registered when cache-metrics +/// emission is disabled via the emitCacheMetrics: false toggle on +/// . Records nothing and +/// reports Enabled = false, so the short-circuit guard in +/// eliminates all overhead (FR10, ADR 0021). +/// +public sealed class NoOpCacheMeter : IAmADarkerCacheMeter +{ + /// + public void RecordCacheOperation(Activity activity) { } + + /// + public bool Enabled => false; +} diff --git a/src/Paramore.Darker/Observability/DarkerSemanticConventions.cs b/src/Paramore.Darker/Observability/DarkerSemanticConventions.cs index fe916322..c13a727e 100644 --- a/src/Paramore.Darker/Observability/DarkerSemanticConventions.cs +++ b/src/Paramore.Darker/Observability/DarkerSemanticConventions.cs @@ -90,6 +90,14 @@ public static class DarkerSemanticConventions /// The database user name (db.user). public const string DbUser = "db.user"; + // ── Cache span attributes ───────────────────────────────────────────────── + + /// + /// The cache-lookup outcome attribute key (paramore.darker.cache.outcome). + /// Value is "hit" when a cached result is returned, or "miss" when the handler is invoked. + /// + public const string CacheOutcome = "paramore.darker.cache.outcome"; + // ── Meter / metric names ────────────────────────────────────────────────── /// The name of the used by Darker. @@ -101,6 +109,9 @@ public static class DarkerSemanticConventions /// The name of the DB-client-operation-duration histogram instrument (db.client.operation.duration). public const string DbClientOperationDurationMetricName = "db.client.operation.duration"; + /// The name of the cache-requests counter instrument (paramore.darker.cache.requests). + public const string CacheRequestsMetricName = "paramore.darker.cache.requests"; + // ── Resource / service attributes ───────────────────────────────────────── /// The service name resource attribute (service.name). @@ -158,4 +169,22 @@ public static class DarkerSemanticConventions #else }; #endif + + /// + /// The low-cardinality tag keys permitted on the paramore.darker.cache.requests counter. + /// High-cardinality keys such as are intentionally excluded. + /// +#if NET8_0_OR_GREATER + public static readonly FrozenSet CacheRequestsAllowedTags = new[] +#else + public static readonly HashSet CacheRequestsAllowedTags = new() +#endif + { + QueryType, + CacheOutcome +#if NET8_0_OR_GREATER + }.ToFrozenSet(); +#else + }; +#endif } diff --git a/test/Paramore.Darker.Caching.Tests/DarkerActivitySourceCollection.cs b/test/Paramore.Darker.Caching.Tests/DarkerActivitySourceCollection.cs new file mode 100644 index 00000000..5cb2fb90 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/DarkerActivitySourceCollection.cs @@ -0,0 +1,16 @@ +using Xunit; + +namespace Paramore.Darker.Caching.Tests +{ + /// + /// Serialises every test that registers an on + /// the process-global paramore.darker , so a + /// leaked listener from one test cannot make the no-listener zero-overhead test observe a span + /// (test isolation). Being DisableParallelization it also never runs concurrently with other + /// ActivityListener tests in this project. + /// + [CollectionDefinition("DarkerActivitySource", DisableParallelization = true)] + public sealed class DarkerActivitySourceCollection + { + } +} diff --git a/test/Paramore.Darker.Caching.Tests/Paramore.Darker.Caching.Tests.csproj b/test/Paramore.Darker.Caching.Tests/Paramore.Darker.Caching.Tests.csproj new file mode 100644 index 00000000..1e19714e --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/Paramore.Darker.Caching.Tests.csproj @@ -0,0 +1,25 @@ + + + net8.0;net9.0 + false + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/AsyncOnMissHybridCache.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/AsyncOnMissHybridCache.cs new file mode 100644 index 00000000..96fd40c4 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/AsyncOnMissHybridCache.cs @@ -0,0 +1,88 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test double whose +/// always suspends via +/// await Task.Yield() before invoking the factory. Because the method +/// yields before returning, the it returns +/// is not IsCompletedSuccessfully at the call site. This forces the +/// else blocking-fallback branch in +/// CacheableQueryDecorator.Execute that calls +/// .AsTask().GetAwaiter().GetResult(). +/// +/// +/// No caching is performed — the factory is always invoked, so the handler runs on +/// every Execute call. The other abstract members are no-ops that return +/// a completed because they are not exercised by the +/// sync-fallback test. +/// +public sealed class AsyncOnMissHybridCache : HybridCache +{ + /// + /// + /// Yields before calling the factory so the returned + /// is never synchronously completed, exercising the blocking fallback in + /// CacheableQueryDecorator.Execute. + /// + public override async ValueTask GetOrCreateAsync( + string key, + TState state, + Func> factory, + HybridCacheEntryOptions? options = null, + IEnumerable? tags = null, + CancellationToken cancellationToken = default) + { + // Force an async suspension so the ValueTask returned to the caller is NOT + // IsCompletedSuccessfully — this is the condition that triggers the else branch + // in CacheableQueryDecorator.Execute. + await Task.Yield(); + return await factory(state, cancellationToken); + } + + /// + public override ValueTask SetAsync( + string key, + T value, + HybridCacheEntryOptions? options = null, + IEnumerable? tags = null, + CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + /// + public override ValueTask RemoveAsync( + string key, + CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + /// + public override ValueTask RemoveByTagAsync( + string tag, + CancellationToken cancellationToken = default) => ValueTask.CompletedTask; +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/CacheTestQuery.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/CacheTestQuery.cs new file mode 100644 index 00000000..b5e6875c --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/CacheTestQuery.cs @@ -0,0 +1,42 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A minimal query used by end-to-end caching pipeline tests. Its single property +/// determines the cache key so that tests can control whether two executions produce a +/// cache hit or miss. +/// +public sealed class CacheTestQuery : IQuery +{ + /// Gets or sets the payload used to compute the cache key and build the result. + public string Payload { get; set; } = string.Empty; + + /// The result returned by . + public sealed class Result + { + /// Gets or sets the value echoed from the query payload. + public string Value { get; set; } = string.Empty; + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/CacheTestQueryHandlerAsync.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/CacheTestQueryHandlerAsync.cs new file mode 100644 index 00000000..5c0145c1 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/CacheTestQueryHandlerAsync.cs @@ -0,0 +1,62 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test-double async handler for whose ExecuteAsync +/// method carries so that the caching decorator +/// is wired into the pipeline automatically. +/// +/// +/// Must be public so that AddHandlersFromAssemblies discovers it via +/// Assembly.GetExportedTypes() when scanning the test assembly in end-to-end tests. +/// The injected lets tests assert the exact number of handler +/// invocations without fragile static state. +/// +public sealed class CacheTestQueryHandlerAsync : QueryHandlerAsync +{ + private readonly HandlerCallCounter _counter; + + /// + /// Initialises the handler with the shared call counter. + /// + /// The counter that tracks how many times the handler body was entered. + public CacheTestQueryHandlerAsync(HandlerCallCounter counter) + { + _counter = counter; + } + + /// + [CacheableQueryAttributeAsync(1, 60)] + public override Task ExecuteAsync( + CacheTestQuery query, + CancellationToken cancellationToken = default) + { + _counter.Increment(); + return Task.FromResult(new CacheTestQuery.Result { Value = query.Payload }); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/CustomKeyGenerator.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/CustomKeyGenerator.cs new file mode 100644 index 00000000..8a350bab --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/CustomKeyGenerator.cs @@ -0,0 +1,44 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test-double that records how many times +/// was called and returns a distinctive key shape so tests can +/// prove the custom generator was used by the pipeline end-to-end. +/// +public sealed class CustomKeyGenerator : ICacheKeyGenerator +{ + private int _callCount; + + /// Gets the number of times has been called. + public int GenerateKeyCallCount => _callCount; + + /// + public string GenerateKey(object query) + { + _callCount++; + return $"custom|{query.GetType().Name}"; + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/EmptyCacheKeyPipelineQuery.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/EmptyCacheKeyPipelineQuery.cs new file mode 100644 index 00000000..fc219c57 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/EmptyCacheKeyPipelineQuery.cs @@ -0,0 +1,44 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// An end-to-end pipeline test double that implements both and +/// . Its always returns an empty string at +/// runtime, which is the condition that triggers the fail-fast ConfigurationException +/// from (FR4). +/// +public sealed class EmptyCacheKeyPipelineQuery : IQuery, IAmCacheable +{ + /// + /// Deliberately returns an empty string to exercise the FR4 fail-fast path. + public string CacheKey => string.Empty; + + /// The result type returned by . + public sealed class Result + { + /// Gets or sets the value produced by the handler. + public string Value { get; set; } = string.Empty; + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/EmptyCacheKeyPipelineQueryHandlerAsync.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/EmptyCacheKeyPipelineQueryHandlerAsync.cs new file mode 100644 index 00000000..e3dfd9bd --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/EmptyCacheKeyPipelineQueryHandlerAsync.cs @@ -0,0 +1,59 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test-double async handler for whose +/// ExecuteAsync carries so that the +/// caching decorator is wired into the pipeline automatically. +/// +/// +/// The lets end-to-end tests assert that the handler body +/// was never entered — the failure must occur during key generation, before next runs. +/// +public sealed class EmptyCacheKeyPipelineQueryHandlerAsync + : QueryHandlerAsync +{ + private readonly HandlerCallCounter _counter; + + /// Initialises the handler with the shared call counter. + /// Tracks how many times the handler body was entered. + public EmptyCacheKeyPipelineQueryHandlerAsync(HandlerCallCounter counter) + { + _counter = counter; + } + + /// + [CacheableQueryAttributeAsync(1, 60)] + public override Task ExecuteAsync( + EmptyCacheKeyPipelineQuery query, + CancellationToken cancellationToken = default) + { + _counter.Increment(); + return Task.FromResult(new EmptyCacheKeyPipelineQuery.Result { Value = "executed" }); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/HandlerCallCounter.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/HandlerCallCounter.cs new file mode 100644 index 00000000..d219883e --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/HandlerCallCounter.cs @@ -0,0 +1,39 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// Records how many times the handler body was entered during query execution. +/// Register as a singleton in +/// and inject into so that end-to-end tests can assert +/// the exact number of handler invocations without fragile static mutable state. +/// +public sealed class HandlerCallCounter +{ + /// Gets the number of times the handler body was entered. + public int CallCount { get; private set; } + + /// Records one handler invocation by incrementing the call count. + public void Increment() => CallCount++; +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/InnerInvocationRecorder.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/InnerInvocationRecorder.cs new file mode 100644 index 00000000..9d0bfae9 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/InnerInvocationRecorder.cs @@ -0,0 +1,44 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// Records separate invocation counts for the inner recording decorator and the handler in +/// decorator-ordering / short-circuit tests. Register as a singleton so the same instance +/// is shared between the decorator, the handler, and the test assertion. +/// +public sealed class InnerInvocationRecorder +{ + /// Gets the number of times the inner recording decorator was entered. + public int InnerDecoratorCallCount { get; private set; } + + /// Gets the number of times the handler body was entered. + public int HandlerCallCount { get; private set; } + + /// Records one invocation of the inner recording decorator. + public void IncrementInnerDecorator() => InnerDecoratorCallCount++; + + /// Records one invocation of the handler. + public void IncrementHandler() => HandlerCallCount++; +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/KeyedCacheQuery.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/KeyedCacheQuery.cs new file mode 100644 index 00000000..d6058238 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/KeyedCacheQuery.cs @@ -0,0 +1,51 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using Paramore.Darker.Caching; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A query that implements so that end-to-end pipeline tests can +/// prove the runtime query is IAmCacheable detection path works when the decorator is +/// closed over IQuery<TResult> (not the concrete type). +/// +/// The test controls explicitly, making it straightforward to create two +/// queries with the same key (cache hit) or different keys (distinct entries) from a single type. +/// +/// +public sealed class KeyedCacheQuery : IQuery, IAmCacheable +{ + /// + public string CacheKey { get; init; } = string.Empty; + + /// Gets or sets the payload echoed back by the handler. + public string Payload { get; init; } = string.Empty; + + /// The result returned by . + public sealed class Result + { + /// Gets or sets the value echoed from the query payload. + public string Value { get; set; } = string.Empty; + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/KeyedCacheQueryHandlerAsync.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/KeyedCacheQueryHandlerAsync.cs new file mode 100644 index 00000000..ba3a6969 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/KeyedCacheQueryHandlerAsync.cs @@ -0,0 +1,64 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test-double async handler for whose ExecuteAsync +/// carries so that the caching decorator is wired +/// into the pipeline automatically when the test registers via +/// AddHandlersFromAssemblies. +/// +/// +/// The injected lets end-to-end tests assert the exact number +/// of handler invocations. Because the pipeline closes decorators over IQuery<TResult> +/// (not the concrete type), this handler exercises the runtime query is IAmCacheable +/// detection path in through a real +/// . +/// +public sealed class KeyedCacheQueryHandlerAsync : QueryHandlerAsync +{ + private readonly HandlerCallCounter _counter; + + /// + /// Initialises the handler with the shared call counter. + /// + /// Tracks how many times the handler body was entered. + public KeyedCacheQueryHandlerAsync(HandlerCallCounter counter) + { + _counter = counter; + } + + /// + [CacheableQueryAttributeAsync(1, 60)] + public override Task ExecuteAsync( + KeyedCacheQuery query, + CancellationToken cancellationToken = default) + { + _counter.Increment(); + return Task.FromResult(new KeyedCacheQuery.Result { Value = query.Payload }); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/NegativeExpiryQuery.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/NegativeExpiryQuery.cs new file mode 100644 index 00000000..4b787b7e --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/NegativeExpiryQuery.cs @@ -0,0 +1,39 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test query whose handler is annotated with expirationSeconds: -5. +/// Used to verify that a negative expiry fails fast at pipeline build with a +/// . +/// +public sealed class NegativeExpiryQuery : IQuery +{ + /// The result returned by . + public sealed class Result + { + /// Gets or sets the echoed payload. + public string Value { get; set; } = string.Empty; + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/NegativeExpiryQueryHandlerAsync.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/NegativeExpiryQueryHandlerAsync.cs new file mode 100644 index 00000000..6cf5556c --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/NegativeExpiryQueryHandlerAsync.cs @@ -0,0 +1,61 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test-double async handler for whose +/// ExecuteAsync carries expirationSeconds: -5. This is deliberately +/// invalid — the decorator must fail fast at pipeline build before the handler body runs. +/// +/// +/// Must be public so that AddHandlersFromAssemblies discovers it via +/// Assembly.GetExportedTypes() when scanning the test assembly in end-to-end tests. +/// +public sealed class NegativeExpiryQueryHandlerAsync + : QueryHandlerAsync +{ + private readonly HandlerCallCounter _counter; + + /// + /// Initialises the handler with the shared call counter. + /// + /// The counter used to assert the handler body never ran. + public NegativeExpiryQueryHandlerAsync(HandlerCallCounter counter) + { + _counter = counter; + } + + /// + [CacheableQueryAttributeAsync(1, -5)] + public override Task ExecuteAsync( + NegativeExpiryQuery query, + CancellationToken cancellationToken = default) + { + _counter.Increment(); + return Task.FromResult(new NegativeExpiryQuery.Result { Value = "should-never-reach-here" }); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/NoTagSupportHybridCache.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/NoTagSupportHybridCache.cs new file mode 100644 index 00000000..3be20679 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/NoTagSupportHybridCache.cs @@ -0,0 +1,104 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test double that stores entries in memory so that a second +/// call with the same key is a cache hit, but whose tag support is absent: +/// is a no-op (does not evict any entries) and the +/// tags argument on is accepted without +/// being used for eviction. +/// +/// +/// This models the FR14 best-effort tagging contract: tagging against an implementation +/// without tag support still caches the entry and never fails the query. The key property +/// under test is that stores and serves entries +/// correctly so the second call is a hit (handler count 1), while +/// silently does nothing and the entry remains cached. +/// +public sealed class NoTagSupportHybridCache : HybridCache +{ + private readonly ConcurrentDictionary _store = new(); + + /// + /// + /// Tags are accepted but silently ignored — this implementation has no tag-eviction + /// support. Entries are stored and served from an in-memory dictionary keyed by + /// so that the second call with the same key is a cache hit. + /// + public override async ValueTask GetOrCreateAsync( + string key, + TState state, + Func> factory, + HybridCacheEntryOptions? options = null, + IEnumerable? tags = null, + CancellationToken cancellationToken = default) + { + if (_store.TryGetValue(key, out var cached)) + return (T)cached!; + + var value = await factory(state, cancellationToken).ConfigureAwait(false); + _store[key] = value; + return value; + } + + /// + public override ValueTask SetAsync( + string key, + T value, + HybridCacheEntryOptions? options = null, + IEnumerable? tags = null, + CancellationToken cancellationToken = default) + { + _store[key] = value; + return ValueTask.CompletedTask; + } + + /// + public override ValueTask RemoveAsync( + string key, + CancellationToken cancellationToken = default) + { + _store.TryRemove(key, out _); + return ValueTask.CompletedTask; + } + + /// + /// + /// No-op: this implementation does not support tag-based eviction. The cached entry + /// is retained after calling this method — consistent with FR14's best-effort tagging + /// stance that supplying a tag never fails the query even when the underlying cache + /// ignores it. + /// + public override ValueTask RemoveByTagAsync( + string tag, + CancellationToken cancellationToken = default) => ValueTask.CompletedTask; +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/NonPumpingSynchronizationContext.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/NonPumpingSynchronizationContext.cs new file mode 100644 index 00000000..32c10a13 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/NonPumpingSynchronizationContext.cs @@ -0,0 +1,54 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Collections.Concurrent; +using System.Threading; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test double that models a single-threaded context +/// (classic ASP.NET request thread, WPF/WinForms UI thread) which is never pumped. +/// merely queues the continuation; nothing ever runs it. So any async +/// continuation captured by this context — as happens when library code awaits without +/// ConfigureAwait(false) — will never execute. If code under test then blocks the +/// originating thread waiting for that continuation, it deadlocks. +/// +/// +/// Unlike the base (whose Post dispatches to the +/// thread pool), this double deliberately does not dispatch, so it can reproduce the classic +/// sync-over-async deadlock deterministically. +/// +public sealed class NonPumpingSynchronizationContext : SynchronizationContext +{ + private readonly ConcurrentQueue<(SendOrPostCallback callback, object state)> _queued = new(); + + /// Gets the number of continuations captured by this context but never run. + public int QueuedCount => _queued.Count; + + /// + public override void Post(SendOrPostCallback d, object state) => _queued.Enqueue((d, state)); + + /// + public override void Send(SendOrPostCallback d, object state) => d(state); +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/NullReturningCacheQuery.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/NullReturningCacheQuery.cs new file mode 100644 index 00000000..5b0ff033 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/NullReturningCacheQuery.cs @@ -0,0 +1,48 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A minimal query used by negative-caching tests. The handler for this query always returns +/// null, allowing tests to verify FR11: the caching decorator stores and serves a +/// null result as a hit without re-running the handler. +/// +/// +/// The result type is a class (reference type) so that null is a valid runtime value. +/// The query uses the default cache-key strategy (type name + JSON of properties) so that +/// two calls with the same value resolve to the same cache entry. +/// +public sealed class NullReturningCacheQuery : IQuery +{ + /// Gets or sets the value included in the default cache key for this query. + public string QueryKey { get; set; } = "null-returning-query"; + + /// + /// A reference-type result whose null value is what the handler stores in the cache. + /// + public sealed class Result + { + // Intentionally empty — only the null value matters for FR11 negative-caching tests. + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/NullReturningCacheQueryHandlerAsync.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/NullReturningCacheQueryHandlerAsync.cs new file mode 100644 index 00000000..3ce785fb --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/NullReturningCacheQueryHandlerAsync.cs @@ -0,0 +1,61 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test-double async handler for that always returns +/// null, exercising the decorator's negative-caching behaviour (FR11). +/// +/// +/// The injected lets end-to-end tests assert that the +/// handler runs exactly once: a second call must serve the cached null as a hit +/// and must not re-invoke the handler. +/// +public sealed class NullReturningCacheQueryHandlerAsync + : QueryHandlerAsync +{ + private readonly HandlerCallCounter _counter; + + /// + /// Initialises the handler with the shared call counter. + /// + /// Tracks how many times the handler body was entered. + public NullReturningCacheQueryHandlerAsync(HandlerCallCounter counter) + { + _counter = counter; + } + + /// + [CacheableQueryAttributeAsync(1, 60)] + public override Task ExecuteAsync( + NullReturningCacheQuery query, + CancellationToken cancellationToken = default) + { + _counter.Increment(); + return Task.FromResult(default(NullReturningCacheQuery.Result)); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/OrderingTestQuery.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/OrderingTestQuery.cs new file mode 100644 index 00000000..6c644132 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/OrderingTestQuery.cs @@ -0,0 +1,45 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A query used by decorator-ordering / short-circuit tests. Implements +/// so the cache decorator uses a stable, predictable key — +/// guaranteeing a cache hit on the second execution with the same query instance. +/// +public sealed class OrderingTestQuery : IQuery, IAmCacheable +{ + /// Gets or sets the payload echoed into the result. + public string Payload { get; set; } = string.Empty; + + /// + public string CacheKey => "ordering-test-query"; + + /// The result returned by . + public sealed class Result + { + /// Gets or sets the value echoed from the query payload. + public string Value { get; set; } = string.Empty; + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/OrderingTestQueryHandlerAsync.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/OrderingTestQueryHandlerAsync.cs new file mode 100644 index 00000000..66916765 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/OrderingTestQueryHandlerAsync.cs @@ -0,0 +1,69 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// Handler for used in decorator-ordering / short-circuit tests. +/// Carries both the cache attribute (step = 1, lower step = outer position) and the recording +/// decorator attribute (step = 2, higher step = inner position). On a cache hit the cache +/// decorator is outermost and short-circuits next, so the recording decorator and this +/// handler must not be invoked. +/// +/// +/// Step ordering recap: PipelineBuilder.GetDecoratorsAsync sorts attributes by step +/// descending, so the attribute with the highest step is processed first and sits closest +/// to the handler; the attribute with the lowest step is processed last and added outermost. +/// Therefore step = 1 (cache) is OUTER and step = 2 (recording) is INNER. +/// +public sealed class OrderingTestQueryHandlerAsync + : QueryHandlerAsync +{ + private readonly InnerInvocationRecorder _recorder; + + /// + /// Initialises the handler with the shared invocation recorder. + /// + /// + /// The singleton recorder shared with the test; receives a call to + /// each time the handler body runs. + /// + public OrderingTestQueryHandlerAsync(InnerInvocationRecorder recorder) + { + _recorder = recorder; + } + + /// + [CacheableQueryAttributeAsync(1, 60)] + [RecordingDecoratorAttributeAsync(2)] + public override Task ExecuteAsync( + OrderingTestQuery query, + CancellationToken cancellationToken = default) + { + _recorder.IncrementHandler(); + return Task.FromResult(new OrderingTestQuery.Result { Value = query.Payload }); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/QueryWithEmptyCacheKey.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/QueryWithEmptyCacheKey.cs new file mode 100644 index 00000000..1cef6c36 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/QueryWithEmptyCacheKey.cs @@ -0,0 +1,31 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// An query whose returns an empty string at runtime. +public sealed record QueryWithEmptyCacheKey : IAmCacheable +{ + /// + public string CacheKey => string.Empty; +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/QueryWithNullCacheKey.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/QueryWithNullCacheKey.cs new file mode 100644 index 00000000..6293ae03 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/QueryWithNullCacheKey.cs @@ -0,0 +1,31 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// An query whose returns null at runtime. +public sealed record QueryWithNullCacheKey : IAmCacheable +{ + /// + public string CacheKey => null!; +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/QueryWithWhitespaceCacheKey.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/QueryWithWhitespaceCacheKey.cs new file mode 100644 index 00000000..62f36582 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/QueryWithWhitespaceCacheKey.cs @@ -0,0 +1,31 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// An query whose returns whitespace at runtime. +public sealed record QueryWithWhitespaceCacheKey : IAmCacheable +{ + /// + public string CacheKey => " "; +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/RecordingDecoratorAsync.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/RecordingDecoratorAsync.cs new file mode 100644 index 00000000..82379989 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/RecordingDecoratorAsync.cs @@ -0,0 +1,75 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A minimal async pipeline decorator used in ordering / short-circuit tests. Each time its +/// is entered it increments +/// then delegates to . Because it is registered at a higher step +/// than (i.e. it sits inside the cache +/// decorator in the pipeline), it must not be invoked on a cache hit. +/// +/// The query type. +/// The result type. +public sealed class RecordingDecoratorAsync : IQueryHandlerDecoratorAsync + where TQuery : IQuery +{ + private readonly InnerInvocationRecorder _recorder; + + /// + public IQueryContext Context { get; set; } = null!; + + /// + /// Initialises the decorator with the shared invocation recorder. + /// + /// + /// The singleton recorder shared with the test; receives a call to + /// each time this decorator runs. + /// + public RecordingDecoratorAsync(InnerInvocationRecorder recorder) + { + _recorder = recorder; + } + + /// + public void InitializeFromAttributeParams(object[] attributeParams) + { + // No attribute state to apply. + } + + /// + public async Task ExecuteAsync( + TQuery query, + Func> next, + Func> fallback, + CancellationToken cancellationToken = default) + { + _recorder.IncrementInnerDecorator(); + return await next(query, cancellationToken).ConfigureAwait(false); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/RecordingDecoratorAttributeAsync.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/RecordingDecoratorAttributeAsync.cs new file mode 100644 index 00000000..2d29597b --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/RecordingDecoratorAttributeAsync.cs @@ -0,0 +1,52 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// Attribute that wires into the +/// async decorator pipeline. Used in ordering/short-circuit tests to verify that an inner +/// decorator (higher step) is skipped when the outer cache decorator (lower step) returns a hit. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class RecordingDecoratorAttributeAsync : QueryHandlerAttributeAsync +{ + /// + /// Initialises the attribute with a pipeline step ordering value. + /// + /// + /// The step order for this decorator in the pipeline. A higher value than the cache + /// decorator's step places this decorator inside the cache, so it is skipped on a hit. + /// + public RecordingDecoratorAttributeAsync(int step) : base(step) + { + } + + /// + public override Type GetDecoratorType() => typeof(RecordingDecoratorAsync<,>); + + /// + public override object[] GetAttributeParams() => []; +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/SerializationFailureQuery.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/SerializationFailureQuery.cs new file mode 100644 index 00000000..895a16cb --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/SerializationFailureQuery.cs @@ -0,0 +1,38 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A minimal query used by serialization-failure pipeline tests (FR13). +/// The handler for this query is annotated with +/// and returns a . A +/// registered for that result type causes +/// HybridCache to throw on serialization during a cache miss, allowing tests to verify +/// that the exception surfaces to the caller unswallowed. +/// +public sealed class SerializationFailureQuery : IQuery +{ + /// Gets or sets a payload value echoed by the handler. + public string Payload { get; set; } = "test"; +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/SerializationFailureQueryHandlerAsync.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/SerializationFailureQueryHandlerAsync.cs new file mode 100644 index 00000000..d8528d9b --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/SerializationFailureQueryHandlerAsync.cs @@ -0,0 +1,48 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test-double async handler for whose +/// ExecuteAsync is annotated with so that +/// the caching decorator is wired into the Darker pipeline automatically. +/// On a cache miss the handler returns a ; HybridCache +/// then attempts to serialize it using the registered , +/// which throws. That exception must surface to the caller — not be swallowed by the decorator. +/// +public sealed class SerializationFailureQueryHandlerAsync + : QueryHandlerAsync +{ + /// + [CacheableQueryAttributeAsync(1, 60)] + public override Task ExecuteAsync( + SerializationFailureQuery query, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new SerializationFailureResult { Value = query.Payload }); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/SerializationFailureResult.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/SerializationFailureResult.cs new file mode 100644 index 00000000..04498915 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/SerializationFailureResult.cs @@ -0,0 +1,37 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A minimal result type used by serialization-failure pipeline tests (FR13). +/// A is registered for this type so that +/// any attempt by HybridCache to serialize it on a cache miss throws a +/// , exercising the decorator's obligation +/// to surface that exception to the caller without swallowing it. +/// +public sealed class SerializationFailureResult +{ + /// Gets or sets a value that the handler populates on a cache miss. + public string Value { get; set; } = string.Empty; +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/ShortExpiryCacheTestQuery.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/ShortExpiryCacheTestQuery.cs new file mode 100644 index 00000000..20dff5a0 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/ShortExpiryCacheTestQuery.cs @@ -0,0 +1,41 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A minimal query used by cache-expiry tests. Its handler is annotated with a 1-second +/// cache expiry so that tests can observe handler re-execution after the TTL elapses. +/// +public sealed class ShortExpiryCacheTestQuery : IQuery +{ + /// Gets or sets the payload echoed back by the handler. + public string Payload { get; set; } = string.Empty; + + /// The result returned by . + public sealed class Result + { + /// Gets or sets the value echoed from the query payload. + public string Value { get; set; } = string.Empty; + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/ShortExpiryCacheTestQueryHandlerAsync.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/ShortExpiryCacheTestQueryHandlerAsync.cs new file mode 100644 index 00000000..a6fe8fd8 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/ShortExpiryCacheTestQueryHandlerAsync.cs @@ -0,0 +1,61 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test-double async handler for whose +/// ExecuteAsync carries a 1-second so +/// that expiry tests can observe handler re-execution after the TTL elapses. +/// +/// +/// Must be public so that AddHandlersFromAssemblies discovers it via +/// Assembly.GetExportedTypes() when scanning the test assembly in end-to-end tests. +/// +public sealed class ShortExpiryCacheTestQueryHandlerAsync + : QueryHandlerAsync +{ + private readonly HandlerCallCounter _counter; + + /// + /// Initialises the handler with the shared call counter. + /// + /// The counter that tracks how many times the handler body was entered. + public ShortExpiryCacheTestQueryHandlerAsync(HandlerCallCounter counter) + { + _counter = counter; + } + + /// + [CacheableQueryAttributeAsync(1, 1)] + public override Task ExecuteAsync( + ShortExpiryCacheTestQuery query, + CancellationToken cancellationToken = default) + { + _counter.Increment(); + return Task.FromResult(new ShortExpiryCacheTestQuery.Result { Value = query.Payload }); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/SyncCacheTestQuery.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/SyncCacheTestQuery.cs new file mode 100644 index 00000000..550cc4af --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/SyncCacheTestQuery.cs @@ -0,0 +1,42 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A minimal synchronous query used by sync caching pipeline tests. Its single property +/// determines the cache key so that tests can control whether two executions produce a +/// cache hit or miss. +/// +public sealed class SyncCacheTestQuery : IQuery +{ + /// Gets or sets the payload used to compute the cache key and build the result. + public string Payload { get; set; } = string.Empty; + + /// The result returned by . + public sealed class Result + { + /// Gets or sets the value echoed from the query payload. + public string Value { get; set; } = string.Empty; + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/SyncCacheTestQueryHandler.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/SyncCacheTestQueryHandler.cs new file mode 100644 index 00000000..3dacb125 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/SyncCacheTestQueryHandler.cs @@ -0,0 +1,57 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test-double synchronous handler for whose Execute +/// method carries so that the sync caching decorator +/// is wired into the pipeline automatically. +/// +/// +/// Must be public so that AddHandlersFromAssemblies discovers it via +/// Assembly.GetExportedTypes() when scanning the test assembly in end-to-end tests. +/// The injected lets tests assert the exact number of handler +/// invocations without fragile static state. +/// +public sealed class SyncCacheTestQueryHandler : QueryHandler +{ + private readonly HandlerCallCounter _counter; + + /// + /// Initialises the handler with the shared call counter. + /// + /// The counter that tracks how many times the handler body was entered. + public SyncCacheTestQueryHandler(HandlerCallCounter counter) + { + _counter = counter; + } + + /// + [CacheableQuery(1, 60)] + public override SyncCacheTestQuery.Result Execute(SyncCacheTestQuery query) + { + _counter.Increment(); + return new SyncCacheTestQuery.Result { Value = query.Payload }; + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/ThrowingHybridCacheSerializer.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/ThrowingHybridCacheSerializer.cs new file mode 100644 index 00000000..5e9dba65 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/ThrowingHybridCacheSerializer.cs @@ -0,0 +1,56 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; +using System.Buffers; +using Microsoft.Extensions.Caching.Hybrid; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test-double for +/// that always throws +/// from both and +/// . +/// +/// +/// Register this serializer for via +/// AddHybridCache().AddSerializer<SerializationFailureResult, ThrowingHybridCacheSerializer>() +/// to cause HybridCache to throw when it attempts to serialize the handler's result on a +/// cache miss. This verifies FR13: the caching decorator does not swallow serialization +/// exceptions — they surface to the caller unswallowed. +/// +public sealed class ThrowingHybridCacheSerializer : IHybridCacheSerializer +{ + /// + /// Always thrown to simulate a serialization failure. + public void Serialize(SerializationFailureResult value, IBufferWriter target) + => throw new NotSupportedException( + "ThrowingHybridCacheSerializer: serialization is intentionally unsupported (FR13 test double)."); + + /// + /// Always thrown to simulate a deserialization failure. + public SerializationFailureResult Deserialize(ReadOnlySequence source) + => throw new NotSupportedException( + "ThrowingHybridCacheSerializer: deserialization is intentionally unsupported (FR13 test double)."); +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/ZeroExpiryQuery.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/ZeroExpiryQuery.cs new file mode 100644 index 00000000..8cb7a462 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/ZeroExpiryQuery.cs @@ -0,0 +1,39 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test query whose handler is annotated with expirationSeconds: 0. +/// Used to verify that a zero expiry fails fast at pipeline build with a +/// . +/// +public sealed class ZeroExpiryQuery : IQuery +{ + /// The result returned by . + public sealed class Result + { + /// Gets or sets the echoed payload. + public string Value { get; set; } = string.Empty; + } +} diff --git a/test/Paramore.Darker.Caching.Tests/TestDoubles/ZeroExpiryQueryHandlerAsync.cs b/test/Paramore.Darker.Caching.Tests/TestDoubles/ZeroExpiryQueryHandlerAsync.cs new file mode 100644 index 00000000..d102b2d8 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/TestDoubles/ZeroExpiryQueryHandlerAsync.cs @@ -0,0 +1,61 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Darker.Caching.Tests.TestDoubles; + +/// +/// A test-double async handler for whose +/// ExecuteAsync carries expirationSeconds: 0. This is deliberately +/// invalid — the decorator must fail fast at pipeline build before the handler body runs. +/// +/// +/// Must be public so that AddHandlersFromAssemblies discovers it via +/// Assembly.GetExportedTypes() when scanning the test assembly in end-to-end tests. +/// +public sealed class ZeroExpiryQueryHandlerAsync + : QueryHandlerAsync +{ + private readonly HandlerCallCounter _counter; + + /// + /// Initialises the handler with the shared call counter. + /// + /// The counter used to assert the handler body never ran. + public ZeroExpiryQueryHandlerAsync(HandlerCallCounter counter) + { + _counter = counter; + } + + /// + [CacheableQueryAttributeAsync(1, 0)] + public override Task ExecuteAsync( + ZeroExpiryQuery query, + CancellationToken cancellationToken = default) + { + _counter.Increment(); + return Task.FromResult(new ZeroExpiryQuery.Result { Value = "should-never-reach-here" }); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_AddCaching_called_should_register_decorators_and_key_generator.cs b/test/Paramore.Darker.Caching.Tests/When_AddCaching_called_should_register_decorators_and_key_generator.cs new file mode 100644 index 00000000..75bbd418 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_AddCaching_called_should_register_decorators_and_key_generator.cs @@ -0,0 +1,98 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// Proves that AddCaching() and AddCaching(Action{CachingOptions}) register the +/// caching decorators and wire up the correct : the parameterless +/// path uses ; the options-callback path honours a custom +/// generator supplied by the caller and the generator is used end-to-end through the pipeline. +/// +public sealed class AddCachingRegistrationTests +{ + [Fact] + public void When_AddCaching_called_should_register_decorators_and_key_generator() + { + // Arrange — build a ServiceCollection with the parameterless AddCaching() overload + var services = new ServiceCollection(); + services.AddDarker().AddCaching(); + + // Act — build the provider and resolve ICacheKeyGenerator + using var provider = services.BuildServiceProvider(); + var keyGenerator = provider.GetRequiredService(); + + // Assert — ICacheKeyGenerator resolves as the default implementation + keyGenerator.ShouldBeOfType( + "parameterless AddCaching() must register DefaultCacheKeyGenerator as ICacheKeyGenerator"); + + // Assert — both decorator open-generic types are present in the service collection + services.Any(sd => sd.ServiceType == typeof(CacheableQueryDecoratorAsync<,>)).ShouldBeTrue( + "AddCaching() must register CacheableQueryDecoratorAsync<,> as a decorator"); + services.Any(sd => sd.ServiceType == typeof(CacheableQueryDecorator<,>)).ShouldBeTrue( + "AddCaching() must register CacheableQueryDecorator<,> as a decorator"); + } + + [Fact] + public async Task When_AddCaching_called_with_custom_key_generator_should_use_custom_generator() + { + // Arrange — supply a custom ICacheKeyGenerator via the options callback + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddHybridCache(); + + var customKeyGenerator = new CustomKeyGenerator(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(CacheTestQueryHandlerAsync).Assembly) + .AddCaching(o => o.KeyGenerator = customKeyGenerator); + + // Act — build provider and verify the custom instance is resolved + await using var provider = services.BuildServiceProvider(); + var keyGenerator = provider.GetRequiredService(); + + // Assert — the exact custom instance is returned from the container + keyGenerator.ShouldBeSameAs(customKeyGenerator, + "AddCaching(o => o.KeyGenerator = ...) must register the supplied instance as ICacheKeyGenerator"); + + // Act — execute a cacheable query end-to-end to prove the custom generator was used + var queryProcessor = provider.GetRequiredService(); + var query = new CacheTestQuery { Payload = "custom-key-test" }; + await queryProcessor.ExecuteAsync(query); + + // Assert — the custom generator was invoked during pipeline execution + customKeyGenerator.GenerateKeyCallCount.ShouldBe(1, + "the custom ICacheKeyGenerator must be called exactly once when the query flows through the caching decorator"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_backing_cache_is_fusioncache_should_cache_via_di_switch.cs b/test/Paramore.Darker.Caching.Tests/When_backing_cache_is_fusioncache_should_cache_via_di_switch.cs new file mode 100644 index 00000000..f566bcd8 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_backing_cache_is_fusioncache_should_cache_via_di_switch.cs @@ -0,0 +1,84 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ZiggyCreatures.Caching.Fusion; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end pipeline test: proves that the async caching decorator caches through FusionCache +/// when FusionCache is registered as the HybridCache implementation via its DI extension, +/// with no change to the handler or decorator code (FR6, FR7, ADR 0021 — switching is a pure DI choice). +/// +public class FusionCacheBackingTests +{ + [Fact] + public async Task When_backing_cache_is_fusioncache_should_cache_via_di_switch() + { + // Arrange — build a real ServiceProvider. + // The ONLY difference from the Microsoft HybridCache test is that we register + // FusionCache as the HybridCache implementation instead of AddHybridCache(). + // The handler, decorator, and AddCaching() call are completely unchanged. + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + + // Register FusionCache as the HybridCache implementation — pure DI switch, no Darker code change. + services.AddFusionCache() + .AsHybridCache(); + + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(CacheTestQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + var counter = provider.GetRequiredService(); + + var query = new CacheTestQuery { Payload = "fusioncache-test" }; + + // Act — first call: cache miss, factory runs and the handler is invoked + var firstResult = await queryProcessor.ExecuteAsync(query); + + // Assert — handler ran once and the result contains the expected value + firstResult.ShouldNotBeNull(); + firstResult.Value.ShouldBe("fusioncache-test"); + counter.CallCount.ShouldBe(1); + + // Act — second call with the same query: cache hit served by FusionCache + var secondResult = await queryProcessor.ExecuteAsync(query); + + // Assert — cached result returned and handler was not re-invoked + secondResult.ShouldNotBeNull(); + secondResult.Value.ShouldBe("fusioncache-test"); + counter.CallCount.ShouldBe(1, "the handler must not run a second time when FusionCache serves a hit"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_bag_tag_absent_or_not_string_should_store_untagged.cs b/test/Paramore.Darker.Caching.Tests/When_bag_tag_absent_or_not_string_should_store_untagged.cs new file mode 100644 index 00000000..d619c68b --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_bag_tag_absent_or_not_string_should_store_untagged.cs @@ -0,0 +1,153 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end tests proving that when the Bag key +/// is absent, or its value is not a non-empty string (e.g. , empty, +/// whitespace, or a non-string object), the cache entry is stored untagged and no exception is +/// thrown — consistent with the best-effort stance of FR9. +/// +public class UntaggedStorageTests +{ + [Fact] + public async Task When_bag_tag_absent_or_not_string_should_store_untagged() + { + // ───────────────────────────────────────────────────────────────── + // Scenario 1 — CacheTag key is absent from the Bag entirely + // ───────────────────────────────────────────────────────────────── + + // Arrange — no CacheTag entry; Bag is left empty (the default) + var (processor1, counter1, provider1) = BuildProcessor(); + await using (provider1) + { + var query1 = new CacheTestQuery { Payload = "absent-tag-scenario" }; + var context1 = new QueryContext(); // Bag is an empty dictionary by default + + // Act — first call: cache miss; factory runs; handler is invoked + var result1 = await processor1.ExecuteAsync(query1, queryContext: context1); + + // Act — second call with the same query: entry is stored untagged; should be a cache hit + var result2 = await processor1.ExecuteAsync(query1, queryContext: context1); + + // Assert — no exception thrown; caching works; handler ran exactly once + result1.ShouldNotBeNull(); + result2.ShouldNotBeNull(); + result2.Value.ShouldBe(result1.Value); + counter1.CallCount.ShouldBe(1, "second call should be a cache hit; handler must not run again"); + } + + // ───────────────────────────────────────────────────────────────── + // Scenario 2 — CacheTag value is a non-string object (e.g. int 42) + // ───────────────────────────────────────────────────────────────── + + // Arrange — CacheTag key present but value is int, not string + var (processor2, counter2, provider2) = BuildProcessor(); + await using (provider2) + { + var query2 = new CacheTestQuery { Payload = "non-string-tag-scenario" }; + var context2 = new QueryContext + { + Bag = new Dictionary + { + [CacheableQueryAttribute.CacheTag] = 42 // int — not a string; must be ignored + } + }; + + // Act — first call: cache miss; factory runs; entry stored untagged (no throw) + var result3 = await processor2.ExecuteAsync(query2, queryContext: context2); + + // Act — second call with the same query: untagged entry is still a cache hit + var result4 = await processor2.ExecuteAsync(query2, queryContext: context2); + + // Assert — no exception thrown; caching works; handler ran exactly once + result3.ShouldNotBeNull(); + result4.ShouldNotBeNull(); + result4.Value.ShouldBe(result3.Value); + counter2.CallCount.ShouldBe(1, "second call should be a cache hit; handler must not run again"); + } + + // ───────────────────────────────────────────────────────────────── + // Scenario 3 — CacheTag value is a whitespace-only string + // ───────────────────────────────────────────────────────────────── + + // Arrange — CacheTag key present but value is whitespace; treated as absent + var (processor3, counter3, provider3) = BuildProcessor(); + await using (provider3) + { + var query3 = new CacheTestQuery { Payload = "whitespace-tag-scenario" }; + var context3 = new QueryContext + { + Bag = new Dictionary + { + [CacheableQueryAttribute.CacheTag] = " " // whitespace-only — must be treated as absent + } + }; + + // Act — first call: cache miss; factory runs; entry stored untagged (no throw) + var result5 = await processor3.ExecuteAsync(query3, queryContext: context3); + + // Act — second call with the same query: untagged entry is still a cache hit + var result6 = await processor3.ExecuteAsync(query3, queryContext: context3); + + // Assert — no exception thrown; caching works; handler ran exactly once + result5.ShouldNotBeNull(); + result6.ShouldNotBeNull(); + result6.Value.ShouldBe(result5.Value); + counter3.CallCount.ShouldBe(1, "second call should be a cache hit; handler must not run again"); + } + } + + /// + /// Builds a fresh DI container with the full Darker/caching pipeline and returns the + /// processor, call counter, and provider for one isolated scenario. + /// + private static (IQueryProcessor processor, HandlerCallCounter counter, ServiceProvider provider) BuildProcessor() + { + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddHybridCache(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(CacheTestQueryHandlerAsync).Assembly) + .AddCaching(); + + var provider = services.BuildServiceProvider(); + return ( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider + ); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_cache_entry_expires_should_rerun_handler.cs b/test/Paramore.Darker.Caching.Tests/When_cache_entry_expires_should_rerun_handler.cs new file mode 100644 index 00000000..782ca099 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_cache_entry_expires_should_rerun_handler.cs @@ -0,0 +1,94 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end pipeline test: proves that after the configured cache TTL elapses, a +/// subsequent query execution re-runs the handler rather than returning a stale cached +/// result — verifying that +/// maps expirationSeconds to +/// and passes it to every GetOrCreateAsync call. +/// +public class CacheExpiryTests +{ + [Fact] + public async Task When_cache_entry_expires_should_rerun_handler() + { + // Arrange — build a real ServiceProvider with the full Darker DI pipeline. + // The handler uses [CacheableQueryAttributeAsync(1, expirationSeconds: 1)] so the + // cache entry expires after 1 second. + // HandlerCallCounter is a singleton so the same instance is injected into every + // handler invocation and can be resolved for assertions. + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddHybridCache(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(ShortExpiryCacheTestQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + var counter = provider.GetRequiredService(); + + var QUERY = new ShortExpiryCacheTestQuery { Payload = "expiry-test" }; + + // Act — first call: cache miss, the factory runs and the handler is invoked + var firstResult = await queryProcessor.ExecuteAsync(QUERY); + + // Assert — handler ran once and the result contains the expected value + firstResult.ShouldNotBeNull(); + firstResult.Value.ShouldBe("expiry-test"); + counter.CallCount.ShouldBe(1); + + // Act — immediate second call with the same query: cache hit, handler must not run again + var secondResult = await queryProcessor.ExecuteAsync(QUERY); + + // Assert — same result returned from cache and handler was not re-invoked + secondResult.ShouldNotBeNull(); + secondResult.Value.ShouldBe("expiry-test"); + counter.CallCount.ShouldBe(1); + + // Arrange — wait slightly longer than the 1-second TTL so the entry expires + Thread.Sleep(1500); + + // Act — third call after expiry: cache miss, handler must re-run + var thirdResult = await queryProcessor.ExecuteAsync(QUERY); + + // Assert — handler ran a second time and the result is still correct + thirdResult.ShouldNotBeNull(); + thirdResult.Value.ShouldBe("expiry-test"); + counter.CallCount.ShouldBe(2); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_cache_hit_should_skip_inner_decorator.cs b/test/Paramore.Darker.Caching.Tests/When_cache_hit_should_skip_inner_decorator.cs new file mode 100644 index 00000000..7b377907 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_cache_hit_should_skip_inner_decorator.cs @@ -0,0 +1,97 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end pipeline test proving that a cache hit short-circuits the pipeline so +/// that decorators ordered inside the cache decorator are not invoked. +/// +/// Decorator ordering recap: PipelineBuilder.GetDecoratorsAsync sorts attributes +/// OrderByDescending(step), so the highest-step attribute sits closest to the handler +/// (innermost) and the lowest-step attribute is added last and becomes outermost. The cache +/// attribute carries step = 1 (outer); carries +/// step = 2 (inner). On a cache hit the cache decorator returns the cached value without +/// invoking next, so the recording decorator and the handler never run. +/// +/// +public class CacheHitSkipsInnerDecoratorTests +{ + [Fact] + public async Task When_cache_hit_should_skip_inner_decorator() + { + // Arrange — build a real ServiceProvider with AddDarker + AddCaching + HybridCache. + // InnerInvocationRecorder is a singleton so the same instance is injected into the + // recording decorator, the handler, and resolved here for assertions. + // RegisterDecorator registers RecordingDecoratorAsync<,> so the pipeline factory can + // resolve it when the handler's [RecordingDecoratorAttributeAsync(2)] is processed. + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddHybridCache(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(OrderingTestQueryHandlerAsync).Assembly) + .AddCaching() + .RegisterDecorator(typeof(RecordingDecoratorAsync<,>)); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + var recorder = provider.GetRequiredService(); + + var QUERY = new OrderingTestQuery { Payload = "ordering-payload" }; + + // Act — first execution: cache miss; both the inner recording decorator and the handler run + var firstResult = await queryProcessor.ExecuteAsync(QUERY); + + // Assert — on miss: inner decorator ran once, handler ran once, result is correct + firstResult.ShouldNotBeNull(); + firstResult.Value.ShouldBe("ordering-payload"); + recorder.InnerDecoratorCallCount.ShouldBe(1, + "on a cache miss the inner recording decorator must run exactly once"); + recorder.HandlerCallCount.ShouldBe(1, + "on a cache miss the handler must run exactly once"); + + // Act — second execution with the same query: cache hit; result served from cache + var secondResult = await queryProcessor.ExecuteAsync(QUERY); + + // Assert — on hit: inner decorator count stays at 1, handler count stays at 1 + // The cache decorator is outermost (step = 1) and short-circuits `next` on a hit, + // so the recording decorator (step = 2, inner) and the handler are never entered. + secondResult.ShouldNotBeNull(); + secondResult.Value.ShouldBe("ordering-payload"); + recorder.InnerDecoratorCallCount.ShouldBe(1, + "on a cache hit the inner recording decorator must not run — the cache short-circuits next"); + recorder.HandlerCallCount.ShouldBe(1, + "on a cache hit the handler must not run — the cache short-circuits next"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_cache_impl_lacks_tag_support_should_still_cache_query.cs b/test/Paramore.Darker.Caching.Tests/When_cache_impl_lacks_tag_support_should_still_cache_query.cs new file mode 100644 index 00000000..b633d1f5 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_cache_impl_lacks_tag_support_should_still_cache_query.cs @@ -0,0 +1,120 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end test proving that when a implementation lacks +/// tag-based eviction support, a tagged cacheable query still executes successfully: the +/// first call populates the cache, the second call is a hit (handler count 1), and no +/// tagging-related failure surfaces — consistent with FR14's best-effort tagging contract. +/// +public class NoTagSupportCachingTests +{ + [Fact] + public async Task When_cache_impl_lacks_tag_support_should_still_cache_query() + { + // Arrange — real ServiceProvider with the full Darker DI pipeline and a + // NoTagSupportHybridCache registered instead of the standard AddHybridCache(). + const string TAG = "fr14-best-effort-tag"; + + var noTagCache = new NoTagSupportHybridCache(); + + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + + // Register the no-tag-support double as the HybridCache implementation. + // No AddHybridCache() call — this cache silently ignores tags on GetOrCreateAsync + // and is a no-op on RemoveByTagAsync. + services.AddSingleton(noTagCache); + + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(CacheTestQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + var counter = provider.GetRequiredService(); + + var query = new CacheTestQuery { Payload = "no-tag-support-payload" }; + + // Act — first call: cache miss; factory runs; handler is invoked once + var firstResult = await queryProcessor.ExecuteAsync( + query, + queryContext: MakeContext(TAG)); + + // Assert — handler ran once; result echoes the payload; no exception thrown + firstResult.ShouldNotBeNull(); + firstResult.Value.ShouldBe("no-tag-support-payload"); + counter.CallCount.ShouldBe(1); + + // Act — second call with the same query: the entry must be a cache hit even though + // the underlying cache does not support tags (caching still works — FR14) + var secondResult = await queryProcessor.ExecuteAsync( + query, + queryContext: MakeContext(TAG)); + + // Assert — result served from cache; handler NOT invoked again + secondResult.ShouldNotBeNull(); + secondResult.Value.ShouldBe("no-tag-support-payload"); + counter.CallCount.ShouldBe(1, "second call must be a cache hit; handler must not run again"); + + // Act — call RemoveByTagAsync directly to confirm it is a no-op on this implementation + await noTagCache.RemoveByTagAsync(TAG); + + // Act — third call after the attempted (no-op) eviction: still a hit + var thirdResult = await queryProcessor.ExecuteAsync( + query, + queryContext: MakeContext(TAG)); + + // Assert — RemoveByTagAsync was a no-op; entry was NOT evicted; still a cache hit + thirdResult.ShouldNotBeNull(); + thirdResult.Value.ShouldBe("no-tag-support-payload"); + counter.CallCount.ShouldBe(1, "third call must still be a cache hit after no-op tag eviction"); + } + + /// + /// Creates a with the given tag in + /// so the caching decorator attempts to apply it on each execution. + /// + private static QueryContext MakeContext(string tag) => + new() + { + Bag = new Dictionary + { + [CacheableQueryAttribute.CacheTag] = tag + } + }; +} diff --git a/test/Paramore.Darker.Caching.Tests/When_cacheable_key_is_null_or_whitespace_should_throw_configuration_exception.cs b/test/Paramore.Darker.Caching.Tests/When_cacheable_key_is_null_or_whitespace_should_throw_configuration_exception.cs new file mode 100644 index 00000000..4e5d5680 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_cacheable_key_is_null_or_whitespace_should_throw_configuration_exception.cs @@ -0,0 +1,52 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Exceptions; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +public class EmptyCacheableKeyTests +{ + private readonly DefaultCacheKeyGenerator defaultCacheKeyGenerator = new(); + + [Fact] + public void Should_throw_configuration_exception_for_null_empty_and_whitespace_cache_keys() + { + // Arrange + var nullKeyQuery = new QueryWithNullCacheKey(); + var emptyKeyQuery = new QueryWithEmptyCacheKey(); + var whitespaceKeyQuery = new QueryWithWhitespaceCacheKey(); + + // Act & Assert — null cache key + Should.Throw(() => defaultCacheKeyGenerator.GenerateKey(nullKeyQuery)); + + // Act & Assert — empty cache key + Should.Throw(() => defaultCacheKeyGenerator.GenerateKey(emptyKeyQuery)); + + // Act & Assert — whitespace cache key + Should.Throw(() => defaultCacheKeyGenerator.GenerateKey(whitespaceKeyQuery)); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_cacheable_query_executed_through_processor_should_cache_under_its_key.cs b/test/Paramore.Darker.Caching.Tests/When_cacheable_query_executed_through_processor_should_cache_under_its_key.cs new file mode 100644 index 00000000..7ca71e83 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_cacheable_query_executed_through_processor_should_cache_under_its_key.cs @@ -0,0 +1,94 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end pipeline tests proving that the runtime query is IAmCacheable detection +/// in works correctly when a query flows through a real +/// — where the decorator's TQuery is IQuery<TResult> +/// at runtime, not the concrete query type. A directly-instantiated decorator would exercise a +/// resolution path the pipeline never uses (false-green), so these tests drive the full DI stack. +/// +public class IAmCacheablePipelineTests +{ + [Fact] + public async Task When_cacheable_query_executed_through_processor_should_cache_under_its_key() + { + // Arrange — build a real ServiceProvider with AddDarker + AddCaching + HybridCache. + // HandlerCallCounter is a singleton so the same instance is injected into every + // KeyedCacheQueryHandlerAsync and can be resolved for assertions. + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddHybridCache(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(KeyedCacheQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + var counter = provider.GetRequiredService(); + + const string SHARED_KEY = "user-42"; + + // Act — first call: IAmCacheable key "user-42", cache miss, handler must run + var firstQuery = new KeyedCacheQuery { CacheKey = SHARED_KEY, Payload = "alice" }; + var firstResult = await queryProcessor.ExecuteAsync(firstQuery); + + // Assert — handler ran once and result carries the expected payload + firstResult.ShouldNotBeNull(); + firstResult.Value.ShouldBe("alice"); + counter.CallCount.ShouldBe(1); + + // Act — second call with the same IAmCacheable CacheKey: must be a cache hit + var secondQuery = new KeyedCacheQuery { CacheKey = SHARED_KEY, Payload = "alice" }; + var secondResult = await queryProcessor.ExecuteAsync(secondQuery); + + // Assert — result served from cache; handler was not re-invoked (count stays at 1) + secondResult.ShouldNotBeNull(); + secondResult.Value.ShouldBe("alice"); + counter.CallCount.ShouldBe(1, + "A second call with the same IAmCacheable CacheKey must hit the cache without re-running the handler"); + + // Act — third call with a different CacheKey: distinct entry, handler must run again + const string DIFFERENT_KEY = "user-99"; + var differentQuery = new KeyedCacheQuery { CacheKey = DIFFERENT_KEY, Payload = "bob" }; + var differentResult = await queryProcessor.ExecuteAsync(differentQuery); + + // Assert — handler ran for the new key (counter reaches 2); result carries new payload + differentResult.ShouldNotBeNull(); + differentResult.Value.ShouldBe("bob"); + counter.CallCount.ShouldBe(2, + "A call with a different IAmCacheable CacheKey must miss the cache and re-run the handler, proving key drives cache-entry identity"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_cached_query_executed_twice_should_run_handler_once_and_serve_hit.cs b/test/Paramore.Darker.Caching.Tests/When_cached_query_executed_twice_should_run_handler_once_and_serve_hit.cs new file mode 100644 index 00000000..cde85907 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_cached_query_executed_twice_should_run_handler_once_and_serve_hit.cs @@ -0,0 +1,80 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end pipeline test: proves that the async caching decorator runs the handler exactly +/// once on a cache miss and returns the cached result on a subsequent hit — exercising the real +/// so that the runtime-type trap in +/// is exposed rather than hidden. +/// +public class CachingQueryProcessorPipelineTests +{ + [Fact] + public async Task When_cached_query_executed_twice_should_run_handler_once_and_serve_hit() + { + // Arrange — build a real ServiceProvider with the full Darker DI pipeline. + // HandlerCallCounter is a singleton so the same instance is injected into every + // CacheTestQueryHandlerAsync and can be resolved from the provider for assertions. + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddHybridCache(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(CacheTestQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + var counter = provider.GetRequiredService(); + + var QUERY = new CacheTestQuery { Payload = "hello" }; + + // Act — first call: cache miss, the factory runs and the handler is invoked + var firstResult = await queryProcessor.ExecuteAsync(QUERY); + + // Assert — handler ran once and the result contains the expected value + firstResult.ShouldNotBeNull(); + firstResult.Value.ShouldBe("hello"); + counter.CallCount.ShouldBe(1); + + // Act — second call with the same query: cache hit, handler must not run again + var secondResult = await queryProcessor.ExecuteAsync(QUERY); + + // Assert — same result returned from cache and handler was not re-invoked + secondResult.ShouldNotBeNull(); + secondResult.Value.ShouldBe("hello"); + counter.CallCount.ShouldBe(1); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_executed_under_single_threaded_synchronization_context_should_not_deadlock.cs b/test/Paramore.Darker.Caching.Tests/When_executed_under_single_threaded_synchronization_context_should_not_deadlock.cs new file mode 100644 index 00000000..fbc7bdba --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_executed_under_single_threaded_synchronization_context_should_not_deadlock.cs @@ -0,0 +1,99 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; +using System.Threading; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// Proves the sync caching decorator does not deadlock when Execute runs on a thread +/// carrying a single-threaded (classic ASP.NET / UI thread) +/// and the cache resolves asynchronously. +/// +/// +/// Uses , whose GetOrCreateAsync awaits +/// Task.Yield() without ConfigureAwait(false), so it captures the ambient +/// . If the decorator blocks the originating thread +/// while that continuation is queued to the never-pumped context, the call deadlocks — which this +/// test detects via a join timeout rather than hanging the suite. +/// +public class SyncCacheSynchronizationContextTests +{ + [Fact] + public void When_executed_under_single_threaded_synchronization_context_should_not_deadlock() + { + // Arrange — AsyncOnMissHybridCache forces the async slow path that captures the ambient + // SynchronizationContext. + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddSingleton(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(SyncCacheTestQueryHandler).Assembly) + .AddCaching(); + + using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + + var QUERY = new SyncCacheTestQuery { Payload = "sync-context-payload" }; + + string resultValue = null; + Exception error = null; + + // Run Execute on a dedicated thread that carries a non-pumping single-threaded context. + var worker = new Thread(() => + { + SynchronizationContext.SetSynchronizationContext(new NonPumpingSynchronizationContext()); + try + { + resultValue = queryProcessor.Execute(QUERY).Value; + } + catch (Exception ex) + { + error = ex; + } + }) + { + IsBackground = true + }; + + // Act — start the worker and wait with a timeout. A deadlock never completes, so the join + // returns false rather than the suite hanging forever. + worker.Start(); + var completed = worker.Join(TimeSpan.FromSeconds(10)); + + // Assert — Execute completed (no deadlock) and returned the correct result. + completed.ShouldBeTrue("Execute deadlocked under a single-threaded SynchronizationContext"); + error.ShouldBeNull(); + resultValue.ShouldBe("sync-context-payload"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_expiration_seconds_not_positive_should_throw_at_pipeline_build.cs b/test/Paramore.Darker.Caching.Tests/When_expiration_seconds_not_positive_should_throw_at_pipeline_build.cs new file mode 100644 index 00000000..1e1693c0 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_expiration_seconds_not_positive_should_throw_at_pipeline_build.cs @@ -0,0 +1,80 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Exceptions; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end pipeline tests: prove that a [CacheableQueryAsync] attribute with a +/// non-positive expirationSeconds throws at +/// pipeline BUILD time — i.e. before the handler body is ever entered — when +/// +/// validates the supplied value. Both zero and negative values are covered in the single +/// test method so the RALPH-VERIFY filter matches the method name. +/// +public class NonPositiveExpirationSecondsBuildTests +{ + [Fact] + public async Task When_expiration_seconds_not_positive_should_throw_at_pipeline_build() + { + // Arrange — a real QueryProcessor with handlers for both zero and negative expiry values. + // Both ZeroExpiryQueryHandlerAsync and NegativeExpiryQueryHandlerAsync live in this assembly + // and are discovered by AddHandlersFromAssemblies. + // HandlerCallCounter is a singleton shared across all handlers; it must stay at zero + // for both queries to prove the failure happened at pipeline build, not inside the handler. + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddHybridCache(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(ZeroExpiryQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + var counter = provider.GetRequiredService(); + + // Act & Assert — expirationSeconds: 0 throws at pipeline build + await Should.ThrowAsync( + async () => await queryProcessor.ExecuteAsync(new ZeroExpiryQuery())); + + // Assert — handler body was never entered for zero expiry + counter.CallCount.ShouldBe(0); + + // Act & Assert — expirationSeconds: -5 likewise throws at pipeline build + await Should.ThrowAsync( + async () => await queryProcessor.ExecuteAsync(new NegativeExpiryQuery())); + + // Assert — handler body was never entered for negative expiry either + counter.CallCount.ShouldBe(0); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_generating_default_key_should_be_deterministic_and_distinct_per_input.cs b/test/Paramore.Darker.Caching.Tests/When_generating_default_key_should_be_deterministic_and_distinct_per_input.cs new file mode 100644 index 00000000..18fc40d1 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_generating_default_key_should_be_deterministic_and_distinct_per_input.cs @@ -0,0 +1,52 @@ +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +// Test doubles: query with UserId property for the body assertions +file record GetUser(int UserId); + +// Test doubles: two types with identical property shape (int Id) for the collision assertion +file record CollisionQueryA(int Id); +file record CollisionQueryB(int Id); + +public class DefaultCacheKeyGeneratorTests +{ + private readonly DefaultCacheKeyGenerator defaultCacheKeyGenerator = new(); + + [Fact] + public void When_generating_default_key_should_be_deterministic_and_distinct_per_input() + { + //Arrange + var queryWith42 = new GetUser(42); + var queryWith43 = new GetUser(43); + var collisionA = new CollisionQueryA(42); + var collisionB = new CollisionQueryB(42); + + //Act + var key42 = defaultCacheKeyGenerator.GenerateKey(queryWith42); + var key43 = defaultCacheKeyGenerator.GenerateKey(queryWith43); + var keyA = defaultCacheKeyGenerator.GenerateKey(collisionA); + var keyB = defaultCacheKeyGenerator.GenerateKey(collisionB); + var key42Again = defaultCacheKeyGenerator.GenerateKey(queryWith42); + + //Assert + + // Key starts with the query type's FullName + key42.ShouldStartWith(typeof(GetUser).FullName!); + + // Key body is exactly |{"UserId":42} + key42.ShouldEndWith("|{\"UserId\":42}"); + + // Different input value produces a different key body + key43.ShouldEndWith("|{\"UserId\":43}"); + key43.ShouldNotBe(key42); + + // Two different query types with identical property shape and same value produce different keys + // (proven by the Type.FullName prefix — not merely implied) + keyA.ShouldNotBe(keyB); + + // Determinism: calling GenerateKey twice on equal inputs yields the identical string + key42Again.ShouldBe(key42); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_handler_returns_null_should_cache_null_and_serve_hit.cs b/test/Paramore.Darker.Caching.Tests/When_handler_returns_null_should_cache_null_and_serve_hit.cs new file mode 100644 index 00000000..04029030 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_handler_returns_null_should_cache_null_and_serve_hit.cs @@ -0,0 +1,80 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end pipeline test that verifies FR11 negative-caching behaviour: +/// the caching decorator does not special-case a null result. +/// A null returned by the handler on a cache miss is stored as-is by +/// GetOrCreateAsync; a subsequent call with the same key within the +/// expiry window returns the cached null as a hit without re-running the handler. +/// +public class NullResultCachingTests +{ + [Fact] + public async Task When_handler_returns_null_should_cache_null_and_serve_hit() + { + // Arrange — build a real ServiceProvider with AddDarker + AddCaching + HybridCache. + // HandlerCallCounter is a singleton so the same instance is injected into + // NullReturningCacheQueryHandlerAsync and can be resolved here for assertions. + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddHybridCache(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(NullReturningCacheQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + var counter = provider.GetRequiredService(); + + var QUERY = new NullReturningCacheQuery { QueryKey = "null-result-key" }; + + // Act — first call: cache miss; the factory runs and the handler returns null + var firstResult = await queryProcessor.ExecuteAsync(QUERY); + + // Assert — handler ran once and the result is null (the handler always returns null) + firstResult.ShouldBeNull(); + counter.CallCount.ShouldBe(1, "the handler must run exactly once on a cache miss"); + + // Act — second call with the same query: the cached null must be returned as a hit + var secondResult = await queryProcessor.ExecuteAsync(QUERY); + + // Assert — the cached null is returned and the handler was NOT re-invoked + secondResult.ShouldBeNull(); + counter.CallCount.ShouldBe(1, + "the handler must not re-run — the decorator does not special-case null; the cached null is a hit"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_hybrid_cache_not_registered_should_throw_configuration_exception.cs b/test/Paramore.Darker.Caching.Tests/When_hybrid_cache_not_registered_should_throw_configuration_exception.cs new file mode 100644 index 00000000..2e27bf3e --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_hybrid_cache_not_registered_should_throw_configuration_exception.cs @@ -0,0 +1,68 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Exceptions; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end pipeline test: proves that executing a [CacheableQueryAsync]-annotated +/// query when no is registered +/// in DI throws — failing fast rather than silently +/// bypassing the cache (FR12). +/// +public class MissingHybridCacheRegistrationTests +{ + [Fact] + public async Task When_hybrid_cache_not_registered_should_throw_configuration_exception() + { + // Arrange — build a real QueryProcessor with AddCaching() but deliberately WITHOUT AddHybridCache(). + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + // NOTE: services.AddHybridCache() is intentionally omitted to trigger FR12 fail-fast. + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(CacheTestQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + + var query = new CacheTestQuery { Payload = "test" }; + + // Act & Assert — executing a cacheable query without HybridCache registered throws ConfigurationException. + var exception = await Should.ThrowAsync( + async () => await queryProcessor.ExecuteAsync(query)); + + // Assert — the exception message names the missing HybridCache registration. + exception.Message.ShouldContain("HybridCache"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_inspecting_caching_assembly_should_have_no_otel_or_metrics_dependency.cs b/test/Paramore.Darker.Caching.Tests/When_inspecting_caching_assembly_should_have_no_otel_or_metrics_dependency.cs new file mode 100644 index 00000000..1ca52622 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_inspecting_caching_assembly_should_have_no_otel_or_metrics_dependency.cs @@ -0,0 +1,111 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +public class CachingAssemblyDependencyTests +{ + [Fact] + public void When_inspecting_caching_assembly_should_have_no_otel_or_metrics_dependency() + { + // Arrange — locate the caching assembly via its key public type. + // The caching package must not reference any OpenTelemetry assembly; it records + // cache outcomes purely as a core Activity span attribute (ADR 0021, FR7, FR10). + var cachingAssembly = typeof(CacheableQueryDecoratorAsync<,>).Assembly; + + // Act — collect all assembly names recorded in the compiled IL metadata. + var referencedNames = cachingAssembly.GetReferencedAssemblies() + .Select(a => a.Name ?? string.Empty) + .ToArray(); + + // Assert (no OpenTelemetry) — no referenced assembly name starts with "OpenTelemetry". + // This is the load-bearing guard: the caching package must not bring in any OTel + // assembly. The metrics-from-traces subsystem (ADR 0018) lives in + // Paramore.Darker.Extensions.Diagnostics, not in the caching package. + var otelAssemblies = referencedNames + .Where(name => name.StartsWith("OpenTelemetry", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + otelAssemblies.ShouldBeEmpty( + $"Paramore.Darker.Caching must not reference any OpenTelemetry assembly (ADR 0021). " + + $"Found: {string.Join(", ", otelAssemblies)}"); + + // Assert (positive controls) — the guard is inspecting the right assembly. + // These confirm the assembly is really the caching package, not a framework assembly. + // Note: HybridCache types are compiled into Microsoft.Extensions.Caching.Abstractions + // (not the NuGet package name "Microsoft.Extensions.Caching.Hybrid") in the IL metadata. + referencedNames.ShouldContain( + "Paramore.Darker", + "Expected Paramore.Darker.Caching to reference the Darker core assembly."); + + referencedNames.ShouldContain( + "Microsoft.Extensions.Caching.Abstractions", + "Expected Paramore.Darker.Caching to reference Microsoft.Extensions.Caching.Abstractions " + + "(where HybridCache types live in compiled IL)."); + + // Arrange (csproj check) — locate the caching project file relative to the test output. + // AppContext.BaseDirectory = …/test/Paramore.Darker.Caching.Tests/bin/// + // Walking up five levels reaches the repository root. + var repoRoot = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + var csprojPath = Path.Combine( + repoRoot, + "src", + "Paramore.Darker.Caching", + "Paramore.Darker.Caching.csproj"); + + // Act (csproj check) — parse the project file and extract all PackageReference / + // ProjectReference Include values. + File.Exists(csprojPath).ShouldBeTrue($"Caching csproj not found at '{csprojPath}'"); + var doc = XDocument.Load(csprojPath); + var referenceIncludes = doc.Descendants() + .Where(e => e.Name.LocalName is "PackageReference" or "ProjectReference") + .Select(e => e.Attribute("Include")?.Value ?? string.Empty) + .ToArray(); + + // Assert (csproj positive control) — the csproj declares the expected caching package. + // Microsoft.Extensions.Caching.Hybrid is the NuGet package name (even though its types + // compile into Microsoft.Extensions.Caching.Abstractions in the IL). + referenceIncludes.ShouldContain( + "Microsoft.Extensions.Caching.Hybrid", + "Expected Paramore.Darker.Caching.csproj to declare a PackageReference to Microsoft.Extensions.Caching.Hybrid."); + + // Assert (csproj check) — no declared reference contains "OpenTelemetry". + // Complements the IL check: catches a declared-but-unused package reference that the + // compiler would otherwise strip from IL metadata. + var otelReferences = referenceIncludes + .Where(include => include.StartsWith("OpenTelemetry", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + otelReferences.ShouldBeEmpty( + $"Paramore.Darker.Caching csproj must not declare an OpenTelemetry reference (ADR 0021). " + + $"Found: {string.Join(", ", otelReferences)}"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_query_has_indexer_property_should_generate_key_without_throwing.cs b/test/Paramore.Darker.Caching.Tests/When_query_has_indexer_property_should_generate_key_without_throwing.cs new file mode 100644 index 00000000..7b4859f2 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_query_has_indexer_property_should_generate_key_without_throwing.cs @@ -0,0 +1,34 @@ +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +// Test double: a query that exposes an indexer. Reflection returns the indexer among the +// query's public readable properties, but an indexer cannot be read without an index argument. +file sealed class IndexedQuery : IQuery +{ + public int PageSize { get; init; } + + // The indexer — GetProperties() reports this as a readable "Item" property with parameters. + public string this[int index] => index.ToString(); +} + +public class IndexerPropertyKeyTests +{ + private readonly DefaultCacheKeyGenerator defaultCacheKeyGenerator = new(); + + [Fact] + public void When_query_has_indexer_property_should_generate_key_without_throwing() + { + //Arrange + var query = new IndexedQuery { PageSize = 25 }; + + //Act + var key = defaultCacheKeyGenerator.GenerateKey(query); + + //Assert + // The indexer is skipped (it has no readable scalar value); the ordinary PageSize + // property still contributes to the key. + key.ShouldContain("\"PageSize\":25"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_query_has_interface_typed_property_should_generate_distinct_keys_per_concrete_value.cs b/test/Paramore.Darker.Caching.Tests/When_query_has_interface_typed_property_should_generate_distinct_keys_per_concrete_value.cs new file mode 100644 index 00000000..a1c834d0 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_query_has_interface_typed_property_should_generate_distinct_keys_per_concrete_value.cs @@ -0,0 +1,38 @@ +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +// Test doubles: a marker interface whose implementations carry the distinguishing data. +// The interface itself exposes no readable members, so serializing a property against the +// DECLARED interface type would drop that data. +file interface IFilter; + +file record TextFilter(string Text) : IFilter; + +// Test double: a query whose only property is declared as the interface type. Two instances +// differ solely in the concrete filter's data (the search text). +file record SearchQuery(IFilter Filter) : IQuery; + +public class InterfaceTypedPropertyKeyTests +{ + private readonly DefaultCacheKeyGenerator defaultCacheKeyGenerator = new(); + + [Fact] + public void When_query_has_interface_typed_property_should_generate_distinct_keys_per_concrete_value() + { + //Arrange + // Same query type, same concrete filter type — only the filter's data differs. + var searchCats = new SearchQuery(new TextFilter("cats")); + var searchDogs = new SearchQuery(new TextFilter("dogs")); + + //Act + var keyForCats = defaultCacheKeyGenerator.GenerateKey(searchCats); + var keyForDogs = defaultCacheKeyGenerator.GenerateKey(searchDogs); + + //Assert + // The distinguishing data lives on the concrete TextFilter, not on IFilter, so two + // queries that must produce different results MUST NOT collide onto the same cache key. + keyForDogs.ShouldNotBe(keyForCats); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_query_is_cacheable_should_use_its_cache_key.cs b/test/Paramore.Darker.Caching.Tests/When_query_is_cacheable_should_use_its_cache_key.cs new file mode 100644 index 00000000..442c0870 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_query_is_cacheable_should_use_its_cache_key.cs @@ -0,0 +1,36 @@ +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +// Test double: a query that implements IAmCacheable and supplies its own cache key +file record GetCacheableUser(int UserId) : IAmCacheable +{ + public string CacheKey => $"GetUser-{UserId}"; +} + +public class When_query_is_cacheable_should_use_its_cache_key +{ + private readonly DefaultCacheKeyGenerator defaultCacheKeyGenerator = new(); + + [Fact] + public void When_query_implements_IAmCacheable_should_return_its_CacheKey_verbatim() + { + //Arrange + var query = new GetCacheableUser(42); + + //Act + var key = defaultCacheKeyGenerator.GenerateKey(query); + + //Assert + + // The key should be exactly the value returned by IAmCacheable.CacheKey + key.ShouldBe("GetUser-42"); + + // The key must NOT contain the type FullName (proving the default strategy was bypassed) + key.ShouldNotContain(typeof(GetCacheableUser).FullName!); + + // The key must NOT contain the pipe separator used by the default strategy + key.ShouldNotContain("|"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_recording_outcome_should_set_cache_outcome_span_attribute.cs b/test/Paramore.Darker.Caching.Tests/When_recording_outcome_should_set_cache_outcome_span_attribute.cs new file mode 100644 index 00000000..ba01768f --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_recording_outcome_should_set_cache_outcome_span_attribute.cs @@ -0,0 +1,141 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Paramore.Darker.Observability; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end pipeline test proving that the async caching decorator writes +/// paramore.darker.cache.outcome = "miss" on a first-time (miss) execution and +/// "hit" on a subsequent (hit) execution, using only the BCL +/// API — no OpenTelemetry/metrics dependency. +/// Also proves the null-span guard: when no tracer is configured ( +/// is null) the same executions succeed without throwing. +/// +/// +/// Uses to prevent parallel execution with other +/// ActivityListener tests so that a leaked listener cannot corrupt the completed-span list. +/// End-to-end through a real — directly instantiating the decorator +/// would bypass the pipeline that populates and would be a false +/// test (ADR 0021, Implementation Approach step 9). +/// +[Collection("DarkerActivitySource")] +public class CacheOutcomeSpanAttributeTests +{ + private static ActivityListener CreateListener(List completed) + { + var listener = new ActivityListener + { + ShouldListenTo = s => s.Name == DarkerSemanticConventions.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + SampleUsingParentId = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = a => completed.Add(a), + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + [Fact] + public async Task When_recording_outcome_should_set_cache_outcome_span_attribute() + { + // ── Part 1: with tracer — miss sets "miss", subsequent hit sets "hit" ──────────────── + + // Arrange — real QueryProcessor with DarkerTracer so Context.Span is populated. + // The ActivityListener captures spans to the completed list when they stop, so the + // cache-outcome tag (set during ExecuteAsync) is readable after each call returns. + var completed = new List(); + using var tracer = new DarkerTracer(); + using var listener = CreateListener(completed); + + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddSingleton(tracer); + services.AddHybridCache(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(CacheTestQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + + var QUERY = new CacheTestQuery { Payload = "outcome-test" }; + + // Act — first execution: cache miss; the factory runs and the handler is invoked + await queryProcessor.ExecuteAsync(QUERY); + + // Assert — miss span carries "miss" on the cache-outcome tag + completed.Count.ShouldBe(1, "one span should be completed after the miss"); + var missSpan = completed[0]; + missSpan.GetTagItem(DarkerSemanticConventions.CacheOutcome).ShouldBe("miss", + "a cache miss must leave paramore.darker.cache.outcome = \"miss\" on the query span"); + + // Act — second execution with same query: cache hit; the factory does not run + await queryProcessor.ExecuteAsync(QUERY); + + // Assert — hit span carries "hit" on the cache-outcome tag + completed.Count.ShouldBe(2, "a second span should be completed after the hit"); + var hitSpan = completed[1]; + hitSpan.GetTagItem(DarkerSemanticConventions.CacheOutcome).ShouldBe("hit", + "a cache hit must leave paramore.darker.cache.outcome = \"hit\" on the query span"); + + // ── Part 2: no-tracer guard — Context.Span is null, the ?. guard must prevent a throw ─ + + // Arrange — separate provider with no IAmADarkerTracer so Context.Span is null in the decorator + var noTracerServices = new ServiceCollection(); + noTracerServices.AddSingleton(LoggerFactory.Create(_ => { })); + noTracerServices.AddSingleton(); + noTracerServices.AddHybridCache(); + noTracerServices + .AddDarker() + .AddHandlersFromAssemblies(typeof(CacheTestQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var noTracerProvider = noTracerServices.BuildServiceProvider(); + var noTracerProcessor = noTracerProvider.GetRequiredService(); + + var noTracerQuery = new CacheTestQuery { Payload = "no-tracer" }; + + // Act & Assert — miss call (Context.Span is null): must not throw + var noTracerMissResult = await noTracerProcessor.ExecuteAsync(noTracerQuery); + noTracerMissResult.ShouldNotBeNull( + "a cache miss without a tracer must still return a result and not throw"); + + // Act & Assert — hit call (Context.Span is null): must not throw + var noTracerHitResult = await noTracerProcessor.ExecuteAsync(noTracerQuery); + noTracerHitResult.ShouldNotBeNull( + "a cache hit without a tracer must still return a result and not throw"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_result_cannot_be_serialized_should_surface_exception.cs b/test/Paramore.Darker.Caching.Tests/When_result_cannot_be_serialized_should_surface_exception.cs new file mode 100644 index 00000000..70e09245 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_result_cannot_be_serialized_should_surface_exception.cs @@ -0,0 +1,71 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end pipeline test: verifies FR13 — when the configured +/// cannot serialize the handler's result type on a cache miss, the serialization exception +/// surfaces to the caller. The caching decorator must NOT swallow the exception or silently +/// return an uncached result without signal. +/// +public class SerializationFailureTests +{ + [Fact] + public async Task When_result_cannot_be_serialized_should_surface_exception() + { + // Arrange — build a real ServiceProvider with the full Darker DI pipeline. + // A ThrowingHybridCacheSerializer is registered for SerializationFailureResult so + // that HybridCache throws when it attempts to serialize the factory result on a miss. + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddHybridCache() + .AddSerializer(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(SerializationFailureQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + + var QUERY = new SerializationFailureQuery { Payload = "boom" }; + + // Act & Assert — ExecuteAsync must throw the serializer's exception (not swallow it). + // The decorator calls GetOrCreateAsync; on a miss the factory runs, HybridCache + // serializes the result with ThrowingHybridCacheSerializer.Serialize which throws + // NotSupportedException; that exception must propagate out of ExecuteAsync unmodified. + await Should.ThrowAsync( + () => queryProcessor.ExecuteAsync(QUERY)); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_runtime_cache_key_is_empty_should_throw_through_processor.cs b/test/Paramore.Darker.Caching.Tests/When_runtime_cache_key_is_empty_should_throw_through_processor.cs new file mode 100644 index 00000000..34e84570 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_runtime_cache_key_is_empty_should_throw_through_processor.cs @@ -0,0 +1,79 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Exceptions; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end pipeline test proving that executing an query whose +/// CacheKey returns an empty string at runtime surfaces a +/// through the real pipeline, and that the handler never runs (FR4). +/// +/// +/// Because TQuery is IQuery<TResult> at runtime in the Darker pipeline (not the +/// concrete query type), this test drives the full DI stack to prove the runtime +/// query is IAmCacheable detection in fails fast on +/// an empty key when flowing through the real processor — a directly-instantiated decorator would +/// exercise a different resolution path. +/// +public class EmptyRuntimeCacheKeyPipelineTests +{ + [Fact] + public async Task When_runtime_cache_key_is_empty_should_throw_through_processor() + { + // Arrange — real ServiceProvider with AddDarker + AddCaching + HybridCache. + // HandlerCallCounter is a singleton so we can assert after the exception that + // the handler body was never entered. + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddHybridCache(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(EmptyCacheKeyPipelineQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + var counter = provider.GetRequiredService(); + + // The query's IAmCacheable.CacheKey returns "" at runtime — the fail-fast condition (FR4). + var query = new EmptyCacheKeyPipelineQuery(); + + // Act — executing through the processor must surface ConfigurationException. + await Should.ThrowAsync( + async () => await queryProcessor.ExecuteAsync(query)); + + // Assert — the handler never ran; the exception occurred during key generation, before next. + counter.CallCount.ShouldBe(0, + "The handler must not execute when IAmCacheable.CacheKey returns an empty string at runtime"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_sync_cache_value_task_completed_should_return_synchronously.cs b/test/Paramore.Darker.Caching.Tests/When_sync_cache_value_task_completed_should_return_synchronously.cs new file mode 100644 index 00000000..92f11cdf --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_sync_cache_value_task_completed_should_return_synchronously.cs @@ -0,0 +1,83 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end sync pipeline test: proves that the sync caching decorator serves a warm cache +/// entry via the IsCompletedSuccessfully fast path on a +/// returned by HybridCache, returning the cached result without re-running the handler. +/// Also verifies the shared constant value. +/// +public class SyncCacheValueTaskFastPathTests +{ + [Fact] + public void When_sync_cache_value_task_completed_should_return_synchronously() + { + // Arrange — build a real ServiceProvider with the full Darker DI pipeline (sync path). + // HandlerCallCounter is a singleton so the same instance is injected into every + // SyncCacheTestQueryHandler and can be resolved from the provider for assertions. + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddHybridCache(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(SyncCacheTestQueryHandler).Assembly) + .AddCaching(); + + using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + var counter = provider.GetRequiredService(); + + var QUERY = new SyncCacheTestQuery { Payload = "sync-hello" }; + + // Act — first call: cache miss, the factory runs and the handler is invoked + var firstResult = queryProcessor.Execute(QUERY); + + // Assert — handler ran once and the result contains the expected value + firstResult.ShouldNotBeNull(); + firstResult.Value.ShouldBe("sync-hello"); + counter.CallCount.ShouldBe(1); + + // Act — second call with the same query: cache hit, handler must not run again. + // The HybridCache in-memory L1 hit returns a completed ValueTask synchronously, + // so the sync decorator's IsCompletedSuccessfully fast path is taken. + var secondResult = queryProcessor.Execute(QUERY); + + // Assert — same result returned from cache and handler was not re-invoked + secondResult.ShouldNotBeNull(); + secondResult.Value.ShouldBe("sync-hello"); + counter.CallCount.ShouldBe(1); + + // Assert — shared CacheTag constant is the canonical tag value + CacheableQueryAttribute.CacheTag.ShouldBe("Paramore.Darker.Caching.Tag"); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_sync_cache_value_task_not_completed_should_block_and_return.cs b/test/Paramore.Darker.Caching.Tests/When_sync_cache_value_task_not_completed_should_block_and_return.cs new file mode 100644 index 00000000..7b079267 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_sync_cache_value_task_not_completed_should_block_and_return.cs @@ -0,0 +1,82 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end sync pipeline test: proves the blocking fallback branch in +/// CacheableQueryDecorator.Execute — the else path that calls +/// .AsTask().GetAwaiter().GetResult() — when +/// returns a +/// that is not already completed. +/// +/// +/// Uses (registered in place of the real +/// ) which always yields before calling the factory so the +/// returned is never +/// IsCompletedSuccessfully at the call site. +/// +public class SyncCacheValueTaskBlockingFallbackTests +{ + [Fact] + public void When_sync_cache_value_task_not_completed_should_block_and_return() + { + // Arrange — register AsyncOnMissHybridCache instead of AddHybridCache() so that + // GetOrCreateAsync always yields before invoking the factory. This guarantees the + // returned ValueTask is NOT IsCompletedSuccessfully, forcing the else blocking-fallback + // branch in CacheableQueryDecorator.Execute. + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddSingleton(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(SyncCacheTestQueryHandler).Assembly) + .AddCaching(); + + using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + var counter = provider.GetRequiredService(); + + var QUERY = new SyncCacheTestQuery { Payload = "async-miss-payload" }; + + // Act — Execute uses the sync pipeline; the decorator receives a non-completed ValueTask + // from AsyncOnMissHybridCache and blocks via .AsTask().GetAwaiter().GetResult(). + var result = queryProcessor.Execute(QUERY); + + // Assert — the correct result is returned via the blocking fallback path + result.ShouldNotBeNull(); + result.Value.ShouldBe("async-miss-payload"); + + // Assert — the handler ran exactly once; the ValueTask was consumed once (not twice) + counter.CallCount.ShouldBe(1); + } +} diff --git a/test/Paramore.Darker.Caching.Tests/When_tag_supplied_in_bag_should_apply_and_allow_remove_by_tag.cs b/test/Paramore.Darker.Caching.Tests/When_tag_supplied_in_bag_should_apply_and_allow_remove_by_tag.cs new file mode 100644 index 00000000..eb7f4c09 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_tag_supplied_in_bag_should_apply_and_allow_remove_by_tag.cs @@ -0,0 +1,110 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ +#endregion + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Paramore.Darker.Caching.Tests.TestDoubles; +using Paramore.Darker.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +/// +/// End-to-end test proving that a tag placed in under +/// is applied to the cache entry so that +/// with that tag evicts the entry, causing the +/// next execution to be a miss that re-runs the handler. +/// +public class TagBasedEvictionTests +{ + [Fact] + public async Task When_tag_supplied_in_bag_should_apply_and_allow_remove_by_tag() + { + // Arrange — real ServiceProvider with the full Darker DI pipeline and a named tag. + const string TAG = "test-eviction-tag"; + + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory.Create(_ => { })); + services.AddSingleton(); + services.AddHybridCache(); + services + .AddDarker() + .AddHandlersFromAssemblies(typeof(CacheTestQueryHandlerAsync).Assembly) + .AddCaching(); + + await using var provider = services.BuildServiceProvider(); + var queryProcessor = provider.GetRequiredService(); + var hybridCache = provider.GetRequiredService(); + var counter = provider.GetRequiredService(); + + var query = new CacheTestQuery { Payload = "tagged-hello" }; + + // Act — first call: cache miss; factory runs; handler is invoked + var firstResult = await queryProcessor.ExecuteAsync( + query, + queryContext: MakeContext(TAG)); + + // Assert — handler ran once; result echoes the payload + firstResult.ShouldNotBeNull(); + firstResult.Value.ShouldBe("tagged-hello"); + counter.CallCount.ShouldBe(1); + + // Act — second call with the same query: the entry is a cache hit; handler must not run again + var secondResult = await queryProcessor.ExecuteAsync( + query, + queryContext: MakeContext(TAG)); + + // Assert — cached result returned; handler count unchanged at 1 + secondResult.ShouldNotBeNull(); + secondResult.Value.ShouldBe("tagged-hello"); + counter.CallCount.ShouldBe(1); + + // Act — evict the tagged entry then re-execute + await hybridCache.RemoveByTagAsync(TAG); + var thirdResult = await queryProcessor.ExecuteAsync( + query, + queryContext: MakeContext(TAG)); + + // Assert — after tag eviction the entry is gone; cache miss re-runs the handler + thirdResult.ShouldNotBeNull(); + thirdResult.Value.ShouldBe("tagged-hello"); + counter.CallCount.ShouldBe(2); + } + + /// + /// Creates a fresh with the given tag in the Bag so that the + /// caching decorator reads and applies it on each execution. + /// + private static QueryContext MakeContext(string tag) => + new() + { + Bag = new Dictionary + { + [CacheableQueryAttribute.CacheTag] = tag + } + }; +} diff --git a/test/Paramore.Darker.Caching.Tests/When_test_project_bootstrapped_should_run.cs b/test/Paramore.Darker.Caching.Tests/When_test_project_bootstrapped_should_run.cs new file mode 100644 index 00000000..54605d73 --- /dev/null +++ b/test/Paramore.Darker.Caching.Tests/When_test_project_bootstrapped_should_run.cs @@ -0,0 +1,18 @@ +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Caching.Tests; + +public class BootstrapTests +{ + [Fact] + public void When_test_project_bootstrapped_should_run() + { + //Arrange + + //Act + + //Assert + true.ShouldBeTrue(); + } +} diff --git a/test/Paramore.Darker.Core.Tests/When_reading_cache_semantic_conventions_should_expose_cache_names_and_tags.cs b/test/Paramore.Darker.Core.Tests/When_reading_cache_semantic_conventions_should_expose_cache_names_and_tags.cs new file mode 100644 index 00000000..93cce1c1 --- /dev/null +++ b/test/Paramore.Darker.Core.Tests/When_reading_cache_semantic_conventions_should_expose_cache_names_and_tags.cs @@ -0,0 +1,30 @@ +using Paramore.Darker.Observability; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Core.Tests +{ + public class When_reading_cache_semantic_conventions_should_expose_cache_names_and_tags + { + [Fact] + public void When_reading_cache_semantic_conventions_should_expose_cache_names_and_tags_fact() + { + // Arrange — nothing to arrange; constants and sets are static and stateless + + // Act — read all public cache-related members from the conventions class + var cacheOutcome = DarkerSemanticConventions.CacheOutcome; + var cacheRequestsMetricName = DarkerSemanticConventions.CacheRequestsMetricName; + var cacheRequestsAllowedTags = DarkerSemanticConventions.CacheRequestsAllowedTags; + + // Assert — string constants match documented values; allowed-tag set has correct membership + cacheOutcome.ShouldBe("paramore.darker.cache.outcome"); + cacheRequestsMetricName.ShouldBe("paramore.darker.cache.requests"); + + // CacheRequestsAllowedTags: exactly QueryType and CacheOutcome (no high-cardinality keys) + cacheRequestsAllowedTags.Count.ShouldBe(2); + cacheRequestsAllowedTags.ShouldContain(DarkerSemanticConventions.QueryType); + cacheRequestsAllowedTags.ShouldContain(DarkerSemanticConventions.CacheOutcome); + cacheRequestsAllowedTags.ShouldNotContain(DarkerSemanticConventions.QueryId); + } + } +} diff --git a/test/Paramore.Darker.Extensions.Diagnostics.Tests/Paramore.Darker.Extensions.Diagnostics.Tests.csproj b/test/Paramore.Darker.Extensions.Diagnostics.Tests/Paramore.Darker.Extensions.Diagnostics.Tests.csproj index 5b87aa33..00942e38 100644 --- a/test/Paramore.Darker.Extensions.Diagnostics.Tests/Paramore.Darker.Extensions.Diagnostics.Tests.csproj +++ b/test/Paramore.Darker.Extensions.Diagnostics.Tests/Paramore.Darker.Extensions.Diagnostics.Tests.csproj @@ -9,6 +9,8 @@ + + @@ -21,5 +23,7 @@ + + diff --git a/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_adding_darker_instrumentation_should_register_cache_meter_with_toggle.cs b/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_adding_darker_instrumentation_should_register_cache_meter_with_toggle.cs new file mode 100644 index 00000000..024d0005 --- /dev/null +++ b/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_adding_darker_instrumentation_should_register_cache_meter_with_toggle.cs @@ -0,0 +1,127 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenTelemetry.Metrics; +using Paramore.Darker.Extensions.Diagnostics.Observability; +using Paramore.Darker.Observability; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Extensions.Diagnostics.Tests; + +[Collection("DarkerMeter")] +public class AddDarkerInstrumentationCacheMeterToggleTests +{ + /// + /// Minimal that creates bare instances. + /// In production, is registered by the ASP.NET Core / Generic Host + /// infrastructure. This stub fulfils that role in a bare test. + /// + private sealed class TestMeterFactory : IMeterFactory + { + private readonly List _meters = []; + + public Meter Create(MeterOptions options) + { + var meter = new Meter(options.Name, options.Version); + _meters.Add(meter); + return meter; + } + + public void Dispose() + { + foreach (var meter in _meters) + meter.Dispose(); + } + } + + [Fact] + public void When_adding_darker_instrumentation_with_default_should_resolve_cache_meter() + { + //Arrange + var metrics = new List(); + var services = new ServiceCollection(); + + // In production (ASP.NET Core / Generic Host) IMeterFactory is registered by the host. + // Register a test stub so CacheMeter can be activated from DI. + services.TryAddSingleton(); + + services.AddOpenTelemetry() + .WithMetrics(b => b + .AddDarkerInstrumentation() + .AddInMemoryExporter(metrics)); + + using var sp = services.BuildServiceProvider(); + + //Act + // Resolve MeterProvider to activate collection before resolving meters + _ = sp.GetRequiredService(); + var cacheMeter = sp.GetRequiredService(); + + //Assert — the real CacheMeter is registered and is enabled + cacheMeter.ShouldBeOfType(); + cacheMeter.Enabled.ShouldBeTrue(); + } + + [Fact] + public void When_adding_darker_instrumentation_with_cache_metrics_disabled_should_register_noop_cache_meter() + { + //Arrange + var metrics = new List(); + var services = new ServiceCollection(); + + services.TryAddSingleton(); + + services.AddOpenTelemetry() + .WithMetrics(b => b + .AddDarkerInstrumentation(emitCacheMetrics: false) + .AddInMemoryExporter(metrics)); + + using var sp = services.BuildServiceProvider(); + var meterProvider = sp.GetRequiredService(); + + //Act + var cacheMeter = sp.GetRequiredService(); + + //Assert — a no-op meter is registered with Enabled == false + cacheMeter.Enabled.ShouldBeFalse(); + + // Routing a cache-outcome span through the meter records nothing on paramore.darker.cache.requests + using var activity = new Activity("TestQuery caching"); + activity.Start(); + activity.SetTag(DarkerSemanticConventions.CacheOutcome, "hit"); + activity.Stop(); + + cacheMeter.RecordCacheOperation(activity); + meterProvider.ForceFlush(); + + metrics.ShouldNotContain(m => m.Name == DarkerSemanticConventions.CacheRequestsMetricName); + } +} diff --git a/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_cached_query_executed_with_metrics_should_emit_hit_and_miss_counters.cs b/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_cached_query_executed_with_metrics_should_emit_hit_and_miss_counters.cs new file mode 100644 index 00000000..ea424cca --- /dev/null +++ b/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_cached_query_executed_with_metrics_should_emit_hit_and_miss_counters.cs @@ -0,0 +1,261 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using Paramore.Darker.Caching; +using Paramore.Darker.Extensions.DependencyInjection; +using Paramore.Darker.Observability; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Extensions.Diagnostics.Tests; + +/// +/// End-to-end verification (ADR 0021 §Implementation Approach step 8) that executing a cacheable +/// query twice through a real — with tracing and +/// both wired — records +/// exactly one paramore.darker.cache.outcome = "miss" and one +/// paramore.darker.cache.outcome = "hit" measurement on +/// paramore.darker.cache.requests. +/// When the emitCacheMetrics toggle is disabled, no counter is recorded even though +/// results are unaffected (FR10, Resolved Decision 4). +/// +/// +/// Tests are kept single-threaded / sequential so the HybridCache stampede-protection caveat +/// (joined concurrent callers observe ran == false and are counted as hits) cannot skew +/// assertions — the caveat is documented in ADR 0021 Risks and accepted as a metrics-only +/// inaccuracy; it does not affect returned results. +/// +[Collection("DarkerMeter")] +public class CachedQueryMetricsEmissionTests +{ + // ── Test doubles ────────────────────────────────────────────────────────────────────────── + + private sealed class CacheMetricsQuery : IQuery + { + public string Payload { get; init; } = "test-payload"; + } + + /// Thread-safe counter injected into the handler to count invocations. + private sealed class CacheMetricsCallCounter + { + public int CallCount { get; private set; } + public void Increment() => CallCount++; + } + + /// + /// Async handler decorated with so the caching + /// decorator is wired into the pipeline. Increments the shared counter on every handler body + /// entry so tests can assert the exact number of handler runs. + /// + private sealed class CacheMetricsHandlerAsync : QueryHandlerAsync + { + private readonly CacheMetricsCallCounter _counter; + + public CacheMetricsHandlerAsync(CacheMetricsCallCounter counter) + { + _counter = counter; + } + + [CacheableQueryAttributeAsync(1, 60)] + public override Task ExecuteAsync( + CacheMetricsQuery query, + CancellationToken cancellationToken = default) + { + _counter.Increment(); + return Task.FromResult(query.Payload); + } + } + + /// + /// Minimal that creates bare instances. + /// In production the Generic Host supplies this. This stub fulfils that role in a bare + /// so can be activated from DI. + /// + private sealed class TestMeterFactory : IMeterFactory + { + private readonly List _meters = []; + + public Meter Create(MeterOptions options) + { + var meter = new Meter(options.Name, options.Version); + _meters.Add(meter); + return meter; + } + + public void Dispose() + { + foreach (var meter in _meters) + meter.Dispose(); + } + } + + // ── Service provider builder ────────────────────────────────────────────────────────────── + + /// + /// Builds a fully-wired containing: the OpenTelemetry tracer + /// (so query spans are created and the cache.outcome attribute is set), the meter + /// pipeline (so the span attribute drives a counter), HybridCache, and the Darker handler + /// pipeline with the caching decorator. + /// + /// Collector populated by AddInMemoryExporter. + /// Counter shared with the handler for invocation assertions. + /// + /// Mirrors the AddDarkerInstrumentation(emitCacheMetrics) toggle (FR10). + /// + private static ServiceProvider BuildStack( + List metrics, + CacheMetricsCallCounter counter, + bool emitCacheMetrics) + { + var services = new ServiceCollection(); + + // IMeterFactory is provided by the Generic Host in production. Stub it here. + services.TryAddSingleton(); + + // HybridCache needs logging; supply a null-sink factory. + services.AddSingleton(LoggerFactory.Create(_ => { })); + + // WithMetrics BEFORE WithTracing: the meter registrations (IAmADarkerQueryMeter etc.) + // must be visible when the tracer builder's AddDarkerInstrumentation checks for them + // (ConfigureServices callbacks run immediately at call time, not lazily at build time). + services.AddOpenTelemetry() + .WithMetrics(b => b + .AddDarkerInstrumentation(emitCacheMetrics) + .AddInMemoryExporter(metrics)) + .WithTracing(b => b.AddDarkerInstrumentation()); + + // Register HybridCache (in-memory, default Microsoft implementation). + services.AddHybridCache(); + + // Register the shared call counter before building so it can be injected into the handler. + services.AddSingleton(counter); + + // Wire Darker: register the handler manually (avoids assembly scanning the entire test + // project) and add the caching decorator + default cache-key generator. + services + .AddDarker() + .AddAsyncHandlers(registry => + registry.Register()) + .AddCaching(); + + return services.BuildServiceProvider(); + } + + // ── Tests ───────────────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task When_cached_query_executed_with_metrics_should_emit_hit_and_miss_counters() + { + // Arrange + var metrics = new List(); + var counter = new CacheMetricsCallCounter(); + await using var sp = BuildStack(metrics, counter, emitCacheMetrics: true); + + var tracerProvider = sp.GetRequiredService(); + var meterProvider = sp.GetRequiredService(); + var queryProcessor = sp.GetRequiredService(); + + var query = new CacheMetricsQuery { Payload = "hello" }; + + // Act — first execution: cache miss — the factory runs, handler invoked, result cached + var firstResult = await queryProcessor.ExecuteAsync(query); + + // Act — second execution: cache hit — factory does NOT run, handler NOT invoked + var secondResult = await queryProcessor.ExecuteAsync(query); + + // Flush spans through DarkerMetricsFromTracesProcessor and then flush the counter + tracerProvider.ForceFlush(); + meterProvider.ForceFlush(); + + // Assert — results are correct + firstResult.ShouldBe("hello"); + secondResult.ShouldBe("hello"); + counter.CallCount.ShouldBe(1); // handler ran exactly once (on the miss) + + // Assert — paramore.darker.cache.requests has exactly one miss point and one hit point + var cacheMetric = metrics.Single(m => m.Name == DarkerSemanticConventions.CacheRequestsMetricName); + + var missSum = 0L; + var hitSum = 0L; + + foreach (var point in cacheMetric.GetMetricPoints()) + { + foreach (var tag in point.Tags) + { + if (tag.Key == DarkerSemanticConventions.CacheOutcome) + { + if (tag.Value is "miss") missSum += point.GetSumLong(); + else if (tag.Value is "hit") hitSum += point.GetSumLong(); + } + } + } + + missSum.ShouldBe(1L, "first execution should record exactly one miss"); + hitSum.ShouldBe(1L, "second execution should record exactly one hit"); + } + + [Fact] + public async Task When_cached_query_executed_with_cache_metrics_disabled_should_record_no_cache_counters() + { + // Arrange — same wiring but with the cache-metrics opt-out toggle engaged + var metrics = new List(); + var counter = new CacheMetricsCallCounter(); + await using var sp = BuildStack(metrics, counter, emitCacheMetrics: false); + + var tracerProvider = sp.GetRequiredService(); + var meterProvider = sp.GetRequiredService(); + var queryProcessor = sp.GetRequiredService(); + + var query = new CacheMetricsQuery { Payload = "world" }; + + // Act — first execution: cache miss + var firstResult = await queryProcessor.ExecuteAsync(query); + + // Act — second execution: cache hit + var secondResult = await queryProcessor.ExecuteAsync(query); + + tracerProvider.ForceFlush(); + meterProvider.ForceFlush(); + + // Assert — query results are still correct even though metrics are suppressed + firstResult.ShouldBe("world"); + secondResult.ShouldBe("world"); + counter.CallCount.ShouldBe(1); // handler still ran exactly once (on the miss) + + // Assert — no measurements on paramore.darker.cache.requests (toggle disabled) + metrics.ShouldNotContain(m => m.Name == DarkerSemanticConventions.CacheRequestsMetricName); + } +} diff --git a/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_ending_internal_span_with_cache_outcome_should_dispatch_to_cache_meter.cs b/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_ending_internal_span_with_cache_outcome_should_dispatch_to_cache_meter.cs new file mode 100644 index 00000000..32938f4e --- /dev/null +++ b/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_ending_internal_span_with_cache_outcome_should_dispatch_to_cache_meter.cs @@ -0,0 +1,160 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using Paramore.Darker.Extensions.Diagnostics.Observability; +using Paramore.Darker.Observability; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Extensions.Diagnostics.Tests; + +[Collection("DarkerMeter")] +public class CacheMetricsFromTracesProcessorTests : IDisposable +{ + private readonly List _metrics; + private readonly MeterProvider _meterProvider; + private readonly QueryMeter _queryMeter; + private readonly DbMeter _dbMeter; + private readonly CacheMeter _cacheMeter; + private readonly IAmADarkerTracer _tracer; + private readonly DarkerMetricsFromTracesProcessor _darkerMetricsFromTracesProcessor; + private readonly ActivitySource _activitySource; + private readonly ActivityListener _activityListener; + private readonly SimpleMeterFactory _meterFactory; + + private sealed class SimpleMeterFactory : IMeterFactory + { + private readonly List _meters = new(); + + public Meter Create(MeterOptions options) + { + var meter = new Meter(options.Name, options.Version); + _meters.Add(meter); + return meter; + } + + public void Dispose() + { + foreach (var meter in _meters) + meter.Dispose(); + } + } + + public CacheMetricsFromTracesProcessorTests() + { + _metrics = new List(); + + // Build the MeterProvider BEFORE creating the meters so that the SDK's + // MeterListener is registered and will pick up the counters when they are published. + _meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter(DarkerSemanticConventions.MeterName) + .AddInMemoryExporter(_metrics) + .Build()!; + + _meterFactory = new SimpleMeterFactory(); + _queryMeter = new QueryMeter(_meterFactory, _meterProvider); + _dbMeter = new DbMeter(_meterFactory, _meterProvider); + _cacheMeter = new CacheMeter(_meterFactory, _meterProvider); + + _tracer = new DarkerTracer(); + + _darkerMetricsFromTracesProcessor = new DarkerMetricsFromTracesProcessor(_tracer, _queryMeter, _dbMeter, _cacheMeter); + + _activitySource = new ActivitySource(DarkerSemanticConventions.SourceName); + _activityListener = new ActivityListener + { + ShouldListenTo = s => s.Name == DarkerSemanticConventions.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = _ => { }, + ActivityStopped = _ => { } + }; + ActivitySource.AddActivityListener(_activityListener); + } + + [Fact] + public void When_ending_internal_span_with_cache_outcome_should_dispatch_to_cache_meter() + { + //Arrange + var activity = _activitySource.StartActivity("TestQuery query", ActivityKind.Internal); + activity!.SetTag(DarkerSemanticConventions.QueryType, "TestQuery"); + activity.SetTag(DarkerSemanticConventions.Operation, "query"); + activity.SetTag(DarkerSemanticConventions.CacheOutcome, "miss"); + activity.Stop(); + + //Act + _darkerMetricsFromTracesProcessor.OnEnd(activity); + _meterProvider.ForceFlush(); + + //Assert - Internal span with cache outcome routes to cache meter and query meter + _metrics.ShouldContain(m => m.Name == DarkerSemanticConventions.CacheRequestsMetricName); + _metrics.ShouldContain(m => m.Name == DarkerSemanticConventions.QueryDurationMetricName); + } + + [Fact] + public void When_no_meter_enabled_should_short_circuit_and_not_throw() + { + // Dispose the subscribing provider first so no MeterProvider is listening to paramore.darker. + // This ensures the meters created below report Enabled == false (NFR2 short-circuit guard). + _meterProvider.Dispose(); + + //Arrange - build a provider that does NOT subscribe to paramore.darker → Enabled == false + using var disabledMeterFactory = new SimpleMeterFactory(); + using var disabledProvider = Sdk.CreateMeterProviderBuilder() + .Build()!; + + var disabledQueryMeter = new QueryMeter(disabledMeterFactory, disabledProvider); + var disabledDbMeter = new DbMeter(disabledMeterFactory, disabledProvider); + var disabledCacheMeter = new CacheMeter(disabledMeterFactory, disabledProvider); + + disabledQueryMeter.Enabled.ShouldBeFalse(); + disabledDbMeter.Enabled.ShouldBeFalse(); + disabledCacheMeter.Enabled.ShouldBeFalse(); + + using var disabledProcessor = new DarkerMetricsFromTracesProcessor( + _tracer, disabledQueryMeter, disabledDbMeter, disabledCacheMeter); + + var activity = _activitySource.StartActivity("TestQuery query", ActivityKind.Internal); + activity!.Stop(); + + //Act & Assert - short-circuit must not throw + Should.NotThrow(() => disabledProcessor.OnEnd(activity)); + } + + public void Dispose() + { + _darkerMetricsFromTracesProcessor.Dispose(); + _activityListener.Dispose(); + _activitySource.Dispose(); + _tracer.Dispose(); + _meterFactory.Dispose(); + _meterProvider.Dispose(); + } +} diff --git a/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_ending_span_through_processor_should_dispatch_to_meter_by_activity_kind.cs b/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_ending_span_through_processor_should_dispatch_to_meter_by_activity_kind.cs index 76d2162e..e9ec0821 100644 --- a/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_ending_span_through_processor_should_dispatch_to_meter_by_activity_kind.cs +++ b/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_ending_span_through_processor_should_dispatch_to_meter_by_activity_kind.cs @@ -43,6 +43,7 @@ public class MetricsFromTracesProcessorTests : IDisposable private readonly MeterProvider _meterProvider; private readonly QueryMeter _queryMeter; private readonly DbMeter _dbMeter; + private readonly CacheMeter _cacheMeter; private readonly IAmADarkerTracer _tracer; private readonly DarkerMetricsFromTracesProcessor _processor; private readonly ActivitySource _activitySource; @@ -81,10 +82,11 @@ public MetricsFromTracesProcessorTests() _meterFactory = new SimpleMeterFactory(); _queryMeter = new QueryMeter(_meterFactory, _meterProvider); _dbMeter = new DbMeter(_meterFactory, _meterProvider); + _cacheMeter = new CacheMeter(_meterFactory, _meterProvider); _tracer = new DarkerTracer(); - _processor = new DarkerMetricsFromTracesProcessor(_tracer, _queryMeter, _dbMeter); + _processor = new DarkerMetricsFromTracesProcessor(_tracer, _queryMeter, _dbMeter, _cacheMeter); _activitySource = new ActivitySource(DarkerSemanticConventions.SourceName); _activityListener = new ActivityListener @@ -173,11 +175,13 @@ public void When_neither_meter_enabled_should_short_circuit_and_not_throw() var disabledQueryMeter = new QueryMeter(disabledMeterFactory, disabledProvider); var disabledDbMeter = new DbMeter(disabledMeterFactory, disabledProvider); + var disabledCacheMeter = new CacheMeter(disabledMeterFactory, disabledProvider); disabledQueryMeter.Enabled.ShouldBeFalse(); disabledDbMeter.Enabled.ShouldBeFalse(); + disabledCacheMeter.Enabled.ShouldBeFalse(); - using var disabledProcessor = new DarkerMetricsFromTracesProcessor(_tracer, disabledQueryMeter, disabledDbMeter); + using var disabledProcessor = new DarkerMetricsFromTracesProcessor(_tracer, disabledQueryMeter, disabledDbMeter, disabledCacheMeter); var activity = _activitySource.StartActivity("TestQuery query", ActivityKind.Internal); activity!.Stop(); diff --git a/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_recording_cache_operation_should_record_counter_with_allowed_tags.cs b/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_recording_cache_operation_should_record_counter_with_allowed_tags.cs new file mode 100644 index 00000000..5e62a795 --- /dev/null +++ b/test/Paramore.Darker.Extensions.Diagnostics.Tests/When_recording_cache_operation_should_record_counter_with_allowed_tags.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Linq; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using Paramore.Darker.Extensions.Diagnostics.Observability; +using Paramore.Darker.Observability; +using Shouldly; +using Xunit; + +namespace Paramore.Darker.Extensions.Diagnostics.Tests; + +[Collection("DarkerMeter")] +public class CacheMeterRecordingTests : IDisposable +{ + private readonly List _metrics; + private readonly MeterProvider _meterProvider; + private readonly CacheMeter _cacheMeter; + private readonly ActivitySource _activitySource; + private readonly ActivityListener _activityListener; + private readonly SimpleMeterFactory _meterFactory; + + /// + /// Minimal that creates bare instances. + /// The OTel SDK's picks them up via its MeterListener once + /// the counter instrument is published. + /// + private sealed class SimpleMeterFactory : IMeterFactory + { + private readonly List _meters = new(); + + public Meter Create(MeterOptions options) + { + var meter = new Meter(options.Name, options.Version); + _meters.Add(meter); + return meter; + } + + public void Dispose() + { + foreach (var meter in _meters) + meter.Dispose(); + } + } + + public CacheMeterRecordingTests() + { + _metrics = new List(); + + // Build the MeterProvider BEFORE creating the CacheMeter so that the SDK's + // MeterListener is registered and will pick up the counter when it is published. + _meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter(DarkerSemanticConventions.MeterName) + .AddInMemoryExporter(_metrics) + .Build()!; + + _meterFactory = new SimpleMeterFactory(); + _cacheMeter = new CacheMeter(_meterFactory, _meterProvider); + + _activitySource = new ActivitySource(DarkerSemanticConventions.SourceName); + _activityListener = new ActivityListener + { + ShouldListenTo = s => s.Name == DarkerSemanticConventions.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = _ => { }, + ActivityStopped = _ => { } + }; + ActivitySource.AddActivityListener(_activityListener); + } + + [Fact] + public void When_recording_cache_operation_should_record_counter_with_allowed_tags() + { + //Arrange - activity with CacheOutcome "hit" and querytype; QueryId is high-cardinality and must be excluded + var activity = _activitySource.StartActivity("TestQuery caching", ActivityKind.Internal); + activity!.SetTag(DarkerSemanticConventions.QueryType, "TestQuery"); + activity.SetTag(DarkerSemanticConventions.CacheOutcome, "hit"); + activity.SetTag(DarkerSemanticConventions.QueryId, "some-high-cardinality-id"); + activity.Stop(); + + //Act + _cacheMeter.RecordCacheOperation(activity); + _meterProvider.ForceFlush(); + + //Assert - one measurement on the cache.requests counter with only the allowed tags + var counterMetric = _metrics.Single(m => m.Name == DarkerSemanticConventions.CacheRequestsMetricName); + + var points = new List(); + foreach (var point in counterMetric.GetMetricPoints()) + points.Add(point); + points.Count.ShouldBe(1); + + var tagKeys = new List(); + foreach (var tag in points[0].Tags) + tagKeys.Add(tag.Key); + + // cache.outcome and querytype are allowed and must be present + tagKeys.ShouldContain(DarkerSemanticConventions.CacheOutcome); + tagKeys.ShouldContain(DarkerSemanticConventions.QueryType); + // high-cardinality queryid must be filtered out + tagKeys.ShouldNotContain(DarkerSemanticConventions.QueryId); + } + + [Fact] + public void When_activity_has_no_cache_outcome_tag_should_record_nothing() + { + //Arrange - activity WITHOUT the CacheOutcome tag → RecordCacheOperation is a no-op + var activity = _activitySource.StartActivity("TestQuery caching", ActivityKind.Internal); + activity!.SetTag(DarkerSemanticConventions.QueryType, "TestQuery"); + // Note: CacheOutcome tag intentionally absent + activity.Stop(); + + //Act + _cacheMeter.RecordCacheOperation(activity); + _meterProvider.ForceFlush(); + + //Assert - no measurement recorded; metric must not be present + _metrics.ShouldNotContain(m => m.Name == DarkerSemanticConventions.CacheRequestsMetricName); + } + + [Fact] + public void When_no_meter_provider_subscribes_to_paramore_darker_enabled_should_be_false() + { + // Dispose the subscribing provider first so no MeterProvider is listening to paramore.darker. + _meterProvider.Dispose(); + + //Arrange - build a provider that does NOT subscribe to paramore.darker → Enabled == false + using var disabledMeterFactory = new SimpleMeterFactory(); + using var disabledProvider = Sdk.CreateMeterProviderBuilder().Build()!; + + //Act + var cacheMeter = new CacheMeter(disabledMeterFactory, disabledProvider); + + //Assert + cacheMeter.Enabled.ShouldBeFalse(); + } + + public void Dispose() + { + _activityListener.Dispose(); + _activitySource.Dispose(); + _meterFactory.Dispose(); + _meterProvider.Dispose(); + } +}