From 7de0719b5e5b5f8d05f3d29114ec7da43f13a1a8 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 13 Aug 2026 18:55:16 +0200 Subject: [PATCH 1/6] Add design spec and implementation plan for readiness health check Co-Authored-By: Claude Sonnet 5 --- .../2026-08-13-readiness-health-check.md | 313 ++++++++++++++++++ ...026-08-13-readiness-health-check-design.md | 69 ++++ 2 files changed, 382 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-13-readiness-health-check.md create mode 100644 docs/superpowers/specs/2026-08-13-readiness-health-check-design.md diff --git a/docs/superpowers/plans/2026-08-13-readiness-health-check.md b/docs/superpowers/plans/2026-08-13-readiness-health-check.md new file mode 100644 index 000000000..fdc365ba6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-readiness-health-check.md @@ -0,0 +1,313 @@ +# Readiness Health Check Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `/health/ready` endpoint, distinct from the existing `/health/live`, that reports whether the application process has finished starting and is configured — without probing MongoDB or Redis. + +**Architecture:** Two tags (`live`, `ready`) partition the ASP.NET Core `HealthCheckService` registry. `/health/live` keeps mapping to the existing always-healthy `"self"` check (tag `live`). A new `StartupHealthCheck` (tag `ready`) is healthy only once `IHostApplicationLifetime.ApplicationStarted` has fired and `DataSettingsManager.DatabaseIsInstalled()` reports a configured connection string — both are in-memory/process-local checks, no network I/O. `/health/ready` is mapped with a `Predicate` that filters to the `ready` tag. + +**Tech Stack:** ASP.NET Core `Microsoft.Extensions.Diagnostics.HealthChecks` (already referenced), MSTest + Moq (existing test stack in `Grand.Web.Common.Tests`). + +## Global Constraints + +- Do not add any MongoDB or Redis connectivity check — readiness here covers the application process only. (Explicit scope decision; tracked as a future extension under `OBS-011` in `docs/architecture/grandnode-architecture-roadmap.md`.) +- `/health/live` behavior must not change (always returns `Healthy`). +- No new NuGet packages — `Microsoft.Extensions.Diagnostics.HealthChecks` and `IHostApplicationLifetime` (part of `Microsoft.Extensions.Hosting.Abstractions`) are already available transitively. +- Follow existing folder→namespace convention in `Grand.Web.Common` (e.g. `Infrastructure/Middleware` → `Grand.Web.Common.Middleware`). +- Follow existing MSTest + Moq test conventions (see `src/Tests/Grand.Web.Common.Tests/Infrastructure/BackgroundServiceTaskTests.cs`). + +--- + +### Task 1: `StartupHealthCheck` + +**Files:** +- Create: `src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs` +- Test: `src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs` + +**Interfaces:** +- Consumes: `Grand.Data.DataSettingsManager.DatabaseIsInstalled()` (static, existing), `Microsoft.Extensions.Hosting.IHostApplicationLifetime.ApplicationStarted` (framework-provided `CancellationToken` property). +- Produces: `Grand.Web.Common.Infrastructure.HealthChecks.StartupHealthCheck`, a public class implementing `Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck` with a public constructor `StartupHealthCheck(IHostApplicationLifetime applicationLifetime)`. Task 2 registers this type with `hcBuilder.AddCheck(...)`. + +`DataSettingsManager` is a process-wide static singleton whose `DatabaseIsInstalled()` result is cached after the first call and `ResetCache()` can only force it to `false` (never back to `true`) — see `src/Core/Grand.Data/DataSettingsManager.cs:74-91`. To get independent, order-safe `true`/`false` results per test, each test resets the private static `_instance` field via reflection and re-initializes against its own temp settings file, mirroring the fresh-`Initialize` pattern already used in `src/Tests/Grand.Domain.Tests/Data/DataSettingsManagerTests.cs` and `src/Tests/Grand.Web.Common.Tests/AuthorizeMenuAttributeTests.cs:33-35`, but going one step further (full instance reset) so the "installed" and "not installed" cases don't leak into each other within the same test process. + +- [ ] **Step 1: Write the failing tests** + +Create `src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs`: + +```csharp +using System.Reflection; +using Grand.Data; +using Grand.Web.Common.Infrastructure.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Infrastructure.HealthChecks; + +[TestClass] +public class StartupHealthCheckTests +{ + // DataSettingsManager caches DatabaseIsInstalled() on first call and its public ResetCache() + // can only force the cached value to false, never back to true - see DataSettingsManager.cs. + // Resetting the private static instance per test keeps "installed" and "not installed" cases + // independent instead of depending on test execution order. + private static readonly FieldInfo InstanceField = typeof(DataSettingsManager) + .GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static)!; + + private string _settingsPath = null!; + + [TestInitialize] + public void Setup() + { + InstanceField.SetValue(null, null); + _settingsPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.txt"); + DataSettingsManager.Initialize(_settingsPath); + } + + [TestCleanup] + public void Cleanup() + { + if (File.Exists(_settingsPath)) + File.Delete(_settingsPath); + } + + private static Mock MockLifetime(bool started) + { + var cts = new CancellationTokenSource(); + if (started) cts.Cancel(); + + var lifetime = new Mock(); + lifetime.Setup(l => l.ApplicationStarted).Returns(cts.Token); + return lifetime; + } + + [TestMethod] + public async Task CheckHealthAsync_ApplicationNotStarted_ReturnsUnhealthy() + { + DataSettingsManager.Instance.LoadDataSettings( + new DataSettings { ConnectionString = "mongodb://localhost/test", DbProvider = DbProvider.MongoDB }); + + var check = new StartupHealthCheck(MockLifetime(started: false).Object); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + Assert.AreEqual(HealthStatus.Unhealthy, result.Status); + } + + [TestMethod] + public async Task CheckHealthAsync_StartedButDatabaseNotConfigured_ReturnsUnhealthy() + { + // no connection string loaded - DatabaseIsInstalled() evaluates to false on first call + var check = new StartupHealthCheck(MockLifetime(started: true).Object); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + Assert.AreEqual(HealthStatus.Unhealthy, result.Status); + } + + [TestMethod] + public async Task CheckHealthAsync_StartedAndDatabaseConfigured_ReturnsHealthy() + { + DataSettingsManager.Instance.LoadDataSettings( + new DataSettings { ConnectionString = "mongodb://localhost/test", DbProvider = DbProvider.MongoDB }); + + var check = new StartupHealthCheck(MockLifetime(started: true).Object); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + Assert.AreEqual(HealthStatus.Healthy, result.Status); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail to compile** + +Run: `dotnet test src/Tests/Grand.Web.Common.Tests/Grand.Web.Common.Tests.csproj --filter StartupHealthCheckTests` +Expected: build error — `Grand.Web.Common.Infrastructure.HealthChecks.StartupHealthCheck` does not exist. + +- [ ] **Step 3: Write the implementation** + +Create `src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs`: + +```csharp +using Grand.Data; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace Grand.Web.Common.Infrastructure.HealthChecks; + +/// +/// Reports whether the application has finished starting and is configured with a database +/// connection. Intentionally does not probe MongoDB or Redis - see OBS-011 in +/// docs/architecture/grandnode-architecture-roadmap.md for that extension. +/// +public class StartupHealthCheck : IHealthCheck +{ + private readonly IHostApplicationLifetime _applicationLifetime; + + public StartupHealthCheck(IHostApplicationLifetime applicationLifetime) + { + _applicationLifetime = applicationLifetime; + } + + public Task CheckHealthAsync(HealthCheckContext context, + CancellationToken cancellationToken = default) + { + if (!_applicationLifetime.ApplicationStarted.IsCancellationRequested) + return Task.FromResult(HealthCheckResult.Unhealthy("Application has not finished starting.")); + + return Task.FromResult(DataSettingsManager.DatabaseIsInstalled() + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy("Database connection is not configured.")); + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test src/Tests/Grand.Web.Common.Tests/Grand.Web.Common.Tests.csproj --filter StartupHealthCheckTests` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs +git commit -m "Add StartupHealthCheck for application readiness" +``` + +--- + +### Task 2: Register the readiness check with tags + +**Files:** +- Modify: `src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs:267-271` + +**Interfaces:** +- Consumes: `Grand.Web.Common.Infrastructure.HealthChecks.StartupHealthCheck` (Task 1). +- Produces: two tagged health checks (`"self"` tagged `"live"`, `"startup"` tagged `"ready"`) in the DI-registered `HealthCheckService`, consumed by Task 3's endpoint mapping via `HealthCheckOptions.Predicate`. + +- [ ] **Step 1: Update `AddGrandHealthChecks`** + +Replace (`src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs:267-271`): + +```csharp + public static void AddGrandHealthChecks(this IServiceCollection services) + { + var hcBuilder = services.AddHealthChecks(); + hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy()); + } +``` + +with: + +```csharp + public static void AddGrandHealthChecks(this IServiceCollection services) + { + var hcBuilder = services.AddHealthChecks(); + //liveness: process can respond to a request - never touches an external dependency + hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]); + //readiness: application finished starting and is configured - intentionally does not + //probe MongoDB/Redis, see OBS-011 in docs/architecture/grandnode-architecture-roadmap.md + hcBuilder.AddCheck("startup", tags: ["ready"]); + } +``` + +Add the using at the top of the file (alongside the existing `using Microsoft.Extensions.Diagnostics.HealthChecks;` on line 19): + +```csharp +using Grand.Web.Common.Infrastructure.HealthChecks; +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `dotnet build src/Web/Grand.Web.Common/Grand.Web.Common.csproj` +Expected: build succeeds with no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs +git commit -m "Tag health checks as live/ready and register StartupHealthCheck" +``` + +--- + +### Task 3: Map `/health/ready` + +**Files:** +- Modify: `src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs:192-199` + +**Interfaces:** +- Consumes: the `"live"`/`"ready"` tags registered in Task 2. +- Produces: `/health/live` and `/health/ready` HTTP endpoints, mapped by `GrandMvcStartup`/`GrandCommonStartup` (unchanged call site — `application.UseGrandHealthChecks()` in `src/Web/Grand.Web.Common/Startup/GrandCommonStartup.cs:97`). + +- [ ] **Step 1: Update `UseGrandHealthChecks`** + +Replace (`src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs:196-199`): + +```csharp + public static void UseGrandHealthChecks(this WebApplication application) + { + application.UseHealthChecks("/health/live"); + } +``` + +with: + +```csharp + public static void UseGrandHealthChecks(this WebApplication application) + { + application.UseHealthChecks("/health/live", new HealthCheckOptions { + Predicate = check => check.Tags.Contains("live") + }); + + //intentionally does not probe MongoDB/Redis - see OBS-011 in + //docs/architecture/grandnode-architecture-roadmap.md for that extension + application.UseHealthChecks("/health/ready", new HealthCheckOptions { + Predicate = check => check.Tags.Contains("ready") + }); + } +``` + +Add the using at the top of the file (alongside the existing `using Microsoft.AspNetCore.Builder;` on line 8): + +```csharp +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `dotnet build src/Web/Grand.Web.Common/Grand.Web.Common.csproj` +Expected: build succeeds with no errors. + +- [ ] **Step 3: Manual verification against a running host** + +Run: `dotnet run --project src/Web/Grand.Web/Grand.Web.csproj` (Kestrel, per `reference_running_the_storefront` — IIS Express needs a `web.config` this repo doesn't ship). + +Once the app is listening: + +```bash +curl -i http://localhost:/health/live +curl -i http://localhost:/health/ready +``` + +Expected: both return `200 OK` with body `Healthy` once the app and (if applicable) the install wizard have completed. If the instance has no database configured yet, `/health/ready` returns `503 Service Unavailable` while `/health/live` still returns `200 OK`. + +- [ ] **Step 4: Commit** + +```bash +git add src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs +git commit -m "Map /health/ready alongside /health/live" +``` + +--- + +## Definition of Done + +- [ ] All three `StartupHealthCheck` unit tests pass (Task 1). +- [ ] `Grand.Web.Common` builds with no warnings introduced. +- [ ] `/health/live` still returns `200` unconditionally (unchanged behavior, verified manually in Task 3). +- [ ] `/health/ready` returns `503` before startup completes / before the database is configured, `200` once both hold (verified manually in Task 3). +- [ ] No MongoDB or Redis check was added anywhere in this change. +- [ ] Design spec (`docs/superpowers/specs/2026-08-13-readiness-health-check-design.md`) and this plan are committed alongside the code changes. diff --git a/docs/superpowers/specs/2026-08-13-readiness-health-check-design.md b/docs/superpowers/specs/2026-08-13-readiness-health-check-design.md new file mode 100644 index 000000000..3a6487be2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-readiness-health-check-design.md @@ -0,0 +1,69 @@ +# Readiness health check — design + +**Date:** 2026-08-13 +**Related:** `docs/architecture/grandnode-architecture-roadmap.md` → `OBS-001`, `OBS-011` + +## Problem + +`AddGrandHealthChecks` registers a single check (`"self"`) that always returns `Healthy`, and +`UseGrandHealthChecks` maps only `/health/live` to it +(`src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs:267-271`, +`src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs:196-199`). There is no +`/health/ready` endpoint, so an orchestrator has no way to tell "process is alive" apart from +"instance has finished starting and is configured to serve traffic". + +The architecture roadmap (`OBS-011`) recommends a `/health/ready` that also pings MongoDB and +Redis. **Explicit scope decision for this change: do not probe MongoDB or Redis.** Readiness here +checks only the application process itself — no network calls to external dependencies. Checking +those is left as a future extension (tracked by `OBS-011`). + +## Design + +Two tags distinguish the two endpoints, following the standard ASP.NET Core +`Microsoft.Extensions.Diagnostics.HealthChecks` pattern: + +- **`live`** — existing `"self"` check, unchanged behavior (always `Healthy`). Tagged `"live"`. +- **`ready`** — new `"startup"` check, tagged `"ready"`. `Healthy` only when both hold: + 1. `IHostApplicationLifetime.ApplicationStarted.IsCancellationRequested` is `true` — every + `IStartupApplication.Configure` and hosted service has completed startup. Guards against an + orchestrator routing traffic to an instance that is still initializing. + 2. `DataSettingsManager.DatabaseIsInstalled()` is `true` — the instance has a connection string + configured (via the install wizard's `App_Data/Settings.txt` or via + `ConnectionStrings`/environment configuration read by `StartupBase.InitDatabase`). This is a + read of already-loaded in-memory state, not a network call — no DB/Redis is contacted. It + distinguishes a freshly-deployed instance still waiting on the install wizard from a + configured one. + +### Implementation + +- `ServiceCollectionExtensions.AddGrandHealthChecks`: + - `hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])` + - `hcBuilder.AddCheck("startup", tags: ["ready"])` + - New `StartupHealthCheck : IHealthCheck` (constructor-injects `IHostApplicationLifetime`) + implementing the two conditions above. +- `ApplicationBuilderExtensions.UseGrandHealthChecks`: + - `application.UseHealthChecks("/health/live", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") });` + - `application.UseHealthChecks("/health/ready", new HealthCheckOptions { Predicate = r => r.Tags.Contains("ready") });` + - A short comment on the `/health/ready` mapping states the check intentionally does not probe + MongoDB/Redis and points to `OBS-011` for the tracked extension. + +### Testing + +- Unit test for `StartupHealthCheck` in `src/Tests/Grand.Web.Common.Tests`: + - Unhealthy when application has not finished starting. + - Unhealthy when started but `DatabaseIsInstalled()` is false. + - Healthy when both conditions are true. + +### Non-goals + +- No MongoDB/Redis connectivity check (explicit user decision — future work under `OBS-011`). +- No change to `/health/live` behavior. +- No health check UI/dashboard. + +## Acceptance criteria + +- [ ] `/health/live` still returns 200 unconditionally (unchanged). +- [ ] `/health/ready` returns 503 before `ApplicationStarted` fires. +- [ ] `/health/ready` returns 503 when the database/install is not configured. +- [ ] `/health/ready` returns 200 once both conditions hold. +- [ ] Unit tests for `StartupHealthCheck` cover all three states above. From da4747f1aea580f7b8cd75dcc48429d4b4b8ff3a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 13 Aug 2026 18:57:00 +0200 Subject: [PATCH 2/6] Add StartupHealthCheck for application readiness Co-Authored-By: Claude Sonnet 5 --- .../HealthChecks/StartupHealthCheckTests.cs | 84 +++++++++++++++++++ .../HealthChecks/StartupHealthCheck.cs | 31 +++++++ 2 files changed, 115 insertions(+) create mode 100644 src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs create mode 100644 src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs diff --git a/src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs b/src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs new file mode 100644 index 000000000..f6974ca95 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs @@ -0,0 +1,84 @@ +using System.Reflection; +using Grand.Data; +using Grand.Web.Common.Infrastructure.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Infrastructure.HealthChecks; + +[TestClass] +public class StartupHealthCheckTests +{ + // DataSettingsManager caches DatabaseIsInstalled() on first call and its public ResetCache() + // can only force the cached value to false, never back to true - see DataSettingsManager.cs. + // Resetting the private static instance per test keeps "installed" and "not installed" cases + // independent instead of depending on test execution order. + private static readonly FieldInfo InstanceField = typeof(DataSettingsManager) + .GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static)!; + + private string _settingsPath = null!; + + [TestInitialize] + public void Setup() + { + InstanceField.SetValue(null, null); + _settingsPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.txt"); + DataSettingsManager.Initialize(_settingsPath); + } + + [TestCleanup] + public void Cleanup() + { + if (File.Exists(_settingsPath)) + File.Delete(_settingsPath); + } + + private static Mock MockLifetime(bool started) + { + var cts = new CancellationTokenSource(); + if (started) cts.Cancel(); + + var lifetime = new Mock(); + lifetime.Setup(l => l.ApplicationStarted).Returns(cts.Token); + return lifetime; + } + + [TestMethod] + public async Task CheckHealthAsync_ApplicationNotStarted_ReturnsUnhealthy() + { + DataSettingsManager.Instance.LoadDataSettings( + new DataSettings { ConnectionString = "mongodb://localhost/test", DbProvider = DbProvider.MongoDB }); + + var check = new StartupHealthCheck(MockLifetime(started: false).Object); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + Assert.AreEqual(HealthStatus.Unhealthy, result.Status); + } + + [TestMethod] + public async Task CheckHealthAsync_StartedButDatabaseNotConfigured_ReturnsUnhealthy() + { + // no connection string loaded - DatabaseIsInstalled() evaluates to false on first call + var check = new StartupHealthCheck(MockLifetime(started: true).Object); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + Assert.AreEqual(HealthStatus.Unhealthy, result.Status); + } + + [TestMethod] + public async Task CheckHealthAsync_StartedAndDatabaseConfigured_ReturnsHealthy() + { + DataSettingsManager.Instance.LoadDataSettings( + new DataSettings { ConnectionString = "mongodb://localhost/test", DbProvider = DbProvider.MongoDB }); + + var check = new StartupHealthCheck(MockLifetime(started: true).Object); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + Assert.AreEqual(HealthStatus.Healthy, result.Status); + } +} diff --git a/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs b/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs new file mode 100644 index 000000000..f4af37313 --- /dev/null +++ b/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs @@ -0,0 +1,31 @@ +using Grand.Data; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace Grand.Web.Common.Infrastructure.HealthChecks; + +/// +/// Reports whether the application has finished starting and is configured with a database +/// connection. Intentionally does not probe MongoDB or Redis - see OBS-011 in +/// docs/architecture/grandnode-architecture-roadmap.md for that extension. +/// +public class StartupHealthCheck : IHealthCheck +{ + private readonly IHostApplicationLifetime _applicationLifetime; + + public StartupHealthCheck(IHostApplicationLifetime applicationLifetime) + { + _applicationLifetime = applicationLifetime; + } + + public Task CheckHealthAsync(HealthCheckContext context, + CancellationToken cancellationToken = default) + { + if (!_applicationLifetime.ApplicationStarted.IsCancellationRequested) + return Task.FromResult(HealthCheckResult.Unhealthy("Application has not finished starting.")); + + return Task.FromResult(DataSettingsManager.DatabaseIsInstalled() + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy("Database connection is not configured.")); + } +} From 936966cfa6634485eb1a398e48e195c38cfc9a3b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 13 Aug 2026 18:59:17 +0200 Subject: [PATCH 3/6] Tag health checks as live/ready and register StartupHealthCheck --- .../Infrastructure/ServiceCollectionExtensions.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs index 13f25e5bc..bc1c88cda 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.WebEncoders; +using Grand.Web.Common.Infrastructure.HealthChecks; using StackExchange.Redis; using System.Text.Encodings.Web; using System.Text.Unicode; @@ -267,7 +268,11 @@ public static void AddSettings(this IServiceCollection services) public static void AddGrandHealthChecks(this IServiceCollection services) { var hcBuilder = services.AddHealthChecks(); - hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy()); + //liveness: process can respond to a request - never touches an external dependency + hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]); + //readiness: application finished starting and is configured - intentionally does not + //probe MongoDB/Redis, see OBS-011 in docs/architecture/grandnode-architecture-roadmap.md + hcBuilder.AddCheck("startup", tags: ["ready"]); } /// From 426f5f727884fa5312656cec04e64e2d67859a9a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 13 Aug 2026 19:03:52 +0200 Subject: [PATCH 4/6] Map /health/ready alongside /health/live --- .../Infrastructure/ApplicationBuilderExtensions.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs index e06e3bbf1..cfb3d43fb 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; @@ -195,7 +196,15 @@ public static void UseGrandForwardedHeaders(this WebApplication application) /// Builder for configuring an application's request pipeline public static void UseGrandHealthChecks(this WebApplication application) { - application.UseHealthChecks("/health/live"); + application.UseHealthChecks("/health/live", new HealthCheckOptions { + Predicate = check => check.Tags.Contains("live") + }); + + //intentionally does not probe MongoDB/Redis - see OBS-011 in + //docs/architecture/grandnode-architecture-roadmap.md for that extension + application.UseHealthChecks("/health/ready", new HealthCheckOptions { + Predicate = check => check.Tags.Contains("ready") + }); } /// From 3e394dba583dc83eda0da77e13e9c8cc844b4396 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 13 Aug 2026 19:11:49 +0200 Subject: [PATCH 5/6] Address code review findings for readiness health check - Reword shipped source comments to be self-contained instead of pointing at an untracked local roadmap doc (StartupHealthCheck.cs, ServiceCollectionExtensions.cs, ApplicationBuilderExtensions.cs) - Add ArgumentNullException.ThrowIfNull guard in StartupHealthCheck constructor - Add a comment warning that every registered health check must carry a live or ready tag - StartupHealthCheckTests: reset DataSettingsManager._instance in Cleanup(), replace null-forgiving operator with an explicit null check, add [DoNotParallelize] to the test class - Plan: clarify which verification method covers the 503 case - Spec: document DataSettingsManager.DatabaseIsInstalled() caching caveat and the need for a restart after install --- .../plans/2026-08-13-readiness-health-check.md | 2 +- .../specs/2026-08-13-readiness-health-check-design.md | 8 ++++++++ .../HealthChecks/StartupHealthCheckTests.cs | 9 +++++++-- .../Infrastructure/ApplicationBuilderExtensions.cs | 5 +++-- .../Infrastructure/HealthChecks/StartupHealthCheck.cs | 6 ++++-- .../Infrastructure/ServiceCollectionExtensions.cs | 7 +++++-- 6 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-08-13-readiness-health-check.md b/docs/superpowers/plans/2026-08-13-readiness-health-check.md index fdc365ba6..06563c6f2 100644 --- a/docs/superpowers/plans/2026-08-13-readiness-health-check.md +++ b/docs/superpowers/plans/2026-08-13-readiness-health-check.md @@ -308,6 +308,6 @@ git commit -m "Map /health/ready alongside /health/live" - [ ] All three `StartupHealthCheck` unit tests pass (Task 1). - [ ] `Grand.Web.Common` builds with no warnings introduced. - [ ] `/health/live` still returns `200` unconditionally (unchanged behavior, verified manually in Task 3). -- [ ] `/health/ready` returns `503` before startup completes / before the database is configured, `200` once both hold (verified manually in Task 3). +- [ ] `/health/ready` returns `503` before startup completes / before the database is configured (covered by `StartupHealthCheck`'s unit tests with real assertions - Task 1), and `200` once both hold (verified both by unit test and manually via curl in Task 3; the manual curl check only exercised the already-configured/healthy case, since the dev environment used for verification had a database configured). - [ ] No MongoDB or Redis check was added anywhere in this change. - [ ] Design spec (`docs/superpowers/specs/2026-08-13-readiness-health-check-design.md`) and this plan are committed alongside the code changes. diff --git a/docs/superpowers/specs/2026-08-13-readiness-health-check-design.md b/docs/superpowers/specs/2026-08-13-readiness-health-check-design.md index 3a6487be2..a2889691c 100644 --- a/docs/superpowers/specs/2026-08-13-readiness-health-check-design.md +++ b/docs/superpowers/specs/2026-08-13-readiness-health-check-design.md @@ -54,6 +54,14 @@ Two tags distinguish the two endpoints, following the standard ASP.NET Core - Unhealthy when started but `DatabaseIsInstalled()` is false. - Healthy when both conditions are true. +### Operational caveat + +`DataSettingsManager.DatabaseIsInstalled()` caches its result after the first call and can only be +forced to `false` afterward (never back to `true`) without a process restart. As a result, a +freshly-installed instance keeps returning `503` on `/health/ready` until the app restarts +post-install - this matches the installer's own existing guidance to restart after installation +completes, and is expected behavior, not a bug. + ### Non-goals - No MongoDB/Redis connectivity check (explicit user decision — future work under `OBS-011`). diff --git a/src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs b/src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs index f6974ca95..0d848e7be 100644 --- a/src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs @@ -9,14 +9,18 @@ namespace Grand.Web.Common.Tests.Infrastructure.HealthChecks; [TestClass] +[DoNotParallelize] public class StartupHealthCheckTests { // DataSettingsManager caches DatabaseIsInstalled() on first call and its public ResetCache() // can only force the cached value to false, never back to true - see DataSettingsManager.cs. // Resetting the private static instance per test keeps "installed" and "not installed" cases - // independent instead of depending on test execution order. + // independent instead of depending on test execution order. [DoNotParallelize] on this class + // stops these tests running concurrently with each other against that same process-wide state. private static readonly FieldInfo InstanceField = typeof(DataSettingsManager) - .GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static)!; + .GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException( + "DataSettingsManager._instance field not found - has the type changed?"); private string _settingsPath = null!; @@ -31,6 +35,7 @@ public void Setup() [TestCleanup] public void Cleanup() { + InstanceField.SetValue(null, null); if (File.Exists(_settingsPath)) File.Delete(_settingsPath); } diff --git a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs index cfb3d43fb..88ad025cb 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs @@ -200,8 +200,9 @@ public static void UseGrandHealthChecks(this WebApplication application) Predicate = check => check.Tags.Contains("live") }); - //intentionally does not probe MongoDB/Redis - see OBS-011 in - //docs/architecture/grandnode-architecture-roadmap.md for that extension + //intentionally does not probe MongoDB or Redis - readiness here covers only the + //application process itself. Dependency probing (DB/Redis ping) is a deliberate future + //extension, not an oversight. application.UseHealthChecks("/health/ready", new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready") }); diff --git a/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs b/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs index f4af37313..a783c9ad0 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs @@ -6,8 +6,9 @@ namespace Grand.Web.Common.Infrastructure.HealthChecks; /// /// Reports whether the application has finished starting and is configured with a database -/// connection. Intentionally does not probe MongoDB or Redis - see OBS-011 in -/// docs/architecture/grandnode-architecture-roadmap.md for that extension. +/// connection. Intentionally does not probe MongoDB or Redis - readiness here covers only the +/// application process itself. Dependency probing (DB/Redis ping) is a deliberate future +/// extension, not an oversight. /// public class StartupHealthCheck : IHealthCheck { @@ -15,6 +16,7 @@ public class StartupHealthCheck : IHealthCheck public StartupHealthCheck(IHostApplicationLifetime applicationLifetime) { + ArgumentNullException.ThrowIfNull(applicationLifetime); _applicationLifetime = applicationLifetime; } diff --git a/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs b/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs index bc1c88cda..ec9cda3e8 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs @@ -268,10 +268,13 @@ public static void AddSettings(this IServiceCollection services) public static void AddGrandHealthChecks(this IServiceCollection services) { var hcBuilder = services.AddHealthChecks(); + //every check registered here must carry a "live" or "ready" tag - /health/live and + // /health/ready each filter by tag, so an untagged check would silently run on neither //liveness: process can respond to a request - never touches an external dependency hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]); - //readiness: application finished starting and is configured - intentionally does not - //probe MongoDB/Redis, see OBS-011 in docs/architecture/grandnode-architecture-roadmap.md + //readiness: application finished starting and is configured. Intentionally does not probe + //MongoDB or Redis - readiness here covers only the application process itself. Dependency + //probing (DB/Redis ping) is a deliberate future extension, not an oversight. hcBuilder.AddCheck("startup", tags: ["ready"]); } From aeb63679d819b9d2904f985ec9a815b18974145c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Thu, 13 Aug 2026 19:42:32 +0200 Subject: [PATCH 6/6] Drop docs/superpowers process artifacts, keep rationale in code The design spec and implementation plan under docs/superpowers/ were working artifacts of the agentic workflow, not established documentation for this repo (no prior convention here) and not requested output. The one piece of durable rationale they carried - that DataSettingsManager .DatabaseIsInstalled() caches after its first call and can only be forced to false, never back to true, without a process restart - now lives as a comment on StartupHealthCheck instead. Co-Authored-By: Claude Sonnet 5 --- .../2026-08-13-readiness-health-check.md | 313 ------------------ ...026-08-13-readiness-health-check-design.md | 77 ----- .../HealthChecks/StartupHealthCheck.cs | 4 + 3 files changed, 4 insertions(+), 390 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-13-readiness-health-check.md delete mode 100644 docs/superpowers/specs/2026-08-13-readiness-health-check-design.md diff --git a/docs/superpowers/plans/2026-08-13-readiness-health-check.md b/docs/superpowers/plans/2026-08-13-readiness-health-check.md deleted file mode 100644 index 06563c6f2..000000000 --- a/docs/superpowers/plans/2026-08-13-readiness-health-check.md +++ /dev/null @@ -1,313 +0,0 @@ -# Readiness Health Check Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `/health/ready` endpoint, distinct from the existing `/health/live`, that reports whether the application process has finished starting and is configured — without probing MongoDB or Redis. - -**Architecture:** Two tags (`live`, `ready`) partition the ASP.NET Core `HealthCheckService` registry. `/health/live` keeps mapping to the existing always-healthy `"self"` check (tag `live`). A new `StartupHealthCheck` (tag `ready`) is healthy only once `IHostApplicationLifetime.ApplicationStarted` has fired and `DataSettingsManager.DatabaseIsInstalled()` reports a configured connection string — both are in-memory/process-local checks, no network I/O. `/health/ready` is mapped with a `Predicate` that filters to the `ready` tag. - -**Tech Stack:** ASP.NET Core `Microsoft.Extensions.Diagnostics.HealthChecks` (already referenced), MSTest + Moq (existing test stack in `Grand.Web.Common.Tests`). - -## Global Constraints - -- Do not add any MongoDB or Redis connectivity check — readiness here covers the application process only. (Explicit scope decision; tracked as a future extension under `OBS-011` in `docs/architecture/grandnode-architecture-roadmap.md`.) -- `/health/live` behavior must not change (always returns `Healthy`). -- No new NuGet packages — `Microsoft.Extensions.Diagnostics.HealthChecks` and `IHostApplicationLifetime` (part of `Microsoft.Extensions.Hosting.Abstractions`) are already available transitively. -- Follow existing folder→namespace convention in `Grand.Web.Common` (e.g. `Infrastructure/Middleware` → `Grand.Web.Common.Middleware`). -- Follow existing MSTest + Moq test conventions (see `src/Tests/Grand.Web.Common.Tests/Infrastructure/BackgroundServiceTaskTests.cs`). - ---- - -### Task 1: `StartupHealthCheck` - -**Files:** -- Create: `src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs` -- Test: `src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs` - -**Interfaces:** -- Consumes: `Grand.Data.DataSettingsManager.DatabaseIsInstalled()` (static, existing), `Microsoft.Extensions.Hosting.IHostApplicationLifetime.ApplicationStarted` (framework-provided `CancellationToken` property). -- Produces: `Grand.Web.Common.Infrastructure.HealthChecks.StartupHealthCheck`, a public class implementing `Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck` with a public constructor `StartupHealthCheck(IHostApplicationLifetime applicationLifetime)`. Task 2 registers this type with `hcBuilder.AddCheck(...)`. - -`DataSettingsManager` is a process-wide static singleton whose `DatabaseIsInstalled()` result is cached after the first call and `ResetCache()` can only force it to `false` (never back to `true`) — see `src/Core/Grand.Data/DataSettingsManager.cs:74-91`. To get independent, order-safe `true`/`false` results per test, each test resets the private static `_instance` field via reflection and re-initializes against its own temp settings file, mirroring the fresh-`Initialize` pattern already used in `src/Tests/Grand.Domain.Tests/Data/DataSettingsManagerTests.cs` and `src/Tests/Grand.Web.Common.Tests/AuthorizeMenuAttributeTests.cs:33-35`, but going one step further (full instance reset) so the "installed" and "not installed" cases don't leak into each other within the same test process. - -- [ ] **Step 1: Write the failing tests** - -Create `src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs`: - -```csharp -using System.Reflection; -using Grand.Data; -using Grand.Web.Common.Infrastructure.HealthChecks; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; - -namespace Grand.Web.Common.Tests.Infrastructure.HealthChecks; - -[TestClass] -public class StartupHealthCheckTests -{ - // DataSettingsManager caches DatabaseIsInstalled() on first call and its public ResetCache() - // can only force the cached value to false, never back to true - see DataSettingsManager.cs. - // Resetting the private static instance per test keeps "installed" and "not installed" cases - // independent instead of depending on test execution order. - private static readonly FieldInfo InstanceField = typeof(DataSettingsManager) - .GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static)!; - - private string _settingsPath = null!; - - [TestInitialize] - public void Setup() - { - InstanceField.SetValue(null, null); - _settingsPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.txt"); - DataSettingsManager.Initialize(_settingsPath); - } - - [TestCleanup] - public void Cleanup() - { - if (File.Exists(_settingsPath)) - File.Delete(_settingsPath); - } - - private static Mock MockLifetime(bool started) - { - var cts = new CancellationTokenSource(); - if (started) cts.Cancel(); - - var lifetime = new Mock(); - lifetime.Setup(l => l.ApplicationStarted).Returns(cts.Token); - return lifetime; - } - - [TestMethod] - public async Task CheckHealthAsync_ApplicationNotStarted_ReturnsUnhealthy() - { - DataSettingsManager.Instance.LoadDataSettings( - new DataSettings { ConnectionString = "mongodb://localhost/test", DbProvider = DbProvider.MongoDB }); - - var check = new StartupHealthCheck(MockLifetime(started: false).Object); - - var result = await check.CheckHealthAsync(new HealthCheckContext()); - - Assert.AreEqual(HealthStatus.Unhealthy, result.Status); - } - - [TestMethod] - public async Task CheckHealthAsync_StartedButDatabaseNotConfigured_ReturnsUnhealthy() - { - // no connection string loaded - DatabaseIsInstalled() evaluates to false on first call - var check = new StartupHealthCheck(MockLifetime(started: true).Object); - - var result = await check.CheckHealthAsync(new HealthCheckContext()); - - Assert.AreEqual(HealthStatus.Unhealthy, result.Status); - } - - [TestMethod] - public async Task CheckHealthAsync_StartedAndDatabaseConfigured_ReturnsHealthy() - { - DataSettingsManager.Instance.LoadDataSettings( - new DataSettings { ConnectionString = "mongodb://localhost/test", DbProvider = DbProvider.MongoDB }); - - var check = new StartupHealthCheck(MockLifetime(started: true).Object); - - var result = await check.CheckHealthAsync(new HealthCheckContext()); - - Assert.AreEqual(HealthStatus.Healthy, result.Status); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail to compile** - -Run: `dotnet test src/Tests/Grand.Web.Common.Tests/Grand.Web.Common.Tests.csproj --filter StartupHealthCheckTests` -Expected: build error — `Grand.Web.Common.Infrastructure.HealthChecks.StartupHealthCheck` does not exist. - -- [ ] **Step 3: Write the implementation** - -Create `src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs`: - -```csharp -using Grand.Data; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; - -namespace Grand.Web.Common.Infrastructure.HealthChecks; - -/// -/// Reports whether the application has finished starting and is configured with a database -/// connection. Intentionally does not probe MongoDB or Redis - see OBS-011 in -/// docs/architecture/grandnode-architecture-roadmap.md for that extension. -/// -public class StartupHealthCheck : IHealthCheck -{ - private readonly IHostApplicationLifetime _applicationLifetime; - - public StartupHealthCheck(IHostApplicationLifetime applicationLifetime) - { - _applicationLifetime = applicationLifetime; - } - - public Task CheckHealthAsync(HealthCheckContext context, - CancellationToken cancellationToken = default) - { - if (!_applicationLifetime.ApplicationStarted.IsCancellationRequested) - return Task.FromResult(HealthCheckResult.Unhealthy("Application has not finished starting.")); - - return Task.FromResult(DataSettingsManager.DatabaseIsInstalled() - ? HealthCheckResult.Healthy() - : HealthCheckResult.Unhealthy("Database connection is not configured.")); - } -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `dotnet test src/Tests/Grand.Web.Common.Tests/Grand.Web.Common.Tests.csproj --filter StartupHealthCheckTests` -Expected: 3 tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs src/Tests/Grand.Web.Common.Tests/Infrastructure/HealthChecks/StartupHealthCheckTests.cs -git commit -m "Add StartupHealthCheck for application readiness" -``` - ---- - -### Task 2: Register the readiness check with tags - -**Files:** -- Modify: `src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs:267-271` - -**Interfaces:** -- Consumes: `Grand.Web.Common.Infrastructure.HealthChecks.StartupHealthCheck` (Task 1). -- Produces: two tagged health checks (`"self"` tagged `"live"`, `"startup"` tagged `"ready"`) in the DI-registered `HealthCheckService`, consumed by Task 3's endpoint mapping via `HealthCheckOptions.Predicate`. - -- [ ] **Step 1: Update `AddGrandHealthChecks`** - -Replace (`src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs:267-271`): - -```csharp - public static void AddGrandHealthChecks(this IServiceCollection services) - { - var hcBuilder = services.AddHealthChecks(); - hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy()); - } -``` - -with: - -```csharp - public static void AddGrandHealthChecks(this IServiceCollection services) - { - var hcBuilder = services.AddHealthChecks(); - //liveness: process can respond to a request - never touches an external dependency - hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]); - //readiness: application finished starting and is configured - intentionally does not - //probe MongoDB/Redis, see OBS-011 in docs/architecture/grandnode-architecture-roadmap.md - hcBuilder.AddCheck("startup", tags: ["ready"]); - } -``` - -Add the using at the top of the file (alongside the existing `using Microsoft.Extensions.Diagnostics.HealthChecks;` on line 19): - -```csharp -using Grand.Web.Common.Infrastructure.HealthChecks; -``` - -- [ ] **Step 2: Build to verify it compiles** - -Run: `dotnet build src/Web/Grand.Web.Common/Grand.Web.Common.csproj` -Expected: build succeeds with no errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs -git commit -m "Tag health checks as live/ready and register StartupHealthCheck" -``` - ---- - -### Task 3: Map `/health/ready` - -**Files:** -- Modify: `src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs:192-199` - -**Interfaces:** -- Consumes: the `"live"`/`"ready"` tags registered in Task 2. -- Produces: `/health/live` and `/health/ready` HTTP endpoints, mapped by `GrandMvcStartup`/`GrandCommonStartup` (unchanged call site — `application.UseGrandHealthChecks()` in `src/Web/Grand.Web.Common/Startup/GrandCommonStartup.cs:97`). - -- [ ] **Step 1: Update `UseGrandHealthChecks`** - -Replace (`src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs:196-199`): - -```csharp - public static void UseGrandHealthChecks(this WebApplication application) - { - application.UseHealthChecks("/health/live"); - } -``` - -with: - -```csharp - public static void UseGrandHealthChecks(this WebApplication application) - { - application.UseHealthChecks("/health/live", new HealthCheckOptions { - Predicate = check => check.Tags.Contains("live") - }); - - //intentionally does not probe MongoDB/Redis - see OBS-011 in - //docs/architecture/grandnode-architecture-roadmap.md for that extension - application.UseHealthChecks("/health/ready", new HealthCheckOptions { - Predicate = check => check.Tags.Contains("ready") - }); - } -``` - -Add the using at the top of the file (alongside the existing `using Microsoft.AspNetCore.Builder;` on line 8): - -```csharp -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -``` - -- [ ] **Step 2: Build to verify it compiles** - -Run: `dotnet build src/Web/Grand.Web.Common/Grand.Web.Common.csproj` -Expected: build succeeds with no errors. - -- [ ] **Step 3: Manual verification against a running host** - -Run: `dotnet run --project src/Web/Grand.Web/Grand.Web.csproj` (Kestrel, per `reference_running_the_storefront` — IIS Express needs a `web.config` this repo doesn't ship). - -Once the app is listening: - -```bash -curl -i http://localhost:/health/live -curl -i http://localhost:/health/ready -``` - -Expected: both return `200 OK` with body `Healthy` once the app and (if applicable) the install wizard have completed. If the instance has no database configured yet, `/health/ready` returns `503 Service Unavailable` while `/health/live` still returns `200 OK`. - -- [ ] **Step 4: Commit** - -```bash -git add src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs -git commit -m "Map /health/ready alongside /health/live" -``` - ---- - -## Definition of Done - -- [ ] All three `StartupHealthCheck` unit tests pass (Task 1). -- [ ] `Grand.Web.Common` builds with no warnings introduced. -- [ ] `/health/live` still returns `200` unconditionally (unchanged behavior, verified manually in Task 3). -- [ ] `/health/ready` returns `503` before startup completes / before the database is configured (covered by `StartupHealthCheck`'s unit tests with real assertions - Task 1), and `200` once both hold (verified both by unit test and manually via curl in Task 3; the manual curl check only exercised the already-configured/healthy case, since the dev environment used for verification had a database configured). -- [ ] No MongoDB or Redis check was added anywhere in this change. -- [ ] Design spec (`docs/superpowers/specs/2026-08-13-readiness-health-check-design.md`) and this plan are committed alongside the code changes. diff --git a/docs/superpowers/specs/2026-08-13-readiness-health-check-design.md b/docs/superpowers/specs/2026-08-13-readiness-health-check-design.md deleted file mode 100644 index a2889691c..000000000 --- a/docs/superpowers/specs/2026-08-13-readiness-health-check-design.md +++ /dev/null @@ -1,77 +0,0 @@ -# Readiness health check — design - -**Date:** 2026-08-13 -**Related:** `docs/architecture/grandnode-architecture-roadmap.md` → `OBS-001`, `OBS-011` - -## Problem - -`AddGrandHealthChecks` registers a single check (`"self"`) that always returns `Healthy`, and -`UseGrandHealthChecks` maps only `/health/live` to it -(`src/Web/Grand.Web.Common/Infrastructure/ServiceCollectionExtensions.cs:267-271`, -`src/Web/Grand.Web.Common/Infrastructure/ApplicationBuilderExtensions.cs:196-199`). There is no -`/health/ready` endpoint, so an orchestrator has no way to tell "process is alive" apart from -"instance has finished starting and is configured to serve traffic". - -The architecture roadmap (`OBS-011`) recommends a `/health/ready` that also pings MongoDB and -Redis. **Explicit scope decision for this change: do not probe MongoDB or Redis.** Readiness here -checks only the application process itself — no network calls to external dependencies. Checking -those is left as a future extension (tracked by `OBS-011`). - -## Design - -Two tags distinguish the two endpoints, following the standard ASP.NET Core -`Microsoft.Extensions.Diagnostics.HealthChecks` pattern: - -- **`live`** — existing `"self"` check, unchanged behavior (always `Healthy`). Tagged `"live"`. -- **`ready`** — new `"startup"` check, tagged `"ready"`. `Healthy` only when both hold: - 1. `IHostApplicationLifetime.ApplicationStarted.IsCancellationRequested` is `true` — every - `IStartupApplication.Configure` and hosted service has completed startup. Guards against an - orchestrator routing traffic to an instance that is still initializing. - 2. `DataSettingsManager.DatabaseIsInstalled()` is `true` — the instance has a connection string - configured (via the install wizard's `App_Data/Settings.txt` or via - `ConnectionStrings`/environment configuration read by `StartupBase.InitDatabase`). This is a - read of already-loaded in-memory state, not a network call — no DB/Redis is contacted. It - distinguishes a freshly-deployed instance still waiting on the install wizard from a - configured one. - -### Implementation - -- `ServiceCollectionExtensions.AddGrandHealthChecks`: - - `hcBuilder.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])` - - `hcBuilder.AddCheck("startup", tags: ["ready"])` - - New `StartupHealthCheck : IHealthCheck` (constructor-injects `IHostApplicationLifetime`) - implementing the two conditions above. -- `ApplicationBuilderExtensions.UseGrandHealthChecks`: - - `application.UseHealthChecks("/health/live", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") });` - - `application.UseHealthChecks("/health/ready", new HealthCheckOptions { Predicate = r => r.Tags.Contains("ready") });` - - A short comment on the `/health/ready` mapping states the check intentionally does not probe - MongoDB/Redis and points to `OBS-011` for the tracked extension. - -### Testing - -- Unit test for `StartupHealthCheck` in `src/Tests/Grand.Web.Common.Tests`: - - Unhealthy when application has not finished starting. - - Unhealthy when started but `DatabaseIsInstalled()` is false. - - Healthy when both conditions are true. - -### Operational caveat - -`DataSettingsManager.DatabaseIsInstalled()` caches its result after the first call and can only be -forced to `false` afterward (never back to `true`) without a process restart. As a result, a -freshly-installed instance keeps returning `503` on `/health/ready` until the app restarts -post-install - this matches the installer's own existing guidance to restart after installation -completes, and is expected behavior, not a bug. - -### Non-goals - -- No MongoDB/Redis connectivity check (explicit user decision — future work under `OBS-011`). -- No change to `/health/live` behavior. -- No health check UI/dashboard. - -## Acceptance criteria - -- [ ] `/health/live` still returns 200 unconditionally (unchanged). -- [ ] `/health/ready` returns 503 before `ApplicationStarted` fires. -- [ ] `/health/ready` returns 503 when the database/install is not configured. -- [ ] `/health/ready` returns 200 once both conditions hold. -- [ ] Unit tests for `StartupHealthCheck` cover all three states above. diff --git a/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs b/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs index a783c9ad0..31f39cfda 100644 --- a/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs +++ b/src/Web/Grand.Web.Common/Infrastructure/HealthChecks/StartupHealthCheck.cs @@ -9,6 +9,10 @@ namespace Grand.Web.Common.Infrastructure.HealthChecks; /// connection. Intentionally does not probe MongoDB or Redis - readiness here covers only the /// application process itself. Dependency probing (DB/Redis ping) is a deliberate future /// extension, not an oversight. +/// Note: DataSettingsManager.DatabaseIsInstalled() caches its result after the first call and +/// can only be forced to false afterward, never back to true, without a process restart - so a +/// freshly-installed instance keeps returning Unhealthy here until the app restarts post-install +/// (expected: the installer already asks for a restart once setup completes). /// public class StartupHealthCheck : IHealthCheck {