74 lines
3.4 KiB
C#
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);
|
|
}
|