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

In-Memory Cache #1881

Merged
merged 19 commits into from
Dec 7, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
84d9138
caching setup
seantleonard Oct 9, 2023
9450efb
class folder structure cchanges
seantleonard Oct 18, 2023
d624689
Merge remote-tracking branch 'origin/main' into dev/seantleonard/inme…
seantleonard Oct 30, 2023
bdc776c
Adding caching interfaces and initial implementations.
seantleonard Oct 31, 2023
6b61514
Merge branch 'main' of https://github.com/Azure/data-api-builder into…
seantleonard Oct 31, 2023
4cd8f8e
Merge branch 'dev/seantleonard/inmemorycache' of https://github.com/A…
seantleonard Oct 31, 2023
e8470ae
progress
seantleonard Nov 11, 2023
2eea6bd
tests and caching updates
seantleonard Nov 14, 2023
8c845d5
updated tests.
seantleonard Nov 14, 2023
abd72a8
more comments and test revisions. Feature flagging the main execution…
seantleonard Nov 14, 2023
d89aad3
Address feedback and added comments. Plus added additional asserts in…
seantleonard Dec 6, 2023
b8aace6
Merge branch 'main' into dev/seantleonard/inmemorycache
seantleonard Dec 6, 2023
86d4854
Merge branch 'dev/seantleonard/inmemorycache' of https://github.com/A…
seantleonard Dec 6, 2023
18eb138
updating cache gate to consider config.cacheEnabled too.
seantleonard Dec 6, 2023
bee1759
Merge branch 'main' into dev/seantleonard/inmemorycache
seantleonard Dec 6, 2023
d257e4b
Update caching code to utilize configuration values for ttl (global c…
seantleonard Dec 6, 2023
909ce74
Merge branch 'main' into dev/seantleonard/inmemorycache
seantleonard Dec 6, 2023
229d912
reverting change to verified file that was needed locally.
seantleonard Dec 7, 2023
fd02027
Remove FeatureFlag in favor of runtime config.
seantleonard Dec 7, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Core/Azure.DataApiBuilder.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.FeatureManagement" />
<PackageReference Include="Microsoft.FeatureManagement.AspNetCore" />
<PackageReference Include="Microsoft.OData.Core" />
<PackageReference Include="Microsoft.OData.Edm" />
<PackageReference Include="Microsoft.OpenApi" />
Expand All @@ -34,6 +36,7 @@
<PackageReference Include="Polly" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="ZiggyCreatures.FusionCache" />
</ItemGroup>

<PropertyGroup Condition="'$(TF_BUILD)' == 'true'">
Expand Down
28 changes: 28 additions & 0 deletions src/Core/Models/DatabaseQueryMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Azure.DataApiBuilder.Core.Models;

/// <summary>
/// Represents the database query built from a query structure.
/// Contains all query metadata need to create a cache key.
/// </summary>
public class DatabaseQueryMetadata
{
public string QueryText { get; }
public string DataSource { get; }
public Dictionary<string, DbConnectionParam> QueryParameters { get; }

/// <summary>
/// Creates a "Data Transfer Object" (DTO) used for provided query metadata to dependent services.
/// </summary>
/// <param name="queryText">Raw query text built from a query structure object.</param>
/// <param name="dataSource">Name of the data source where the query will execute.</param>
/// <param name="queryParameters">Dictonary of query parameter names and values.</param>
public DatabaseQueryMetadata(string queryText, string dataSource, Dictionary<string, DbConnectionParam> queryParameters)
{
QueryText = queryText;
DataSource = dataSource;
QueryParameters = queryParameters;
}
}
8 changes: 6 additions & 2 deletions src/Core/Resolvers/Factories/QueryEngineFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Core.Models;
using Azure.DataApiBuilder.Core.Services.Cache;
using Azure.DataApiBuilder.Core.Services.MetadataProviders;
using Azure.DataApiBuilder.Service.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.FeatureManagement;

namespace Azure.DataApiBuilder.Core.Resolvers.Factories
{
Expand All @@ -29,7 +31,9 @@ public QueryEngineFactory(RuntimeConfigProvider runtimeConfigProvider,
IHttpContextAccessor contextAccessor,
IAuthorizationResolver authorizationResolver,
GQLFilterParser gQLFilterParser,
ILogger<IQueryEngine> logger)
ILogger<IQueryEngine> logger,
DabCacheService cache,
IFeatureManager featureManager)
{
_queryEngines = new Dictionary<DatabaseType, IQueryEngine>();

Expand All @@ -38,7 +42,7 @@ public QueryEngineFactory(RuntimeConfigProvider runtimeConfigProvider,
if (config.SqlDataSourceUsed)
{
IQueryEngine queryEngine = new SqlQueryEngine(
queryManagerFactory, metadataProviderFactory, contextAccessor, authorizationResolver, gQLFilterParser, logger, runtimeConfigProvider);
queryManagerFactory, metadataProviderFactory, contextAccessor, authorizationResolver, gQLFilterParser, logger, runtimeConfigProvider, cache, featureManager);
_queryEngines.Add(DatabaseType.MSSQL, queryEngine);
_queryEngines.Add(DatabaseType.MySQL, queryEngine);
_queryEngines.Add(DatabaseType.PostgreSQL, queryEngine);
Expand Down
42 changes: 32 additions & 10 deletions src/Core/Resolvers/SqlQueryEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
using Azure.DataApiBuilder.Core.Models;
using Azure.DataApiBuilder.Core.Resolvers.Factories;
using Azure.DataApiBuilder.Core.Services;
using Azure.DataApiBuilder.Core.Services.Cache;
using Azure.DataApiBuilder.Core.Services.MetadataProviders;
using HotChocolate.Resolvers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.FeatureManagement;
using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLStoredProcedureBuilder;

namespace Azure.DataApiBuilder.Core.Resolvers
Expand All @@ -30,6 +32,8 @@ public class SqlQueryEngine : IQueryEngine
private readonly ILogger<IQueryEngine> _logger;
private readonly RuntimeConfigProvider _runtimeConfigProvider;
private readonly GQLFilterParser _gQLFilterParser;
private readonly DabCacheService _cache;
private readonly IFeatureManager _featureManager;

// <summary>
// Constructor.
Expand All @@ -41,7 +45,9 @@ public SqlQueryEngine(
IAuthorizationResolver authorizationResolver,
GQLFilterParser gQLFilterParser,
ILogger<IQueryEngine> logger,
RuntimeConfigProvider runtimeConfigProvider)
RuntimeConfigProvider runtimeConfigProvider,
DabCacheService cache,
IFeatureManager featureManager)
{
_queryFactory = queryFactory;
_sqlMetadataProviderFactory = sqlMetadataProviderFactory;
Expand All @@ -50,6 +56,8 @@ public SqlQueryEngine(
_gQLFilterParser = gQLFilterParser;
_logger = logger;
_runtimeConfigProvider = runtimeConfigProvider;
_cache = cache;
_featureManager = featureManager;
}

/// <summary>
Expand Down Expand Up @@ -199,20 +207,34 @@ public async Task<IActionResult> ExecuteAsync(StoredProcedureRequestContext cont
// </summary>
private async Task<JsonDocument?> ExecuteAsync(SqlQueryStructure structure, string dataSourceName)
{
DatabaseType databaseType = _runtimeConfigProvider.GetConfig().GetDataSourceFromDataSourceName(dataSourceName).DatabaseType;
RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
DatabaseType databaseType = runtimeConfig.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType;
IQueryBuilder queryBuilder = _queryFactory.GetQueryBuilder(databaseType);
IQueryExecutor queryExecutor = _queryFactory.GetQueryExecutor(databaseType);

// Open connection and execute query using _queryExecutor
string queryString = queryBuilder.Build(structure);
JsonDocument? jsonDocument =
await queryExecutor.ExecuteQueryAsync(
sqltext: queryString,
parameters: structure.Parameters,
dataReaderHandler: queryExecutor.GetJsonResultAsync<JsonDocument>,
httpContext: _httpContextAccessor.HttpContext!,
args: null,
dataSourceName: dataSourceName);

if (await _featureManager.IsEnabledAsync("CachingPreview") && runtimeConfig.IsCachingEnabled)
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
{
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
if (_runtimeConfigProvider.GetConfig().SqlDataSourceUsed
Aniruddh25 marked this conversation as resolved.
Show resolved Hide resolved
&& (!_runtimeConfigProvider.GetConfig().DataSource.GetTypedOptions<MsSqlOptions>()?.SetSessionContext ?? true))
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
{
DatabaseQueryMetadata queryMetadata = new(queryText: queryString, dataSource: dataSourceName, queryParameters: structure.Parameters);
JsonElement result = await _cache.GetOrSetAsync<JsonElement>(queryExecutor, queryMetadata, cacheEntryTtl: 5);
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(result);
JsonDocument doc = JsonDocument.Parse(jsonBytes);
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
return doc;
}
}

JsonDocument? jsonDocument = await queryExecutor.ExecuteQueryAsync(
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
sqltext: queryString,
parameters: structure.Parameters,
dataReaderHandler: queryExecutor.GetJsonResultAsync<JsonDocument>,
httpContext: _httpContextAccessor.HttpContext!,
args: null,
dataSourceName: dataSourceName);
return jsonDocument;
}

Expand Down
137 changes: 137 additions & 0 deletions src/Core/Services/Cache/DabCacheService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

severussundar marked this conversation as resolved.
Show resolved Hide resolved
using System.Text;
using System.Text.Json;
using Azure.DataApiBuilder.Core.Models;
using Azure.DataApiBuilder.Core.Resolvers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using ZiggyCreatures.Caching.Fusion;

namespace Azure.DataApiBuilder.Core.Services.Cache;

/// <summary>
/// Service which wraps an internal cache implementation that enables
/// provided an in-memory cache for the DAB engine.
/// </summary>
public class DabCacheService
{
// Dependencies
private readonly IFusionCache _cache;
private readonly ILogger? _logger;
private readonly IHttpContextAccessor _httpContextAccessor;

// Constants
private const char KEY_DELIMITER = ':';

/// <summary>
/// Create cache service which encapsulates actual caching implementation.
/// </summary>
/// <param name="cache">Cache implementation.</param>
/// <param name="logger">Logger.</param>
/// <param name="httpContextAccessor">Accessor which provides httpContext within factory method.</param>
public DabCacheService(IFusionCache cache, ILogger<DabCacheService>? logger, IHttpContextAccessor httpContextAccessor)
{
_cache = cache;
_logger = logger;
_httpContextAccessor = httpContextAccessor;
}

/// <summary>
/// Attempts to fetch response from cache. If there is a cache miss, call the 'factory method' to get a response
/// from the backing database.
/// </summary>
/// <typeparam name="JsonElement">Response payload</typeparam>
/// <param name="queryExecutor">Factory method. Only executed after a cache miss.</param>
/// <param name="queryMetadata">Metadata used to create a cache key or fetch a response from the database.</param>
/// <param name="cacheEntryTtl">Number of seconds the cache entry should be valid before eviction.</param>
/// <returns>JSON Response</returns>
/// <exception cref="Exception">Throws when the cache-miss factory method execution fails.</exception>
public async ValueTask<JsonElement?> GetOrSetAsync<JsonElement>(IQueryExecutor queryExecutor, DatabaseQueryMetadata queryMetadata, int cacheEntryTtl)
{
string cacheKey = CreateCacheKey(queryMetadata);
JsonElement? result = await _cache.GetOrSetAsync(
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
key: cacheKey,
async (FusionCacheFactoryExecutionContext<JsonElement> ctx, CancellationToken ct) =>
{
// Need to handle undesirable results like db errors or null.
JsonElement? result = await queryExecutor.ExecuteQueryAsync(
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
sqltext: queryMetadata.QueryText,
parameters: queryMetadata.QueryParameters,
dataReaderHandler: queryExecutor.GetJsonResultAsync<JsonElement>,
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
httpContext: _httpContextAccessor.HttpContext!,
args: null,
dataSourceName: queryMetadata.DataSource);

ctx.Options.SetSize(EstimateCacheEntrySize(cacheKey: cacheKey, cacheValue: result?.ToString()));
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
ctx.Options.SetDuration(duration: TimeSpan.FromSeconds(cacheEntryTtl));

return result;
});

return result;
}

/// <summary>
/// Creates a cache key using the request metadata resolved from a built SqlQueryStructure
/// Format: DataSourceName:QueryText:JSON_QueryParameters
/// Example: 7a07f92a-1aa2-4e2a-81d6-b9af0a25bbb6:select * from MyTable where id = @param1 = :{"@param1":{"Value":"42","DbType":null}}
/// </summary>
/// <returns>Cache key string</returns>
private string CreateCacheKey(DatabaseQueryMetadata queryMetadata)
{
StringBuilder cacheKeyBuilder = new();
cacheKeyBuilder.Append(queryMetadata.DataSource);
cacheKeyBuilder.Append(KEY_DELIMITER);
cacheKeyBuilder.Append(queryMetadata.QueryText);
cacheKeyBuilder.Append(KEY_DELIMITER);
cacheKeyBuilder.Append(JsonSerializer.Serialize(queryMetadata.QueryParameters));
string cacheKey = cacheKeyBuilder.ToString();

if (_logger?.IsEnabled(LogLevel.Trace) ?? false)
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
{
_logger.LogTrace(message: "{cacheKey}", cacheKey);
}

return cacheKeyBuilder.ToString();
}

/// <summary>
/// Estimates the size of the cache entry in bytes.
/// The cache entry is the concatenation of the cache key and cache value.
/// </summary>
/// <param name="cacheKey">Cache key string.</param>
/// <param name="cacheValue">Cache value as a serialized JSON payload.</param>
/// <returns>Size in bytes.</returns>
/// <exception cref="ArgumentException">Thrown when the cacheKey value is empty or whitespace.</exception>
/// <exception cref="OverflowException">Thrown when the cache entry size is too big.</exception>
private long EstimateCacheEntrySize(string cacheKey, string? cacheValue)
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
throw new ArgumentException(message: "Cache key should not be empty.");
}

try
{
checked
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
{
long size = 0L;
long cacheValueSize = string.IsNullOrWhiteSpace(cacheValue) ? 0L : cacheValue.Length;
size += cacheKey.Length * sizeof(char);
size += cacheValueSize * sizeof(char);
return size;
}
}
catch (OverflowException)
{
if (_logger?.IsEnabled(LogLevel.Trace) ?? false)
{
_logger.LogTrace(message: "Cache entry is too big.");
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
}

throw;
}
}
}
5 changes: 4 additions & 1 deletion src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.ApplicationInsights" Version="2.21.0" />
<PackageVersion Include="Microsoft.FeatureManagement" Version="3.0.0" />
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="3.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageVersion Include="Microsoft.OData.Edm" Version="7.12.5" />
<PackageVersion Include="Microsoft.OData.Core" Version="7.12.5" />
Expand All @@ -50,5 +52,6 @@
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="1.1.1" />
<PackageVersion Include="Verify.MsTest" Version="20.1.0" />
<PackageVersion Include="Verify.DiffPlex" Version="2.2.1" />
<PackageVersion Include="ZiggyCreatures.FusionCache" Version="0.23.0" />
</ItemGroup>
</Project>
</Project>
Loading
Loading