Skip to content

Add server-side output caching for topic pages based on CacheProfile #150

Description

@JeremyCaney

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.

Dependencies

Implementation Notes

Shared CacheProfile decode

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:

internal readonly record struct CacheProfileDecision(
  bool                  IsCacheable,
  int                   Duration,
  ResponseCacheLocation Location,
  bool                  NoStore,
  string?               VaryByHeader,
  string[]              VaryByQueryKeys
);

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

Cross-server invalidation

  • Introduce the output-cache invalidation subscriber, evicting $"topic:{id}" on the local write events and on TopicUpdated (Notify changes from Refresh() via a new TopicUpdated event #151)
    • Add unit tests for local and per-topic eviction
  • Add the periodic coarse "topic" structural sweep, decoupled from per-page Duration
    • Add unit tests for the coarse structural sweep
  • Register the invalidation subscriber with AddTopicCacheRefresh()
  • Add the end-to-end invalidation integration test ensuring per-topic and structural invalidation

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions