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); + } +}