Files
Aether/crates/aether-usage/runtime/src/queue_read_budget.rs
T
elky ecc16673eb fix: harden concurrency limits and high-RPM runtime paths
Bound request, stream, queue, and shutdown resource lifetimes. Reduce scheduler and Redis hot-path work and isolate database maintenance. Include regression coverage, load probes, and concurrency audit results.
2026-09-10 08:14:58 +08:00

374 lines
15 KiB
Rust

use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, LazyLock};
use aether_data_contracts::DataLayerError;
use aether_runtime_state::RuntimeQueueEntry;
use tokio::sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError};
const DEFAULT_READ_PAYLOAD_BUDGET_BYTES: usize = 128 * 1024 * 1024;
const DEFAULT_READ_BATCH_PAYLOAD_BYTES: usize = 8 * 1024 * 1024;
static READ_BUDGET: LazyLock<Arc<QueueReadBudget>> = LazyLock::new(|| {
let limit = std::env::var("AETHER_USAGE_QUEUE_READ_PAYLOAD_BUDGET_BYTES").ok();
let batch = std::env::var("AETHER_USAGE_QUEUE_READ_BATCH_PAYLOAD_BYTES").ok();
Arc::new(QueueReadBudget::new(
configured_bytes(limit.as_deref(), DEFAULT_READ_PAYLOAD_BUDGET_BYTES),
configured_bytes(batch.as_deref(), DEFAULT_READ_BATCH_PAYLOAD_BYTES),
))
});
pub(crate) fn shared_queue_read_budget() -> Arc<QueueReadBudget> {
Arc::clone(&READ_BUDGET)
}
pub(crate) fn queue_read_budget_metrics() -> QueueReadBudgetSnapshot {
READ_BUDGET.snapshot()
}
fn maximum_budget_bytes() -> usize {
Semaphore::MAX_PERMITS.min(u32::MAX as usize)
}
fn configured_bytes(raw: Option<&str>, fallback: usize) -> usize {
raw.and_then(|raw| raw.trim().parse::<u128>().ok())
.filter(|value| *value > 0)
.map(|value| value.min(maximum_budget_bytes() as u128) as usize)
.unwrap_or(fallback.min(maximum_budget_bytes()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct QueueReadBudgetSnapshot {
pub(crate) limit_bytes: usize,
pub(crate) batch_limit_bytes: usize,
pub(crate) reserved_bytes: usize,
pub(crate) waiters: usize,
pub(crate) wait_total: u64,
/// Observed field names plus values, without cloning the returned strings.
pub(crate) actual_field_bytes_total: u64,
pub(crate) oversized_entries_total: u64,
pub(crate) oversized_batches_total: u64,
}
/// A process-wide reservation based on the current producer payload limit.
/// Historical or externally written messages can exceed the estimate. Field names,
/// allocation capacity, RESP decoding, and decoded JSON are not an RSS bound here.
pub(crate) struct QueueReadBudget {
limit_bytes: usize,
batch_limit_bytes: usize,
permits: Arc<Semaphore>,
reserved_bytes: AtomicUsize,
waiters: AtomicUsize,
wait_total: AtomicU64,
actual_field_bytes_total: AtomicU64,
oversized_entries_total: AtomicU64,
oversized_batches_total: AtomicU64,
}
impl QueueReadBudget {
pub(crate) fn new(limit_bytes: usize, batch_limit_bytes: usize) -> Self {
let limit_bytes = limit_bytes.clamp(1, maximum_budget_bytes());
let batch_limit_bytes = batch_limit_bytes.clamp(1, limit_bytes);
Self {
limit_bytes,
batch_limit_bytes,
permits: Arc::new(Semaphore::new(limit_bytes)),
reserved_bytes: AtomicUsize::new(0),
waiters: AtomicUsize::new(0),
wait_total: AtomicU64::new(0),
actual_field_bytes_total: AtomicU64::new(0),
oversized_entries_total: AtomicU64::new(0),
oversized_batches_total: AtomicU64::new(0),
}
}
pub(crate) fn snapshot(&self) -> QueueReadBudgetSnapshot {
QueueReadBudgetSnapshot {
limit_bytes: self.limit_bytes,
batch_limit_bytes: self.batch_limit_bytes,
reserved_bytes: self.reserved_bytes.load(Ordering::Relaxed),
waiters: self.waiters.load(Ordering::Relaxed),
wait_total: self.wait_total.load(Ordering::Relaxed),
actual_field_bytes_total: self.actual_field_bytes_total.load(Ordering::Relaxed),
oversized_entries_total: self.oversized_entries_total.load(Ordering::Relaxed),
oversized_batches_total: self.oversized_batches_total.load(Ordering::Relaxed),
}
}
pub(crate) async fn reserve(
self: &Arc<Self>,
requested_count: usize,
payload_limit: usize,
) -> Result<(usize, QueueReadReservation), DataLayerError> {
if payload_limit == 0 || payload_limit > self.limit_bytes {
return Err(DataLayerError::InvalidConfiguration(format!(
"usage queue payload limit {payload_limit} must be positive and not exceed the {}-byte read payload budget",
self.limit_bytes
)));
}
// A single valid payload may exceed the preferred batch target, but never
// the total budget. Clamp before multiplying or converting to u32 permits.
let count = requested_count
.max(1)
.min((self.batch_limit_bytes / payload_limit).max(1));
let reserved_bytes = count * payload_limit;
let permits = reserved_bytes as u32;
let permit = match Arc::clone(&self.permits).try_acquire_many_owned(permits) {
Ok(permit) => permit,
Err(TryAcquireError::NoPermits) => {
self.wait_total.fetch_add(1, Ordering::Relaxed);
self.waiters.fetch_add(1, Ordering::Relaxed);
let _waiting = WaitingReservation { budget: self };
Arc::clone(&self.permits)
.acquire_many_owned(permits)
.await
.map_err(|_| closed_budget_error())?
}
Err(TryAcquireError::Closed) => return Err(closed_budget_error()),
};
self.reserved_bytes
.fetch_add(reserved_bytes, Ordering::Relaxed);
Ok((
count,
QueueReadReservation {
budget: Arc::clone(self),
reserved_bytes,
permit: Some(permit),
},
))
}
}
fn closed_budget_error() -> DataLayerError {
DataLayerError::InvalidConfiguration("usage queue read payload budget is closed".to_string())
}
struct WaitingReservation<'a> {
budget: &'a QueueReadBudget,
}
impl Drop for WaitingReservation<'_> {
fn drop(&mut self) {
self.budget.waiters.fetch_sub(1, Ordering::Relaxed);
}
}
// Deliberately not Clone: every concurrently retained batch needs its own lease.
pub(crate) struct QueueReadReservation {
budget: Arc<QueueReadBudget>,
reserved_bytes: usize,
permit: Option<OwnedSemaphorePermit>,
}
impl QueueReadReservation {
pub(crate) fn observe_entries(&mut self, entries: &[RuntimeQueueEntry], payload_limit: usize) {
let mut value_bytes = 0usize;
let mut field_bytes = 0usize;
let mut oversized_entries = 0u64;
for entry in entries {
let mut entry_value_bytes = 0usize;
for (key, value) in &entry.fields {
entry_value_bytes = entry_value_bytes.saturating_add(value.len());
field_bytes = field_bytes
.saturating_add(key.len())
.saturating_add(value.len());
}
value_bytes = value_bytes.saturating_add(entry_value_bytes);
oversized_entries += u64::from(entry_value_bytes > payload_limit);
}
self.budget.actual_field_bytes_total.fetch_add(
u64::try_from(field_bytes).unwrap_or(u64::MAX),
Ordering::Relaxed,
);
self.budget
.oversized_entries_total
.fetch_add(oversized_entries, Ordering::Relaxed);
if value_bytes > self.reserved_bytes {
self.budget
.oversized_batches_total
.fetch_add(1, Ordering::Relaxed);
}
// Shrink unused payload estimates. Never wait for an upgrade after reading
// an oversized historical batch: other batches may hold all remaining bytes.
self.shrink_to(value_bytes.min(self.reserved_bytes));
}
fn shrink_to(&mut self, retained_bytes: usize) {
let released = self.reserved_bytes.saturating_sub(retained_bytes);
if released == 0 {
return;
}
let permit = self
.permit
.as_mut()
.expect("positive reservation must hold a permit")
.split(released)
.expect("released bytes must belong to this reservation");
self.reserved_bytes -= released;
self.budget
.reserved_bytes
.fetch_sub(released, Ordering::Relaxed);
drop(permit);
}
}
impl Drop for QueueReadReservation {
fn drop(&mut self) {
self.budget
.reserved_bytes
.fetch_sub(self.reserved_bytes, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::future::Future;
use std::task::Poll;
use super::*;
#[tokio::test]
async fn queue_read_budget_cancelled_wait_preserves_current_reservations() {
let budget = Arc::new(QueueReadBudget::new(32, 16));
let (_, first) = budget.reserve(8, 8).await.unwrap();
let (_, second) = budget.reserve(8, 8).await.unwrap();
let mut pending = Box::pin(budget.reserve(1, 8));
std::future::poll_fn(|cx| {
assert!(pending.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
assert_eq!(budget.snapshot().reserved_bytes, 32);
assert_eq!(budget.snapshot().waiters, 1);
drop(pending);
assert_eq!(budget.snapshot().waiters, 0);
assert_eq!(budget.snapshot().wait_total, 1);
drop(first);
let (_, replacement) = budget.reserve(2, 8).await.unwrap();
assert_eq!(budget.snapshot().reserved_bytes, 32);
drop((second, replacement));
assert_eq!(budget.snapshot().reserved_bytes, 0);
assert_eq!(budget.permits.available_permits(), 32);
}
#[tokio::test]
async fn queue_read_budget_counts_payload_values_and_observes_legacy_excess_without_waiting() {
let budget = Arc::new(QueueReadBudget::new(16, 16));
let (count, mut reservation) = budget.reserve(2, 8).await.unwrap();
assert_eq!(count, 2);
let entries = [RuntimeQueueEntry {
id: "1-0".to_string(),
fields: BTreeMap::from([("payload".to_string(), "x".repeat(8))]),
}];
reservation.observe_entries(&entries, 8);
assert_eq!(budget.snapshot().reserved_bytes, 8);
assert_eq!(budget.snapshot().actual_field_bytes_total, 15);
assert_eq!(budget.snapshot().oversized_entries_total, 0);
let (_, mut legacy) = budget.reserve(1, 8).await.unwrap();
let legacy_entries = [RuntimeQueueEntry {
id: "2-0".to_string(),
fields: BTreeMap::from([
("payload".to_string(), "x".repeat(8)),
("extra".to_string(), "y".repeat(24)),
]),
}];
legacy.observe_entries(&legacy_entries, 8);
assert_eq!(budget.snapshot().reserved_bytes, 16);
assert_eq!(budget.snapshot().actual_field_bytes_total, 59);
assert_eq!(budget.snapshot().oversized_entries_total, 1);
assert_eq!(budget.snapshot().oversized_batches_total, 1);
assert_eq!(budget.snapshot().wait_total, 0);
drop((reservation, legacy));
assert_eq!(budget.snapshot().reserved_bytes, 0);
}
#[tokio::test]
async fn queue_read_budget_empty_response_releases_all_reserved_bytes() {
let budget = Arc::new(QueueReadBudget::new(16, 16));
let (_, mut reservation) = budget.reserve(2, 8).await.unwrap();
reservation.observe_entries(&[], 8);
assert_eq!(budget.snapshot().reserved_bytes, 0);
assert_eq!(budget.permits.available_permits(), 16);
drop(reservation);
assert_eq!(budget.snapshot().reserved_bytes, 0);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn queue_read_budget_concurrent_shrink_and_drop_preserve_shared_capacity() {
let budget = Arc::new(QueueReadBudget::new(64, 16));
let barrier = Arc::new(tokio::sync::Barrier::new(16));
let mut tasks = tokio::task::JoinSet::new();
for _ in 0..16 {
let budget = Arc::clone(&budget);
let barrier = Arc::clone(&barrier);
tasks.spawn(async move {
barrier.wait().await;
for _ in 0..16 {
let (count, mut reservation) = budget.reserve(128, 8).await.unwrap();
assert_eq!(count, 2);
assert!(budget.snapshot().reserved_bytes <= 64);
reservation.observe_entries(
&[RuntimeQueueEntry {
id: "1-0".to_string(),
fields: BTreeMap::from([(
"payload".to_string(),
"12345678".to_string(),
)]),
}],
8,
);
tokio::task::yield_now().await;
drop(reservation);
}
});
}
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while let Some(result) = tasks.join_next().await {
result.expect("reservation task");
}
})
.await
.expect("all shared reservations complete");
assert_eq!(budget.snapshot().reserved_bytes, 0);
assert_eq!(budget.snapshot().waiters, 0);
assert_eq!(budget.snapshot().actual_field_bytes_total, 16 * 16 * 15);
assert_eq!(budget.permits.available_permits(), 64);
}
#[tokio::test]
async fn queue_read_budget_large_values_cannot_overflow_or_wait_for_impossible_permits() {
let budget = Arc::new(QueueReadBudget::new(usize::MAX, usize::MAX));
let maximum = maximum_budget_bytes();
assert_eq!(budget.snapshot().limit_bytes, maximum);
let (count, reservation) = budget.reserve(usize::MAX, 1).await.unwrap();
assert_eq!(count, maximum);
assert_eq!(budget.snapshot().reserved_bytes, maximum);
drop(reservation);
assert!(matches!(
budget.reserve(1, maximum + 1).await,
Err(DataLayerError::InvalidConfiguration(_))
));
assert!(matches!(
budget.reserve(1, 0).await,
Err(DataLayerError::InvalidConfiguration(_))
));
let small_batch = Arc::new(QueueReadBudget::new(32, 4));
let (count, reservation) = small_batch.reserve(usize::MAX, 16).await.unwrap();
assert_eq!(count, 1);
assert_eq!(small_batch.snapshot().reserved_bytes, 16);
drop(reservation);
}
#[test]
fn queue_read_budget_env_uses_positive_defaults_and_caps_extreme_values() {
for raw in [None, Some(""), Some("0"), Some("-1"), Some("bad")] {
assert_eq!(configured_bytes(raw, 128), 128);
}
assert_eq!(configured_bytes(Some(" 42 "), 128), 42);
assert_eq!(
configured_bytes(Some(&u128::MAX.to_string()), 128),
maximum_budget_bytes()
);
}
}