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();
}