use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
use crate::{DataFrameError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StreamOptions {
pub memory_limit_bytes: u64,
pub max_in_flight_batches: NonZeroUsize,
pub batch_rows: NonZeroUsize,
}
impl StreamOptions {
pub fn new(
memory_limit_bytes: u64,
max_in_flight_batches: NonZeroUsize,
batch_rows: NonZeroUsize,
) -> Self {
Self {
memory_limit_bytes,
max_in_flight_batches,
batch_rows,
}
}
}
impl Default for StreamOptions {
fn default() -> Self {
Self {
memory_limit_bytes: 64 * 1024 * 1024,
max_in_flight_batches: NonZeroUsize::MIN,
batch_rows: NonZeroUsize::new(8_192).expect("8,192 is non-zero"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceScope {
Source,
Decode,
Operator,
Expression,
Output,
MaterializedOutput,
Spill,
}
impl ResourceScope {
pub const fn as_str(self) -> &'static str {
match self {
Self::Source => "source",
Self::Decode => "decode",
Self::Operator => "operator",
Self::Expression => "expression",
Self::Output => "output",
Self::MaterializedOutput => "materialized_output",
Self::Spill => "spill",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResourceUsage {
pub reserved_bytes: u64,
pub reserved_batches: usize,
}
#[derive(Debug)]
struct BudgetState {
usage: ResourceUsage,
}
#[derive(Debug, Clone)]
pub struct ResourceBudget {
memory_limit_bytes: u64,
max_in_flight_batches: usize,
state: Arc<Mutex<BudgetState>>,
}
impl ResourceBudget {
pub fn from_options(options: StreamOptions) -> Self {
Self::new(options.memory_limit_bytes, options.max_in_flight_batches)
}
pub fn new(memory_limit_bytes: u64, max_in_flight_batches: NonZeroUsize) -> Self {
Self {
memory_limit_bytes,
max_in_flight_batches: max_in_flight_batches.get(),
state: Arc::new(Mutex::new(BudgetState {
usage: ResourceUsage {
reserved_bytes: 0,
reserved_batches: 0,
},
})),
}
}
pub fn reserve(&self, scope: ResourceScope, bytes: u64) -> Result<ResourceReservation> {
self.reserve_inner(scope, bytes, 0)
}
pub fn reserve_batch(&self, scope: ResourceScope, bytes: u64) -> Result<ResourceReservation> {
self.reserve_inner(scope, bytes, 1)
}
pub const fn memory_limit_bytes(&self) -> u64 {
self.memory_limit_bytes
}
pub const fn max_in_flight_batches(&self) -> usize {
self.max_in_flight_batches
}
pub fn usage(&self) -> ResourceUsage {
self.state
.lock()
.expect("resource budget mutex poisoned")
.usage
}
fn reserve_inner(
&self,
scope: ResourceScope,
bytes: u64,
batches: usize,
) -> Result<ResourceReservation> {
let mut state = self.state.lock().expect("resource budget mutex poisoned");
let observed_bytes = state.usage.reserved_bytes.saturating_add(bytes);
let observed_batches = state.usage.reserved_batches.saturating_add(batches);
if observed_bytes > self.memory_limit_bytes || observed_batches > self.max_in_flight_batches
{
return Err(DataFrameError::resource_limit_exceeded(
self.memory_limit_bytes,
observed_bytes,
self.max_in_flight_batches,
observed_batches,
scope,
));
}
state.usage = ResourceUsage {
reserved_bytes: observed_bytes,
reserved_batches: observed_batches,
};
Ok(ResourceReservation {
budget: self.clone(),
bytes,
batches,
released: false,
})
}
fn release(&self, bytes: u64, batches: usize) {
let mut state = self.state.lock().expect("resource budget mutex poisoned");
state.usage.reserved_bytes = state.usage.reserved_bytes.saturating_sub(bytes);
state.usage.reserved_batches = state.usage.reserved_batches.saturating_sub(batches);
}
}
#[derive(Debug)]
pub struct ResourceReservation {
budget: ResourceBudget,
bytes: u64,
batches: usize,
released: bool,
}
impl ResourceReservation {
pub fn promote_to_batch(&mut self, scope: ResourceScope) -> Result<()> {
if self.released || self.batches != 0 {
return Ok(());
}
let mut state = self
.budget
.state
.lock()
.expect("resource budget mutex poisoned");
let observed_batches = state.usage.reserved_batches.saturating_add(1);
if observed_batches > self.budget.max_in_flight_batches {
return Err(DataFrameError::resource_limit_exceeded(
self.budget.memory_limit_bytes,
state.usage.reserved_bytes,
self.budget.max_in_flight_batches,
observed_batches,
scope,
));
}
state.usage.reserved_batches = observed_batches;
self.batches = 1;
Ok(())
}
pub fn release(&mut self) {
if !self.released {
self.budget.release(self.bytes, self.batches);
self.released = true;
}
}
pub fn shrink_to(&mut self, bytes: u64) {
let bytes = bytes.min(self.bytes);
if !self.released && bytes < self.bytes {
self.budget.release(self.bytes - bytes, 0);
self.bytes = bytes;
}
}
pub const fn bytes(&self) -> u64 {
self.bytes
}
}
impl Drop for ResourceReservation {
fn drop(&mut self) {
self.release();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamFailureClass {
Unsupported,
ResourceLimit,
Source,
Decode,
Schema,
Cancelled,
Closed,
Internal,
}
impl StreamFailureClass {
pub const fn as_str(self) -> &'static str {
match self {
Self::Unsupported => "unsupported",
Self::ResourceLimit => "resource_limit",
Self::Source => "source",
Self::Decode => "decode",
Self::Schema => "schema",
Self::Cancelled => "cancelled",
Self::Closed => "closed",
Self::Internal => "internal",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamFailure {
pub code: &'static str,
pub classification: StreamFailureClass,
}
impl StreamFailure {
pub const fn new(code: &'static str, classification: StreamFailureClass) -> Self {
Self {
code,
classification,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamTerminal {
Open,
Exhausted,
Closed,
Cancelled,
Failed(StreamFailure),
}
impl StreamTerminal {
pub fn as_error(&self) -> Option<DataFrameError> {
match self {
Self::Open | Self::Exhausted => None,
Self::Closed => Some(DataFrameError::stream_closed()),
Self::Cancelled => Some(DataFrameError::stream_cancelled()),
Self::Failed(failure) => Some(DataFrameError::stream_failed(
failure.code,
failure.classification,
)),
}
}
}
#[derive(Debug, Clone)]
pub struct StreamTerminalState {
terminal: Arc<Mutex<StreamTerminal>>,
}
impl Default for StreamTerminalState {
fn default() -> Self {
Self::new()
}
}
impl StreamTerminalState {
pub fn new() -> Self {
Self {
terminal: Arc::new(Mutex::new(StreamTerminal::Open)),
}
}
pub fn status(&self) -> StreamTerminal {
self.terminal
.lock()
.expect("stream terminal mutex poisoned")
.clone()
}
pub fn finish(&self, requested: StreamTerminal) -> StreamTerminal {
debug_assert!(!matches!(requested, StreamTerminal::Open));
let mut terminal = self
.terminal
.lock()
.expect("stream terminal mutex poisoned");
if matches!(*terminal, StreamTerminal::Open) {
*terminal = requested;
}
terminal.clone()
}
pub fn ensure_open(&self) -> Result<()> {
let terminal = self.status();
match terminal {
StreamTerminal::Open => Ok(()),
StreamTerminal::Exhausted => Ok(()),
_ => Err(terminal
.as_error()
.expect("non-open non-exhausted terminals have errors")),
}
}
}
#[cfg(test)]
mod tests {
use std::num::NonZeroUsize;
use super::{
ResourceBudget, ResourceScope, StreamFailure, StreamFailureClass, StreamTerminal,
StreamTerminalState,
};
use crate::DataFrameError;
#[test]
fn reservation_accounts_bytes_and_batches_and_releases_once() {
let budget = ResourceBudget::new(16, NonZeroUsize::new(1).unwrap());
let mut reservation = budget.reserve_batch(ResourceScope::Decode, 12).unwrap();
assert_eq!(budget.usage().reserved_bytes, 12);
assert_eq!(budget.usage().reserved_batches, 1);
reservation.release();
reservation.release();
assert_eq!(budget.usage().reserved_bytes, 0);
assert_eq!(budget.usage().reserved_batches, 0);
}
#[test]
fn reservation_rejects_before_mutating_usage() {
let budget = ResourceBudget::new(16, NonZeroUsize::new(1).unwrap());
let err = budget.reserve_batch(ResourceScope::Output, 17).unwrap_err();
assert!(matches!(err, DataFrameError::ResourceLimitExceeded { .. }));
assert_eq!(budget.usage().reserved_bytes, 0);
assert_eq!(budget.usage().reserved_batches, 0);
}
#[test]
fn terminal_keeps_the_first_non_open_outcome() {
let state = StreamTerminalState::new();
let failure = StreamFailure::new("decode_failed", StreamFailureClass::Decode);
assert_eq!(
state.finish(StreamTerminal::Failed(failure.clone())),
StreamTerminal::Failed(failure)
);
assert_eq!(state.finish(StreamTerminal::Cancelled), state.status());
assert!(matches!(
state.ensure_open(),
Err(DataFrameError::StreamFailed {
code: "decode_failed",
..
})
));
}
}