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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
<PackageVersion Include="Temporalio" Version="1.17.0" />
<PackageVersion Include="Quartz.Extensions.DependencyInjection" Version="3.18.2" />
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.18.2" />
<PackageVersion Include="StackExchange.Redis" Version="3.0.25" />
</ItemGroup>

<!-- Sample-only dependencies -->
Expand All @@ -67,6 +68,7 @@
<PackageVersion Include="Microsoft.Playwright" Version="1.61.0" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="Testcontainers.Redis" Version="4.13.0" />
</ItemGroup>

</Project>
15 changes: 15 additions & 0 deletions Healthie.NET.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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.

---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<PackageId>Healthie.NET.Redis</PackageId>
<Description>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.</Description>
<PackageTags>$(HealthieCommonTags);redis;cache;state</PackageTags>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="Healthie.Tests.Unit" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Healthie.Abstractions\Healthie.Abstractions.csproj" />
</ItemGroup>

</Project>
65 changes: 65 additions & 0 deletions src/Healthie.StateProviding.Redis/README.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading