diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 6c7862312f..b8e772daf9 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -739,7 +739,7 @@ private static bool TryUpdateConfiguredRuntimeOptions( if (options.RuntimeCacheEnabled != null || options.RuntimeCacheTTL != null) { - EntityCacheOptions? updatedCacheOptions = runtimeConfig?.Runtime?.Cache ?? new(); + RuntimeCacheOptions? updatedCacheOptions = runtimeConfig?.Runtime?.Cache ?? new(); bool status = TryUpdateConfiguredCacheValues(options, ref updatedCacheOptions); if (status) { @@ -906,7 +906,7 @@ private static bool TryUpdateConfiguredGraphQLValues( /// True if the value needs to be udpated in the runtime config, else false private static bool TryUpdateConfiguredCacheValues( ConfigureOptions options, - ref EntityCacheOptions? updatedCacheOptions) + ref RuntimeCacheOptions? updatedCacheOptions) { object? updatedValue; try diff --git a/src/Config/Converters/EntityCacheOptionsConverterFactory.cs b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs index cba543ddc6..32a616ab81 100644 --- a/src/Config/Converters/EntityCacheOptionsConverterFactory.cs +++ b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs @@ -12,6 +12,10 @@ namespace Azure.DataApiBuilder.Config.Converters; /// internal class EntityCacheOptionsConverterFactory : JsonConverterFactory { + // Determines whether to replace environment variable with its + // value or not while deserializing. + private bool _replaceEnvVar; + /// public override bool CanConvert(Type typeToConvert) { @@ -21,11 +25,29 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new EntityCacheOptionsConverter(); + return new EntityCacheOptionsConverter(_replaceEnvVar); + } + + /// Whether to replace environment variable with its + /// value or not while deserializing. + internal EntityCacheOptionsConverterFactory(bool replaceEnvVar) + { + _replaceEnvVar = replaceEnvVar; } private class EntityCacheOptionsConverter : JsonConverter { + // Determines whether to replace environment variable with its + // value or not while deserializing. + private bool _replaceEnvVar; + + /// Whether to replace environment variable with its + /// value or not while deserializing. + public EntityCacheOptionsConverter(bool replaceEnvVar) + { + _replaceEnvVar = replaceEnvVar; + } + /// /// Defines how DAB reads an entity's cache options and defines which values are /// used to instantiate EntityCacheOptions. @@ -40,11 +62,13 @@ private class EntityCacheOptionsConverter : JsonConverter // Defer to EntityCacheOptions record definition to define default ttl value. int? ttlSeconds = null; + EntityCacheLevel? level = null; + while (reader.Read()) { if (reader.TokenType is JsonTokenType.EndObject) { - return new EntityCacheOptions(enabled, ttlSeconds); + return new EntityCacheOptions(enabled, ttlSeconds, level); } string? property = reader.GetString(); @@ -79,6 +103,15 @@ private class EntityCacheOptionsConverter : JsonConverter ttlSeconds = parseTtlSeconds; } + break; + case "level": + if (reader.TokenType is JsonTokenType.Null) + { + throw new JsonException("level property cannot be null."); + } + + level = EnumExtensions.Deserialize(reader.DeserializeString(_replaceEnvVar)!); + break; } } @@ -89,9 +122,9 @@ private class EntityCacheOptionsConverter : JsonConverter /// /// When writing the EntityCacheOptions back to a JSON file, only write the ttl-seconds - /// property and value when EntityCacheOptions.Enabled is true. This avoids polluting - /// the written JSON file with a property the user most likely omitted when writing the - /// original DAB runtime config file. + /// and level properties and values when EntityCacheOptions.Enabled is true. + /// This avoids polluting the written JSON file with a property the user most likely + /// omitted when writing the original DAB runtime config file. /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. /// public override void Write(Utf8JsonWriter writer, EntityCacheOptions value, JsonSerializerOptions options) @@ -105,6 +138,12 @@ public override void Write(Utf8JsonWriter writer, EntityCacheOptions value, Json JsonSerializer.Serialize(writer, value.TtlSeconds, options); } + if (value?.UserProvidedLevelOptions is true) + { + writer.WritePropertyName("level"); + JsonSerializer.Serialize(writer, value.Level, options); + } + writer.WriteEndObject(); } } diff --git a/src/Config/Converters/HostOptionsConverterFactory.cs b/src/Config/Converters/HostOptionsConverterFactory.cs index cf39b871fb..02ea9fa22d 100644 --- a/src/Config/Converters/HostOptionsConverterFactory.cs +++ b/src/Config/Converters/HostOptionsConverterFactory.cs @@ -43,7 +43,7 @@ private class HostOptionsConverter : JsonConverter /// /// When writing the HostOptions back to a JSON file, only write the MaxResponseSizeMB property /// if the property is user provided. This avoids polluting the written JSON file with a property - /// the user most likely ommitted when writing the original DAB runtime config file. + /// the user most likely omitted when writing the original DAB runtime config file. /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. /// public override void Write(Utf8JsonWriter writer, HostOptions value, JsonSerializerOptions options) diff --git a/src/Config/Converters/RuntimeCacheLevel2OptionsConverterFactory.cs b/src/Config/Converters/RuntimeCacheLevel2OptionsConverterFactory.cs new file mode 100644 index 0000000000..a0a9e892c7 --- /dev/null +++ b/src/Config/Converters/RuntimeCacheLevel2OptionsConverterFactory.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// Defines how DAB reads and writes a runtime cache options (JSON). +/// +internal class RuntimeCacheLevel2OptionsConverterFactory : JsonConverterFactory +{ + /// + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(RuntimeCacheLevel2Options)); + } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return new RuntimeCacheLevel2OptionsConverter(); + } + + private class RuntimeCacheLevel2OptionsConverter : JsonConverter + { + /// + /// Defines how DAB reads a runtime cache level2 options and defines which values are + /// used to instantiate RuntimeCacheLevel2Options. + /// + /// Thrown when improperly formatted cache options are provided. + public override RuntimeCacheLevel2Options? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Remove the converter so we don't recurse. + JsonSerializerOptions jsonSerializerOptions = new(options); + jsonSerializerOptions.Converters.Remove(jsonSerializerOptions.Converters.First(c => c is RuntimeCacheLevel2OptionsConverterFactory)); + + RuntimeCacheLevel2Options? res = JsonSerializer.Deserialize(ref reader, jsonSerializerOptions); + + // TODO: maybe add a check to ensure that the provider is valid? + + return res; + } + + /// + /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// + public override void Write(Utf8JsonWriter writer, RuntimeCacheLevel2Options value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteBoolean("enabled", value?.Enabled ?? false); + + if (value is not null) + { + if (value.Provider is not null) + { + writer.WritePropertyName("provider"); + JsonSerializer.Serialize(writer, value.Provider, options); + } + + if (value.Partition is not null) + { + writer.WritePropertyName("partition"); + JsonSerializer.Serialize(writer, value.Partition, options); + } + + if (value.ConnectionString is not null) + { + writer.WritePropertyName("connection-string"); + JsonSerializer.Serialize(writer, value.ConnectionString, options); + } + } + + writer.WriteEndObject(); + } + } +} diff --git a/src/Config/Converters/RuntimeCacheOptionsConverterFactory.cs b/src/Config/Converters/RuntimeCacheOptionsConverterFactory.cs new file mode 100644 index 0000000000..fff1197bb8 --- /dev/null +++ b/src/Config/Converters/RuntimeCacheOptionsConverterFactory.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// Defines how DAB reads and writes a runtime cache options (JSON). +/// +internal class RuntimeCacheOptionsConverterFactory : JsonConverterFactory +{ + /// + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(RuntimeCacheOptions)); + } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return new RuntimeCacheOptionsConverter(); + } + + private class RuntimeCacheOptionsConverter : JsonConverter + { + /// + /// Defines how DAB reads a runtime cache options and defines which values are + /// used to instantiate RuntimeCacheOptions. + /// + /// Thrown when improperly formatted cache options are provided. + public override RuntimeCacheOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Remove the converter so we don't recurse. + JsonSerializerOptions jsonSerializerOptions = new(options); + jsonSerializerOptions.Converters.Remove(jsonSerializerOptions.Converters.First(c => c is RuntimeCacheOptionsConverterFactory)); + + RuntimeCacheOptions? res = JsonSerializer.Deserialize(ref reader, jsonSerializerOptions); + + if (res is not null) + { + if (res.TtlSeconds <= 0) + { + throw new JsonException($"Invalid value for ttl-seconds: {res.TtlSeconds}. Value must be greater than 0."); + } + } + + return res; + } + + /// + /// When writing the RuntimeCacheOptions back to a JSON file, only write the ttl-seconds + /// property and value when RuntimeCacheOptions.Enabled is true. This avoids polluting + /// the written JSON file with a property the user most likely omitted when writing the + /// original DAB runtime config file. + /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// + public override void Write(Utf8JsonWriter writer, RuntimeCacheOptions value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteBoolean("enabled", value?.Enabled ?? false); + + if (value is not null) + { + if (value.UserProvidedTtlOptions is true) + { + writer.WritePropertyName("ttl-seconds"); + JsonSerializer.Serialize(writer, value.TtlSeconds, options); + } + + if (value.Level2 is not null) + { + writer.WritePropertyName("level-2"); + JsonSerializer.Serialize(writer, value.Level2, options); + } + } + + writer.WriteEndObject(); + } + } +} diff --git a/src/Config/ObjectModel/Entity.cs b/src/Config/ObjectModel/Entity.cs index aa4d441fda..5660864088 100644 --- a/src/Config/ObjectModel/Entity.cs +++ b/src/Config/ObjectModel/Entity.cs @@ -70,10 +70,7 @@ public Entity( /// Whether caching is enabled for the entity. [JsonIgnore] [MemberNotNullWhen(true, nameof(Cache))] - public bool IsCachingEnabled => - Cache is not null && - Cache.Enabled is not null && - Cache.Enabled is true; + public bool IsCachingEnabled => Cache?.Enabled is true; [JsonIgnore] public bool IsEntityHealthEnabled => diff --git a/src/Config/ObjectModel/EntityCacheLevel.cs b/src/Config/ObjectModel/EntityCacheLevel.cs new file mode 100644 index 0000000000..cb4aa58c95 --- /dev/null +++ b/src/Config/ObjectModel/EntityCacheLevel.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +public enum EntityCacheLevel +{ + L1, + L1L2 +} diff --git a/src/Config/ObjectModel/EntityCacheOptions.cs b/src/Config/ObjectModel/EntityCacheOptions.cs index 1cbbba2a69..cb9bc1cf22 100644 --- a/src/Config/ObjectModel/EntityCacheOptions.cs +++ b/src/Config/ObjectModel/EntityCacheOptions.cs @@ -7,7 +7,7 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; /// -/// Entity specific in-memory cache configuration. +/// Entity specific cache configuration. /// Properties are nullable to support DAB CLI merge config /// expected behavior. /// @@ -18,6 +18,11 @@ public record EntityCacheOptions /// public const int DEFAULT_TTL_SECONDS = 5; + /// + /// Default ttl value for an entity. + /// + public const EntityCacheLevel DEFAULT_LEVEL = EntityCacheLevel.L1L2; + /// /// Whether the cache should be used for the entity. /// @@ -30,9 +35,16 @@ public record EntityCacheOptions [JsonPropertyName("ttl-seconds")] public int? TtlSeconds { get; init; } = null; + /// + /// The cache levels to use for a cache entry. + /// + [JsonPropertyName("level")] + public EntityCacheLevel? Level { get; init; } = null; + [JsonConstructor] - public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null) + public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null, EntityCacheLevel? Level = null) { + // TODO: shouldn't we apply the same "UserProvidedXyz" logic to Enabled, too? this.Enabled = Enabled; if (TtlSeconds is not null) @@ -44,6 +56,16 @@ public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null) { this.TtlSeconds = DEFAULT_TTL_SECONDS; } + + if (Level is not null) + { + this.Level = Level; + UserProvidedLevelOptions = true; + } + else + { + this.Level = DEFAULT_LEVEL; + } } /// @@ -59,4 +81,18 @@ public EntityCacheOptions(bool? Enabled = null, int? TtlSeconds = null) [JsonIgnore(Condition = JsonIgnoreCondition.Always)] [MemberNotNullWhen(true, nameof(TtlSeconds))] public bool UserProvidedTtlOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write ttl-seconds + /// property and value to the runtime config file. + /// When user doesn't provide the ttl-seconds property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a ttl-seconds + /// property/value specified would be interpreted by DAB as "user explicitly set ttl." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Level))] + public bool UserProvidedLevelOptions { get; init; } = false; } diff --git a/src/Config/ObjectModel/RuntimeCacheLevel2Options.cs b/src/Config/ObjectModel/RuntimeCacheLevel2Options.cs new file mode 100644 index 0000000000..4f7a39a1ba --- /dev/null +++ b/src/Config/ObjectModel/RuntimeCacheLevel2Options.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Runtime specific level2 cache configuration. +/// Properties are nullable to support DAB CLI merge config +/// expected behavior. +/// +public record RuntimeCacheLevel2Options +{ + /// + /// Whether the cache should be used. + /// + [JsonPropertyName("enabled")] + public bool? Enabled { get; init; } = false; + + /// + /// The provider for the L2 cache. Currently only "redis" is supported. + /// + [JsonPropertyName("provider")] + public string? Provider { get; init; } = null; + + /// + /// The connection string for the level2 cache. + /// + [JsonPropertyName("connection-string")] + public string? ConnectionString { get; init; } = null; + + /// + /// The prefix to use for the cache keys in level2 + backplane: useful in a shared environment (eg: a shared Redis instance) to avoid collisions of cache keys or the backplane channel. + /// + [JsonPropertyName("partition")] + public string? Partition { get; init; } = null; + + [JsonConstructor] + public RuntimeCacheLevel2Options(bool? Enabled = null, string? Provider = null, string? ConnectionString = null, string? Partition = null) + { + this.Enabled = Enabled; + + this.Provider = Provider; + + this.ConnectionString = ConnectionString; + + this.Partition = Partition; + } +} diff --git a/src/Config/ObjectModel/RuntimeCacheOptions.cs b/src/Config/ObjectModel/RuntimeCacheOptions.cs new file mode 100644 index 0000000000..b507ba6fb3 --- /dev/null +++ b/src/Config/ObjectModel/RuntimeCacheOptions.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Runtime specific cache configuration. +/// Properties are nullable to support DAB CLI merge config +/// expected behavior. +/// +public record RuntimeCacheOptions +{ + /// + /// Default ttl value for an entity. + /// + public const int DEFAULT_TTL_SECONDS = 5; + + /// + /// Whether the level2 cache should be used. + /// + [JsonPropertyName("enabled")] + public bool? Enabled { get; init; } = false; + + /// + /// The number of seconds a cache entry is valid before eligible for cache eviction. + /// + [JsonPropertyName("ttl-seconds")] + public int? TtlSeconds { get; init; } = null; + + /// + /// The options for the level2 cache (and backplane). + /// + [JsonPropertyName("level-2")] + public RuntimeCacheLevel2Options? Level2 { get; init; } = null; + + [JsonConstructor] + public RuntimeCacheOptions(bool? Enabled = null, int? TtlSeconds = null) + { + this.Enabled = Enabled; + + if (TtlSeconds is not null) + { + this.TtlSeconds = TtlSeconds; + UserProvidedTtlOptions = true; + } + else + { + this.TtlSeconds = DEFAULT_TTL_SECONDS; + } + } + + /// + /// Flag which informs CLI and JSON serializer whether to write ttl-seconds + /// property and value to the runtime config file. + /// When user doesn't provide the ttl-seconds property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a ttl-seconds + /// property/value specified would be interpreted by DAB as "user explicitly set ttl." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(TtlSeconds))] + public bool UserProvidedTtlOptions { get; init; } = false; +} diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 87c553dc2c..3e3f60325c 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -454,6 +454,41 @@ public int GetEntityCacheEntryTtl(string entityName) } } + /// + /// Returns the cache level value for a given entity. + /// If the property is not set, returns the default (L1L2) for a given entity. + /// + /// Name of the entity to check cache configuration. + /// Cache level that a cache entry should be stored in. + /// Raised when an invalid entity name is provided or if the entity has caching disabled. + public EntityCacheLevel GetEntityCacheEntryLevel(string entityName) + { + if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) + { + throw new DataApiBuilderException( + message: $"{entityName} is not a valid entity.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); + } + + if (!entityConfig.IsCachingEnabled) + { + throw new DataApiBuilderException( + message: $"{entityName} does not have caching enabled.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); + } + + if (entityConfig.Cache.UserProvidedLevelOptions) + { + return entityConfig.Cache.Level.Value; + } + else + { + return EntityCacheLevel.L1L2; + } + } + /// /// Whether the caching service should be used for a given operation. This is determined by /// - whether caching is enabled globally diff --git a/src/Config/ObjectModel/RuntimeOptions.cs b/src/Config/ObjectModel/RuntimeOptions.cs index 0c6fa4ea28..8e05df4b62 100644 --- a/src/Config/ObjectModel/RuntimeOptions.cs +++ b/src/Config/ObjectModel/RuntimeOptions.cs @@ -13,7 +13,7 @@ public record RuntimeOptions public HostOptions? Host { get; set; } public string? BaseRoute { get; init; } public TelemetryOptions? Telemetry { get; init; } - public EntityCacheOptions? Cache { get; init; } + public RuntimeCacheOptions? Cache { get; init; } public PaginationOptions? Pagination { get; init; } public RuntimeHealthCheckConfig? Health { get; init; } @@ -24,7 +24,7 @@ public RuntimeOptions( HostOptions? Host, string? BaseRoute = null, TelemetryOptions? Telemetry = null, - EntityCacheOptions? Cache = null, + RuntimeCacheOptions? Cache = null, PaginationOptions? Pagination = null, RuntimeHealthCheckConfig? Health = null) { @@ -45,10 +45,7 @@ public RuntimeOptions( /// Whether caching is enabled globally. [JsonIgnore] [MemberNotNullWhen(true, nameof(Cache))] - public bool IsCachingEnabled => - Cache is not null && - Cache.Enabled is not null && - Cache.Enabled is true; + public bool IsCachingEnabled => Cache?.Enabled is true; [JsonIgnore] [MemberNotNullWhen(true, nameof(Rest))] diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index 55e3e9b51e..b4f72335c3 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -251,7 +251,9 @@ public static JsonSerializerOptions GetSerializationOptions( options.Converters.Add(new EntityRestOptionsConverterFactory(replaceEnvVar)); options.Converters.Add(new EntityActionConverterFactory()); options.Converters.Add(new DataSourceFilesConverter()); - options.Converters.Add(new EntityCacheOptionsConverterFactory()); + options.Converters.Add(new EntityCacheOptionsConverterFactory(replaceEnvVar)); + options.Converters.Add(new RuntimeCacheOptionsConverterFactory()); + options.Converters.Add(new RuntimeCacheLevel2OptionsConverterFactory()); options.Converters.Add(new MultipleCreateOptionsConverter()); options.Converters.Add(new MultipleMutationOptionsConverter(options)); options.Converters.Add(new DataSourceConverterFactory(replaceEnvVar)); diff --git a/src/Core/Resolvers/CosmosQueryEngine.cs b/src/Core/Resolvers/CosmosQueryEngine.cs index 243394ae28..e9d4caa380 100644 --- a/src/Core/Resolvers/CosmosQueryEngine.cs +++ b/src/Core/Resolvers/CosmosQueryEngine.cs @@ -102,7 +102,7 @@ public async Task> ExecuteAsync( DatabaseQueryMetadata queryMetadata = new(queryText: queryString, dataSource: dataSourceKey.ToString(), queryParameters: structure.Parameters); - executeQueryResult = await _cache.GetOrSetAsync(async () => await ExecuteQueryAsync(structure, querySpec, queryRequestOptions, container, idValue, partitionKeyValue), queryMetadata, runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName)); + executeQueryResult = await _cache.GetOrSetAsync(async () => await ExecuteQueryAsync(structure, querySpec, queryRequestOptions, container, idValue, partitionKeyValue), queryMetadata, runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName), runtimeConfig.GetEntityCacheEntryLevel(entityName: structure.EntityName)); } else { diff --git a/src/Core/Resolvers/SqlQueryEngine.cs b/src/Core/Resolvers/SqlQueryEngine.cs index 53547a96a3..22eb86ef50 100644 --- a/src/Core/Resolvers/SqlQueryEngine.cs +++ b/src/Core/Resolvers/SqlQueryEngine.cs @@ -329,7 +329,7 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta if (!dbPolicyConfigured && entityCacheEnabled) { DatabaseQueryMetadata queryMetadata = new(queryText: queryString, dataSource: dataSourceName, queryParameters: structure.Parameters); - JsonElement result = await _cache.GetOrSetAsync(queryExecutor, queryMetadata, cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName)); + JsonElement result = await _cache.GetOrSetAsync(queryExecutor, queryMetadata, cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName), runtimeConfig.GetEntityCacheEntryLevel(entityName: structure.EntityName)); byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(result); JsonDocument cacheServiceResponse = JsonDocument.Parse(jsonBytes); return cacheServiceResponse; @@ -392,7 +392,8 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta args: null, dataSourceName: dataSourceName), queryMetadata, - runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName)); + runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName), + runtimeConfig.GetEntityCacheEntryLevel(entityName: structure.EntityName)); JsonDocument? cacheServiceResponse = null; diff --git a/src/Core/Services/Cache/DabCacheService.cs b/src/Core/Services/Cache/DabCacheService.cs index 20ab2905d4..559d0403c8 100644 --- a/src/Core/Services/Cache/DabCacheService.cs +++ b/src/Core/Services/Cache/DabCacheService.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; +using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Models; using Azure.DataApiBuilder.Core.Resolvers; using Microsoft.AspNetCore.Http; @@ -27,8 +28,8 @@ public class DabCacheService // Log Messages private const string CACHE_KEY_EMPTY = "The cache key should not be empty."; - private const string CACHE_KEY_TOO_LARGE = "The cache key is too large."; private const string CACHE_KEY_CREATED = "The cache key was created by the cache service."; + private const string CACHE_ENTRY_TOO_LARGE = "The cache entry is too large."; /// /// Create cache service which encapsulates actual caching implementation. @@ -47,34 +48,42 @@ public DabCacheService(IFusionCache cache, ILogger? logger, IHt /// Attempts to fetch response from cache. If there is a cache miss, call the 'factory method' to get a response /// from the backing database. /// - /// Response payload + /// Response payload /// Factory method. Only executed after a cache miss. /// Metadata used to create a cache key or fetch a response from the database. /// Number of seconds the cache entry should be valid before eviction. /// JSON Response /// Throws when the cache-miss factory method execution fails. - public async ValueTask GetOrSetAsync( + public async ValueTask GetOrSetAsync( IQueryExecutor queryExecutor, DatabaseQueryMetadata queryMetadata, - int cacheEntryTtl) + int cacheEntryTtl, + EntityCacheLevel cacheEntryLevel) { string cacheKey = CreateCacheKey(queryMetadata); - JsonElement? result = await _cache.GetOrSetAsync( + TResult? result = await _cache.GetOrSetAsync( key: cacheKey, - async (FusionCacheFactoryExecutionContext ctx, CancellationToken ct) => + async (FusionCacheFactoryExecutionContext ctx, CancellationToken ct) => { // Need to handle undesirable results like db errors or null. - JsonElement? result = await queryExecutor.ExecuteQueryAsync( + TResult? result = await queryExecutor.ExecuteQueryAsync( sqltext: queryMetadata.QueryText, parameters: queryMetadata.QueryParameters, - dataReaderHandler: queryExecutor.GetJsonResultAsync, + dataReaderHandler: queryExecutor.GetJsonResultAsync, httpContext: _httpContextAccessor.HttpContext!, args: null, dataSourceName: queryMetadata.DataSource); + // TODO: check if still needed, probably not (since no SizeLimit has been set on the underlying MemoryCache) ctx.Options.SetSize(EstimateCacheEntrySize(cacheKey: cacheKey, cacheValue: result?.ToString())); + ctx.Options.SetDuration(duration: TimeSpan.FromSeconds(cacheEntryTtl)); + if (cacheEntryLevel == EntityCacheLevel.L1) + { + ctx.Options.SetSkipDistributedCache(true, true); + } + return result; }); @@ -93,9 +102,9 @@ public DabCacheService(IFusionCache cache, ILogger? logger, IHt public async ValueTask GetOrSetAsync( Func> executeQueryAsync, DatabaseQueryMetadata queryMetadata, - int cacheEntryTtl) + int cacheEntryTtl, + EntityCacheLevel cacheEntryLevel) { - string cacheKey = CreateCacheKey(queryMetadata); TResult? result = await _cache.GetOrSetAsync( key: cacheKey, @@ -103,9 +112,16 @@ public DabCacheService(IFusionCache cache, ILogger? logger, IHt { TResult result = await executeQueryAsync(); + // TODO: check if still needed, probably not (since no SizeLimit has been set on the underlying MemoryCache) ctx.Options.SetSize(EstimateCacheEntrySize(cacheKey: cacheKey, cacheValue: JsonSerializer.Serialize(result?.ToString()))); + ctx.Options.SetDuration(duration: TimeSpan.FromSeconds(cacheEntryTtl)); + if (cacheEntryLevel == EntityCacheLevel.L1) + { + ctx.Options.SetSkipDistributedCache(true, true); + } + return result; }); @@ -125,6 +141,10 @@ public DabCacheService(IFusionCache cache, ILogger? logger, IHt /// Cache key string private string CreateCacheKey(DatabaseQueryMetadata queryMetadata) { + // TODO: to avoid cache keys being too large, we should consider the use of hashing. + // We can hash the query parameters, and maybe even the query text. + // I would exclude the datasource, for easier investigations. + // The hash algorithm should be deterministic and fast, not cryptographically secure. StringBuilder cacheKeyBuilder = new(); cacheKeyBuilder.Append(queryMetadata.DataSource); cacheKeyBuilder.Append(KEY_DELIMITER); @@ -172,7 +192,7 @@ private long EstimateCacheEntrySize(string cacheKey, string? cacheValue) { if (_logger?.IsEnabled(LogLevel.Trace) ?? false) { - _logger.LogTrace(message: CACHE_KEY_TOO_LARGE); + _logger.LogTrace(message: CACHE_ENTRY_TOO_LARGE); } throw; diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index d6f7461b7f..0aa87e6d31 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -59,7 +59,10 @@ - + + + + diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs index 67fe370383..2780af63c5 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -150,7 +150,7 @@ public void GlobalCacheOptionsDeserialization_ValidValues( Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed."); Assert.AreEqual(expected: expectCacheEnabled, actual: config.IsCachingEnabled, message: "RuntimeConfig.CacheEnabled expected to be: " + expectCacheEnabled); - EntityCacheOptions? resolvedGlobalCacheOptions = config?.Runtime?.Cache; + RuntimeCacheOptions? resolvedGlobalCacheOptions = config?.Runtime?.Cache; if (expectCacheEnabled) { Assert.IsNotNull(config?.IsCachingEnabled, message: "Expected global cache property to be non-null."); @@ -224,7 +224,7 @@ public void GlobalCacheOptionsOverridesEntityCacheOptions(string globalCacheConf // Assert Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed."); - EntityCacheOptions? resolvedGlobalCacheOptions = config?.Runtime?.Cache; + RuntimeCacheOptions? resolvedGlobalCacheOptions = config?.Runtime?.Cache; Assert.IsNotNull(config?.IsCachingEnabled, message: "Expected global cache property to be non-null."); Assert.IsNotNull(resolvedGlobalCacheOptions, message: "GlobalCacheConfig must not be null, unexpected JSON deserialization result."); Assert.AreEqual(expected: expectedGlobalCacheTtl, actual: resolvedGlobalCacheOptions.TtlSeconds); diff --git a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs index 1185726764..9197317e9e 100644 --- a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs +++ b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs @@ -10,6 +10,7 @@ using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; +using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Models; using Azure.DataApiBuilder.Core.Resolvers; using Azure.DataApiBuilder.Core.Services.Cache; @@ -63,7 +64,8 @@ public async Task FirstCacheServiceInvocationCallsFactory() // Act int cacheEntryTtlInSeconds = 1; - JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + EntityCacheLevel cacheEntryLevel = EntityCacheLevel.L1L2; + JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Assert Assert.AreEqual(expected: true, actual: mockQueryExecutor.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS); @@ -100,10 +102,11 @@ public async Task SecondCacheServiceInvocation_CacheHit_NoSecondFactoryCall() // Prime the cache with a single entry int cacheEntryTtlInSeconds = 1; - _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + EntityCacheLevel cacheEntryLevel = EntityCacheLevel.L1L2; + _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Act - JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Assert Assert.IsFalse(mockQueryExecutor.Invocations.Count is 2, message: "Expected a cache hit, but observed two cache misses."); @@ -135,14 +138,15 @@ public async Task ThirdCacheServiceInvocation_CacheHit_NoSecondFactoryCall() // Prime the cache with a single entry int cacheEntryTtlInSeconds = 1; - _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); - _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + EntityCacheLevel cacheEntryLevel = EntityCacheLevel.L1L2; + _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); + _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Sleep for the amount of time the cache entry is valid to trigger eviction. Thread.Sleep(millisecondsTimeout: cacheEntryTtlInSeconds * 1000); // Act - JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Assert Assert.IsFalse(mockQueryExecutor.Invocations.Count is 1, message: "QueryExecutor invocation count too low. A cache hit shouldn't have occurred since the entry should have expired."); @@ -175,10 +179,11 @@ public async Task LargeCacheKey_BadBehavior() // Prime the cache. int cacheEntryTtlInSeconds = 1; - _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + EntityCacheLevel cacheEntryLevel = EntityCacheLevel.L1L2; + _ = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Act - JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Assert Assert.IsFalse(mockQueryExecutor.Invocations.Count is 1, message: "Unexpected cache hit when cache entry size exceeded cache capacity."); @@ -209,7 +214,8 @@ public async Task CacheServiceFactoryInvocationReturnsNull() // Act int cacheEntryTtlInSeconds = 1; - JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + EntityCacheLevel cacheEntryLevel = EntityCacheLevel.L1L2; + JsonElement? result = await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Assert Assert.AreEqual(expected: true, actual: mockQueryExecutor.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS); @@ -248,8 +254,9 @@ public async Task CacheServiceFactoryInvocationThrowsException() // Act and Assert int cacheEntryTtlInSeconds = 1; + EntityCacheLevel cacheEntryLevel = EntityCacheLevel.L1L2; await Assert.ThrowsExceptionAsync( - async () => await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds), + async () => await dabCache.GetOrSetAsync(queryExecutor: mockQueryExecutor.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel), message: "Expected an exception to be thrown."); } @@ -284,7 +291,8 @@ public async Task JsonArray_CacheServiceInvocation_CacheEmpty_ReturnsFactoryResu // Act int cacheEntryTtlInSeconds = 1; - JsonArray? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + EntityCacheLevel cacheEntryLevel = EntityCacheLevel.L1L2; + JsonArray? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Assert Assert.AreEqual(expected: true, actual: mockExecuteQuery.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS); @@ -324,11 +332,12 @@ public async Task JsonArray_CacheServiceInvocation_CacheHit_NoFactoryInvocation( DabCacheService dabCache = CreateDabCacheService(cache); int cacheEntryTtlInSeconds = 1; + EntityCacheLevel cacheEntryLevel = EntityCacheLevel.L1L2; // First call. Cache miss - _ = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + _ = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Act - JsonArray? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + JsonArray? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Assert Assert.IsFalse(mockExecuteQuery.Invocations.Count > 1, message: "Expected a cache hit, but observed cache misses."); @@ -362,7 +371,8 @@ public async Task FirstCacheServiceInvocationCallsFuncAndReturnResult() // Act int cacheEntryTtlInSeconds = 1; - JObject? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + EntityCacheLevel cacheEntryLevel = EntityCacheLevel.L1L2; + JObject? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Assert Assert.AreEqual(expected: true, actual: mockExecuteQuery.Invocations.Count is 1, message: ERROR_UNEXPECTED_INVOCATIONS); @@ -394,11 +404,12 @@ public async Task SecondCacheServiceInvocation_CacheHit_NoFuncInvocation() DabCacheService dabCache = CreateDabCacheService(cache); int cacheEntryTtlInSeconds = 1; + EntityCacheLevel cacheEntryLevel = EntityCacheLevel.L1L2; // First call. Cache miss - _ = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + _ = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Act - JObject? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + JObject? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Assert Assert.IsFalse(mockExecuteQuery.Invocations.Count > 1, message: "Expected a cache hit, but observed cache misses."); @@ -431,16 +442,17 @@ public async Task ThirdCacheServiceInvocation_CacheHit_NoFuncInvocation() DabCacheService dabCache = CreateDabCacheService(cache); int cacheEntryTtlInSeconds = 1; + EntityCacheLevel cacheEntryLevel = EntityCacheLevel.L1L2; // First call. Cache miss - _ = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); - _ = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + _ = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); + _ = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Sleep for the amount of time the cache entry is valid to trigger eviction. Thread.Sleep(millisecondsTimeout: cacheEntryTtlInSeconds * 1000); // Act - JObject? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds); + JObject? result = await dabCache.GetOrSetAsync(executeQueryAsync: mockExecuteQuery.Object, queryMetadata: queryMetadata, cacheEntryTtl: cacheEntryTtlInSeconds, cacheEntryLevel: cacheEntryLevel); // Assert Assert.IsFalse(mockExecuteQuery.Invocations.Count < 2, message: "QueryExecutor invocation count too low. A cache hit shouldn't have occurred since the entry should have expired."); diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index fa88441ca5..c2b5dcbed2 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -5075,7 +5075,7 @@ public static RuntimeConfig InitMinimalRuntimeConfig( RestRuntimeOptions restOptions, Entity entity = null, string entityName = null, - EntityCacheOptions cacheOptions = null + RuntimeCacheOptions cacheOptions = null ) { entity ??= new( @@ -5111,7 +5111,9 @@ public static RuntimeConfig InitMinimalRuntimeConfig( Schema: "IntegrationTestMinimalSchema", DataSource: dataSource, Runtime: new(restOptions, graphqlOptions, - Host: new(Cors: null, Authentication: authenticationOptions, Mode: HostMode.Development), Cache: cacheOptions), + Host: new(Cors: null, Authentication: authenticationOptions, Mode: HostMode.Development), + Cache: cacheOptions + ), Entities: new(entityMap) ); } diff --git a/src/Service.Tests/CosmosTests/QueryTests.cs b/src/Service.Tests/CosmosTests/QueryTests.cs index 3dc9732c7c..97cffa3c98 100644 --- a/src/Service.Tests/CosmosTests/QueryTests.cs +++ b/src/Service.Tests/CosmosTests/QueryTests.cs @@ -724,7 +724,7 @@ type Planet @model(name:""Planet"") { string entityName = "Planet"; // cache configuration - RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName, new EntityCacheOptions() { Enabled = true, TtlSeconds = 5 }); + RuntimeConfig configuration = ConfigurationTests.InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, entity, entityName, new RuntimeCacheOptions() { Enabled = true, TtlSeconds = 5 }); const string CUSTOM_CONFIG = "custom-config.json"; const string CUSTOM_SCHEMA = "custom-schema.gql"; diff --git a/src/Service/Azure.DataApiBuilder.Service.csproj b/src/Service/Azure.DataApiBuilder.Service.csproj index e4fbddd825..e178707477 100644 --- a/src/Service/Azure.DataApiBuilder.Service.csproj +++ b/src/Service/Azure.DataApiBuilder.Service.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -77,7 +77,10 @@ - + + + + diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index a871d3354f..3e913c8e41 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -39,6 +39,7 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.StackExchangeRedis; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -49,7 +50,10 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +using StackExchange.Redis; using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; +using ZiggyCreatures.Caching.Fusion.Serialization.SystemTextJson; using CorsOptions = Azure.DataApiBuilder.Config.ObjectModel.CorsOptions; namespace Azure.DataApiBuilder.Service @@ -125,6 +129,9 @@ public void ConfigureServices(IServiceCollection services) metrics.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(runtimeConfig.Runtime.Telemetry.OpenTelemetry.ServiceName!)) .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() + // TODO: should we also add FusionCache metrics? + // To do so we just need to add the package ZiggyCreatures.FusionCache.OpenTelemetry and call + // .AddFusionCacheInstrumentation() .AddOtlpExporter(configure => { configure.Endpoint = new Uri(runtimeConfig.Runtime.Telemetry.OpenTelemetry.Endpoint!); @@ -138,6 +145,9 @@ public void ConfigureServices(IServiceCollection services) tracing.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(runtimeConfig.Runtime.Telemetry.OpenTelemetry.ServiceName!)) .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() + // TODO: should we also add FusionCache traces? + // To do so we just need to add the package ZiggyCreatures.FusionCache.OpenTelemetry and call + // .AddFusionCacheInstrumentation() .AddOtlpExporter(configure => { configure.Endpoint = new Uri(runtimeConfig.Runtime.Telemetry.OpenTelemetry.Endpoint!); @@ -290,17 +300,73 @@ public void ConfigureServices(IServiceCollection services) // Subscribe the GraphQL schema refresh method to the specific hot-reload event _hotReloadEventHandler.Subscribe(DabConfigEvents.GRAPHQL_SCHEMA_REFRESH_ON_CONFIG_CHANGED, (sender, args) => RefreshGraphQLSchema(services)); - services.AddFusionCache() + // Cache config + IFusionCacheBuilder fusionCacheBuilder = services.AddFusionCache() .WithOptions(options => { options.FactoryErrorsLogLevel = LogLevel.Debug; options.EventHandlingErrorsLogLevel = LogLevel.Debug; + string? cachePartition = runtimeConfig?.Runtime?.Cache?.Level2?.Partition; + if (string.IsNullOrWhiteSpace(cachePartition) == false) + { + options.CacheKeyPrefix = cachePartition + "_"; + options.BackplaneChannelPrefix = cachePartition + "_"; + } }) .WithDefaultEntryOptions(new FusionCacheEntryOptions { - Duration = TimeSpan.FromSeconds(5) + Duration = TimeSpan.FromSeconds(RuntimeCacheOptions.DEFAULT_TTL_SECONDS), + ReThrowBackplaneExceptions = false, + ReThrowDistributedCacheExceptions = false, + ReThrowSerializationExceptions = false, }); + // Level2 cache config + bool isLevel2Enabled = runtimeConfigAvailable + && (runtimeConfig?.Runtime?.IsCachingEnabled ?? false) + && (runtimeConfig?.Runtime?.Cache?.Level2?.Enabled ?? false); + + if (isLevel2Enabled) + { + RuntimeCacheLevel2Options level2CacheOptions = runtimeConfig!.Runtime!.Cache!.Level2!; + string level2CacheProvider = level2CacheOptions.Provider ?? "redis"; + + switch (level2CacheProvider) + { + case "redis": + if (string.IsNullOrWhiteSpace(level2CacheOptions.ConnectionString)) + { + throw new Exception($"Cache Provider: the \"redis\" level2 cache provider requires a valid connection-string. Please provide one."); + } + else + { + // NOTE: this is done to reuse the same connection multiplexer for both the cache and backplane + Task connectionMultiplexerTask = ConnectionMultiplexer.ConnectAsync(level2CacheOptions.ConnectionString); + + fusionCacheBuilder + .WithSerializer(new FusionCacheSystemTextJsonSerializer()) + .WithDistributedCache(new RedisCache(new RedisCacheOptions + { + ConnectionMultiplexerFactory = async () => + { + return await connectionMultiplexerTask; + } + })) + .WithBackplane(new RedisBackplane(new RedisBackplaneOptions + { + ConnectionMultiplexerFactory = async () => + { + return await connectionMultiplexerTask; + } + })); + } + + break; + default: + throw new Exception($"Cache Provider: ${level2CacheOptions.Provider} not supported. Please provide a valid cache provider."); + } + } + services.AddSingleton(); services.AddControllers(); }