#[cfg(not(feature = "arena"))]
use minarrow::Vec64;
use minarrow::structs::shared_buffer::SharedBuffer;
use std::collections::HashSet;
use std::fs::File;
use std::io;
#[cfg(feature = "arena")]
use std::mem::MaybeUninit;
#[cfg(unix)]
use std::os::unix::fs::FileExt;
#[cfg(windows)]
use std::os::windows::fs::FileExt;
use std::path::Path;
use std::sync::Arc;
#[cfg(unix)]
fn read_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result<()> {
file.read_exact_at(buf, offset)
}
#[cfg(all(unix, feature = "arena"))]
fn read_at_uninit(file: &File, buf: &mut [MaybeUninit<u8>], offset: u64) -> io::Result<()> {
use std::os::fd::AsRawFd;
let mut filled = 0usize;
while filled < buf.len() {
let file_offset: libc::off_t = (offset + filled as u64).try_into().map_err(|_| {
io::Error::new(io::ErrorKind::InvalidInput, "file offset exceeds off_t")
})?;
let n = unsafe {
libc::pread(
file.as_raw_fd(),
buf.as_mut_ptr().add(filled).cast(),
buf.len() - filled,
file_offset,
)
};
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"file ended before requested offset/length",
));
}
if n < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(error);
}
filled += n as usize;
}
Ok(())
}
#[cfg(all(windows, feature = "arena"))]
fn read_at_uninit(file: &File, buf: &mut [MaybeUninit<u8>], offset: u64) -> io::Result<()> {
for byte in &mut *buf {
byte.write(0);
}
let initialised =
unsafe { std::slice::from_raw_parts_mut(buf.as_mut_ptr().cast::<u8>(), buf.len()) };
read_at(file, initialised, offset)
}
#[cfg(windows)]
fn read_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result<()> {
let mut total = 0;
while total < buf.len() {
let n = file.seek_read(&mut buf[total..], offset + total as u64)?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"file ended before requested offset/length",
));
}
total += n;
}
Ok(())
}
use flatbuffers::Vector;
use minarrow::{Field, SuperTable, Table};
use crate::arrow::file::org::apache::arrow::flatbuf as fbf;
use crate::arrow::message::org::apache::arrow::flatbuf as fbm;
use crate::constants::ARROW_MAGIC_NUMBER;
#[cfg(feature = "arena")]
use crate::models::streams::stream_arena::StreamArena;
use crate::models::decoders::ipc::parser::{
convert_fb_field_to_arrow, decode_record_batch, handle_dictionary_batch,
};
use crate::models::decoders::limits::DecodeLimits;
use crate::models::readers::ipc::window::window_table;
#[derive(Debug, Clone)]
struct IPCFileBlock {
offset: usize,
meta_bytes: usize,
body_bytes: usize,
}
#[derive(Clone)]
pub struct FileTableReader {
file: Arc<File>,
schema: Vec<Arc<Field>>,
dict_blocks: Vec<IPCFileBlock>,
record_blocks: Vec<IPCFileBlock>,
dictionaries: std::collections::HashMap<i64, Vec<String>>,
#[cfg(feature = "arena")]
block_arena: Arc<std::sync::Mutex<StreamArena>>,
}
impl FileTableReader {
pub fn open<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let file = File::open(path.as_ref())?;
let file_len = file.metadata()?.len() as usize;
if file_len < 12 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"file too small for Arrow",
));
}
let mut tail = [0u8; 10];
read_at(&file, &mut tail, (file_len - 10) as u64)?;
if &tail[4..] != ARROW_MAGIC_NUMBER {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"missing closing magic",
));
}
let footer_len = u32::from_le_bytes(tail[..4].try_into().unwrap()) as usize;
let footer_start = (file_len - 10).checked_sub(footer_len).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"footer length exceeds file size",
)
})?;
if footer_start < 8 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"footer out of bounds",
));
}
let mut footer_buf = vec![0u8; footer_len];
read_at(&file, &mut footer_buf, footer_start as u64)?;
let footer_msg = flatbuffers::root::<fbf::Footer>(&footer_buf)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("bad footer: {e}")))?;
let fb_schema = footer_msg
.schema()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "footer missing schema"))?;
let fb_fields = fb_schema.fields().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "footer schema missing fields")
})?;
let mut fields = Vec::with_capacity(fb_fields.len());
for i in 0..fb_fields.len() {
let f = convert_fb_field_to_arrow(&fb_fields.get(i))?;
fields.push(Arc::new(f));
}
let dict_blocks = footer_msg
.dictionaries()
.unwrap_or_else(|| unsafe { Vector::new(&[], 0) })
.iter()
.map(|b| IPCFileBlock {
offset: b.offset() as usize,
meta_bytes: b.metaDataLength() as usize,
body_bytes: b.bodyLength() as usize,
})
.collect::<Vec<_>>();
let record_blocks = footer_msg
.recordBatches()
.unwrap_or_else(|| unsafe { Vector::new(&[], 0) })
.iter()
.map(|b| IPCFileBlock {
offset: b.offset() as usize,
meta_bytes: b.metaDataLength() as usize,
body_bytes: b.bodyLength() as usize,
})
.collect::<Vec<_>>();
for blk in dict_blocks.iter().chain(record_blocks.iter()) {
let end = blk
.offset
.checked_add(blk.meta_bytes)
.and_then(|v| v.checked_add(blk.body_bytes));
match end {
Some(end) if blk.offset >= 8 && end <= file_len => {}
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"footer block out of bounds",
));
}
}
}
#[cfg(feature = "arena")]
let arena_capacity = dict_blocks
.iter()
.chain(record_blocks.iter())
.map(|b| b.meta_bytes + b.body_bytes)
.max()
.unwrap_or(0);
let mut rdr = Self {
file: Arc::new(file),
schema: fields,
dict_blocks,
record_blocks,
dictionaries: std::collections::HashMap::new(),
#[cfg(feature = "arena")]
block_arena: Arc::new(std::sync::Mutex::new(StreamArena::with_capacity(
arena_capacity,
))),
};
rdr.load_all_dictionaries()?;
Ok(rdr)
}
#[inline]
pub fn schema(&self) -> &[Arc<Field>] {
&self.schema
}
#[inline]
pub fn num_batches(&self) -> usize {
self.record_blocks.len()
}
pub fn read_batch(&self, idx: usize) -> io::Result<Table> {
let blk = self
.record_blocks
.get(idx)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "batch idx OOB"))?;
self.parse_batch_block(blk, None)
}
pub fn read_batch_cols(&self, idx: usize, columns: &[&str]) -> io::Result<Table> {
let blk = self
.record_blocks
.get(idx)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "batch idx OOB"))?;
let projection = self.resolve_column_indices(columns)?;
self.parse_batch_block(blk, Some(&projection))
}
pub fn read_batch_window(
&self,
idx: usize,
row_offset: usize,
rows: usize,
) -> io::Result<Table> {
let table = self.read_batch(idx)?;
window_table(&table, row_offset, rows)
}
pub fn read_batch_windows(&self, idx: usize, target_bytes: usize) -> io::Result<SuperTable> {
let mut batches = Vec::new();
for window in self.batch_windows(idx, target_bytes)? {
batches.push(Arc::new(window?));
}
Ok(SuperTable::from_batches(batches, None))
}
pub fn batch_windows(
&self,
idx: usize,
target_bytes: usize,
) -> io::Result<impl Iterator<Item = io::Result<Table>> + '_> {
let blk = self
.record_blocks
.get(idx)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "batch idx OOB"))?;
let body_bytes = blk.body_bytes;
let table = self.parse_batch_block(blk, None)?;
let rows = table.n_rows;
let per_row = (body_bytes / rows.max(1)).max(1);
let stride = ((target_bytes / per_row) & !511).max(512).min(rows.max(1));
Ok((0..rows.max(1))
.step_by(stride)
.map(move |off| window_table(&table, off, stride.min(rows - off))))
}
pub fn load_batched(&self, name_override: Option<String>) -> io::Result<SuperTable> {
let mut batches = Vec::with_capacity(self.record_blocks.len());
for blk in &self.record_blocks {
batches.push(Arc::new(self.parse_batch_block(blk, None)?));
}
Ok(SuperTable::from_batches(batches, name_override))
}
pub fn load_batched_cols(
&self,
columns: &[&str],
name_override: Option<String>,
) -> io::Result<SuperTable> {
let projection = self.resolve_column_indices(columns)?;
let mut batches = Vec::with_capacity(self.record_blocks.len());
for blk in &self.record_blocks {
batches.push(Arc::new(self.parse_batch_block(blk, Some(&projection))?));
}
Ok(SuperTable::from_batches(batches, name_override))
}
pub fn load_table(&self) -> io::Result<Table> {
use minarrow::Consolidate;
Ok(self.load_batched(None)?.consolidate())
}
pub fn load_table_cols(&self, columns: &[&str]) -> io::Result<Table> {
use minarrow::Consolidate;
Ok(self.load_batched_cols(columns, None)?.consolidate())
}
#[cfg(feature = "arena")]
fn read_block(&self, blk: &IPCFileBlock) -> io::Result<SharedBuffer> {
let total = blk.meta_bytes + blk.body_bytes;
let mut arena = self.block_arena.lock().unwrap();
arena.recycle_if_free();
arena.ensure_capacity(total);
let start = arena.write_pos();
let spare = arena.spare_uninit();
read_at_uninit(&self.file, &mut spare[..total], blk.offset as u64)?;
unsafe { arena.advance(total) };
let shared = arena.window(start, total);
arena.align();
Ok(shared)
}
#[cfg(not(feature = "arena"))]
fn read_block(&self, blk: &IPCFileBlock) -> io::Result<SharedBuffer> {
let total = blk.meta_bytes + blk.body_bytes;
let mut buf = Vec64::with_capacity(total);
unsafe {
buf.set_len(total);
}
read_at(&self.file, &mut buf, blk.offset as u64)?;
Ok(SharedBuffer::from_vec64(buf))
}
fn parse_frame_header(buf: &[u8]) -> io::Result<&[u8]> {
if buf.len() < 8 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"block too short",
));
}
let cont = u32::from_le_bytes(buf[..4].try_into().unwrap());
if cont != 0xFFFF_FFFF {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("bad continuation marker: {cont:#X}"),
));
}
let meta_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize;
let end = 8 + meta_len;
if end > buf.len() {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "metadata OOB"));
}
Ok(&buf[8..end])
}
fn load_all_dictionaries(&mut self) -> io::Result<()> {
let mut new_dicts = std::collections::HashMap::<i64, Vec<String>>::new();
for blk in &self.dict_blocks {
let shared = self.read_block(blk)?;
let meta = Self::parse_frame_header(shared.as_slice())?;
let fb_msg = flatbuffers::root::<fbm::Message>(meta).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("bad dict msg: {e}"))
})?;
let dict_batch = fb_msg.header_as_dictionary_batch().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "expected DictionaryBatch")
})?;
let body = &shared.as_slice()[blk.meta_bytes..blk.meta_bytes + blk.body_bytes];
handle_dictionary_batch(&dict_batch, body, &mut new_dicts, DecodeLimits::default())?;
}
self.dictionaries = new_dicts;
Ok(())
}
fn resolve_column_indices(&self, columns: &[&str]) -> io::Result<HashSet<usize>> {
let mut indices = HashSet::with_capacity(columns.len());
for name in columns {
let idx = self
.schema
.iter()
.position(|f| f.name == *name)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("column '{}' not found in schema", name),
)
})?;
indices.insert(idx);
}
Ok(indices)
}
fn parse_batch_block(
&self,
blk: &IPCFileBlock,
projection: Option<&HashSet<usize>>,
) -> io::Result<Table> {
let shared = self.read_block(blk)?;
let body_offset = blk.meta_bytes;
let body_len = blk.body_bytes;
let fields: Vec<_> = self.schema.iter().map(|a| a.as_ref().clone()).collect();
let meta = Self::parse_frame_header(shared.as_slice())?;
let fb_msg = flatbuffers::root::<fbm::Message>(meta).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("bad record msg: {e}"))
})?;
let rec = fb_msg.header_as_record_batch().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "expected RecordBatch header")
})?;
let (table, _) = decode_record_batch(
&rec,
&fields,
&self.dictionaries,
shared.clone(),
body_offset,
body_len,
projection,
DecodeLimits::default(),
)?;
Ok(table)
}
}
#[cfg(test)]
mod tests {
use minarrow::{
Array, Field, FieldArray, NumericArray, Table, TextArray, Vec64, arr_f64, arr_i32,
arr_str32,
};
use tempfile::NamedTempFile;
use tracing::debug;
use crate::{
models::readers::ipc::file_table::FileTableReader,
models::writers::ipc::table::write_tables_to_file,
test_helpers::{make_all_types_table, write_test_table_to_file},
};
#[tokio::test]
async fn test_single_batch_roundtrip_heap() {
let table = make_all_types_table();
let temp = write_test_table_to_file(&[table.clone()]).await;
let rdr = FileTableReader::open(&temp.path()).unwrap();
assert_eq!(rdr.num_batches(), 1);
let table2 = rdr.read_batch(0).unwrap();
assert_eq!(table2.n_rows, 4);
assert_eq!(table2.cols.len(), table.cols.len());
println!("TABLE {:?}\n", &table2);
match &table2.cols[0].array {
Array::NumericArray(NumericArray::Int32(arr)) => {
let s: i32 = arr.data.as_ref().iter().sum();
assert_eq!(s, 10);
if arr.data.is_shared() {
debug!("Int32 buffer is shared (zero-copy)");
} else {
debug!("Int32 buffer was cloned (not 64-byte aligned in file)");
}
}
_ => panic!("wrong type"),
}
match &table2.cols[5].array {
Array::NumericArray(NumericArray::Float64(arr)) => {
let vals: Vec<_> = arr.data.as_ref().iter().cloned().collect();
assert_eq!(vals, vec![1.1, 2.2, 3.3, 4.4]);
if arr.data.is_shared() {
debug!("Float64 buffer is shared (zero-copy)");
} else {
debug!("Float64 buffer was cloned (not 64-byte aligned in file)");
}
}
_ => panic!("wrong type"),
}
let mut seen_string = false;
let mut seen_bool = false;
let mut any_shared = false;
for arr in &table2.cols {
match &arr.array {
Array::TextArray(TextArray::String32(a)) => {
seen_string = true;
if a.data.is_shared() {
debug!("String32 data buffer is shared (zero-copy)");
any_shared = true;
} else {
debug!("String32 data buffer was cloned (not 64-byte aligned in file)");
}
}
Array::BooleanArray(a) => {
seen_bool = true;
if a.data.bits.is_shared() {
debug!("Boolean bits buffer is shared (zero-copy)");
any_shared = true;
} else {
debug!("Boolean bits buffer was cloned (not 64-byte aligned in file)");
}
}
_ => {}
}
}
assert!(
seen_string && seen_bool,
"String32 and Bool must be present"
);
debug!("Any buffers shared: {}", any_shared);
drop(rdr);
drop(temp);
}
#[tokio::test]
async fn test_shared_buffers_with_aligned_data() {
let table = make_all_types_table();
let tables = vec![table.clone()];
let temp = write_test_table_to_file(&tables).await;
let rdr = FileTableReader::open(&temp.path()).unwrap();
assert_eq!(rdr.num_batches(), 1);
let table2 = rdr.read_batch(0).unwrap();
let mut shared_count = 0;
let mut cloned_count = 0;
for col in &table2.cols {
match &col.array {
Array::NumericArray(na) => match na {
NumericArray::Int32(arr) if arr.data.is_shared() => shared_count += 1,
NumericArray::Int64(arr) if arr.data.is_shared() => shared_count += 1,
NumericArray::UInt32(arr) if arr.data.is_shared() => shared_count += 1,
NumericArray::UInt64(arr) if arr.data.is_shared() => shared_count += 1,
NumericArray::Float32(arr) if arr.data.is_shared() => shared_count += 1,
NumericArray::Float64(arr) if arr.data.is_shared() => shared_count += 1,
#[cfg(feature = "extended_numeric_types")]
NumericArray::Int8(arr) if arr.data.is_shared() => shared_count += 1,
#[cfg(feature = "extended_numeric_types")]
NumericArray::Int16(arr) if arr.data.is_shared() => shared_count += 1,
#[cfg(feature = "extended_numeric_types")]
NumericArray::UInt8(arr) if arr.data.is_shared() => shared_count += 1,
#[cfg(feature = "extended_numeric_types")]
NumericArray::UInt16(arr) if arr.data.is_shared() => shared_count += 1,
_ => cloned_count += 1,
},
Array::BooleanArray(arr) => {
if arr.data.bits.is_shared() {
shared_count += 1;
} else {
cloned_count += 1;
}
}
Array::TextArray(ta) => match ta {
TextArray::String32(arr) if arr.data.is_shared() => shared_count += 1,
#[cfg(feature = "large_string")]
TextArray::String64(arr) if arr.data.is_shared() => shared_count += 1,
#[cfg(any(
not(feature = "default_categorical_8"),
feature = "extended_categorical"
))]
TextArray::Categorical32(arr) if arr.data.is_shared() => shared_count += 1,
#[cfg(feature = "default_categorical_8")]
TextArray::Categorical8(arr) if arr.data.is_shared() => shared_count += 1,
#[cfg(feature = "extended_categorical")]
TextArray::Categorical16(arr) if arr.data.is_shared() => shared_count += 1,
#[cfg(feature = "extended_categorical")]
TextArray::Categorical64(arr) if arr.data.is_shared() => shared_count += 1,
_ => cloned_count += 1,
},
_ => {}
}
}
debug!(
"Shared buffers: {}, Cloned buffers: {}",
shared_count, cloned_count
);
debug!("Note: Cloning is expected when file data is not 64-byte aligned.");
debug!("The writer currently doesn't guarantee 64-byte alignment.");
assert_eq!(table2.n_rows, 4);
assert_eq!(table2.cols.len(), table.cols.len());
}
#[tokio::test]
async fn test_read_batch_window_matches_slice_clone() {
let n: usize = 2048;
let ids: Vec64<i32> = (0..n as i32).collect();
let vals: Vec64<f64> = (0..n).map(|i| i as f64 * 0.5).collect();
let labels: Vec<String> = (0..n).map(|i| format!("row_{i}")).collect();
let label_refs: Vec64<&str> = labels.iter().map(String::as_str).collect();
let table = Table::new(
"windowed".to_string(),
Some(vec![
FieldArray::from_arr("ids", arr_i32!(ids)),
FieldArray::from_arr("vals", arr_f64!(vals)),
FieldArray::from_arr("labels", arr_str32!(label_refs)),
]),
);
let schema: Vec<Field> = table.schema().iter().map(|f| (**f).clone()).collect();
let temp = NamedTempFile::new().unwrap();
write_tables_to_file(temp.path().to_str().unwrap(), &[table.clone()], schema)
.await
.unwrap();
let rdr = FileTableReader::open(&temp.path()).unwrap();
for (off, len) in [(0usize, 512usize), (512, 512), (1024, 1024), (1536, 512)] {
let window = rdr.read_batch_window(0, off, len).unwrap();
let expected = table.slice_clone(off, len);
assert_eq!(window.n_rows, len);
for (w, e) in window.cols.iter().zip(expected.cols.iter()) {
assert_eq!(w.array.to_string(), e.array.to_string(), "col {}", w.field.name);
}
}
let mut rows = 0usize;
for subbatch in rdr.batch_windows(0, 4096).unwrap() {
let subbatch = subbatch.unwrap();
let expected = table.slice_clone(rows, subbatch.n_rows);
for (w, e) in subbatch.cols.iter().zip(expected.cols.iter()) {
assert_eq!(w.array.to_string(), e.array.to_string(), "col {}", w.field.name);
}
rows += subbatch.n_rows;
}
assert_eq!(rows, n);
let st = rdr.read_batch_windows(0, 4096).unwrap();
assert_eq!(st.n_rows(), n);
assert!(rdr.read_batch_window(0, 100, 100).is_err());
}
}