feat(431): TTL-cache health-check results; ?refresh=true forces a fresh run
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m16s
Build ErsatzTV Image / decisions.md append-only (pull_request) Failing after 12m23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Failing after 14m6s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

GET /api/v1/health re-ran all 14 health checks on every request, 4 of
which shell out to ffmpeg/ffprobe via CliWrap — so each poll spawned ~4
subprocesses. The existing HealthCheckSummary cache was write-only.

Cache the full result list for 30s inside HealthCheckService keyed on a
new "healthcheck.results" entry; a non-forced call returns it on a hit,
skipping the checks and the (subscriber-less) summary publish. Add a
`bool forceRefresh` first parameter to IHealthCheckService.PerformHealthChecks:
the API poll path reads the cache, while startup (RunHealthChecksService)
and the troubleshooting support bundle force a fresh run.

Refresh surface: GET /api/v1/health gains an optional `[FromQuery] bool
refresh` (additive, follows the ?deep= exemplar); the SPA "Refresh health"
button calls /api/v1/health?refresh=true, the initial/poll load does not.

Tests: HealthCheckService cache-hit vs force-bypass (mutually opposing,
non-vacuous), handler+controller refresh-flag threading, SPA refresh URL.
Docs: decisions.md 2026-07-19 (#431), api-conventions §2; regenerated v1.json.

fixes #431

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 14:41:12 +02:00
co-authored by Claude Opus 4.8
parent 67dc9ca4c0
commit be25df670e
18 changed files with 260 additions and 35 deletions
@@ -2,4 +2,4 @@ using ErsatzTV.Core.Api.Health;
namespace ErsatzTV.Application.Health;
public record GetAllHealthCheckResultsForApi : IRequest<List<HealthCheckResponseModel>>;
public record GetAllHealthCheckResultsForApi(bool Refresh = false) : IRequest<List<HealthCheckResponseModel>>;
@@ -18,7 +18,8 @@ public class GetAllHealthCheckResultsForApiHandler
{
try
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
List<HealthCheckResult> results =
await _healthCheckService.PerformHealthChecks(request.Refresh, cancellationToken);
return results
.Filter(r => r.Status != HealthCheckStatus.NotApplicable)
.Map(ProjectToResponseModel)
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health;
namespace ErsatzTV.Application.Health;
@@ -15,7 +15,7 @@ public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheck
{
try
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(false, cancellationToken);
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
@@ -45,7 +45,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
public async Task<TroubleshootingInfo> Handle(GetTroubleshootingInfo request, CancellationToken cancellationToken)
{
List<HealthCheckResult> healthCheckResults = await _healthCheckService.PerformHealthChecks(cancellationToken);
// Support bundle wants current state, so force a fresh run rather than serving the poll cache.
List<HealthCheckResult> healthCheckResults = await _healthCheckService.PerformHealthChecks(true, cancellationToken);
string version = Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
+2 -2
View File
@@ -1,7 +1,7 @@
namespace ErsatzTV.Core.Health;
namespace ErsatzTV.Core.Health;
public interface IHealthCheckService
{
Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken);
Task<List<HealthCheckResult>> PerformHealthChecks(bool forceRefresh, CancellationToken cancellationToken);
HealthCheckSummary GetHealthCheckSummary();
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
@@ -8,7 +8,14 @@ namespace ErsatzTV.Infrastructure.Health;
public class HealthCheckService : IHealthCheckService
{
private const string CacheKey = "healthcheck.summary";
private const string SummaryCacheKey = "healthcheck.summary";
private const string ResultsCacheKey = "healthcheck.results";
// Health checks shell out to ffmpeg/ffprobe (4 of the 14 checks) on every run, so a bare
// GET /api/v1/health spawns ~4 subprocesses per request. Cache the full result list for a
// short window so repeated polls (a status widget, an MCP client, monitoring) reuse it; an
// explicit refresh (forceRefresh) bypasses and repopulates. See docs/decisions.md 2026-07-19 (#431).
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
private readonly List<IHealthCheck> _checks; // ReSharper disable SuggestBaseTypeForParameterInConstructor
private readonly IMemoryCache _memoryCache;
@@ -56,8 +63,13 @@ public class HealthCheckService : IHealthCheckService
];
}
public async Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken)
public async Task<List<HealthCheckResult>> PerformHealthChecks(bool forceRefresh, CancellationToken cancellationToken)
{
if (!forceRefresh && _memoryCache.TryGetValue(ResultsCacheKey, out List<HealthCheckResult> cached) && cached is not null)
{
return cached;
}
List<HealthCheckResult> result = await _checks.Map(c =>
{
var failedResult = new HealthCheckResult(
@@ -75,7 +87,8 @@ public class HealthCheckService : IHealthCheckService
result.Count(x => x.Status is HealthCheckStatus.Warning),
result.Count(x => x.Status is HealthCheckStatus.Fail));
_memoryCache.Set(CacheKey, summary);
_memoryCache.Set(ResultsCacheKey, result, CacheTtl);
_memoryCache.Set(SummaryCacheKey, summary);
await _mediator.Publish(summary, cancellationToken);
@@ -83,7 +96,7 @@ public class HealthCheckService : IHealthCheckService
}
public HealthCheckSummary GetHealthCheckSummary() =>
_memoryCache.Get<HealthCheckSummary>(CacheKey) ?? new HealthCheckSummary(0, 0);
_memoryCache.Get<HealthCheckSummary>(SummaryCacheKey) ?? new HealthCheckSummary(0, 0);
private HealthCheckResult LogAndReturn(Exception ex, HealthCheckResult failedResult)
{
@@ -32,7 +32,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
new("Info Check", HealthCheckStatus.Info, "fyi", "info", Option<HealthCheckLink>.None)
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
@@ -50,7 +50,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
new("NA Check", HealthCheckStatus.NotApplicable, "skip", "skip", Option<HealthCheckLink>.None)
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
@@ -72,7 +72,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
Option<HealthCheckLink>.Some(HealthCheckLink.ExternalDoc("https://example.com/docs")))
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
@@ -99,7 +99,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
Option<HealthCheckLink>.Some(HealthCheckLink.AppRoute("/app/trash")))
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
@@ -118,7 +118,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
new("No Link Check", HealthCheckStatus.Pass, "detail", "brief", Option<HealthCheckLink>.None)
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
@@ -135,7 +135,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
new("Blank Brief", HealthCheckStatus.Pass, "detail", string.Empty, Option<HealthCheckLink>.None)
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
@@ -146,7 +146,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
[Test]
public async Task Should_Return_Empty_List_On_Cancellation()
{
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>())
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns<Task<List<HealthCheckResult>>>(_ => throw new TaskCanceledException());
List<HealthCheckResponseModel> response =
@@ -154,4 +154,26 @@ public class GetAllHealthCheckResultsForApiHandlerTests
response.ShouldBeEmpty();
}
[Test]
public async Task Should_Not_Force_Refresh_By_Default()
{
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(new List<HealthCheckResult>());
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
await _healthCheckService.Received(1).PerformHealthChecks(false, Arg.Any<CancellationToken>());
}
[Test]
public async Task Should_Force_Refresh_When_Requested()
{
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(new List<HealthCheckResult>());
await _handler.Handle(new GetAllHealthCheckResultsForApi(Refresh: true), CancellationToken.None);
await _healthCheckService.Received(1).PerformHealthChecks(true, Arg.Any<CancellationToken>());
}
}
@@ -53,7 +53,7 @@ public class HealthControllerTests
_mediator.Send(Arg.Any<GetAllHealthCheckResultsForApi>(), Arg.Any<CancellationToken>())
.Returns(expected);
List<HealthCheckResponseModel> result = await _controller.GetAll(CancellationToken.None);
List<HealthCheckResponseModel> result = await _controller.GetAll(false, CancellationToken.None);
result.ShouldBe(expected);
}
@@ -64,8 +64,21 @@ public class HealthControllerTests
_mediator.Send(Arg.Any<GetAllHealthCheckResultsForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
List<HealthCheckResponseModel> result = await _controller.GetAll(CancellationToken.None);
List<HealthCheckResponseModel> result = await _controller.GetAll(false, CancellationToken.None);
result.ShouldBeEmpty();
}
[Test]
public async Task GetAll_Should_Forward_Refresh_Flag_To_Query()
{
_mediator.Send(Arg.Any<GetAllHealthCheckResultsForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
await _controller.GetAll(true, CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetAllHealthCheckResultsForApi>(q => q.Refresh),
Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,107 @@
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Infrastructure.Health;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Infrastructure.Health;
[TestFixture]
public class HealthCheckServiceTests
{
private IFFmpegVersionHealthCheck _representativeCheck = null!;
private IMemoryCache _memoryCache = null!;
private IMediator _mediator = null!;
private HealthCheckService _service = null!;
[SetUp]
public void SetUp()
{
_memoryCache = new MemoryCache(new MemoryCacheOptions());
_mediator = Substitute.For<IMediator>();
_representativeCheck = PassCheck<IFFmpegVersionHealthCheck>();
// All 14 checks run together on a single PerformHealthChecks call, so the representative
// check's invocation count equals the number of actual (non-cached) runs.
_service = new HealthCheckService(
PassCheck<IMacOsConfigFolderHealthCheck>(),
_representativeCheck,
PassCheck<IFFmpegCapabilitiesHealthCheck>(),
PassCheck<IFFmpegReportsHealthCheck>(),
PassCheck<IHardwareAccelerationHealthCheck>(),
PassCheck<IMovieMetadataHealthCheck>(),
PassCheck<IEpisodeMetadataHealthCheck>(),
PassCheck<IZeroDurationHealthCheck>(),
PassCheck<IFileNotFoundHealthCheck>(),
PassCheck<IUnavailableHealthCheck>(),
PassCheck<IVaapiDriverHealthCheck>(),
PassCheck<IUnifiedDockerHealthCheck>(),
PassCheck<IDowngradeHealthCheck>(),
PassCheck<IEmptyScheduleHealthCheck>(),
_memoryCache,
_mediator,
NullLogger<HealthCheckService>.Instance);
}
[TearDown]
public void TearDown() => (_memoryCache as MemoryCache)?.Dispose();
[Test]
public async Task PerformHealthChecks_Should_Serve_Cache_Within_Window()
{
await _service.PerformHealthChecks(false, CancellationToken.None);
await _service.PerformHealthChecks(false, CancellationToken.None);
// Second call is served from cache: checks are not re-run, summary is not re-published.
await _representativeCheck.Received(1).Check(Arg.Any<CancellationToken>());
await _mediator.Received(1).Publish(Arg.Any<HealthCheckSummary>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task PerformHealthChecks_Should_Return_Cached_Instance_Within_Window()
{
List<HealthCheckResult> first = await _service.PerformHealthChecks(false, CancellationToken.None);
List<HealthCheckResult> second = await _service.PerformHealthChecks(false, CancellationToken.None);
second.ShouldBeSameAs(first);
}
[Test]
public async Task PerformHealthChecks_Should_Bypass_Cache_On_Force_Refresh()
{
await _service.PerformHealthChecks(false, CancellationToken.None);
await _service.PerformHealthChecks(true, CancellationToken.None);
// Force refresh re-runs the checks and re-publishes, regardless of the warm cache.
await _representativeCheck.Received(2).Check(Arg.Any<CancellationToken>());
await _mediator.Received(2).Publish(Arg.Any<HealthCheckSummary>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Force_Refresh_Should_Repopulate_Cache_For_Later_Reads()
{
// A forced run should warm the cache so a following non-forced read is a hit.
await _service.PerformHealthChecks(true, CancellationToken.None);
await _service.PerformHealthChecks(false, CancellationToken.None);
await _representativeCheck.Received(1).Check(Arg.Any<CancellationToken>());
}
private static T PassCheck<T>() where T : class, IHealthCheck
{
var check = Substitute.For<T>();
check.Check(Arg.Any<CancellationToken>())
.Returns(_ => new HealthCheckResult(
typeof(T).Name,
HealthCheckStatus.Pass,
"ok",
"ok",
Option<HealthCheckLink>.None));
return check;
}
}
+6 -2
View File
@@ -11,8 +11,12 @@ public class HealthController(IMediator mediator) : ControllerBase
[HttpGet("/api/v1/health", Name = "GetHealthChecks")]
[Tags("Health")]
[EndpointSummary("Get health check results")]
[EndpointDescription(
"Results are cached briefly; pass refresh=true to force a fresh run (re-executes ffmpeg-backed checks).")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<HealthCheckResponseModel>), StatusCodes.Status200OK)]
public async Task<List<HealthCheckResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllHealthCheckResultsForApi(), cancellationToken);
public async Task<List<HealthCheckResponseModel>> GetAll(
[FromQuery] bool refresh,
CancellationToken cancellationToken) =>
await mediator.Send(new GetAllHealthCheckResultsForApi(refresh), cancellationToken);
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Health;
namespace ErsatzTV.Services.RunOnce;
@@ -30,6 +30,6 @@ public class RunHealthChecksService(IServiceScopeFactory serviceScopeFactory, Sy
using IServiceScope scope = serviceScopeFactory.CreateScope();
IHealthCheckService healthCheckService = scope.ServiceProvider.GetRequiredService<IHealthCheckService>();
await healthCheckService.PerformHealthChecks(stoppingToken);
await healthCheckService.PerformHealthChecks(true, stoppingToken);
}
}
+20
View File
@@ -7829,7 +7829,17 @@
"Health"
],
"summary": "Get health check results",
"description": "Results are cached briefly; pass refresh=true to force a fresh run (re-executes ffmpeg-backed checks).",
"operationId": "GetHealthChecks",
"parameters": [
{
"name": "refresh",
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "OK",
@@ -7869,6 +7879,16 @@
}
}
}
},
"400": {
"description": "Request validation failed (model binding or FluentValidation).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
}
},
"security": [
+6
View File
@@ -96,6 +96,12 @@ Exemplars:
the action; filter server-side only when it has a value. Exemplar: `?fillerKind=` on
`GET /api/v1/filler-presets` (`GetAllFillerPresetsForApi(FillerKind? FillerKind = null)`). An invalid
enum value is rejected by model binding (400) — no handler-side guard needed.
- **Optional bool query param (flag / cache-bust)**: bind `[FromQuery] bool name` (absent → `false`) and
thread it into the query record with a defaulted parameter so existing callers are unaffected. Exemplars:
`?deep=` on `POST /api/v1/libraries/{id}/scan` (§3b), and `?refresh=` on `GET /api/v1/health`
(`GetAllHealthCheckResultsForApi(bool Refresh = false)`) which forces a fresh run past the service's TTL
result cache — the cached poll path is the default, the flag is the explicit opt-out (see `decisions.md`
2026-07-19, #431).
- **Evolving a frozen DTO: deprecate-in-place, add the richer field, never remove.** `/api/v1` is
frozen-additive (#286), so when a response field's shape needs to grow, keep the old member
populated (mark it deprecated in an XML/`//` comment) and add the replacement alongside. Exemplar:
+32
View File
@@ -100,6 +100,7 @@ in-file entries.
- [2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip](#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip)
- [2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)](#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409)
- [2026-07-19 — WeightedShuffle SPA: weights edited on the multi-collection, order offered only on classic MultiCollection schedule items; fair-share is a reset not a mode (#404)](#2026-07-19--weightedshuffle-spa-weights-edited-on-the-multi-collection-order-offered-only-on-classic-multicollection-schedule-items-fair-share-is-a-reset-not-a-mode-404)
- [2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431)](#2026-07-19--health-check-results-are-ttl-cached-refreshtrue-forces-a-fresh-run-431)
---
@@ -1982,3 +1983,34 @@ is purely SPA (+ docs).
weight field is held as a string in the editor draft so it edits smoothly, clamped to the API validator's
1..1000 on blur and again at save — an out-of-range value never reaches the server as a raw 400. `Input`
gained `min`/`max`/`inputMode`/`onBlur` passthroughs for this (reusable by #425's weight UI).
## 2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431)
`HealthCheckService.PerformHealthChecks` re-ran all 14 checks on **every** call, four of which shell out to
`ffmpeg`/`ffprobe` via CliWrap — so a bare `GET /api/v1/health` spawned ~4 subprocesses per request. The
existing `HealthCheckSummary` cache was **write-only** (populated + published, never read back to short-circuit
a re-run). Harmless while the SPA Dashboard health panel refreshes on-demand only, but a real cost the moment
anything *polls* health (a status widget, an MCP client, monitoring). Split out of #164 as the orthogonal
performance half.
- **A short TTL cache of the full result list lives inside `HealthCheckService`.** A `_memoryCache` entry
(`"healthcheck.results"`, `TimeSpan.FromSeconds(30)`) holds the last `List<HealthCheckResult>`; a non-forced
call returns it directly on a hit, skipping both the 14 checks and the summary `Publish`. Chosen over
"make the existing summary cache read-through" because the API returns the full per-check list, not the
2-int summary — the summary entry (`"healthcheck.summary"`, read by `GetHealthCheckSummary`) is kept as-is
(un-expiring) so its fallback behavior is unchanged.
- **`PerformHealthChecks` gained a `bool forceRefresh` first parameter** (interface signature change; one
implementer, 3 live callers). `forceRefresh: true` bypasses the cache and repopulates it.
- **The refresh surface is an optional `?refresh=` query param on the existing GET**, following the
`?deep=` bool-query-param exemplar (`api-conventions.md` §1/§3b) — additive, backward-compatible, no new
endpoint. `[FromQuery] bool refresh` → `GetAllHealthCheckResultsForApi(Refresh)` → `PerformHealthChecks(request.Refresh, …)`.
The SPA "Refresh health" button calls `/api/v1/health?refresh=true`; the initial/poll load calls the bare
path (cached). A separate `POST …/refresh` endpoint was rejected as unnecessary surface for a read.
- **Who forces vs. who reads the cache:** the API GET poll path reads the cache; the **startup**
`RunHealthChecksService` and the **troubleshooting** support bundle force a fresh run (both want current
state — startup is a cold cache anyway, and a diagnostic bundle should reflect *now*, not a ≤30s-old poll).
The legacy `GetAllHealthCheckResults` handler is dead (no senders) and reads the cache.
- **Thundering-herd on a cold cache was left out of scope** (no request-coalescing lock): polling is sequential
per client and the TTL collapses steady-state load, so at most a handful of exactly-simultaneous cold callers
re-run — a once-per-30s edge, not the repeated per-request cost the issue targets. Recorded here so a later
reviewer doesn't read the absence of a `SemaphoreSlim` as an oversight.
+1 -1
View File
@@ -1403,7 +1403,7 @@ function mockDashboardApi({
return Promise.resolve(jsonResponse(playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })));
}
if (path === '/api/v1/health') {
if (path.startsWith('/api/v1/health')) {
return Promise.resolve(jsonResponse(health));
}
+7 -6
View File
@@ -47,8 +47,9 @@ export async function getDashboardData(): Promise<DashboardData> {
return { channels, channelStates, mediaSources, playouts };
}
export function getDashboardHealth(): Promise<DashboardHealthCheck[]> {
return request<DashboardHealthCheck[]>('/api/v1/health');
export function getDashboardHealth(refresh = false): Promise<DashboardHealthCheck[]> {
// Health results are cached server-side; the on-demand "Refresh health" action forces a fresh run.
return request<DashboardHealthCheck[]>(refresh ? '/api/v1/health?refresh=true' : '/api/v1/health');
}
export function getDashboardVersion(): Promise<DashboardVersion> {
@@ -102,8 +103,8 @@ export function useDashboardHealthQuery(): DashboardHealthQueryState {
};
}, []);
const loadHealth = useCallback(() => {
getDashboardHealth()
const loadHealth = useCallback((refresh: boolean) => {
getDashboardHealth(refresh)
.then((checks) => {
if (activeRef.current) {
setState({ checks, error: null, status: 'success' });
@@ -118,11 +119,11 @@ export function useDashboardHealthQuery(): DashboardHealthQueryState {
const refresh = useCallback(() => {
setState({ checks: null, error: null, status: 'loading' });
loadHealth();
loadHealth(true);
}, [loadHealth]);
useEffect(() => {
loadHealth();
loadHealth(false);
}, [loadHealth]);
if (state.status === 'success') {
+7 -2
View File
@@ -228,11 +228,16 @@ describe('DashboardScreen', () => {
expect(await screen.findByText('All checks passed')).toBeInTheDocument();
expect(fetchCount('/api/v1/health')).toBe(1);
// Initial load uses the cached (non-forced) endpoint.
expect(window.fetch).toHaveBeenCalledWith('/api/v1/health', expect.any(Object));
expect(window.fetch).not.toHaveBeenCalledWith('/api/v1/health?refresh=true', expect.any(Object));
fireEvent.click(screen.getByRole('button', { name: 'Refresh health' }));
expect(await screen.findByText('All checks passed')).toBeInTheDocument();
expect(fetchCount('/api/v1/health')).toBe(2);
// The explicit "Refresh health" action forces a fresh server-side run.
expect(window.fetch).toHaveBeenCalledWith('/api/v1/health?refresh=true', expect.any(Object));
});
it('shows the API error detail when dashboard loading fails', async () => {
@@ -297,7 +302,7 @@ function mockDashboardApi({
return Promise.resolve(jsonResponse(playouts));
}
if (path === '/api/v1/health') {
if (path.startsWith('/api/v1/health')) {
return Promise.resolve(jsonResponse(health));
}
@@ -313,5 +318,5 @@ function jsonResponse(body: unknown, status = 200): Response {
}
function fetchCount(path: string): number {
return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length;
return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString().startsWith(path)).length;
}
+1 -1
View File
@@ -655,7 +655,7 @@ function mockSettingsApi({
return Promise.resolve(jsonResponse({ apiVersion: 3, appVersion: '26.4.0' }));
}
if (path === '/api/v1/health') {
if (path.startsWith('/api/v1/health')) {
return Promise.resolve(jsonResponse([]));
}