use std::fs::File;
use std::io::{BufReader, BufWriter};
use std::time::{Duration, Instant};
use arrow_array::RecordBatch;
use arrow_ipc::reader::StreamReader;
use arrow_ipc::writer::StreamWriter;
use arrow_schema::Schema;
use crossbeam_channel::{Receiver, Sender};
use rayon::prelude::*;
use tempfile::NamedTempFile;
use crate::input_set::{ConvertSource, ReadPlan, RowGroupSelection};
use super::convert::ConvertError;
use super::level::{MemoryProfile, Mode};
use super::pipe::scoped_pipe;
use super::stream::{process_batch_cascade, process_level_batch, LevelStreamCtx, Pass2Timers};
use super::writer::{LevelWriteOutcome, OverviewWriter};
#[derive(Clone, Copy, Debug)]
pub(super) enum SinkBacking {
Ram,
Spill,
}
const DUPLICATING_BYTES_PER_ROW: u64 = 8_192;
const PARTITIONING_BYTES_PER_ROW: u64 = 16_384;
const SINK_ROW_OVERHEAD_BYTES: u64 = 4_096;
const DUPLICATING_GEOM_FACTOR: u64 = 2;
const PARTITIONING_GEOM_FACTOR: u64 = 4;
const AUTO_RAM_FRACTION: f64 = 0.6;
const AUTO_FALLBACK_BUDGET_BYTES: u64 = 4 * 1024 * 1024 * 1024;
const PARTITIONING_SPILL_ROWS: usize = 2_000_000;
fn estimate_buffered_bytes(mode: Mode, buffered_rows: usize, avg_geom_bytes: Option<u64>) -> u64 {
let per_row = match avg_geom_bytes {
Some(avg) if avg > 0 => {
let factor = match mode {
Mode::Duplicating => DUPLICATING_GEOM_FACTOR,
Mode::Partitioning => PARTITIONING_GEOM_FACTOR,
};
SINK_ROW_OVERHEAD_BYTES.saturating_add(factor.saturating_mul(avg))
}
_ => match mode {
Mode::Duplicating => DUPLICATING_BYTES_PER_ROW,
Mode::Partitioning => PARTITIONING_BYTES_PER_ROW,
},
};
(buffered_rows as u64).saturating_mul(per_row)
}
fn auto_budget_bytes(available_ram_bytes: Option<u64>) -> u64 {
match available_ram_bytes {
Some(ram) => ((ram as f64) * AUTO_RAM_FRACTION) as u64,
None => AUTO_FALLBACK_BUDGET_BYTES,
}
}
pub(super) fn auto_backing(
mode: Mode,
buffered_rows: usize,
available_ram_bytes: Option<u64>,
avg_geom_bytes: Option<u64>,
) -> SinkBacking {
let estimate = estimate_buffered_bytes(mode, buffered_rows, avg_geom_bytes);
let budget = auto_budget_bytes(available_ram_bytes);
let ram_gate_spill = estimate > budget;
let abs_gate_spill =
matches!(mode, Mode::Partitioning) && buffered_rows > PARTITIONING_SPILL_ROWS;
if ram_gate_spill || abs_gate_spill {
SinkBacking::Spill
} else {
SinkBacking::Ram
}
}
pub(super) fn pass1_grid_budget_bytes(profile: MemoryProfile) -> u64 {
match profile {
MemoryProfile::Speed => u64::MAX,
MemoryProfile::Bounded | MemoryProfile::Auto => auto_budget_bytes(available_memory_bytes()),
}
}
pub(super) fn available_memory_bytes() -> Option<u64> {
if let Ok(v) = std::env::var("TYLERTOO_AUTO_MEM_LIMIT_BYTES") {
if let Ok(n) = v.trim().parse::<u64>() {
return Some(n);
}
}
read_proc_mem_available()
}
#[cfg(target_os = "linux")]
fn read_proc_mem_available() -> Option<u64> {
let text = std::fs::read_to_string("/proc/meminfo").ok()?;
for line in text.lines() {
if let Some(rest) = line.strip_prefix("MemAvailable:") {
let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
return Some(kb.saturating_mul(1024));
}
}
None
}
#[cfg(not(target_os = "linux"))]
fn read_proc_mem_available() -> Option<u64> {
None
}
pub(super) fn resolve_backing(
profile: MemoryProfile,
mode: Mode,
buffered_rows: usize,
avg_geom_bytes: Option<u64>,
) -> SinkBacking {
match profile {
MemoryProfile::Speed => {
log::debug!(
"pass2 memory profile Speed + {mode:?} (buffered ~{buffered_rows} rows) → Ram"
);
SinkBacking::Ram
}
MemoryProfile::Bounded => {
log::debug!(
"pass2 memory profile Bounded + {mode:?} (buffered ~{buffered_rows} rows) → Spill"
);
SinkBacking::Spill
}
MemoryProfile::Auto => {
let available = available_memory_bytes();
let backing = auto_backing(mode, buffered_rows, available, avg_geom_bytes);
let estimate = estimate_buffered_bytes(mode, buffered_rows, avg_geom_bytes);
let budget = auto_budget_bytes(available);
let avail_mib = available.map_or_else(
|| "unknown".to_string(),
|b| format!("{} MiB", b / (1024 * 1024)),
);
let geom = avg_geom_bytes.filter(|&b| b > 0).map_or_else(
|| "unmeasured (calibrated constants)".to_string(),
|b| format!("measured avg geom {b} B/row"),
);
log::info!(
"[convert] pass2 auto + {mode:?}: buffered ~{buffered_rows} rows, \
{geom}, est {} MiB vs budget {} MiB (avail {avail_mib}) → {backing:?}",
estimate / (1024 * 1024),
budget / (1024 * 1024),
);
backing
}
}
}
struct ReadMsg {
row_offset: usize,
batch: RecordBatch,
read_dur: Duration,
}
enum LevelSink {
Ram(Vec<RecordBatch>),
Spill(Box<SpillState>),
}
impl LevelSink {
fn new(backing: SinkBacking, out_schema: &Schema) -> Result<Self, ConvertError> {
Ok(match backing {
SinkBacking::Ram => LevelSink::Ram(Vec::new()),
SinkBacking::Spill => LevelSink::Spill(Box::new(SpillState::new(out_schema)?)),
})
}
fn push(&mut self, batch: RecordBatch) -> Result<(), ConvertError> {
match self {
LevelSink::Ram(v) => {
v.push(batch);
Ok(())
}
LevelSink::Spill(s) => s.push(&batch),
}
}
}
struct SpillState {
writer: StreamWriter<BufWriter<File>>,
temp: NamedTempFile,
}
impl SpillState {
fn new(out_schema: &Schema) -> Result<Self, ConvertError> {
let temp = NamedTempFile::new()?;
let write_handle = temp.reopen()?;
let writer = StreamWriter::try_new(BufWriter::new(write_handle), out_schema)?;
Ok(SpillState { writer, temp })
}
fn push(&mut self, batch: &RecordBatch) -> Result<(), ConvertError> {
self.writer.write(batch)?;
Ok(())
}
fn into_reader(self) -> Result<(StreamReader<BufReader<File>>, NamedTempFile), ConvertError> {
let SpillState { mut writer, temp } = self;
writer.finish()?; drop(writer); let read_handle = temp.reopen()?;
let reader = StreamReader::try_new(BufReader::new(read_handle), None)?;
Ok((reader, temp))
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn run_pass2_buffered(
writer: &mut OverviewWriter<File>,
ctxs: &[LevelStreamCtx<'_>],
hints: &[usize],
source: &ConvertSource,
read_batch_size: usize,
selected_row_groups: Option<&RowGroupSelection>,
in_flight: usize,
backing: SinkBacking,
out_schema: &Schema,
) -> Result<Vec<(LevelWriteOutcome, usize, usize)>, ConvertError> {
let num_levels = ctxs.len();
debug_assert_eq!(num_levels, hints.len());
let t_engine = Instant::now();
let timers = Pass2Timers::default();
let mut sinks: Vec<LevelSink> = Vec::with_capacity(num_levels);
for _ in 0..num_levels {
sinks.push(LevelSink::new(backing, out_schema)?);
}
let mut rows = vec![0usize; num_levels];
let mut verts = vec![0usize; num_levels];
let mut reader = source.open_stream(&ReadPlan {
batch_size: read_batch_size.max(1),
projection: None,
row_groups: selected_row_groups,
})?;
let (rows_ref, verts_ref, sinks_ref) = (&mut rows, &mut verts, &mut sinks);
let timers_ref = &timers;
let cascade = ctxs.first().is_some_and(|c| c.is_cascading_duplicating());
scoped_pipe(
in_flight,
|tx: &Sender<ReadMsg>| -> Result<(), ConvertError> {
let mut row_offset = 0usize;
loop {
let t_read = Instant::now();
match reader.next() {
None => break,
Some(Ok(batch)) => {
let read_dur = t_read.elapsed();
let offset = row_offset;
row_offset += batch.num_rows();
if tx
.send(ReadMsg {
row_offset: offset,
batch,
read_dur,
})
.is_err()
{
break; }
}
Some(Err(e)) => return Err(e.into()),
}
}
Ok(())
},
|rx: Receiver<ReadMsg>| -> Result<(), ConvertError> {
let mut last_progress = Instant::now();
for msg in rx.iter() {
Pass2Timers::add_dur(timers_ref.read_cell(), msg.read_dur);
let batch = &msg.batch;
let row_offset = msg.row_offset;
let per_level: Vec<Option<(RecordBatch, usize)>> = if cascade {
process_batch_cascade(batch, row_offset, ctxs, timers_ref)?
} else {
let results: Vec<Result<Option<(RecordBatch, usize)>, ConvertError>> = (0
..num_levels)
.into_par_iter()
.map(|li| process_level_batch(batch, row_offset, &ctxs[li], timers_ref))
.collect();
let mut v = Vec::with_capacity(num_levels);
for res in results {
v.push(res?);
}
v
};
for (li, out) in per_level.into_iter().enumerate() {
if let Some((out, v)) = out {
rows_ref[li] += out.num_rows();
verts_ref[li] += v;
sinks_ref[li].push(out)?;
}
}
if last_progress.elapsed().as_secs() >= 10 {
last_progress = Instant::now();
log::info!(
"[convert] pass 2: {} input row(s) processed ({} output \
row(s) buffered across {num_levels} level(s))",
row_offset + batch.num_rows(),
rows_ref.iter().sum::<usize>(),
);
}
}
Ok(())
},
)?;
let mut outcomes = Vec::with_capacity(num_levels);
for li in 0..num_levels {
let sink = std::mem::replace(&mut sinks[li], LevelSink::Ram(Vec::new()));
outcomes.push(drain_sink(writer, li, hints[li], sink)?);
}
timers.log_engine_summary(t_engine.elapsed().as_secs_f64(), rows.iter().sum());
Ok(outcomes
.into_iter()
.zip(rows.into_iter().zip(verts))
.map(|(outcome, (r, v))| (outcome, r, v))
.collect())
}
fn drain_sink(
writer: &mut OverviewWriter<File>,
level_idx: usize,
hint: usize,
sink: LevelSink,
) -> Result<LevelWriteOutcome, ConvertError> {
match sink {
LevelSink::Ram(batches) => {
Ok(writer.write_level(level_idx, Some(hint), batches.into_iter())?)
}
LevelSink::Spill(state) => {
let (mut reader, _temp) = state.into_reader()?;
let err: std::cell::RefCell<Option<ConvertError>> = std::cell::RefCell::new(None);
let iter = std::iter::from_fn(|| match reader.next() {
None => None,
Some(Ok(b)) => Some(b),
Some(Err(e)) => {
*err.borrow_mut() = Some(ConvertError::Arrow(e));
None
}
});
let res = writer.write_level(level_idx, Some(hint), iter);
if let Some(e) = err.borrow_mut().take() {
return Err(e); }
Ok(res?)
}
}
}
#[cfg(test)]
mod backing_tests {
use super::*;
use crate::overview::level::{MemoryProfile, Mode};
const GIB: u64 = 1024 * 1024 * 1024;
#[test]
fn explicit_profiles_ignore_workload() {
assert!(matches!(
resolve_backing(
MemoryProfile::Speed,
Mode::Duplicating,
10_000_000_000,
None
),
SinkBacking::Ram
));
assert!(matches!(
resolve_backing(MemoryProfile::Bounded, Mode::Duplicating, 1, None),
SinkBacking::Spill
));
}
#[test]
fn auto_duplicating_small_stays_in_ram() {
assert!(matches!(
auto_backing(Mode::Duplicating, 10_000, Some(54 * GIB), None),
SinkBacking::Ram
));
}
#[test]
fn auto_duplicating_large_spills() {
assert!(matches!(
auto_backing(Mode::Duplicating, 10_000_000, Some(54 * GIB), None),
SinkBacking::Spill
));
}
#[test]
fn auto_decision_scales_with_available_ram() {
let rows = 5_000_000; assert!(
matches!(
auto_backing(Mode::Duplicating, rows, Some(4 * GIB), None),
SinkBacking::Spill
),
"small box must spill"
);
assert!(
matches!(
auto_backing(Mode::Duplicating, rows, Some(256 * GIB), None),
SinkBacking::Ram
),
"huge box may keep it in RAM"
);
}
#[test]
fn auto_partitioning_preserves_row_ceiling() {
assert!(matches!(
auto_backing(Mode::Partitioning, 3_000_000, Some(10_000 * GIB), None),
SinkBacking::Spill
));
assert!(matches!(
auto_backing(Mode::Partitioning, 100_000, Some(54 * GIB), None),
SinkBacking::Ram
));
}
#[test]
fn auto_uses_fallback_budget_when_ram_unknown() {
assert!(matches!(
auto_backing(Mode::Duplicating, 100_000_000, None, None),
SinkBacking::Spill
));
assert!(matches!(
auto_backing(Mode::Duplicating, 1_000, None, None),
SinkBacking::Ram
));
}
#[test]
fn estimate_scales_with_mode_and_rows() {
assert_eq!(
estimate_buffered_bytes(Mode::Duplicating, 1_000, None),
8_192_000
);
assert_eq!(
estimate_buffered_bytes(Mode::Partitioning, 1_000, None),
16_384_000
);
assert_eq!(
estimate_buffered_bytes(Mode::Duplicating, usize::MAX, None),
u64::MAX
);
}
#[test]
fn measured_estimate_is_overhead_plus_geometry_margin() {
assert_eq!(
estimate_buffered_bytes(Mode::Duplicating, 1_000, Some(1_000)),
6_096_000 );
assert_eq!(
estimate_buffered_bytes(Mode::Partitioning, 1_000, Some(1_000)),
8_096_000 );
assert_eq!(
estimate_buffered_bytes(Mode::Duplicating, usize::MAX, Some(1)),
u64::MAX
);
}
#[test]
fn measured_zero_falls_back_to_constants() {
assert_eq!(
estimate_buffered_bytes(Mode::Duplicating, 1_000, Some(0)),
8_192_000
);
assert_eq!(
estimate_buffered_bytes(Mode::Partitioning, 1_000, Some(0)),
16_384_000
);
}
#[test]
fn measured_tiny_geometry_keeps_ram_where_constant_spills() {
assert!(matches!(
auto_backing(Mode::Duplicating, 5_000_000, Some(54 * GIB), None),
SinkBacking::Spill
));
assert!(matches!(
auto_backing(Mode::Duplicating, 5_000_000, Some(54 * GIB), Some(100)),
SinkBacking::Ram
));
}
#[test]
fn measured_huge_geometry_spills_where_constant_kept_ram() {
assert!(matches!(
auto_backing(Mode::Duplicating, 1_000_000, Some(25 * GIB), None),
SinkBacking::Ram
));
assert!(matches!(
auto_backing(Mode::Duplicating, 1_000_000, Some(25 * GIB), Some(11_534)),
SinkBacking::Spill
));
}
#[test]
fn measured_partitioning_small_geometry_keeps_ram() {
assert!(matches!(
auto_backing(Mode::Partitioning, 1_000_000, Some(20 * GIB), None),
SinkBacking::Spill
));
assert!(matches!(
auto_backing(Mode::Partitioning, 1_000_000, Some(20 * GIB), Some(983)),
SinkBacking::Ram
));
}
#[test]
fn partitioning_row_ceiling_ignores_measurement() {
assert!(matches!(
auto_backing(Mode::Partitioning, 3_000_000, Some(10_000 * GIB), Some(8)),
SinkBacking::Spill
));
}
}