Add injectable IScenarioStateStore for distributed scenario state (#1430)

* Move ScenarioState to Abstractions and add IScenarioStateStore interface

ScenarioState is moved to the Abstractions project so it can be referenced
by the new IScenarioStateStore interface. The interface defines the contract
for storing and retrieving scenario states, enabling distributed implementations.

* Add InMemoryScenarioStateStore default implementation

Wraps ConcurrentDictionary with OrdinalIgnoreCase comparer, preserving
exact current behavior. The Update method encapsulates read-modify-write
so distributed implementations can make it atomic.

* Wire IScenarioStateStore into middleware options, settings, and consumers

Replace direct ConcurrentDictionary<string, ScenarioState> usage with
IScenarioStateStore across all consumer files. The store is injectable
via WireMockServerSettings.ScenarioStateStore, defaulting to the
InMemoryScenarioStateStore for backward compatibility.

* Add FileBasedScenarioStateStore for persistent scenario state

In-memory ConcurrentDictionary backed by JSON file persistence in
__admin/scenarios/. Reads from cache, mutations write through to disk.
Constructor loads existing state from disk on startup.

* Make ScenarioStateStore non-nullable with default InMemoryScenarioStateStore

Move InMemoryScenarioStateStore from WireMock.Net.Minimal to
WireMock.Net.Shared so it lives alongside WireMockServerSettings.
This allows WireMockServerSettings.ScenarioStateStore to be
non-nullable with a default value, following the same pattern as
DefaultJsonSerializer. The null-coalescing fallback in
WireMockMiddlewareOptionsHelper is no longer needed.
This commit is contained in:
m4tchl0ck
2026-03-25 13:04:44 +01:00
committed by GitHub
parent cdd33695e5
commit f919929cb7
17 changed files with 1454 additions and 801 deletions

View File

@@ -0,0 +1,26 @@
// Copyright © WireMock.Net
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
namespace WireMock.Handlers;
public interface IScenarioStateStore
{
bool TryGet(string name, [NotNullWhen(true)] out ScenarioState? state);
IReadOnlyList<ScenarioState> GetAll();
bool ContainsKey(string name);
bool TryAdd(string name, ScenarioState scenarioState);
ScenarioState AddOrUpdate(string name, Func<string, ScenarioState> addFactory, Func<string, ScenarioState, ScenarioState> updateFactory);
ScenarioState? Update(string name, Action<ScenarioState> updateAction);
bool TryRemove(string name);
void Clear();
}

View File

@@ -31,4 +31,4 @@ public class ScenarioState
/// Gets or sets the state counter.
/// </summary>
public int Counter { get; set; }
}
}

View File

@@ -0,0 +1,131 @@
// Copyright © WireMock.Net
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
namespace WireMock.Handlers;
public class FileBasedScenarioStateStore : IScenarioStateStore
{
private readonly ConcurrentDictionary<string, ScenarioState> _scenarios = new(StringComparer.OrdinalIgnoreCase);
private readonly string _scenariosFolder;
private readonly object _lock = new();
public FileBasedScenarioStateStore(string rootFolder)
{
_scenariosFolder = Path.Combine(rootFolder, "__admin", "scenarios");
Directory.CreateDirectory(_scenariosFolder);
LoadScenariosFromDisk();
}
public bool TryGet(string name, [NotNullWhen(true)] out ScenarioState? state)
{
return _scenarios.TryGetValue(name, out state);
}
public IReadOnlyList<ScenarioState> GetAll()
{
return _scenarios.Values.ToArray();
}
public bool ContainsKey(string name)
{
return _scenarios.ContainsKey(name);
}
public bool TryAdd(string name, ScenarioState scenarioState)
{
if (_scenarios.TryAdd(name, scenarioState))
{
WriteScenarioToFile(name, scenarioState);
return true;
}
return false;
}
public ScenarioState AddOrUpdate(string name, Func<string, ScenarioState> addFactory, Func<string, ScenarioState, ScenarioState> updateFactory)
{
lock (_lock)
{
var result = _scenarios.AddOrUpdate(name, addFactory, updateFactory);
WriteScenarioToFile(name, result);
return result;
}
}
public ScenarioState? Update(string name, Action<ScenarioState> updateAction)
{
lock (_lock)
{
if (_scenarios.TryGetValue(name, out var state))
{
updateAction(state);
WriteScenarioToFile(name, state);
return state;
}
return null;
}
}
public bool TryRemove(string name)
{
if (_scenarios.TryRemove(name, out _))
{
DeleteScenarioFile(name);
return true;
}
return false;
}
public void Clear()
{
_scenarios.Clear();
foreach (var file in Directory.GetFiles(_scenariosFolder, "*.json"))
{
File.Delete(file);
}
}
private string GetScenarioFilePath(string name)
{
var sanitized = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
return Path.Combine(_scenariosFolder, sanitized + ".json");
}
private void WriteScenarioToFile(string name, ScenarioState state)
{
var json = JsonConvert.SerializeObject(state, Formatting.Indented);
File.WriteAllText(GetScenarioFilePath(name), json);
}
private void DeleteScenarioFile(string name)
{
var path = GetScenarioFilePath(name);
if (File.Exists(path))
{
File.Delete(path);
}
}
private void LoadScenariosFromDisk()
{
foreach (var file in Directory.GetFiles(_scenariosFolder, "*.json"))
{
var json = File.ReadAllText(file);
var state = JsonConvert.DeserializeObject<ScenarioState>(json);
if (state != null)
{
_scenarios.TryAdd(state.Name, state);
}
}
}
}

View File

@@ -27,7 +27,7 @@ internal interface IWireMockMiddlewareOptions
ConcurrentDictionary<Guid, IMapping> Mappings { get; }
ConcurrentDictionary<string, ScenarioState> Scenarios { get; }
IScenarioStateStore ScenarioStateStore { get; set; }
ConcurrentObservableCollection<LogEntry> LogEntries { get; }

View File

@@ -89,14 +89,13 @@ internal class MappingMatcher(IWireMockMiddlewareOptions options, IRandomizerDou
private string? GetNextState(IMapping mapping)
{
// If the mapping does not have a scenario or _options.Scenarios does not contain this scenario from the mapping,
// If the mapping does not have a scenario or the store does not contain this scenario,
// just return null to indicate that there is no next state.
if (mapping.Scenario == null || !_options.Scenarios.ContainsKey(mapping.Scenario))
if (mapping.Scenario == null)
{
return null;
}
// Else just return the next state
return _options.Scenarios[mapping.Scenario].NextState;
return _options.ScenarioStateStore.TryGet(mapping.Scenario, out var state) ? state.NextState : null;
}
}

View File

@@ -81,9 +81,9 @@ internal class WireMockMiddleware(
}
// Set scenario start
if (!options.Scenarios.ContainsKey(mapping.Scenario) && mapping.IsStartState)
if (!options.ScenarioStateStore.ContainsKey(mapping.Scenario) && mapping.IsStartState)
{
options.Scenarios.TryAdd(mapping.Scenario, new ScenarioState
options.ScenarioStateStore.TryAdd(mapping.Scenario, new ScenarioState
{
Name = mapping.Scenario
});
@@ -233,20 +233,21 @@ internal class WireMockMiddleware(
private void UpdateScenarioState(IMapping mapping)
{
var scenario = options.Scenarios[mapping.Scenario!];
// Increase the number of times this state has been executed
scenario.Counter++;
// Only if the number of times this state is executed equals the required StateTimes, proceed to next state and reset the counter to 0
if (scenario.Counter == (mapping.TimesInSameState ?? 1))
options.ScenarioStateStore.Update(mapping.Scenario!, scenario =>
{
scenario.NextState = mapping.NextState;
scenario.Counter = 0;
}
// Increase the number of times this state has been executed
scenario.Counter++;
// Else just update Started and Finished
scenario.Started = true;
scenario.Finished = mapping.NextState == null;
// Only if the number of times this state is executed equals the required StateTimes, proceed to next state and reset the counter to 0
if (scenario.Counter == (mapping.TimesInSameState ?? 1))
{
scenario.NextState = mapping.NextState;
scenario.Counter = 0;
}
// Else just update Started and Finished
scenario.Started = true;
scenario.Finished = mapping.NextState == null;
});
}
}
}

View File

@@ -27,7 +27,7 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions
public ConcurrentDictionary<Guid, IMapping> Mappings { get; } = new ConcurrentDictionary<Guid, IMapping>();
public ConcurrentDictionary<string, ScenarioState> Scenarios { get; } = new(StringComparer.OrdinalIgnoreCase);
public IScenarioStateStore ScenarioStateStore { get; set; } = new InMemoryScenarioStateStore();
public ConcurrentObservableCollection<LogEntry> LogEntries { get; } = new();

View File

@@ -27,6 +27,7 @@ internal static class WireMockMiddlewareOptionsHelper
options.FileSystemHandler = settings.FileSystemHandler;
options.HandleRequestsSynchronously = settings.HandleRequestsSynchronously;
options.Logger = settings.Logger;
options.ScenarioStateStore = settings.ScenarioStateStore;
options.MaxRequestLogCount = settings.MaxRequestLogCount;
options.PostWireMockMiddlewareInit = settings.PostWireMockMiddlewareInit;
options.PreWireMockMiddlewareInit = settings.PreWireMockMiddlewareInit;

View File

@@ -672,7 +672,7 @@ public partial class WireMockServer
#region Scenarios
private IResponseMessage ScenariosGet(HttpContext _, IRequestMessage requestMessage)
{
var scenariosStates = Scenarios.Values.Select(s => new ScenarioStateModel
var scenariosStates = Scenarios.Select(s => new ScenarioStateModel
{
Name = s.Name,
NextState = s.NextState,
@@ -705,7 +705,7 @@ public partial class WireMockServer
private IResponseMessage ScenariosSetState(HttpContext _, IRequestMessage requestMessage)
{
var name = Enumerable.Reverse(requestMessage.Path.Split('/')).Skip(1).First();
if (!_options.Scenarios.ContainsKey(name))
if (!_options.ScenarioStateStore.ContainsKey(name))
{
ResponseMessageBuilder.Create(HttpStatusCode.NotFound, $"No scenario found by name '{name}'.");
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
// Copyright © WireMock.Net
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace WireMock.Handlers;
public class InMemoryScenarioStateStore : IScenarioStateStore
{
private readonly ConcurrentDictionary<string, ScenarioState> _scenarios = new(StringComparer.OrdinalIgnoreCase);
public bool TryGet(string name, [NotNullWhen(true)] out ScenarioState? state)
{
return _scenarios.TryGetValue(name, out state);
}
public IReadOnlyList<ScenarioState> GetAll()
{
return _scenarios.Values.ToArray();
}
public bool ContainsKey(string name)
{
return _scenarios.ContainsKey(name);
}
public bool TryAdd(string name, ScenarioState scenarioState)
{
return _scenarios.TryAdd(name, scenarioState);
}
public ScenarioState AddOrUpdate(string name, Func<string, ScenarioState> addFactory, Func<string, ScenarioState, ScenarioState> updateFactory)
{
return _scenarios.AddOrUpdate(name, addFactory, updateFactory);
}
public ScenarioState? Update(string name, Action<ScenarioState> updateAction)
{
if (_scenarios.TryGetValue(name, out var state))
{
updateAction(state);
return state;
}
return null;
}
public bool TryRemove(string name)
{
return _scenarios.TryRemove(name, out _);
}
public void Clear()
{
_scenarios.Clear();
}
}

View File

@@ -175,6 +175,10 @@ public class WireMockServerSettings
[JsonIgnore]
public IFileSystemHandler FileSystemHandler { get; set; } = null!;
[PublicAPI]
[JsonIgnore]
public IScenarioStateStore ScenarioStateStore { get; set; } = new InMemoryScenarioStateStore();
/// <summary>
/// Action which can be used to add additional Handlebars registrations. [Optional]
/// </summary>