Skip to content

Commit de1477c

Browse files
aaronburtleCopilotAniruddh25RubenCerna2079
authored
Add header support for caching (#2650)
## Why make this change? Closes #2604 ## What is this change? Inside of the `SqlQueryStructure`, there is a private constructor, that all of the public constructors will call. This private constructor offers a single point where all of the queries will bottleneck, and therefore we add the cache control information to the query structure at this point. Then when we are in the `SqlQueryEngine`, we can check for this cache control information and handle the query appropriately. The cache control options can be found here: #2253 and here: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control#request_directives But for reference, they are `no-cache`, `no-store`, `only-if-cached`, which will mean we do not get from the cache, we do not store in the cache, and if there is a cache miss we return gateway timeout, respectively. ## How was this tested? Run against test suite. ## Sample Request(s) To test you need to include the relevant cache headers in the request, "no-cache", "no-store", or "only-if-cached" --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Aniruddh Munde <[email protected]> Co-authored-by: RubenCerna2079 <[email protected]>
1 parent 4aa9d42 commit de1477c

File tree

8 files changed

+539
-18
lines changed

8 files changed

+539
-18
lines changed

src/Config/ObjectModel/RuntimeConfig.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public record RuntimeConfig
2222

2323
public RuntimeOptions? Runtime { get; init; }
2424

25-
public RuntimeEntities Entities { get; init; }
25+
public virtual RuntimeEntities Entities { get; init; }
2626

2727
public DataSourceFiles? DataSourceFiles { get; init; }
2828

@@ -325,7 +325,7 @@ public RuntimeConfig(string Schema, DataSource DataSource, RuntimeOptions Runtim
325325
/// <param name="dataSourceName">Name of datasource.</param>
326326
/// <returns>DataSource object.</returns>
327327
/// <exception cref="DataApiBuilderException">Not found exception if key is not found.</exception>
328-
public DataSource GetDataSourceFromDataSourceName(string dataSourceName)
328+
public virtual DataSource GetDataSourceFromDataSourceName(string dataSourceName)
329329
{
330330
CheckDataSourceNamePresent(dataSourceName);
331331
return _dataSourceNameToDataSource[dataSourceName];
@@ -430,7 +430,7 @@ Runtime is not null && Runtime.Host is not null
430430
/// <param name="entityName">Name of the entity to check cache configuration.</param>
431431
/// <returns>Number of seconds (ttl) that a cache entry should be valid before cache eviction.</returns>
432432
/// <exception cref="DataApiBuilderException">Raised when an invalid entity name is provided or if the entity has caching disabled.</exception>
433-
public int GetEntityCacheEntryTtl(string entityName)
433+
public virtual int GetEntityCacheEntryTtl(string entityName)
434434
{
435435
if (!Entities.TryGetValue(entityName, out Entity? entityConfig))
436436
{
@@ -464,7 +464,7 @@ public int GetEntityCacheEntryTtl(string entityName)
464464
/// - whether the datasource is SQL and session context is disabled.
465465
/// </summary>
466466
/// <returns>Whether cache operations should proceed.</returns>
467-
public bool CanUseCache()
467+
public virtual bool CanUseCache()
468468
{
469469
bool setSessionContextEnabled = DataSource.GetTypedOptions<MsSqlOptions>()?.SetSessionContext ?? true;
470470
return IsCachingEnabled && !setSessionContextEnabled;

src/Core/Configurations/RuntimeConfigProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ public string ConfigFilePath
117117
/// <returns>The RuntimeConfig instance.</returns>
118118
/// <remark>Dont use this method if environment variable references need to be retained.</remark>
119119
/// <exception cref="DataApiBuilderException">Thrown when the loader is unable to load an instance of the config from its known location.</exception>
120-
public RuntimeConfig GetConfig()
120+
public virtual RuntimeConfig GetConfig()
121121
{
122122
if (_configLoader.RuntimeConfig is not null)
123123
{

src/Core/Resolvers/BaseQueryStructure.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public class BaseQueryStructure
1818
/// <summary>
1919
/// The Entity name associated with this query as appears in the config file.
2020
/// </summary>
21-
public string EntityName { get; set; }
21+
public virtual string EntityName { get; set; }
2222

2323
/// <summary>
2424
/// The alias of the entity as used in the generated query.
@@ -73,7 +73,7 @@ public class BaseQueryStructure
7373
/// DbPolicyPredicates is a string that represents the filter portion of our query
7474
/// in the WHERE Clause added by virtue of the database policy.
7575
/// </summary>
76-
public Dictionary<EntityActionOperation, string?> DbPolicyPredicatesForOperations { get; set; } = new();
76+
public virtual Dictionary<EntityActionOperation, string?> DbPolicyPredicatesForOperations { get; set; } = new();
7777

7878
public const string PARAM_NAME_PREFIX = "@";
7979

@@ -155,7 +155,7 @@ public string CreateTableAlias()
155155
/// <summary>
156156
/// Returns the SourceDefinitionDefinition for the entity(table/view) of this query.
157157
/// </summary>
158-
public SourceDefinition GetUnderlyingSourceDefinition()
158+
public virtual SourceDefinition GetUnderlyingSourceDefinition()
159159
{
160160
return MetadataProvider.GetSourceDefinition(EntityName);
161161
}

src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,20 @@ public class SqlQueryStructure : BaseSqlQueryStructure
9292
/// </summary>
9393
public GroupByMetadata GroupByMetadata { get; private set; }
9494

95+
public virtual string? CacheControlOption { get; set; }
96+
97+
public const string CACHE_CONTROL = "Cache-Control";
98+
99+
public const string CACHE_CONTROL_NO_STORE = "no-store";
100+
101+
public const string CACHE_CONTROL_NO_CACHE = "no-cache";
102+
103+
public const string CACHE_CONTROL_ONLY_IF_CACHED = "only-if-cached";
104+
105+
public HashSet<string> cacheControlHeaderOptions = new(
106+
new[] { CACHE_CONTROL_NO_STORE, CACHE_CONTROL_NO_CACHE, CACHE_CONTROL_ONLY_IF_CACHED },
107+
StringComparer.OrdinalIgnoreCase);
108+
95109
/// <summary>
96110
/// Generate the structure for a SQL query based on GraphQL query
97111
/// information.
@@ -150,6 +164,7 @@ public SqlQueryStructure(
150164
: this(sqlMetadataProvider,
151165
authorizationResolver,
152166
gQLFilterParser,
167+
gQLFilterParser.GetHttpContextFromMiddlewareContext(ctx).Request.Headers,
153168
predicates: null,
154169
entityName: entityName,
155170
counter: counter)
@@ -217,6 +232,7 @@ public SqlQueryStructure(
217232
}
218233

219234
HttpContext httpContext = GraphQLFilterParser.GetHttpContextFromMiddlewareContext(ctx);
235+
220236
// Process Authorization Policy of the entity being processed.
221237
AuthorizationPolicyHelpers.ProcessAuthorizationPolicies(EntityActionOperation.Read, queryStructure: this, httpContext, authorizationResolver, sqlMetadataProvider);
222238

@@ -255,6 +271,7 @@ public SqlQueryStructure(
255271
: this(sqlMetadataProvider,
256272
authorizationResolver,
257273
gQLFilterParser,
274+
httpRequestHeaders: httpContext?.Request.Headers,
258275
predicates: null,
259276
entityName: context.EntityName,
260277
counter: new IncrementingInteger(),
@@ -380,10 +397,10 @@ private SqlQueryStructure(
380397
: this(sqlMetadataProvider,
381398
authorizationResolver,
382399
gQLFilterParser,
400+
gQLFilterParser.GetHttpContextFromMiddlewareContext(ctx).Request.Headers,
383401
predicates: null,
384402
entityName: entityName,
385-
counter: counter
386-
)
403+
counter: counter)
387404
{
388405
_ctx = ctx;
389406
IOutputType outputType = schemaField.Type;
@@ -541,6 +558,7 @@ private SqlQueryStructure(
541558
ISqlMetadataProvider metadataProvider,
542559
IAuthorizationResolver authorizationResolver,
543560
GQLFilterParser gQLFilterParser,
561+
IHeaderDictionary? httpRequestHeaders,
544562
List<Predicate>? predicates = null,
545563
string entityName = "",
546564
IncrementingInteger? counter = null,
@@ -559,6 +577,25 @@ private SqlQueryStructure(
559577
ColumnLabelToParam = new();
560578
FilterPredicates = string.Empty;
561579
OrderByColumns = new();
580+
AddCacheControlOptions(httpRequestHeaders);
581+
}
582+
583+
private void AddCacheControlOptions(IHeaderDictionary? httpRequestHeaders)
584+
{
585+
// Set the cache control based on the request header if it exists.
586+
if (httpRequestHeaders is not null && httpRequestHeaders.TryGetValue(CACHE_CONTROL, out Microsoft.Extensions.Primitives.StringValues cacheControlOption))
587+
{
588+
CacheControlOption = cacheControlOption;
589+
}
590+
591+
if (!string.IsNullOrEmpty(CacheControlOption) &&
592+
!cacheControlHeaderOptions.Contains(CacheControlOption))
593+
{
594+
throw new DataApiBuilderException(
595+
message: "Request Header Cache-Control is invalid: " + CacheControlOption,
596+
statusCode: HttpStatusCode.BadRequest,
597+
subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest);
598+
}
562599
}
563600

564601
/// <summary>

src/Core/Resolvers/SqlQueryEngine.cs

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
using Azure.DataApiBuilder.Core.Services;
1313
using Azure.DataApiBuilder.Core.Services.Cache;
1414
using Azure.DataApiBuilder.Core.Services.MetadataProviders;
15+
using Azure.DataApiBuilder.Service.Exceptions;
1516
using Azure.DataApiBuilder.Service.GraphQLBuilder;
1617
using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
1718
using HotChocolate.Resolvers;
1819
using Microsoft.AspNetCore.Http;
1920
using Microsoft.AspNetCore.Mvc;
2021
using Microsoft.Extensions.Logging;
22+
using ZiggyCreatures.Caching.Fusion;
2123
using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLStoredProcedureBuilder;
2224

2325
namespace Azure.DataApiBuilder.Core.Resolvers
@@ -328,12 +330,13 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta
328330
// We want to avoid caching token metadata because token metadata can change frequently and we want to avoid caching it.
329331
if (!dbPolicyConfigured && entityCacheEnabled)
330332
{
331-
DatabaseQueryMetadata queryMetadata = new(queryText: queryString, dataSource: dataSourceName, queryParameters: structure.Parameters);
332-
JsonElement result = await _cache.GetOrSetAsync<JsonElement>(queryExecutor, queryMetadata, cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName));
333-
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(result);
334-
JsonDocument cacheServiceResponse = JsonDocument.Parse(jsonBytes);
335-
336-
return cacheServiceResponse;
333+
return await GetResultInCacheScenario(
334+
runtimeConfig,
335+
structure,
336+
queryString,
337+
dataSourceName,
338+
queryExecutor
339+
);
337340
}
338341
}
339342

@@ -353,6 +356,82 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta
353356
return response;
354357
}
355358

359+
private async Task<JsonDocument?> GetResultInCacheScenario(RuntimeConfig runtimeConfig, SqlQueryStructure structure, string queryString, string dataSourceName, IQueryExecutor queryExecutor)
360+
{
361+
DatabaseQueryMetadata queryMetadata = new(queryText: queryString, dataSource: dataSourceName, queryParameters: structure.Parameters);
362+
JsonElement? result;
363+
MaybeValue<JsonElement?>? maybeResult;
364+
switch (structure.CacheControlOption?.ToLowerInvariant())
365+
{
366+
// Do not get result from cache even if it exists, still cache result.
367+
case SqlQueryStructure.CACHE_CONTROL_NO_CACHE:
368+
result = await queryExecutor.ExecuteQueryAsync(
369+
sqltext: queryMetadata.QueryText,
370+
parameters: queryMetadata.QueryParameters,
371+
dataReaderHandler: queryExecutor.GetJsonResultAsync<JsonElement>,
372+
httpContext: _httpContextAccessor.HttpContext!,
373+
args: null,
374+
dataSourceName: queryMetadata.DataSource);
375+
_cache.Set<JsonElement?>(
376+
queryMetadata,
377+
cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName),
378+
result);
379+
return ParseResultIntoJsonDocument(result);
380+
381+
// Do not store result even if valid, still get from cache if available.
382+
case SqlQueryStructure.CACHE_CONTROL_NO_STORE:
383+
maybeResult = _cache.TryGet<JsonElement?>(queryMetadata);
384+
// maybeResult is a nullable wrapper so we must check hasValue at outer and inner layer.
385+
if (maybeResult.HasValue && maybeResult.Value.HasValue)
386+
{
387+
result = maybeResult.Value.Value;
388+
}
389+
else
390+
{
391+
result = await queryExecutor.ExecuteQueryAsync(
392+
sqltext: queryMetadata.QueryText,
393+
parameters: queryMetadata.QueryParameters,
394+
dataReaderHandler: queryExecutor.GetJsonResultAsync<JsonElement>,
395+
httpContext: _httpContextAccessor.HttpContext!,
396+
args: null,
397+
dataSourceName: queryMetadata.DataSource);
398+
}
399+
400+
return ParseResultIntoJsonDocument(result);
401+
402+
// Only return query response if it exists in cache, return gateway timeout otherwise.
403+
case SqlQueryStructure.CACHE_CONTROL_ONLY_IF_CACHED:
404+
maybeResult = _cache.TryGet<JsonElement?>(queryMetadata);
405+
// maybeResult is a nullable wrapper so we must check hasValue at outer and inner layer.
406+
if (maybeResult.HasValue && maybeResult.Value.HasValue)
407+
{
408+
result = maybeResult.Value.Value;
409+
}
410+
else
411+
{
412+
throw new DataApiBuilderException(
413+
message: "Header 'only-if-cached' was used but item was not found in cache.",
414+
statusCode: System.Net.HttpStatusCode.GatewayTimeout,
415+
subStatusCode: DataApiBuilderException.SubStatusCodes.ItemNotFound);
416+
}
417+
418+
return ParseResultIntoJsonDocument(result);
419+
420+
default:
421+
result = await _cache.GetOrSetAsync<JsonElement>(
422+
queryExecutor,
423+
queryMetadata,
424+
cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName));
425+
return ParseResultIntoJsonDocument(result);
426+
}
427+
}
428+
429+
private static JsonDocument? ParseResultIntoJsonDocument(JsonElement? result)
430+
{
431+
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(result);
432+
return JsonDocument.Parse(jsonBytes);
433+
}
434+
356435
// <summary>
357436
// Given the SqlExecuteStructure structure, obtains the query text and executes it against the backend.
358437
// Unlike a normal query, result from database may not be JSON. Instead we treat output as SqlMutationEngine does (extract by row).

src/Core/Services/Cache/DabCacheService.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,41 @@ public DabCacheService(IFusionCache cache, ILogger<DabCacheService>? logger, IHt
8181
return result;
8282
}
8383

84+
/// <summary>
85+
/// Try to get cacheValue from the cache with the derived cache key.
86+
/// </summary>
87+
/// <typeparam name="JsonElement">The type of value in the cache</typeparam>
88+
/// <param name="queryMetadata">Metadata used to create a cache key or fetch a response from the database.</param>
89+
/// <returns>JSON Response</returns>
90+
public MaybeValue<JsonElement>? TryGet<JsonElement>(DatabaseQueryMetadata queryMetadata)
91+
{
92+
string cacheKey = CreateCacheKey(queryMetadata);
93+
return _cache.TryGet<JsonElement>(key: cacheKey);
94+
}
95+
96+
/// <summary>
97+
/// Store cacheValue into the cache with the derived cache key.
98+
/// </summary>
99+
/// <typeparam name="JsonElement">The type of value in the cache</typeparam>
100+
/// <param name="queryMetadata">Metadata used to create a cache key or fetch a response from the database.</param>
101+
/// <param name="cacheEntryTtl">Number of seconds the cache entry should be valid before eviction.</param>
102+
/// <param name="cacheValue"">The value to store in the cache.</param>
103+
public void Set<JsonElement>(
104+
DatabaseQueryMetadata queryMetadata,
105+
int cacheEntryTtl,
106+
JsonElement? cacheValue)
107+
{
108+
string cacheKey = CreateCacheKey(queryMetadata);
109+
_cache.Set(
110+
key: cacheKey,
111+
value: cacheValue,
112+
(FusionCacheEntryOptions options) =>
113+
{
114+
options.SetSize(EstimateCacheEntrySize(cacheKey: cacheKey, cacheValue: cacheValue?.ToString()));
115+
options.SetDuration(duration: TimeSpan.FromSeconds(cacheEntryTtl));
116+
});
117+
}
118+
84119
/// <summary>
85120
/// Attempts to fetch response from cache. If there is a cache miss, invoke executeQueryAsync Func to get a response
86121
/// </summary>

src/Core/Services/MetadataProviders/SqlMetadataProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public abstract class SqlMetadataProvider<ConnectionT, DataAdapterT, CommandT> :
8282
/// <summary>
8383
/// Maps an entity name to a DatabaseObject.
8484
/// </summary>
85-
public Dictionary<string, DatabaseObject> EntityToDatabaseObject { get; set; } =
85+
public virtual Dictionary<string, DatabaseObject> EntityToDatabaseObject { get; set; } =
8686
new(StringComparer.InvariantCulture);
8787

8888
protected readonly ILogger<ISqlMetadataProvider> _logger;

0 commit comments

Comments
 (0)