Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ public EntityCacheOptionsConverter(DeserializationVariableReplacementSettings? r
{
if (reader.TokenType is JsonTokenType.StartObject)
{
bool? enabled = false;
// Default to null (unset) so that an empty cache object ("cache": {})
// is treated as "not explicitly configured" and inherits from the runtime setting.
bool? enabled = null;

// Defer to EntityCacheOptions record definition to define default ttl value.
int? ttlSeconds = null;
Expand Down
4 changes: 4 additions & 0 deletions src/Config/ObjectModel/EntityCacheLevel.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Runtime.Serialization;

namespace Azure.DataApiBuilder.Config.ObjectModel;

public enum EntityCacheLevel
{
[EnumMember(Value = "L1")]
L1,
[EnumMember(Value = "L1L2")]
L1L2
}
15 changes: 11 additions & 4 deletions src/Config/ObjectModel/EntityCacheOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ public record EntityCacheOptions

/// <summary>
/// Default cache level for an entity.
/// Placeholder cache level value used when the entity does not explicitly set a level.
/// This value is stored on the EntityCacheOptions object but is NOT used at runtime
/// for resolution � GetEntityCacheEntryLevel() falls through to GlobalCacheEntryLevel()
/// (which infers the level from the runtime Level2 configuration) when UserProvidedLevelOptions is false.
/// </summary>
public const EntityCacheLevel DEFAULT_LEVEL = EntityCacheLevel.L1L2;

Expand All @@ -30,26 +34,29 @@ public record EntityCacheOptions

/// <summary>
/// Whether the cache should be used for the entity.
/// When null, indicates the user did not explicitly set this property, and the entity
/// should inherit the runtime-level cache enabled setting.
/// Using Enabled.HasValue (rather than a separate UserProvided flag) ensures correct
/// behavior regardless of whether the object was created via JsonConstructor or with-expression.
/// </summary>
[JsonPropertyName("enabled")]
public bool? Enabled { get; init; } = false;
public bool? Enabled { get; init; }

/// <summary>
/// The number of seconds a cache entry is valid before eligible for cache eviction.
/// </summary>
[JsonPropertyName("ttl-seconds")]
public int? TtlSeconds { get; init; } = null;
public int? TtlSeconds { get; init; }

/// <summary>
/// The cache levels to use for a cache entry.
/// </summary>
[JsonPropertyName("level")]
public EntityCacheLevel? Level { get; init; } = null;
public EntityCacheLevel? Level { get; init; }

[JsonConstructor]
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)
Expand Down
8 changes: 8 additions & 0 deletions src/Config/ObjectModel/RuntimeCacheOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,12 @@ public RuntimeCacheOptions(bool? Enabled = null, int? TtlSeconds = null)
[JsonIgnore(Condition = JsonIgnoreCondition.Always)]
[MemberNotNullWhen(true, nameof(TtlSeconds))]
public bool UserProvidedTtlOptions { get; init; } = false;

/// <summary>
/// Infers the cache level from the Level2 configuration.
/// If Level2 is enabled, the cache level is L1L2, otherwise L1.
/// </summary>
[JsonIgnore]
public EntityCacheLevel InferredLevel =>
Level2?.Enabled is true ? EntityCacheLevel.L1L2 : EntityCacheLevel.L1;
}
84 changes: 71 additions & 13 deletions src/Config/ObjectModel/RuntimeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,9 @@ public RuntimeConfig(
}

SetupDataSourcesUsed();

// Resolve entity cache inheritance: if an entity's Cache.Enabled is null,
// inherit the global runtime cache enabled setting.
this.Entities = ResolveEntityCacheInheritance(this.Entities, this.Runtime);
}

/// <summary>
Expand Down Expand Up @@ -404,6 +406,10 @@ public RuntimeConfig(string Schema, DataSource DataSource, RuntimeOptions Runtim
this.AzureKeyVault = AzureKeyVault;

SetupDataSourcesUsed();

// Resolve entity cache inheritance: if an entity's Cache.Enabled is null,
// inherit the global runtime cache enabled setting.
this.Entities = ResolveEntityCacheInheritance(this.Entities, this.Runtime);
}

/// <summary>
Expand Down Expand Up @@ -565,7 +571,8 @@ public virtual int GetEntityCacheEntryTtl(string entityName)

/// <summary>
/// Returns the cache level value for a given entity.
/// If the property is not set, returns the default (L1L2) for a given entity.
/// If the entity explicitly sets level, that value is used.
/// Otherwise, the level is inferred from the runtime cache Level2 configuration.
/// </summary>
/// <param name="entityName">Name of the entity to check cache configuration.</param>
/// <returns>Cache level that a cache entry should be stored in.</returns>
Expand All @@ -592,10 +599,35 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName)
{
return entityConfig.Cache.Level.Value;
}
else
{
return EntityCacheLevel.L1L2;
}

// GlobalCacheEntryLevel() returns null when runtime cache is not configured.
// Callers guard with IsCachingEnabled, so null is not expected here,
// but we default to L1 defensively.
return GlobalCacheEntryLevel() ?? EntityCacheLevel.L1;
}

/// <summary>
/// Returns the ttl-seconds value for the global cache entry.
/// If no value is explicitly set, returns the global default value.
/// </summary>
/// <returns>Number of seconds a cache entry should be valid before cache eviction.</returns>
public virtual int GlobalCacheEntryTtl()
{
return Runtime is not null && Runtime.IsCachingEnabled && Runtime.Cache.UserProvidedTtlOptions
? Runtime.Cache.TtlSeconds.Value
: EntityCacheOptions.DEFAULT_TTL_SECONDS;
}

/// <summary>
/// Returns the cache level value for the global cache entry.
/// The level is inferred from the runtime cache Level2 configuration:
/// if Level2 is enabled, the level is L1L2; otherwise L1.
/// Returns null when runtime cache is not configured.
/// </summary>
/// <returns>Cache level for a cache entry, or null if runtime cache is not configured.</returns>
public virtual EntityCacheLevel? GlobalCacheEntryLevel()
{
return Runtime?.Cache?.InferredLevel;
}

/// <summary>
Expand All @@ -611,15 +643,41 @@ public virtual bool CanUseCache()
}

/// <summary>
/// Returns the ttl-seconds value for the global cache entry.
/// If no value is explicitly set, returns the global default value.
/// Resolves entity cache inheritance at construction time.
/// For each entity whose Cache.Enabled is null (not explicitly set by the user),
/// inherits the global runtime cache enabled setting (Runtime.Cache.Enabled).
/// This ensures Entity.IsCachingEnabled is the single source of truth for whether
/// an entity has caching enabled, without callers needing to check the global setting.
/// </summary>
/// <returns>Number of seconds a cache entry should be valid before cache eviction.</returns>
public int GlobalCacheEntryTtl()
/// <returns>A new RuntimeEntities with inheritance resolved, or the original if no changes needed.</returns>
private static RuntimeEntities ResolveEntityCacheInheritance(RuntimeEntities entities, RuntimeOptions? runtime)
{
return Runtime is not null && Runtime.IsCachingEnabled && Runtime.Cache.UserProvidedTtlOptions
? Runtime.Cache.TtlSeconds.Value
: EntityCacheOptions.DEFAULT_TTL_SECONDS;
bool globalCacheEnabled = runtime?.Cache?.Enabled is true;

Dictionary<string, Entity> resolvedEntities = new();
bool anyResolved = false;

foreach (KeyValuePair<string, Entity> kvp in entities)
{
Entity entity = kvp.Value;

// If entity has no cache config at all, and global is enabled, create one inheriting enabled.
// If entity has cache config but Enabled is null, inherit the global value.
if (entity.Cache is null && globalCacheEnabled)
{
entity = entity with { Cache = new EntityCacheOptions(Enabled: true) };
anyResolved = true;
}
else if (entity.Cache is not null && !entity.Cache.Enabled.HasValue)
{
entity = entity with { Cache = entity.Cache with { Enabled = globalCacheEnabled } };
anyResolved = true;
}

resolvedEntities.Add(kvp.Key, entity);
}

return anyResolved ? new RuntimeEntities(resolvedEntities) : entities;
}

private void CheckDataSourceNamePresent(string dataSourceName)
Expand Down
131 changes: 131 additions & 0 deletions src/Service.Tests/Caching/CachingConfigProcessingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,137 @@ public void DefaultTtlNotWrittenToSerializedJsonConfigFile(string cacheConfig)
}
}

/// <summary>
/// Validates that Entity.IsCachingEnabled correctly reflects inheritance from the runtime cache enabled
/// setting when the entity does not explicitly set cache enabled.
/// Inheritance is resolved at RuntimeConfig construction time via ResolveEntityCacheInheritance().
/// Also validates that entity-level explicit enabled overrides the runtime setting.
/// </summary>
/// <param name="globalCacheConfig">Global cache configuration JSON fragment.</param>
/// <param name="entityCacheConfig">Entity cache configuration JSON fragment.</param>
/// <param name="expectedIsEntityCachingEnabled">Whether Entity.IsCachingEnabled should return true.</param>
[DataRow(@",""cache"": { ""enabled"": true }", @"", true, DisplayName = "Global cache enabled, entity cache omitted: entity inherits enabled from runtime.")]
[DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": {}", true, DisplayName = "Global cache enabled, entity cache empty: entity inherits enabled from runtime.")]
[DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": false }", false, DisplayName = "Global cache enabled, entity cache explicitly disabled: entity explicit value wins.")]
[DataRow(@",""cache"": { ""enabled"": false }", @"", false, DisplayName = "Global cache disabled, entity cache omitted: entity inherits disabled from runtime.")]
[DataRow(@",""cache"": { ""enabled"": false }", @",""cache"": { ""enabled"": true }", true, DisplayName = "Global cache disabled, entity cache explicitly enabled: entity explicit value wins.")]
[DataRow(@"", @"", false, DisplayName = "No global cache, no entity cache: defaults to disabled.")]
[DataRow(@"", @",""cache"": { ""enabled"": true }", true, DisplayName = "No global cache, entity cache explicitly enabled: entity explicit value wins.")]
[DataTestMethod]
public void EntityIsCachingEnabled_InheritsFromRuntimeCache(
string globalCacheConfig,
string entityCacheConfig,
bool expectedIsEntityCachingEnabled)
{
// Arrange
string fullConfig = GetRawConfigJson(globalCacheConfig: globalCacheConfig, entityCacheConfig: entityCacheConfig);
RuntimeConfigLoader.TryParseConfig(
json: fullConfig,
out RuntimeConfig? config,
replacementSettings: null);

Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed.");

Entity entity = config.Entities.First().Value;

// Act - Entity.IsCachingEnabled should reflect the inherited value resolved at construction time.
bool actualIsEntityCachingEnabled = entity.IsCachingEnabled;

// Assert
Assert.AreEqual(expected: expectedIsEntityCachingEnabled, actual: actualIsEntityCachingEnabled,
message: $"Entity.IsCachingEnabled should be {expectedIsEntityCachingEnabled}.");
}

/// <summary>
/// Validates that GlobalCacheEntryLevel infers the cache level from the runtime cache Level2 configuration.
/// When Level2 is enabled, the global level is L1L2; when Level2 is absent or disabled, the global level is L1.
/// </summary>
/// <param name="globalCacheConfig">Global cache configuration JSON fragment.</param>
/// <param name="expectedLevel">Expected inferred cache level.</param>
[DataRow(@",""cache"": { ""enabled"": true }", EntityCacheLevel.L1, DisplayName = "Global cache enabled, no Level2: inferred level is L1.")]
[DataRow(@",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": true } }", EntityCacheLevel.L1L2, DisplayName = "Global cache enabled, Level2 enabled: inferred level is L1L2.")]
[DataRow(@",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": false } }", EntityCacheLevel.L1, DisplayName = "Global cache enabled, Level2 disabled: inferred level is L1.")]
[DataTestMethod]
public void GlobalCacheEntryLevel_InfersFromLevel2Config(
string globalCacheConfig,
EntityCacheLevel expectedLevel)
{
// Arrange
string fullConfig = GetRawConfigJson(globalCacheConfig: globalCacheConfig, entityCacheConfig: string.Empty);
RuntimeConfigLoader.TryParseConfig(
json: fullConfig,
out RuntimeConfig? config,
replacementSettings: null);

Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed.");

// Act
EntityCacheLevel? actualLevel = config.GlobalCacheEntryLevel();

// Assert
Assert.IsNotNull(actualLevel, message: "GlobalCacheEntryLevel should not be null when runtime cache is configured.");
Assert.AreEqual(expected: expectedLevel, actual: actualLevel.Value,
message: $"GlobalCacheEntryLevel should be {expectedLevel}.");
}

/// <summary>
/// Validates that GlobalCacheEntryLevel returns null when runtime cache is not configured,
/// since determining a cache level is meaningless when caching is disabled.
/// </summary>
[TestMethod]
public void GlobalCacheEntryLevel_ReturnsNullWhenRuntimeCacheIsNull()
{
// Arrange: no global cache config
string fullConfig = GetRawConfigJson(globalCacheConfig: string.Empty, entityCacheConfig: string.Empty);
RuntimeConfigLoader.TryParseConfig(
json: fullConfig,
out RuntimeConfig? config,
replacementSettings: null);

Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed.");

// Act
EntityCacheLevel? actualLevel = config.GlobalCacheEntryLevel();

// Assert
Assert.IsNull(actualLevel, "GlobalCacheEntryLevel should return null when runtime cache is not configured.");
}

/// <summary>
/// Validates that the entity cache level is serialized with the correct casing (e.g. "L1", "L1L2")
/// when writing the runtime config to JSON. This ensures the serialized config passes JSON schema
/// validation which expects uppercase enum values.
/// </summary>
/// <param name="levelValue">The cache level value as written in the JSON config.</param>
/// <param name="expectedSerializedLevel">The expected string in the serialized JSON output.</param>
[DataRow("L1", "L1", DisplayName = "L1 level serialized with correct casing.")]
[DataRow("L1L2", "L1L2", DisplayName = "L1L2 level serialized with correct casing.")]
[DataTestMethod]
public void EntityCacheLevelSerializedWithCorrectCasing(string levelValue, string expectedSerializedLevel)
{
// Arrange
string entityCacheConfig = @",""cache"": { ""enabled"": true, ""level"": """ + levelValue + @""" }";
string fullConfig = GetRawConfigJson(globalCacheConfig: @",""cache"": { ""enabled"": true }", entityCacheConfig: entityCacheConfig);
RuntimeConfigLoader.TryParseConfig(
json: fullConfig,
out RuntimeConfig? config,
replacementSettings: null);
Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed.");

// Act
string serializedConfig = config.ToJson();

// Assert
using JsonDocument parsedConfig = JsonDocument.Parse(serializedConfig);
JsonElement entityElement = parsedConfig.RootElement
.GetProperty("entities")
.EnumerateObject().First().Value;
JsonElement cacheElement = entityElement.GetProperty("cache");
string? actualLevel = cacheElement.GetProperty("level").GetString();
Assert.AreEqual(expected: expectedSerializedLevel, actual: actualLevel,
message: $"Cache level should be serialized as '{expectedSerializedLevel}', not lowercase.");
}

/// <summary>
/// Returns a JSON string of the runtime config with the test-provided
/// cache configuration.
Expand Down