You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
OnTopic.AspNetCore.Mvc should have first-class support for server-side output caching for topic pages, driven by CacheProfile, using ASP.NET Core's OutputCache middleware (already used for the sitemap; #112). Any page should be able to opt-into shared server-side caching through its existing CacheProfile topic, resolved by a new TopicOutputCachePolicy.
The legacy IResponseCachingFeature.VaryByQueryKeys coupling—which relies on the now-discouraged ResponseCaching middleware—should be retired, its variances moved into the policy. Cached pages should be invalidated when content changes, both locally and cross-server via the poll-based Refresh() loop, so shared caches never serve stale pages.
Background
Page caching today is limited to client and proxy Cache-Control headers set by TopicResponseCacheAttribute, plus a single server-side hook (VaryByQueryKeys) on the legacy ResponseCaching middleware. Server-side caching cannot be driven from an MVC action filter since UseOutputCache() runs earlier in the pipeline and has already decided whether to serve from or write to the cache before any filter executes. A custom IOutputCachePolicy, which runs inside the middleware, is the only extensibility point that can make that decision. Responsibilities therefore split cleanly: The attribute keeps the client-facing response headers, and the new policy owns the server-side cache.
The decoding of the CacheProfile currently in TopicResponseCacheAttribute should be moved into a shared helper that returns an immutable record, so the header path and the server path always interpret the same profile identically:
Both TopicResponseCacheAttribute (headers) and TopicOutputCachePolicy (server cache) should consume it.
TopicOutputCachePolicy : IOutputCachePolicy
CacheRequestAsync should resolve ITopicRepository from context.HttpContext.RequestServices, resolve the current topic via Load(RouteData), read its CacheProfile (falling back to Configuration:CacheProfiles:Default), and decode it through the shared helper. It must honor four rules to succeed:
Default off: Set EnableOutputCaching = false unless the request is cacheable with a Duration, a cacheable Location, and !NoStore. Most pages resolve to ImplicitDefault, so caching by default would cache every page on the server
Security: Never provide server-side caching for unsafe or personalized responses: Disable on an Authorization header or a non-GET or HEAD method, and rely on the middleware's Set-Cookie, non-200, and no-store defaults, since the cache is shared across all users
Vary by view:FindView() resolves the same URL to different HTML by ?View= query string and the Accept header
The policy should SetVaryByQuery("View") unconditionally
The policy should compute CacheVaryByRules.VaryByValues["accept"] as the media subtype only when Accept is a single concrete media type (no ,, no *), collapsing all other Accept headers to one default bucket
This prevents browsers from negating the cache while giving each serialization format (JSON, XML) its own entry
Expiry and tags: Set ResponseExpirationTimeSpan from Duration, and tag every entry with both a coarse "topic" tag and a per-topic $"topic:{topic.Id}" tag
Registration and application
A new AddTopicOutputCache() should register the policy, applied as a named policy on the MapTopic* route builders for the narrowest scope (i.e., default to disabled). Host wiring (via AddOutputCache() and UseOutputCache() after UseRouting()) is documentation only, as the NuGet distributable doesn't ship a host.
Retiring the legacy VaryByQueryKeys branch
Once the policy owns server-side variance, the IResponseCachingFeature.VaryByQueryKeys branch and its using Microsoft.AspNetCore.ResponseCaching; should be removed from TopicResponseCacheAttribute, with the <remarks> updated to reflect the change. No PackageReference is dropped; the types all live in the shared framework.
Invalidation
Cross-server invalidation is critical: A topic changed on one node must invalidate caches on every node. An invalidation subscriber, registered via AddTopicCacheRefresh(), drives eviction across two mechanisms:
Per-topic eviction: The subscriber should evict $"topic:{id}" on the local write events (TopicSaved, TopicDeleted, TopicMoved, TopicRenamed) and on TopicUpdated (Notify changes from Refresh() via a new TopicUpdated event #151), which the poll-based Refresh() loop raises for each topic it updates based on cross-server saves. This allows unaffected pages to live for their full Duration.
Periodic coarse structural sweep:TopicUpdated reports value-updates to in-memory topics only; it doesn't indicate remote deletes, reorders, or new topics. An infrequent periodic eviction of the coarse "topic" tag, decoupled from per-page Duration, covers those structural changes.
Tasks
Shared CacheProfile decoding
Introduce CacheProfileDecision and the shared decoding helper
Add unit tests for the CacheProfile decoding
Consume CacheProfileDecision from TopicResponseCacheAttribute
TopicOutputCachePolicy and registration
Introduce TopicOutputCachePolicy with security, variance rules, and tags for coarse and per-topic invalidation
Add unit tests for the policy (opt-in, security, and view tests)
Add AddTopicOutputCache() registration
Apply the policy as a named policy on the topic routes
Retire the legacy VaryByQueryKeys branch
Remove the IResponseCachingFeature and VaryByQueryKeys branch from TopicResponseCacheAttribute
Update TopicResponseCacheAttribute tests for the removed branch
OnTopic.AspNetCore.Mvcshould have first-class support for server-side output caching for topic pages, driven byCacheProfile, using ASP.NET Core's OutputCache middleware (already used for the sitemap; #112). Any page should be able to opt-into shared server-side caching through its existingCacheProfiletopic, resolved by a newTopicOutputCachePolicy.The legacy
IResponseCachingFeature.VaryByQueryKeyscoupling—which relies on the now-discouragedResponseCachingmiddleware—should be retired, its variances moved into the policy. Cached pages should be invalidated when content changes, both locally and cross-server via the poll-basedRefresh()loop, so shared caches never serve stale pages.Background
Page caching today is limited to client and proxy
Cache-Controlheaders set byTopicResponseCacheAttribute, plus a single server-side hook (VaryByQueryKeys) on the legacyResponseCachingmiddleware. Server-side caching cannot be driven from an MVC action filter sinceUseOutputCache()runs earlier in the pipeline and has already decided whether to serve from or write to the cache before any filter executes. A customIOutputCachePolicy, which runs inside the middleware, is the only extensibility point that can make that decision. Responsibilities therefore split cleanly: The attribute keeps the client-facing response headers, and the new policy owns the server-side cache.Dependencies
Refresh()as a scheduled service #138 provideAddTopicCacheRefresh()for wiring the invalidation subscriberRefresh()via a newTopicUpdatedevent #151 (as part ofITopicRepositoryEvent improvements #121), which notifiesRefresh()'s changed topics through the newTopicUpdatedevent (see Invalidation)Load(RouteData), and this feature then removes theVaryByQueryKeysbranch ported by that migrationImplementation Notes
Shared
CacheProfiledecodeThe decoding of the
CacheProfilecurrently inTopicResponseCacheAttributeshould be moved into a shared helper that returns an immutable record, so the header path and the server path always interpret the same profile identically:Both
TopicResponseCacheAttribute(headers) andTopicOutputCachePolicy(server cache) should consume it.TopicOutputCachePolicy : IOutputCachePolicyCacheRequestAsyncshould resolveITopicRepositoryfromcontext.HttpContext.RequestServices, resolve the current topic viaLoad(RouteData), read itsCacheProfile(falling back toConfiguration:CacheProfiles:Default), and decode it through the shared helper. It must honor four rules to succeed:EnableOutputCaching = falseunless the request is cacheable with aDuration, a cacheableLocation, and!NoStore. Most pages resolve toImplicitDefault, so caching by default would cache every page on the serverAuthorizationheader or a non-GETorHEADmethod, and rely on the middleware'sSet-Cookie, non-200, andno-storedefaults, since the cache is shared across all usersFindView()resolves the same URL to different HTML by?View=query string and theAcceptheaderSetVaryByQuery("View")unconditionallyCacheVaryByRules.VaryByValues["accept"]as the media subtype only whenAcceptis a single concrete media type (no,, no*), collapsing all otherAcceptheaders to onedefaultbucketResponseExpirationTimeSpanfromDuration, and tag every entry with both a coarse"topic"tag and a per-topic$"topic:{topic.Id}"tagRegistration and application
A new
AddTopicOutputCache()should register the policy, applied as a named policy on theMapTopic*route builders for the narrowest scope (i.e., default to disabled). Host wiring (viaAddOutputCache()andUseOutputCache()afterUseRouting()) is documentation only, as the NuGet distributable doesn't ship a host.Retiring the legacy
VaryByQueryKeysbranchOnce the policy owns server-side variance, the
IResponseCachingFeature.VaryByQueryKeysbranch and itsusing Microsoft.AspNetCore.ResponseCaching;should be removed fromTopicResponseCacheAttribute, with the<remarks>updated to reflect the change. NoPackageReferenceis dropped; the types all live in the shared framework.Invalidation
Cross-server invalidation is critical: A topic changed on one node must invalidate caches on every node. An invalidation subscriber, registered via
AddTopicCacheRefresh(), drives eviction across two mechanisms:$"topic:{id}"on the local write events (TopicSaved,TopicDeleted,TopicMoved,TopicRenamed) and onTopicUpdated(Notify changes fromRefresh()via a newTopicUpdatedevent #151), which the poll-basedRefresh()loop raises for each topic it updates based on cross-server saves. This allows unaffected pages to live for their fullDuration.TopicUpdatedreports value-updates to in-memory topics only; it doesn't indicate remote deletes, reorders, or new topics. An infrequent periodic eviction of the coarse"topic"tag, decoupled from per-pageDuration, covers those structural changes.Tasks
Shared
CacheProfiledecodingCacheProfileDecisionand the shared decoding helperCacheProfiledecodingCacheProfileDecisionfromTopicResponseCacheAttributeTopicOutputCachePolicyand registrationTopicOutputCachePolicywith security, variance rules, and tags for coarse and per-topic invalidationAddTopicOutputCache()registrationRetire the legacy
VaryByQueryKeysbranchIResponseCachingFeatureandVaryByQueryKeysbranch fromTopicResponseCacheAttributeTopicResponseCacheAttributetests for the removed branchCross-server invalidation
$"topic:{id}"on the local write events and onTopicUpdated(Notify changes fromRefresh()via a newTopicUpdatedevent #151)"topic"structural sweep, decoupled from per-pageDurationAddTopicCacheRefresh()