diff --git a/CLAUDE.md b/CLAUDE.md index b9a02ad..320897a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ dotnet test # Run all tests (multi-targets: net8.0, net9.0, net10.0) dotnet test --filter "FullyQualifiedName~SomeTest" # Run a single test ``` -Build artifacts go to `/artifacts/bin/` (configured in `Directory.Build.props`). +Build artifacts go to `/artifacts/bin/` (configured in `Directory.Build.props`). The solution also contains `examples/HealthMonitor.Examples/` with runnable usage examples. ## Architecture @@ -37,7 +37,9 @@ Build artifacts go to `/artifacts/bin/` (configured in `Directory.Build.props`). ### DI registration -`AddHealthMonitor()` registers monitors as keyed services (by name, .NET 8+) and via `IEnumerable` on all targets. The hosted service resolves all monitors through the coordinator. +`AddHealthMonitor()` registers monitors as keyed services (by name, .NET 8+) and via `IEnumerable` on all targets. The hosted service resolves all monitors through the coordinator. The call is idempotent for the shared infrastructure (hosted service, coordinator) — call it once per monitor name. + +On `netstandard2.0` (no keyed DI), resolve a specific monitor by filtering `IEnumerable` by `Name`. ### Dynamic registration (no DI) diff --git a/Directory.Build.props b/Directory.Build.props index 2b67b89..4576551 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,6 @@ - - $(MSBuildThisFileDirectory)artifacts - + true latest enable enable diff --git a/Directory.Packages.props b/Directory.Packages.props index d4961c4..b69656f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ - + diff --git a/README.md b/README.md index 7d6d1e3..0e38b38 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![CI](https://github.com/coldhighsun/HealthMonitor/actions/workflows/ci.yml/badge.svg)](https://github.com/coldhighsun/HealthMonitor/actions/workflows/ci.yml) [![NuGet Version](https://img.shields.io/nuget/v/HealthMonitor.Core)](https://www.nuget.org/packages/HealthMonitor.Core) [![NuGet Downloads](https://img.shields.io/nuget/dt/HealthMonitor.Core)](https://www.nuget.org/packages/HealthMonitor.Core) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) A lightweight .NET library for monitoring application health via periodic heartbeats. Supports dependency injection, multiple named monitors, and fires `Degraded` / `Recovered` events on state transitions. @@ -120,7 +121,7 @@ monitor.Signal(); ```csharp using var manager = new DynamicHealthMonitorManager(); -// Subscribe once on the manager — fires for all monitors, including those added later +// Subscribe at any time — fires for all registered monitors, regardless of when they were added manager.Degraded += (_, e) => Console.WriteLine($"{e.MonitorName} degraded"); manager.Recovered += (_, e) => Console.WriteLine($"{e.MonitorName} recovered"); @@ -178,6 +179,7 @@ Build outputs land in `./artifacts/bin/`. [![CI](https://github.com/coldhighsun/HealthMonitor/actions/workflows/ci.yml/badge.svg)](https://github.com/coldhighsun/HealthMonitor/actions/workflows/ci.yml) [![NuGet 版本](https://img.shields.io/nuget/v/HealthMonitor.Core)](https://www.nuget.org/packages/HealthMonitor.Core) [![NuGet 下载量](https://img.shields.io/nuget/dt/HealthMonitor.Core)](https://www.nuget.org/packages/HealthMonitor.Core) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) 轻量级 .NET 健康监控库,通过周期性心跳信号判断组件是否存活,在状态切换时触发 `Degraded` / `Recovered` 事件。支持依赖注入、多个命名监控器。 @@ -295,7 +297,7 @@ monitor.Signal(); ```csharp using var manager = new DynamicHealthMonitorManager(); -// 在 manager 上订阅一次 — 对所有监控器生效,包括后续动态添加的 +// 可在任意时机订阅 — 对所有已注册的监控器生效,无论添加顺序 manager.Degraded += (_, e) => Console.WriteLine($"{e.MonitorName} 已降级"); manager.Recovered += (_, e) => Console.WriteLine($"{e.MonitorName} 已恢复"); diff --git a/tests/HealthMonitor.Tests/Monitors/DynamicHealthMonitorManagerTests.cs b/tests/HealthMonitor.Tests/Monitors/DynamicHealthMonitorManagerTests.cs index 0e5cb8e..15d35c1 100644 --- a/tests/HealthMonitor.Tests/Monitors/DynamicHealthMonitorManagerTests.cs +++ b/tests/HealthMonitor.Tests/Monitors/DynamicHealthMonitorManagerTests.cs @@ -299,6 +299,27 @@ public void TryGet_NonExistentMonitor_ReturnsNull() Assert.Null(manager.TryGet("missing")); } + [Fact] + public async Task ManagerDegraded_FiresForHandlerSubscribedAfterAdd() + { + using var manager = new DynamicHealthMonitorManager(new FakeSystemTimeProvider()); + + manager.Add("svc", new HealthMonitorOptions + { + DegradedThreshold = TimeSpan.FromMilliseconds(100), + CheckInterval = TimeSpan.FromMilliseconds(50), + }); + + // Subscribe AFTER adding the monitor + HealthDegradedEventArgs? captured = null; + manager.Degraded += (_, e) => captured = e; + + await PollUntil(() => captured is not null, timeout: TimeSpan.FromSeconds(5)); + + Assert.NotNull(captured); + Assert.Equal("svc", captured.MonitorName); + } + private static async Task PollUntil(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; diff --git a/tests/HealthMonitor.Tests/Monitors/HealthMonitorBaseTests.cs b/tests/HealthMonitor.Tests/Monitors/HealthMonitorBaseTests.cs index 308bf9c..8ed3080 100644 --- a/tests/HealthMonitor.Tests/Monitors/HealthMonitorBaseTests.cs +++ b/tests/HealthMonitor.Tests/Monitors/HealthMonitorBaseTests.cs @@ -218,6 +218,56 @@ private static ( return (monitor, clock, signalWatch, checkWatch, stateWatch); } + [Fact] + public void Recovered_Args_Timestamp_UsesWallClock() + { + var (monitor, clock, signalWatch, checkWatch, _) = CreateMonitor(); + + // Degrade first + signalWatch.Advance(TimeSpan.FromSeconds(31)); + checkWatch.Advance(TimeSpan.FromSeconds(5)); + monitor.Tick(); + + HealthRecoveredEventArgs? recovered = null; + ((IHealthMonitor)monitor).Recovered += (_, e) => recovered = e; + + clock.Advance(TimeSpan.FromMinutes(1)); + var expectedTime = clock.UtcNow; + ((IHealthMonitor)monitor).Signal(); + + Assert.NotNull(recovered); + Assert.Equal(expectedTime, recovered.Timestamp); + } + + [Fact] + public void Degrade_Recover_Degrade_CycleWorks() + { + var (monitor, _, signalWatch, checkWatch, _) = CreateMonitor(degradedThreshold: TimeSpan.FromSeconds(30)); + + var degradedCount = 0; + var recoveredCount = 0; + ((IHealthMonitor)monitor).Degraded += (_, _) => degradedCount++; + ((IHealthMonitor)monitor).Recovered += (_, _) => recoveredCount++; + + // First degradation + signalWatch.Advance(TimeSpan.FromSeconds(31)); + checkWatch.Advance(TimeSpan.FromSeconds(5)); + monitor.Tick(); + Assert.Equal(1, degradedCount); + + // Recovery + ((IHealthMonitor)monitor).Signal(); + Assert.Equal(1, recoveredCount); + Assert.True(monitor.IsHealthy); + + // Second degradation + signalWatch.Advance(TimeSpan.FromSeconds(31)); + checkWatch.Advance(TimeSpan.FromSeconds(5)); + monitor.Tick(); + Assert.Equal(2, degradedCount); + Assert.False(monitor.IsHealthy); + } + // ── Initial state ────────────────────────────────────────────────────────── // ── Degraded ─────────────────────────────────────────────────────────────── // ── Signal / Recovered ───────────────────────────────────────────────────── diff --git a/tests/HealthMonitor.Tests/Services/DependencyInjectionTests.cs b/tests/HealthMonitor.Tests/Services/DependencyInjectionTests.cs index 639d23c..7db8109 100644 --- a/tests/HealthMonitor.Tests/Services/DependencyInjectionTests.cs +++ b/tests/HealthMonitor.Tests/Services/DependencyInjectionTests.cs @@ -86,6 +86,22 @@ public void AddHealthMonitor_RegistersSingleMonitor() Assert.Equal("quotes", monitors[0].Name); } + [Fact] + public void AddHealthMonitor_IsIdempotent_ForSharedInfrastructure() + { + using var provider = BuildProvider(s => + { + s.AddHealthMonitor("quotes"); + s.AddHealthMonitor("orders"); + }); + + // Shared infrastructure (hosted service) should be registered exactly once + var hostedServices = provider.GetRequiredService>() + .Where(hs => hs.GetType().Name == "HealthMonitorHostedService") + .ToList(); + Assert.Single(hostedServices); + } + private static ServiceProvider BuildProvider(Action configure) { var services = new ServiceCollection();