diff --git a/src/WireMock.Net.Abstractions/Admin/Settings/SettingsModel.cs b/src/WireMock.Net.Abstractions/Admin/Settings/SettingsModel.cs index ad78701b3..b3f88f91a 100644 --- a/src/WireMock.Net.Abstractions/Admin/Settings/SettingsModel.cs +++ b/src/WireMock.Net.Abstractions/Admin/Settings/SettingsModel.cs @@ -34,6 +34,12 @@ public class SettingsModel /// public int? MaxRequestLogCount { get; set; } + /// + /// Gets or sets whether MaxRequestLogCount should be enforced using a background timer + /// instead of trimming synchronously on each request. + /// + public bool? SoftMaxRequestLogCountEnabled { get; set; } + /// /// Allow a Body for all HTTP Methods. (default set to false). /// diff --git a/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareOptions.cs b/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareOptions.cs index 9a7395b7a..fe5ad2e73 100644 --- a/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareOptions.cs +++ b/src/WireMock.Net.Minimal/Owin/IWireMockMiddlewareOptions.cs @@ -41,6 +41,8 @@ internal interface IWireMockMiddlewareOptions int? MaxRequestLogCount { get; set; } + bool? SoftMaxRequestLogCountEnabled { get; set; } + Action? PreWireMockMiddlewareInit { get; set; } Action? PostWireMockMiddlewareInit { get; set; } diff --git a/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs b/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs index 790a352e7..fd8e7a731 100644 --- a/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs +++ b/src/WireMock.Net.Minimal/Owin/WireMockMiddleware.cs @@ -368,12 +368,20 @@ private void LogRequest(LogEntry entry, bool addRequest) } // In case MaxRequestLogCount has a value greater than 0, try to delete existing request logs based on the count. - if (_options.MaxRequestLogCount is > 0) + // Entries are always appended chronologically, so oldest are at the front - no sort needed. + if (_options.MaxRequestLogCount is > 0 && _options.SoftMaxRequestLogCountEnabled != true) { - var logEntries = _options.LogEntries.ToList(); - foreach (var logEntry in logEntries.OrderBy(le => le.RequestMessage.DateTime).Take(logEntries.Count - _options.MaxRequestLogCount.Value)) + while (_options.LogEntries.Count > _options.MaxRequestLogCount.Value) { - TryRemoveLogEntry(logEntry); + try + { + _options.LogEntries.RemoveAt(0); + } + catch + { + // Ignore exception (can happen during stress testing) + break; + } } } diff --git a/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptions.cs b/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptions.cs index 5c24198fd..d7155cd1a 100644 --- a/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptions.cs +++ b/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptions.cs @@ -39,6 +39,8 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions public int? MaxRequestLogCount { get; set; } + public bool? SoftMaxRequestLogCountEnabled { get; set; } + public Action? PreWireMockMiddlewareInit { get; set; } public Action? PostWireMockMiddlewareInit { get; set; } diff --git a/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptionsHelper.cs b/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptionsHelper.cs index 824173e33..caae898a0 100644 --- a/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptionsHelper.cs +++ b/src/WireMock.Net.Minimal/Owin/WireMockMiddlewareOptionsHelper.cs @@ -29,6 +29,7 @@ public static IWireMockMiddlewareOptions InitFromSettings( options.HandleRequestsSynchronously = settings.HandleRequestsSynchronously; options.Logger = settings.Logger; options.MaxRequestLogCount = settings.MaxRequestLogCount; + options.SoftMaxRequestLogCountEnabled = settings.SoftMaxRequestLogCountEnabled; options.PostWireMockMiddlewareInit = settings.PostWireMockMiddlewareInit; options.PreWireMockMiddlewareInit = settings.PreWireMockMiddlewareInit; options.QueryParameterMultipleValueSupport = settings.QueryParameterMultipleValueSupport; diff --git a/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs b/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs index 45dbc141a..ca0646f01 100644 --- a/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs +++ b/src/WireMock.Net.Minimal/Server/WireMockServer.Admin.cs @@ -286,6 +286,7 @@ private IResponseMessage SettingsGet(IRequestMessage requestMessage) HandleRequestsSynchronously = _settings.HandleRequestsSynchronously, HostingScheme = _settings.HostingScheme, MaxRequestLogCount = _settings.MaxRequestLogCount, + SoftMaxRequestLogCountEnabled = _settings.SoftMaxRequestLogCountEnabled, ProtoDefinitions = _settings.ProtoDefinitions, QueryParameterMultipleValueSupport = _settings.QueryParameterMultipleValueSupport, ReadStaticMappings = _settings.ReadStaticMappings, @@ -321,6 +322,7 @@ private IResponseMessage SettingsUpdate(IRequestMessage requestMessage) _settings.DoNotSaveDynamicResponseInLogEntry = settings.DoNotSaveDynamicResponseInLogEntry; _settings.HandleRequestsSynchronously = settings.HandleRequestsSynchronously; _settings.MaxRequestLogCount = settings.MaxRequestLogCount; + _settings.SoftMaxRequestLogCountEnabled = settings.SoftMaxRequestLogCountEnabled; _settings.ProtoDefinitions = settings.ProtoDefinitions; _settings.ProxyAndRecordSettings = TinyMapperUtils.Instance.Map(settings.ProxyAndRecordSettings); _settings.QueryParameterMultipleValueSupport = settings.QueryParameterMultipleValueSupport; diff --git a/src/WireMock.Net.Minimal/Server/WireMockServer.cs b/src/WireMock.Net.Minimal/Server/WireMockServer.cs index fc0a6d857..48f41cd4e 100644 --- a/src/WireMock.Net.Minimal/Server/WireMockServer.cs +++ b/src/WireMock.Net.Minimal/Server/WireMockServer.cs @@ -46,6 +46,7 @@ public partial class WireMockServer : IWireMockServer private readonly MatcherMapper _matcherMapper; private readonly MappingToFileSaver _mappingToFileSaver; private readonly MappingBuilder _mappingBuilder; + private Timer? _softMaxLogTrimTimer; private readonly IGuidUtils _guidUtils = new GuidUtils(); private readonly IDateTimeUtils _dateTimeUtils = new DateTimeUtils(); private readonly MappingSerializer _mappingSerializer; @@ -116,6 +117,8 @@ public void Dispose() /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool disposing) { + _softMaxLogTrimTimer?.Dispose(); + _softMaxLogTrimTimer = null; DisposeEnhancedFileSystemWatcher(); _httpServer?.StopAsync(); } @@ -764,5 +767,39 @@ private void InitSettings(WireMockServerSettings settings) { SetMaxRequestLogCount(settings.MaxRequestLogCount); } + + // Start or stop the soft max log trim timer based on settings + _softMaxLogTrimTimer?.Dispose(); + _softMaxLogTrimTimer = null; + if (settings.SoftMaxRequestLogCountEnabled == true && settings.MaxRequestLogCount is > 0) + { + _softMaxLogTrimTimer = new Timer( + _ => TrimExcessLogEntries(), + null, + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5)); + } + } + + private void TrimExcessLogEntries() + { + try + { + var maxCount = _options.MaxRequestLogCount; + if (maxCount is not > 0) return; + + var logEntries = _options.LogEntries.ToList(); + var excess = logEntries.Count - maxCount.Value; + if (excess <= 0) return; + + foreach (var entry in logEntries.OrderBy(e => e.RequestMessage.DateTime).Take(excess)) + { + try { _options.LogEntries.Remove(entry); } catch { /* concurrent removal is safe */ } + } + } + catch + { + // Timer callback must never throw -- prevents timer from stopping + } } } \ No newline at end of file diff --git a/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs b/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs index 6cd639a5f..e3ac8a426 100644 --- a/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs +++ b/src/WireMock.Net.Minimal/Settings/WireMockServerSettingsParser.cs @@ -61,6 +61,7 @@ public static bool TryParseArguments(string[] args, IDictionary? environment, [N HandleRequestsSynchronously = parser.GetBoolValue(nameof(WireMockServerSettings.HandleRequestsSynchronously)), HostingScheme = parser.GetEnumValue(nameof(WireMockServerSettings.HostingScheme)), MaxRequestLogCount = parser.GetIntValue(nameof(WireMockServerSettings.MaxRequestLogCount)), + SoftMaxRequestLogCountEnabled = parser.GetBoolValue(nameof(WireMockServerSettings.SoftMaxRequestLogCountEnabled)), ProtoDefinitions = parser.GetObjectValueFromJson>(nameof(settings.ProtoDefinitions)), QueryParameterMultipleValueSupport = parser.GetEnumValue(nameof(WireMockServerSettings.QueryParameterMultipleValueSupport)), ReadStaticMappings = parser.GetBoolValue(nameof(WireMockServerSettings.ReadStaticMappings)), diff --git a/src/WireMock.Net.Minimal/Util/ConcurrentObservableCollection.cs b/src/WireMock.Net.Minimal/Util/ConcurrentObservableCollection.cs index 95625655a..d2103b722 100644 --- a/src/WireMock.Net.Minimal/Util/ConcurrentObservableCollection.cs +++ b/src/WireMock.Net.Minimal/Util/ConcurrentObservableCollection.cs @@ -77,6 +77,32 @@ protected override void MoveItem(int oldIndex, int newIndex) } } + /// + /// Gets the number of elements contained in the collection (thread-safe). + /// + public new int Count + { + get + { + lock (_lockObject) + { + return Items.Count; + } + } + } + + /// + /// Removes the element at the specified index of the collection (thread-safe). + /// + /// The zero-based index of the element to remove. + public new void RemoveAt(int index) + { + lock (_lockObject) + { + base.RemoveItem(index); + } + } + public List ToList() { lock (_lockObject) diff --git a/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs b/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs index 2f70ef58e..0c90c1c2e 100644 --- a/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs +++ b/src/WireMock.Net.Shared/Settings/WireMockServerSettings.cs @@ -139,6 +139,14 @@ public class WireMockServerSettings [PublicAPI] public int? MaxRequestLogCount { get; set; } + /// + /// Gets or sets whether MaxRequestLogCount should be enforced using a background timer + /// instead of trimming synchronously on each request. When enabled, log trimming happens + /// periodically in the background, reducing request processing latency. + /// + [PublicAPI] + public bool? SoftMaxRequestLogCountEnabled { get; set; } + /// /// Action which is called (with the IAppBuilder or IApplicationBuilder) before the internal WireMockMiddleware is initialized. [Optional] /// diff --git a/test/WireMock.Net.Tests/WireMockServer.Admin.cs b/test/WireMock.Net.Tests/WireMockServer.Admin.cs index f72bf3429..0a5450029 100644 --- a/test/WireMock.Net.Tests/WireMockServer.Admin.cs +++ b/test/WireMock.Net.Tests/WireMockServer.Admin.cs @@ -427,6 +427,25 @@ public async Task WireMockServer_Admin_Logging_SetMaxRequestLogCount_To_0_Should server.Stop(); } + [Fact] + public async Task WireMockServer_Admin_Logging_SetMaxRequestLogCount_HighVolume() + { + // Arrange + using var client = new HttpClient(); + using var server = WireMockServer.Start(); + server.SetMaxRequestLogCount(5); + + // Act - send 50 requests + for (int i = 0; i < 50; i++) + { + await client.GetAsync($"http://localhost:{server.Ports[0]}/req{i}").ConfigureAwait(false); + } + + // Assert - should have exactly 5 entries, the most recent ones + server.LogEntries.Should().HaveCount(5); + server.LogEntries.Last().RequestMessage.Path.Should().EndWith("/req49"); + } + [Fact] public async Task WireMockServer_Admin_Logging_FindLogEntries() { diff --git a/test/WireMock.Net.Tests/WireMockServer.SoftMax.cs b/test/WireMock.Net.Tests/WireMockServer.SoftMax.cs new file mode 100644 index 000000000..16534a35d --- /dev/null +++ b/test/WireMock.Net.Tests/WireMockServer.SoftMax.cs @@ -0,0 +1,294 @@ +// Copyright (c) WireMock.Net + +#if !(NET452 || NET461 || NETCOREAPP3_1) +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json.Linq; +using WireMock.Logging; +using WireMock.Owin; +using WireMock.Server; +using WireMock.Settings; +using Xunit; + +namespace WireMock.Net.Tests; + +public class WireMockServerSoftMaxTests +{ + [Fact] + public void SoftMaxRequestLogCountEnabled_DefaultsToNull() + { + // Arrange & Act + var settings = new WireMockServerSettings(); + + // Assert + settings.SoftMaxRequestLogCountEnabled.Should().BeNull(); + } + + [Fact] + public void SoftMaxRequestLogCountEnabled_CanBeSetToTrue() + { + // Arrange & Act + var settings = new WireMockServerSettings + { + SoftMaxRequestLogCountEnabled = true + }; + + // Assert + settings.SoftMaxRequestLogCountEnabled.Should().BeTrue(); + } + + [Fact] + public void SoftMaxRequestLogCountEnabled_CanBeSetToFalse() + { + // Arrange & Act + var settings = new WireMockServerSettings + { + SoftMaxRequestLogCountEnabled = false + }; + + // Assert + settings.SoftMaxRequestLogCountEnabled.Should().BeFalse(); + } + + [Fact] + public void SoftMaxRequestLogCountEnabled_PropagatesFromSettingsToMiddlewareOptions() + { + // Arrange + var settings = new WireMockServerSettings + { + SoftMaxRequestLogCountEnabled = true, + Logger = new WireMockNullLogger() + }; + + // Act + var options = WireMockMiddlewareOptionsHelper.InitFromSettings(settings); + + // Assert + options.SoftMaxRequestLogCountEnabled.Should().BeTrue(); + } + + [Fact] + public void SoftMaxRequestLogCountEnabled_PropagatesNullFromSettingsToMiddlewareOptions() + { + // Arrange + var settings = new WireMockServerSettings + { + Logger = new WireMockNullLogger() + }; + + // Act + var options = WireMockMiddlewareOptionsHelper.InitFromSettings(settings); + + // Assert + options.SoftMaxRequestLogCountEnabled.Should().BeNull(); + } + + [Fact] + public async Task SoftMaxRequestLogCountEnabled_AdminApi_SettingsGet_ReturnsValue() + { + // Arrange + var server = WireMockServer.Start(new WireMockServerSettings + { + SoftMaxRequestLogCountEnabled = true, + StartAdminInterface = true + }); + + try + { + using var httpClient = new HttpClient(); + + // Act + var response = await httpClient.GetStringAsync($"{server.Urls[0]}/__admin/settings"); + var json = JObject.Parse(response); + + // Assert + json["SoftMaxRequestLogCountEnabled"]?.Value().Should().BeTrue(); + } + finally + { + server.Stop(); + } + } + + [Fact] + public async Task SoftMaxRequestLogCountEnabled_AdminApi_SettingsUpdate_PersistsValue() + { + // Arrange + var server = WireMockServer.Start(new WireMockServerSettings + { + StartAdminInterface = true + }); + + try + { + using var httpClient = new HttpClient(); + + // Act - Update the setting + var content = new StringContent( + "{\"SoftMaxRequestLogCountEnabled\": true}", + Encoding.UTF8, + "application/json" + ); + var putResponse = await httpClient.PutAsync($"{server.Urls[0]}/__admin/settings", content); + putResponse.EnsureSuccessStatusCode(); + + // Act - Read back the setting + var response = await httpClient.GetStringAsync($"{server.Urls[0]}/__admin/settings"); + var json = JObject.Parse(response); + + // Assert + json["SoftMaxRequestLogCountEnabled"]?.Value().Should().BeTrue(); + } + finally + { + server.Stop(); + } + } + // --- Plan 02: Background timer behavior and compatibility tests --- + + [Fact] + public async Task SoftMaxDisabled_InlineTrimStillRunsOnEveryRequest() + { + // Arrange - SoftMaxRequestLogCountEnabled is NOT set (default null = disabled) + var server = WireMockServer.Start(new WireMockServerSettings + { + MaxRequestLogCount = 3 + }); + + try + { + using var httpClient = new HttpClient(); + var baseUrl = server.Urls[0]; + + // Act - Send 5 requests (exceeds MaxRequestLogCount of 3) + for (int i = 0; i < 5; i++) + { + await httpClient.GetAsync($"{baseUrl}/request{i}"); + } + + // Assert - Inline trim should have kept count at exactly MaxRequestLogCount + server.LogEntries.Count().Should().Be(3); + } + finally + { + server.Stop(); + } + } + + [Fact] + public async Task SoftMaxEnabled_LogCountCanTemporarilyExceedMax() + { + // Arrange - SoftMaxRequestLogCountEnabled = true, so inline trim is skipped + var server = WireMockServer.Start(new WireMockServerSettings + { + MaxRequestLogCount = 3, + SoftMaxRequestLogCountEnabled = true + }); + + try + { + using var httpClient = new HttpClient(); + var baseUrl = server.Urls[0]; + + // Act - Send 6 requests quickly (before timer fires) + for (int i = 0; i < 6; i++) + { + await httpClient.GetAsync($"{baseUrl}/request{i}"); + } + + // Assert - Count should exceed MaxRequestLogCount since inline trim is bypassed + server.LogEntries.Count().Should().BeGreaterThan(3); + } + finally + { + server.Stop(); + } + } + + [Fact] + public async Task SoftMaxEnabled_BackgroundTimerEventuallyTrimsExcess() + { + // Arrange - SoftMaxRequestLogCountEnabled = true with timer + var server = WireMockServer.Start(new WireMockServerSettings + { + MaxRequestLogCount = 3, + SoftMaxRequestLogCountEnabled = true + }); + + try + { + using var httpClient = new HttpClient(); + var baseUrl = server.Urls[0]; + + // Act - Send 10 requests + for (int i = 0; i < 10; i++) + { + await httpClient.GetAsync($"{baseUrl}/request{i}"); + } + + // Verify excess before timer fires + server.LogEntries.Count().Should().BeGreaterThan(3); + + // Wait for background timer to fire (timer interval is 5 seconds) + await Task.Delay(TimeSpan.FromSeconds(7)); + + // Assert - Timer should have trimmed excess entries + server.LogEntries.Count().Should().BeLessThanOrEqualTo(3); + } + finally + { + server.Stop(); + } + } + + [Fact] + public void SoftMaxEnabled_ServerDisposesCleanly() + { + // Arrange - Start server with soft max enabled (timer running) + var server = WireMockServer.Start(new WireMockServerSettings + { + MaxRequestLogCount = 5, + SoftMaxRequestLogCountEnabled = true + }); + + // Act & Assert - Dispose should not throw + var act = () => server.Dispose(); + act.Should().NotThrow(); + } + + [Fact] + public async Task SoftMaxExplicitlyFalse_InlineTrimStillRunsOnEveryRequest() + { + // Arrange - SoftMaxRequestLogCountEnabled explicitly false + var server = WireMockServer.Start(new WireMockServerSettings + { + MaxRequestLogCount = 3, + SoftMaxRequestLogCountEnabled = false + }); + + try + { + using var httpClient = new HttpClient(); + var baseUrl = server.Urls[0]; + + // Act - Send 5 requests + for (int i = 0; i < 5; i++) + { + await httpClient.GetAsync($"{baseUrl}/request{i}"); + } + + // Assert - Inline trim should keep count at exactly MaxRequestLogCount + server.LogEntries.Count().Should().Be(3); + } + finally + { + server.Stop(); + } + } +} +#endif