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