diff --git a/CHANGELOG.md b/CHANGELOG.md
index 124369f..80fdb7d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -73,6 +73,13 @@ its code behaves exactly as it did.
Workflows URL and an Adaptive Card, because the Office 365 connectors and the `MessageCard`
payload they took are retired. PagerDuty resolves the incident it opened rather than raising a
second one: `Alert.DeduplicationKey` and `Alert.IsRecovery` already existed for exactly that.
+- **`Healthie.NET.Redis`.** State is written on every tick of every checker, and Redis is the store
+ that does not mind: a relational provider does a round trip to a disk-backed engine for each of
+ those writes, and this does one to memory. One hash per checker holding the state, the type it was
+ written as, and a version, so the compare and the write are a single Lua script -- Redis runs one
+ to completion without interleaving anything else, which is the guarantee a read-then-write cannot
+ give. Durability is whatever the Redis is configured for, and the package README says so rather
+ than implying more.
- **Schedules.** `PulseSchedule` says either "every this long" or "on this cron expression", and
sits alongside `PulseInterval` rather than replacing it. The enum stopped at five minutes, which
is short of what a certificate-expiry or disk-space check wants. Cron is standard Unix syntax and
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6286295..5819f11 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -52,6 +52,7 @@
+
@@ -67,6 +68,7 @@
+
diff --git a/Healthie.NET.sln b/Healthie.NET.sln
index caadf65..d61c039 100644
--- a/Healthie.NET.sln
+++ b/Healthie.NET.sln
@@ -66,6 +66,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.Scheduling.Tempora
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.LeaderElection", "src\Healthie.LeaderElection\Healthie.LeaderElection.csproj", "{0EF6CF0A-9019-4A1D-A668-6C4982832BC6}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.StateProviding.Redis", "src\Healthie.StateProviding.Redis\Healthie.StateProviding.Redis.csproj", "{AE94818D-ABCF-4B84-AF49-525A16D7890C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -376,6 +378,18 @@ Global
{0EF6CF0A-9019-4A1D-A668-6C4982832BC6}.Release|x64.Build.0 = Release|Any CPU
{0EF6CF0A-9019-4A1D-A668-6C4982832BC6}.Release|x86.ActiveCfg = Release|Any CPU
{0EF6CF0A-9019-4A1D-A668-6C4982832BC6}.Release|x86.Build.0 = Release|Any CPU
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C}.Debug|x64.Build.0 = Debug|Any CPU
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C}.Debug|x86.Build.0 = Debug|Any CPU
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C}.Release|x64.ActiveCfg = Release|Any CPU
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C}.Release|x64.Build.0 = Release|Any CPU
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C}.Release|x86.ActiveCfg = Release|Any CPU
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -406,6 +420,7 @@ Global
{A1ABC955-B526-4C9A-B75C-194795FD4D33} = {9BAF98D2-CC85-4721-9C67-88576218F61C}
{4E2A9262-B7AC-4E51-9CB1-EDD8D81D2CBE} = {9BAF98D2-CC85-4721-9C67-88576218F61C}
{0EF6CF0A-9019-4A1D-A668-6C4982832BC6} = {9BAF98D2-CC85-4721-9C67-88576218F61C}
+ {AE94818D-ABCF-4B84-AF49-525A16D7890C} = {9BAF98D2-CC85-4721-9C67-88576218F61C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0EE98644-4A9C-4D31-8145-997C8B5A8119}
diff --git a/README.md b/README.md
index c657e96..dc43153 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,7 @@ Everything heavy is opt-in: `Healthie.NET.Abstractions` carries exactly one depe
| **[Healthie.NET.Postgres](https://www.nuget.org/packages/Healthie.NET.Postgres)** | PostgreSQL `IStateProvider` implementation. Also covers Databricks Lakebase, which is managed PostgreSQL. | `dotnet add package Healthie.NET.Postgres` |
| **[Healthie.NET.SqlServer](https://www.nuget.org/packages/Healthie.NET.SqlServer)** | SQL Server and Azure SQL `IStateProvider` implementation. | `dotnet add package Healthie.NET.SqlServer` |
| **[Healthie.NET.Sqlite](https://www.nuget.org/packages/Healthie.NET.Sqlite)** | SQLite `IStateProvider` implementation -- durable state with no server to stand up. | `dotnet add package Healthie.NET.Sqlite` |
+| **[Healthie.NET.Redis](https://www.nuget.org/packages/Healthie.NET.Redis)** | Redis `IStateProvider` implementation -- the fastest option for state written on every tick. | `dotnet add package Healthie.NET.Redis` |
| **[Healthie.NET.Relational](https://www.nuget.org/packages/Healthie.NET.Relational)** | The engine behind the three above. Use it directly for any other database with an ADO.NET driver. | `dotnet add package Healthie.NET.Relational` |
| **[Healthie.NET.Dashboard](https://www.nuget.org/packages/Healthie.NET.Dashboard)** | Blazor health monitoring dashboard (Razor Class Library, zero third-party dependencies). | `dotnet add package Healthie.NET.Dashboard` |
| **[Healthie.NET.Quartz](https://www.nuget.org/packages/Healthie.NET.Quartz)** | Quartz.NET `IPulseScheduler` implementation for CRON-based scheduling. | `dotnet add package Healthie.NET.Quartz` |
@@ -824,11 +825,18 @@ Upgrading from v1.x? See the [v1 to v2 migration guide](https://github.com/ivanv
Shipped since 3.1.4: alerting on transitions, OpenTelemetry metrics and traces, arbitrary intervals
and cron, PostgreSQL / SQL Server / SQLite state providers, Hangfire / Coravel / Temporal
scheduling, ready-made checkers, uptime reporting, leader election, optimistic concurrency
-on `IStateProvider`, `HealthChanged` on the state-changed event, and Slack / Teams /
-PagerDuty alert sinks. What is left:
-
-- **A Redis state provider** -- the fastest option for state written on every tick, and a natural
- lease store for leader election.
+on `IStateProvider`, `HealthChanged` on the state-changed event, Slack / Teams / PagerDuty alert
+sinks, and a Redis state provider.
+
+Every item that was on this list has shipped. Two open questions are decisions rather than features,
+and both are deliberate as they stand:
+
+- **`Healthie.Api` requires no authorization unless the host asks for it**, and the dashboard's
+ `HealthieUIOptions.AllowMutations` defaults to `true`. A host that maps either and does nothing
+ else exposes read *and* write control of its checkers. Both are documented, and changing either
+ default is a behaviour break for every existing consumer.
+- **Restore is not pinned by hash.** The fix is NuGet lock files, which means every package change
+ needs the lock updated and CI running in locked mode.
---
diff --git a/src/Healthie.StateProviding.Redis/Healthie.StateProviding.Redis.csproj b/src/Healthie.StateProviding.Redis/Healthie.StateProviding.Redis.csproj
new file mode 100644
index 0000000..0101139
--- /dev/null
+++ b/src/Healthie.StateProviding.Redis/Healthie.StateProviding.Redis.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Healthie.NET.Redis
+ Redis state provider for Healthie.NET -- the fastest durable option for state written on every tick, with optimistic concurrency through a Lua compare-and-swap.
+ $(HealthieCommonTags);redis;cache;state
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Healthie.StateProviding.Redis/README.md b/src/Healthie.StateProviding.Redis/README.md
new file mode 100644
index 0000000..3c78786
--- /dev/null
+++ b/src/Healthie.StateProviding.Redis/README.md
@@ -0,0 +1,65 @@
+[](https://www.nuget.org/packages/Healthie.NET.Redis)
+
+# Healthie.NET.Redis
+
+Redis state provider for [Healthie.NET](https://github.com/ivanvyd/Healthie.NET).
+
+State is written on **every tick of every checker**. A relational provider does a round trip to a disk-backed engine for each of those; this does one to memory. That is the whole reason to pick it.
+
+## Installation
+
+```shell
+dotnet add package Healthie.NET.Redis
+```
+
+## Usage
+
+```csharp
+using Healthie.StateProviding.Redis;
+
+builder.Services
+ .AddHealthie(typeof(Program).Assembly)
+ .AddHealthieRedis("localhost:6379");
+```
+
+If your application already registers an `IConnectionMultiplexer` — for its own cache, or through `AddStackExchangeRedisCache` — share it instead of opening a second connection to the same server:
+
+```csharp
+builder.Services.AddHealthieRedis(); // uses the registered IConnectionMultiplexer
+```
+
+Every key is prefixed, `healthie:state:` by default, so the provider stays out of the way of whatever else lives on that server. Pass your own as the last argument.
+
+## Optimistic concurrency
+
+`SupportsOptimisticConcurrency` is `true`. A write can be made conditional on the state not having changed since it was read, which is what stops a check overwriting a setting somebody changed from the dashboard.
+
+The compare and the write are a **Lua script**, which Redis runs to completion without interleaving anything else:
+
+```lua
+if redis.call('HGET', KEYS[1], 'version') ~= ARGV[3] then
+ return 0
+end
+redis.call('HSET', KEYS[1], 'value', ARGV[1], 'state_type', ARGV[2], 'version', ARGV[4])
+return 1
+```
+
+A `WATCH`/`MULTI`/`EXEC` transaction would also work, but it needs a retry loop of its own around the optimistic failure, and it holds state on the connection. A script needs neither.
+
+Creating is the same shape against `EXISTS`, so two writers that both find nothing cannot both create — the case where there is no version to compare and a lost update would otherwise slip through.
+
+## How it works
+
+- One **hash** per checker, at `{prefix}{checkerName}`, holding `value` (the state as JSON), `state_type`, and `version`. A hash rather than a plain string so the version can be swapped in the same command as the write.
+- `GetStatesAsync` issues every read before awaiting any. StackExchange.Redis pipelines on one connection, so listing every checker on the dashboard costs one round trip rather than one per checker.
+- Each hash records the type its state was written as, and reading it as a different type throws rather than returning a mismatched state. The comparison is on `Type.FullName`, not the assembly-qualified name — that embeds the assembly version, and this library's version changes with every release, so comparing it would make state written by one release unreadable by the next.
+- A hash written before this provider versioned its writes has no `version` field. It reports as unversioned, and a caller writes it unconditionally exactly as it did before; the next write gives it one.
+
+## Durability is Redis's, not this package's
+
+Redis is in memory. Whether state survives a restart is your Redis configuration — RDB snapshots, AOF, or a managed offering's own guarantees — not something this provider can promise. For pulse checker state that is usually the right trade: the interesting state is the current one, and a lost history is a gap in a chart rather than an outage. If it is not the right trade for you, use PostgreSQL, SQL Server or CosmosDB.
+
+## See also
+
+- [Healthie.NET](https://www.nuget.org/packages/Healthie.NET) — the metapackage
+- [Healthie.NET.Postgres](https://www.nuget.org/packages/Healthie.NET.Postgres) / [Healthie.NET.SqlServer](https://www.nuget.org/packages/Healthie.NET.SqlServer) / [Healthie.NET.CosmosDb](https://www.nuget.org/packages/Healthie.NET.CosmosDb) — the durable-by-default alternatives
diff --git a/src/Healthie.StateProviding.Redis/RedisStateProvider.cs b/src/Healthie.StateProviding.Redis/RedisStateProvider.cs
new file mode 100644
index 0000000..2803ea8
--- /dev/null
+++ b/src/Healthie.StateProviding.Redis/RedisStateProvider.cs
@@ -0,0 +1,263 @@
+using Healthie.Abstractions.StateProviding;
+using StackExchange.Redis;
+using System.Text.Json;
+
+namespace Healthie.StateProviding.Redis;
+
+///
+/// Stores pulse checker state in Redis.
+///
+///
+///
+/// The fastest of the durable providers, because state is written on every tick of every checker and
+/// Redis is the store that does not mind. A relational provider does a round trip to a disk-backed
+/// engine for each of those writes; this does one to memory.
+///
+///
+/// One hash per checker, holding the state, the type it was written as, and a version. A hash rather
+/// than a plain string so the version can be compared and swapped in the same command as the write
+/// -- is a Lua script, which Redis runs to completion without
+/// interleaving anything else, so the compare and the write cannot come apart.
+///
+///
+public sealed class RedisStateProvider : IStateProvider
+{
+ private const string ValueField = "value";
+ private const string TypeField = "state_type";
+ private const string VersionField = "version";
+
+ ///
+ /// Writes only if the stored version is still the one the caller read.
+ ///
+ ///
+ /// Returns 1 when the write landed and 0 when it was refused, which is the same shape the
+ /// relational providers get from rows-affected. Redis runs a script atomically, so nothing can
+ /// change the version between the HGET and the HSET.
+ ///
+ private const string ConditionalWriteScript = """
+ if redis.call('HGET', KEYS[1], 'version') ~= ARGV[3] then
+ return 0
+ end
+ redis.call('HSET', KEYS[1], 'value', ARGV[1], 'state_type', ARGV[2], 'version', ARGV[4])
+ return 1
+ """;
+
+ ///
+ /// Writes only if nothing is stored yet.
+ ///
+ ///
+ /// The create half of the guarantee: two writers both finding nothing must not both create, or
+ /// one of the two changes is lost. EXISTS and the write are in one script for the same
+ /// reason as above.
+ ///
+ private const string CreateIfAbsentScript = """
+ if redis.call('EXISTS', KEYS[1]) == 1 then
+ return 0
+ end
+ redis.call('HSET', KEYS[1], 'value', ARGV[1], 'state_type', ARGV[2], 'version', ARGV[3])
+ return 1
+ """;
+
+ private readonly IConnectionMultiplexer _connection;
+ private readonly string _keyPrefix;
+
+ /// Initializes a new instance of the class.
+ /// The connection to Redis.
+ /// Prefixed to every key this provider owns.
+ public RedisStateProvider(IConnectionMultiplexer connection, string keyPrefix)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(keyPrefix);
+
+ _connection = connection ?? throw new ArgumentNullException(nameof(connection));
+ _keyPrefix = keyPrefix;
+ }
+
+ ///
+ public bool SupportsOptimisticConcurrency => true;
+
+ private IDatabase Database => _connection.GetDatabase();
+
+ private RedisKey KeyFor(string name) => _keyPrefix + name;
+
+ /// A fresh version for a write.
+ ///
+ /// Generated here rather than by Redis, which has no per-field revision to read. It is opaque to
+ /// callers, so only its uniqueness matters.
+ ///
+ private static string NewVersion() => Guid.NewGuid().ToString("N");
+
+ ///
+ public async Task GetStateAsync(string name, CancellationToken cancellationToken = default)
+ {
+ var entry = await GetStateEntryAsync(name, cancellationToken).ConfigureAwait(false);
+
+ return entry is null ? default : entry.Value;
+ }
+
+ ///
+ public async Task?> GetStateEntryAsync(
+ string name,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ var fields = await Database
+ .HashGetAsync(KeyFor(name), [ValueField, TypeField, VersionField])
+ .ConfigureAwait(false);
+
+ if (fields[0].IsNull)
+ {
+ return null;
+ }
+
+ EnsureStoredTypeMatches(name, fields[1].IsNull ? null : fields[1].ToString());
+
+ var value = JsonSerializer.Deserialize(fields[0].ToString());
+
+ if (value is null)
+ {
+ return null;
+ }
+
+ // Null for a hash written before this provider versioned its writes. Reported as unversioned
+ // rather than invented, so a caller writes it unconditionally as it did before.
+ var version = fields[2].IsNull ? null : fields[2].ToString();
+
+ return new StateEntry(value, version);
+ }
+
+ ///
+ public Task SetStateAsync(string name, TState state, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ return Database.HashSetAsync(
+ KeyFor(name),
+ [
+ new HashEntry(ValueField, JsonSerializer.Serialize(state)),
+ new HashEntry(TypeField, typeof(TState).FullName),
+ new HashEntry(VersionField, NewVersion()),
+ ]);
+ }
+
+ ///
+ public async Task TrySetStateAsync(
+ string name,
+ TState state,
+ string? expectedVersion,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ if (expectedVersion is null)
+ {
+ await SetStateAsync(name, state, cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ var json = JsonSerializer.Serialize(state);
+ var type = typeof(TState).FullName;
+ var version = NewVersion();
+
+ var script = expectedVersion == IStateProvider.AbsentVersion ? CreateIfAbsentScript : ConditionalWriteScript;
+
+ RedisValue[] arguments = expectedVersion == IStateProvider.AbsentVersion
+ ? [json, type, version]
+ : [json, type, expectedVersion, version];
+
+ var result = await Database
+ .ScriptEvaluateAsync(script, [KeyFor(name)], arguments)
+ .ConfigureAwait(false);
+
+ return (long)result == 1;
+ }
+
+ ///
+ public Task DeleteStateAsync(string name, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ // Redis reports whether the key was there, which is the only thing a caller can act on.
+ return Database.KeyDeleteAsync(KeyFor(name));
+ }
+
+ ///
+ ///
+ /// Every read is issued before any is awaited. StackExchange.Redis pipelines commands on one
+ /// connection, so this costs one round trip rather than one per checker -- which is the whole
+ /// point of the bulk read on a dashboard that lists every checker.
+ ///
+ public async Task> GetStatesAsync(
+ IEnumerable names,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(names);
+
+ var wanted = names.Distinct(StringComparer.Ordinal).ToList();
+ var states = new Dictionary(StringComparer.Ordinal);
+
+ if (wanted.Count == 0)
+ {
+ return states;
+ }
+
+ var database = Database;
+
+ var reads = wanted
+ .Select(name => database.HashGetAsync(KeyFor(name), [ValueField, TypeField]))
+ .ToList();
+
+ var results = await Task.WhenAll(reads).ConfigureAwait(false);
+
+ for (var i = 0; i < wanted.Count; i++)
+ {
+ var fields = results[i];
+
+ if (fields[0].IsNull)
+ {
+ continue;
+ }
+
+ EnsureStoredTypeMatches(wanted[i], fields[1].IsNull ? null : fields[1].ToString());
+
+ if (JsonSerializer.Deserialize(fields[0].ToString()) is { } state)
+ {
+ states[wanted[i]] = state;
+ }
+ }
+
+ return states;
+ }
+
+ ///
+ /// Refuses to return a state as a type it was not written as.
+ ///
+ ///
+ /// Compares the full name rather than the assembly-qualified one, because that embeds the
+ /// assembly version and this library's version changes with every release -- comparing it would
+ /// make state written by one release unreadable by the next. The same reasoning, and the same
+ /// prefix allowance for releases that wrote the longer form, as the CosmosDB provider.
+ ///
+ internal static void EnsureStoredTypeMatches(string name, string? storedStateType)
+ {
+ // A hash written before the type was recorded carries none to compare against.
+ if (string.IsNullOrWhiteSpace(storedStateType))
+ {
+ return;
+ }
+
+ var expectedStateType = typeof(TState).FullName;
+
+ if (expectedStateType is null
+ || string.Equals(storedStateType, expectedStateType, StringComparison.Ordinal)
+ || storedStateType.StartsWith(expectedStateType + ",", StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ throw new InvalidOperationException(
+ $"The state stored for pulse checker '{name}' was written as '{storedStateType}', and was " +
+ $"read as '{expectedStateType}'. Reading it as a different type would return a state that " +
+ "is not the one that was stored.");
+ }
+}
diff --git a/src/Healthie.StateProviding.Redis/StartupExtensions.cs b/src/Healthie.StateProviding.Redis/StartupExtensions.cs
new file mode 100644
index 0000000..6d85055
--- /dev/null
+++ b/src/Healthie.StateProviding.Redis/StartupExtensions.cs
@@ -0,0 +1,71 @@
+using Healthie.Abstractions.StateProviding;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using StackExchange.Redis;
+
+namespace Healthie.StateProviding.Redis;
+
+///
+/// Extension methods for registering the Redis state provider with dependency injection.
+///
+public static class StartupExtensions
+{
+ /// The key prefix used when none is given.
+ public const string DefaultKeyPrefix = "healthie:state:";
+
+ ///
+ /// Registers a state provider backed by Redis.
+ ///
+ /// The service collection to register with.
+ ///
+ /// A StackExchange.Redis configuration string, such as localhost:6379.
+ ///
+ /// Prefixed to every key the provider owns.
+ /// The service collection for fluent chaining.
+ ///
+ /// Opens the connection here, once, and keeps it: an is
+ /// designed to be shared for the life of the application and is expensive to build per call.
+ /// Registered as a singleton so the container disposes it on shutdown.
+ ///
+ public static IServiceCollection AddHealthieRedis(
+ this IServiceCollection services,
+ string configuration,
+ string keyPrefix = DefaultKeyPrefix)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentException.ThrowIfNullOrWhiteSpace(configuration);
+
+ services.TryAddSingleton(_ => ConnectionMultiplexer.Connect(configuration));
+
+ return services.AddHealthieRedis(keyPrefix);
+ }
+
+ ///
+ /// Registers a state provider backed by a Redis connection the application already owns.
+ ///
+ /// The service collection to register with.
+ /// Prefixed to every key the provider owns.
+ /// The service collection for fluent chaining.
+ ///
+ ///
+ /// For an application that already registers an -- for its
+ /// own cache, or through AddStackExchangeRedisCache. Sharing it is the point: a second
+ /// connection to the same server buys nothing.
+ ///
+ ///
+ /// Registered with AddSingleton rather than TryAddSingleton on purpose: the
+ /// built-in in-memory provider registers with TryAdd, so whichever call comes first, this
+ /// one wins.
+ ///
+ ///
+ public static IServiceCollection AddHealthieRedis(
+ this IServiceCollection services,
+ string keyPrefix = DefaultKeyPrefix)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentException.ThrowIfNullOrWhiteSpace(keyPrefix);
+
+ return services.AddSingleton(provider =>
+ new RedisStateProvider(provider.GetRequiredService(), keyPrefix));
+ }
+}
diff --git a/tests/Healthie.Tests.Unit/Healthie.Tests.Unit.csproj b/tests/Healthie.Tests.Unit/Healthie.Tests.Unit.csproj
index d7abaa1..4d9c72d 100644
--- a/tests/Healthie.Tests.Unit/Healthie.Tests.Unit.csproj
+++ b/tests/Healthie.Tests.Unit/Healthie.Tests.Unit.csproj
@@ -16,6 +16,7 @@
+
@@ -46,6 +47,7 @@
+
diff --git a/tests/Healthie.Tests.Unit/RedisStateProviderTests.cs b/tests/Healthie.Tests.Unit/RedisStateProviderTests.cs
new file mode 100644
index 0000000..2daa91f
--- /dev/null
+++ b/tests/Healthie.Tests.Unit/RedisStateProviderTests.cs
@@ -0,0 +1,329 @@
+using Healthie.Abstractions.Enums;
+using Healthie.Abstractions.Models;
+using Healthie.Abstractions.StateProviding;
+using Healthie.StateProviding.Redis;
+using StackExchange.Redis;
+using Testcontainers.Redis;
+
+namespace Healthie.Tests.Unit;
+
+///
+/// Driven against a real Redis in a container, because the guarantees under test are Redis's own:
+/// that a Lua script runs to completion without interleaving, and that HSET and EXISTS
+/// inside one see a consistent view. A fake would only re-state what the provider already believes.
+///
+///
+/// Skipped rather than failed when there is no container runtime, so the suite still passes on a
+/// machine without Docker -- the same bargain the CosmosDB combinations strike in the E2E project.
+///
+public sealed class RedisStateProviderTests : IAsyncLifetime
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private RedisContainer? _redis;
+ private IConnectionMultiplexer? _connection;
+ private string? _unavailable;
+
+ private RedisStateProvider Provider(string prefix = "test:") =>
+ new(_connection!, prefix);
+
+ public async ValueTask InitializeAsync()
+ {
+ try
+ {
+ _redis = new RedisBuilder().WithImage("redis:7-alpine").Build();
+ await _redis.StartAsync(Ct);
+ _connection = await ConnectionMultiplexer.ConnectAsync(_redis.GetConnectionString());
+ }
+ catch (Exception ex)
+ {
+ // No Docker, no daemon, no network for the image: not a failing provider.
+ _unavailable = $"{ex.GetType().Name}: {ex.Message}";
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_connection is not null)
+ {
+ await _connection.DisposeAsync();
+ }
+
+ if (_redis is not null)
+ {
+ await _redis.DisposeAsync();
+ }
+ }
+
+ private bool Unavailable()
+ {
+ Assert.SkipWhen(_unavailable is not null, $"Redis is not reachable. {_unavailable}");
+ return false;
+ }
+
+ [Fact]
+ public async Task StateSurvivesARoundTrip()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ var provider = Provider();
+ await provider.SetStateAsync("x", new PulseCheckerState(PulseInterval.Every30Seconds), Ct);
+
+ var state = await provider.GetStateAsync("x", Ct);
+
+ Assert.Equal(PulseInterval.Every30Seconds, state!.Interval);
+ }
+
+ [Fact]
+ public async Task NothingStored_ReadsAsNull()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ Assert.Null(await Provider().GetStateAsync("never-written", Ct));
+ Assert.Null(await Provider().GetStateEntryAsync("never-written", Ct));
+ }
+
+ [Fact]
+ public async Task TheProvider_SaysItCanVersionAWrite()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ Assert.True(Provider().SupportsOptimisticConcurrency);
+ }
+
+ [Fact]
+ public async Task AReadEntry_CarriesAVersion()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ var provider = Provider();
+ await provider.SetStateAsync("x", new PulseCheckerState(), Ct);
+
+ Assert.True((await provider.GetStateEntryAsync("x", Ct))!.IsVersioned);
+ }
+
+ [Fact]
+ public async Task EveryWrite_MovesTheVersionOn()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ var provider = Provider();
+ await provider.SetStateAsync("x", new PulseCheckerState(), Ct);
+ var first = (await provider.GetStateEntryAsync("x", Ct))!.Version;
+
+ await provider.SetStateAsync("x", new PulseCheckerState(PulseInterval.Every2Seconds), Ct);
+ var second = (await provider.GetStateEntryAsync("x", Ct))!.Version;
+
+ Assert.NotEqual(first, second);
+ }
+
+ [Fact]
+ public async Task AWriteFromACurrentRead_Lands()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ var provider = Provider();
+ await provider.SetStateAsync("x", new PulseCheckerState(), Ct);
+
+ var entry = await provider.GetStateEntryAsync("x", Ct);
+ entry!.Value.IsPinned = true;
+
+ Assert.True(await provider.TrySetStateAsync("x", entry.Value, entry.Version, Ct));
+ Assert.True((await provider.GetStateAsync("x", Ct))!.IsPinned);
+ }
+
+ ///
+ /// The point of the whole thing: a write made from a state that has since moved on is refused
+ /// rather than overwriting whoever moved it.
+ ///
+ [Fact]
+ public async Task AWriteFromAStaleRead_IsRefused()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ var provider = Provider();
+ await provider.SetStateAsync("x", new PulseCheckerState(PulseInterval.EverySecond), Ct);
+
+ var stale = await provider.GetStateEntryAsync("x", Ct);
+
+ // Somebody else writes in between.
+ await provider.SetStateAsync("x", new PulseCheckerState(PulseInterval.Every5Minutes), Ct);
+
+ Assert.False(await provider.TrySetStateAsync("x", stale!.Value, stale.Version, Ct));
+
+ // And their write is still there.
+ Assert.Equal(
+ PulseInterval.Every5Minutes,
+ (await provider.GetStateAsync("x", Ct))!.Interval);
+ }
+
+ [Fact]
+ public async Task AConditionalWriteAgainstAMissingKey_IsRefusedRatherThanCreating()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ var provider = Provider();
+
+ Assert.False(await provider.TrySetStateAsync("gone", new PulseCheckerState(), "made-up-version", Ct));
+ Assert.Null(await provider.GetStateAsync("gone", Ct));
+ }
+
+ [Fact]
+ public async Task ACreateThatLosesToAnotherCreate_IsRefused()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ var provider = Provider();
+
+ Assert.True(await provider.TrySetStateAsync(
+ "x", new PulseCheckerState { Group = "first" }, IStateProvider.AbsentVersion, Ct));
+
+ Assert.False(await provider.TrySetStateAsync(
+ "x", new PulseCheckerState { Group = "second" }, IStateProvider.AbsentVersion, Ct));
+
+ Assert.Equal("first", (await provider.GetStateAsync("x", Ct))!.Group);
+ }
+
+ ///
+ /// Many writers, one checker, all reading the same version and racing to write it. Exactly one
+ /// may win. This is the property a Lua script buys that a read-then-write cannot.
+ ///
+ [Fact]
+ public async Task WhenManyWritersRaceFromOneRead_ExactlyOneWins()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ var provider = Provider();
+ await provider.SetStateAsync("contended", new PulseCheckerState(), Ct);
+
+ var entry = await provider.GetStateEntryAsync("contended", Ct);
+
+ const int Writers = 12;
+ using var readyToRace = new Barrier(Writers);
+
+ var attempts = await Task.WhenAll(Enumerable.Range(0, Writers).Select(i => Task.Run(
+ async () =>
+ {
+ readyToRace.SignalAndWait(Ct);
+ return await provider.TrySetStateAsync(
+ "contended",
+ new PulseCheckerState { Group = $"writer-{i}" },
+ entry!.Version,
+ Ct);
+ },
+ Ct)));
+
+ Assert.Equal(1, attempts.Count(won => won));
+ }
+
+ [Fact]
+ public async Task DeleteStateAsync_SaysWhetherThereWasAnythingToRemove()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ var provider = Provider();
+ await provider.SetStateAsync("x", new PulseCheckerState(), Ct);
+
+ Assert.True(await provider.DeleteStateAsync("x", Ct));
+ Assert.False(await provider.DeleteStateAsync("x", Ct));
+ Assert.Null(await provider.GetStateAsync("x", Ct));
+ }
+
+ [Fact]
+ public async Task GetStatesAsync_ReadsManyAndOmitsWhatIsNotThere()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ var provider = Provider();
+ await provider.SetStateAsync("a", new PulseCheckerState(PulseInterval.EverySecond), Ct);
+ await provider.SetStateAsync("b", new PulseCheckerState(PulseInterval.Every5Minutes), Ct);
+
+ var states = await provider.GetStatesAsync(["a", "b", "absent"], Ct);
+
+ Assert.Equal(2, states.Count);
+ Assert.Equal(PulseInterval.EverySecond, states["a"].Interval);
+ Assert.Equal(PulseInterval.Every5Minutes, states["b"].Interval);
+ Assert.False(states.ContainsKey("absent"));
+ }
+
+ [Fact]
+ public async Task GetStatesAsync_ForNoNames_AsksRedisNothing()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ Assert.Empty(await Provider().GetStatesAsync([], Ct));
+ }
+
+ ///
+ /// The prefix is what keeps this provider's keys out of the way of the application's own, so two
+ /// prefixes over one server must not see each other.
+ ///
+ [Fact]
+ public async Task TwoPrefixes_DoNotSeeEachOther()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ await Provider("one:").SetStateAsync("x", new PulseCheckerState(PulseInterval.EverySecond), Ct);
+
+ Assert.Null(await Provider("two:").GetStateAsync("x", Ct));
+ }
+
+ [Fact]
+ public async Task ReadingAStateAsTheWrongType_Throws()
+ {
+ if (Unavailable())
+ {
+ return;
+ }
+
+ var provider = Provider();
+ await provider.SetStateAsync("typed", new PulseCheckerState(), Ct);
+
+ var ex = await Assert.ThrowsAsync(
+ () => provider.GetStateAsync("typed", Ct));
+
+ Assert.Contains("typed", ex.Message, StringComparison.Ordinal);
+ }
+}