7dcd62fdd7
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
The deploy that ends a maintenance window may ship a client-incompatible change (wire/schema bump), and the in-session bundle is the old one. On recovery, reload to pick up the fresh client instead of just hiding the overlay. Ordering is safe: the edge maintenance flag (deploy/prod-deploy.sh) spans the WHOLE roll and clears only at script exit — after the gateway (which serves the embedded SPA) has rolled — so the edge 503s everything until the entire deploy is live; recovery therefore always serves the new SPA. A window.__maint.recover() hook + e2e cover the reload.
100 lines
3.3 KiB
TypeScript
100 lines
3.3 KiB
TypeScript
// Global maintenance signal + self-clearing poll. `active` is true while the edge is in a
|
|
// planned deploy window (a caddy 503 carried `X-Scrabble-Maintenance`; see maintenance.ts).
|
|
// A non-dismissable overlay (MaintenanceOverlay.svelte) covers the app while active, and a
|
|
// cheap read is retried on a capped-backoff cadence (mirroring connection.svelte.ts) — the
|
|
// first success lifts the overlay, so it can never get stuck even with no other traffic.
|
|
// Mock mode never reports maintenance, so it simply stays inactive.
|
|
|
|
import { backoffMs } from './retry';
|
|
|
|
let active = $state(false);
|
|
let retryHintMs = $state(0);
|
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let attempt = 0;
|
|
let probing = false;
|
|
let probe: (() => Promise<void>) | null = null;
|
|
|
|
export const maintenance = {
|
|
/** active is true while the edge is in a planned maintenance window. */
|
|
get active(): boolean {
|
|
return active;
|
|
},
|
|
/** retryHintMs is the last Retry-After hint in milliseconds (0 when unknown). */
|
|
get retryHintMs(): number {
|
|
return retryHintMs;
|
|
},
|
|
};
|
|
|
|
/** registerMaintenanceProbe installs the reachability read the poll fires while active
|
|
* (the transport wires a cheap authenticated read). */
|
|
export function registerMaintenanceProbe(fn: () => Promise<void>): void {
|
|
probe = fn;
|
|
}
|
|
|
|
/** reportMaintenance raises the overlay and starts the self-clearing poll. Idempotent: a
|
|
* repeat hit only refreshes the hint, it never restarts a running poll. */
|
|
export function reportMaintenance(retryAfterMs: number): void {
|
|
retryHintMs = retryAfterMs;
|
|
if (active) return;
|
|
active = true;
|
|
attempt = 0;
|
|
if (probe) schedulePoll();
|
|
}
|
|
|
|
/** clearMaintenance lowers the overlay and stops the poll without reloading (a reset, or the
|
|
* e2e hook). Prod recovery goes through maintenanceRecovered instead. */
|
|
export function clearMaintenance(): void {
|
|
active = false;
|
|
if (pollTimer) {
|
|
clearTimeout(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
}
|
|
|
|
/** maintenanceRecovered runs when the edge answers again after a window we were showing.
|
|
* The deploy that ended the window may have shipped a client-incompatible change (a wire /
|
|
* schema bump), and the running bundle is the OLD one — so reload to pick up the fresh
|
|
* client instead of merely hiding the overlay. A no-op unless the overlay was up; the cover
|
|
* stays over the brief reload flash. In prod recovery is the only way the overlay clears. */
|
|
export function maintenanceRecovered(): void {
|
|
if (!active) return;
|
|
active = false;
|
|
if (pollTimer) {
|
|
clearTimeout(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
if (typeof location !== 'undefined') location.reload();
|
|
}
|
|
|
|
/** retryNow forces an immediate re-check (the overlay's button). It never closes the
|
|
* overlay itself — only a successful probe does. A no-op while a probe is in flight. */
|
|
export function retryNow(): void {
|
|
if (!active || !probe || probing) return;
|
|
if (pollTimer) {
|
|
clearTimeout(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
attempt = 0;
|
|
runProbe();
|
|
}
|
|
|
|
function schedulePoll(): void {
|
|
pollTimer = setTimeout(runProbe, backoffMs(++attempt));
|
|
}
|
|
|
|
function runProbe(): void {
|
|
pollTimer = null;
|
|
if (!active || !probe || probing) return;
|
|
probing = true;
|
|
probe().then(
|
|
() => {
|
|
probing = false;
|
|
maintenanceRecovered();
|
|
},
|
|
() => {
|
|
probing = false;
|
|
if (active) schedulePoll();
|
|
},
|
|
);
|
|
}
|