From e57417e4775d699a5cb6b7eeae1411ce5fdefcdf Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 24 Feb 2026 11:38:37 -0800 Subject: [PATCH 1/9] entity fall back for cache --- src/Config/ObjectModel/RuntimeConfig.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 1e567da1cd..ac1cfcdd04 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -481,7 +481,7 @@ Runtime is not null && Runtime.Host is not null /// /// Name of the entity to check cache configuration. /// Number of seconds (ttl) that a cache entry should be valid before cache eviction. - /// Raised when an invalid entity name is provided or if the entity has caching disabled. + /// Raised when an invalid entity name is provided. public virtual int GetEntityCacheEntryTtl(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) @@ -494,10 +494,7 @@ public virtual int GetEntityCacheEntryTtl(string entityName) if (!entityConfig.IsCachingEnabled) { - throw new DataApiBuilderException( - message: $"{entityName} does not have caching enabled.", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); + return GlobalCacheEntryTtl(); } if (entityConfig.Cache.UserProvidedTtlOptions) @@ -516,7 +513,7 @@ public virtual int GetEntityCacheEntryTtl(string entityName) /// /// 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. + /// Raised when an invalid entity name is provided. public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) @@ -529,10 +526,7 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) if (!entityConfig.IsCachingEnabled) { - throw new DataApiBuilderException( - message: $"{entityName} does not have caching enabled.", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported); + return EntityCacheLevel.L1L2; } if (entityConfig.Cache.UserProvidedLevelOptions) From e5a75947d95104179e131b0e99f9b38a20d12dbd Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 24 Feb 2026 12:10:18 -0800 Subject: [PATCH 2/9] regression test for fix --- .../Caching/CachingConfigProcessingTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs index 1294c009da..fedd6b1f24 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -416,4 +416,47 @@ private static string GetRawConfigJson(string globalCacheConfig, string entityCa return expectedRuntimeConfigJson.ToString(); } + + /// + /// Regression test: Validates that when global runtime cache is enabled but entity cache is disabled, + /// GetEntityCacheEntryTtl and GetEntityCacheEntryLevel return sensible defaults instead of throwing. + /// Previously, these methods threw a DataApiBuilderException (BadRequest/NotSupported) when the entity + /// had caching disabled, which caused 400 errors for valid requests when the global cache was enabled. + /// + /// Global cache configuration JSON fragment. + /// Entity cache configuration JSON fragment. + /// Expected TTL returned by GetEntityCacheEntryTtl. + /// Expected cache level returned by GetEntityCacheEntryLevel. + [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @",""cache"": { ""enabled"": false }", 10, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with custom TTL, entity cache disabled: entity falls back to global TTL.")] + [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": false }", DEFAULT_CACHE_TTL_SECONDS, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with default TTL, entity cache disabled: entity falls back to default TTL.")] + [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @"", DEFAULT_CACHE_TTL_SECONDS, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled, entity cache omitted: entity falls back to default TTL.")] + [DataTestMethod] + public void GetEntityCacheEntryTtlAndLevel_DoesNotThrow_WhenRuntimeCacheEnabledAndEntityCacheDisabled( + string globalCacheConfig, + string entityCacheConfig, + int expectedTtl, + 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."); + Assert.IsTrue(config.IsCachingEnabled, message: "Global caching should be enabled for this test scenario."); + + Entity entity = config.Entities.First().Value; + Assert.IsFalse(entity.IsCachingEnabled, message: "Entity caching should be disabled for this test scenario."); + + string entityName = config.Entities.First().Key; + + // Act & Assert - These calls must not throw. + int actualTtl = config.GetEntityCacheEntryTtl(entityName); + EntityCacheLevel actualLevel = config.GetEntityCacheEntryLevel(entityName); + + Assert.AreEqual(expected: expectedTtl, actual: actualTtl, message: "GetEntityCacheEntryTtl should return the global/default TTL when entity cache is disabled."); + Assert.AreEqual(expected: expectedLevel, actual: actualLevel, message: "GetEntityCacheEntryLevel should return the default level when entity cache is disabled."); + } } From 5dc4506b387d4e08e96feed3dd440014a95a4987 Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:37:05 -0800 Subject: [PATCH 3/9] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Service.Tests/Caching/CachingConfigProcessingTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs index fedd6b1f24..3505b8e8ca 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -429,7 +429,7 @@ private static string GetRawConfigJson(string globalCacheConfig, string entityCa /// Expected cache level returned by GetEntityCacheEntryLevel. [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @",""cache"": { ""enabled"": false }", 10, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with custom TTL, entity cache disabled: entity falls back to global TTL.")] [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": false }", DEFAULT_CACHE_TTL_SECONDS, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with default TTL, entity cache disabled: entity falls back to default TTL.")] - [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @"", DEFAULT_CACHE_TTL_SECONDS, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled, entity cache omitted: entity falls back to default TTL.")] + [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @"", 10, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with custom TTL, entity cache omitted: entity falls back to global TTL.")] [DataTestMethod] public void GetEntityCacheEntryTtlAndLevel_DoesNotThrow_WhenRuntimeCacheEnabledAndEntityCacheDisabled( string globalCacheConfig, From e8ccbf8e4f76a9156dc4e0c644aef7764ea97f3f Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 26 Feb 2026 11:45:11 -0800 Subject: [PATCH 4/9] use default value when available --- src/Config/ObjectModel/RuntimeConfig.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index ac1cfcdd04..03ce50dee5 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -526,7 +526,7 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) if (!entityConfig.IsCachingEnabled) { - return EntityCacheLevel.L1L2; + return EntityCacheOptions.DEFAULT_LEVEL; } if (entityConfig.Cache.UserProvidedLevelOptions) @@ -535,7 +535,7 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) } else { - return EntityCacheLevel.L1L2; + return EntityCacheOptions.DEFAULT_LEVEL; } } From 3bea2cbb9266fdb8ac03ac88d1ec270d3b017fa3 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 26 Feb 2026 14:24:58 -0800 Subject: [PATCH 5/9] change test sig for flakiness --- src/Service.Tests/Configuration/ConfigurationTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 6781c81675..7116156f32 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -2903,7 +2903,7 @@ public async Task ValidateErrorMessageForMutationWithoutReadPermission() }"; string queryName = "stock_by_pk"; - ValidateMutationSucceededAtDbLayer(server, client, graphQLQuery, queryName, authToken, AuthorizationResolver.ROLE_AUTHENTICATED); + await ValidateMutationSucceededAtDbLayer(server, client, graphQLQuery, queryName, authToken, AuthorizationResolver.ROLE_AUTHENTICATED); } finally { @@ -3225,7 +3225,7 @@ public async Task ValidateInheritanceOfReadPermissionFromAnonymous() /// GraphQL query/mutation text /// GraphQL query/mutation name /// Auth token for the graphQL request - private static async void ValidateMutationSucceededAtDbLayer(TestServer server, HttpClient client, string query, string queryName, string authToken, string clientRoleHeader) + private static async Task ValidateMutationSucceededAtDbLayer(TestServer server, HttpClient client, string query, string queryName, string authToken, string clientRoleHeader) { JsonElement queryResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync( client, @@ -3237,6 +3237,7 @@ private static async void ValidateMutationSucceededAtDbLayer(TestServer server, clientRoleHeader: clientRoleHeader); Assert.IsNotNull(queryResponse); + Assert.AreNotEqual(JsonValueKind.Null, queryResponse.ValueKind, "Expected a JSON object response but received null."); Assert.IsFalse(queryResponse.TryGetProperty("errors", out _)); } From 8b05eeb331ed3d55b17f5bee52871d77ce83e26d Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 5 Mar 2026 07:02:41 -0800 Subject: [PATCH 6/9] return null when cache not enabled --- src/Config/ObjectModel/RuntimeConfig.cs | 14 +++++----- src/Core/Resolvers/CosmosQueryEngine.cs | 2 +- src/Core/Resolvers/SqlQueryEngine.cs | 10 +++---- .../Caching/CachingConfigProcessingTests.cs | 26 ++++++++----------- .../DabCacheServiceIntegrationTests.cs | 4 +-- 5 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 6535ae52b1..98a2abaf84 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -529,13 +529,14 @@ Runtime is not null && Runtime.Host is not null /// /// Returns the ttl-seconds value for a given entity. + /// If entity caching is disabled, returns null (there is no TTL when caching is off). /// If the property is not set, returns the global default value set in the runtime config. /// If the global default value is not set, the default value is used (5 seconds). /// /// Name of the entity to check cache configuration. - /// Number of seconds (ttl) that a cache entry should be valid before cache eviction. + /// Number of seconds (ttl) that a cache entry should be valid before cache eviction, or null if entity caching is disabled. /// Raised when an invalid entity name is provided. - public virtual int GetEntityCacheEntryTtl(string entityName) + public virtual int? GetEntityCacheEntryTtl(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) { @@ -547,7 +548,7 @@ public virtual int GetEntityCacheEntryTtl(string entityName) if (!entityConfig.IsCachingEnabled) { - return GlobalCacheEntryTtl(); + return null; } if (entityConfig.Cache.UserProvidedTtlOptions) @@ -562,12 +563,13 @@ public virtual int GetEntityCacheEntryTtl(string entityName) /// /// Returns the cache level value for a given entity. + /// If entity caching is disabled, returns null (there is no cache level when caching is off). /// 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. + /// Cache level that a cache entry should be stored in, or null if entity caching is disabled. /// Raised when an invalid entity name is provided. - public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) + public virtual EntityCacheLevel? GetEntityCacheEntryLevel(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) { @@ -579,7 +581,7 @@ public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) if (!entityConfig.IsCachingEnabled) { - return EntityCacheOptions.DEFAULT_LEVEL; + return null; } if (entityConfig.Cache.UserProvidedLevelOptions) diff --git a/src/Core/Resolvers/CosmosQueryEngine.cs b/src/Core/Resolvers/CosmosQueryEngine.cs index 7525318089..61722e6861 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), runtimeConfig.GetEntityCacheEntryLevel(entityName: structure.EntityName)); + executeQueryResult = await _cache.GetOrSetAsync(async () => await ExecuteQueryAsync(structure, querySpec, queryRequestOptions, container, idValue, partitionKeyValue), queryMetadata, runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName)!.Value, runtimeConfig.GetEntityCacheEntryLevel(entityName: structure.EntityName)!.Value); } else { diff --git a/src/Core/Resolvers/SqlQueryEngine.cs b/src/Core/Resolvers/SqlQueryEngine.cs index 6523589532..ecac95e6bf 100644 --- a/src/Core/Resolvers/SqlQueryEngine.cs +++ b/src/Core/Resolvers/SqlQueryEngine.cs @@ -342,7 +342,7 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad queryString, dataSourceName, queryExecutor, - runtimeConfig.GetEntityCacheEntryLevel(structure.EntityName) + runtimeConfig.GetEntityCacheEntryLevel(structure.EntityName)!.Value ); } } @@ -387,7 +387,7 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad dataSourceName: queryMetadata.DataSource); _cache.Set( queryMetadata, - cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName), + cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName)!.Value, result, cacheEntryLevel); return ParseResultIntoJsonDocument(result); @@ -435,7 +435,7 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad result = await _cache.GetOrSetAsync( queryExecutor, queryMetadata, - cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName), + cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName)!.Value, cacheEntryLevel); return ParseResultIntoJsonDocument(result); } @@ -488,8 +488,8 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad args: null, dataSourceName: dataSourceName), queryMetadata, - runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName), - runtimeConfig.GetEntityCacheEntryLevel(entityName: structure.EntityName)); + runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName)!.Value, + runtimeConfig.GetEntityCacheEntryLevel(entityName: structure.EntityName)!.Value); JsonDocument? cacheServiceResponse = null; diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs index 3505b8e8ca..f70b1fe2d5 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -419,23 +419,19 @@ private static string GetRawConfigJson(string globalCacheConfig, string entityCa /// /// Regression test: Validates that when global runtime cache is enabled but entity cache is disabled, - /// GetEntityCacheEntryTtl and GetEntityCacheEntryLevel return sensible defaults instead of throwing. + /// GetEntityCacheEntryTtl and GetEntityCacheEntryLevel return null instead of throwing. /// Previously, these methods threw a DataApiBuilderException (BadRequest/NotSupported) when the entity /// had caching disabled, which caused 400 errors for valid requests when the global cache was enabled. /// /// Global cache configuration JSON fragment. /// Entity cache configuration JSON fragment. - /// Expected TTL returned by GetEntityCacheEntryTtl. - /// Expected cache level returned by GetEntityCacheEntryLevel. - [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @",""cache"": { ""enabled"": false }", 10, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with custom TTL, entity cache disabled: entity falls back to global TTL.")] - [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": false }", DEFAULT_CACHE_TTL_SECONDS, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with default TTL, entity cache disabled: entity falls back to default TTL.")] - [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @"", 10, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with custom TTL, entity cache omitted: entity falls back to global TTL.")] + [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @",""cache"": { ""enabled"": false }", DisplayName = "Global cache enabled with custom TTL, entity cache disabled: entity returns null.")] + [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": false }", DisplayName = "Global cache enabled with default TTL, entity cache disabled: entity returns null.")] + [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @"", DisplayName = "Global cache enabled, entity cache omitted: entity returns null.")] [DataTestMethod] - public void GetEntityCacheEntryTtlAndLevel_DoesNotThrow_WhenRuntimeCacheEnabledAndEntityCacheDisabled( + public void GetEntityCacheEntryTtlAndLevel_ReturnsNull_WhenRuntimeCacheEnabledAndEntityCacheDisabled( string globalCacheConfig, - string entityCacheConfig, - int expectedTtl, - EntityCacheLevel expectedLevel) + string entityCacheConfig) { // Arrange string fullConfig = GetRawConfigJson(globalCacheConfig: globalCacheConfig, entityCacheConfig: entityCacheConfig); @@ -452,11 +448,11 @@ public void GetEntityCacheEntryTtlAndLevel_DoesNotThrow_WhenRuntimeCacheEnabledA string entityName = config.Entities.First().Key; - // Act & Assert - These calls must not throw. - int actualTtl = config.GetEntityCacheEntryTtl(entityName); - EntityCacheLevel actualLevel = config.GetEntityCacheEntryLevel(entityName); + // Act & Assert - These calls must not throw and must return null. + int? actualTtl = config.GetEntityCacheEntryTtl(entityName); + EntityCacheLevel? actualLevel = config.GetEntityCacheEntryLevel(entityName); - Assert.AreEqual(expected: expectedTtl, actual: actualTtl, message: "GetEntityCacheEntryTtl should return the global/default TTL when entity cache is disabled."); - Assert.AreEqual(expected: expectedLevel, actual: actualLevel, message: "GetEntityCacheEntryLevel should return the default level when entity cache is disabled."); + Assert.IsNull(actualTtl, message: "GetEntityCacheEntryTtl should return null when entity cache is disabled."); + Assert.IsNull(actualLevel, message: "GetEntityCacheEntryLevel should return null when entity cache is disabled."); } } diff --git a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs index 68c9225b96..eecfa6f844 100644 --- a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs +++ b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs @@ -779,10 +779,10 @@ private static Mock CreateMockRuntimeConfigProvider(strin .Returns(true); mockRuntimeConfig .Setup(c => c.GetEntityCacheEntryTtl(It.IsAny())) - .Returns(60); + .Returns((int?)60); mockRuntimeConfig .Setup(c => c.GetEntityCacheEntryLevel(It.IsAny())) - .Returns(EntityCacheLevel.L1); + .Returns((EntityCacheLevel?)EntityCacheLevel.L1); Mock mockLoader = new(null, null); Mock mockRuntimeConfigProvider = new(mockLoader.Object); mockRuntimeConfigProvider From bdd199001ea83be86cc48b1ac69345ed0668c8ea Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 5 Mar 2026 11:53:06 -0800 Subject: [PATCH 7/9] return global default when non exists --- src/Config/ObjectModel/RuntimeConfig.cs | 46 +++++++------------ src/Core/Resolvers/CosmosQueryEngine.cs | 2 +- src/Core/Resolvers/SqlQueryEngine.cs | 12 ++--- .../Caching/CachingConfigProcessingTests.cs | 27 ++++++----- .../DabCacheServiceIntegrationTests.cs | 4 +- 5 files changed, 41 insertions(+), 50 deletions(-) diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index 98a2abaf84..b5c241a0a4 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -373,7 +373,6 @@ public RuntimeConfig( } SetupDataSourcesUsed(); - } /// @@ -529,14 +528,14 @@ Runtime is not null && Runtime.Host is not null /// /// Returns the ttl-seconds value for a given entity. - /// If entity caching is disabled, returns null (there is no TTL when caching is off). - /// If the property is not set, returns the global default value set in the runtime config. - /// If the global default value is not set, the default value is used (5 seconds). + /// If the entity explicitly sets ttl-seconds, that value is used. + /// Otherwise, falls back to the global cache TTL setting. + /// Callers are responsible for checking whether caching is enabled before using the result. /// /// Name of the entity to check cache configuration. - /// Number of seconds (ttl) that a cache entry should be valid before cache eviction, or null if entity caching is disabled. + /// Number of seconds (ttl) that a cache entry should be valid before cache eviction. /// Raised when an invalid entity name is provided. - public virtual int? GetEntityCacheEntryTtl(string entityName) + public virtual int GetEntityCacheEntryTtl(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) { @@ -546,30 +545,24 @@ Runtime is not null && Runtime.Host is not null subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - if (!entityConfig.IsCachingEnabled) - { - return null; - } - - if (entityConfig.Cache.UserProvidedTtlOptions) + if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedTtlOptions) { return entityConfig.Cache.TtlSeconds.Value; } - else - { - return GlobalCacheEntryTtl(); - } + + return GlobalCacheEntryTtl(); } /// /// Returns the cache level value for a given entity. - /// If entity caching is disabled, returns null (there is no cache level when caching is off). - /// 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, falls back to the global cache level or the default. + /// Callers are responsible for checking whether caching is enabled before using the result. /// /// Name of the entity to check cache configuration. - /// Cache level that a cache entry should be stored in, or null if entity caching is disabled. + /// Cache level that a cache entry should be stored in. /// Raised when an invalid entity name is provided. - public virtual EntityCacheLevel? GetEntityCacheEntryLevel(string entityName) + public virtual EntityCacheLevel GetEntityCacheEntryLevel(string entityName) { if (!Entities.TryGetValue(entityName, out Entity? entityConfig)) { @@ -579,19 +572,12 @@ Runtime is not null && Runtime.Host is not null subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - if (!entityConfig.IsCachingEnabled) - { - return null; - } - - if (entityConfig.Cache.UserProvidedLevelOptions) + if (entityConfig.Cache is not null && entityConfig.Cache.UserProvidedLevelOptions) { return entityConfig.Cache.Level.Value; } - else - { - return EntityCacheOptions.DEFAULT_LEVEL; - } + + return EntityCacheOptions.DEFAULT_LEVEL; } /// diff --git a/src/Core/Resolvers/CosmosQueryEngine.cs b/src/Core/Resolvers/CosmosQueryEngine.cs index 61722e6861..7525318089 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)!.Value, runtimeConfig.GetEntityCacheEntryLevel(entityName: structure.EntityName)!.Value); + 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 ecac95e6bf..c634b2d9c0 100644 --- a/src/Core/Resolvers/SqlQueryEngine.cs +++ b/src/Core/Resolvers/SqlQueryEngine.cs @@ -26,7 +26,7 @@ namespace Azure.DataApiBuilder.Core.Resolvers { // // SqlQueryEngine to execute queries against Sql like databases. - // + // public class SqlQueryEngine : IQueryEngine { private readonly IMetadataProviderFactory _sqlMetadataProviderFactory; @@ -342,7 +342,7 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad queryString, dataSourceName, queryExecutor, - runtimeConfig.GetEntityCacheEntryLevel(structure.EntityName)!.Value + runtimeConfig.GetEntityCacheEntryLevel(structure.EntityName) ); } } @@ -387,7 +387,7 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad dataSourceName: queryMetadata.DataSource); _cache.Set( queryMetadata, - cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName)!.Value, + cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName), result, cacheEntryLevel); return ParseResultIntoJsonDocument(result); @@ -435,7 +435,7 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad result = await _cache.GetOrSetAsync( queryExecutor, queryMetadata, - cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName)!.Value, + cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName), cacheEntryLevel); return ParseResultIntoJsonDocument(result); } @@ -488,8 +488,8 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad args: null, dataSourceName: dataSourceName), queryMetadata, - runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName)!.Value, - runtimeConfig.GetEntityCacheEntryLevel(entityName: structure.EntityName)!.Value); + runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName), + runtimeConfig.GetEntityCacheEntryLevel(entityName: structure.EntityName)); JsonDocument? cacheServiceResponse = null; diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs index f70b1fe2d5..d8cd279d95 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -419,19 +419,24 @@ private static string GetRawConfigJson(string globalCacheConfig, string entityCa /// /// Regression test: Validates that when global runtime cache is enabled but entity cache is disabled, - /// GetEntityCacheEntryTtl and GetEntityCacheEntryLevel return null instead of throwing. + /// GetEntityCacheEntryTtl and GetEntityCacheEntryLevel do not throw and return sensible defaults. /// Previously, these methods threw a DataApiBuilderException (BadRequest/NotSupported) when the entity /// had caching disabled, which caused 400 errors for valid requests when the global cache was enabled. + /// These methods are now pure accessors that always return a value regardless of cache enablement. /// /// Global cache configuration JSON fragment. /// Entity cache configuration JSON fragment. - [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @",""cache"": { ""enabled"": false }", DisplayName = "Global cache enabled with custom TTL, entity cache disabled: entity returns null.")] - [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": false }", DisplayName = "Global cache enabled with default TTL, entity cache disabled: entity returns null.")] - [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @"", DisplayName = "Global cache enabled, entity cache omitted: entity returns null.")] + /// Expected TTL returned by GetEntityCacheEntryTtl. + /// Expected cache level returned by GetEntityCacheEntryLevel. + [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @",""cache"": { ""enabled"": false }", 10, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with custom TTL, entity cache disabled: entity returns global TTL and default level.")] + [DataRow(@",""cache"": { ""enabled"": true }", @",""cache"": { ""enabled"": false }", 5, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with default TTL, entity cache disabled: entity returns default TTL and default level.")] + [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 10 }", @"", 10, EntityCacheLevel.L1L2, DisplayName = "Global cache enabled with custom TTL, entity cache omitted: entity returns global TTL and default level.")] [DataTestMethod] - public void GetEntityCacheEntryTtlAndLevel_ReturnsNull_WhenRuntimeCacheEnabledAndEntityCacheDisabled( + public void GetEntityCacheEntryTtlAndLevel_DoesNotThrow_WhenRuntimeCacheEnabledAndEntityCacheDisabled( string globalCacheConfig, - string entityCacheConfig) + string entityCacheConfig, + int expectedTtl, + EntityCacheLevel expectedLevel) { // Arrange string fullConfig = GetRawConfigJson(globalCacheConfig: globalCacheConfig, entityCacheConfig: entityCacheConfig); @@ -448,11 +453,11 @@ public void GetEntityCacheEntryTtlAndLevel_ReturnsNull_WhenRuntimeCacheEnabledAn string entityName = config.Entities.First().Key; - // Act & Assert - These calls must not throw and must return null. - int? actualTtl = config.GetEntityCacheEntryTtl(entityName); - EntityCacheLevel? actualLevel = config.GetEntityCacheEntryLevel(entityName); + // Act & Assert - These calls must not throw. + int actualTtl = config.GetEntityCacheEntryTtl(entityName); + EntityCacheLevel actualLevel = config.GetEntityCacheEntryLevel(entityName); - Assert.IsNull(actualTtl, message: "GetEntityCacheEntryTtl should return null when entity cache is disabled."); - Assert.IsNull(actualLevel, message: "GetEntityCacheEntryLevel should return null when entity cache is disabled."); + Assert.AreEqual(expected: expectedTtl, actual: actualTtl, message: "GetEntityCacheEntryTtl should return the global/default TTL when entity cache is disabled."); + Assert.AreEqual(expected: expectedLevel, actual: actualLevel, message: "GetEntityCacheEntryLevel should return the default level when entity cache is disabled."); } } diff --git a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs index eecfa6f844..68c9225b96 100644 --- a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs +++ b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs @@ -779,10 +779,10 @@ private static Mock CreateMockRuntimeConfigProvider(strin .Returns(true); mockRuntimeConfig .Setup(c => c.GetEntityCacheEntryTtl(It.IsAny())) - .Returns((int?)60); + .Returns(60); mockRuntimeConfig .Setup(c => c.GetEntityCacheEntryLevel(It.IsAny())) - .Returns((EntityCacheLevel?)EntityCacheLevel.L1); + .Returns(EntityCacheLevel.L1); Mock mockLoader = new(null, null); Mock mockRuntimeConfigProvider = new(mockLoader.Object); mockRuntimeConfigProvider From 7836a4b53bd8947a2caa00d1273a909bf4e66bbb Mon Sep 17 00:00:00 2001 From: Aniruddh Munde Date: Thu, 5 Mar 2026 17:57:17 -0800 Subject: [PATCH 8/9] Apply suggestions from code review --- src/Core/Resolvers/SqlQueryEngine.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Resolvers/SqlQueryEngine.cs b/src/Core/Resolvers/SqlQueryEngine.cs index c634b2d9c0..6523589532 100644 --- a/src/Core/Resolvers/SqlQueryEngine.cs +++ b/src/Core/Resolvers/SqlQueryEngine.cs @@ -26,7 +26,7 @@ namespace Azure.DataApiBuilder.Core.Resolvers { // // SqlQueryEngine to execute queries against Sql like databases. - // + // public class SqlQueryEngine : IQueryEngine { private readonly IMetadataProviderFactory _sqlMetadataProviderFactory; From 844e27f4569554ea3dde969ebb24edba45aefa8e Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Fri, 6 Mar 2026 13:47:40 -0800 Subject: [PATCH 9/9] flakiness fix --- .../Configuration/Telemetry/AzureLogAnalyticsTests.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Service.Tests/Configuration/Telemetry/AzureLogAnalyticsTests.cs b/src/Service.Tests/Configuration/Telemetry/AzureLogAnalyticsTests.cs index db6b58681b..dc35b1c4dd 100644 --- a/src/Service.Tests/Configuration/Telemetry/AzureLogAnalyticsTests.cs +++ b/src/Service.Tests/Configuration/Telemetry/AzureLogAnalyticsTests.cs @@ -120,9 +120,18 @@ public async Task TestAzureLogAnalyticsFlushServiceSucceed(string message, LogLe _ = Task.Run(() => flusherService.StartAsync(tokenSource.Token)); - await Task.Delay(2000); + // Poll until the log appears (the flusher service needs time to dequeue and upload) + int maxWaitMs = 10000; + int pollIntervalMs = 100; + int elapsed = 0; + while (customClient.LogAnalyticsLogs.Count == 0 && elapsed < maxWaitMs) + { + await Task.Delay(pollIntervalMs); + elapsed += pollIntervalMs; + } // Assert + Assert.IsTrue(customClient.LogAnalyticsLogs.Count > 0, $"Expected at least one log entry after waiting {elapsed}ms, but found none."); AzureLogAnalyticsLogs actualLog = customClient.LogAnalyticsLogs[0]; Assert.AreEqual(logLevel.ToString(), actualLog.LogLevel); Assert.AreEqual(message, actualLog.Message);