From 10b4402769591642dbf5d902b5078cfdc2587a0c Mon Sep 17 00:00:00 2001 From: Rami Date: Sat, 7 Mar 2026 14:12:09 -0800 Subject: [PATCH] Add SynchronousWrite option for immediate log persistence Introduce SynchronousWrite setting to file logger configuration, allowing log entries to be written directly to file on the calling thread and bypassing the background queue. This ensures logs are persisted before ILogger.Log returns, useful for crash diagnostics. Updated processor, settings, and documentation. Added unit tests for both global and per-file SynchronousWrite behavior. --- README.md | 2 + source/FileLogger/FileLogger.cs | 6 +- source/FileLogger/FileLoggerProcessor.cs | 36 ++++++ source/FileLogger/LogFileSettings.cs | 10 ++ test/FileLogger.Test/LoggingTest.cs | 140 +++++++++++++++++++++++ test/FileLogger.Test/SettingsTest.cs | 2 + 6 files changed, 195 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d7fd1e9..e044851 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,7 @@ The log file settings below can be specified globally (at provider level) and in | **IncludeScopes** | Enables log scopes to be included in the output. | `false` | Works the same way as `ConsoleLogger`. | | **MaxQueueSize** | Defines the maximum capacity of the log processor queue (per file). | `0` (unbounded) | If set to a value greater than 0, log entries will be discarded when the queue is full, that is, when the specified limit is exceeded. | | **PathPlaceholderResolver** | Provides a way to hook into path template resolution. | | This is a callback that can be used to customize or extend the resolution of path template placeholders. Enables special formatting, custom placeholders, etc.
For an example of usage, see [this sample application](https://github.com/adams85/filelogger/tree/master/samples/CustomPathPlaceholder). | +| **SynchronousWrite** | When enabled, log entries are written directly to the file on the calling thread, bypassing the background queue. | `false` | Guarantees that log entries are persisted before `ILogger.Log` returns, which is useful for crash-critical diagnostics. **Blocks the calling thread** during file I/O, so it reduces throughput. Can be combined with any `FileAccessMode`. | ### Sample JSON configuration ``` json5 @@ -294,6 +295,7 @@ The log file settings below can be specified globally (at provider level) and in }, "IncludeScopes": true, "MaxQueueSize": 100, + "SynchronousWrite": false, "Files": [ // a simple log file definition, which inherits all settings from the provider (will produce files like "default-000.log") { diff --git a/source/FileLogger/FileLogger.cs b/source/FileLogger/FileLogger.cs index b9fee50..e56ddff 100644 --- a/source/FileLogger/FileLogger.cs +++ b/source/FileLogger/FileLogger.cs @@ -180,7 +180,11 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except break; } - Processor.Enqueue(entry, fileSettings, currentState.Settings); + bool synchronousWrite = fileSettings.SynchronousWrite ?? currentState.Settings.SynchronousWrite ?? false; + if (synchronousWrite) + Processor.WriteDirectly(entry, fileSettings, currentState.Settings); + else + Processor.Enqueue(entry, fileSettings, currentState.Settings); } } diff --git a/source/FileLogger/FileLoggerProcessor.cs b/source/FileLogger/FileLoggerProcessor.cs index 94d68a1..6d1538f 100644 --- a/source/FileLogger/FileLoggerProcessor.cs +++ b/source/FileLogger/FileLoggerProcessor.cs @@ -21,6 +21,11 @@ public interface IFileLoggerProcessor : IDisposable void Enqueue(in FileLogEntry entry, ILogFileSettings fileSettings, IFileLoggerSettings settings); + /// + /// Writes a log entry directly to the file, bypassing the queue. Blocks the calling thread until the write completes. + /// + void WriteDirectly(in FileLogEntry entry, ILogFileSettings fileSettings, IFileLoggerSettings settings); + Task ResetAsync(Action? onQueuesCompleted = null); Task CompleteAsync(); } @@ -363,6 +368,37 @@ public void Enqueue(in FileLogEntry entry, ILogFileSettings fileSettings, IFileL Context.GetDiagnosticEventReporter()?.Invoke(new FileLoggerDiagnosticEvent.LogEntryDropped(this, logFile, entry)); } + public void WriteDirectly(in FileLogEntry entry, ILogFileSettings fileSettings, IFileLoggerSettings settings) + { + LogFileInfo logFile; + + lock (_logFiles) + { + if (_status == Status.Completed) + throw new ObjectDisposedException(nameof(FileLoggerProcessor)); + + if (_status != Status.Running) + return; + +#if NET6_0_OR_GREATER + ref LogFileInfo? logFileRef = ref CollectionsMarshal.GetValueRefOrAddDefault(_logFiles, fileSettings, out bool logFileExists); + logFile = logFileExists ? logFileRef! : (logFileRef = CreateLogFile(fileSettings, settings)); +#else + if (!_logFiles.TryGetValue(fileSettings, out logFile)) + _logFiles.Add(fileSettings, logFile = CreateLogFile(fileSettings, settings)); +#endif + } + + // Synchronize with the background WriteFileAsync task to avoid concurrent access to LogFileInfo state. + lock (logFile) + { + WriteEntryAsync(logFile, entry, _forcedCompleteTokenSource.Token) + .AsTask() + .GetAwaiter() + .GetResult(); + } + } + protected virtual string GetDate(string? inlineFormat, LogFileInfo logFile, in FileLogEntry entry) { return entry.Timestamp.ToLocalTime().ToString(inlineFormat ?? logFile.DateFormat ?? "yyyyMMdd", CultureInfo.InvariantCulture); diff --git a/source/FileLogger/LogFileSettings.cs b/source/FileLogger/LogFileSettings.cs index 089711f..64ef86a 100644 --- a/source/FileLogger/LogFileSettings.cs +++ b/source/FileLogger/LogFileSettings.cs @@ -30,6 +30,7 @@ public interface ILogFileSettingsBase bool? IncludeScopes { get; } int? MaxQueueSize { get; } LogFilePathPlaceholderResolver? PathPlaceholderResolver { get; } + bool? SynchronousWrite { get; } } public interface ILogFileSettings : ILogFileSettingsBase @@ -60,6 +61,7 @@ protected LogFileSettingsBase(LogFileSettingsBase other) IncludeScopes = other.IncludeScopes; MaxQueueSize = other.MaxQueueSize; PathPlaceholderResolver = other.PathPlaceholderResolver; + SynchronousWrite = other.SynchronousWrite; } public LogFileAccessMode? FileAccessMode { get; set; } @@ -117,6 +119,8 @@ public string? TextBuilderType public LogFilePathPlaceholderResolver? PathPlaceholderResolver { get; set; } + public bool? SynchronousWrite { get; set; } + #if NET8_0_OR_GREATER public abstract class BindingWrapperBase where TOptions : LogFileSettingsBase @@ -177,6 +181,12 @@ public int? MaxQueueSize get => Options.MaxQueueSize; set => Options.MaxQueueSize = value; } + + public bool? SynchronousWrite + { + get => Options.SynchronousWrite; + set => Options.SynchronousWrite = value; + } } #endif } diff --git a/test/FileLogger.Test/LoggingTest.cs b/test/FileLogger.Test/LoggingTest.cs index e4be4e9..2846727 100644 --- a/test/FileLogger.Test/LoggingTest.cs +++ b/test/FileLogger.Test/LoggingTest.cs @@ -595,4 +595,144 @@ public async Task Issue36_MinLevelShouldNotActAsAnInclusionList() "" }, lines); } + + [Fact] + public async Task SynchronousWriteToMemoryWithoutDI() + { + const string logsDirName = "Logs"; + + var fileProvider = new MemoryFileProvider(); + + var filterOptions = new LoggerFilterOptions { MinLevel = LogLevel.Trace }; + + var options = new FileLoggerOptions + { + FileAppender = new MemoryFileAppender(fileProvider), + BasePath = logsDirName, + FileAccessMode = LogFileAccessMode.OpenTemporarily, + FileEncoding = Encoding.UTF8, + SynchronousWrite = true, + Files = + [ + new LogFileOptions + { + Path = "sync.log", + MinLevel = new Dictionary + { + [LogFileOptions.DefaultCategoryName] = LogLevel.Information, + } + }, + ], + }; + + var context = new TestFileLoggerContext(CancellationToken.None, completionTimeout: Timeout.InfiniteTimeSpan); + context.SetTimestamp(new DateTime(2017, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + bool diagnosticEventReceived = false; + context.DiagnosticEvent += _ => diagnosticEventReceived = true; + + var provider = new FileLoggerProvider(context, Options.Create(options)); + + await using (provider) + using (var loggerFactory = new LoggerFactory([provider], filterOptions)) + { + ILogger logger = loggerFactory.CreateLogger(); + + logger.LogInformation("Synchronous message 1."); + logger.LogWarning(1, "Synchronous message 2."); + + // Because SynchronousWrite is true, the messages should already be written + // to the file at this point without needing to complete the provider. + var logFile = (MemoryFileInfo)fileProvider.GetFileInfo($"{logsDirName}/sync.log"); + Assert.True(logFile.Exists && !logFile.IsDirectory); + + string[] lines = logFile.ReadAllText(out Encoding encoding).Split([Environment.NewLine], StringSplitOptions.None); + Assert.Equal(Encoding.UTF8, encoding); + Assert.Equal(new[] + { + $"info: {typeof(LoggingTest)}[0] @ {context.GetTimestamp().ToLocalTime():o}", + $" Synchronous message 1.", + $"warn: {typeof(LoggingTest)}[1] @ {context.GetTimestamp().ToLocalTime():o}", + $" Synchronous message 2.", + "" + }, lines); + } + + Assert.False(diagnosticEventReceived); + } + + [Fact] + public async Task SynchronousWritePerFileOverride() + { + const string logsDirName = "Logs"; + + var fileProvider = new MemoryFileProvider(); + + var filterOptions = new LoggerFilterOptions { MinLevel = LogLevel.Trace }; + + var options = new FileLoggerOptions + { + FileAppender = new MemoryFileAppender(fileProvider), + BasePath = logsDirName, + FileAccessMode = LogFileAccessMode.OpenTemporarily, + FileEncoding = Encoding.UTF8, + Files = + [ + new LogFileOptions + { + Path = "sync.log", + SynchronousWrite = true, + MinLevel = new Dictionary + { + [LogFileOptions.DefaultCategoryName] = LogLevel.Information, + } + }, + new LogFileOptions + { + Path = "async.log", + MinLevel = new Dictionary + { + [LogFileOptions.DefaultCategoryName] = LogLevel.Information, + } + }, + ], + }; + + var context = new TestFileLoggerContext(CancellationToken.None, completionTimeout: Timeout.InfiniteTimeSpan); + context.SetTimestamp(new DateTime(2017, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + var provider = new FileLoggerProvider(context, Options.Create(options)); + + await using (provider) + using (var loggerFactory = new LoggerFactory([provider], filterOptions)) + { + ILogger logger = loggerFactory.CreateLogger(); + + logger.LogInformation("Test message."); + + // The synchronous file should have the content immediately. + var syncLogFile = (MemoryFileInfo)fileProvider.GetFileInfo($"{logsDirName}/sync.log"); + Assert.True(syncLogFile.Exists && !syncLogFile.IsDirectory); + + string[] syncLines = syncLogFile.ReadAllText(out _).Split([Environment.NewLine], StringSplitOptions.None); + Assert.Equal(new[] + { + $"info: {typeof(LoggingTest)}[0] @ {context.GetTimestamp().ToLocalTime():o}", + $" Test message.", + "" + }, syncLines); + } + + // After provider disposal, the async file should also have the content. + var asyncLogFile = (MemoryFileInfo)fileProvider.GetFileInfo($"{logsDirName}/async.log"); + Assert.True(asyncLogFile.Exists && !asyncLogFile.IsDirectory); + + string[] asyncLines = asyncLogFile.ReadAllText(out _).Split([Environment.NewLine], StringSplitOptions.None); + Assert.Equal(new[] + { + $"info: {typeof(LoggingTest)}[0] @ {context.GetTimestamp().ToLocalTime():o}", + $" Test message.", + "" + }, asyncLines); + } } diff --git a/test/FileLogger.Test/SettingsTest.cs b/test/FileLogger.Test/SettingsTest.cs index 3868467..71e6c4a 100644 --- a/test/FileLogger.Test/SettingsTest.cs +++ b/test/FileLogger.Test/SettingsTest.cs @@ -50,6 +50,7 @@ public void ParsingOptions() "{{nameof(FileLoggerOptions.TextBuilderType)}}": "{{typeof(CustomLogEntryTextBuilder).AssemblyQualifiedName}}", "{{nameof(FileLoggerOptions.IncludeScopes)}}": true, "{{nameof(FileLoggerOptions.MaxQueueSize)}}": 100, + "{{nameof(FileLoggerOptions.SynchronousWrite)}}": true, } } """; @@ -99,6 +100,7 @@ public void ParsingOptions() Assert.Equal(typeof(CustomLogEntryTextBuilder), settings.TextBuilder.GetType()); Assert.True(settings.IncludeScopes); Assert.Equal(100, settings.MaxQueueSize); + Assert.True(settings.SynchronousWrite); } [Fact]