Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Healthie.StateProviding.Redis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ If your application already registers an `IConnectionMultiplexer` — for its ow
builder.Services.AddHealthieRedis(); // uses the registered IConnectionMultiplexer
```

Both shapes are the same method: pass a configuration string to have the connection opened for you, or leave it out to use the registered one. Name the argument when you only want a different prefix — `AddHealthieRedis(keyPrefix: "myapp:health:")`.

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
Expand Down
56 changes: 25 additions & 31 deletions src/Healthie.StateProviding.Redis/StartupExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,53 +18,47 @@ public static class StartupExtensions
/// </summary>
/// <param name="services">The service collection to register with.</param>
/// <param name="configuration">
/// A StackExchange.Redis configuration string, such as <c>localhost:6379</c>.
/// A StackExchange.Redis configuration string, such as <c>localhost:6379</c>. Leave it out to use
/// an <see cref="IConnectionMultiplexer"/> the application already registers -- for its own cache,
/// or through <c>AddStackExchangeRedisCache</c>. Sharing that one is the better option where it
/// exists: a second connection to the same server buys nothing.
/// </param>
/// <param name="keyPrefix">Prefixed to every key the provider owns.</param>
/// <returns>The service collection for fluent chaining.</returns>
/// <remarks>
/// Opens the connection here, once, and keeps it: an <see cref="IConnectionMultiplexer"/> 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.
/// </remarks>
public static IServiceCollection AddHealthieRedis(
this IServiceCollection services,
string configuration,
string keyPrefix = DefaultKeyPrefix)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(configuration);

services.TryAddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(configuration));

return services.AddHealthieRedis(keyPrefix);
}

/// <summary>
/// Registers a state provider backed by a Redis connection the application already owns.
/// </summary>
/// <param name="services">The service collection to register with.</param>
/// <param name="keyPrefix">Prefixed to every key the provider owns.</param>
/// <returns>The service collection for fluent chaining.</returns>
/// <remarks>
/// <para>
/// For an application that already registers an <see cref="IConnectionMultiplexer"/> -- for its
/// own cache, or through <c>AddStackExchangeRedisCache</c>. Sharing it is the point: a second
/// connection to the same server buys nothing.
/// One method rather than one per case, because two overloads separated only by what a
/// <see cref="string"/> means cannot be told apart at the call site. C# resolved
/// <c>AddHealthieRedis("localhost:6379")</c> to the overload whose single string was the key
/// prefix, so the connection string quietly became a prefix, no connection was registered, and
/// the failure arrived later as a missing service -- from the very call the README documents.
/// </para>
/// <para>
/// The connection is opened once and kept: an <see cref="IConnectionMultiplexer"/> is built to be
/// shared for the life of the application and is expensive to create per call. It goes in with
/// <c>TryAdd</c>, so an application that already has one keeps it.
/// </para>
/// <para>
/// Registered with <c>AddSingleton</c> rather than <c>TryAddSingleton</c> on purpose: the
/// built-in in-memory provider registers with <c>TryAdd</c>, so whichever call comes first, this
/// one wins.
/// The provider is registered with <c>AddSingleton</c> rather than <c>TryAddSingleton</c> on
/// purpose: the built-in in-memory provider registers with <c>TryAdd</c>, so whichever call comes
/// first, this one wins.
/// </para>
/// </remarks>
public static IServiceCollection AddHealthieRedis(
this IServiceCollection services,
string? configuration = null,
string keyPrefix = DefaultKeyPrefix)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(keyPrefix);

if (configuration is not null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configuration);

services.TryAddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(configuration));
}

return services.AddSingleton<IStateProvider>(provider =>
new RedisStateProvider(provider.GetRequiredService<IConnectionMultiplexer>(), keyPrefix));
}
Expand Down
83 changes: 83 additions & 0 deletions tests/Healthie.Tests.Unit/RedisStateProviderTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using Healthie.Abstractions.Enums;
using Healthie.Abstractions.Models;
using Healthie.Abstractions.StateProviding;
using Healthie.DependencyInjection;
using Healthie.StateProviding.Redis;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
using Testcontainers.Redis;

Expand Down Expand Up @@ -61,6 +63,87 @@ private bool Unavailable()
return false;
}

/// <summary>
/// Through <c>AddHealthieRedis</c>, not by constructing the provider. Nothing did that before,
/// which is how a registration that never registered the connection shipped: the documented call
/// bound to the overload whose one string was the key prefix.
/// </summary>
[Fact]
public async Task AddHealthieRedis_WithAConfigurationString_ResolvesAWorkingProvider()
{
if (Unavailable())
{
return;
}

var services = new ServiceCollection();
services.AddHealthie(typeof(RedisStateProviderTests).Assembly);
services.AddHealthieRedis(_redis!.GetConnectionString());

await using var host = services.BuildServiceProvider();

var provider = host.GetRequiredService<IStateProvider>();

Assert.IsType<RedisStateProvider>(provider);

await provider.SetStateAsync("via-di", new PulseCheckerState(PulseInterval.Every30Seconds), Ct);
Assert.Equal(
PulseInterval.Every30Seconds,
(await provider.GetStateAsync<PulseCheckerState>("via-di", Ct))!.Interval);
}

/// <summary>
/// The other shape: the application owns the connection and Healthie shares it.
/// </summary>
[Fact]
public async Task AddHealthieRedis_WithAnExistingConnection_SharesIt()
{
if (Unavailable())
{
return;
}

var services = new ServiceCollection();
services.AddHealthie(typeof(RedisStateProviderTests).Assembly);
services.AddSingleton(_connection!);
services.AddHealthieRedis();

await using var host = services.BuildServiceProvider();

await host.GetRequiredService<IStateProvider>()
.SetStateAsync("shared", new PulseCheckerState(), Ct);

// Read back through the prefix the registration defaults to, over the same connection.
Assert.NotNull(await Provider(Healthie.StateProviding.Redis.StartupExtensions.DefaultKeyPrefix)
.GetStateAsync<PulseCheckerState>("shared", Ct));
}

/// <summary>
/// A prefix is still a prefix when it is the only thing passed, and it must be named to be that.
/// </summary>
[Fact]
public async Task AddHealthieRedis_WithOnlyAPrefix_UsesTheRegisteredConnection()
{
if (Unavailable())
{
return;
}

var services = new ServiceCollection();
services.AddHealthie(typeof(RedisStateProviderTests).Assembly);
services.AddSingleton(_connection!);
services.AddHealthieRedis(keyPrefix: "named-prefix:");

await using var host = services.BuildServiceProvider();

await host.GetRequiredService<IStateProvider>()
.SetStateAsync("k", new PulseCheckerState(), Ct);

// Written under the prefix that was asked for, and nowhere else.
Assert.NotNull(await Provider("named-prefix:").GetStateAsync<PulseCheckerState>("k", Ct));
Assert.Null(await Provider().GetStateAsync<PulseCheckerState>("k", Ct));
}

[Fact]
public async Task StateSurvivesARoundTrip()
{
Expand Down
Loading