blob: e88f79a005ba41c18d865b7a5139d40029c9e2f6 [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.
//! In-flight admission for awaited partition writes: the per-session /
//! shard-global budget guard. Coupled to the router body cap because the shard
//! inbox is one shared bounded channel, so admission is correctness-adjacent.
use std::cell::Cell;
use crate::http::error::PartitionWriteError;
/// Per-session cap on concurrently awaited partition writes (produce /
/// consumer-offset). Bounds how much of [`MAX_IN_FLIGHT_WRITES_GLOBAL`] one
/// credential can occupy, so a session that outruns its own commits saturates
/// itself (429, its own backpressure signal) before it can starve every other
/// session out of the shared budget.
const MAX_IN_FLIGHT_WRITES_PER_SESSION: u32 = 32;
/// Shard-0-global budget for concurrently awaited partition writes, across all
/// sessions. Every admitted write parks a handler for up to
/// [`PARTITION_WRITE_REPLY_TIMEOUT`] while pinning its buffered body, and its
/// decode/encode/HS256 CPU runs on the same single-threaded core that pumps
/// consensus. The budget therefore bounds both starvation terms: budget x
/// `max_request_size` bounds the worst-case buffered bytes, and budget x
/// per-request CPU bounds how far admitted HTTP work can delay the consensus
/// pump. `?ack=none` produces are admitted through the same caps: they install
/// no reply slot and never await a commit, but they still park inside dispatch
/// for the routable-wait budget while pinning their buffered body, so leaving
/// them uncapped would bypass both terms. A session that saturates its own cap
/// reads its own 429 before it can spill onto the shared budget.
const MAX_IN_FLIGHT_WRITES_GLOBAL: u32 = 128;
/// In-flight admission token for one awaited partition write. One guard owns
/// both releases (session + global) so success, every error return, the reply
/// timeout, and handler cancellation (the client hanging up mid-await drops
/// this future) all decrement through the same `Drop`.
pub(in crate::http) struct InFlightWriteGuard<'a> {
session_in_flight: &'a Cell<u32>,
global_in_flight: &'a Cell<u32>,
}
impl Drop for InFlightWriteGuard<'_> {
fn drop(&mut self) {
self.session_in_flight.set(self.session_in_flight.get() - 1);
self.global_in_flight.set(self.global_in_flight.get() - 1);
}
}
/// Admit one awaited partition write against the per-session cap and the
/// shard-0 global budget, incrementing both counters only when both pass. The
/// session cap is checked first so a session that saturates itself reads as
/// its own 429 rather than as server-wide pressure.
pub(in crate::http) fn admit_partition_write<'a>(
session_in_flight: &'a Cell<u32>,
global_in_flight: &'a Cell<u32>,
) -> Result<InFlightWriteGuard<'a>, PartitionWriteError> {
if session_in_flight.get() >= MAX_IN_FLIGHT_WRITES_PER_SESSION {
return Err(PartitionWriteError::TooManyInFlight);
}
if global_in_flight.get() >= MAX_IN_FLIGHT_WRITES_GLOBAL {
return Err(PartitionWriteError::ServerBusy);
}
session_in_flight.set(session_in_flight.get() + 1);
global_in_flight.set(global_in_flight.get() + 1);
Ok(InFlightWriteGuard {
session_in_flight,
global_in_flight,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_flight_write_guard_decrements_both_counters_on_drop() {
let session = Cell::new(0);
let global = Cell::new(0);
let guard = admit_partition_write(&session, &global).expect("below both caps");
assert_eq!(session.get(), 1);
assert_eq!(global.get(), 1);
drop(guard);
assert_eq!(session.get(), 0);
assert_eq!(global.get(), 0);
}
#[test]
fn admission_at_session_cap_rejects_with_too_many_in_flight() {
let session = Cell::new(MAX_IN_FLIGHT_WRITES_PER_SESSION);
let global = Cell::new(0);
assert!(matches!(
admit_partition_write(&session, &global),
Err(PartitionWriteError::TooManyInFlight)
));
// A refusal must not leak a partial increment on either counter.
assert_eq!(session.get(), MAX_IN_FLIGHT_WRITES_PER_SESSION);
assert_eq!(global.get(), 0);
}
#[test]
fn admission_at_global_budget_rejects_with_server_busy() {
let session = Cell::new(0);
let global = Cell::new(MAX_IN_FLIGHT_WRITES_GLOBAL);
assert!(matches!(
admit_partition_write(&session, &global),
Err(PartitionWriteError::ServerBusy)
));
assert_eq!(session.get(), 0);
assert_eq!(global.get(), MAX_IN_FLIGHT_WRITES_GLOBAL);
}
#[test]
fn interleaved_admission_reopens_exactly_released_session_slots() {
let session = Cell::new(0);
let global = Cell::new(0);
let mut guards = Vec::new();
for _ in 0..MAX_IN_FLIGHT_WRITES_PER_SESSION {
guards.push(admit_partition_write(&session, &global).expect("below both caps"));
}
assert!(matches!(
admit_partition_write(&session, &global),
Err(PartitionWriteError::TooManyInFlight)
));
let released = 3;
guards.truncate((MAX_IN_FLIGHT_WRITES_PER_SESSION - released) as usize);
assert_eq!(session.get(), MAX_IN_FLIGHT_WRITES_PER_SESSION - released);
for _ in 0..released {
guards.push(admit_partition_write(&session, &global).expect("released slots"));
}
assert!(matches!(
admit_partition_write(&session, &global),
Err(PartitionWriteError::TooManyInFlight)
));
drop(guards);
assert_eq!(session.get(), 0);
assert_eq!(global.get(), 0);
}
#[test]
fn global_budget_spans_sessions_and_reopens_after_release() {
let global = Cell::new(0);
let session_count =
MAX_IN_FLIGHT_WRITES_GLOBAL.div_ceil(MAX_IN_FLIGHT_WRITES_PER_SESSION) as usize;
let sessions: Vec<Cell<u32>> = (0..session_count).map(|_| Cell::new(0)).collect();
let mut guards = Vec::new();
'fill: for session in &sessions {
for _ in 0..MAX_IN_FLIGHT_WRITES_PER_SESSION {
match admit_partition_write(session, &global) {
Ok(guard) => guards.push(guard),
Err(PartitionWriteError::ServerBusy) => break 'fill,
Err(other) => {
panic!("only the global budget may refuse this fill, got {other:?}")
}
}
}
}
assert_eq!(global.get(), MAX_IN_FLIGHT_WRITES_GLOBAL);
// A fresh session is refused on the shared budget, not its own cap.
let fresh = Cell::new(0);
assert!(matches!(
admit_partition_write(&fresh, &global),
Err(PartitionWriteError::ServerBusy)
));
drop(guards.pop());
let readmitted = admit_partition_write(&fresh, &global).expect("budget slot released");
assert_eq!(fresh.get(), 1);
assert_eq!(global.get(), MAX_IN_FLIGHT_WRITES_GLOBAL);
drop(readmitted);
}
}