diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt index 42aa58224f..6b3b5cc018 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt @@ -49,7 +49,9 @@ ], Cache: { Enabled: true, - TtlSeconds: 1 + TtlSeconds: 1, + Level: L1L2, + UserProvidedLevelOptions: false } } } diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt index 4a6174108b..9ae0e33948 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt @@ -43,7 +43,9 @@ }, Cache: { Enabled: true, - TtlSeconds: 1 + TtlSeconds: 1, + Level: L1L2, + UserProvidedLevelOptions: false } } } diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index de399d72e0..7655b84cee 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -741,7 +741,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) { @@ -908,7 +908,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..d60f90208f --- /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 L2 (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 the 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..08f4d7a4db --- /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 the 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 the 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..a947cd6d99 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,16 @@ public record EntityCacheOptions /// public const int DEFAULT_TTL_SECONDS = 5; + /// + /// Default cache level for an entity. + /// + public const EntityCacheLevel DEFAULT_LEVEL = EntityCacheLevel.L1L2; + + /// + /// The L2 cache provider we support. + /// + public const string L2_CACHE_PROVIDER = "redis"; + /// /// Whether the cache should be used for the entity. /// @@ -30,9 +40,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 +61,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 +86,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 the Level option + /// property and value to the runtime config file. + /// When user doesn't provide the level 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 level + /// property/value specified would be interpreted by DAB as "user explicitly set level." + /// + [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 16cfd9a80d..1172b60a8f 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -458,6 +458,41 @@ public virtual 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 virtual 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 7227dbc0a4..12a305c574 100644 --- a/src/Core/Resolvers/SqlQueryEngine.cs +++ b/src/Core/Resolvers/SqlQueryEngine.cs @@ -335,7 +335,8 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta structure, queryString, dataSourceName, - queryExecutor + queryExecutor, + runtimeConfig.GetEntityCacheEntryLevel(structure.EntityName) ); } } @@ -356,7 +357,13 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta return response; } - private async Task GetResultInCacheScenario(RuntimeConfig runtimeConfig, SqlQueryStructure structure, string queryString, string dataSourceName, IQueryExecutor queryExecutor) + private async Task GetResultInCacheScenario( + RuntimeConfig runtimeConfig, + SqlQueryStructure structure, + string queryString, + string dataSourceName, + IQueryExecutor queryExecutor, + EntityCacheLevel cacheEntryLevel) { DatabaseQueryMetadata queryMetadata = new(queryText: queryString, dataSource: dataSourceName, queryParameters: structure.Parameters); JsonElement? result; @@ -375,12 +382,13 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta _cache.Set( queryMetadata, cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName), - result); + result, + cacheEntryLevel); return ParseResultIntoJsonDocument(result); // Do not store result even if valid, still get from cache if available. case SqlQueryStructure.CACHE_CONTROL_NO_STORE: - maybeResult = _cache.TryGet(queryMetadata); + maybeResult = _cache.TryGet(queryMetadata, cacheEntryLevel); // maybeResult is a nullable wrapper so we must check hasValue at outer and inner layer. if (maybeResult.HasValue && maybeResult.Value.HasValue) { @@ -401,7 +409,7 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta // Only return query response if it exists in cache, return gateway timeout otherwise. case SqlQueryStructure.CACHE_CONTROL_ONLY_IF_CACHED: - maybeResult = _cache.TryGet(queryMetadata); + maybeResult = _cache.TryGet(queryMetadata, cacheEntryLevel); // maybeResult is a nullable wrapper so we must check hasValue at outer and inner layer. if (maybeResult.HasValue && maybeResult.Value.HasValue) { @@ -421,7 +429,8 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta result = await _cache.GetOrSetAsync( queryExecutor, queryMetadata, - cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName)); + cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName), + cacheEntryLevel); return ParseResultIntoJsonDocument(result); } } @@ -473,7 +482,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 b51584ddfa..942534fbec 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); - T? 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. - T? 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; }); @@ -87,9 +96,16 @@ public DabCacheService(IFusionCache cache, ILogger? logger, IHt /// The type of value in the cache /// Metadata used to create a cache key or fetch a response from the database. /// JSON Response - public MaybeValue? TryGet(DatabaseQueryMetadata queryMetadata) + public MaybeValue? TryGet(DatabaseQueryMetadata queryMetadata, EntityCacheLevel cacheEntryLevel) { string cacheKey = CreateCacheKey(queryMetadata); + FusionCacheEntryOptions options = new(); + + if (cacheEntryLevel == EntityCacheLevel.L1) + { + options.SetSkipDistributedCache(true, true); + } + return _cache.TryGet(key: cacheKey); } @@ -103,7 +119,8 @@ public DabCacheService(IFusionCache cache, ILogger? logger, IHt public void Set( DatabaseQueryMetadata queryMetadata, int cacheEntryTtl, - JsonElement? cacheValue) + JsonElement? cacheValue, + EntityCacheLevel cacheEntryLevel) { string cacheKey = CreateCacheKey(queryMetadata); _cache.Set( @@ -113,6 +130,12 @@ public void Set( { options.SetSize(EstimateCacheEntrySize(cacheKey: cacheKey, cacheValue: cacheValue?.ToString())); options.SetDuration(duration: TimeSpan.FromSeconds(cacheEntryTtl)); + + if (cacheEntryLevel == EntityCacheLevel.L1) + { + options.SetSkipDistributedCache(true, true); + } + }); } @@ -128,9 +151,9 @@ public void Set( 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, @@ -138,9 +161,16 @@ public void Set( { 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; }); @@ -160,6 +190,10 @@ public void Set( /// 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); @@ -207,7 +241,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 62c751b945..5a43da6a24 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -63,7 +63,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 4166135869..74f455c822 100644 --- a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs +++ b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs @@ -74,7 +74,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); @@ -111,10 +112,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."); @@ -146,14 +148,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."); @@ -186,10 +189,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."); @@ -220,7 +224,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); @@ -259,8 +264,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."); } @@ -295,7 +301,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); @@ -335,11 +342,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."); @@ -373,7 +381,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); @@ -405,11 +414,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."); @@ -442,16 +452,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."); @@ -481,7 +492,7 @@ public async Task RequestHeaderContainsCacheControlOptionNoCache() DatabaseQueryMetadata queryMetadata = new(queryText: queryText, dataSource: dataSourceName, queryParameters: new()); using FusionCache cache = CreateFusionCache(sizeLimit: 1000, defaultEntryTtlSeconds: 60); DabCacheService dabCache = CreateDabCacheService(cache); - dabCache.Set(queryMetadata, cacheEntryTtl: 60, cacheValue: new JsonElement()); + dabCache.Set(queryMetadata, cacheEntryTtl: 60, cacheValue: new JsonElement(), EntityCacheLevel.L1); SqlQueryEngine queryEngine = CreateQueryEngine(dabCache, queryText, expectedDatabaseResponse, entityName); Mock mockStructure = CreateMockSqlQueryStructure(entityName, dataSourceName, cacheControlOption); @@ -507,7 +518,7 @@ public async Task RequestHeaderContainsCacheControlOptionNoCache() } )!; - JsonElement? cachedResult = dabCache.TryGet(queryMetadata); + JsonElement? cachedResult = dabCache.TryGet(queryMetadata, EntityCacheLevel.L1); // Assert // Validates that the expected database response is returned by the query engine and is correct within the cache service. @@ -561,7 +572,7 @@ public async Task RequestHeaderContainsCacheControlOptionNoStore() isMultipleCreateOperation })!; - MaybeValue? cachedResult = dabCache.TryGet(queryMetadata); + MaybeValue? cachedResult = dabCache.TryGet(queryMetadata, EntityCacheLevel.L1); // Assert // Validates that the expected database response is returned by the query engine and that nothing was cached. @@ -766,6 +777,9 @@ private static Mock CreateMockRuntimeConfigProvider(strin mockRuntimeConfig .Setup(c => c.GetEntityCacheEntryTtl(It.IsAny())) .Returns(60); + mockRuntimeConfig + .Setup(c => c.GetEntityCacheEntryLevel(It.IsAny())) + .Returns(EntityCacheLevel.L1); Mock mockLoader = new(null, null); Mock mockRuntimeConfigProvider = new(mockLoader.Object); mockRuntimeConfigProvider diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 3b79aaf9be..61759cce0f 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -2886,9 +2886,11 @@ private static async void ValidateMutationSucceededAtDbLayer(TestServer server, /// Base Route is not configured in the config file used for this test. If base-route is configured, the Location header URL should contain the base-route. /// This test performs a POST request, and in the event that it results in a 201 response, it performs a subsequent GET request /// with the Location header to validate the correctness of the URL. + /// Currently ignored as it is part of the setof flakey tests that are being investigated, see: https://github.com/Azure/data-api-builder/issues/2010 /// /// Type of the entity /// Request path for performing POST API requests on the entity + [Ignore] [DataTestMethod] [TestCategory(TestCategory.MSSQL)] [DataRow(EntitySourceType.Table, "/api/Book", DisplayName = "Location Header validation - Table, Base Route not configured")] @@ -4861,7 +4863,7 @@ public static RuntimeConfig InitMinimalRuntimeConfig( RestRuntimeOptions restOptions, Entity entity = null, string entityName = null, - EntityCacheOptions cacheOptions = null + RuntimeCacheOptions cacheOptions = null ) { entity ??= new( @@ -4897,7 +4899,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 7d41aaef28..e757ca4ee8 100644 --- a/src/Service/Azure.DataApiBuilder.Service.csproj +++ b/src/Service/Azure.DataApiBuilder.Service.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -78,6 +78,9 @@ + + + diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 86e43eb52e..a696148332 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -43,6 +43,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; @@ -55,7 +56,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 @@ -139,6 +143,9 @@ public void ConfigureServices(IServiceCollection services) .WithMetrics(metrics => { metrics.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(runtimeConfig.Runtime.Telemetry.OpenTelemetry.ServiceName!)) + // 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!); @@ -151,6 +158,9 @@ public void ConfigureServices(IServiceCollection services) { tracing.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(runtimeConfig.Runtime.Telemetry.OpenTelemetry.ServiceName!)) .AddHttpClientInstrumentation() + // TODO: should we also add FusionCache traces? + // To do so we just need to add the package ZiggyCreatures.FusionCache.OpenTelemetry and call + // .AddFusionCacheInstrumentation() .AddHotChocolateInstrumentation() .AddOtlpExporter(configure => { @@ -350,17 +360,73 @@ public void ConfigureServices(IServiceCollection services) DabConfigEvents.GRAPHQL_SCHEMA_REFRESH_ON_CONFIG_CHANGED, (_, _) => 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 ?? EntityCacheOptions.L2_CACHE_PROVIDER; + + switch (level2CacheProvider.ToLowerInvariant()) + { + case EntityCacheOptions.L2_CACHE_PROVIDER: + if (string.IsNullOrWhiteSpace(level2CacheOptions.ConnectionString)) + { + throw new Exception($"Cache Provider: the \"{EntityCacheOptions.L2_CACHE_PROVIDER}\" 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(); }