blob: 5f1984cd41db474837e4f15d0c8398d193e1e318 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Shared shard-0 HTTP state: the [`HttpInner`] bridge (shard handle, JWT
//! issuer, per-credential VSR session table, cluster roster) plus the axum
//! `State` newtype and the per-response view-header stamp.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::net::IpAddr;
use std::rc::Rc;
use std::sync::Arc;
use axum::http::{HeaderName, HeaderValue};
use axum::response::Response;
use configs::server::ServerSystemConfig;
use consensus::{MetadataHandle, VsrConsensus};
use futures::channel::oneshot;
use iggy_common::{ClusterMetadata, IggyTimestamp};
use message_bus::InstanceToken;
use metadata::MetadataSubmitError;
use send_wrapper::SendWrapper;
use tokio::sync::Mutex;
use tracing::warn;
use crate::cluster_meta::ClusterRoster;
use crate::dispatch::session_ops::submit_register_on_owner;
use crate::http::error::{AuthError, ReadError, primary_redirect_location};
use crate::http::jwt::JwtManager;
use crate::http::metrics::HttpMetrics;
use crate::http::session::{
BarrierEntry, FIRST_REQUEST_ID, FRESH_ENTRY_WATERMARK, HttpSession, RegistrationBarrier,
forget_if_same, live_entry, sweep_expired,
};
use crate::shell::ServerShard;
/// Response header carrying the current VSR view number. Stamped by
/// `insert_view_header` on success and redirect responses only (never on
/// errors, and the router suppresses it on `/ping`) while this node has live
/// consensus. Fill-if-absent: a response relayed from the primary already
/// carries the SERVING node's view, which must win over the relaying
/// follower's possibly-stale one.
pub(in crate::http) const VIEW_HEADER: HeaderName = HeaderName::from_static("iggy-view");
/// Axum router state: shard-0's [`HttpInner`] behind an `Rc`, `!Send` yet
/// bridged into axum's `Send + Sync` requirement by `SendWrapper`. Sound
/// because the listener and every handler run on shard 0's compio thread - the
/// same thread that builds this state. Never touch it off that thread.
pub(in crate::http) type HttpState = SendWrapper<Rc<HttpInner>>;
/// Per-node forwarding context hung off `HttpInner`: the outbound client
/// (pinned-cert TLS when the listener serves HTTPS), the scheme it dials, the
/// request-body buffer bound, and the in-flight budget. Built by
/// `http::forward::build_forward_state`; lives here so the state hub never
/// imports the forwarding middleware.
pub(in crate::http) struct ForwardState {
/// False when no cluster-wide bearer key material exists (no configured
/// JWT secret, no cluster PSK): a forwarded bearer would 401 on the
/// primary, so the middleware passes through and followers answer with
/// the transient 503 instead.
pub(in crate::http) active: bool,
pub(in crate::http) client: cyper::Client,
/// Also read by the 307 redirect builder: the primary is assumed to serve
/// the same scheme as this node (uniform cluster HTTP config).
pub(in crate::http) scheme: &'static str,
pub(in crate::http) body_limit: usize,
pub(in crate::http) in_flight: Cell<u32>,
}
/// Shared shard-0 HTTP state.
///
/// Groups the shard handle, the JWT issuer/verifier, and the per-credential VSR
/// session table so every handler and the [`Authenticated`] extractor reach
/// them through one axum `State`.
pub(in crate::http) struct HttpInner {
pub(in crate::http) shard: Rc<ServerShard>,
pub(in crate::http) jwt: JwtManager,
/// Read-only server config for the snapshot collector (log directory +
/// runtime config paths); the shard does not expose config on the read
/// path.
pub(in crate::http) system_config: Arc<ServerSystemConfig>,
/// Per-credential VSR sessions keyed by JWT `jti` / PAT hash. `RefCell` is
/// sound here - shard 0 is single-threaded and the `SendWrapper` state
/// bridge tolerates the `!Sync` interior - but the guard must never be held
/// across an `.await` (see [`HttpInner::resolve_session`]).
pub(in crate::http) sessions: RefCell<HashMap<String, Rc<HttpSession>>>,
/// Per-key registration barrier: prevents a thundering herd of first
/// requests for one credential from each running its own `Register`.
pub(in crate::http) registrations: RegistrationBarrier,
pub(in crate::http) roster: Rc<ClusterRoster>,
/// Cap on live per-credential sessions: half the configured `[metadata]
/// clients_table_max`, so HTTP sessions cannot crowd the TCP/QUIC/WS virtual
/// clients out of the shared VSR client table. Read by `resolve_session`
/// when admitting a fresh session.
pub(in crate::http) max_http_sessions: usize,
/// Configured `[personal_access_token] max_tokens_per_user`, enforced
/// pre-consensus by the PAT rewrite inside the write submit (the cap is
/// config-derived, so it must never branch inside the replicated apply).
pub(in crate::http) max_tokens_per_user: u32,
/// Awaited partition writes currently in flight across all sessions, gated
/// by [`MAX_IN_FLIGHT_WRITES_GLOBAL`]. Only [`InFlightWriteGuard`] touches
/// it, so every admission is paired with exactly one release.
pub(in crate::http) in_flight_writes: Cell<u32>,
/// Follower-to-primary forwarding context: outbound client, scheme, body
/// bound, and its own in-flight budget (see `http::forward`).
pub(in crate::http) forward: ForwardState,
/// Legacy-parity metric registry served by the scrape route; the router's
/// counting layer holds a clone of its request counter.
pub(in crate::http) metrics: HttpMetrics,
}
impl HttpInner {
/// True when this shard-0 node is the current VSR metadata primary, i.e.
/// `primary_index(current_view) == own_replica_id` - the check consensus
/// `is_primary` already encapsulates over the live view and this replica's
/// id. Absent consensus (never on shard 0 under VSR, only a no-replica
/// build) is treated as not-primary so a linearizable read fails closed
/// rather than serving possibly-stale local state as authoritative.
pub(in crate::http) fn is_metadata_primary(&self) -> bool {
self.shard
.plane
.metadata()
.consensus
.as_ref()
.is_some_and(VsrConsensus::is_primary)
}
/// Grade a linearizable read that reached a follower: redirect (307) to the
/// current VSR primary's HTTP address when it resolves from the roster, else
/// fail closed to the 503. The target is the roster node whose `replica_id`
/// equals `primary_index(view)`; an absent consensus, an unmatched id, or a
/// port-less node all fall back to [`ReadError::NotPrimary`]. `client_ip`
/// picks the primary's advertised address from its per-client-network
/// selectors, so the redirected client lands on the address for its own
/// network.
pub(in crate::http) fn not_primary_read_error(
&self,
path_and_query: &str,
client_ip: Option<IpAddr>,
) -> ReadError {
let location = self
.shard
.plane
.metadata()
.consensus
.as_ref()
.and_then(|consensus| {
let primary_index = consensus.primary_index(consensus.view());
primary_redirect_location(
&self.roster,
primary_index,
self.forward.scheme,
path_and_query,
client_ip,
)
});
location.map_or(ReadError::NotPrimary, ReadError::RedirectToPrimary)
}
/// Resolve the VSR session for `key`, minting and Registering one on first
/// use. Every later request bearing the same credential reuses it.
///
/// Borrow discipline: the `RefCell` table guard is taken, read, and dropped
/// WITHOUT crossing the `.await`. Holding it across the Register suspend
/// would panic the moment a sibling shard-0 task borrowed the table while
/// this one is parked (single-threaded `RefCell` + cooperative scheduling).
pub(in crate::http) async fn resolve_session(
&self,
key: String,
user_id: u32,
expiry: u64,
) -> Result<Rc<HttpSession>, AuthError> {
loop {
let now = IggyTimestamp::now().to_secs();
if let Some(session) = self.live_session(&key, now) {
return Ok(session);
}
// Miss. Serialize registration per credential so a herd of
// concurrent first-requests runs one `Register`, not N.
match self.registrations.enter(&key) {
BarrierEntry::Wait(waiter) => {
// Another first-request is registering this credential.
// Park until it finishes (its guard wakes us on drop),
// then loop to re-check the table for what it installed.
let _ = waiter.await;
}
BarrierEntry::Lead(_guard) => {
// Sole registrant for this key: mint + Register with no
// borrow held (an async VSR commit). The guard wakes any
// waiters when this scope ends, cancellation drop included.
let fresh = self.register_session(key.clone(), user_id, expiry).await?;
// Resample after the await: the pre-await stamp is stale for
// the expiry sweep and cap check below.
let now = IggyTimestamp::now().to_secs();
let (admitted, torn) = {
let mut table = self.sessions.borrow_mut();
let torn = sweep_expired(&mut table, now);
if table.len() >= self.max_http_sessions {
// Still full after dropping expired entries: too many
// genuinely live sessions. Refuse rather than evict a
// live one (its `fresh` client id is orphaned on the
// peers until they evict it - a rare at-cap cost).
(None, torn)
} else {
table.insert(key.clone(), Rc::clone(&fresh));
(Some(fresh), torn)
}
};
self.teardown_reply_targets(torn);
return admitted.ok_or(AuthError::SessionUnavailable);
}
}
}
}
/// Clone the live (non-expired) entry for `key`, if present. Confines the
/// shared `RefCell` borrow to this call so it can never span an `.await`.
fn live_session(&self, key: &str, now_secs: u64) -> Option<Rc<HttpSession>> {
live_entry(&self.sessions.borrow(), key, now_secs)
}
/// Mint a shard-0 client id and run the VSR `Register` for a fresh session,
/// retrying on a fresh id if the minted one turns out to be taken.
///
/// The minter is a per-process counter reseeded from the client table at
/// boot, so a fresh mint normally lands on a free id. Two situations break
/// that, and neither is predictable from here: a promoted primary mints
/// from a counter with no relationship to the ids its predecessor
/// committed, and in a cluster every node counts independently. Landing on
/// an occupied entry is therefore reactive to detect and cheap to fix --
/// mint again. Bounded, because a run of collisions means the counter is
/// wrong rather than unlucky, and looping would hide that.
///
/// The two collision signals are asymmetric. A different owner is refused
/// terminally by the register ownership gate. The SAME user is not refused
/// at all -- it rebinds, silently inheriting a watermark written by another
/// of that user's sessions, which would make this session's first writes
/// read as duplicates and answer them from the other session's cache. A
/// non-zero watermark on what should be a brand-new session is exactly that
/// tell.
async fn register_session(
&self,
key: String,
user_id: u32,
expiry: u64,
) -> Result<Rc<HttpSession>, AuthError> {
/// Enough to ride out a promotion-era counter overlap; beyond this the
/// minter is misconfigured and the 503 is the honest answer.
const MINT_ATTEMPTS: u8 = 3;
for attempt in 1..=MINT_ATTEMPTS {
match self
.register_session_once(key.clone(), user_id, expiry)
.await
{
Ok(session) => return Ok(session),
Err(AuthError::SessionIdOwnedByAnotherUser | AuthError::SessionIdTaken)
if attempt < MINT_ATTEMPTS =>
{
warn!(
attempt,
"server HTTP: minted client id was already registered; re-minting"
);
}
Err(error) => return Err(error),
}
}
Err(AuthError::SessionUnavailable)
}
/// One mint-and-Register attempt. `SessionIdTaken` means the id was live
/// under this same user, so the caller should mint a different one.
async fn register_session_once(
&self,
key: String,
user_id: u32,
expiry: u64,
) -> Result<Rc<HttpSession>, AuthError> {
let coordinator = self
.shard
.coordinator()
.ok_or(AuthError::SessionUnavailable)?;
// Refold the client table into the minter if this is the first mint of
// the current view. Cheap and skipped within a view, and it is what
// stops a PROMOTED primary from minting against ids its predecessor
// committed from an unrelated counter -- the table is replicated, the
// counter is per process. Boot does the same call (`bootstrap`); this
// one covers every later view.
{
let metadata = self.shard.plane.metadata();
if let Some(consensus) = metadata.consensus.as_ref() {
coordinator.seed_client_sequence(
consensus.view(),
metadata.client_table.borrow().client_ids(),
);
}
}
// Reuse the TCP accept path's minter: it draws from the same shard-0
// `client_seq`, so an HTTP session id can never collide with a TCP
// virtual client's and the shard-0 tag (top 16 bits == 0) is preserved.
let client_id = coordinator.mint_shard_zero_client_id();
// The minter seeds at 1, so 0 is only reachable after a 2^112 wrap.
// Guard anyway: `submit_register_in_process` asserts `client_id != 0`,
// and an assert on this request path would be a panic.
if client_id == 0 {
return Err(AuthError::SessionUnavailable);
}
// Shared Register entry point; on shard 0 (always, for HTTP) it runs
// `submit_register_in_process` directly on the metadata owner.
//
// Detached so a client disconnect cannot cancel the Register
// mid-flight: the in-process submit drives shared consensus machinery
// (pipeline push, WAL append, the `on_ack` commit loop), and hyper
// drops this handler future the moment the HTTP peer disconnects.
// A canceled submit used to strand consensus state mid-await; now the
// detached task always drives it to completion and a disconnect only
// drops the receiver half (same discipline as `submit_committed`).
let (result_slot, committed) = oneshot::channel();
let shard = Rc::clone(&self.shard);
compio::runtime::spawn(async move {
let result = submit_register_on_owner(&shard, client_id, user_id).await;
// A failed send means the handler died mid-await; the Register
// itself has already committed, which is what matters.
let _ = result_slot.send(result);
})
.detach();
let bound = committed
.await
.map_err(|_| AuthError::SessionUnavailable)?
.map_err(|error| {
warn!(?error, "server HTTP: VSR Register submit failed");
register_submit_auth_error(&error)
})?;
// A fresh mint must land on a fresh entry, so a watermark it did not
// write means the id was already registered to this same user (see
// `register_session`). Rebinding onto it would inherit that session's
// dedup history; hand the id back instead and let the caller re-mint.
if bound.watermark != FRESH_ENTRY_WATERMARK {
warn!(
client_id,
user_id,
watermark = bound.watermark,
"server HTTP: minted client id already had a committed session for this user"
);
return Err(AuthError::SessionIdTaken);
}
Ok(Rc::new(HttpSession {
key,
client_id,
session: bound.epoch,
user_id,
expiry,
gate: Mutex::new(FIRST_REQUEST_ID),
data_gate: Mutex::new(FIRST_REQUEST_ID),
registry_token: Cell::new(None),
in_flight_writes: Cell::new(0),
}))
}
/// Drop the session table entry for `session`, but only if it is still the
/// current occupant of its key (pointer-fenced, so a later re-registration
/// under the same key is never purged). Also tears down its in-process
/// reply target. Called when a control write comes back evicted: the VSR
/// slot is gone, so leaving the entry would 401-loop every retry on the
/// same credential until the token expires; removing it makes the next
/// request re-register cleanly through the barrier.
pub(in crate::http) fn forget_session(&self, session: &Rc<HttpSession>) {
let torn = forget_if_same(&mut self.sessions.borrow_mut(), session);
self.teardown_reply_targets(torn.into_iter().collect());
}
/// Tear down the in-process reply targets of swept/forgotten sessions,
/// token-fenced so a stale teardown can never remove a later occupant's
/// registry entry. Runs outside the `sessions` borrow.
fn teardown_reply_targets(&self, torn: Vec<(u128, InstanceToken)>) {
for (client_id, token) in torn {
self.shard
.bus
.clients()
.remove_if_token_matches(client_id, token);
}
}
/// Build the live [`ClusterMetadata`] for `GET /cluster/metadata` through the
/// shared [`ClusterRoster`] assembly. The leader marking comes from this
/// shard's consensus view; the HTTP listener is shard-0-only, so consensus is
/// always present and every roster read carries real leader/follower roles.
/// `client_ip` picks each node's advertised address from its
/// per-client-network selectors.
pub(in crate::http) fn build_cluster_metadata(
&self,
client_ip: Option<IpAddr>,
) -> ClusterMetadata {
let primary_index = self
.shard
.plane
.metadata()
.consensus
.as_ref()
.map(|consensus| consensus.primary_index(consensus.view()));
self.roster.cluster_metadata(primary_index, client_ip)
}
}
const fn register_submit_auth_error(error: &MetadataSubmitError) -> AuthError {
match error {
// These outcomes prove the Register never entered a pipeline, so a
// forwarding peer may safely retry against a re-resolved primary.
MetadataSubmitError::NotPrimary
| MetadataSubmitError::NotCaughtUp
| MetadataSubmitError::PipelineFull
| MetadataSubmitError::PrimaryUnreachable => AuthError::SessionNotAccepted,
MetadataSubmitError::ClientIdOwnedByAnotherUser => AuthError::SessionIdOwnedByAnotherUser,
// The proposal may still commit. Unknown future outcomes fail closed
// into the same client-only retry class.
MetadataSubmitError::InProgress
| MetadataSubmitError::Canceled
| MetadataSubmitError::ForwardTimedOut
| _ => AuthError::SessionUnavailable,
}
}
/// Set the [`VIEW_HEADER`] to the current VSR view on a successful or redirect
/// `response`. Omits the header on error responses and when this node has no
/// live consensus: a missing header is unambiguous, whereas a fabricated view
/// number would mislead.
pub(in crate::http) fn insert_view_header(state: &HttpInner, mut response: Response) -> Response {
// The view is cluster-internal; error responses (notably pre-auth 401s)
// must not leak it. Success and the 307 primary-redirect still carry it.
if !(response.status().is_success() || response.status().is_redirection()) {
return response;
}
if let Some(consensus) = state.shard.plane.metadata().consensus.as_ref() {
// Fill-if-absent: a relayed response already carries the serving
// primary's view, which must not be overwritten with this follower's.
response
.headers_mut()
.entry(VIEW_HEADER)
.or_insert(HeaderValue::from(consensus.view()));
}
response
}
#[cfg(test)]
mod tests {
use super::register_submit_auth_error;
use crate::http::error::AuthError;
use metadata::MetadataSubmitError;
#[test]
fn register_submit_errors_preserve_known_and_unknown_outcomes() {
for error in [
MetadataSubmitError::NotPrimary,
MetadataSubmitError::NotCaughtUp,
MetadataSubmitError::PipelineFull,
MetadataSubmitError::PrimaryUnreachable,
] {
assert!(matches!(
register_submit_auth_error(&error),
AuthError::SessionNotAccepted
));
}
assert!(matches!(
register_submit_auth_error(&MetadataSubmitError::ClientIdOwnedByAnotherUser),
AuthError::SessionIdOwnedByAnotherUser
));
for error in [
MetadataSubmitError::InProgress,
MetadataSubmitError::Canceled,
MetadataSubmitError::ForwardTimedOut,
] {
assert!(matches!(
register_submit_auth_error(&error),
AuthError::SessionUnavailable
));
}
}
}