Spec 014: Caching decorator — requirements, ADR 0021, and task breakdown (#291)#366
Merged
Conversation
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
requested review from
DevJonny,
holytshirt and
preardon
as code owners
July 21, 2026 14:03
- 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>
- 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>
Member
Author
Code review follow-up — correctness fixes to the caching decoratorRan 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).
Notes
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Planning and design phase for the query caching decorator (issue #291) — a declarative
[CacheableQuery]pipeline stage built on Microsoft'sHybridCache. 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 overHybridCachewithGetOrCreateAsyncas the pipeline short-circuit; pluggableICacheKeyGenerator; 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, diagnosticsCacheMeter), the runtime-type key-generation trap with mandatory end-to-endQueryProcessortests, and every fail-fast/boundary path.review-requirements.md,review-design.md, andreview-tasks.md(adversarial review reached PASS on round 3).Design highlights baked into the tasks
IQuery<TResult>, so key/IAmCacheabledetection reflects onquery.GetType(); each core behaviour is proven end-to-end through a realQueryProcessor(guards against the ADR 0020 false-green).Paramore.Darker.Cachingrecords only a coreActivityspan attribute; the counter is derived inParamore.Darker.Extensions.Diagnostics; convention constants live in core.HybridCache, and null/empty runtimeCacheKeyall surfaceConfigurationException.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