implemented zero-copy via cursor movement and reduced overhead.

This commit is contained in:
Bill
2025-11-18 11:27:10 -07:00
parent b2224a15b8
commit 69c709b77f
4 changed files with 221 additions and 134 deletions

View File

@@ -15,6 +15,9 @@ DB_HOST = "localhost"
DB_PORT = "5433" # <-- Your local Docker port DB_PORT = "5433" # <-- Your local Docker port
DB_NAME = "postgres" DB_NAME = "postgres"
# Global Configuration
BLAST_RADIUS = 625000 # Rows per parallel task (1M / 62500 = 16 partitions)
# SQLAlchemy connection string (for Pandas) # SQLAlchemy connection string (for Pandas)
sqlalchemy_conn_str = f"postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" sqlalchemy_conn_str = f"postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
engine = sqlalchemy.create_engine(sqlalchemy_conn_str) engine = sqlalchemy.create_engine(sqlalchemy_conn_str)
@@ -62,12 +65,14 @@ def test_connectorx():
return df return df
def test_unchecked_io(): def test_unchecked_io():
arrow_table = unchecked_io.load_data_from_config(config_file) # FIX: Pass the BLAST_RADIUS argument
arrow_table = unchecked_io.load_data_from_config(config_file, BLAST_RADIUS)
return arrow_table return arrow_table
# --- 4. Run Benchmarks --- # --- 4. Run Benchmarks ---
run_count = 3 run_count = 3
print(f"Running benchmarks for 1,000,000 rows (average of {run_count} runs)...") print(f"Running benchmarks for 5,000,000 rows (average of {run_count} runs)...")
print(f"Blast Radius: {BLAST_RADIUS} rows per task")
# --- Pandas --- # --- Pandas ---
print("\nRunning Pandas warmup...") print("\nRunning Pandas warmup...")
@@ -92,7 +97,7 @@ print(f"UncheckedIO Average Time: {unchecked_io_time * 1000:.2f} ms")
# --- 5. Print Results --- # --- 5. Print Results ---
print("\n" + "---" * 10) print("\n" + "---" * 10)
print("--- Benchmark Results (1,000,000 Rows) ---") print("--- Benchmark Results (5,000,000 Rows) ---")
print(f"Pandas: {pandas_time * 1000:>10.2f} ms") print(f"Pandas: {pandas_time * 1000:>10.2f} ms")
print(f"ConnectorX: {connectorx_time * 1000:>10.2f} ms") print(f"ConnectorX: {connectorx_time * 1000:>10.2f} ms")
print(f"UncheckedIO: {unchecked_io_time * 1000:>10.2f} ms") print(f"UncheckedIO: {unchecked_io_time * 1000:>10.2f} ms")

87
docs/sprint4.md Normal file
View File

@@ -0,0 +1,87 @@
# Sprint 4: Optimization & Stabilization
This focuses on fixing the "death by a thousand cuts" performance regression we identified in `UncheckedIO`.
You can copy-paste this directly into a new note in your vault (e.g., named `Sprint 4 - UncheckedIO Optimization`).
---
# [[UncheckedIO]] Sprint 4: Optimization & Stabilization
**Goal:** Recover performance lost during parallelization (Target: >2x faster than ConnectorX) and eliminate the 15M+ heap allocations. **Current Status:** ~1.5x faster than Pandas, but 0.5x speed of ConnectorX. **Tags:** #rust #optimization #project/unchecked-io #sprint
## 🚨 Priority 1: The "String Killer" Fix (Memory)
_Fixes the 15,000,000 heap allocations causing allocator contention._
- [ ] **Refactor `parse_row` string handling**
- [ ] Remove `vec![0; field_len_usize]` allocation in `DynamicBuilder::String` match arm.
- [ ] Implement zero-copy slicing: `let val_str = std::str::from_utf8(&current_chunk[start..end])?`.
- [ ] Update `StringBuilder` to append the slice directly (`b.append_value(val_str)`).
- [ ] **Verify Memory Profile**
- [ ] Run 5M row benchmark.
- [ ] Ensure RAM usage is flat (no spikes matching row count).
## ⚡ Priority 2: The "Dispatch Killer" Fix (CPU)
_Fixes the 50,000,000 runtime type checks._
- [ ] **Implement `Danger Mode` (Task 7)**
- [ ] Add `danger_mode: bool` argument to `run_db_logic` and `load_data_from_config`.
- [ ] **Fast Path (`danger_mode=true`):**
- [ ] Create a new parsing loop that _assumes_ types based on column index.
- [ ] Remove the inner `match builder` loop.
- [ ] Use `get_unchecked` or specific Typed Builders if possible to bypass runtime checks.
- [ ] **Safe Path (`danger_mode=false`):**
- [ ] Keep the current `match` based logic for fallback/recovery.
## 🛠️ Priority 3: Tuning & Architecture
- [ ] **Tune `BLAST_RADIUS`**
- [ ] Reduce partition count from 80 to ~8-16 (matching logical cores).
- [ ] Test `BLAST_RADIUS` values: `312,500` (16 chunks) vs `625,000` (8 chunks) vs `62,500` (80 chunks).
- [ ] Goal: Minimize connection overhead (TCP handshakes) vs parallel throughput.
- [ ] **Connection Pooling (Optional)**
- [ ] Investigate if `bb8` or `deadpool-postgres` can reuse connections instead of opening 80 new ones.
## 📉 Benchmarking & Validation
- [ ] **Run `benchmark.py`**
- [ ] Target: Beat `ConnectorX` (< 6700ms).
- [ ] Target: Beat `Pandas` by >4x (< 5000ms).
- [ ] **Profile `UncheckedIO`**
- [ ] Identify if `malloc` is still the bottleneck after string fix.
---
**Notes & Observations:**
- Current regression caused by 15M allocations * 80 threads fighting for locks.
- Previous "Safe Mode" parser was ~2x faster than ConnectorX (single-threaded). We need to get back to that baseline efficiency _before_ scaling out.

View File

@@ -6,7 +6,7 @@ mod parser;
use pyo3::prelude::*; use pyo3::prelude::*;
use pyo3::exceptions::PyValueError; use pyo3::exceptions::PyValueError;
use tokio; use tokio;
use pyo3::types::PyModule; use pyo3::types::{PyModule, PyAny}; // Added PyAny
use pyo3::Bound; use pyo3::Bound;
// FIX 1: Import PyRecordBatch. // FIX 1: Import PyRecordBatch.
@@ -24,7 +24,14 @@ use crate::parser::run_db_logic;
#[allow(unsafe_code)] #[allow(unsafe_code)]
#[allow(unsafe_op_in_unsafe_fn)] #[allow(unsafe_op_in_unsafe_fn)]
#[allow(rust_2024_compatibility)] #[allow(rust_2024_compatibility)]
fn load_data_from_config<'py>(py: Python<'py>, config_path: String) -> PyResult<Bound<'py, PyAny>> { // FIX 2: Updated signature to accept 'blast_radius' and return 'Bound<'py, PyAny>'
// This matches what you confirmed works with .into_pyarrow(py).
fn load_data_from_config<'py>(
py: Python<'py>,
config_path: String,
blast_radius: i64
) -> PyResult<Bound<'py, PyAny>> {
// --- Phase 1: Load and Validate Configuration --- // --- Phase 1: Load and Validate Configuration ---
let config: ConnectorConfig = match load_and_validate_config(&config_path) { let config: ConnectorConfig = match load_and_validate_config(&config_path) {
Ok(c) => c, Ok(c) => c,
@@ -42,26 +49,26 @@ fn load_data_from_config<'py>(py: Python<'py>, config_path: String) -> PyResult<
.build() .build()
.unwrap() .unwrap()
.block_on(async { .block_on(async {
run_db_logic(config).await // FIX 3: Pass the 'blast_radius' argument to the parser logic
run_db_logic(config, blast_radius).await
}) })
}).map_err(|e| PyValueError::new_err(format!("Database/Runtime Error: {:?}", e)))?; }).map_err(|e| PyValueError::new_err(format!("Database/Runtime Error: {:?}", e)))?;
// --- Phase 3: Return Data to Python --- // --- Phase 3: Return Data to Python ---
// FIX 2: Create PyRecordBatch wrapper using the standard PyO3 __new__ convention.
// let py_record_batch = PyRecordBatch::new(record_batch); // Create the wrapper. .new() takes one argument in this version.
// // Replace: let py_record_batch = PyRecordBatch::new(py, record_batch).map_err(|e| ...)?;
// // With:
let py_record_batch = PyRecordBatch::new(record_batch); let py_record_batch = PyRecordBatch::new(record_batch);
// Return the PyRecordBatch wrapper object as a generic PyObject reference.
// Call the correct conversion method which returns PyResult<Bound<'py, PyAny>>
py_record_batch.into_pyarrow(py) py_record_batch.into_pyarrow(py)
} }
// --- PYTHON MODULE EXPORT --- // --- PYTHON MODULE EXPORT ---
#[pymodule] #[pymodule]
fn unchecked_io(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { fn unchecked_io(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
// FIX: Use the two-argument version of wrap_pyfunction! // FIX 4: Use the two-argument wrap_pyfunction! macro pattern which you confirmed works.
// This resolves the E0308 type mismatch.
m.add_function(wrap_pyfunction!(load_data_from_config, m)?)?; m.add_function(wrap_pyfunction!(load_data_from_config, m)?)?;
Ok(()) Ok(())

View File

@@ -1,7 +1,7 @@
// --- External Crates --- // --- External Crates ---
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use tokio_postgres::{NoTls, CopyOutStream, Client, Config as PgConfig}; use tokio_postgres::{NoTls, CopyOutStream, Config as PgConfig};
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use arrow::array::{ use arrow::array::{
ArrayBuilder, ArrayRef, ArrayBuilder, ArrayRef,
@@ -10,15 +10,13 @@ use arrow::array::{
}; };
use arrow::datatypes::{ use arrow::datatypes::{
DataType, Field, Schema, DataType, Field, Schema,
Float64Type, Float32Type, Int64Type, Int32Type, Utf8Type, BooleanType, TimestampNanosecondType,
Date32Type
}; };
use arrow::record_batch::RecordBatch; use arrow::record_batch::RecordBatch;
use futures_util::stream::StreamExt; use futures_util::stream::StreamExt;
use bytes::Bytes; use bytes::{Bytes, BytesMut, Buf};
use byteorder::{BigEndian, ReadBytesExt}; use byteorder::{BigEndian, ReadBytesExt};
use std::io::{Cursor, Read}; use std::io::{Cursor, Read};
use std::str; use std::str; // Ensure this is imported for zero-copy parsing
use chrono::{NaiveDateTime, NaiveDate}; use chrono::{NaiveDateTime, NaiveDate};
use std::mem; use std::mem;
use std::str::FromStr; use std::str::FromStr;
@@ -30,153 +28,139 @@ use crate::config::ConnectorConfig;
// --- 1. CORE DATABASE LOGIC (PARALLEL COORDINATOR) --- // --- 1. CORE DATABASE LOGIC (PARALLEL COORDINATOR) ---
pub async fn run_db_logic(config: ConnectorConfig) -> Result<RecordBatch> { // (This section is unchanged from your previous correct version)
pub async fn run_db_logic(config: ConnectorConfig, blast_radius: i64) -> Result<RecordBatch> {
println!("UncheckedIO: Starting Query Planner..."); println!("UncheckedIO: Starting Query Planner (Blast Radius: {} rows)...", blast_radius);
// 1. Establish the *coordinator* connection // 1. Establish the *coordinator* connection
let pg_config = PgConfig::from_str(&config.connection_string)?; let pg_config = PgConfig::from_str(&config.connection_string)?;
let (client, connection) = pg_config.connect(NoTls).await let (client, connection) = pg_config.connect(NoTls).await
.context("Coordinator: Failed to connect to PostgreSQL")?; .context("Coordinator: Failed to connect to PostgreSQL")?;
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = connection.await { if let Err(e) = connection.await { eprintln!("Coordinator connection error: {}", e); }
eprintln!("Coordinator connection error: {}", e);
}
}); });
// 2. Define Partition Strategy // 2. Define Partition Strategy
let num_partitions = num_cpus::get().max(2);
let partition_key = "id"; let partition_key = "id";
// 3. Query for Table Bounds // 3. Query for Table Bounds
let (base_query, _) = config.query let (base_query, _) = config.query.trim().split_once("TO STDOUT (FORMAT binary)")
.trim()
.split_once("TO STDOUT (FORMAT binary)")
.context("Failed to parse base query from config")?; .context("Failed to parse base query from config")?;
let base_query_inner = base_query.trim().trim_start_matches("COPY (").trim_end_matches(")"); let base_query_inner = base_query.trim().trim_start_matches("COPY (").trim_end_matches(")");
let stats_query = format!( let stats_query = format!("SELECT MIN({}), MAX({}) FROM ({}) AS subquery", partition_key, partition_key, base_query_inner);
"SELECT MIN({}), MAX({}), COUNT(*) FROM ({}) AS subquery",
partition_key, partition_key, base_query_inner
);
println!("UncheckedIO: Running stats query: {}", stats_query);
let row = client.query_one(&stats_query, &[]).await?; let row = client.query_one(&stats_query, &[]).await?;
let min_id: i64 = row.try_get(0).context("Failed to get MIN(id)")?; let min_id: i64 = row.try_get(0).context("Failed to get MIN(id)")?;
let max_id: i64 = row.try_get(1).context("Failed to get MAX(id)")?; let max_id: i64 = row.try_get(1).context("Failed to get MAX(id)")?;
let count: i64 = row.try_get(2).context("Failed to get COUNT(*)")?; println!("UncheckedIO: ID Range: {} to {}", min_id, max_id);
if count == 0 {
println!("UncheckedIO: Table has no rows (COUNT=0). Returning empty batch.");
let arrow_schema = build_arrow_schema(&config)?;
// FIX 1: Wrap schema in Arc to satisfy SchemaRef
return Ok(RecordBatch::new_empty(Arc::new(arrow_schema)));
}
let chunk_size = (count as f64 / num_partitions as f64).ceil() as i64;
println!("UncheckedIO: Found {} rows. Creating {} partitions of ~{} rows each.", count, num_partitions, chunk_size);
// 4. Generate Partitioned Queries // 4. Generate Partitioned Queries
let mut partition_queries: Vec<String> = Vec::new(); struct PartitionTask {
for i in 0..num_partitions { index: usize,
let part_min = min_id + (i as i64 * chunk_size); query: String,
let part_max = (part_min + chunk_size - 1).min(max_id); expected_rows: usize
if part_min > max_id {
break;
}
let new_query = format!(
"COPY (SELECT * FROM ({}) AS sub WHERE {} BETWEEN {} AND {}) TO STDOUT (FORMAT binary)",
base_query_inner,
partition_key,
part_min,
part_max
);
partition_queries.push(new_query);
} }
println!("UncheckedIO: Generated {} parallel queries.", partition_queries.len()); let mut partitions: Vec<PartitionTask> = Vec::new();
let mut current_min = min_id;
let mut idx = 0;
while current_min <= max_id {
let current_max = (current_min + blast_radius - 1).min(max_id);
let new_query = format!(
"COPY (SELECT * FROM ({}) AS sub WHERE {} BETWEEN {} AND {}) TO STDOUT (FORMAT binary)",
base_query_inner, partition_key, current_min, current_max
);
// Estimate rows
let estimated_rows = (current_max - current_min + 1) as usize;
partitions.push(PartitionTask { index: idx, query: new_query, expected_rows: estimated_rows });
current_min += blast_radius;
idx += 1;
}
println!("UncheckedIO: Generated {} parallel partitions.", partitions.len());
// --- Phase 2: Parallel Execution --- // --- Phase 2: Parallel Execution ---
let arrow_schema = Arc::new(build_arrow_schema(&config)?); let arrow_schema = Arc::new(build_arrow_schema(&config)?);
let mut join_set = JoinSet::new(); let mut join_set = JoinSet::new();
for query in partition_queries { for task in partitions {
let worker_pg_config = pg_config.clone(); let worker_pg_config = pg_config.clone();
let worker_schema = arrow_schema.clone(); let worker_schema = arrow_schema.clone();
join_set.spawn(async move { join_set.spawn(async move {
let (worker_client, worker_connection) = worker_pg_config.connect(NoTls).await?; let worker_logic = async {
let (worker_client, worker_connection) = worker_pg_config.connect(NoTls).await?;
tokio::spawn(async move {
if let Err(e) = worker_connection.await { eprintln!("Worker connection error: {}", e); }
});
tokio::spawn(async move { let copy_stream = worker_client.copy_out(task.query.as_str()).await?;
if let Err(e) = worker_connection.await { let pinned_stream: Pin<Box<CopyOutStream>> = Box::pin(copy_stream);
eprintln!("Worker connection error: {}", e);
}
});
let copy_stream = worker_client.copy_out(query.as_str()).await?; parse_binary_stream(pinned_stream, worker_schema).await
let pinned_stream: Pin<Box<CopyOutStream>> = Box::pin(copy_stream); };
let record_batch = parse_binary_stream(pinned_stream, worker_schema).await?; let result = worker_logic.await;
(task.index, result, task.expected_rows)
Ok::<_, anyhow::Error>(record_batch)
}); });
} }
// --- Phase 3: Collect and Concatenate --- // --- Phase 3: Collect, Order, and Stitch ---
let mut batches: Vec<RecordBatch> = Vec::new(); let mut results: Vec<Option<RecordBatch>> = vec![None; idx];
while let Some(join_result) = join_set.join_next().await { while let Some(join_result) = join_set.join_next().await {
match join_result { // NOTE: In production, handle JoinError better than .context() panic
Ok(batch_result) => { let (index, parse_result, expected_rows) = join_result.context("Worker thread panic")?;
match batch_result {
Ok(batch) => { match parse_result {
if batch.num_rows() > 0 { Ok(batch) => {
batches.push(batch); results[index] = Some(batch.1);
} }
}, Err(e) => {
Err(e) => return Err(anyhow!("A worker task failed: {}", e)), eprintln!("UncheckedIO: Partition {} failed! Error: {}. Filling with NULLs.", index, e);
} let null_batch = create_null_batch(arrow_schema.clone(), expected_rows)?;
}, results[index] = Some(null_batch);
Err(e) => return Err(anyhow!("A tokio task failed to join: {}", e)), }
} }
} }
let batches: Vec<RecordBatch> = results.into_iter()
.filter_map(|b| b)
.collect();
if batches.is_empty() { if batches.is_empty() {
println!("UncheckedIO: All partitions returned empty. Returning empty batch.");
return Ok(RecordBatch::new_empty(arrow_schema)); return Ok(RecordBatch::new_empty(arrow_schema));
} }
// FIX 2: Removed the '?' after concat_batches so .context() applies to the Result let final_batch = concat_batches(&arrow_schema, &batches)
let final_batch = concat_batches(&batches[0].schema(), &batches)
.context("Failed to concatenate parallel batches")?; .context("Failed to concatenate parallel batches")?;
Ok(final_batch) Ok(final_batch)
} }
/// Helper function to build the Arrow Schema from the config fn create_null_batch(schema: Arc<Schema>, num_rows: usize) -> Result<RecordBatch> {
let columns: Vec<ArrayRef> = schema.fields().iter().map(|field| {
arrow::array::new_null_array(field.data_type(), num_rows)
}).collect();
RecordBatch::try_new(schema, columns).context("Failed to create null placeholder batch")
}
fn build_arrow_schema(config: &ConnectorConfig) -> Result<Schema> { fn build_arrow_schema(config: &ConnectorConfig) -> Result<Schema> {
let schema_fields: Vec<Field> = config.schema.iter().map(|col_cfg| { let schema_fields: Vec<Field> = config.schema.iter().map(|col_cfg| {
let nullable = col_cfg.column_name == "notes"; // Hack for MVP let nullable = col_cfg.column_name == "notes";
let arrow_type = match col_cfg.arrow_type.as_str() { let arrow_type = match col_cfg.arrow_type.as_str() {
"Int64" => DataType::Int64, "Int64" => DataType::Int64, "Int32" => DataType::Int32,
"Int32" => DataType::Int32, "Float64" => DataType::Float64, "Float32" => DataType::Float32,
"Float64" => DataType::Float64, "Utf8" | "String" => DataType::Utf8, "Boolean" => DataType::Boolean,
"Float32" => DataType::Float32,
"Utf8" | "String" => DataType::Utf8,
"Boolean" => DataType::Boolean,
"Timestamp(Nanosecond, None)" => DataType::Timestamp(arrow::datatypes::TimeUnit::Nanosecond, None), "Timestamp(Nanosecond, None)" => DataType::Timestamp(arrow::datatypes::TimeUnit::Nanosecond, None),
"Date32" => DataType::Date32, "Date32" => DataType::Date32,
_ => return Err(anyhow!("Unsupported type in config: {}", col_cfg.arrow_type)), _ => return Err(anyhow!("Unsupported type in config: {}", col_cfg.arrow_type)),
}; };
Ok(Field::new(&col_cfg.column_name, arrow_type, nullable)) Ok(Field::new(&col_cfg.column_name, arrow_type, nullable))
}).collect::<Result<Vec<Field>>>()?; }).collect::<Result<Vec<Field>>>()?;
Ok(Schema::new(schema_fields)) Ok(Schema::new(schema_fields))
} }
@@ -200,7 +184,7 @@ const UNIX_EPOCH_NAIVE_DATE: NaiveDate = NaiveDate::from_ymd_opt(1970, 1, 1).unw
async fn parse_binary_stream( async fn parse_binary_stream(
mut stream: Pin<Box<CopyOutStream>>, mut stream: Pin<Box<CopyOutStream>>,
arrow_schema: Arc<Schema> arrow_schema: Arc<Schema>
) -> Result<RecordBatch> { ) -> Result<(usize, RecordBatch)> {
let mut builders: Vec<DynamicBuilder> = arrow_schema.fields().iter().map(|field| { let mut builders: Vec<DynamicBuilder> = arrow_schema.fields().iter().map(|field| {
match field.data_type() { match field.data_type() {
@@ -218,68 +202,60 @@ async fn parse_binary_stream(
} }
}).collect(); }).collect();
let mut leftover_buffer: Vec<u8> = Vec::new(); let mut buffer = BytesMut::with_capacity(64 * 1024);
let mut is_header_parsed: bool = false; let mut is_header_parsed: bool = false;
let mut rows_processed: usize = 0; let mut rows_processed: usize = 0;
'stream_loop: while let Some(segment_result) = stream.next().await { 'stream_loop: while let Some(segment_result) = stream.next().await {
let segment: Bytes = segment_result.context("Error reading segment from CopyOutStream")?; let segment: Bytes = segment_result.context("Error reading segment from CopyOutStream")?;
buffer.extend_from_slice(&segment);
let mut current_chunk: Vec<u8> = mem::take(&mut leftover_buffer);
current_chunk.extend_from_slice(&segment);
let mut cursor = Cursor::new(&current_chunk[..]);
if !is_header_parsed { if !is_header_parsed {
if current_chunk.len() < 19 { if buffer.len() < 19 {
leftover_buffer = current_chunk;
continue 'stream_loop; continue 'stream_loop;
} }
let mut header_cursor = Cursor::new(&buffer[..]);
parse_stream_header(&mut cursor)?; parse_stream_header(&mut header_cursor)?;
buffer.advance(19);
is_header_parsed = true; is_header_parsed = true;
} }
'parsing_loop: loop { 'parsing_loop: loop {
let safe_position = cursor.position(); // NOTE: We create a cursor over the *entire* remaining buffer slice
let mut cursor = Cursor::new(&buffer[..]);
let col_count = match cursor.read_i16::<BigEndian>() { let col_count = match cursor.read_i16::<BigEndian>() {
Ok(count) => count, Ok(count) => count,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
cursor.set_position(safe_position);
leftover_buffer.extend_from_slice(&current_chunk[safe_position as usize..]);
break 'parsing_loop; break 'parsing_loop;
} }
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
}; };
if col_count == -1 { if col_count == -1 {
// println!("UncheckedIO: Worker reached end-of-stream trailer."); buffer.advance(2);
leftover_buffer.clear();
break 'stream_loop; break 'stream_loop;
} }
match parse_row(&mut cursor, &mut builders, &current_chunk) { // Pass the slice `buffer.as_ref()` explicitly for zero-copy logic
match parse_row(&mut cursor, &mut builders, buffer.as_ref()) {
Ok(_) => { Ok(_) => {
rows_processed += 1; rows_processed += 1;
let bytes_consumed = cursor.position();
buffer.advance(bytes_consumed as usize);
} }
// FIX 3: Error handling for std::io::Error
// We check .kind() directly because 'e' is std::io::Error
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
cursor.set_position(safe_position);
leftover_buffer.extend_from_slice(&current_chunk[safe_position as usize..]);
break 'parsing_loop; break 'parsing_loop;
} }
Err(e) => { Err(e) => {
// Explicitly convert std::io::Error to anyhow::Error
return Err(e.into()); return Err(e.into());
} }
} }
} }
} }
if !leftover_buffer.is_empty() { if !buffer.is_empty() {
return Err(anyhow!("Stream ended with leftover bytes ({}) but no trailer.", leftover_buffer.len())); return Err(anyhow!("Stream ended with leftover bytes ({}) but no trailer.", buffer.len()));
} }
let final_columns: Vec<ArrayRef> = builders.into_iter().map(|builder| { let final_columns: Vec<ArrayRef> = builders.into_iter().map(|builder| {
@@ -300,7 +276,7 @@ async fn parse_binary_stream(
final_columns, final_columns,
).context("Failed to create final Arrow RecordBatch")?; ).context("Failed to create final Arrow RecordBatch")?;
Ok(record_batch) Ok((rows_processed, record_batch))
} }
fn parse_stream_header(cursor: &mut Cursor<&[u8]>) -> Result<()> { fn parse_stream_header(cursor: &mut Cursor<&[u8]>) -> Result<()> {
@@ -311,7 +287,6 @@ fn parse_stream_header(cursor: &mut Cursor<&[u8]>) -> Result<()> {
} }
let _flags = cursor.read_u32::<BigEndian>().context("Failed to read flags")?; let _flags = cursor.read_u32::<BigEndian>().context("Failed to read flags")?;
let _header_ext_len = cursor.read_u32::<BigEndian>().context("Failed to read header extension length")?; let _header_ext_len = cursor.read_u32::<BigEndian>().context("Failed to read header extension length")?;
Ok(()) Ok(())
} }
@@ -321,7 +296,7 @@ fn parse_row(
current_chunk: &[u8] current_chunk: &[u8]
) -> Result<(), std::io::Error> { ) -> Result<(), std::io::Error> {
for (i, builder) in builders.iter_mut().enumerate() { for builder in builders.iter_mut() {
let field_len_i32 = cursor.read_i32::<BigEndian>()?; let field_len_i32 = cursor.read_i32::<BigEndian>()?;
if field_len_i32 == -1 { if field_len_i32 == -1 {
@@ -361,13 +336,26 @@ fn parse_row(
let val = cursor.read_f32::<BigEndian>()?; let val = cursor.read_f32::<BigEndian>()?;
b.append_value(val); b.append_value(val);
} }
// --- OPTIMIZATION START: ZERO-COPY STRING PARSING ---
DynamicBuilder::String(b) => { DynamicBuilder::String(b) => {
let mut str_buf = vec![0; field_len_usize]; // 1. Get current cursor position (start of string data)
cursor.read_exact(&mut str_buf)?; let start = cursor.position() as usize;
let val_str = str::from_utf8(&str_buf) let end = start + field_len_usize;
// 2. Slice the bytes directly from current_chunk (Zero-Copy)
// Safety: We already verified bounds check above.
let slice = &current_chunk[start..end];
// 3. Verify UTF-8 and append (still validates, but no allocation)
let val_str = str::from_utf8(slice)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
b.append_value(val_str); b.append_value(val_str);
// 4. Manually advance cursor since we didn't use read_exact
cursor.set_position(end as u64);
} }
// --- OPTIMIZATION END ---
DynamicBuilder::Boolean(b) => { DynamicBuilder::Boolean(b) => {
let val_bool = cursor.read_u8()?; let val_bool = cursor.read_u8()?;
b.append_value(val_bool != 0); b.append_value(val_bool != 0);