use std::sync::{Arc, Mutex};
use datafusion::execution::memory_pool::{MemoryPool, MemoryReservation};
pub const DEFAULT_UNSPILLABLE_HEADROOM_NUMERATOR: usize = 1;
pub const DEFAULT_UNSPILLABLE_HEADROOM_DENOMINATOR: usize = 4;
pub const UNSPILLABLE_HEADROOM_PERCENT_ENV: &str = "KRISHIV_UNSPILLABLE_HEADROOM_PERCENT";
#[must_use]
pub fn headroom_bytes(pool_size: usize) -> usize {
let percent = std::env::var(UNSPILLABLE_HEADROOM_PERCENT_ENV)
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|p| *p <= 100);
match percent {
Some(p) => pool_size / 100 * p,
None => {
pool_size / DEFAULT_UNSPILLABLE_HEADROOM_DENOMINATOR
* DEFAULT_UNSPILLABLE_HEADROOM_NUMERATOR
}
}
}
#[derive(Debug)]
pub struct UnspillableHeadroomPool {
inner: Arc<dyn MemoryPool>,
spillable_ceiling: usize,
spillable_used: Mutex<usize>,
pool_size: usize,
}
impl UnspillableHeadroomPool {
#[must_use]
pub fn new(inner: Arc<dyn MemoryPool>, pool_size: usize, headroom: usize) -> Self {
let spillable_ceiling = if headroom == 0 || headroom >= pool_size {
pool_size
} else {
pool_size - headroom
};
Self {
inner,
spillable_ceiling,
spillable_used: Mutex::new(0),
pool_size,
}
}
#[must_use]
pub fn spillable_ceiling(&self) -> usize {
self.spillable_ceiling
}
fn add_spillable(&self, additional: usize) {
if let Ok(mut used) = self.spillable_used.lock() {
*used = used.saturating_add(additional);
}
}
fn sub_spillable(&self, shrink: usize) {
if let Ok(mut used) = self.spillable_used.lock() {
*used = used.saturating_sub(shrink);
}
}
}
impl std::fmt::Display for UnspillableHeadroomPool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"fair+unspillable-headroom(pool_size: {}, spillable_ceiling: {})",
human_bytes(self.pool_size),
human_bytes(self.spillable_ceiling)
)
}
}
impl MemoryPool for UnspillableHeadroomPool {
fn name(&self) -> &str {
"fair+unspillable-headroom"
}
fn register(&self, consumer: &datafusion::execution::memory_pool::MemoryConsumer) {
self.inner.register(consumer);
}
fn unregister(&self, consumer: &datafusion::execution::memory_pool::MemoryConsumer) {
self.inner.unregister(consumer);
}
fn grow(&self, reservation: &MemoryReservation, additional: usize) {
if reservation.consumer().can_spill() {
self.add_spillable(additional);
}
self.inner.grow(reservation, additional);
}
fn shrink(&self, reservation: &MemoryReservation, shrink: usize) {
if reservation.consumer().can_spill() {
self.sub_spillable(shrink);
}
self.inner.shrink(reservation, shrink);
}
fn try_grow(&self, reservation: &MemoryReservation, additional: usize) -> datafusion::error::Result<()> {
if !reservation.consumer().can_spill() {
return self.inner.try_grow(reservation, additional);
}
let Ok(mut used) = self.spillable_used.lock() else {
return self.inner.try_grow(reservation, additional);
};
let requested = used.saturating_add(additional);
if requested > self.spillable_ceiling {
return Err(datafusion::error::DataFusionError::ResourcesExhausted(format!(
"spillable consumers are capped at {} of the {} pool so that operators \
which cannot spill (hash join build sides) keep a usable floor; \
'{}' asked for {additional} more with {} already held across all \
spillable consumers. This consumer should spill. Set {}=0 to \
restore unbounded fair-share behaviour.",
human_bytes(self.spillable_ceiling),
human_bytes(self.pool_size),
reservation.consumer().name(),
human_bytes(*used),
UNSPILLABLE_HEADROOM_PERCENT_ENV,
)));
}
self.inner.try_grow(reservation, additional)?;
*used = requested;
Ok(())
}
fn reserved(&self) -> usize {
self.inner.reserved()
}
}
fn human_bytes(bytes: usize) -> String {
const MIB: usize = 1024 * 1024;
if bytes >= MIB {
format!("{:.1} MiB", bytes as f64 / MIB as f64)
} else {
format!("{bytes} B")
}
}
#[cfg(test)]
mod tests {
use super::*;
use datafusion::execution::memory_pool::{FairSpillPool, MemoryConsumer};
fn pool(size: usize, headroom: usize) -> Arc<dyn MemoryPool> {
Arc::new(UnspillableHeadroomPool::new(
Arc::new(FairSpillPool::new(size)),
size,
headroom,
))
}
#[test]
fn a_spiller_cannot_starve_a_consumer_that_cannot_spill() {
const SIZE: usize = 1024 * 1024;
let bare: Arc<dyn MemoryPool> = Arc::new(FairSpillPool::new(SIZE));
let spiller = MemoryConsumer::new("ShuffleWriteBuffer")
.with_can_spill(true)
.register(&bare);
spiller.try_grow(SIZE).expect("the only spiller may take it all");
let join = MemoryConsumer::new("HashJoinInput").register(&bare);
let error = join
.try_grow(877)
.expect_err("this is the q10/q11 failure and it must reproduce");
assert!(
error.to_string().contains("HashJoinInput"),
"got: {error}"
);
let guarded = pool(SIZE, SIZE / 4);
let spiller = MemoryConsumer::new("ShuffleWriteBuffer")
.with_can_spill(true)
.register(&guarded);
let error = spiller
.try_grow(SIZE)
.expect_err("a spiller must not be able to take the whole pool");
assert!(
error.to_string().contains("cannot spill"),
"the refusal must say why, got: {error}"
);
spiller
.try_grow(SIZE / 4 * 3)
.expect("up to the ceiling is still allowed");
let join = MemoryConsumer::new("HashJoinInput").register(&guarded);
join.try_grow(877)
.expect("the headroom exists precisely for this");
}
#[test]
fn the_ceiling_bounds_spillers_in_aggregate_not_individually() {
const SIZE: usize = 1024 * 1024;
let guarded = pool(SIZE, SIZE / 4);
let mut held = Vec::new();
for i in 0..4 {
let c = MemoryConsumer::new(format!("spiller{i}"))
.with_can_spill(true)
.register(&guarded);
if i < 3 {
c.try_grow(SIZE / 4).expect("within the ceiling");
} else {
c.try_grow(SIZE / 4)
.expect_err("the fourth quarter crosses the ceiling");
}
held.push(c);
}
let join = MemoryConsumer::new("HashJoinInput").register(&guarded);
join.try_grow(SIZE / 8).expect("headroom is intact");
}
#[test]
fn shrinking_returns_capacity_to_the_spillable_budget() {
const SIZE: usize = 1024 * 1024;
let guarded = pool(SIZE, SIZE / 4);
let spiller = MemoryConsumer::new("s")
.with_can_spill(true)
.register(&guarded);
spiller.try_grow(SIZE / 4 * 3).expect("fills the ceiling");
spiller
.try_grow(1)
.expect_err("nothing left under the ceiling");
spiller.shrink(SIZE / 2); spiller
.try_grow(SIZE / 4)
.expect("capacity came back after spilling");
}
#[test]
fn both_bounded_engine_memories_install_the_guard() {
const SIZE: usize = 1024 * 1024;
for (label, pool) in [
("Private", crate::EngineMemory::Private(SIZE).pool()),
("Shared", Some(crate::EngineMemory::shared_pool(SIZE))),
] {
let pool = pool.unwrap_or_else(|| panic!("{label} must install a pool"));
assert_eq!(
pool.name(),
"fair+unspillable-headroom",
"{label} installed an unguarded pool"
);
let spiller = MemoryConsumer::new("s").with_can_spill(true).register(&pool);
assert!(
spiller.try_grow(SIZE).is_err(),
"{label}: a lone spiller took the entire pool, so the guard is absent"
);
spiller
.try_grow(SIZE / 4 * 3)
.unwrap_or_else(|e| panic!("{label}: the ceiling itself must be reachable — {e}"));
let join = MemoryConsumer::new("HashJoinInput").register(&pool);
join.try_grow(877)
.unwrap_or_else(|e| panic!("{label}: headroom absent — {e}"));
}
}
#[test]
fn zero_headroom_delegates_unchanged() {
const SIZE: usize = 1024 * 1024;
let guarded = pool(SIZE, 0);
let spiller = MemoryConsumer::new("s")
.with_can_spill(true)
.register(&guarded);
spiller
.try_grow(SIZE)
.expect("with no headroom a lone spiller may still take everything");
}
#[test]
fn absurd_headroom_does_not_deadlock_every_spiller() {
const SIZE: usize = 1024 * 1024;
let guarded = pool(SIZE, SIZE * 4);
let spiller = MemoryConsumer::new("s")
.with_can_spill(true)
.register(&guarded);
spiller.try_grow(SIZE).expect("ceiling disabled, not zeroed");
}
}