feat(maintenance): add a maintenance notification during backend migration

This commit is contained in:
Edouard Vanbelle
2026-08-01 18:01:10 +02:00
parent 142afecbbf
commit bbfb106a32
27 changed files with 537 additions and 23 deletions
+18 -7
View File
@@ -687,13 +687,24 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// them on every overlapping request.
router = router.route("/{*rest}", any(api_not_found));
// No per-router layers: the global `TraceLayer` + request-id stack in
// `main.rs` wraps the whole app (this `/api` router is nested into it),
// so a second `TraceLayer` here just double-wrapped every `/api`
// request in a redundant span + response-future poll (benches/ROUND13.md
// §H1). Compression is likewise the global layer's job — re-applying it
// here (no predicate) would compress media downloads, burning CPU for
// ~0 gain and stripping `Content-Length`.
// Server-status header. Stamps `X-Server-Status` on every
// response so the frontend's fetch wrapper can update a
// reactive store — banner shows/hides without polling.
// Sub-nanosecond on the hot path (single atomic load), a few
// µs on the cold path (only during a running migration). See
// `middleware::server_status`.
let router = router.layer(axum::middleware::from_fn_with_state(
app_state.clone(),
crate::interfaces::middleware::server_status::server_status_middleware,
));
// No per-router layers beyond that: the global `TraceLayer` + request-id
// stack in `main.rs` wraps the whole app (this `/api` router is nested
// into it), so a second `TraceLayer` here just double-wrapped every
// `/api` request in a redundant span + response-future poll
// (benches/ROUND13.md §H1). Compression is likewise the global layer's
// job — re-applying it here (no predicate) would compress media
// downloads, burning CPU for ~0 gain and stripping `Content-Length`.
router
}
+1
View File
@@ -3,6 +3,7 @@ pub mod auth;
pub mod csrf;
pub mod locale;
pub mod rate_limit;
pub mod server_status;
pub mod trace_span;
pub mod trusted_proxy;
pub mod user;
+119
View File
@@ -0,0 +1,119 @@
//! Middleware that stamps `X-Server-Status` on every response.
//!
//! Consumed by the frontend `apiFetch` wrapper — every API round-trip
//! carries the current server maintenance state back to the client
//! (no polling, no dedicated endpoint). The banner in the app shell
//! subscribes to a store the wrapper updates and shows/hides itself
//! reactively. See `docs/plan/storage-multi-entry.md` §"Read-only mode"
//! for the broader design.
//!
//! ## Cost model
//!
//! On the *hot path* (no migration running — the ~100% case in normal
//! operation) this middleware does:
//! 1. one `AtomicBool::load(Relaxed)` — sub-nanosecond;
//! 2. an early return when `false`.
//!
//! No allocation, no lock, no formatting. Adds no measurable latency
//! at any user count.
//!
//! On the *cold path* (migration in progress) this middleware does:
//! 1. the atomic load above;
//! 2. one `RwLock::read` (uncontended — writers are the migration
//! handler, one per batch every ~100 blobs);
//! 3. one small `serde_json::to_string` call on a 4-field struct
//! (a few dozen bytes);
//! 4. one header insertion.
//!
//! Total per-request work in this branch: microseconds.
use axum::extract::Request;
use axum::extract::State;
use axum::http::HeaderValue;
use axum::middleware::Next;
use axum::response::Response;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use crate::common::di::AppState;
/// Name of the response header the frontend reads. Kept short — an
/// admin browser session may keep this header around in every open
/// tab's dev-tools network view during a migration; the value is
/// small JSON but the name should not add bloat.
pub const SERVER_STATUS_HEADER: &str = "x-server-status";
/// Compact JSON shape written into the header. Fields are documented
/// in `common::migration_progress::MigrationProgress`.
///
/// Kept internal so the wire format can evolve. Frontend treats the
/// header as opaque JSON and pattern-matches on the fields it
/// currently understands.
#[derive(serde::Serialize)]
struct HeaderPayload {
readonly: bool,
#[serde(skip_serializing_if = "Option::is_none")]
migration: Option<MigrationHeader>,
}
#[derive(serde::Serialize)]
struct MigrationHeader {
// `target` is owned here — the RwLock guard is released before
// serialisation, so a borrowed slice wouldn't survive. Names
// are small (`[a-z0-9_-]{1,32}`) so the copy is trivial.
target: String,
migrated: u64,
total: u64,
percent: u8,
}
pub async fn server_status_middleware(
State(state): State<Arc<AppState>>,
request: Request,
next: Next,
) -> Response {
// Hot-path fast return. When no migration is running the flag is
// false and there's nothing to emit — a bare atomic load and out.
let readonly = state.migration_readonly.load(Ordering::Relaxed);
let mut response = next.run(request).await;
if !readonly {
return response;
}
// Cold path — build the payload from the shared progress
// snapshot. If the snapshot is absent (readonly is true but the
// handler hasn't seeded progress yet, or a restart-during-
// migration scenario) we still emit `readonly: true` so the
// banner shows — the frontend renders a "maintenance in progress"
// message even when specific numbers aren't available.
let payload = {
let guard = state
.migration_progress
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
HeaderPayload {
readonly: true,
migration: guard.as_ref().map(|p| MigrationHeader {
target: p.target_name.clone(),
migrated: p.migrated_blobs,
total: p.total_blobs,
percent: p.percent,
}),
}
};
// `serde_json::to_string` on this 4-field struct is a few
// dozen-byte allocation — negligible against the response body.
// A serialize failure here would be a programming bug (all
// fields are trivially serializable), so we degrade to a
// minimal `readonly: true` string rather than skipping the
// header entirely.
let value =
serde_json::to_string(&payload).unwrap_or_else(|_| r#"{"readonly":true}"#.to_string());
if let Ok(header_value) = HeaderValue::from_str(&value) {
response
.headers_mut()
.insert(SERVER_STATUS_HEADER, header_value);
}
response
}