From 358270ac39c6a71024c05b276b9813b870926a2f Mon Sep 17 00:00:00 2001 From: Ivan Vydrin Date: Thu, 30 Jul 2026 01:08:02 +0300 Subject: [PATCH] Add a Redis state provider, the last item on the roadmap 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, and it is why the roadmap asked for it. 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: TrySetStateAsync is a Lua script, and Redis runs one to completion without interleaving anything else. WATCH/MULTI/ EXEC would also work, but it needs its own retry loop 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. That last property is asserted against a real Redis rather than argued for: twelve writers read one version, race to write it, and exactly one wins. A fake would only have restated what the provider already believes, so the tests run against redis:7-alpine in a container through Testcontainers, and skip rather than fail where there is no container runtime -- the same bargain the CosmosDB combinations strike in the E2E project. GetStatesAsync issues every read before awaiting any. StackExchange.Redis pipelines on one connection, so the dashboard listing every checker costs one round trip rather than one per checker. Durability is whatever the Redis is configured for -- snapshots, AOF, or a managed offering's own promises -- and the README says that rather than implying the provider can offer more than the server does. The roadmap is now empty. What remains in its place is written as what it is: two decisions, not two features. --- CHANGELOG.md | 7 + Directory.Packages.props | 2 + Healthie.NET.sln | 15 + README.md | 18 +- .../Healthie.StateProviding.Redis.csproj | 21 ++ src/Healthie.StateProviding.Redis/README.md | 65 ++++ .../RedisStateProvider.cs | 263 ++++++++++++++ .../StartupExtensions.cs | 71 ++++ .../Healthie.Tests.Unit.csproj | 2 + .../RedisStateProviderTests.cs | 329 ++++++++++++++++++ 10 files changed, 788 insertions(+), 5 deletions(-) create mode 100644 src/Healthie.StateProviding.Redis/Healthie.StateProviding.Redis.csproj create mode 100644 src/Healthie.StateProviding.Redis/README.md create mode 100644 src/Healthie.StateProviding.Redis/RedisStateProvider.cs create mode 100644 src/Healthie.StateProviding.Redis/StartupExtensions.cs create mode 100644 tests/Healthie.Tests.Unit/RedisStateProviderTests.cs 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 @@ +[![NuGet](https://img.shields.io/nuget/v/Healthie.NET.Redis.svg)](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); + } +}