Serve static maintenance page with 503 when PocketBase is unreachable
This commit is contained in:
1 parent
ed6aa5d5ec
commit
7e56e83953
7 files changed
+500
-4
No files matched your search
@@ -0,0 +1,73 @@
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user