Files
Elternbeirat/Elternbeirat.Web/Shared/PocketBaseHealthCache.cs
T

74 lines
3.4 KiB
C#

namespace Elternbeirat.Web.Shared;
/// <summary>
/// Remembers for a few seconds whether PocketBase answered its health check, so the
/// maintenance gate does not probe PocketBase on every page request.
/// </summary>
/// <param name="time">
/// The clock that decides when a result is stale; injected so tests can step past
/// <see cref="Lifetime"/> without waiting.
/// </param>
/// <remarks>
/// The gate in <c>Program.cs</c> asks this cache before rendering any page and serves
/// the static <c>maintenance.html</c> with 503 while the answer is "unhealthy". A
/// failed check is kept just as long as a passed one: while PocketBase is down every
/// probe would otherwise wait for the connection to fail again.
/// <para>
/// The price of caching is a short blind spot in both directions: after
/// PocketBase fails, pages may still render for up to <see cref="Lifetime"/> (the
/// components' own fallbacks cover that), and after it recovers the maintenance
/// page may stay up for as long.
/// </para>
/// <para>
/// Registered as a singleton. No lock: two requests that find the result stale at
/// the same moment both probe, and the later answer wins -- harmless, and cheaper
/// than making every request wait on one another.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// var healthy = await cache.IsHealthyAsync(pocketBase.IsHealthyAsync, context.RequestAborted);
/// </code>
/// </example>
/// <seealso cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>
public sealed class PocketBaseHealthCache(TimeProvider time)
{
/// <summary>
/// How long a health check result is reused before PocketBase is asked again.
/// </summary>
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(10);
// One reference, swapped as a whole, so a reader never sees the result of one
// check paired with the time of another.
private volatile Check? _last;
/// <summary>
/// Returns the last known health of PocketBase, probing again once it is older
/// than <see cref="Lifetime"/>.
/// </summary>
/// <param name="probe">
/// The actual check, usually <see cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>.
/// Passed in rather than injected because the typed client is transient and this
/// cache is a singleton.
/// </param>
/// <param name="token">Cancels the probe, e.g. when the visitor goes away.</param>
/// <returns>
/// <see langword="true"/> if PocketBase answered its last check (or answers this
/// one), otherwise <see langword="false"/>.
/// </returns>
/// <exception cref="OperationCanceledException">
/// <paramref name="token"/> was cancelled during a probe; nothing is cached then.
/// </exception>
public async Task<bool> IsHealthyAsync(Func<CancellationToken, Task<bool>> probe, CancellationToken token) =>
_last is { } last && time.GetUtcNow() - last.At < Lifetime
? last.Healthy
: (_last = new Check(await probe(token), time.GetUtcNow())).Healthy;
/// <summary>
/// One health check result and when it was taken.
/// </summary>
/// <param name="Healthy">Whether PocketBase answered.</param>
/// <param name="At">When the answer came in.</param>
private sealed record Check(bool Healthy, DateTimeOffset At);
}