Execution: Pipeline, Parallel, Bounded, Input¶
crates/rypipe-core/src/pipeline.rs, parallel.rs, bounded.rs, input.rs plus merge.rs and plan.rs decide how bytes become batches.
Pipeline¶
S: Splitter + Clone and P: RecordParser + Clone so the pipeline can be reused across files and modes.
new(splitter, parser) -> SelfwithExecutionPlan::new()with_plan(plan) -> Selfreplaces the plan (builder chain)read_bytes(&self, bytes: &[u8]) -> Result<RecordBatch>createsTableBuilder::with_plan(bytes.len() / 512, plan), callsvalidatethenparse_chunkon the whole slice, thenfinish. Single thread, single batch.read_bytes_par(&self, bytes: &[u8], num_chunks: usize) -> Result<Vec<RecordBatch>>delegates toParallelExecutor::parse(bytes, &splitter, parser.clone(), plan, num_chunks)(no file IO)read_bytes_stream(&self, bytes: &[u8], budget: MemoryBudget) -> Result<Vec<RecordBatch>>delegates toBoundedExecutor::new(budget).run_bytes(bytes, &splitter, parser.clone(), plan)read_path(&self, path: impl AsRef<Path>, use_mmap: bool, prefault: bool) -> Result<RecordBatch>opensInputBuffer::open(path, use_mmap, prefault)and callsread_bytes(input.as_slice())read_path_par(&self, path, num_chunks, use_mmap, prefault) -> Result<Vec<RecordBatch>>opens and callsParallelExecutor::parse(input.as_slice(), ...)read_path_stream(&self, path, budget, prefault) -> Result<Vec<RecordBatch>>delegates toBoundedExecutor::run(path, &splitter, parser, plan, prefault)
All six methods share the same Splitter plus RecordParser plus ExecutionPlan. Tests in pipeline::tests use a LineSplitter and LineParser that split on \n and parse key=value tokens.
ParallelExecutor¶
crates/rypipe-core/src/parallel.rs:16 pub struct ParallelExecutor; with one associated function:
pub fn parse<P>(bytes: &[u8], splitter: &dyn Splitter, parser: P, plan: ExecutionPlan, num_chunks: usize) -> Result<Vec<RecordBatch>>
where P: RecordParser + Clone + Send + Sync
Steps:
-
splitter.find_split_points(bytes, num_chunks)thensplit_points_to_ranges(&points, bytes.len())to getVec<Range<usize>>. -
into_par_iterviarayonmaps eachRangetocatch_unwind(AssertUnwindSafe(|| { let mut sink = TableBuilder::with_plan(est, plan.clone()); parser.validate(&bytes[range])?; parser.parse_chunk(&bytes[range], &mut sink)?; Ok(sink) }))whereest = (range.len() / 512).max(64). Panics are caught and turned intoError::Merge("worker panicked during parallel parse: {msg}")by downcastingpayloadto&strorString. -
collect::<Result<Vec<TableBuilder>>>()joins. Ifenginesis empty, returnOk(vec![]). -
Fast path:
if !plan.auto_dict && schemas_consistent(&engines) { return engines_to_record_batches(engines, &plan) }
schemas_consistent builds base: HashMap<&str, &str> from first.field_index plus first.columns[idx].variant_key() and checks every other engine's field_index entries have the same variant_key. Missing columns are fine (null filled later). This allows int64 plus float64 to be considered inconsistent here (so merge path will promote), but string plus dictionary is also inconsistent and will promote in the fast path via unify_variants (not here). Actually schemas_consistent requires exact key equality, so int64 vs float64 fails and falls to merge path which also promotes; string vs dictionary also fails but fast path engines_to_record_batches handles promotion as well, so the fast path is still taken when auto_dict is false? Wait, schemas_consistent returning true requires exact match, so mixed string/dictionary would be false and go to merge path even though engines_to_record_batches could handle it. Current code does: fast path only if !auto_dict && schemas_consistent. That means string plus dictionary with auto_dict false but different variants will go to merge path (single batch) instead of fast path (multiple batches with unified schema). This is intentional to keep engines_to_record_batches as the unified schema path; the merge path also handles it but with single batch. The doc says fast path emits one batch per chunk with unified schema; merge path returns single merged batch. Both handle promotion, but fast path keeps chunked batches.
- Merge path:
let mut merged = TableBuilder::with_plan(engines.len().max(64) * 512, plan.clone()); for engine in engines { merged.extend(engine)?; } let batch = merged.finish()?; if let Some(filter) = plan.filter { return Ok(vec![apply_compare_filter(batch, filter)?]) }Noteapply_compare_filteris only applied here for the merged single batch; fast path applies it per batch insideengines_to_record_batches.
All row filters (Equal, NotEqual, Compare, and And/Or/Not trees) are evaluated per row during finish_row in both paths, so they never force the merge path.
BoundedExecutor¶
crates/rypipe-core/src/bounded.rs:14 MemoryBudget is bytes: usize with new and bytes().
BoundedExecutor { budget: MemoryBudget } has:
-
plan_chunks(&self, bytes: &[u8], splitter: &dyn Splitter) -> (Vec<Range<usize>>, usize, usize)estimatesbytes_per_row = splitter.estimate_bytes_per_row(bytes).max(1),total_rows_est = bytes.len() / bytes_per_row,rows_per_batch = (budget.bytes() / bytes_per_row).max(1).min(total_rows_est.max(1)),num_batches = (total_rows_est / rows_per_batch).max(1),split_points = splitter.find_split_points(bytes, num_batches.min(MAX_SPLIT_CHUNKS))whereMAX_SPLIT_CHUNKS = 256, thensplit_points_to_ranges. -
run_bytes<P>(&self, bytes: &[u8], splitter: &dyn Splitter, parser: P, plan: ExecutionPlan) -> Result<Vec<RecordBatch>>whereP: RecordParser + Clone + Send + Sync. For empty bytes returnsOk(vec![]). Otherwise it gets(chunks, rows_per_batch, bytes_per_row), createsbatch_engine = TableBuilder::with_plan(bytes_per_row.max(64), plan), then for eachchunkslices&bytes[chunk.start..chunk.end], creates a per chunkchunk_engine, callsvalidateandparse_chunk, extendsbatch_engineviaextend, tracksrows_in_batch, flushes whenrows_in_batch >= rows_per_batchviabatch_engine.finish()plusreset. At the end flushes remainder and callsapply_plan_filter(which appliesapply_compare_filteronly for pureCompareandAndtrees; other trees are no ops because per row is authoritative). -
run<P>(&self, path: &Path, splitter: &dyn Splitter, parser: P, plan: ExecutionPlan, prefault: bool) -> Result<Vec<RecordBatch>>opensInputBuffer::open(path, use_mmap = cfg(feature="mmap"), prefault). If the buffer isMmap, it callsrun_mappedwhich doesplan_chunkson the mapped slice, drops the mapping, then reopens the file withFile::openand for eachchunkdoesseekplusread_exactinto a freshVec<u8>, parses, and accumulates as above. This keeps RSS low for large files: the mapping is released before the parse loop, and only one chunk buffer is live at a time. If the buffer isOwned(including transparently decompressed), it delegates torun_bytes(input.as_slice(), ...). -
run_mappedis#[cfg(feature="mmap")]and takesinput: InputBufferby value (so the mapping is dropped afterplan_chunks). The file is reopened; chunk reads useSeekFrom::Start(chunk.start)plusread_exact.
MAX_SPLIT_CHUNKS = 256 is the internal safeguard: never request more than 256 split points even if budget would imply more batches; pathological bytes_per_row cannot explode per chunk overhead. Batches may still exceed budget when the required count exceeds the cap (documented).
InputBuffer¶
crates/rypipe-core/src/input.rs:36 enum InputBuffer { Mmap(MmapHandle), Owned(Vec<u8>) } where MmapHandle wraps memmap2::Mmap.
-
MmapHandle::new(file, prefault)maps the file and on Unix doesmmap.advise(WillNeed)ifprefaultelseSequential. -
detect_compression(path) -> Option<Compression>reads the first 4 bytes and matches magic:gzip1f 8b(2 bytes),zstd28 b5 2f fd,lz4frame04 22 4d 18. Each arm is#[cfg(feature = "gzip"/"zstd"/"lz4")]so detection only fires when the feature is enabled. No extension check, only magic. -
decompress(path, codec) -> Result<Vec<u8>>opens the file again and wraps it inflate2::read::GzDecoder,zstd::stream::read::Decoder, orlz4_flex::frame::FrameDecoderdepending on codec and feature, thenread_to_end. -
open(path: &Path, use_mmap: bool, prefault: bool) -> Result<Self>first callsdetect_compression; ifSome, returnsOwned(decompress(...)?)(so all execution modes operate on decompressed bytes). Otherwise, if#[cfg(feature="mmap")]anduse_mmap, returnsMmap; else reads viafs::readintoOwned. -
Cargo features:
gzip = ["dep:flate2"],zstd = ["dep:zstd"],lz4 = ["dep:lz4_flex"],compress-all = ["gzip","zstd","lz4"],mmap = ["dep:memmap2"]. Thezstdandlz4decoders are pure Rust when possible (flate2withrust_backend).
Merge¶
crates/rypipe-core/src/merge.rs:14 impl TableBuilder { extend, } plus engines_to_record_batches.
-
extend(&mut self, mut other: TableBuilder) -> Result<()>mergesotherintoself. Steps: (1) for each name inother.column_order.clone()where!self.field_index.contains_key(name), create a builderwith_capacity(est, &col_type)whereest = self_rows + other.estimated_rows.max(64), backfillself_rowsnulls, push tocolumns, insert tofield_index, push false torow_dirty, insert intocolumn_orderatschema_insert_index. (2) snapshotorder_snapshot = self.column_order.clone(), then for eachnameinorder_snapshotgetself_idxviafield_index, takeself_b = &mut columns[self_idx], tryother.take_column(name); ifSome, checkvariant_keyequality, callunify_variantsif different (string plus dictionary to dictionary, int64 plus float64 to float64 elseError::Mergewith column name and hint to providefield_types), thenpromote_to_varianton both, thenextend_owned; else null padother_rowstimes. Finallyrow_count = self_rows + other_rows. -
engines_to_record_batches(mut engines: Vec<TableBuilder>, plan: &ExecutionPlan) -> Result<Vec<RecordBatch>>exports per chunk builders without serial merge. It normalizes and retainsrow_count > 0, buildsorderplustargets: HashMap<String, &'static str>viaget_columnandunify_variantsfolding, promotes each builder's columns to the unified variant, buildstypes: HashMap<String, DataType>from first sightingarrow_datatype, createsSchema, thenpar_iterover engines to buildarraysperorder(viaget_columnornull_arrayfor missing),RecordBatch::try_new, collects viarayon, then appliesapply_compare_filterper batch ifplan.filteris Some.
Arrow export¶
crates/rypipe-core/src/arrow_export.rs null_array, apply_compare_filter, compare_columns, is_numeric.
apply_compare_filter(batch, predicate)is only for pureCompareandAndtrees (checked viais_pure_compare_tree). Other trees returnOk(batch)unchanged because per row is authoritative. For pure trees, it builds a mask viacompare_mask(recursingAndwithandkernel) andcompare_columns(casts both toFloat64if numeric elseUtf8, then Arrowgt,lt,gt_eq,lt_eq,eq,neq), thenfilter_record_batch.
See also Engine for TableBuilder::finish and Arrow to_arrow_array details per ColumnBuilder variant.