88using System . Net . Http . Json ;
99using System . Text . Json ;
1010using System . Threading . Tasks ;
11+ using Azure . DataApiBuilder . Config ;
1112using Azure . DataApiBuilder . Config . ObjectModel ;
1213using Azure . DataApiBuilder . Core . Configurations ;
1314using Azure . DataApiBuilder . Service . Tests . SqlTests ;
@@ -27,6 +28,7 @@ public class ConfigurationHotReloadTests
2728 private static RuntimeConfigProvider _configProvider ;
2829 private static StringWriter _writer ;
2930 private static readonly object _writerLock = new ( ) ;
31+ private static HotReloadFailureObserver _hotReloadFailureObserver ;
3032 private const string CONFIG_FILE_NAME = "hot-reload.dab-config.json" ;
3133 private const string GQL_QUERY_NAME = "books" ;
3234 private const string HOT_RELOAD_SUCCESS_MESSAGE = "Validated hot-reloaded configuration file" ;
@@ -229,6 +231,11 @@ public static async Task ClassInitializeAsync(TestContext context)
229231 {
230232 Console . WriteLine ( $ "Initializing test server (attempt { attempt } /{ maxRetries } )...") ;
231233 _testServer = new ( Program . CreateWebHostBuilder ( new string [ ] { "--ConfigFileName" , CONFIG_FILE_NAME } ) ) ;
234+ _hotReloadFailureObserver = new (
235+ _testServer . Services . GetRequiredService < ILogger < FileSystemRuntimeConfigLoader > > ( ) ) ;
236+ _testServer . Services
237+ . GetRequiredService < FileSystemRuntimeConfigLoader > ( )
238+ . SetLogger ( _hotReloadFailureObserver ) ;
232239 _testClient = _testServer . CreateClient ( ) ;
233240 _configProvider = _testServer . Services . GetService < RuntimeConfigProvider > ( ) ;
234241
@@ -316,6 +323,79 @@ private static bool WriterContains(string message)
316323 }
317324 }
318325
326+ /// <summary>
327+ /// Observes the loader's structured hot-reload failure log. The loader now owns and logs reload
328+ /// failures inside its serialized pipeline, so they no longer escape to ConfigFileWatcher's
329+ /// legacy Console.WriteLine fallback.
330+ /// </summary>
331+ private sealed class HotReloadFailureObserver (
332+ ILogger < FileSystemRuntimeConfigLoader > innerLogger ) : ILogger < FileSystemRuntimeConfigLoader >
333+ {
334+ private readonly object _syncRoot = new ( ) ;
335+ private TaskCompletionSource < string > _failureSource = CreateFailureSource ( ) ;
336+
337+ public IDisposable ? BeginScope < TState > ( TState state )
338+ where TState : notnull => innerLogger . BeginScope ( state ) ;
339+
340+ public bool IsEnabled ( LogLevel logLevel ) =>
341+ logLevel == LogLevel . Error || innerLogger . IsEnabled ( logLevel ) ;
342+
343+ public void Log < TState > (
344+ LogLevel logLevel ,
345+ EventId eventId ,
346+ TState state ,
347+ Exception ? exception ,
348+ Func < TState , Exception ? , string > formatter )
349+ {
350+ innerLogger . Log ( logLevel , eventId , state , exception , formatter ) ;
351+
352+ if ( logLevel != LogLevel . Error )
353+ {
354+ return ;
355+ }
356+
357+ string message = formatter ( state , exception ) ;
358+ if ( ! message . Contains (
359+ HOT_RELOAD_FAILURE_MESSAGE ,
360+ StringComparison . Ordinal ) )
361+ {
362+ return ;
363+ }
364+
365+ RecordFailure ( message ) ;
366+ }
367+
368+ public void Reset ( )
369+ {
370+ lock ( _syncRoot )
371+ {
372+ _failureSource = CreateFailureSource ( ) ;
373+ }
374+ }
375+
376+ public async Task < string > WaitForFailureAsync ( TimeSpan timeout )
377+ {
378+ Task < string > failureTask ;
379+ lock ( _syncRoot )
380+ {
381+ failureTask = _failureSource . Task ;
382+ }
383+
384+ return await failureTask . WaitAsync ( timeout ) ;
385+ }
386+
387+ private static TaskCompletionSource < string > CreateFailureSource ( ) =>
388+ new ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
389+
390+ private void RecordFailure ( string message )
391+ {
392+ lock ( _syncRoot )
393+ {
394+ _failureSource . TrySetResult ( message ) ;
395+ }
396+ }
397+ }
398+
319399 /// <summary>
320400 /// Hot reload the configuration by saving a new file with different rest and graphQL paths.
321401 /// Validate that the response is correct when making a request with the newly hot-reloaded paths.
@@ -754,18 +834,15 @@ public async Task HotReloadConfigConnectionString()
754834
755835 // Act
756836 // Hot Reload should fail here
837+ _hotReloadFailureObserver . Reset ( ) ;
757838 GenerateConfigFile (
758839 connectionString : $ "WrongConnectionString") ;
759- await WaitForConditionAsync (
760- ( ) => WriterContains ( HOT_RELOAD_FAILURE_MESSAGE ) ,
761- TimeSpan . FromSeconds ( HOT_RELOAD_TIMEOUT_SECONDS ) ,
762- TimeSpan . FromMilliseconds ( 500 ) ) ;
840+ string failedConfigLog = await _hotReloadFailureObserver . WaitForFailureAsync (
841+ TimeSpan . FromSeconds ( HOT_RELOAD_TIMEOUT_SECONDS ) ) ;
763842
764843 // Log that shows that hot-reload was not able to validate properly
765- string failedConfigLog ;
766844 lock ( _writerLock )
767845 {
768- failedConfigLog = _writer . ToString ( ) ;
769846 _writer . GetStringBuilder ( ) . Clear ( ) ;
770847 }
771848
@@ -851,19 +928,16 @@ public async Task HotReloadConfigDatabaseType()
851928
852929 // Act
853930 // Hot Reload should fail here
931+ _hotReloadFailureObserver . Reset ( ) ;
854932 GenerateConfigFile (
855933 databaseType : DatabaseType . PostgreSQL ,
856934 connectionString : $ "{ ConfigurationTests . GetConnectionStringFromEnvironmentConfig ( TestCategory . POSTGRESQL ) . Replace ( "\\ " , "\\ \\ " ) } ") ;
857- await WaitForConditionAsync (
858- ( ) => WriterContains ( HOT_RELOAD_FAILURE_MESSAGE ) ,
859- TimeSpan . FromSeconds ( HOT_RELOAD_TIMEOUT_SECONDS ) ,
860- TimeSpan . FromMilliseconds ( 500 ) ) ;
935+ string failedConfigLog = await _hotReloadFailureObserver . WaitForFailureAsync (
936+ TimeSpan . FromSeconds ( HOT_RELOAD_TIMEOUT_SECONDS ) ) ;
861937
862938 // Log that shows that hot-reload was not able to validate properly
863- string failedConfigLog ;
864939 lock ( _writerLock )
865940 {
866- failedConfigLog = _writer . ToString ( ) ;
867941 _writer . GetStringBuilder ( ) . Clear ( ) ;
868942 }
869943
@@ -919,17 +993,16 @@ public async Task HotReloadValidationFail()
919993
920994 // Act
921995 // Generate a config that will fail validation by disabling REST, GraphQL, and MCP (which is not allowed)
996+ _hotReloadFailureObserver . Reset ( ) ;
922997 GenerateConfigFile (
923998 connectionString : $ "{ ConfigurationTests . GetConnectionStringFromEnvironmentConfig ( TestCategory . MSSQL ) . Replace ( "\\ " , "\\ \\ " ) } ",
924999 restEnabled : "false" ,
9251000 gQLEnabled : "false" ,
9261001 mcpEnabled : "false" ) ;
9271002
9281003 // Wait for hot-reload to fail
929- await WaitForConditionAsync (
930- ( ) => WriterContains ( HOT_RELOAD_FAILURE_MESSAGE ) ,
931- TimeSpan . FromSeconds ( HOT_RELOAD_TIMEOUT_SECONDS ) ,
932- TimeSpan . FromMilliseconds ( 500 ) ) ;
1004+ await _hotReloadFailureObserver . WaitForFailureAsync (
1005+ TimeSpan . FromSeconds ( HOT_RELOAD_TIMEOUT_SECONDS ) ) ;
9331006
9341007 RuntimeConfig newRuntimeConfig = _configProvider . GetConfig ( ) ;
9351008
@@ -967,16 +1040,15 @@ public async Task HotReloadParsingFail()
9671040 bool originalGraphQLEnabled = lkgRuntimeConfig . Runtime . GraphQL . Enabled ;
9681041
9691042 // Act
1043+ _hotReloadFailureObserver . Reset ( ) ;
9701044 GenerateConfigFile (
9711045 connectionString : $ "{ ConfigurationTests . GetConnectionStringFromEnvironmentConfig ( TestCategory . MSSQL ) . Replace ( "\\ " , "\\ \\ " ) } ",
9721046 restEnabled : "invalid" ,
9731047 gQLEnabled : "invalid" ) ;
9741048
9751049 // Wait for hot-reload to fail (parsing error should trigger failure message)
976- await WaitForConditionAsync (
977- ( ) => WriterContains ( HOT_RELOAD_FAILURE_MESSAGE ) ,
978- TimeSpan . FromSeconds ( HOT_RELOAD_TIMEOUT_SECONDS ) ,
979- TimeSpan . FromMilliseconds ( 500 ) ) ;
1050+ await _hotReloadFailureObserver . WaitForFailureAsync (
1051+ TimeSpan . FromSeconds ( HOT_RELOAD_TIMEOUT_SECONDS ) ) ;
9801052
9811053 RuntimeConfig newRuntimeConfig = _configProvider . GetConfig ( ) ;
9821054
0 commit comments