Skip to content

Commit 40dab94

Browse files
authored
In-Memory Cache (#1881)
## Why make this change? - Second set of work attributed to #1618 #1617 ## What is this change? - Implements the design in #1801. The one behavior which isn't in this PR is caching GraphQL "list" queries. That will come in a separate commit once i resolve the errors that arise with generics and the DataReaderHandler delegates. - Creates a `DabCacheService` which is used in database queries (REST/GraphQL) such that requests and responses are cached in memory. - This PR is not dependent on #1865. However, once that PR and this PR are merged, a third follow-up PR will make a small adjustment in `SqlQueryEngine.ExecuteAsync(...)` such that the `cacheEntryTtl` and `cacheEnabled` values are resolved from runtime config and used determine whether to use the cache and how to use the cache. ## How was this tested? - [x] Integration Tests -> integration tests included which mock the QueryExecutor and test how the DabCacheService handles request metadata and caching method execution. The integration tests aim to test the **behavior** of the DabCacheService and intentionally do not make private methods public or break out key generation and entry size calculation into separate classes. Additionally, the integration tests are not meant to test the functionality of FusionCache. That library has its own tests which validate functionality. ## Sample Request(s) Until all related PRs are merged, this feature is gated by a feature flag. To add the feature flag, add the following snippet to the `AppConfig.json`: ```json // Define feature flags in config file "FeatureManagement": { "CachingPreview": true, // Turn-On feature } ``` Point requests will be cached with a default 10 second ttl. You can try out whether response times are quicker for your use case.
1 parent ee1f78c commit 40dab94

16 files changed

+679
-16
lines changed

src/Config/FeatureFlagConstants.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Azure.DataApiBuilder.Config;
5+
6+
public static class FeatureFlagConstants
7+
{
8+
public const string INMEMORYCACHE = "InMemoryCachingPreview";
9+
}

src/Config/ObjectModel/Entity.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Diagnostics.CodeAnalysis;
45
using System.Text.Json.Serialization;
56

67
namespace Azure.DataApiBuilder.Config.ObjectModel;
@@ -56,6 +57,7 @@ public Entity(
5657
/// </summary>
5758
/// <returns>Whether caching is enabled for the entity.</returns>
5859
[JsonIgnore]
60+
[MemberNotNullWhen(true, nameof(Cache))]
5961
public bool IsCachingEnabled =>
6062
Cache is not null &&
6163
Cache.Enabled is not null &&

src/Config/ObjectModel/EntityCacheOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Diagnostics.CodeAnalysis;
45
using System.Text.Json.Serialization;
56

67
namespace Azure.DataApiBuilder.Config.ObjectModel;
@@ -56,5 +57,6 @@ public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null)
5657
/// property/value specified would be interpreted by DAB as "user explicitly set ttl."
5758
/// </summary>
5859
[JsonIgnore(Condition = JsonIgnoreCondition.Always)]
60+
[MemberNotNullWhen(true, nameof(TtlSeconds))]
5961
public bool UserProvidedTtlOptions { get; init; } = false;
6062
}

src/Config/ObjectModel/RuntimeConfig.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,66 @@ public bool IsDevelopmentMode() =>
329329
Runtime is not null && Runtime.Host is not null
330330
&& Runtime.Host.Mode is HostMode.Development;
331331

332+
/// <summary>
333+
/// Returns the ttl-seconds value for a given entity.
334+
/// If the property is not set, returns the global default value set in the runtime config.
335+
/// If the global default value is not set, the default value is used (5 seconds).
336+
/// </summary>
337+
/// <param name="entityName">Name of the entity to check cache configuration.</param>
338+
/// <returns>Number of seconds (ttl) that a cache entry should be valid before cache eviction.</returns>
339+
/// <exception cref="DataApiBuilderException">Raised when an invalid entity name is provided or if the entity has caching disabled.</exception>
340+
public int GetEntityCacheEntryTtl(string entityName)
341+
{
342+
if (!Entities.TryGetValue(entityName, out Entity? entityConfig))
343+
{
344+
throw new DataApiBuilderException(
345+
message: $"{entityName} is not a valid entity.",
346+
statusCode: HttpStatusCode.BadRequest,
347+
subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
348+
}
349+
350+
if (!entityConfig.IsCachingEnabled)
351+
{
352+
throw new DataApiBuilderException(
353+
message: $"{entityName} does not have caching enabled.",
354+
statusCode: HttpStatusCode.BadRequest,
355+
subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported);
356+
}
357+
358+
if (entityConfig.Cache.UserProvidedTtlOptions)
359+
{
360+
return entityConfig.Cache.TtlSeconds.Value;
361+
}
362+
else
363+
{
364+
return GlobalCacheEntryTtl();
365+
}
366+
}
367+
368+
/// <summary>
369+
/// Whether the caching service should be used for a given operation. This is determined by
370+
/// - whether caching is enabled globally
371+
/// - whether the datasource is SQL and session context is disabled.
372+
/// </summary>
373+
/// <returns>Whether cache operations should proceed.</returns>
374+
public bool CanUseCache()
375+
{
376+
bool setSessionContextEnabled = DataSource.GetTypedOptions<MsSqlOptions>()?.SetSessionContext ?? true;
377+
return IsCachingEnabled && SqlDataSourceUsed && !setSessionContextEnabled;
378+
}
379+
380+
/// <summary>
381+
/// Returns the ttl-seconds value for the global cache entry.
382+
/// If no value is explicitly set, returns the global default value.
383+
/// </summary>
384+
/// <returns>Number of seconds a cache entry should be valid before cache eviction.</returns>
385+
public int GlobalCacheEntryTtl()
386+
{
387+
return Runtime is not null && Runtime.IsCachingEnabled && Runtime.Cache.UserProvidedTtlOptions
388+
? Runtime.Cache.TtlSeconds.Value
389+
: EntityCacheOptions.DEFAULT_TTL_SECONDS;
390+
}
391+
332392
private void CheckDataSourceNamePresent(string dataSourceName)
333393
{
334394
if (!_dataSourceNameToDataSource.ContainsKey(dataSourceName))

src/Config/ObjectModel/RuntimeOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Diagnostics.CodeAnalysis;
45
using System.Text.Json.Serialization;
56

67
namespace Azure.DataApiBuilder.Config.ObjectModel;
@@ -37,6 +38,7 @@ public RuntimeOptions(
3738
/// </summary>
3839
/// <returns>Whether caching is enabled globally.</returns>
3940
[JsonIgnore]
41+
[MemberNotNullWhen(true, nameof(Cache))]
4042
public bool IsCachingEnabled =>
4143
Cache is not null &&
4244
Cache.Enabled is not null &&

src/Core/Azure.DataApiBuilder.Core.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<PackageReference Include="HotChocolate.AspNetCore.Authorization" />
1616
<PackageReference Include="HotChocolate.Types.NodaTime" />
1717
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
18-
<PackageReference Include="NJsonSchema"/>
18+
<PackageReference Include="NJsonSchema" />
1919
<PackageReference Include="Microsoft.Azure.Cosmos" />
2020
<PackageReference Include="Microsoft.Data.SqlClient" />
2121
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
@@ -34,6 +34,7 @@
3434
<PackageReference Include="Polly" />
3535
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
3636
<PackageReference Include="System.IO.Abstractions" />
37+
<PackageReference Include="ZiggyCreatures.FusionCache" />
3738
</ItemGroup>
3839

3940
<PropertyGroup Condition="'$(TF_BUILD)' == 'true'">
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Azure.DataApiBuilder.Core.Models;
5+
6+
/// <summary>
7+
/// Represents the database query built from a query structure.
8+
/// Contains all query metadata need to create a cache key.
9+
/// </summary>
10+
public class DatabaseQueryMetadata
11+
{
12+
public string QueryText { get; }
13+
public string DataSource { get; }
14+
public Dictionary<string, DbConnectionParam> QueryParameters { get; }
15+
16+
/// <summary>
17+
/// Creates a "Data Transfer Object" (DTO) used for provided query metadata to dependent services.
18+
/// </summary>
19+
/// <param name="queryText">Raw query text built from a query structure object.</param>
20+
/// <param name="dataSource">Name of the data source where the query will execute.</param>
21+
/// <param name="queryParameters">Dictonary of query parameter names and values.</param>
22+
public DatabaseQueryMetadata(string queryText, string dataSource, Dictionary<string, DbConnectionParam> queryParameters)
23+
{
24+
QueryText = queryText;
25+
DataSource = dataSource;
26+
QueryParameters = queryParameters;
27+
}
28+
}

src/Core/Resolvers/Factories/QueryEngineFactory.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Azure.DataApiBuilder.Config.ObjectModel;
77
using Azure.DataApiBuilder.Core.Configurations;
88
using Azure.DataApiBuilder.Core.Models;
9+
using Azure.DataApiBuilder.Core.Services.Cache;
910
using Azure.DataApiBuilder.Core.Services.MetadataProviders;
1011
using Azure.DataApiBuilder.Service.Exceptions;
1112
using Microsoft.AspNetCore.Http;
@@ -29,7 +30,8 @@ public QueryEngineFactory(RuntimeConfigProvider runtimeConfigProvider,
2930
IHttpContextAccessor contextAccessor,
3031
IAuthorizationResolver authorizationResolver,
3132
GQLFilterParser gQLFilterParser,
32-
ILogger<IQueryEngine> logger)
33+
ILogger<IQueryEngine> logger,
34+
DabCacheService cache)
3335
{
3436
_queryEngines = new Dictionary<DatabaseType, IQueryEngine>();
3537

@@ -38,7 +40,7 @@ public QueryEngineFactory(RuntimeConfigProvider runtimeConfigProvider,
3840
if (config.SqlDataSourceUsed)
3941
{
4042
IQueryEngine queryEngine = new SqlQueryEngine(
41-
queryManagerFactory, metadataProviderFactory, contextAccessor, authorizationResolver, gQLFilterParser, logger, runtimeConfigProvider);
43+
queryManagerFactory, metadataProviderFactory, contextAccessor, authorizationResolver, gQLFilterParser, logger, runtimeConfigProvider, cache);
4244
_queryEngines.Add(DatabaseType.MSSQL, queryEngine);
4345
_queryEngines.Add(DatabaseType.MySQL, queryEngine);
4446
_queryEngines.Add(DatabaseType.PostgreSQL, queryEngine);

src/Core/Resolvers/SqlQueryEngine.cs

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Azure.DataApiBuilder.Core.Models;
1010
using Azure.DataApiBuilder.Core.Resolvers.Factories;
1111
using Azure.DataApiBuilder.Core.Services;
12+
using Azure.DataApiBuilder.Core.Services.Cache;
1213
using Azure.DataApiBuilder.Core.Services.MetadataProviders;
1314
using HotChocolate.Resolvers;
1415
using Microsoft.AspNetCore.Http;
@@ -30,6 +31,7 @@ public class SqlQueryEngine : IQueryEngine
3031
private readonly ILogger<IQueryEngine> _logger;
3132
private readonly RuntimeConfigProvider _runtimeConfigProvider;
3233
private readonly GQLFilterParser _gQLFilterParser;
34+
private readonly DabCacheService _cache;
3335

3436
// <summary>
3537
// Constructor.
@@ -41,7 +43,8 @@ public SqlQueryEngine(
4143
IAuthorizationResolver authorizationResolver,
4244
GQLFilterParser gQLFilterParser,
4345
ILogger<IQueryEngine> logger,
44-
RuntimeConfigProvider runtimeConfigProvider)
46+
RuntimeConfigProvider runtimeConfigProvider,
47+
DabCacheService cache)
4548
{
4649
_queryFactory = queryFactory;
4750
_sqlMetadataProviderFactory = sqlMetadataProviderFactory;
@@ -50,6 +53,7 @@ public SqlQueryEngine(
5053
_gQLFilterParser = gQLFilterParser;
5154
_logger = logger;
5255
_runtimeConfigProvider = runtimeConfigProvider;
56+
_cache = cache;
5357
}
5458

5559
/// <summary>
@@ -199,21 +203,36 @@ public async Task<IActionResult> ExecuteAsync(StoredProcedureRequestContext cont
199203
// </summary>
200204
private async Task<JsonDocument?> ExecuteAsync(SqlQueryStructure structure, string dataSourceName)
201205
{
202-
DatabaseType databaseType = _runtimeConfigProvider.GetConfig().GetDataSourceFromDataSourceName(dataSourceName).DatabaseType;
206+
RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
207+
DatabaseType databaseType = runtimeConfig.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType;
203208
IQueryBuilder queryBuilder = _queryFactory.GetQueryBuilder(databaseType);
204209
IQueryExecutor queryExecutor = _queryFactory.GetQueryExecutor(databaseType);
205210

206211
// Open connection and execute query using _queryExecutor
207212
string queryString = queryBuilder.Build(structure);
208-
JsonDocument? jsonDocument =
209-
await queryExecutor.ExecuteQueryAsync(
210-
sqltext: queryString,
211-
parameters: structure.Parameters,
212-
dataReaderHandler: queryExecutor.GetJsonResultAsync<JsonDocument>,
213-
httpContext: _httpContextAccessor.HttpContext!,
214-
args: null,
215-
dataSourceName: dataSourceName);
216-
return jsonDocument;
213+
214+
if (runtimeConfig.CanUseCache())
215+
{
216+
bool dbPolicyConfigured = !string.IsNullOrEmpty(structure.DbPolicyPredicatesForOperations[EntityActionOperation.Read]);
217+
218+
if (dbPolicyConfigured)
219+
{
220+
DatabaseQueryMetadata queryMetadata = new(queryText: queryString, dataSource: dataSourceName, queryParameters: structure.Parameters);
221+
JsonElement result = await _cache.GetOrSetAsync<JsonElement>(queryExecutor, queryMetadata, cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName));
222+
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(result);
223+
JsonDocument cacheServiceResponse = JsonDocument.Parse(jsonBytes);
224+
return cacheServiceResponse;
225+
}
226+
}
227+
228+
JsonDocument? response = await queryExecutor.ExecuteQueryAsync(
229+
sqltext: queryString,
230+
parameters: structure.Parameters,
231+
dataReaderHandler: queryExecutor.GetJsonResultAsync<JsonDocument>,
232+
httpContext: _httpContextAccessor.HttpContext!,
233+
args: null,
234+
dataSourceName: dataSourceName);
235+
return response;
217236
}
218237

219238
// <summary>

0 commit comments

Comments
 (0)