krishiv_sql/
unspillable_headroom.rs1use std::sync::{Arc, Mutex};
52
53use datafusion::execution::memory_pool::{MemoryPool, MemoryReservation};
54
55pub const DEFAULT_UNSPILLABLE_HEADROOM_NUMERATOR: usize = 1;
63pub const DEFAULT_UNSPILLABLE_HEADROOM_DENOMINATOR: usize = 4;
65
66pub const UNSPILLABLE_HEADROOM_PERCENT_ENV: &str = "KRISHIV_UNSPILLABLE_HEADROOM_PERCENT";
70
71#[must_use]
73pub fn headroom_bytes(pool_size: usize) -> usize {
74 let percent = std::env::var(UNSPILLABLE_HEADROOM_PERCENT_ENV)
75 .ok()
76 .and_then(|v| v.trim().parse::<usize>().ok())
77 .filter(|p| *p <= 100);
78 match percent {
79 Some(p) => pool_size / 100 * p,
80 None => {
81 pool_size / DEFAULT_UNSPILLABLE_HEADROOM_DENOMINATOR
82 * DEFAULT_UNSPILLABLE_HEADROOM_NUMERATOR
83 }
84 }
85}
86
87#[derive(Debug)]
89pub struct UnspillableHeadroomPool {
90 inner: Arc<dyn MemoryPool>,
91 spillable_ceiling: usize,
93 spillable_used: Mutex<usize>,
96 pool_size: usize,
97}
98
99impl UnspillableHeadroomPool {
100 #[must_use]
107 pub fn new(inner: Arc<dyn MemoryPool>, pool_size: usize, headroom: usize) -> Self {
108 let spillable_ceiling = if headroom == 0 || headroom >= pool_size {
109 pool_size
110 } else {
111 pool_size - headroom
112 };
113 Self {
114 inner,
115 spillable_ceiling,
116 spillable_used: Mutex::new(0),
117 pool_size,
118 }
119 }
120
121 #[must_use]
123 pub fn spillable_ceiling(&self) -> usize {
124 self.spillable_ceiling
125 }
126
127 fn add_spillable(&self, additional: usize) {
128 if let Ok(mut used) = self.spillable_used.lock() {
129 *used = used.saturating_add(additional);
130 }
131 }
132
133 fn sub_spillable(&self, shrink: usize) {
134 if let Ok(mut used) = self.spillable_used.lock() {
135 *used = used.saturating_sub(shrink);
136 }
137 }
138}
139
140impl std::fmt::Display for UnspillableHeadroomPool {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 write!(
146 f,
147 "fair+unspillable-headroom(pool_size: {}, spillable_ceiling: {})",
148 human_bytes(self.pool_size),
149 human_bytes(self.spillable_ceiling)
150 )
151 }
152}
153
154impl MemoryPool for UnspillableHeadroomPool {
155 fn name(&self) -> &str {
156 "fair+unspillable-headroom"
157 }
158
159 fn register(&self, consumer: &datafusion::execution::memory_pool::MemoryConsumer) {
160 self.inner.register(consumer);
161 }
162
163 fn unregister(&self, consumer: &datafusion::execution::memory_pool::MemoryConsumer) {
164 self.inner.unregister(consumer);
165 }
166
167 fn grow(&self, reservation: &MemoryReservation, additional: usize) {
168 if reservation.consumer().can_spill() {
171 self.add_spillable(additional);
172 }
173 self.inner.grow(reservation, additional);
174 }
175
176 fn shrink(&self, reservation: &MemoryReservation, shrink: usize) {
177 if reservation.consumer().can_spill() {
178 self.sub_spillable(shrink);
179 }
180 self.inner.shrink(reservation, shrink);
181 }
182
183 fn try_grow(&self, reservation: &MemoryReservation, additional: usize) -> datafusion::error::Result<()> {
184 if !reservation.consumer().can_spill() {
185 return self.inner.try_grow(reservation, additional);
186 }
187 let Ok(mut used) = self.spillable_used.lock() else {
191 return self.inner.try_grow(reservation, additional);
192 };
193 let requested = used.saturating_add(additional);
194 if requested > self.spillable_ceiling {
195 return Err(datafusion::error::DataFusionError::ResourcesExhausted(format!(
196 "spillable consumers are capped at {} of the {} pool so that operators \
197 which cannot spill (hash join build sides) keep a usable floor; \
198 '{}' asked for {additional} more with {} already held across all \
199 spillable consumers. This consumer should spill. Set {}=0 to \
200 restore unbounded fair-share behaviour.",
201 human_bytes(self.spillable_ceiling),
202 human_bytes(self.pool_size),
203 reservation.consumer().name(),
204 human_bytes(*used),
205 UNSPILLABLE_HEADROOM_PERCENT_ENV,
206 )));
207 }
208 self.inner.try_grow(reservation, additional)?;
209 *used = requested;
210 Ok(())
211 }
212
213 fn reserved(&self) -> usize {
214 self.inner.reserved()
215 }
216}
217
218fn human_bytes(bytes: usize) -> String {
219 const MIB: usize = 1024 * 1024;
220 if bytes >= MIB {
221 format!("{:.1} MiB", bytes as f64 / MIB as f64)
222 } else {
223 format!("{bytes} B")
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use datafusion::execution::memory_pool::{FairSpillPool, MemoryConsumer};
231
232 fn pool(size: usize, headroom: usize) -> Arc<dyn MemoryPool> {
233 Arc::new(UnspillableHeadroomPool::new(
234 Arc::new(FairSpillPool::new(size)),
235 size,
236 headroom,
237 ))
238 }
239
240 #[test]
247 fn a_spiller_cannot_starve_a_consumer_that_cannot_spill() {
248 const SIZE: usize = 1024 * 1024;
249
250 let bare: Arc<dyn MemoryPool> = Arc::new(FairSpillPool::new(SIZE));
252 let spiller = MemoryConsumer::new("ShuffleWriteBuffer")
253 .with_can_spill(true)
254 .register(&bare);
255 spiller.try_grow(SIZE).expect("the only spiller may take it all");
256 let join = MemoryConsumer::new("HashJoinInput").register(&bare);
257 let error = join
258 .try_grow(877)
259 .expect_err("this is the q10/q11 failure and it must reproduce");
260 assert!(
261 error.to_string().contains("HashJoinInput"),
262 "got: {error}"
263 );
264
265 let guarded = pool(SIZE, SIZE / 4);
267 let spiller = MemoryConsumer::new("ShuffleWriteBuffer")
268 .with_can_spill(true)
269 .register(&guarded);
270 let error = spiller
271 .try_grow(SIZE)
272 .expect_err("a spiller must not be able to take the whole pool");
273 assert!(
274 error.to_string().contains("cannot spill"),
275 "the refusal must say why, got: {error}"
276 );
277 spiller
278 .try_grow(SIZE / 4 * 3)
279 .expect("up to the ceiling is still allowed");
280 let join = MemoryConsumer::new("HashJoinInput").register(&guarded);
281 join.try_grow(877)
282 .expect("the headroom exists precisely for this");
283 }
284
285 #[test]
288 fn the_ceiling_bounds_spillers_in_aggregate_not_individually() {
289 const SIZE: usize = 1024 * 1024;
290 let guarded = pool(SIZE, SIZE / 4);
291 let mut held = Vec::new();
292 for i in 0..4 {
293 let c = MemoryConsumer::new(format!("spiller{i}"))
294 .with_can_spill(true)
295 .register(&guarded);
296 if i < 3 {
298 c.try_grow(SIZE / 4).expect("within the ceiling");
299 } else {
300 c.try_grow(SIZE / 4)
301 .expect_err("the fourth quarter crosses the ceiling");
302 }
303 held.push(c);
304 }
305 let join = MemoryConsumer::new("HashJoinInput").register(&guarded);
306 join.try_grow(SIZE / 8).expect("headroom is intact");
307 }
308
309 #[test]
312 fn shrinking_returns_capacity_to_the_spillable_budget() {
313 const SIZE: usize = 1024 * 1024;
314 let guarded = pool(SIZE, SIZE / 4);
315 let spiller = MemoryConsumer::new("s")
316 .with_can_spill(true)
317 .register(&guarded);
318 spiller.try_grow(SIZE / 4 * 3).expect("fills the ceiling");
319 spiller
320 .try_grow(1)
321 .expect_err("nothing left under the ceiling");
322 spiller.shrink(SIZE / 2); spiller
324 .try_grow(SIZE / 4)
325 .expect("capacity came back after spilling");
326 }
327
328 #[test]
338 fn both_bounded_engine_memories_install_the_guard() {
339 const SIZE: usize = 1024 * 1024;
340 for (label, pool) in [
341 ("Private", crate::EngineMemory::Private(SIZE).pool()),
342 ("Shared", Some(crate::EngineMemory::shared_pool(SIZE))),
343 ] {
344 let pool = pool.unwrap_or_else(|| panic!("{label} must install a pool"));
345 assert_eq!(
346 pool.name(),
347 "fair+unspillable-headroom",
348 "{label} installed an unguarded pool"
349 );
350 let spiller = MemoryConsumer::new("s").with_can_spill(true).register(&pool);
353 assert!(
354 spiller.try_grow(SIZE).is_err(),
355 "{label}: a lone spiller took the entire pool, so the guard is absent"
356 );
357 spiller
358 .try_grow(SIZE / 4 * 3)
359 .unwrap_or_else(|e| panic!("{label}: the ceiling itself must be reachable — {e}"));
360 let join = MemoryConsumer::new("HashJoinInput").register(&pool);
361 join.try_grow(877)
362 .unwrap_or_else(|e| panic!("{label}: headroom absent — {e}"));
363 }
364 }
365
366 #[test]
369 fn zero_headroom_delegates_unchanged() {
370 const SIZE: usize = 1024 * 1024;
371 let guarded = pool(SIZE, 0);
372 let spiller = MemoryConsumer::new("s")
373 .with_can_spill(true)
374 .register(&guarded);
375 spiller
376 .try_grow(SIZE)
377 .expect("with no headroom a lone spiller may still take everything");
378 }
379
380 #[test]
382 fn absurd_headroom_does_not_deadlock_every_spiller() {
383 const SIZE: usize = 1024 * 1024;
384 let guarded = pool(SIZE, SIZE * 4);
385 let spiller = MemoryConsumer::new("s")
386 .with_can_spill(true)
387 .register(&guarded);
388 spiller.try_grow(SIZE).expect("ceiling disabled, not zeroed");
389 }
390}