Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
11 changes: 7 additions & 4 deletions src/Config/ObjectModel/EntityCacheOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,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?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dont we need the "UserProvidedxyz" logic to Enabled?

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;
}
70 changes: 57 additions & 13 deletions src/Config/ObjectModel/RuntimeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,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,36 +593,79 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName)
{
return entityConfig.Cache.Level.Value;
}
else
{
return EntityCacheLevel.L1L2;
}

return GlobalCacheEntryLevel();
}

/// <summary>
/// Whether the caching service should be used for a given operation. This is determined by
/// - whether caching is enabled globally
/// - whether the datasource is SQL and session context is disabled.
/// Determines whether caching is enabled for a given entity, taking into account
/// inheritance from the runtime cache settings.
/// If the entity explicitly sets Enabled (Enabled.HasValue is true), that value is used.
/// If the entity does not set Enabled (Enabled is null), the runtime cache enabled value is inherited.
/// Using Enabled.HasValue instead of a separate UserProvided flag ensures correctness
/// regardless of whether the object was created via JsonConstructor or with-expression.
/// </summary>
/// <returns>Whether cache operations should proceed.</returns>
public virtual bool CanUseCache()
/// <param name="entityName">Name of the entity to check cache configuration.</param>
/// <returns>Whether caching is enabled for the entity.</returns>
/// <exception cref="DataApiBuilderException">Raised when an invalid entity name is provided.</exception>
public virtual bool IsEntityCachingEnabled(string entityName)
{
bool setSessionContextEnabled = DataSource.GetTypedOptions<MsSqlOptions>()?.SetSessionContext ?? true;
return IsCachingEnabled && !setSessionContextEnabled;
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 the entity explicitly set Enabled, use that value.
if (entityConfig.Cache is not null && entityConfig.Cache.Enabled.HasValue)
{
return entityConfig.Cache.Enabled.Value;
}

// Otherwise, inherit from the runtime cache enabled setting.
return Runtime?.Cache?.Enabled is true;
}

/// <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 int GlobalCacheEntryTtl()
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.
/// If runtime cache is not configured, the default cache level is used.
/// </summary>
/// <returns>Cache level that a cache entry should be stored in.</returns>
public virtual EntityCacheLevel GlobalCacheEntryLevel()
{
return Runtime?.Cache is not null
? Runtime.Cache.InferredLevel
: EntityCacheOptions.DEFAULT_LEVEL;
Copy link
Collaborator

@Aniruddh25 Aniruddh25 Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Default cache level when level 2 Cache section is NOT specified in runtime is L1 (as is evident from the InferredCacheLevel). But for EntityCacheOptions.DEFAULT_LEVEL it seems to be L1L2. Is that accurate and what we want?

}

/// <summary>
/// Whether the caching service should be used for a given operation. This is determined by
/// - whether caching is enabled globally
/// - whether the datasource is SQL and session context is disabled.
/// </summary>
/// <returns>Whether cache operations should proceed.</returns>
public virtual bool CanUseCache()
{
bool setSessionContextEnabled = DataSource.GetTypedOptions<MsSqlOptions>()?.SetSessionContext ?? true;
return IsCachingEnabled && !setSessionContextEnabled;
}

private void CheckDataSourceNamePresent(string dataSourceName)
{
if (!_dataSourceNameToDataSource.ContainsKey(dataSourceName))
Expand Down
4 changes: 2 additions & 2 deletions src/Core/Resolvers/SqlQueryEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad
{
// Entity level cache behavior checks
bool dbPolicyConfigured = !string.IsNullOrEmpty(structure.DbPolicyPredicatesForOperations[EntityActionOperation.Read]);
bool entityCacheEnabled = runtimeConfig.Entities[structure.EntityName].IsCachingEnabled;
bool entityCacheEnabled = runtimeConfig.IsEntityCachingEnabled(structure.EntityName);

// If a db policy is configured for the read operation in the context of the executing role, skip the cache.
// We want to avoid caching token metadata because token metadata can change frequently and we want to avoid caching it.
Expand Down Expand Up @@ -466,7 +466,7 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad
if (runtimeConfig.CanUseCache())
{
// Entity level cache behavior checks
bool entityCacheEnabled = runtimeConfig.Entities[structure.EntityName].IsCachingEnabled;
bool entityCacheEnabled = runtimeConfig.IsEntityCachingEnabled(structure.EntityName);

// Stored procedures do not support nor honor runtime config defined
// authorization policies. Here, DAB only checks that the entity has
Expand Down
109 changes: 109 additions & 0 deletions src/Service.Tests/Caching/CachingConfigProcessingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,115 @@ public void DefaultTtlNotWrittenToSerializedJsonConfigFile(string cacheConfig)
}
}

/// <summary>
/// Validates that IsEntityCachingEnabled correctly inherits from the runtime cache enabled
/// setting when the entity does not explicitly set cache enabled.
/// 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 IsEntityCachingEnabled 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"": true }", @",""cache"": { ""enabled"": true }", true, DisplayName = "Global cache enabled, entity cache explicitly enabled: 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 IsEntityCachingEnabled_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.");

string entityName = config.Entities.First().Key;

// Act
bool actualIsEntityCachingEnabled = config.IsEntityCachingEnabled(entityName);

// Assert
Assert.AreEqual(expected: expectedIsEntityCachingEnabled, actual: actualIsEntityCachingEnabled,
message: $"IsEntityCachingEnabled 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.")]
[DataRow(@"", EntityCacheLevel.L1L2, DisplayName = "No global cache: default level is L1L2.")]
[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.AreEqual(expected: expectedLevel, actual: actualLevel,
message: $"GlobalCacheEntryLevel should be {expectedLevel}.");
}

/// <summary>
/// Validates that GetEntityCacheEntryLevel returns the entity-level explicit value when set,
/// and falls back to the runtime-inferred level (from Level2) when the entity doesn't set it.
/// </summary>
/// <param name="globalCacheConfig">Global cache configuration JSON fragment.</param>
/// <param name="entityCacheConfig">Entity cache configuration JSON fragment.</param>
/// <param name="expectedLevel">Expected cache level returned by GetEntityCacheEntryLevel.</param>
[DataRow(@",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": true } }", @",""cache"": { ""enabled"": true }", EntityCacheLevel.L1L2, DisplayName = "Level2 enabled, entity has no level: inherits L1L2 from runtime.")]
[DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": true }", EntityCacheLevel.L1, DisplayName = "No Level2, entity has no level: inherits L1 from runtime.")]
[DataRow(@",""cache"": { ""enabled"": true, ""level-2"": { ""enabled"": true } }", @",""cache"": { ""enabled"": true, ""level"": ""L1"" }", EntityCacheLevel.L1, DisplayName = "Level2 enabled, entity explicitly sets L1: entity value wins.")]
[DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": true, ""level"": ""L1L2"" }", EntityCacheLevel.L1L2, DisplayName = "No Level2, entity explicitly sets L1L2: entity value wins.")]
[DataTestMethod]
public void GetEntityCacheEntryLevel_InheritsFromRuntimeLevel2(
string globalCacheConfig,
string entityCacheConfig,
EntityCacheLevel expectedLevel)
{
// 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.");

string entityName = config.Entities.First().Key;

// Act
EntityCacheLevel actualLevel = config.GetEntityCacheEntryLevel(entityName);

// Assert
Assert.AreEqual(expected: expectedLevel, actual: actualLevel,
message: $"GetEntityCacheEntryLevel should be {expectedLevel}.");
}

/// <summary>
/// Returns a JSON string of the runtime config with the test-provided
/// cache configuration.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,9 @@ private static Mock<RuntimeConfigProvider> CreateMockRuntimeConfigProvider(strin
mockRuntimeConfig
.Setup(c => c.CanUseCache())
.Returns(true);
mockRuntimeConfig
.Setup(c => c.IsEntityCachingEnabled(It.IsAny<string>()))
.Returns(true);
mockRuntimeConfig
.Setup(c => c.GetEntityCacheEntryTtl(It.IsAny<string>()))
.Returns(60);
Expand Down