Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Vary + s-maxage to CacheControl directive for efficient CDN caching #6047

Merged
merged 11 commits into from
Sep 26, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,13 @@ public async ValueTask FormatAsync(
response.GetTypedHeaders().CacheControl = cacheControlHeaderValue;
}

if (result.ContextData is not null
&& result.ContextData.TryGetValue(VaryHeaderValue, out var varyValue)
&& varyValue is string varyHeaderValue)
{
response.Headers.Vary = varyHeaderValue;
}

OnWriteResponseHeaders(operationResult, format, response.Headers);

await format.Formatter.FormatAsync(result, response.Body, cancellationToken);
Expand Down
28 changes: 23 additions & 5 deletions src/HotChocolate/Caching/src/Caching/CacheControlAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ namespace HotChocolate.Caching;
public sealed class CacheControlAttribute : DescriptorAttribute
{
private int? _maxAge;
private int? _sharedMaxAge;
private CacheControlScope? _scope;
private bool? _inheritMaxAge;
private string[]? _vary;

public CacheControlAttribute()
{
Expand All @@ -41,16 +43,16 @@ protected internal override void TryConfigure(
switch (descriptor)
{
case IObjectFieldDescriptor objectField:
objectField.CacheControl(_maxAge, _scope, _inheritMaxAge);
objectField.CacheControl(_maxAge, _scope, _inheritMaxAge, _sharedMaxAge, _vary);
break;
case IObjectTypeDescriptor objectType:
objectType.CacheControl(_maxAge, _scope);
objectType.CacheControl(_maxAge, _scope, _sharedMaxAge, _vary);
break;
case IInterfaceTypeDescriptor interfaceType:
interfaceType.CacheControl(_maxAge, _scope);
interfaceType.CacheControl(_maxAge, _scope, _sharedMaxAge, _vary);
break;
case IUnionTypeDescriptor unionType:
unionType.CacheControl(_maxAge, _scope);
unionType.CacheControl(_maxAge, _scope, _sharedMaxAge, _vary);
break;
}
}
Expand All @@ -60,6 +62,12 @@ protected internal override void TryConfigure(
/// </summary>
public int MaxAge { get => _maxAge ?? CacheControlDefaults.MaxAge; set => _maxAge = value; }

/// <summary>
/// The maximum time, in seconds, this resource can be cached on CDNs and other shared caches.
/// If not set, the value of <c>MaxAge</c> is used for shared caches too.
/// </summary>
public int SharedMaxAge { get => _sharedMaxAge ?? 0; set => _sharedMaxAge = value; }

/// <summary>
/// The scope of this resource.
/// </summary>
Expand All @@ -70,12 +78,22 @@ public CacheControlScope Scope
}

/// <summary>
/// Whether this resource should inherit the <c>MaxAge</c>
/// Whether this resource should inherit the <c>MaxAge</c> and <c>SharedMaxAge</c>
/// of its parent.
/// </summary>
public bool InheritMaxAge
{
get => _inheritMaxAge ?? false;
set => _inheritMaxAge = value;
}

/// <summary>
/// List of headers that might affect the value of this resource. Typically, these headers becomes part
/// of the cache key.
/// </summary>
public string[]? Vary
{
get => _vary ?? [];
set => _vary = value;
}
}
115 changes: 102 additions & 13 deletions src/HotChocolate/Caching/src/Caching/CacheControlConstraintsOptimizer.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using HotChocolate.Execution.Processing;
using HotChocolate.Language;
Expand Down Expand Up @@ -26,27 +27,37 @@ public void OptimizeOperation(OperationOptimizerContext context)

var constraints = ComputeCacheControlConstraints(context.CreateOperation());

if (constraints.MaxAge is not null)
if (constraints.MaxAge is not null || constraints.SharedMaxAge is not null)
{
var headerValue = new CacheControlHeaderValue
{
Private = constraints.Scope == CacheControlScope.Private,
MaxAge = TimeSpan.FromSeconds(constraints.MaxAge.Value),
MaxAge = constraints.MaxAge is not null
? TimeSpan.FromSeconds(constraints.MaxAge.Value)
: null,
SharedMaxAge = constraints.SharedMaxAge is not null
? TimeSpan.FromSeconds(constraints.SharedMaxAge.Value)
: null,
};

context.ContextData.Add(
WellKnownContextData.CacheControlConstraints,
new ImmutableCacheConstraints(
constraints.MaxAge.Value,
constraints.Scope));
constraints);

context.ContextData.Add(
WellKnownContextData.CacheControlHeaderValue,
headerValue);
}

if (constraints.Vary is { Length: > 0 })
{
context.ContextData.Add(
WellKnownContextData.VaryHeaderValue,
string.Join(", ", constraints.Vary));
}
}

private static CacheControlConstraints ComputeCacheControlConstraints(
private static ImmutableCacheConstraints ComputeCacheControlConstraints(
IOperation operation)
{
var constraints = new CacheControlConstraints();
Expand All @@ -57,7 +68,28 @@ private static CacheControlConstraints ComputeCacheControlConstraints(
ProcessSelection(rootSelection, constraints, operation);
}

return constraints;
ImmutableArray<string> vary;
if (constraints.Vary is not null)
{
var builder = ImmutableArray.CreateBuilder<string>();

foreach (var value in constraints.Vary.Order(StringComparer.OrdinalIgnoreCase))
{
builder.Add(value.ToLowerInvariant());
}

vary = builder.ToImmutable();
}
else
{
vary = ImmutableArray<string>.Empty;
}

return new ImmutableCacheConstraints(
constraints.MaxAge,
constraints.SharedMaxAge,
constraints.Scope,
vary);
}

private static void ProcessSelection(
Expand All @@ -67,11 +99,13 @@ private static void ProcessSelection(
{
var field = selection.Field;
var maxAgeSet = false;
var sharedMaxAgeSet = false;
var scopeSet = false;
var varySet = false;

ExtractCacheControlDetailsFromDirectives(field.Directives);

if (!maxAgeSet || !scopeSet)
if (!maxAgeSet || !sharedMaxAgeSet || !scopeSet || !varySet)
{
// Either maxAge or scope have not been specified by the @cacheControl
// directive on the field, so we try to infer these details
Expand Down Expand Up @@ -111,13 +145,24 @@ void ExtractCacheControlDetailsFromDirectives(

if (directive is not null)
{
var previousMaxAge = constraints.MaxAge;
if (!maxAgeSet &&
directive.MaxAge.HasValue &&
(!constraints.MaxAge.HasValue || directive.MaxAge < constraints.MaxAge.Value))
directive.MaxAge.HasValue)
{
// The maxAge of the @cacheControl directive is lower
// than the previously lowest maxAge value.
constraints.MaxAge = directive.MaxAge.Value;
// If only max-age has been set, we honor the expected behavior that a CDN
// cannot ever cache longer than this unless s-maxage specifies otherwise.
if (!constraints.MaxAge.HasValue || directive.MaxAge < constraints.MaxAge.Value)
{
constraints.MaxAge = directive.MaxAge.Value;
}

if (!directive.SharedMaxAge.HasValue &&
constraints.SharedMaxAge.HasValue &&
constraints.SharedMaxAge.Value > directive.MaxAge.Value)
{
constraints.SharedMaxAge = directive.MaxAge;
}

maxAgeSet = true;
}
else if (directive.InheritMaxAge == true)
Expand All @@ -127,6 +172,34 @@ void ExtractCacheControlDetailsFromDirectives(
maxAgeSet = true;
}

if (!sharedMaxAgeSet &&
directive.SharedMaxAge.HasValue &&
(!constraints.SharedMaxAge.HasValue || directive.SharedMaxAge < constraints.SharedMaxAge.Value))
{
// The maxAge of the @cacheControl directive is lower
// than the previously lowest maxAge value.
if (!constraints.SharedMaxAge.HasValue &&
previousMaxAge.HasValue &&
previousMaxAge.Value < directive.SharedMaxAge.Value)
{
// If only max-age has been set, we honor the expected behavior that a CDN
// cannot ever cache longer than this unless s-maxage specifies otherwise.
constraints.SharedMaxAge = previousMaxAge.Value;
}
else
{
constraints.SharedMaxAge = directive.SharedMaxAge.Value;
}

sharedMaxAgeSet = true;
}
else if (directive.InheritMaxAge == true)
{
// If inheritMaxAge is set, we keep the
// computed maxAge value as is.
sharedMaxAgeSet = true;
}

if (directive.Scope.HasValue &&
directive.Scope < constraints.Scope)
{
Expand All @@ -135,6 +208,18 @@ void ExtractCacheControlDetailsFromDirectives(
constraints.Scope = directive.Scope.Value;
scopeSet = true;
}

if (directive.Vary is { Length: > 0 })
{
constraints.Vary ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);

foreach (var value in directive.Vary)
{
constraints.Vary.Add(value);
}

varySet = true;
}
}
}
}
Expand Down Expand Up @@ -163,5 +248,9 @@ private sealed class CacheControlConstraints
public CacheControlScope Scope { get; set; } = CacheControlScope.Public;

internal int? MaxAge { get; set; }

internal int? SharedMaxAge { get; set; }

internal HashSet<string>? Vary { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,14 @@

namespace HotChocolate.Caching;

internal sealed class CacheControlTypeInterceptor : TypeInterceptor
internal sealed class CacheControlTypeInterceptor(
ICacheControlOptionsAccessor accessor)
: TypeInterceptor
{
private readonly List<(RegisteredType Type, ObjectTypeDefinition TypeDef)> _types = [];
private readonly ICacheControlOptions _cacheControlOptions;
private readonly ICacheControlOptions _cacheControlOptions = accessor.CacheControl;
private TypeDependency? _cacheControlDependency;

public CacheControlTypeInterceptor(ICacheControlOptionsAccessor accessor)
{
_cacheControlOptions = accessor.CacheControl;
}

public override void OnBeforeCompleteName(
ITypeCompletionContext completionContext,
DefinitionBase definition)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,5 +133,20 @@ private static void ValidateCacheControlOnField(
validationContext.ReportError(error);
}
}

if (directive.SharedMaxAge.HasValue)
{
if (directive.SharedMaxAge.Value < 0)
{
var error = ErrorHelper.CacheControlNegativeSharedMaxAge(obj, field);
validationContext.ReportError(error);
}

if (inheritMaxAge)
{
var error = ErrorHelper.CacheControlBothSharedMaxAgeAndInheritMaxAge(obj, field);
validationContext.ReportError(error);
}
}
}
}
20 changes: 20 additions & 0 deletions src/HotChocolate/Caching/src/Caching/ErrorHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ public static ISchemaError CacheControlNegativeMaxAge(
.SetTypeSystemObject(type)
.Build();

public static ISchemaError CacheControlNegativeSharedMaxAge(
ITypeSystemObject type,
IField field)
=> SchemaErrorBuilder.New()
.SetMessage(
ErrorHelper_CacheControlNegativeSharedMaxAge,
field.Coordinate.ToString())
.SetTypeSystemObject(type)
.Build();

public static ISchemaError CacheControlBothMaxAgeAndInheritMaxAge(
ITypeSystemObject type,
IField field)
Expand All @@ -53,4 +63,14 @@ public static ISchemaError CacheControlBothMaxAgeAndInheritMaxAge(
field.Coordinate.ToString())
.SetTypeSystemObject(type)
.Build();

public static ISchemaError CacheControlBothSharedMaxAgeAndInheritMaxAge(
ITypeSystemObject type,
IField field)
=> SchemaErrorBuilder.New()
.SetMessage(
ErrorHelper_CacheControlBothSharedMaxAgeAndInheritMaxAge,
field.Coordinate.ToString())
.SetTypeSystemObject(type)
.Build();
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Immutable;
using HotChocolate.Caching;

namespace HotChocolate.Types;
Expand All @@ -17,17 +18,32 @@ public static class CacheControlInterfaceTypeDescriptorExtensions
/// <param name="scope">
/// The scope of fields of this type.
/// </param>
/// <param name="sharedMaxAge">
/// The maximum time, in seconds, fields of this
/// type should be cached in a shared cache.
/// </param>
/// <param name="vary">
/// List of headers that might affect the value of this resource.
/// </param>
public static IInterfaceTypeDescriptor CacheControl(
this IInterfaceTypeDescriptor descriptor,
int? maxAge = null, CacheControlScope? scope = null)
int? maxAge = null,
CacheControlScope? scope = null,
int? sharedMaxAge = null,
string[]? vary = null)
{
if (descriptor is null)
{
throw new ArgumentNullException(nameof(descriptor));
}

return descriptor.Directive(
new CacheControlDirective(maxAge, scope));
new CacheControlDirective(
maxAge,
scope,
null,
sharedMaxAge,
vary?.ToImmutableArray()));
}
/// <summary>
/// Specifies the caching rules for this interface type.
Expand Down
Loading
Loading