Skip to content

Spec 014: Caching decorator — requirements, ADR 0021, and task breakdown (#291)#366

Merged
iancooper merged 39 commits into
masterfrom
caching-queries
Jul 23, 2026
Merged

Spec 014: Caching decorator — requirements, ADR 0021, and task breakdown (#291)#366
iancooper merged 39 commits into
masterfrom
caching-queries

Conversation

@iancooper

Copy link
Copy Markdown
Member

Summary

Planning and design phase for the query caching decorator (issue #291) — a declarative [CacheableQuery] pipeline stage built on Microsoft's HybridCache. This PR contains specification artifacts only; no implementation code yet — it establishes the approved requirements, the accepted architecture, and the reviewed task breakdown that the implementation will follow.

What's here

  • docs/adr/0021-caching-decorator-architecture.md (Accepted) — the single architectural decision: concrete decorator over HybridCache with GetOrCreateAsync as the pipeline short-circuit; pluggable ICacheKeyGenerator; TTL-only lifetime plus a well-known Bag-key tag seam; hit/miss metrics derived from traces (reusing the ADR 0018 subsystem) under an independent opt-out toggle; net8.0/net9.0-only packaging.
  • specs/014-Caching-Decorator/requirements.md (Approved) — FR1–FR14 + acceptance criteria.
  • specs/014-Caching-Decorator/ralph-tasks.md (Approved) — 31 dependency-ordered, unattended-TDD tasks covering the three-package split (caching decorator with no OTel dependency, core convention constants, diagnostics CacheMeter), the runtime-type key-generation trap with mandatory end-to-end QueryProcessor tests, and every fail-fast/boundary path.
  • Review recordsreview-requirements.md, review-design.md, and review-tasks.md (adversarial review reached PASS on round 3).

Design highlights baked into the tasks

  • Runtime-type key generation — decorators are closed over IQuery<TResult>, so key/IAmCacheable detection reflects on query.GetType(); each core behaviour is proven end-to-end through a real QueryProcessor (guards against the ADR 0020 false-green).
  • Three-package splitParamore.Darker.Caching records only a core Activity span attribute; the counter is derived in Paramore.Darker.Extensions.Diagnostics; convention constants live in core.
  • Fail-fast — non-positive expiry (at pipeline build), missing HybridCache, and null/empty runtime CacheKey all surface ConfigurationException.
  • Switchable backing cache — Microsoft HybridCache ↔ FusionCache purely via DI, proven with the real FusionCache package in tests.

Not in this PR

Implementation (the 31 tasks) follows in subsequent work on this branch / a follow-up.

Closes #291 once implementation lands.

🤖 Generated with Claude Code

iancooper and others added 2 commits July 21, 2026 12:21
Add the caching-decorator design for spec 014 (issue #291): a concrete
decorator over Microsoft HybridCache with a step-ordered attribute,
GetOrCreateAsync short-circuit-on-hit, a replaceable ICacheKeyGenerator,
Bag-key tag eviction, and hit/miss counters reusing the ADR 0018
metrics-from-traces subsystem. Includes requirements, adversarial review
records (round 1 NEEDS WORK -> round 2 PASS), and the spec tracking files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generate ralph-tasks.md (31 unattended TDD tasks) directly from the
approved ADR 0021, covering the three-package split (caching decorator,
core convention constants, diagnostics CacheMeter), the runtime-type
key-generation trap with mandatory end-to-end QueryProcessor tests, and
all FR1-FR14 fail-fast/boundary paths. Includes the tasks-approved
marker and the round-3 PASS adversarial review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iancooper and others added 25 commits July 21, 2026 18:09
- Test: none (Central Package Management scaffolding)
- Implementation: pinned stable 9.x prerequisite for Paramore.Darker.Caching
- Ralph task: 1/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: none (new csproj + solution wiring)
- Implementation: new caching source project referencing core + HybridCache, wired into Darker.slnx and Darker.Filter.slnf; no OpenTelemetry dependency
- Ralph task: 2/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_test_project_bootstrapped_should_run
- Implementation: new test project (net8.0;net9.0) referencing caching + DI extensions, wired into Darker.Filter.slnf and Darker.slnx
- Ralph task: 3/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_reading_cache_semantic_conventions_should_expose_cache_names_and_tags
- Implementation: CacheOutcome, CacheRequestsMetricName, and CacheRequestsAllowedTags (FrozenSet/HashSet) on DarkerSemanticConventions
- Ralph task: 4/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_generating_default_key_should_be_deterministic_and_distinct_per_input
- Implementation: ICacheKeyGenerator, IAmCacheable, DefaultCacheKeyGenerator reflecting on query.GetType() with ordinal-ordered invariant JSON
- Ralph task: 5/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_query_is_cacheable_should_use_its_cache_key
- Implementation: return IAmCacheable.CacheKey verbatim before the default type+JSON strategy
- Ralph task: 6/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_cacheable_key_is_null_or_whitespace_should_throw_configuration_exception
- Implementation: throw ConfigurationException when a runtime IAmCacheable.CacheKey is null/empty/whitespace
- Ralph task: 7/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Rename _sut to defaultCacheKeyGenerator in Paramore.Darker.Caching.Tests
- Document the convention with a bullet in .agent_instructions/testing.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Test: When_cached_query_executed_twice_should_run_handler_once_and_serve_hit
- Implementation: CacheableQueryAttributeAsync + CacheableQueryDecoratorAsync (HybridCache, runtime-object key) + AddCaching; end-to-end through a real QueryProcessor
- Ralph task: 8/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Add MIT licence headers to DefaultCacheKeyGenerator, IAmCacheable, ICacheKeyGenerator
- Extract WriteProperty method in BuildOrderedJson to express intent and flatten nesting
- Rename test class to EmptyCacheableKeyTests
- Move IAmCacheable empty-key test doubles into TestDoubles/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Test: When_cache_entry_expires_should_rerun_handler
- Implementation: expiry->HybridCacheEntryOptions.Expiration mapping (already present from task 8); adds end-to-end expiry coverage + test doubles
- Ralph task: 9/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
…uild

- Test: When_expiration_seconds_not_positive_should_throw_at_pipeline_build
- Implementation: InitializeFromAttributeParams throws ConfigurationException when expirationSeconds <= 0, before the handler is dispatched
- Ralph task: 10/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_hybrid_cache_not_registered_should_throw_configuration_exception
- Implementation: decorator resolves HybridCache via GetService and throws ConfigurationException naming the required registration when absent
- Ralph task: 11/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_cacheable_query_executed_through_processor_should_cache_under_its_key
- Implementation: end-to-end coverage proving the runtime IAmCacheable path works through the real pipeline (TQuery is IQuery<TResult>); same key serves a hit, differing keys are distinct entries
- Ralph task: 12/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_runtime_cache_key_is_empty_should_throw_through_processor
- Implementation: end-to-end coverage of the FR4 fail-fast path; an IAmCacheable query with an empty CacheKey surfaces ConfigurationException through the real QueryProcessor and the handler never runs
- Ralph task: 13/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_cache_hit_should_skip_inner_decorator
- Implementation: end-to-end ordering/short-circuit coverage; the cache decorator ordered outer (higher step) serves a hit that skips both the inner recording decorator and the handler (each stays at count 1)
- Ralph task: 14/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Add <remarks> to CacheableQueryAttributeAsync explaining a cache hit short-circuits the pipeline, so decorators ordered inside (lower step) are skipped on a hit, with step-placement guidance
- Add Paramore.Darker.Caching package README with an "Ordering matters" section
- Ralph task: 15/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_handler_returns_null_should_cache_null_and_serve_hit
- Implementation: end-to-end coverage proving the decorator does not special-case null; a null handler result is stored and a second call serves the cached null as a hit (handler runs once)
- Ralph task: 16/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_result_cannot_be_serialized_should_surface_exception
- Implementation: end-to-end coverage proving the decorator does not swallow serializer exceptions; a throwing IHybridCacheSerializer on a miss propagates out of ExecuteAsync
- Ralph task: 17/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_recording_outcome_should_set_cache_outcome_span_attribute
- Implementation: add CacheOutcome { Hit, Miss } enum and, after GetOrCreateAsync, write Context.Span?.SetTag(DarkerSemanticConventions.CacheOutcome, hit|miss) using only System.Diagnostics.Activity (no OpenTelemetry dependency); null-span path is guarded
- Ralph task: 18/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
…nously

- Test: When_sync_cache_value_task_completed_should_return_synchronously
- Implementation: add sync CacheableQueryAttribute (shared CacheTag constant, GetDecoratorType -> CacheableQueryDecorator<,>) and CacheableQueryDecorator whose Execute returns valueTask.Result on IsCompletedSuccessfully; AddCaching registers the sync decorator open generic
- Ralph task: 19/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
…alueTask

- Test: When_sync_cache_value_task_not_completed_should_block_and_return
- Implementation: end-to-end coverage forcing the non-completed ValueTask path via an async-on-miss HybridCache double; the sync Execute blocks via AsTask().GetAwaiter().GetResult(), returns the correct result, and consumes the ValueTask exactly once (handler runs once)
- Ralph task: 20/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_tag_supplied_in_bag_should_apply_and_allow_remove_by_tag
- Implementation: shared CacheTagHelper reads Context.Bag[CacheableQueryAttribute.CacheTag] and, when a non-empty string, passes new[] { tag } as GetOrCreateAsync tags in both the async and sync decorators; RemoveByTagAsync then evicts the entry so the next call re-runs the handler
- Ralph task: 21/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
…g (FR9)

- Test: When_bag_tag_absent_or_not_string_should_store_untagged
- Implementation: CacheTagHelper only wraps-and-passes a tag when the Bag value is a non-empty string; absent key, non-string value, or empty/whitespace stores the entry untagged with no throw (best-effort)
- Ralph task: 22/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
…s (FR14)

- Test: When_cache_impl_lacks_tag_support_should_still_cache_query
- Implementation: end-to-end coverage with a HybridCache double that caches but no-ops tag eviction; a tagged cacheable query still populates and serves a hit (handler count 1) and never surfaces a tagging failure
- Ralph task: 23/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
iancooper and others added 12 commits July 23, 2026 00:21
- Test: When_AddCaching_called_should_register_decorators_and_key_generator
- Implementation: add CachingOptions (settable ICacheKeyGenerator) and AddCaching(Action<CachingOptions>) overload; parameterless AddCaching delegates to it (single registration path); a custom KeyGenerator replaces DefaultCacheKeyGenerator without changing the decorator
- Ralph task: 24/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_inspecting_caching_assembly_should_have_no_otel_or_metrics_dependency
- Implementation: dependency-hygiene guard asserting no OpenTelemetry-prefixed referenced assembly (IL) and no OpenTelemetry PackageReference (csproj), with positive controls for the Darker core and Microsoft.Extensions.Caching.Hybrid
- Ralph task: 25/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_backing_cache_is_fusioncache_should_cache_via_di_switch
- Implementation: add test-only ZiggyCreatures.FusionCache pin (CPM + test csproj) and prove services.AddFusionCache().AsHybridCache() replaces AddHybridCache() with zero handler/decorator changes; a cacheable query serves a hit through FusionCache
- Ralph task: 26/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
…ttribute

- Test: When_recording_cache_operation_should_record_counter_with_allowed_tags
- Implementation: add IAmADarkerCacheMeter role and CacheMeter (Counter<long> via IMeterFactory.Create(MeterName), named CacheRequestsMetricName), modelled on QueryMeter; RecordCacheOperation records only when the CacheOutcome tag is present, filtered to CacheRequestsAllowedTags plus service attributes; Enabled short-circuits when no MeterProvider subscribes
- Ralph task: 27/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_ending_internal_span_with_cache_outcome_should_dispatch_to_cache_meter
- Implementation: DarkerMetricsFromTracesProcessor takes IAmADarkerCacheMeter, calls RecordCacheOperation in the Internal branch (coexisting with the query meter; no-op when no cache outcome), and extends the Enabled short-circuit guard; DarkerTracerBuilderExtensions resolves the cache meter (GetService with a NullCacheMeter fallback until task 29 registers it). Updated the existing dispatch test for the new 4-arg constructor
- Ralph task: 28/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
- Test: When_adding_darker_instrumentation_should_register_cache_meter_with_toggle
- Implementation: AddDarkerInstrumentation gains an emitCacheMetrics toggle (default true) and TryAddSingleton<IAmADarkerCacheMeter, CacheMeter>; when disabled it registers NoOpCacheMeter (Enabled == false) so the cache counter is never recorded. Toggle is independent of InstrumentationOptions
- Ralph task: 29/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
…disables

- Test: When_cached_query_executed_with_metrics_should_emit_hit_and_miss_counters
- Implementation: end-to-end wiring (caching decorator span attribute -> DarkerMetricsFromTracesProcessor -> CacheMeter) verified; two sequential executions of a cacheable query record exactly one miss and one hit on paramore.darker.cache.requests; with emitCacheMetrics disabled no counter is recorded while results stay correct. Adds Paramore.Darker.Caching project reference to the diagnostics test project
- Ralph task: 30/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
…green

- Verification-only gate: Darker.Filter.slnf builds (0 errors) and its full test suite passes on net8.0 and net9.0 (349 tests per TFM, 0 failures), no regressions
- Ralph task: 31/31

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
DefaultCacheKeyGenerator serialized each property against its declared
type, so data held behind an interface or abstract base collapsed to
"{}" and distinct queries produced colliding cache keys — serving a
wrong cached result. Serialize against value.GetType() instead, which
captures the concrete value while still writing boxed value types as
JSON primitives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DefaultCacheKeyGenerator reflected over every readable public property,
including indexers. An indexer cannot be read without an index argument,
so prop.GetValue(query) threw TargetParameterCountException and any query
exposing an indexer failed key generation before the cache was consulted.
Exclude indexers from the property set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…deadlock

CacheableQueryDecorator.Execute bridges the async HybridCache to the
sync pipeline by blocking on GetOrCreateAsync. Under a single-threaded
SynchronizationContext (classic ASP.NET request thread, WPF/WinForms UI
thread), the cache's internal continuation is captured onto the
originating thread, which the blocking wait then deadlocks.

Suppress the ambient SynchronizationContext (set it to null) around the
cache call in a try/finally that restores it — the sync-over-async
equivalent of ConfigureAwait(false). Continuations resume on a
thread-pool thread, so the wait cannot deadlock, while the synchronous
L1 fast-path is preserved (no thread-pool hop on hits). Execute is fully
synchronous and restores the context before returning, so the caller
never observes the suppression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The decorators' remarks claimed the state overload was used to "avoid
capturing variables in a closure", but the factory still closes over the
local hit/miss flag. Reword both to state accurately that the overload
passes next/query without capturing them, while the flag is captured so
the outcome can be recorded after the call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@iancooper

Copy link
Copy Markdown
Member Author

Code review follow-up — correctness fixes to the caching decorator

Ran a correctness-focused review over the caching changes on this branch. It surfaced four findings, now each fixed via TDD (failing test first, then minimal fix) and committed to this PR. Full filtered solution is green on net8.0 and net9.0 (26 caching tests + all other suites).

# Severity Finding Fix Commit
CR-1 High (silent wrong result) DefaultCacheKeyGenerator serialized each property against its declared type, so data behind an interface/abstract-typed property collapsed to {} — distinct queries produced colliding cache keys and one query could be served another's cached result. Serialize against value.GetType() (runtime type); boxed value types still emit as JSON primitives (existing determinism test confirms). e399702
CR-2 Medium (crash) Key generation reflected over indexer properties; GetValue with no index arg threw TargetParameterCountException, so any query exposing an indexer failed before the cache was consulted. Exclude indexers from the property set (GetIndexParameters().Length == 0). a5f574c
CR-3 Medium (deadlock) The sync decorator's sync-over-async wait could deadlock under a single-threaded SynchronizationContext (classic ASP.NET / UI thread): the cache's internal continuation is captured onto the originating thread, which the blocking wait then holds. Suppress the ambient SynchronizationContext around the cache call in a try/finally (the sync-over-async equivalent of ConfigureAwait(false)). Continuations resume on the thread pool; the synchronous L1 fast-path is preserved (no thread-pool hop on hits); the caller never observes the suppression. 30c935c
CR-4 Cleanup Both decorators' remarks claimed the state overload was used to "avoid capturing variables in a closure", but the factory still closes over the hit/miss flag. Reworded to describe accurately what is and isn't captured. f36720c

Notes

  • CR-1 is the one worth a second look — it returned wrong data with no error.
  • CR-3 ships a deterministic deadlock regression test (When_executed_under_single_threaded_synchronization_context_should_not_deadlock) using a non-pumping SynchronizationContext double plus a join timeout, so it fails fast rather than hanging CI if the bug ever returns. Context suppression was chosen over a full Task.Run offload after weighing caller-expectation risk — it keeps the L1 hit off the thread pool and leaves any caller-owned TaskScheduler untouched.

@iancooper iancooper self-assigned this Jul 23, 2026
@iancooper iancooper added .NET Pull requests that update .net code 3 - Done Breaking Change V5 labels Jul 23, 2026
@iancooper
iancooper merged commit e41f08e into master Jul 23, 2026
3 of 4 checks passed
@iancooper
iancooper deleted the caching-queries branch July 23, 2026 15:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3 - Done Breaking Change .NET Pull requests that update .net code V5

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add caching decorator with HybridCache and FusionCache support

1 participant