Skip to main content

molframe_core/execution/
batch.rs

1//! Pull-based batches with explicit demand and backpressure.
2
3use super::memory::BatchGuard;
4use super::{ExecutionContext, MemoryBudgetError, MemoryReservation};
5use std::sync::{Arc, Mutex};
6
7/// A bounded unit of columnar or row-oriented work.
8pub trait Batch {
9    /// Number of logical rows in this batch.
10    fn rows(&self) -> usize;
11
12    /// Bytes retained while this batch remains alive.
13    fn retained_bytes(&self) -> usize;
14
15    /// Reports whether this batch contains no logical rows.
16    fn is_empty(&self) -> bool {
17        self.rows() == 0
18    }
19}
20
21/// A batch whose retained bytes are charged to an execution context.
22///
23/// The charge is released only when the lease is dropped, keeping accounting
24/// aligned with the batch's actual lifetime.
25pub struct BatchLease<B: Batch> {
26    batch: LeaseBatch<B>,
27    batch_guard: Option<BatchGuard>,
28    reservation: Option<MemoryReservation>,
29    recycler: Option<Arc<BatchBufferPool<B>>>,
30}
31
32enum LeaseBatch<B> {
33    Owned(B),
34    Recyclable(Arc<B>),
35}
36
37impl<B: Batch> BatchLease<B> {
38    /// Charges the batch's retained bytes and creates a lease.
39    ///
40    /// # Errors
41    ///
42    /// Returns [`MemoryBudgetError::Exhausted`] when existing live batches and
43    /// this batch would exceed the context budget.
44    pub fn try_new(batch: B, context: &ExecutionContext) -> Result<Self, MemoryBudgetError> {
45        let reservation = context.try_reserve(batch.retained_bytes())?;
46        let batch_guard = reservation.begin_batch();
47        Ok(Self {
48            batch: LeaseBatch::Owned(batch),
49            batch_guard: Some(batch_guard),
50            reservation: Some(reservation),
51            recycler: None,
52        })
53    }
54
55    /// Creates a lease from capacity reserved before consuming input.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`MemoryBudgetError::Exhausted`] if the batch retained more than
60    /// the producer reserved before advancing its source.
61    pub fn try_from_reservation(
62        batch: B,
63        mut reservation: MemoryReservation,
64    ) -> Result<Self, MemoryBudgetError> {
65        let retained = batch.retained_bytes();
66        if retained > reservation.bytes() {
67            drop(batch);
68            return Err(MemoryBudgetError::Exhausted {
69                requested: retained,
70                available: reservation.bytes(),
71            });
72        }
73        reservation.shrink_to(retained);
74        let batch_guard = reservation.begin_batch();
75        Ok(Self {
76            batch: LeaseBatch::Owned(batch),
77            batch_guard: Some(batch_guard),
78            reservation: Some(reservation),
79            recycler: None,
80        })
81    }
82
83    /// Creates a lease that returns its batch capacity and memory charge to a
84    /// bounded pool when dropped. The producer transfers exclusive ownership;
85    /// consumers share `Arc<BatchLease<B>>` so the charge cannot be detached.
86    ///
87    /// # Errors
88    ///
89    /// Returns [`MemoryBudgetError::Exhausted`] if the batch retained more than
90    /// the producer reserved before advancing its source.
91    pub fn try_from_reservation_recycling(
92        batch: B,
93        mut reservation: MemoryReservation,
94        recycler: Arc<BatchBufferPool<B>>,
95    ) -> Result<Self, MemoryBudgetError> {
96        let retained = batch.retained_bytes();
97        if retained > reservation.bytes() {
98            drop(batch);
99            return Err(MemoryBudgetError::Exhausted {
100                requested: retained,
101                available: reservation.bytes(),
102            });
103        }
104        reservation.shrink_to(retained);
105        let batch_guard = reservation.begin_batch();
106        Ok(Self {
107            batch: LeaseBatch::Recyclable(Arc::new(batch)),
108            batch_guard: Some(batch_guard),
109            reservation: Some(reservation),
110            recycler: Some(recycler),
111        })
112    }
113
114    /// Borrows the retained batch.
115    #[must_use]
116    pub fn batch(&self) -> &B {
117        match &self.batch {
118            LeaseBatch::Owned(batch) => batch,
119            LeaseBatch::Recyclable(batch) => batch,
120        }
121    }
122}
123
124impl<B: Batch> Drop for BatchLease<B> {
125    fn drop(&mut self) {
126        drop(self.batch_guard.take());
127        let Some(recycler) = &self.recycler else {
128            return;
129        };
130        let Some(reservation) = self.reservation.take() else {
131            return;
132        };
133        let LeaseBatch::Recyclable(batch) = &self.batch else {
134            return;
135        };
136        self.reservation = recycler.put_or_return(Arc::clone(batch), reservation);
137    }
138}
139
140impl<B: Batch + std::fmt::Debug> std::fmt::Debug for BatchLease<B> {
141    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        formatter
143            .debug_struct("BatchLease")
144            .field("batch", self.batch())
145            .field("recycling", &self.recycler.is_some())
146            .finish_non_exhaustive()
147    }
148}
149
150/// One-slot pool that keeps a reusable batch and its memory charge together.
151///
152/// The slot is intentionally singular: concurrent consumers may hold several
153/// leases, but an idle source never retains capacity proportional to their
154/// count. Dropping the pool releases both the allocation and its accounting.
155#[derive(Debug)]
156pub struct BatchBufferPool<B: Batch> {
157    slot: Mutex<Option<(Arc<B>, MemoryReservation)>>,
158}
159
160impl<B: Batch> BatchBufferPool<B> {
161    /// Creates an empty recycling slot.
162    #[must_use]
163    pub const fn new() -> Self {
164        Self {
165            slot: Mutex::new(None),
166        }
167    }
168
169    /// Takes exclusive ownership of the reusable batch and its live charge.
170    ///
171    /// Returns `None` while the previous lease is finishing destruction.
172    /// No raw shared backing owner escapes the pool; consumers share the lease.
173    pub fn take(&self) -> Option<(B, MemoryReservation)> {
174        let Ok(mut slot) = self.slot.lock() else {
175            return None;
176        };
177        if slot
178            .as_ref()
179            .is_some_and(|(batch, _)| Arc::strong_count(batch) != 1)
180        {
181            return None;
182        }
183        let (batch, reservation) = slot.take()?;
184        match Arc::try_unwrap(batch) {
185            Ok(batch) => Some((batch, reservation)),
186            Err(batch) => {
187                *slot = Some((batch, reservation));
188                None
189            }
190        }
191    }
192
193    /// Returns an exclusively owned reusable batch and its charge. An occupied
194    /// slot drops the new buffer before releasing its reservation.
195    ///
196    /// # Errors
197    ///
198    /// Rejects capacity larger than its reservation before retaining the batch.
199    pub fn put(&self, batch: B, reservation: MemoryReservation) -> Result<(), MemoryBudgetError> {
200        let retained = batch.retained_bytes();
201        if retained > reservation.bytes() {
202            drop(batch);
203            return Err(MemoryBudgetError::Exhausted {
204                requested: retained,
205                available: reservation.bytes(),
206            });
207        }
208        drop(self.put_or_return(Arc::new(batch), reservation));
209        Ok(())
210    }
211
212    // A rejected lease still owns another Arc. Return its charge so that the
213    // lease drops the backing buffer before releasing the reservation.
214    fn put_or_return(
215        &self,
216        batch: Arc<B>,
217        reservation: MemoryReservation,
218    ) -> Option<MemoryReservation> {
219        if let Ok(mut slot) = self.slot.lock()
220            && slot.is_none()
221        {
222            *slot = Some((batch, reservation));
223            return None;
224        }
225        drop(batch);
226        Some(reservation)
227    }
228}
229
230impl<B: Batch> Default for BatchBufferPool<B> {
231    fn default() -> Self {
232        Self::new()
233    }
234}
235
236/// Maximum work a consumer is prepared to accept from one pull.
237#[derive(Clone, Copy, Debug, PartialEq, Eq)]
238pub struct BatchDemand {
239    /// Maximum logical rows accepted in the next batch.
240    pub max_rows: usize,
241    /// Maximum retained bytes accepted in the next batch.
242    pub max_bytes: usize,
243}
244
245impl BatchDemand {
246    /// Creates an explicit demand. Zero in either dimension means no work can
247    /// currently be accepted and producers should report backpressure.
248    #[must_use]
249    pub const fn new(max_rows: usize, max_bytes: usize) -> Self {
250        Self {
251            max_rows,
252            max_bytes,
253        }
254    }
255
256    /// Reports whether the consumer currently has capacity for work.
257    #[must_use]
258    pub const fn can_accept_work(self) -> bool {
259        self.max_rows > 0 && self.max_bytes > 0
260    }
261
262    /// Reports whether a batch satisfies this demand.
263    #[must_use]
264    pub fn accepts<B: Batch>(self, batch: &B) -> bool {
265        batch.rows() <= self.max_rows && batch.retained_bytes() <= self.max_bytes
266    }
267}
268
269/// Outcome of one non-blocking batch pull.
270#[derive(Debug)]
271pub enum Backpressure<B> {
272    /// One bounded batch is ready for immediate consumption.
273    Ready(B),
274    /// The producer cannot currently satisfy the supplied demand.
275    Pending,
276    /// The producer is exhausted and will never yield another batch.
277    Finished,
278}
279
280/// A pull source whose live workspace is governed by an [`ExecutionContext`].
281///
282/// Implementations must not return a batch exceeding `demand`. If memory is
283/// temporarily unavailable, they return [`Backpressure::Pending`] without
284/// blocking or allocating an unbounded queue.
285pub trait BatchSource {
286    /// Batch representation produced by this source.
287    type Batch: Batch;
288    /// Format, I/O, or computation error produced by this source.
289    type Error;
290
291    /// Attempts to produce one bounded batch.
292    ///
293    /// # Errors
294    ///
295    /// Returns the source-specific failure without consuming future input that
296    /// could not be represented in the result.
297    fn next_batch(
298        &mut self,
299        demand: BatchDemand,
300        context: &ExecutionContext,
301    ) -> Result<Backpressure<BatchLease<Self::Batch>>, Self::Error>;
302}
303
304#[cfg(test)]
305#[path = "batch_tests.rs"]
306mod tests;