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