From 51163271c36d600df0f9a95b4557f5f2a4c99b41 Mon Sep 17 00:00:00 2001 From: Ivan Vydrin Date: Thu, 30 Jul 2026 16:23:40 +0300 Subject: [PATCH 1/7] Test the two things 4.0.0 shipped on inference rather than evidence The alert history's durability was asserted against the in-memory provider only, which proves the logic and nothing about serialization -- and the log is the one entry a state provider holds that is not a PulseCheckerState, under a key that is not a checker's name. Either could have been an assumption a provider quietly relied on. Now round-tripped through a real Redis and a real PostgreSQL, asserting the nullable enum and the delivery flag survive, and that the log does not read back as a checker. MeterMetricsInsights had no tests at all. It reads instruments that are internal to another assembly by meter name, so nothing about it is checked by the compiler: rename an instrument and it keeps building and silently counts nothing. These run real checks and assert the counters moved, that the healthy share is null before anything has run rather than a confident zero, and that disposal detaches the listener. --- .../AlertHistoryDurabilityTests.cs | 153 ++++++++++++++++++ .../MetricsInsightsTests.cs | 142 ++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 tests/Healthie.Tests.Unit/AlertHistoryDurabilityTests.cs create mode 100644 tests/Healthie.Tests.Unit/MetricsInsightsTests.cs diff --git a/tests/Healthie.Tests.Unit/AlertHistoryDurabilityTests.cs b/tests/Healthie.Tests.Unit/AlertHistoryDurabilityTests.cs new file mode 100644 index 0000000..981e278 --- /dev/null +++ b/tests/Healthie.Tests.Unit/AlertHistoryDurabilityTests.cs @@ -0,0 +1,153 @@ +using Healthie.Abstractions.Enums; +using Healthie.Abstractions.StateProviding; +using Healthie.Alerting; +using Healthie.StateProviding.Redis; +using Healthie.StateProviding.Relational; +using Npgsql; +using System.Data.Common; +using StackExchange.Redis; + +namespace Healthie.Tests.Unit; + +/// +/// The alert history against real durable providers. +/// +/// +/// +/// The claim being tested is the one on the box: a deployment on a durable state provider keeps its +/// alerts across a restart. Everything else about the history is covered against the in-memory +/// provider, which proves the logic and nothing about serialization -- and this is the one entry a +/// state provider holds that is not a PulseCheckerState, stored under a key that is not a +/// checker's name. Either could have been an assumption a provider quietly relied on. +/// +/// +/// Skipped rather than failed where there is no container runtime, like every other real-provider +/// test here. +/// +/// +public abstract class AlertHistoryDurabilityTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + protected abstract string? FixtureUnavailable { get; } + + /// A provider over the real store, keyed so each test method gets its own space. + protected abstract Task ProviderAsync(); + + /// + /// Two histories over one provider: the second is the same application after a redeploy. + /// + [Fact] + public async Task AnAlertLog_SurvivesTheProcessThatWroteIt() + { + Assert.SkipWhen(FixtureUnavailable is not null, $"No container runtime. {FixtureUnavailable}"); + + var store = await ProviderAsync(); + + var before = new AlertHistory(capacity: 10, store); + before.Record(AlertFor("checker-1", PulseCheckerHealth.Unhealthy), delivered: true); + before.Record(AlertFor("checker-2", PulseCheckerHealth.Suspicious), delivered: false); + + // The write is deliberately off the delivery path, so it lands shortly after Record returns. + var deadline = DateTime.UtcNow.AddSeconds(15); + var after = await new AlertHistory(10, store).GetAlertsAsync(0, 10, Ct); + + while (after.Total < 2 && DateTime.UtcNow < deadline) + { + await Task.Delay(100, Ct); + after = await new AlertHistory(10, store).GetAlertsAsync(0, 10, Ct); + } + + Assert.Equal(2, after.Total); + + // Newest first, and every field survived the round trip -- including the nullable enum and + // the delivery flag, which are the two most likely to come back wrong through a serializer. + Assert.Equal("checker-2", after.Alerts[0].CheckerName); + Assert.Equal(PulseCheckerHealth.Suspicious, after.Alerts[0].CurrentHealth); + Assert.Equal(PulseCheckerHealth.Healthy, after.Alerts[0].PreviousHealth); + Assert.False(after.Alerts[0].Delivered); + + Assert.Equal("checker-1", after.Alerts[1].CheckerName); + Assert.Equal(PulseCheckerHealth.Unhealthy, after.Alerts[1].CurrentHealth); + Assert.True(after.Alerts[1].Delivered); + } + + /// + /// The log lives beside checker state in the same store, so it must not be mistaken for a + /// checker: a dashboard reading every state would otherwise list it as one. + /// + [Fact] + public async Task TheAlertLog_DoesNotReadBackAsACheckerState() + { + Assert.SkipWhen(FixtureUnavailable is not null, $"No container runtime. {FixtureUnavailable}"); + + var store = await ProviderAsync(); + var history = new AlertHistory(capacity: 4, store); + + history.Record(AlertFor("checker-1", PulseCheckerHealth.Unhealthy), delivered: true); + + var deadline = DateTime.UtcNow.AddSeconds(15); + + while ((await new AlertHistory(4, store).GetAlertsAsync(0, 10, Ct)).Total == 0 + && DateTime.UtcNow < deadline) + { + await Task.Delay(100, Ct); + } + + // Reading it as the wrong type must fail or come back empty -- never as a checker with a + // plausible-looking default state. + try + { + var asChecker = await store.GetStateAsync( + "healthie.alerts.log", Ct); + + Assert.Null(asChecker?.LastResult); + } + catch (Exception ex) when (ex is not Xunit.Sdk.XunitException) + { + // Refusing outright is the better answer of the two, and some providers do. + } + } + + private static Alert AlertFor(string name, PulseCheckerHealth health) => new( + name, + name, + Group: "durability", + Tags: ["persisted"], + PulseCheckerHealth.Healthy, + health, + "stored and read back", + DateTime.UtcNow); +} + +/// The alert log against a real Redis. +public sealed class RedisAlertHistoryTests(RedisFixture fixture) + : AlertHistoryDurabilityTests, IClassFixture +{ + protected override string? FixtureUnavailable => fixture.Unavailable; + + protected override async Task ProviderAsync() + { + var connection = await ConnectionMultiplexer.ConnectAsync(fixture.ConnectionString); + + return new RedisStateProvider(connection, $"{TestContext.Current.TestMethod?.MethodName}:"); + } +} + +/// The alert log against a real PostgreSQL. +public sealed class PostgresAlertHistoryTests(PostgresFixture fixture) + : AlertHistoryDurabilityTests, IClassFixture +{ + protected override string? FixtureUnavailable => fixture.Unavailable; + + protected override async Task ProviderAsync() + { + var table = $"alerts_{Guid.NewGuid():N}"; + DbConnection Connect() => new NpgsqlConnection(fixture.ConnectionString); + + await new RelationalStateProviderInitializer(Connect, RelationalDialect.PostgreSql, table) + .InitializeAsync(TestContext.Current.CancellationToken); + + return new RelationalStateProvider(Connect, RelationalDialect.PostgreSql, table); + } +} diff --git a/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs b/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs new file mode 100644 index 0000000..df3f37b --- /dev/null +++ b/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs @@ -0,0 +1,142 @@ +using Healthie.Abstractions; +using Healthie.Abstractions.Enums; +using Healthie.Abstractions.Insights; +using Healthie.Abstractions.Models; +using Healthie.Abstractions.StateProviding; +using Healthie.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; + +namespace Healthie.Tests.Unit; + +/// +/// The in-process metrics collector. +/// +/// +/// It reads instruments that are internal to another assembly, by meter name, through a +/// . Nothing about that is checked by the +/// compiler: rename an instrument or change a tag and this keeps building and silently counts +/// nothing. These run real checks and assert the numbers moved. +/// +public class MetricsInsightsTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private sealed class Checker(IStateProvider provider, PulseCheckerHealth health, string name) + : PulseChecker(provider, PulseInterval.EveryMinute) + { + public override string Name => name; + + public override Task CheckAsync(CancellationToken cancellationToken = default) => + Task.FromResult(new PulseCheckerResult(health, "measured")); + } + + [Fact] + public void AddHealthieMetrics_RegistersTheCollector_AndNothingElseDoes() + { + var services = new ServiceCollection(); + services.AddHealthie(typeof(MetricsInsightsTests).Assembly); + + using (var without = services.BuildServiceProvider()) + { + Assert.Null(without.GetService()); + } + + services.AddHealthieMetrics(); + + using var with = services.BuildServiceProvider(); + + Assert.NotNull(with.GetService()); + } + + /// + /// The listener is the whole feature: if it is not attached to the right meter, or the + /// instrument names drift, everything below reads zero and the board shows an empty panel. + /// + [Fact] + public async Task RunningChecks_MovesTheCounters() + { + using var metrics = new MeterMetricsInsights(); + + Assert.Equal(0, metrics.Snapshot(Ct).Checks); + + var provider = new InMemoryStateProvider(); + using var healthy = new Checker(provider, PulseCheckerHealth.Healthy, "metrics-healthy"); + using var failing = new Checker(provider, PulseCheckerHealth.Unhealthy, "metrics-failing"); + + await healthy.TriggerAsync(Ct); + await healthy.TriggerAsync(Ct); + await failing.TriggerAsync(Ct); + + var snapshot = metrics.Snapshot(Ct); + + Assert.Equal(3, snapshot.Checks); + Assert.Equal(2, snapshot.ResultsByHealth.GetValueOrDefault(PulseCheckerHealth.Healthy)); + Assert.Equal(1, snapshot.ResultsByHealth.GetValueOrDefault(PulseCheckerHealth.Unhealthy)); + + // Two checkers went from nothing to a health, so both transitioned. + Assert.True(snapshot.Transitions >= 2, $"expected at least 2 transitions, got {snapshot.Transitions}"); + + Assert.NotNull(snapshot.MeanDuration); + Assert.NotNull(snapshot.SlowestDuration); + Assert.True(snapshot.SlowestDuration >= snapshot.MeanDuration); + } + + /// + /// Two thirds healthy is 66.67%, and a collector that has seen nothing reports no share rather + /// than a confident zero -- the same distinction the uptime panel makes. + /// + [Fact] + public async Task TheHealthyShare_IsOfChecksRun_AndIsNullBeforeAnyHaveRun() + { + using var metrics = new MeterMetricsInsights(); + + Assert.Null(metrics.Snapshot(Ct).HealthyShare); + + var provider = new InMemoryStateProvider(); + using var healthy = new Checker(provider, PulseCheckerHealth.Healthy, "share-healthy"); + using var failing = new Checker(provider, PulseCheckerHealth.Unhealthy, "share-failing"); + + await healthy.TriggerAsync(Ct); + await healthy.TriggerAsync(Ct); + await failing.TriggerAsync(Ct); + + Assert.Equal(66.67, metrics.Snapshot(Ct).HealthyShare!.Value, 1); + } + + /// + /// Overlaps are the figure that means something is wrong rather than something happened, and + /// nothing else on the board reports them. + /// + [Fact] + public void WithNothingOverlapping_HasOverlapsIsFalse() + { + using var metrics = new MeterMetricsInsights(); + var snapshot = metrics.Snapshot(Ct); + + Assert.Equal(0, snapshot.OverlappedTriggers); + Assert.False(snapshot.HasOverlaps); + } + + /// + /// Disposal has to detach the listener. One left attached goes on receiving measurements from + /// every checker in the process for the rest of its life. + /// + [Fact] + public async Task OnceDisposed_ItStopsCounting() + { + var metrics = new MeterMetricsInsights(); + + var provider = new InMemoryStateProvider(); + using var checker = new Checker(provider, PulseCheckerHealth.Healthy, "disposal-target"); + + await checker.TriggerAsync(Ct); + var before = metrics.Snapshot(Ct).Checks; + Assert.True(before > 0); + + metrics.Dispose(); + + await checker.TriggerAsync(Ct); + + Assert.Equal(before, metrics.Snapshot(Ct).Checks); + } +} From 03d2da9fb239539c1a45c35e53cc625e2b2196cf Mon Sep 17 00:00:00 2001 From: Ivan Vydrin Date: Thu, 30 Jul 2026 16:40:18 +0300 Subject: [PATCH 2/7] Pin the sample base images, and keep stray screenshots out of the repo Scorecard raised four Pinned-Dependencies findings against the two sample Dockerfiles: both took their .NET base images by tag. A tag is mutable, so "sdk:10.0" is a different image next week and the build that passed review is not the build that ships. Both are pinned by digest now, which Dependabot updates the way it updates a package version. The repository root had collected forty-four screenshots from unrelated local work -- admin UIs, an email preview -- untracked but one `git add -A` from being published. They are deleted, and root-level PNGs are ignored so it cannot happen again. Anchored to the root rather than a bare `*.png`, which would also have swallowed docs/assets and the sample's favicon; both directions are asserted rather than assumed. --- .gitignore | 16 ++++++++++++++-- samples/Healthie.Sample.BlazorUI/Dockerfile | 9 ++++++--- samples/Healthie.Sample.WebApi/Dockerfile | 9 ++++++--- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index e638876..6eb04d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -## Ignore Visual Studio temporary files, build results, and +## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore @@ -405,4 +405,16 @@ FodyWeavers.xsd .claude fabric CLAUDE.md -.mcp.json \ No newline at end of file +.mcp.json + +# Throwaway screenshots and browser-automation output, which land in the working directory +# during local verification. They are often of somebody's admin UI or inbox, and one stray +# `git add -A` is all it takes to publish one. +# +# Anchored to the repository root, which is where they land. The images the docs and the +# samples genuinely ship live in subdirectories and are deliberately untouched by this -- a +# bare `*.png` would also swallow docs/assets and the sample's favicon. +/*.png +!/healthie.net.png +!/healthie.net.banner.png +.playwright-mcp/ diff --git a/samples/Healthie.Sample.BlazorUI/Dockerfile b/samples/Healthie.Sample.BlazorUI/Dockerfile index e9ce9f7..3eea706 100644 --- a/samples/Healthie.Sample.BlazorUI/Dockerfile +++ b/samples/Healthie.Sample.BlazorUI/Dockerfile @@ -1,7 +1,10 @@ -# Build from the repository root so the shared build configuration and the src/ projects the sample +# Build from the repository root so the shared build configuration and the src/ projects the sample # references are in context: # docker build -f samples/Healthie.Sample.BlazorUI/Dockerfile . -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +# Pinned by digest, not by tag. A tag is mutable, so "sdk:10.0" is a different image next +# week and a build that passed review is not the build that ships. Dependabot updates the +# digest the same way it updates a package version. +FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:ed034a8bf0b24ded0cbbac07e17825d8e9ebfe21e308191d0f7421eaf5ad4664 AS build WORKDIR /src # Restore against the manifests alone first, so this layer is only rebuilt when dependencies change. @@ -17,7 +20,7 @@ RUN dotnet publish samples/Healthie.Sample.BlazorUI/Healthie.Sample.BlazorUI.csp # The samples target net8.0, so they run on the .NET 8 runtime even though the .NET 10 SDK builds # them: the libraries they reference target both, and the newer SDK is needed to build net10.0. # This also exercises the library from a .NET 8 consumer. -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +FROM mcr.microsoft.com/dotnet/aspnet:8.0@sha256:8f6a307ae32fb393a4b4bcde0a81f0ce3f0a715de0e5575df71f3030448f2dde AS runtime WORKDIR /app COPY --from=build /app ./ diff --git a/samples/Healthie.Sample.WebApi/Dockerfile b/samples/Healthie.Sample.WebApi/Dockerfile index 11082dd..3ceaec4 100644 --- a/samples/Healthie.Sample.WebApi/Dockerfile +++ b/samples/Healthie.Sample.WebApi/Dockerfile @@ -1,7 +1,10 @@ -# Build from the repository root so the shared build configuration and the src/ projects the sample +# Build from the repository root so the shared build configuration and the src/ projects the sample # references are in context: # docker build -f samples/Healthie.Sample.WebApi/Dockerfile . -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +# Pinned by digest, not by tag. A tag is mutable, so "sdk:10.0" is a different image next +# week and a build that passed review is not the build that ships. Dependabot updates the +# digest the same way it updates a package version. +FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:ed034a8bf0b24ded0cbbac07e17825d8e9ebfe21e308191d0f7421eaf5ad4664 AS build WORKDIR /src # Restore against the manifests alone first, so this layer is only rebuilt when dependencies change. @@ -17,7 +20,7 @@ RUN dotnet publish samples/Healthie.Sample.WebApi/Healthie.Sample.WebApi.csproj # The samples target net8.0, so they run on the .NET 8 runtime even though the .NET 10 SDK builds # them: the libraries they reference target both, and the newer SDK is needed to build net10.0. # This also exercises the library from a .NET 8 consumer. -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +FROM mcr.microsoft.com/dotnet/aspnet:8.0@sha256:8f6a307ae32fb393a4b4bcde0a81f0ce3f0a715de0e5575df71f3030448f2dde AS runtime WORKDIR /app COPY --from=build /app ./ From ed790347d55e85285c50f9230697745b83a7c130 Mon Sep 17 00:00:00 2001 From: Ivan Vydrin Date: Thu, 30 Jul 2026 17:00:57 +0300 Subject: [PATCH 3/7] Fold uptime and leader election into core, and add the package people guess Both packages carried zero external dependencies, so splitting them out bought a consumer nothing -- there was no driver to keep off anyone's machine -- and cost them two more things to find and install. That is the drift: the provider packages exist to keep Npgsql or Temporalio away from people who do not use them, and these two were not providers. The code now lives in Healthie.NET.DependencyInjection, under the namespaces it always had, so no `using` changes. The old packages remain published as assemblies of type forwards. That matters more than it looks: moving a type between assemblies is a binary break the compiler cannot see, because source keeps building and only the runtime finds out. With the forwards, an application built against 4.0.0 keeps working untouched -- which is what makes this a minor release rather than a major. Healthie.NET is new and is the package to install first. It carries no code, only a dependency on the core package: the name people guess did not exist, and the real entry point was called DependencyInjection, which reads like plumbing rather than the way in. Deliberately not a bundle -- it must not drag a dashboard into a console app. The tests assert against assembly-qualified names rather than the C# types the compiler has already bound, because binding is exactly what hides a missing forward. --- Healthie.NET.sln | 15 +++ .../packages.lock.json | 4 +- .../LeaderElection}/ILeaseProvider.cs | 0 .../LeaderElection}/InMemoryLeaseProvider.cs | 0 .../LeaderElectedPulseScheduler.cs | 0 .../LeaderElection}/LeaderElectionOptions.cs | 0 .../LeaderElection}/LeaderElectionService.cs | 0 .../LeaderElection}/LeadershipInsights.cs | 0 .../LeaderElection}/StartupExtensions.cs | 0 .../Uptime}/IUptimeStore.cs | 0 .../Uptime}/InMemoryUptimeStore.cs | 0 .../Uptime}/StartupExtensions.cs | 0 .../Uptime}/UptimeCalculator.cs | 0 .../Uptime}/UptimeInsights.cs | 0 .../Uptime}/UptimeRecorder.cs | 0 .../Uptime}/UptimeReport.cs | 0 .../Uptime}/UptimeSegment.cs | 0 .../Healthie.LeaderElection.csproj | 15 +-- src/Healthie.LeaderElection/TypeForwards.cs | 16 ++++ .../packages.lock.json | 68 ++++++++++++- src/Healthie.Uptime/Healthie.Uptime.csproj | 11 ++- src/Healthie.Uptime/TypeForwards.cs | 20 ++++ src/Healthie.Uptime/packages.lock.json | 68 ++++++++++++- .../Healthie.Tests.Unit/PackageLayoutTests.cs | 96 +++++++++++++++++++ tests/Healthie.Tests.Unit/packages.lock.json | 8 +- 25 files changed, 301 insertions(+), 20 deletions(-) rename src/{Healthie.LeaderElection => Healthie.DependencyInjection/LeaderElection}/ILeaseProvider.cs (100%) rename src/{Healthie.LeaderElection => Healthie.DependencyInjection/LeaderElection}/InMemoryLeaseProvider.cs (100%) rename src/{Healthie.LeaderElection => Healthie.DependencyInjection/LeaderElection}/LeaderElectedPulseScheduler.cs (100%) rename src/{Healthie.LeaderElection => Healthie.DependencyInjection/LeaderElection}/LeaderElectionOptions.cs (100%) rename src/{Healthie.LeaderElection => Healthie.DependencyInjection/LeaderElection}/LeaderElectionService.cs (100%) rename src/{Healthie.LeaderElection => Healthie.DependencyInjection/LeaderElection}/LeadershipInsights.cs (100%) rename src/{Healthie.LeaderElection => Healthie.DependencyInjection/LeaderElection}/StartupExtensions.cs (100%) rename src/{Healthie.Uptime => Healthie.DependencyInjection/Uptime}/IUptimeStore.cs (100%) rename src/{Healthie.Uptime => Healthie.DependencyInjection/Uptime}/InMemoryUptimeStore.cs (100%) rename src/{Healthie.Uptime => Healthie.DependencyInjection/Uptime}/StartupExtensions.cs (100%) rename src/{Healthie.Uptime => Healthie.DependencyInjection/Uptime}/UptimeCalculator.cs (100%) rename src/{Healthie.Uptime => Healthie.DependencyInjection/Uptime}/UptimeInsights.cs (100%) rename src/{Healthie.Uptime => Healthie.DependencyInjection/Uptime}/UptimeRecorder.cs (100%) rename src/{Healthie.Uptime => Healthie.DependencyInjection/Uptime}/UptimeReport.cs (100%) rename src/{Healthie.Uptime => Healthie.DependencyInjection/Uptime}/UptimeSegment.cs (100%) create mode 100644 src/Healthie.LeaderElection/TypeForwards.cs create mode 100644 src/Healthie.Uptime/TypeForwards.cs create mode 100644 tests/Healthie.Tests.Unit/PackageLayoutTests.cs diff --git a/Healthie.NET.sln b/Healthie.NET.sln index d61c039..1d76845 100644 --- a/Healthie.NET.sln +++ b/Healthie.NET.sln @@ -68,6 +68,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.LeaderElection", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.StateProviding.Redis", "src\Healthie.StateProviding.Redis\Healthie.StateProviding.Redis.csproj", "{AE94818D-ABCF-4B84-AF49-525A16D7890C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.Meta", "src\Healthie.Meta\Healthie.Meta.csproj", "{3C34D1AC-724C-4848-A8E9-7719CDD21423}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -390,6 +392,18 @@ Global {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 + {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Debug|x64.ActiveCfg = Debug|Any CPU + {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Debug|x64.Build.0 = Debug|Any CPU + {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Debug|x86.ActiveCfg = Debug|Any CPU + {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Debug|x86.Build.0 = Debug|Any CPU + {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Release|Any CPU.Build.0 = Release|Any CPU + {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Release|x64.ActiveCfg = Release|Any CPU + {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Release|x64.Build.0 = Release|Any CPU + {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Release|x86.ActiveCfg = Release|Any CPU + {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -421,6 +435,7 @@ Global {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} + {3C34D1AC-724C-4848-A8E9-7719CDD21423} = {9BAF98D2-CC85-4721-9C67-88576218F61C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0EE98644-4A9C-4D31-8145-997C8B5A8119} diff --git a/samples/Healthie.Sample.BlazorUI/packages.lock.json b/samples/Healthie.Sample.BlazorUI/packages.lock.json index c55b3da..37bdefb 100644 --- a/samples/Healthie.Sample.BlazorUI/packages.lock.json +++ b/samples/Healthie.Sample.BlazorUI/packages.lock.json @@ -300,7 +300,7 @@ "Healthie.NET.LeaderElection": { "type": "Project", "dependencies": { - "Healthie.NET.Abstractions": "[1.0.0, )" + "Healthie.NET.DependencyInjection": "[1.0.0, )" } }, "Healthie.NET.Quartz": { @@ -315,7 +315,7 @@ "Healthie.NET.Uptime": { "type": "Project", "dependencies": { - "Healthie.NET.Abstractions": "[1.0.0, )" + "Healthie.NET.DependencyInjection": "[1.0.0, )" } }, "Cronos": { diff --git a/src/Healthie.LeaderElection/ILeaseProvider.cs b/src/Healthie.DependencyInjection/LeaderElection/ILeaseProvider.cs similarity index 100% rename from src/Healthie.LeaderElection/ILeaseProvider.cs rename to src/Healthie.DependencyInjection/LeaderElection/ILeaseProvider.cs diff --git a/src/Healthie.LeaderElection/InMemoryLeaseProvider.cs b/src/Healthie.DependencyInjection/LeaderElection/InMemoryLeaseProvider.cs similarity index 100% rename from src/Healthie.LeaderElection/InMemoryLeaseProvider.cs rename to src/Healthie.DependencyInjection/LeaderElection/InMemoryLeaseProvider.cs diff --git a/src/Healthie.LeaderElection/LeaderElectedPulseScheduler.cs b/src/Healthie.DependencyInjection/LeaderElection/LeaderElectedPulseScheduler.cs similarity index 100% rename from src/Healthie.LeaderElection/LeaderElectedPulseScheduler.cs rename to src/Healthie.DependencyInjection/LeaderElection/LeaderElectedPulseScheduler.cs diff --git a/src/Healthie.LeaderElection/LeaderElectionOptions.cs b/src/Healthie.DependencyInjection/LeaderElection/LeaderElectionOptions.cs similarity index 100% rename from src/Healthie.LeaderElection/LeaderElectionOptions.cs rename to src/Healthie.DependencyInjection/LeaderElection/LeaderElectionOptions.cs diff --git a/src/Healthie.LeaderElection/LeaderElectionService.cs b/src/Healthie.DependencyInjection/LeaderElection/LeaderElectionService.cs similarity index 100% rename from src/Healthie.LeaderElection/LeaderElectionService.cs rename to src/Healthie.DependencyInjection/LeaderElection/LeaderElectionService.cs diff --git a/src/Healthie.LeaderElection/LeadershipInsights.cs b/src/Healthie.DependencyInjection/LeaderElection/LeadershipInsights.cs similarity index 100% rename from src/Healthie.LeaderElection/LeadershipInsights.cs rename to src/Healthie.DependencyInjection/LeaderElection/LeadershipInsights.cs diff --git a/src/Healthie.LeaderElection/StartupExtensions.cs b/src/Healthie.DependencyInjection/LeaderElection/StartupExtensions.cs similarity index 100% rename from src/Healthie.LeaderElection/StartupExtensions.cs rename to src/Healthie.DependencyInjection/LeaderElection/StartupExtensions.cs diff --git a/src/Healthie.Uptime/IUptimeStore.cs b/src/Healthie.DependencyInjection/Uptime/IUptimeStore.cs similarity index 100% rename from src/Healthie.Uptime/IUptimeStore.cs rename to src/Healthie.DependencyInjection/Uptime/IUptimeStore.cs diff --git a/src/Healthie.Uptime/InMemoryUptimeStore.cs b/src/Healthie.DependencyInjection/Uptime/InMemoryUptimeStore.cs similarity index 100% rename from src/Healthie.Uptime/InMemoryUptimeStore.cs rename to src/Healthie.DependencyInjection/Uptime/InMemoryUptimeStore.cs diff --git a/src/Healthie.Uptime/StartupExtensions.cs b/src/Healthie.DependencyInjection/Uptime/StartupExtensions.cs similarity index 100% rename from src/Healthie.Uptime/StartupExtensions.cs rename to src/Healthie.DependencyInjection/Uptime/StartupExtensions.cs diff --git a/src/Healthie.Uptime/UptimeCalculator.cs b/src/Healthie.DependencyInjection/Uptime/UptimeCalculator.cs similarity index 100% rename from src/Healthie.Uptime/UptimeCalculator.cs rename to src/Healthie.DependencyInjection/Uptime/UptimeCalculator.cs diff --git a/src/Healthie.Uptime/UptimeInsights.cs b/src/Healthie.DependencyInjection/Uptime/UptimeInsights.cs similarity index 100% rename from src/Healthie.Uptime/UptimeInsights.cs rename to src/Healthie.DependencyInjection/Uptime/UptimeInsights.cs diff --git a/src/Healthie.Uptime/UptimeRecorder.cs b/src/Healthie.DependencyInjection/Uptime/UptimeRecorder.cs similarity index 100% rename from src/Healthie.Uptime/UptimeRecorder.cs rename to src/Healthie.DependencyInjection/Uptime/UptimeRecorder.cs diff --git a/src/Healthie.Uptime/UptimeReport.cs b/src/Healthie.DependencyInjection/Uptime/UptimeReport.cs similarity index 100% rename from src/Healthie.Uptime/UptimeReport.cs rename to src/Healthie.DependencyInjection/Uptime/UptimeReport.cs diff --git a/src/Healthie.Uptime/UptimeSegment.cs b/src/Healthie.DependencyInjection/Uptime/UptimeSegment.cs similarity index 100% rename from src/Healthie.Uptime/UptimeSegment.cs rename to src/Healthie.DependencyInjection/Uptime/UptimeSegment.cs diff --git a/src/Healthie.LeaderElection/Healthie.LeaderElection.csproj b/src/Healthie.LeaderElection/Healthie.LeaderElection.csproj index bfe994f..172f659 100644 --- a/src/Healthie.LeaderElection/Healthie.LeaderElection.csproj +++ b/src/Healthie.LeaderElection/Healthie.LeaderElection.csproj @@ -1,17 +1,18 @@ + Healthie.NET.LeaderElection - Leader election for Healthie.NET: runs pulse checks on one replica at a time, so a scaled-out application checks each component once rather than once per replica. - $(HealthieCommonTags);leader-election;distributed;lease;ha + DEPRECATED -- use Healthie.NET.DependencyInjection, which now contains leader election. This package remains as type forwards so existing applications keep working; it will not gain new features. + $(HealthieCommonTags);leader-election;ha;replicas;deprecated - - - - - + diff --git a/src/Healthie.LeaderElection/TypeForwards.cs b/src/Healthie.LeaderElection/TypeForwards.cs new file mode 100644 index 0000000..5b52492 --- /dev/null +++ b/src/Healthie.LeaderElection/TypeForwards.cs @@ -0,0 +1,16 @@ +using System.Runtime.CompilerServices; + +// Every public type this package used to define now lives in Healthie.NET.DependencyInjection. +// See the note in Healthie.Uptime's TypeForwards.cs for why these are forwarded rather than simply +// moved: without the forward, an assembly compiled against this one fails at runtime rather than at +// build time, which is the worst way to find out. +// +// This package is deprecated on nuget.org and exists only so applications that reference it keep +// working. Nothing new should be added here. + +[assembly: TypeForwardedTo(typeof(Healthie.LeaderElection.ILeaseProvider))] +[assembly: TypeForwardedTo(typeof(Healthie.LeaderElection.InMemoryLeaseProvider))] +[assembly: TypeForwardedTo(typeof(Healthie.LeaderElection.LeaderElectedPulseScheduler))] +[assembly: TypeForwardedTo(typeof(Healthie.LeaderElection.LeaderElectionOptions))] +[assembly: TypeForwardedTo(typeof(Healthie.LeaderElection.LeaderElectionService))] +[assembly: TypeForwardedTo(typeof(Healthie.LeaderElection.StartupExtensions))] diff --git a/src/Healthie.LeaderElection/packages.lock.json b/src/Healthie.LeaderElection/packages.lock.json index 6ce5436..9a85b39 100644 --- a/src/Healthie.LeaderElection/packages.lock.json +++ b/src/Healthie.LeaderElection/packages.lock.json @@ -55,12 +55,44 @@ "Microsoft.Extensions.Hosting.Abstractions": "[10.0.10, )" } }, + "Healthie.NET.DependencyInjection": { + "type": "Project", + "dependencies": { + "Cronos": "[0.13.0, )", + "Healthie.NET.Abstractions": "[1.0.0, )", + "Microsoft.Extensions.Diagnostics.HealthChecks": "[10.0.10, )" + } + }, + "Cronos": { + "type": "CentralTransitive", + "requested": "[0.13.0, )", + "resolved": "0.13.0", + "contentHash": "Nkve+Yi0+ol3ntSBRHeW0ka9cJQ8vndSUvvkXqF9LVqwpoqWueyxu/HS275r98RDOmF7/mzRnZW5XXUBXob+TA==" + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", "requested": "[10.0.10, )", "resolved": "10.0.10", "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "R0O5oG+zAJeBSM8nNTa+Ycj2Zobyr/v6Ilo7Dha0sNB2Vq/XXoLdoecj9DAWGbN8YrPaW6u8+osTQ5Ypj7ZF0w==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.10", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "6euxgVR7NS83y0a2wLRAxfYXusLQJ2e1ah0MpQgYTYMs5lYrmdNP79C6T8uvRZdP87n5mcCcp6+w0EyWAidKZw==" + }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "CentralTransitive", "requested": "[10.0.10, )", @@ -103,8 +135,8 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" } @@ -129,12 +161,44 @@ "Microsoft.Extensions.Hosting.Abstractions": "[8.0.1, )" } }, + "Healthie.NET.DependencyInjection": { + "type": "Project", + "dependencies": { + "Cronos": "[0.13.0, )", + "Healthie.NET.Abstractions": "[1.0.0, )", + "Microsoft.Extensions.Diagnostics.HealthChecks": "[8.0.29, )" + } + }, + "Cronos": { + "type": "CentralTransitive", + "requested": "[0.13.0, )", + "resolved": "0.13.0", + "contentHash": "Nkve+Yi0+ol3ntSBRHeW0ka9cJQ8vndSUvvkXqF9LVqwpoqWueyxu/HS275r98RDOmF7/mzRnZW5XXUBXob+TA==" + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", "requested": "[8.0.2, )", "resolved": "8.0.2", "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "CentralTransitive", + "requested": "[8.0.29, )", + "resolved": "8.0.29", + "contentHash": "CKaXBomiwjwo+HQv8WhCPVW8LwNP8pVu6nKzRxh23qgOMVNJeerPgxfHaBniFJsFnBzsq9ntu6nXk77CbYvkHA==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.29", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "Microsoft.Extensions.Options": "8.0.2" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "CentralTransitive", + "requested": "[8.0.29, )", + "resolved": "8.0.29", + "contentHash": "HSabrcvae+5j1CYW4MJa8CC9Wd4ZX1l9ImldPjiDeTmcdgfi6XvEuWFLncj/xB2FiVuOVlMAssMWiICPxZnEJA==" + }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "CentralTransitive", "requested": "[8.0.1, )", diff --git a/src/Healthie.Uptime/Healthie.Uptime.csproj b/src/Healthie.Uptime/Healthie.Uptime.csproj index 0ad647a..5415dd0 100644 --- a/src/Healthie.Uptime/Healthie.Uptime.csproj +++ b/src/Healthie.Uptime/Healthie.Uptime.csproj @@ -1,13 +1,18 @@ + Healthie.NET.Uptime - Uptime and SLA reporting for Healthie.NET: records health transitions so you can answer what your uptime was over any window, not just the last hundred checks. - $(HealthieCommonTags);uptime;sla;reporting;availability + DEPRECATED -- use Healthie.NET.DependencyInjection, which now contains uptime reporting. This package remains as type forwards so existing applications keep working; it will not gain new features. + $(HealthieCommonTags);uptime;sla;reporting;availability;deprecated - + diff --git a/src/Healthie.Uptime/TypeForwards.cs b/src/Healthie.Uptime/TypeForwards.cs new file mode 100644 index 0000000..4a79dea --- /dev/null +++ b/src/Healthie.Uptime/TypeForwards.cs @@ -0,0 +1,20 @@ +using System.Runtime.CompilerServices; + +// Every public type this package used to define now lives in Healthie.NET.DependencyInjection. +// +// Forwarded rather than moved outright, because moving a type between assemblies is a binary break +// even when the source is identical: an assembly compiled against Healthie.Uptime asks the runtime +// for Healthie.Uptime!Healthie.Uptime.IUptimeStore by name, and without a forward it gets a +// TypeLoadException at the first call. With one, the runtime follows the pointer and the caller +// never notices. That is what makes folding these into core a minor release rather than a major. +// +// This package is deprecated on nuget.org and exists only so applications that reference it keep +// working. Nothing new should be added here. + +[assembly: TypeForwardedTo(typeof(Healthie.Uptime.IUptimeStore))] +[assembly: TypeForwardedTo(typeof(Healthie.Uptime.InMemoryUptimeStore))] +[assembly: TypeForwardedTo(typeof(Healthie.Uptime.StartupExtensions))] +[assembly: TypeForwardedTo(typeof(Healthie.Uptime.UptimeCalculator))] +[assembly: TypeForwardedTo(typeof(Healthie.Uptime.UptimeRecorder))] +[assembly: TypeForwardedTo(typeof(Healthie.Uptime.UptimeReport))] +[assembly: TypeForwardedTo(typeof(Healthie.Uptime.UptimeSegment))] diff --git a/src/Healthie.Uptime/packages.lock.json b/src/Healthie.Uptime/packages.lock.json index 6ce5436..9a85b39 100644 --- a/src/Healthie.Uptime/packages.lock.json +++ b/src/Healthie.Uptime/packages.lock.json @@ -55,12 +55,44 @@ "Microsoft.Extensions.Hosting.Abstractions": "[10.0.10, )" } }, + "Healthie.NET.DependencyInjection": { + "type": "Project", + "dependencies": { + "Cronos": "[0.13.0, )", + "Healthie.NET.Abstractions": "[1.0.0, )", + "Microsoft.Extensions.Diagnostics.HealthChecks": "[10.0.10, )" + } + }, + "Cronos": { + "type": "CentralTransitive", + "requested": "[0.13.0, )", + "resolved": "0.13.0", + "contentHash": "Nkve+Yi0+ol3ntSBRHeW0ka9cJQ8vndSUvvkXqF9LVqwpoqWueyxu/HS275r98RDOmF7/mzRnZW5XXUBXob+TA==" + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", "requested": "[10.0.10, )", "resolved": "10.0.10", "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "R0O5oG+zAJeBSM8nNTa+Ycj2Zobyr/v6Ilo7Dha0sNB2Vq/XXoLdoecj9DAWGbN8YrPaW6u8+osTQ5Ypj7ZF0w==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.10", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "6euxgVR7NS83y0a2wLRAxfYXusLQJ2e1ah0MpQgYTYMs5lYrmdNP79C6T8uvRZdP87n5mcCcp6+w0EyWAidKZw==" + }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "CentralTransitive", "requested": "[10.0.10, )", @@ -103,8 +135,8 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "8.0.2", - "contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" } @@ -129,12 +161,44 @@ "Microsoft.Extensions.Hosting.Abstractions": "[8.0.1, )" } }, + "Healthie.NET.DependencyInjection": { + "type": "Project", + "dependencies": { + "Cronos": "[0.13.0, )", + "Healthie.NET.Abstractions": "[1.0.0, )", + "Microsoft.Extensions.Diagnostics.HealthChecks": "[8.0.29, )" + } + }, + "Cronos": { + "type": "CentralTransitive", + "requested": "[0.13.0, )", + "resolved": "0.13.0", + "contentHash": "Nkve+Yi0+ol3ntSBRHeW0ka9cJQ8vndSUvvkXqF9LVqwpoqWueyxu/HS275r98RDOmF7/mzRnZW5XXUBXob+TA==" + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", "requested": "[8.0.2, )", "resolved": "8.0.2", "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "CentralTransitive", + "requested": "[8.0.29, )", + "resolved": "8.0.29", + "contentHash": "CKaXBomiwjwo+HQv8WhCPVW8LwNP8pVu6nKzRxh23qgOMVNJeerPgxfHaBniFJsFnBzsq9ntu6nXk77CbYvkHA==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.29", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "Microsoft.Extensions.Options": "8.0.2" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "CentralTransitive", + "requested": "[8.0.29, )", + "resolved": "8.0.29", + "contentHash": "HSabrcvae+5j1CYW4MJa8CC9Wd4ZX1l9ImldPjiDeTmcdgfi6XvEuWFLncj/xB2FiVuOVlMAssMWiICPxZnEJA==" + }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "CentralTransitive", "requested": "[8.0.1, )", diff --git a/tests/Healthie.Tests.Unit/PackageLayoutTests.cs b/tests/Healthie.Tests.Unit/PackageLayoutTests.cs new file mode 100644 index 0000000..930b650 --- /dev/null +++ b/tests/Healthie.Tests.Unit/PackageLayoutTests.cs @@ -0,0 +1,96 @@ +using Healthie.Abstractions.Insights; +using Healthie.DependencyInjection; +using Healthie.LeaderElection; +using Healthie.Uptime; +using Microsoft.Extensions.DependencyInjection; + +namespace Healthie.Tests.Unit; + +/// +/// Uptime and leader election moved into the core package, and the packages they used to live in +/// became type forwards. +/// +/// +/// +/// The reason this is a test and not a note: moving a type between assemblies is a binary break that +/// the compiler cannot see. Source that says using Healthie.Uptime; keeps building either +/// way, and an application compiled against the old assembly only discovers the difference when it +/// throws TypeLoadException at the first call. Nothing else in the suite would catch a +/// forward that was dropped or misspelled. +/// +/// +/// So these assert against assembly-qualified names, the way the runtime resolves them, rather than +/// against the C# types the compiler has already bound for us. +/// +/// +public class PackageLayoutTests +{ + /// Every public type the two deprecated packages ever exposed. + public static TheoryData ForwardedTypes() => new() + { + { "Healthie.Uptime.IUptimeStore", "Healthie.Uptime" }, + { "Healthie.Uptime.InMemoryUptimeStore", "Healthie.Uptime" }, + { "Healthie.Uptime.StartupExtensions", "Healthie.Uptime" }, + { "Healthie.Uptime.UptimeCalculator", "Healthie.Uptime" }, + { "Healthie.Uptime.UptimeRecorder", "Healthie.Uptime" }, + { "Healthie.Uptime.UptimeReport", "Healthie.Uptime" }, + { "Healthie.Uptime.UptimeSegment", "Healthie.Uptime" }, + { "Healthie.LeaderElection.ILeaseProvider", "Healthie.LeaderElection" }, + { "Healthie.LeaderElection.InMemoryLeaseProvider", "Healthie.LeaderElection" }, + { "Healthie.LeaderElection.LeaderElectedPulseScheduler", "Healthie.LeaderElection" }, + { "Healthie.LeaderElection.LeaderElectionOptions", "Healthie.LeaderElection" }, + { "Healthie.LeaderElection.LeaderElectionService", "Healthie.LeaderElection" }, + { "Healthie.LeaderElection.StartupExtensions", "Healthie.LeaderElection" }, + }; + + /// + /// Asking the old assembly for a type it no longer defines must still hand back the type, now + /// living in the core assembly. This is what an application compiled against 4.0.0 does. + /// + [Theory] + [MemberData(nameof(ForwardedTypes))] + public void ATypeAskedForByItsOldAssembly_ResolvesToTheCoreAssembly(string typeName, string oldAssembly) + { + var resolved = Type.GetType($"{typeName}, {oldAssembly}", throwOnError: false); + + Assert.True(resolved is not null, $"'{typeName}, {oldAssembly}' did not resolve -- the type forward is missing."); + Assert.Equal("Healthie.DependencyInjection", resolved!.Assembly.GetName().Name); + } + + /// + /// The point of the move: the core package registers both without a second package installed. + /// + [Fact] + public void TheCorePackage_RegistersUptimeAndLeaderElection() + { + var services = new ServiceCollection(); + services.AddHealthie(typeof(PackageLayoutTests).Assembly); + services.AddHealthieUptime(); + services.AddHealthieLeaderElection(); + + using var provider = services.BuildServiceProvider(); + + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + } + + /// + /// Still opt-in. Folding them into core was about how many packages you install, not about + /// starting services nobody asked for. + /// + [Fact] + public void TheCorePackage_StartsNeitherOfThemUnlessAsked() + { + var services = new ServiceCollection(); + services.AddHealthie(typeof(PackageLayoutTests).Assembly); + + using var provider = services.BuildServiceProvider(); + + Assert.Null(provider.GetService()); + Assert.Null(provider.GetService()); + Assert.Null(provider.GetService()); + Assert.Null(provider.GetService()); + } +} diff --git a/tests/Healthie.Tests.Unit/packages.lock.json b/tests/Healthie.Tests.Unit/packages.lock.json index a353a56..e8fad9b 100644 --- a/tests/Healthie.Tests.Unit/packages.lock.json +++ b/tests/Healthie.Tests.Unit/packages.lock.json @@ -597,7 +597,7 @@ "Healthie.NET.LeaderElection": { "type": "Project", "dependencies": { - "Healthie.NET.Abstractions": "[1.0.0, )" + "Healthie.NET.DependencyInjection": "[1.0.0, )" } }, "Healthie.NET.Mcp": { @@ -662,7 +662,7 @@ "Healthie.NET.Uptime": { "type": "Project", "dependencies": { - "Healthie.NET.Abstractions": "[1.0.0, )" + "Healthie.NET.DependencyInjection": "[1.0.0, )" } }, "Coravel": { @@ -1538,7 +1538,7 @@ "Healthie.NET.LeaderElection": { "type": "Project", "dependencies": { - "Healthie.NET.Abstractions": "[1.0.0, )" + "Healthie.NET.DependencyInjection": "[1.0.0, )" } }, "Healthie.NET.Mcp": { @@ -1603,7 +1603,7 @@ "Healthie.NET.Uptime": { "type": "Project", "dependencies": { - "Healthie.NET.Abstractions": "[1.0.0, )" + "Healthie.NET.DependencyInjection": "[1.0.0, )" } }, "Coravel": { From 85bc750ac8d41bc6068cdb5f1042aba826fa566d Mon Sep 17 00:00:00 2001 From: Ivan Vydrin Date: Thu, 30 Jul 2026 18:28:51 +0300 Subject: [PATCH 4/7] Retier the package docs, note 4.1.0, and fix a test double that broke the suite The README listed twenty packages as one flat table of equal weight, which is what made this look like a twenty-package install when it has always been a one-package install. Grouped now by what you are actually deciding -- swap the storage, swap the scheduler, add a surface, add a capability -- behind collapsed sections, with the single install and its two-node dependency chain stated up front so nobody has to wonder whether the meta-package is a bundle. The metrics test double repeated a mistake this branch had already fixed once: it derived from PulseChecker with a constructor the container cannot satisfy, so assembly scanning failed twenty registration, MCP and AI tests that have nothing to do with metrics. It went unnoticed because the tests were run filtered rather than as a suite. Its assertions were wrong in a second way. A MeterListener hears every measurement in the process, so exact counters passed alone and failed under the full suite. They are deltas with a floor now, and the healthy share is asserted against a snapshot built directly, since the arithmetic is what is under test rather than whatever else the suite was running. --- CHANGELOG.md | 50 ++++++- README.md | 123 ++++++++++++++---- .../MetricsInsightsTests.cs | 104 ++++++++++----- .../Healthie.Tests.Unit/PackageLayoutTests.cs | 37 ++++++ 4 files changed, 250 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b95eb..fd88140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Changelog +# Changelog All notable changes to this project will be documented in this file. @@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [4.1.0] - 2026-07-30 + +Twenty packages was too many, and two of them had no business being separate. This release fixes +that without breaking anything: **an application on 4.0.0 upgrades by changing nothing.** + +### Added + +- **`Healthie.NET`**, the package to install first. It carries no code of its own, only a dependency + on `Healthie.NET.DependencyInjection`, so it is one install and one dependency rather than a + bundle -- no provider, scheduler or UI framework arrives with it. The name people guess did not + exist before, and the actual entry point was called `DependencyInjection`, which reads like + plumbing rather than the way in. +- **Uptime reporting and leader election are in the core package.** `AddHealthieUptime()` and + `AddHealthieLeaderElection()` need nothing else installed. Both stay opt-in: nothing runs until + you call them. + +### Deprecated + +- **`Healthie.NET.Uptime`** and **`Healthie.NET.LeaderElection`**. Neither carried a third-party + dependency, so keeping them separate cost two installs and saved nothing -- the provider packages + exist to keep Npgsql or Temporalio off machines that do not use them, and these two were not + providers. + + **Nothing breaks.** Both packages are still published, now as assemblies of type forwards, so an + application that references either keeps compiling *and* keeps running untouched. That is what + makes this a minor release: moving a type between assemblies is a binary break the compiler cannot + see, because source keeps building and only the runtime finds out. Remove the reference whenever it + suits you; neither package will gain features. + +### Fixed + +- **The sample Dockerfiles took their .NET base images by mutable tag.** A tag is a different image + next week, so the build that passed review is not the build that ships. Both are pinned by digest, + which Dependabot updates the way it updates a package version. Closes four OpenSSF Scorecard + *Pinned-Dependencies* findings. + +### Security + +- **An Azure CosmosDB key committed in May 2025 was confirmed dead and the alert closed.** It had + already been removed from the working tree; the account it belonged to no longer exists in the + owning tenant, verified by enumerating every Cosmos account across that tenant's subscriptions, so + the key authenticates to nothing. It remains in history at `ee0c78e`, inert. +- **Stray screenshots can no longer be committed.** Root-level PNGs are ignored, scoped to the root + so the images the docs and samples genuinely ship are untouched. + ## [4.0.0] - 2026-07-30 Twelve new packages, the schedule model several of them needed, and optimistic concurrency on the @@ -520,7 +565,8 @@ Blazor dashboard, and the CosmosDB and Quartz.NET providers. - Dashboard UI improvements and additional sample pulse checkers. -[Unreleased]: https://github.com/ivanvyd/Healthie.NET/compare/v4.0.0...HEAD +[Unreleased]: https://github.com/ivanvyd/Healthie.NET/compare/v4.1.0...HEAD +[4.1.0]: https://github.com/ivanvyd/Healthie.NET/compare/v4.0.0...v4.1.0 [4.0.0]: https://github.com/ivanvyd/Healthie.NET/compare/v3.1.4...v4.0.0 [3.0.0]: https://github.com/ivanvyd/Healthie.NET/compare/v2.3.0...v3.0.0 [2.3.0]: https://github.com/ivanvyd/Healthie.NET/releases/tag/v2.3.0 diff --git a/README.md b/README.md index d495598..5c719c4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Healthie.NET - Trust your uptime](https://raw.githubusercontent.com/ivanvyd/Healthie.NET/main/healthie.net.banner.png) +![Healthie.NET - Trust your uptime](https://raw.githubusercontent.com/ivanvyd/Healthie.NET/main/healthie.net.banner.png) **Trust your uptime.** A lightweight, extensible health monitoring framework for .NET applications. @@ -39,37 +39,105 @@ Watch it all through a live dashboard, a REST API, Kubernetes probes, or an AI a - **A dashboard with no dependencies.** Pure HTML and CSS, no UI framework, no web fonts -- two lines to add, and it runs air-gapped. - **Built for agents.** An [MCP server](#ai-agents-mcp) that is read-only until you say otherwise, plus [optional diagnostics](#ai-diagnostics) through any `IChatClient`. - **It tells someone.** [Alerting](https://www.nuget.org/packages/Healthie.NET.Alerting) fires on a health change rather than on every check, with recovery notices and flap suppression -- and a webhook that is down cannot delay a check or make a healthy component look unhealthy. -- **It reports on itself.** A `Meter` and an `ActivitySource` OpenTelemetry finds by name, with no package to install. Plus [uptime over any window](https://www.nuget.org/packages/Healthie.NET.Uptime), which the hundred-entry history cannot answer. -- **It scales out.** [Leader election](https://www.nuget.org/packages/Healthie.NET.LeaderElection) runs the checks on one replica at a time, so three replicas do not check everything three times. +- **It reports on itself.** A `Meter` and an `ActivitySource` OpenTelemetry finds by name, with no package to install. Plus uptime over any window, which the hundred-entry history cannot answer -- `AddHealthieUptime()`, in the core package. +- **It scales out.** `AddHealthieLeaderElection()` runs the checks on one replica at a time, so three replicas do not check everything three times. -Everything heavy is opt-in: `Healthie.NET.Abstractions` carries exactly one dependency, and every provider, scheduler, sink and the dashboard is a separate package you add only if you want it. +Everything heavy is opt-in. One package gets you running; a provider, a scheduler, the dashboard or the REST API is a separate install, and each of those exists to keep its driver off machines that do not need it. --- ## NuGet Packages -| Package | Description | Install | +**You need one.** `Healthie.NET` gets you checkers on a schedule, the three-state health model, +in-memory storage and the `IHealthCheck` bridge — everything below is something you add only when +you want it. + +```shell +dotnet add package Healthie.NET +``` + +That is one package and one dependency — `Healthie.NET` → `Healthie.NET.DependencyInjection` → +`Healthie.NET.Abstractions`. It is **not** a bundle: nothing below is pulled in with it, so no +database driver, scheduler or UI framework arrives on your machine unless you name it. + +
+Swap the storage — state survives a restart + +Checker state is written on every tick. The in-memory default loses it when the process ends; pick +one of these and it does not. Each is separate because each carries a database driver, and nobody +wanting PostgreSQL should be made to download Cosmos. + +| Package | For | +|---|---| +| **[Healthie.NET.Postgres](https://www.nuget.org/packages/Healthie.NET.Postgres)** | PostgreSQL, including Databricks Lakebase | +| **[Healthie.NET.SqlServer](https://www.nuget.org/packages/Healthie.NET.SqlServer)** | SQL Server and Azure SQL | +| **[Healthie.NET.Sqlite](https://www.nuget.org/packages/Healthie.NET.Sqlite)** | SQLite — durable, with no server to stand up | +| **[Healthie.NET.CosmosDb](https://www.nuget.org/packages/Healthie.NET.CosmosDb)** | Azure CosmosDB | +| **[Healthie.NET.Redis](https://www.nuget.org/packages/Healthie.NET.Redis)** | Redis — the fastest, for state written every tick | +| **[Healthie.NET.Relational](https://www.nuget.org/packages/Healthie.NET.Relational)** | The engine behind the three SQL providers. Use it directly for any other ADO.NET database | + +
+ +
+Swap the scheduler — where the schedule lives + +The built-in timer schedules in-process and forgets on restart, which is right for most +applications. These put the schedule somewhere that outlives it. + +| Package | For | +|---|---| +| **[Healthie.NET.Quartz](https://www.nuget.org/packages/Healthie.NET.Quartz)** | Quartz.NET, if you already run it | +| **[Healthie.NET.Hangfire](https://www.nuget.org/packages/Healthie.NET.Hangfire)** | Hangfire — schedules survive a restart, each occurrence runs on one replica | +| **[Healthie.NET.Coravel](https://www.nuget.org/packages/Healthie.NET.Coravel)** | Coravel, if you already run it | +| **[Healthie.NET.Temporal](https://www.nuget.org/packages/Healthie.NET.Temporal)** | Temporal — schedules live in the cluster | + +
+ +
+Add a surface — somewhere to look, or something to call + +| Package | For | +|---|---| +| **[Healthie.NET.Dashboard](https://www.nuget.org/packages/Healthie.NET.Dashboard)** | The Blazor dashboard. Zero third-party dependencies, no web fonts | +| **[Healthie.NET.Api](https://www.nuget.org/packages/Healthie.NET.Api)** | REST endpoints for managing checkers, plus liveness and readiness probes | +| **[Healthie.NET.Mcp](https://www.nuget.org/packages/Healthie.NET.Mcp)** | A Model Context Protocol server, so an agent can read and act on health | + +
+ +
+Add a capability — alerting, ready-made checkers, AI + +| Package | For | +|---|---| +| **[Healthie.NET.Alerting](https://www.nuget.org/packages/Healthie.NET.Alerting)** | Health changes become alerts, delivered to Slack, Teams, PagerDuty, a webhook, or your own sink | +| **[Healthie.NET.Checkers](https://www.nuget.org/packages/Healthie.NET.Checkers)** | HTTP endpoints, TCP ports, TLS expiry, DNS and disk space, with no checker code to write | +| **[Healthie.NET.AI](https://www.nuget.org/packages/Healthie.NET.AI)** | Explains a checker's recent failures through any `IChatClient` | + +Uptime reporting and leader election used to be packages of their own. They are in the core package +now — call `AddHealthieUptime()` or `AddHealthieLeaderElection()` and nothing else to install. See +[deprecated packages](#deprecated-packages). + +
+ +
+Reference it directly — for library authors + +| Package | For | +|---|---| +| **[Healthie.NET.Abstractions](https://www.nuget.org/packages/Healthie.NET.Abstractions)** | The contracts and `PulseChecker`, with exactly one dependency. Reference this if you are shipping a provider of your own | +| **[Healthie.NET.DependencyInjection](https://www.nuget.org/packages/Healthie.NET.DependencyInjection)** | What `Healthie.NET` resolves to. Reference it directly if you would rather be explicit | + +
+ +#### Deprecated packages + +| Package | Replaced by | Still works? | |---|---|---| -| **[Healthie.NET.Abstractions](https://www.nuget.org/packages/Healthie.NET.Abstractions)** | Core interfaces, models, enums, and the `PulseChecker` abstract base class. | `dotnet add package Healthie.NET.Abstractions` | -| **[Healthie.NET.DependencyInjection](https://www.nuget.org/packages/Healthie.NET.DependencyInjection)** | DI registration (`AddHealthie`), assembly scanning, the built-in `TimerPulseScheduler`, the in-memory state provider, and the `IHealthCheck` bridge. | `dotnet add package Healthie.NET.DependencyInjection` | -| **[Healthie.NET.Api](https://www.nuget.org/packages/Healthie.NET.Api)** | ASP.NET Core API controller for managing pulse checkers via REST, plus liveness and readiness probe endpoints. | `dotnet add package Healthie.NET.Api` | -| **[Healthie.NET.CosmosDb](https://www.nuget.org/packages/Healthie.NET.CosmosDb)** | Azure CosmosDB `IStateProvider` implementation for persisting pulse checker state. | `dotnet add package Healthie.NET.CosmosDb` | -| **[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` | -| **[Healthie.NET.Hangfire](https://www.nuget.org/packages/Healthie.NET.Hangfire)** | Hangfire `IPulseScheduler` implementation -- schedules survive a restart, and each occurrence runs on exactly one replica. | `dotnet add package Healthie.NET.Hangfire` | -| **[Healthie.NET.Coravel](https://www.nuget.org/packages/Healthie.NET.Coravel)** | Coravel `IPulseScheduler` implementation, for applications already running Coravel. | `dotnet add package Healthie.NET.Coravel` | -| **[Healthie.NET.Temporal](https://www.nuget.org/packages/Healthie.NET.Temporal)** | Temporal `IPulseScheduler` implementation -- schedules live in the cluster and survive a restart. | `dotnet add package Healthie.NET.Temporal` | -| **[Healthie.NET.Mcp](https://www.nuget.org/packages/Healthie.NET.Mcp)** | Model Context Protocol server, so an AI agent can read and act on service health. | `dotnet add package Healthie.NET.Mcp` | -| **[Healthie.NET.AI](https://www.nuget.org/packages/Healthie.NET.AI)** | Optional AI diagnostics that explain a checker's recent failures. Bring any `IChatClient`. | `dotnet add package Healthie.NET.AI` | -| **[Healthie.NET.Checkers](https://www.nuget.org/packages/Healthie.NET.Checkers)** | Ready-made checkers for HTTP endpoints, TCP ports, TLS certificate expiry, DNS and disk space. | `dotnet add package Healthie.NET.Checkers` | -| **[Healthie.NET.Alerting](https://www.nuget.org/packages/Healthie.NET.Alerting)** | Turns health changes into alerts and delivers them to a webhook or your own sink. | `dotnet add package Healthie.NET.Alerting` | -| **[Healthie.NET.Uptime](https://www.nuget.org/packages/Healthie.NET.Uptime)** | Uptime and SLA reporting over any window, by recording transitions rather than every check. | `dotnet add package Healthie.NET.Uptime` | -| **[Healthie.NET.LeaderElection](https://www.nuget.org/packages/Healthie.NET.LeaderElection)** | Runs the checks on one replica at a time, so a scaled-out app checks each component once. | `dotnet add package Healthie.NET.LeaderElection` | +| **Healthie.NET.Uptime** | `Healthie.NET` — call `AddHealthieUptime()` | Yes. It ships type forwards, so an existing application keeps compiling and running untouched | +| **Healthie.NET.LeaderElection** | `Healthie.NET` — call `AddHealthieLeaderElection()` | Yes, the same way | + +Neither carried a third-party dependency, so keeping them separate cost you two installs and saved +you nothing. They will not gain features; remove the reference whenever it suits you. All packages target **.NET 8** and **.NET 10**. @@ -77,11 +145,10 @@ All packages target **.NET 8** and **.NET 10**. ## Quick Start -### 1. Install Packages +### 1. Install ```shell -dotnet add package Healthie.NET.Abstractions -dotnet add package Healthie.NET.DependencyInjection +dotnet add package Healthie.NET ``` ### 2. Create a Pulse Checker diff --git a/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs b/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs index df3f37b..20ede61 100644 --- a/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs +++ b/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs @@ -21,15 +21,31 @@ public class MetricsInsightsTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; - private sealed class Checker(IStateProvider provider, PulseCheckerHealth health, string name) - : PulseChecker(provider, PulseInterval.EveryMinute) + /// + /// A real checker, so the instruments it reports through are the real ones. + /// + /// + /// Takes only a state provider, and carries what it reports as settable properties. Assembly + /// scanning registers every concrete in this assembly, so a + /// constructor the container cannot satisfy fails every registration, MCP and AI test in the + /// suite rather than only this file's -- which is exactly what it did. + /// + internal sealed class MeteredChecker(IStateProvider stateProvider) + : PulseChecker(stateProvider, PulseInterval.EveryMinute) { - public override string Name => name; + public PulseCheckerHealth Reports { get; set; } = PulseCheckerHealth.Healthy; + + public string CheckerName { get; set; } = "metered"; + + public override string Name => CheckerName; public override Task CheckAsync(CancellationToken cancellationToken = default) => - Task.FromResult(new PulseCheckerResult(health, "measured")); + Task.FromResult(new PulseCheckerResult(Reports, "measured")); } + private static MeteredChecker Checker(IStateProvider provider, PulseCheckerHealth reports, string name) => + new(provider) { Reports = reports, CheckerName = name }; + [Fact] public void AddHealthieMetrics_RegistersTheCollector_AndNothingElseDoes() { @@ -52,55 +68,75 @@ public void AddHealthieMetrics_RegistersTheCollector_AndNothingElseDoes() /// The listener is the whole feature: if it is not attached to the right meter, or the /// instrument names drift, everything below reads zero and the board shows an empty panel. /// + /// + /// Asserted as deltas with a floor, not as exact totals. A MeterListener hears every + /// measurement in the process -- that is the feature -- so any other test running a check at the + /// same time lands in these counters too. Exact numbers passed alone and failed in the full + /// suite, which is the worst way for a test to be wrong. + /// [Fact] public async Task RunningChecks_MovesTheCounters() { using var metrics = new MeterMetricsInsights(); - - Assert.Equal(0, metrics.Snapshot(Ct).Checks); + var before = metrics.Snapshot(Ct); var provider = new InMemoryStateProvider(); - using var healthy = new Checker(provider, PulseCheckerHealth.Healthy, "metrics-healthy"); - using var failing = new Checker(provider, PulseCheckerHealth.Unhealthy, "metrics-failing"); + using var healthy = Checker(provider, PulseCheckerHealth.Healthy, "metrics-healthy"); + using var failing = Checker(provider, PulseCheckerHealth.Unhealthy, "metrics-failing"); await healthy.TriggerAsync(Ct); await healthy.TriggerAsync(Ct); await failing.TriggerAsync(Ct); - var snapshot = metrics.Snapshot(Ct); + var after = metrics.Snapshot(Ct); - Assert.Equal(3, snapshot.Checks); - Assert.Equal(2, snapshot.ResultsByHealth.GetValueOrDefault(PulseCheckerHealth.Healthy)); - Assert.Equal(1, snapshot.ResultsByHealth.GetValueOrDefault(PulseCheckerHealth.Unhealthy)); + Assert.True(after.Checks - before.Checks >= 3, $"checks moved by {after.Checks - before.Checks}"); + Assert.True(Counted(after, PulseCheckerHealth.Healthy) - Counted(before, PulseCheckerHealth.Healthy) >= 2); + Assert.True(Counted(after, PulseCheckerHealth.Unhealthy) - Counted(before, PulseCheckerHealth.Unhealthy) >= 1); // Two checkers went from nothing to a health, so both transitioned. - Assert.True(snapshot.Transitions >= 2, $"expected at least 2 transitions, got {snapshot.Transitions}"); + Assert.True(after.Transitions - before.Transitions >= 2); - Assert.NotNull(snapshot.MeanDuration); - Assert.NotNull(snapshot.SlowestDuration); - Assert.True(snapshot.SlowestDuration >= snapshot.MeanDuration); + Assert.NotNull(after.MeanDuration); + Assert.NotNull(after.SlowestDuration); + Assert.True(after.SlowestDuration >= after.MeanDuration); } + private static long Counted(MetricsSnapshot snapshot, PulseCheckerHealth health) => + snapshot.ResultsByHealth.GetValueOrDefault(health); + /// - /// Two thirds healthy is 66.67%, and a collector that has seen nothing reports no share rather - /// than a confident zero -- the same distinction the uptime panel makes. + /// The healthy share is of the checks run, and is nothing at all before any have. /// - [Fact] - public async Task TheHealthyShare_IsOfChecksRun_AndIsNullBeforeAnyHaveRun() + /// + /// Against a snapshot built directly rather than one collected from the meter: the arithmetic is + /// what is under test, and reading it off a live collector would only re-measure whatever else + /// the suite happened to be running. + /// + [Theory] + [InlineData(0, 0, null)] + [InlineData(3, 2, 66.67)] + [InlineData(4, 4, 100.0)] + [InlineData(5, 0, 0.0)] + public void TheHealthyShare_IsOfChecksRun_AndIsNothingBeforeAnyHaveRun(long checks, long healthy, double? expected) { - using var metrics = new MeterMetricsInsights(); - - Assert.Null(metrics.Snapshot(Ct).HealthyShare); - - var provider = new InMemoryStateProvider(); - using var healthy = new Checker(provider, PulseCheckerHealth.Healthy, "share-healthy"); - using var failing = new Checker(provider, PulseCheckerHealth.Unhealthy, "share-failing"); - - await healthy.TriggerAsync(Ct); - await healthy.TriggerAsync(Ct); - await failing.TriggerAsync(Ct); - - Assert.Equal(66.67, metrics.Snapshot(Ct).HealthyShare!.Value, 1); + var snapshot = new MetricsSnapshot( + checks, + new Dictionary { [PulseCheckerHealth.Healthy] = healthy }, + Transitions: 0, + OverlappedTriggers: 0, + MeanDuration: null, + SlowestDuration: null, + Since: DateTime.UtcNow); + + if (expected is null) + { + Assert.Null(snapshot.HealthyShare); + } + else + { + Assert.Equal(expected.Value, snapshot.HealthyShare!.Value, 1); + } } /// @@ -127,7 +163,7 @@ public async Task OnceDisposed_ItStopsCounting() var metrics = new MeterMetricsInsights(); var provider = new InMemoryStateProvider(); - using var checker = new Checker(provider, PulseCheckerHealth.Healthy, "disposal-target"); + using var checker = Checker(provider, PulseCheckerHealth.Healthy, "disposal-target"); await checker.TriggerAsync(Ct); var before = metrics.Snapshot(Ct).Checks; diff --git a/tests/Healthie.Tests.Unit/PackageLayoutTests.cs b/tests/Healthie.Tests.Unit/PackageLayoutTests.cs index 930b650..1351cee 100644 --- a/tests/Healthie.Tests.Unit/PackageLayoutTests.cs +++ b/tests/Healthie.Tests.Unit/PackageLayoutTests.cs @@ -76,6 +76,43 @@ public void TheCorePackage_RegistersUptimeAndLeaderElection() Assert.NotNull(provider.GetService()); } + /// + /// The meta-package must stay a pointer, not a bundle. + /// + /// + /// One stray ProjectReference in Healthie.Meta.csproj turns dotnet add package + /// Healthie.NET into an install that drags a database driver, a scheduler and a UI framework + /// onto machines that asked for none of them. Nothing else would catch that: it builds, it packs, + /// and the damage is only visible in the restore graph of whoever installed it. + /// + [Fact] + public void TheMetaPackage_DependsOnTheCorePackageAndNothingElse() + { + var csproj = FindRepositoryFile("src/Healthie.Meta/Healthie.Meta.csproj"); + + var referenced = System.Text.RegularExpressions.Regex + .Matches(File.ReadAllText(csproj), @" match.Groups[1].Value) + .ToList(); + + Assert.Equal(["Healthie.DependencyInjection"], referenced); + } + + /// Walks up from the test binaries to the repository root. + private static string FindRepositoryFile(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, relativePath))) + { + directory = directory.Parent; + } + + Assert.True(directory is not null, $"Could not find '{relativePath}' above {AppContext.BaseDirectory}."); + + return Path.Combine(directory!.FullName, relativePath); + } + /// /// Still opt-in. Folding them into core was about how many packages you install, not about /// starting services nobody asked for. From b3817c34f4ec7cbc8ea0af1ff9604fa549997c43 Mon Sep 17 00:00:00 2001 From: Ivan Vydrin Date: Thu, 30 Jul 2026 18:46:04 +0300 Subject: [PATCH 5/7] Name the meta-package project so gitignore stops swallowing it CI could not find src/Healthie.Meta/Healthie.Meta.csproj because it was never committed: the standard Visual Studio ignore block carries `*.meta` for build artefacts, and that pattern matches a directory component too, so the whole project folder was invisible. `git add -A` reported nothing, the local build was happy because the files were on disk, and the first sign of trouble was a restore failure on the runner. Renamed to src/Healthie.NET.Package, after the package it produces, which also reads better than "Meta". The ignore block is left alone: it is the standard template and weakening it to rescue one folder name would trade a naming collision for unignored build output. The guard test follows the new path, and a locked-mode restore now succeeds locally, which is the gate that actually failed. --- Healthie.NET.sln | 28 +-- .../Healthie.NET.Package.csproj | 30 +++ src/Healthie.NET.Package/packages.lock.json | 217 ++++++++++++++++++ .../Healthie.Tests.Unit/PackageLayoutTests.cs | 4 +- 4 files changed, 263 insertions(+), 16 deletions(-) create mode 100644 src/Healthie.NET.Package/Healthie.NET.Package.csproj create mode 100644 src/Healthie.NET.Package/packages.lock.json diff --git a/Healthie.NET.sln b/Healthie.NET.sln index 1d76845..01ead33 100644 --- a/Healthie.NET.sln +++ b/Healthie.NET.sln @@ -68,7 +68,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.LeaderElection", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.StateProviding.Redis", "src\Healthie.StateProviding.Redis\Healthie.StateProviding.Redis.csproj", "{AE94818D-ABCF-4B84-AF49-525A16D7890C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.Meta", "src\Healthie.Meta\Healthie.Meta.csproj", "{3C34D1AC-724C-4848-A8E9-7719CDD21423}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.NET.Package", "src\Healthie.NET.Package\Healthie.NET.Package.csproj", "{A5B438EB-322F-4467-962E-190E22061BDB}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -392,18 +392,18 @@ Global {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 - {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Debug|x64.ActiveCfg = Debug|Any CPU - {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Debug|x64.Build.0 = Debug|Any CPU - {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Debug|x86.ActiveCfg = Debug|Any CPU - {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Debug|x86.Build.0 = Debug|Any CPU - {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Release|Any CPU.Build.0 = Release|Any CPU - {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Release|x64.ActiveCfg = Release|Any CPU - {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Release|x64.Build.0 = Release|Any CPU - {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Release|x86.ActiveCfg = Release|Any CPU - {3C34D1AC-724C-4848-A8E9-7719CDD21423}.Release|x86.Build.0 = Release|Any CPU + {A5B438EB-322F-4467-962E-190E22061BDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5B438EB-322F-4467-962E-190E22061BDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5B438EB-322F-4467-962E-190E22061BDB}.Debug|x64.ActiveCfg = Debug|Any CPU + {A5B438EB-322F-4467-962E-190E22061BDB}.Debug|x64.Build.0 = Debug|Any CPU + {A5B438EB-322F-4467-962E-190E22061BDB}.Debug|x86.ActiveCfg = Debug|Any CPU + {A5B438EB-322F-4467-962E-190E22061BDB}.Debug|x86.Build.0 = Debug|Any CPU + {A5B438EB-322F-4467-962E-190E22061BDB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5B438EB-322F-4467-962E-190E22061BDB}.Release|Any CPU.Build.0 = Release|Any CPU + {A5B438EB-322F-4467-962E-190E22061BDB}.Release|x64.ActiveCfg = Release|Any CPU + {A5B438EB-322F-4467-962E-190E22061BDB}.Release|x64.Build.0 = Release|Any CPU + {A5B438EB-322F-4467-962E-190E22061BDB}.Release|x86.ActiveCfg = Release|Any CPU + {A5B438EB-322F-4467-962E-190E22061BDB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -435,7 +435,7 @@ Global {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} - {3C34D1AC-724C-4848-A8E9-7719CDD21423} = {9BAF98D2-CC85-4721-9C67-88576218F61C} + {A5B438EB-322F-4467-962E-190E22061BDB} = {9BAF98D2-CC85-4721-9C67-88576218F61C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0EE98644-4A9C-4D31-8145-997C8B5A8119} diff --git a/src/Healthie.NET.Package/Healthie.NET.Package.csproj b/src/Healthie.NET.Package/Healthie.NET.Package.csproj new file mode 100644 index 0000000..eb82af1 --- /dev/null +++ b/src/Healthie.NET.Package/Healthie.NET.Package.csproj @@ -0,0 +1,30 @@ + + + + + Healthie.NET + Health monitoring for .NET. Install this to get started: pulse checkers on a schedule, a three-state health model, and pluggable storage. Providers, the dashboard and the REST API are separate packages you add only if you want them. + $(HealthieCommonTags);health;monitoring;healthcheck + + + false + false + false + $(NoWarn);NU5128 + + + + + + + diff --git a/src/Healthie.NET.Package/packages.lock.json b/src/Healthie.NET.Package/packages.lock.json new file mode 100644 index 0000000..9a85b39 --- /dev/null +++ b/src/Healthie.NET.Package/packages.lock.json @@ -0,0 +1,217 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "c5zqFCY9DiIpMovLd7/d/CTiEtrMOuQ639dhv3PABtKQIKNQikSHwQt8+N679uii9q+B55lgK28Uv64FOwEu8w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "Healthie.NET.Abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.10, )" + } + }, + "Healthie.NET.DependencyInjection": { + "type": "Project", + "dependencies": { + "Cronos": "[0.13.0, )", + "Healthie.NET.Abstractions": "[1.0.0, )", + "Microsoft.Extensions.Diagnostics.HealthChecks": "[10.0.10, )" + } + }, + "Cronos": { + "type": "CentralTransitive", + "requested": "[0.13.0, )", + "resolved": "0.13.0", + "contentHash": "Nkve+Yi0+ol3ntSBRHeW0ka9cJQ8vndSUvvkXqF9LVqwpoqWueyxu/HS275r98RDOmF7/mzRnZW5XXUBXob+TA==" + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "R0O5oG+zAJeBSM8nNTa+Ycj2Zobyr/v6Ilo7Dha0sNB2Vq/XXoLdoecj9DAWGbN8YrPaW6u8+osTQ5Ypj7ZF0w==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.10", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "6euxgVR7NS83y0a2wLRAxfYXusLQJ2e1ah0MpQgYTYMs5lYrmdNP79C6T8uvRZdP87n5mcCcp6+w0EyWAidKZw==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "5LugpYGHk+mkn0a8IZgcyfBca8PCTAU9RQFoMrTdtOOidq88M2SI5f3px6ugnzgxC+eTkvYYJi8pzlUnG5xdAQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10" + } + } + }, + "net8.0": { + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "elH2vmwNmsXuKmUeMQ4YW9ldXiF+gSGDgg1vORksob5POnpaI6caj1Hu8zaYbEuibhqCoWg0YRWDazBY3zjBfg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Healthie.NET.Abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[8.0.1, )" + } + }, + "Healthie.NET.DependencyInjection": { + "type": "Project", + "dependencies": { + "Cronos": "[0.13.0, )", + "Healthie.NET.Abstractions": "[1.0.0, )", + "Microsoft.Extensions.Diagnostics.HealthChecks": "[8.0.29, )" + } + }, + "Cronos": { + "type": "CentralTransitive", + "requested": "[0.13.0, )", + "resolved": "0.13.0", + "contentHash": "Nkve+Yi0+ol3ntSBRHeW0ka9cJQ8vndSUvvkXqF9LVqwpoqWueyxu/HS275r98RDOmF7/mzRnZW5XXUBXob+TA==" + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "CentralTransitive", + "requested": "[8.0.29, )", + "resolved": "8.0.29", + "contentHash": "CKaXBomiwjwo+HQv8WhCPVW8LwNP8pVu6nKzRxh23qgOMVNJeerPgxfHaBniFJsFnBzsq9ntu6nXk77CbYvkHA==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.29", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "Microsoft.Extensions.Options": "8.0.2" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "CentralTransitive", + "requested": "[8.0.29, )", + "resolved": "8.0.29", + "contentHash": "HSabrcvae+5j1CYW4MJa8CC9Wd4ZX1l9ImldPjiDeTmcdgfi6XvEuWFLncj/xB2FiVuOVlMAssMWiICPxZnEJA==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "nHwq9aPBdBPYXPti6wYEEfgXddfBrYC+CQLn+qISiwQq5tpfaqDZSKOJNxoe9rfQxGf1c+2wC/qWFe1QYJPYqw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2" + } + } + } + } +} \ No newline at end of file diff --git a/tests/Healthie.Tests.Unit/PackageLayoutTests.cs b/tests/Healthie.Tests.Unit/PackageLayoutTests.cs index 1351cee..21620ba 100644 --- a/tests/Healthie.Tests.Unit/PackageLayoutTests.cs +++ b/tests/Healthie.Tests.Unit/PackageLayoutTests.cs @@ -80,7 +80,7 @@ public void TheCorePackage_RegistersUptimeAndLeaderElection() /// The meta-package must stay a pointer, not a bundle. /// /// - /// One stray ProjectReference in Healthie.Meta.csproj turns dotnet add package + /// One stray ProjectReference in Healthie.NET.Package.csproj turns dotnet add package /// Healthie.NET into an install that drags a database driver, a scheduler and a UI framework /// onto machines that asked for none of them. Nothing else would catch that: it builds, it packs, /// and the damage is only visible in the restore graph of whoever installed it. @@ -88,7 +88,7 @@ public void TheCorePackage_RegistersUptimeAndLeaderElection() [Fact] public void TheMetaPackage_DependsOnTheCorePackageAndNothingElse() { - var csproj = FindRepositoryFile("src/Healthie.Meta/Healthie.Meta.csproj"); + var csproj = FindRepositoryFile("src/Healthie.NET.Package/Healthie.NET.Package.csproj"); var referenced = System.Text.RegularExpressions.Regex .Matches(File.ReadAllText(csproj), @" Date: Thu, 30 Jul 2026 18:56:28 +0300 Subject: [PATCH 6/7] wip: serialise alert-history writes (checkpoint before mutation test) --- src/Healthie.Alerting/AlertHistory.cs | 38 +++++++++++++---- tests/Healthie.Tests.Unit/InsightsTests.cs | 47 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src/Healthie.Alerting/AlertHistory.cs b/src/Healthie.Alerting/AlertHistory.cs index 61ace8b..3b846b8 100644 --- a/src/Healthie.Alerting/AlertHistory.cs +++ b/src/Healthie.Alerting/AlertHistory.cs @@ -46,6 +46,9 @@ public sealed class AlertHistory( // A plain object, not System.Threading.Lock: this package targets net8.0 as well. private readonly object _gate = new(); + /// Lets one write reach the state provider at a time. See . + private readonly SemaphoreSlim _writeGate = new(1, 1); + private bool _loaded; private readonly Dictionary _sinks = []; @@ -134,8 +137,6 @@ public void Record(Alert alert, bool delivered) alert.OccurredAt, delivered); - List toPersist; - lock (_gate) { // Trims after enqueuing rather than before. Dropping the oldest first has to special-case @@ -146,13 +147,11 @@ public void Record(Alert alert, bool delivered) { _recent.Dequeue(); } - - toPersist = [.. _recent]; } - // Outside the lock: this is a round trip to the state store, and holding a lock across it - // would stall every reader of the board for the duration of a database write. - _ = PersistAsync(toPersist); + // Outside the lock: a round trip to the state store held under it would stall every reader of + // the board for the duration of a database write. + _ = PersistAsync(); } /// Records that an alert never reached the queue. @@ -231,15 +230,34 @@ private async Task EnsureLoadedAsync(CancellationToken cancellationToken) } } - private async Task PersistAsync(List log) + /// + /// Writes the whole log, one writer at a time. + /// + /// + /// The snapshot is taken inside the gate, not passed in. Two alerts raised back to back + /// start two writes, and nothing orders them: with the snapshot captured at call time the older + /// one could land last and silently drop the newer alert. Taking it here means whichever write + /// goes last writes the newest state, so the store converges on what is actually held. A real + /// PostgreSQL under CI timing found this; two writes in a row on a fast local disk did not. + /// + private async Task PersistAsync() { if (stateProvider is null) { return; } + await _writeGate.WaitAsync().ConfigureAwait(false); + try { + List log; + + lock (_gate) + { + log = [.. _recent]; + } + await stateProvider.SetStateAsync(StorageKey, log).ConfigureAwait(false); } catch (Exception ex) @@ -248,5 +266,9 @@ private async Task PersistAsync(List log) // must not take alerting down with it -- the alert has already reached its sinks. logger?.LogWarning(ex, "Could not persist the alert history to the state provider."); } + finally + { + _writeGate.Release(); + } } } diff --git a/tests/Healthie.Tests.Unit/InsightsTests.cs b/tests/Healthie.Tests.Unit/InsightsTests.cs index b7f3676..59c43af 100644 --- a/tests/Healthie.Tests.Unit/InsightsTests.cs +++ b/tests/Healthie.Tests.Unit/InsightsTests.cs @@ -213,6 +213,53 @@ public async Task AlertHistory_OnADurableProvider_SurvivesTheProcessThatWroteIt( Assert.False(after.Alerts[0].Delivered); } + /// + /// A store slow enough to make the ordering of concurrent writes visible. + /// + /// + /// The first write is held up longer than the second, which is what a real database does under + /// load and what a fast local disk does not. Without serialised persistence the older, shorter + /// snapshot lands last and the newer alert is silently lost. + /// + private sealed class SlowFirstWriteProvider : IStateProvider + { + private readonly InMemoryStateProvider _inner = new(); + private int _writes; + + public async Task SetStateAsync(string name, T state, CancellationToken cancellationToken = default) + { + var delay = Interlocked.Increment(ref _writes) == 1 ? 250 : 10; + await Task.Delay(delay, cancellationToken); + + await _inner.SetStateAsync(name, state, cancellationToken); + } + + public Task GetStateAsync(string name, CancellationToken cancellationToken = default) => + _inner.GetStateAsync(name, cancellationToken); + } + + /// + /// Two alerts raised back to back must both survive. Nothing orders the writes they trigger, so + /// a snapshot captured at call time could be written after a newer one and undo it. + /// + [Fact] + public async Task AlertHistory_WhenTwoAlertsRaceToPersist_NeitherIsLost() + { + var store = new SlowFirstWriteProvider(); + var history = new AlertHistory(capacity: 10, store); + + history.Record(Alert("first"), delivered: true); + history.Record(Alert("second"), delivered: true); + + // Long enough for both writes to have finished, whichever order they ran in. + await WaitForAsync(async () => (await new AlertHistory(10, store).GetAlertsAsync(0, 10, Ct)).Total == 2); + + var reloaded = await new AlertHistory(capacity: 10, store).GetAlertsAsync(0, 10, Ct); + + Assert.Equal(2, reloaded.Total); + Assert.Equal(["second", "first"], reloaded.Alerts.Select(alert => alert.CheckerName)); + } + /// /// A page is a window on the whole history, and the total it reports is what a pager counts /// against -- not the size of the page it happens to be looking at. From cbc40dfd70fbc42f7407fe47239c56021d7a557f Mon Sep 17 00:00:00 2001 From: Ivan Vydrin Date: Thu, 30 Jul 2026 19:01:42 +0300 Subject: [PATCH 7/7] Serialise the alert-history writes, so a second alert cannot undo the first CI found this against a real PostgreSQL: two alerts recorded back to back left one in the store. Record captured a snapshot and started a write without waiting for the one before it, and nothing ordered them -- so the older, shorter snapshot could land last and silently drop the newer alert. It passed locally only because two writes to a fast disk happened to finish in order. The snapshot is now taken inside a write gate rather than at call time, so whichever write goes last writes the newest state and the store converges on what is actually held. The regression test needed correcting twice before it was worth trusting. It first polled until the store held two alerts and asserted -- which passes the instant the fast write lands and looks away before the slow one overwrites it, observing the very failure under test and calling it success. It waits a fixed settle past both writes now. With the gate removed it reports 2 expected, 1 actual. --- tests/Healthie.Tests.Unit/InsightsTests.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/Healthie.Tests.Unit/InsightsTests.cs b/tests/Healthie.Tests.Unit/InsightsTests.cs index 59c43af..321d460 100644 --- a/tests/Healthie.Tests.Unit/InsightsTests.cs +++ b/tests/Healthie.Tests.Unit/InsightsTests.cs @@ -223,12 +223,17 @@ public async Task AlertHistory_OnADurableProvider_SurvivesTheProcessThatWroteIt( /// private sealed class SlowFirstWriteProvider : IStateProvider { + private const int SlowWriteMs = 250; + + /// Comfortably past both writes, so the assert sees the state they settled on. + public static readonly TimeSpan SettleFor = TimeSpan.FromMilliseconds(SlowWriteMs * 4); + private readonly InMemoryStateProvider _inner = new(); private int _writes; public async Task SetStateAsync(string name, T state, CancellationToken cancellationToken = default) { - var delay = Interlocked.Increment(ref _writes) == 1 ? 250 : 10; + var delay = Interlocked.Increment(ref _writes) == 1 ? SlowWriteMs : 10; await Task.Delay(delay, cancellationToken); await _inner.SetStateAsync(name, state, cancellationToken); @@ -251,8 +256,10 @@ public async Task AlertHistory_WhenTwoAlertsRaceToPersist_NeitherIsLost() history.Record(Alert("first"), delivered: true); history.Record(Alert("second"), delivered: true); - // Long enough for both writes to have finished, whichever order they ran in. - await WaitForAsync(async () => (await new AlertHistory(10, store).GetAlertsAsync(0, 10, Ct)).Total == 2); + // A fixed settle, deliberately not a poll for the answer. Polling until the store holds two + // passes the moment the fast write lands and asserts before the slow one overwrites it -- + // which is the very failure under test, observed and then looked away from. + await Task.Delay(SlowFirstWriteProvider.SettleFor, Ct); var reloaded = await new AlertHistory(capacity: 10, store).GetAlertsAsync(0, 10, Ct);