Skip to content

[RFC]feat(pool): add with_pool() to Arrow builders for real-time memory tracking - #1

Draft
wirybeaver wants to merge 1 commit into
mainfrom
xuanyili/withpool
Draft

wirybeaver wants to merge 1 commit into
mainfrom
xuanyili/withpool

Conversation

@wirybeaver

@wirybeaver wirybeaver commented Jun 9, 2026 •

Copy link
Copy Markdown
Owner

Motivation

Arrow builders (PrimitiveBuilder, GenericByteBuilder, GenericListBuilder) allocate internally via Vec<T>, which is invisible to MemoryPool. Today the only way to associate a pool with an array is:

// Post-hoc: pool is updated AFTER the array is built, not during.
let array = builder.finish();
let buffer = array.into_data().buffers()[0].clone();
buffer.claim(&pool);  // reservation happens here, too late to enforce limits

This has two problems:

  1. No back-pressure: the pool cannot reject an allocation that already happened inside Vec::push.
  2. Inaccurate accounting: memory spikes during building are invisible to the pool.

This PR adds with_pool() to the key builders so that every internal Vec growth is tracked (and optionally rejected) through a MemoryPool in real time.


Design

TrackedVec<T> — zero-overhead Vec wrapper

A new pub(crate) struct TrackedVec<T> wraps Vec<T> and holds an optional Box<dyn MemoryReservation>. On every capacity-changing operation it calls reservation.resize(new_bytes). When no pool is attached it is a plain Vec with no overhead.

TrackedVec<T>                on finish()
┌──────────────┐   into()   ┌────────────────┐   into_buffer()   ┌──────────┐   drop
│ inner: Vec   │ ──────────►│ MutableBuffer  │ ────────────────► │  Buffer  │ ────── pool released
│ reservation ─┼──transfer─►│  reservation   │                   │          │
└──────────────┘            └────────────────┘                   └──────────┘

The reservation transfers through MutableBuffer::set_reservation() (new) → MutableBuffer::into_buffer() (existing) → Bytes::reservation → freed on Buffer drop.

Fallible allocation

MemoryPool::try_reserve() and MemoryReservation::try_resize() are added with default infallible implementations, so existing pool implementations don't need to change. Limit-enforcing pools (like DataFusion's GreedyMemoryPool) override these to return Err when capacity is exceeded.

A new MemoryAllocationError type is defined in arrow-buffer to avoid adding an arrow-schema dependency there.


API

PrimitiveBuilder

// Attach a pool to track all buffer growth.
let pool = TrackingMemoryPool::default();
let mut builder = Int32Builder::new()
    .with_pool(&pool)?;

// Infallible append — unchanged API, panics only on OOM.
builder.append_value(42);

// Fallible append — returns Err if pool is exhausted.
builder.try_append_value(42)?;

// finish() transfers the reservation into the array's buffer.
let array = builder.finish();
// pool.used() > 0  ← reservation is alive until the array is dropped
drop(array);
// pool.used() == 0  ← RAII cleanup

GenericByteBuilder / StringBuilder

let pool = TrackingMemoryPool::default();
let mut builder = StringBuilder::new()
    .with_pool(&pool)?;          // tracks both value and offset buffers

builder.append_value("hello");
builder.try_append_value("world")?;  // fallible
let array = builder.finish();

GenericListBuilder with child propagation

let pool = TrackingMemoryPool::default();
let values = Int32Builder::new();
let mut builder = ListBuilder::new(values)
    .with_pool(&pool)?;   // propagates pool to child via WithPool trait

builder.values().append_value(1);
builder.append(true);
let array = builder.finish();

DataFusion Integration Example

DataFusion's GreedyMemoryPool enforces hard limits. Here is how it would plug into Arrow builders to get real-time enforcement during sort/aggregate spilling:

use arrow_array::builder::{Int32Builder, WithPool};
use arrow_buffer::{MemoryPool, MemoryReservation, MemoryAllocationError};
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};

/// A hard-limit pool that rejects allocations once capacity is reached.
#[derive(Debug)]
struct GreedyMemoryPool {
    used: Arc<AtomicUsize>,
    capacity: usize,
}

impl MemoryPool for GreedyMemoryPool {
    fn reserve(&self, size: usize) -> Box<dyn MemoryReservation> {
        // Infallible path: just track, never reject (for backward compat).
        self.used.fetch_add(size, Ordering::Relaxed);
        Box::new(GreedyReservation { size, used: Arc::clone(&self.used) })
    }

    fn try_reserve(&self, size: usize) -> Result<Box<dyn MemoryReservation>, MemoryAllocationError> {
        // Fallible path: enforce the capacity limit.
        let mut current = self.used.load(Ordering::Relaxed);
        loop {
            if current + size > self.capacity {
                return Err(MemoryAllocationError(format!(
                    "GreedyMemoryPool exhausted: {} + {} > {}",
                    current, size, self.capacity
                )));
            }
            match self.used.compare_exchange_weak(
                current, current + size, Ordering::Relaxed, Ordering::Relaxed,
            ) {
                Ok(_) => return Ok(Box::new(GreedyReservation { size, used: Arc::clone(&self.used) })),
                Err(actual) => current = actual,
            }
        }
    }

    fn available(&self) -> isize { self.capacity as isize - self.used() as isize }
    fn used(&self) -> usize { self.used.load(Ordering::Relaxed) }
    fn capacity(&self) -> usize { self.capacity }
}

// In an operator that builds an output batch under memory pressure:
fn build_batch_with_limit(
    pool: &GreedyMemoryPool,
    rows: impl Iterator<Item = i32>,
) -> Result<arrow_array::Int32Array, arrow_schema::ArrowError> {
    let mut builder = Int32Builder::with_capacity(1024)
        .with_pool(pool)?;

    for row in rows {
        match builder.try_append_value(row) {
            Ok(()) => {}
            Err(_oom) => {
                // Pool exhausted — flush what we have and signal to the
                // caller that spilling is needed.
                break;
            }
        }
    }

    Ok(builder.finish())
    // Reservation transfers into the returned array's buffer;
    // it is freed automatically when the batch is dropped.
}

Files Changed

File Change
arrow-buffer/src/pool.rs MemoryAllocationError; try_reserve() on MemoryPool; try_resize() on MemoryReservation
arrow-buffer/src/buffer/mutable.rs with_pool(), set_reservation()
arrow-buffer/src/builder/boolean.rs BooleanBufferBuilder::with_pool()
arrow-buffer/src/builder/null.rs NullBufferBuilder::with_pool()
arrow-array/src/builder/tracked_vec.rs New: TrackedVec<T>
arrow-array/src/builder/mod.rs WithPool trait
arrow-array/src/builder/primitive_builder.rs with_pool(), try_append_value()
arrow-array/src/builder/generic_bytes_builder.rs with_pool(), try_append_value()
arrow-array/src/builder/generic_list_builder.rs with_pool() with child propagation

All new APIs are gated under #[cfg(feature = "pool")]; the default non-pool build is completely unchanged.

…acking

Adds pool-backed memory tracking to PrimitiveBuilder, GenericByteBuilder,
and GenericListBuilder so every buffer growth is accounted for in a
MemoryPool as it happens, rather than post-hoc via buffer.claim(pool).

Key additions:
- TrackedVec<T>: Vec<T> wrapper that syncs a MemoryReservation on every
  capacity change; transfers reservation into MutableBuffer on finish()
- MemoryAllocationError + try_reserve()/try_resize() on pool traits for
  fallible allocation with default infallible impls (backward-compatible)
- MutableBuffer::with_pool() and set_reservation() to support reservation
  transfer across crate boundaries
- BooleanBufferBuilder::with_pool() and NullBufferBuilder::with_pool()
- WithPool trait for generic pool propagation through GenericListBuilder
- try_append_value() on PrimitiveBuilder and GenericByteBuilder for
  fallible appends that respect pool limits

All new APIs are gated under #[cfg(feature = "pool")]; the non-pool code
path is unchanged and compiles without overhead.
@wirybeaver wirybeaver changed the title feat(pool): add with_pool() to Arrow builders for real-time memory tracking [RFC]feat(pool): add with_pool() to Arrow builders for real-time memory tracking Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant