Architecture Overview¶
rypipe is built around one idea: separate the parts of parsing that depend
on a file format from the parts that do not. The format-specific side answers
where a row ends and how to extract fields; the format-agnostic side answers how
to store rows as typed columns, how to project them, and how to get them out as
Arrow.
This directory documents the architecture in depth: read this page first, then dive into the subpages for the component you care about.
Pages¶
| Page | Lines | Focus |
|---|---|---|
| Engine | TableBuilder: row handling, dirty tracking, column dispatch | |
| Columnar storage | StrColumn, ColumnBuilder variants, dictionary, promotions | |
| Execution plan | ExecutionPlan, FieldType, FilterPredicate trees | |
| Execution | Pipeline, Parallel, Bounded, InputBuffer | |
| Decoder API | Splitter, RecordParser, ColumnarSink | |
| Data flow | Diagrams for single, parallel, bounded modes | |
| Optimizations | Every optimization and why it matters | |
| Storage and export | Arrow export, null handling, compare filter |
Design philosophy¶
The engine was extracted from a single format (Crystal Reports XML) and generalized so that the same hot path serves every adapter. The split is deliberate and strict:
Adapter-bound (format specific, lives in adapter crates, you implement it): where does one row end, how do I extract field names and values, what is the encoding or entity rule. This code knows the file format and nothing about column storage.
Core (format agnostic, lives in rypipe crates, reused by every adapter):
how do I store rows as typed columns, how do I rename, drop, cast, filter,
and reorder, how do I export to Arrow, how do I parallelize while staying
inside a memory budget. This code knows the ingestion framework and nothing about
any file format.
Adapters live in separate packages. rypipe ships zero parsers. The boundary
is a trait, not a stringly convention.
High-level flow¶
input bytes
|
v
+---------------------+ (ADAPTER) format specific
| Splitter | find_split_points, estimate_bytes_per_row
+----------+----------+
| Vec<Range<usize>> (CORE) split_points_to_ranges
v
+---------------------+ (ADAPTER) format specific
| RecordParser | validate, parse_chunk
| | -> begin_row, put_field, end_row
+----------+----------+
| Value events (CORE) Value<'a>
v
+---------------------+ (CORE) format agnostic
| TableBuilder | ColumnarSink implementation
| (ColumnarSink) | columns: Vec<ColumnBuilder>
| | field_index: HashMap<String,usize>
| | row_dirty: Vec<u64>
| | plan: Arc<ExecutionPlan>
+----------+----------+
| RecordBatch (CORE) Arrow
v
+---------------------+ (CORE) format agnostic
| Arrow export | to_arrow_array, apply_compare_filter
+---------------------+
| pyarrow.Table / pandas / Polars
v
downstream
Ownership rule: Splitter and RecordParser never see ExecutionPlan or
TableBuilder internals. They emit plain names and Value events. The engine
resolves names via ExecutionPlan::resolve_field and chooses storage via
ExecutionPlan::column_type. This keeps adapters tiny and keeps all pushdown,
typing, and Arrow logic in one place.
Crate overview¶
rypipe-core (pure Rust, no pyo3)¶
| Module | Responsibility |
|---|---|
value |
Value<'a> enum: Str(Cow), Int64, Float64, Bool, Date32, Timestamp, Null |
plan |
ExecutionPlan, FieldType, FilterPredicate trees, CompareOp |
columnar |
StrColumn, ColumnBuilder (7 variants), dictionary, promotions |
engine |
TableBuilder with ColumnarSink impl, dirty bitmask, predicate-first |
decoder |
Splitter, RecordParser, ColumnarSink traits, plan_chunk_count |
pipeline |
Pipeline |
parallel |
ParallelExecutor: rayon, fast path vs merge path |
bounded |
BoundedExecutor, MemoryBudget, mmap-seek path |
input |
InputBuffer (Mmap/Owned), compression detection |
merge |
TableBuilder::extend, engines_to_record_batches |
arrow_export |
null_array, apply_compare_filter |
scan |
Portable byte-search primitives (find, find2, find_literal) |
bench |
Tier ladder, alloc_baseline, ParProfile (behind bench feature) |
schema |
FrozenSchema, DiscoveryOpts, layout_signature caching |
See Schema for the detailed architecture of schema handling.
| dict | SeedDict, unify_dictionaries, apply_remap |
| block_masks | SIMD 64-byte block delimiter scanning |
| parallel_stream | ParallelStreamingExecutor, discovery, ordered/unordered delivery |
| streaming | StreamingBatchIterator: channel-based pull iterator |
| consumer | BatchConsumer, CollectingConsumer, DiscardingConsumer |
| error | Error enum (Utf8, Io, Plan, Merge, Arrow), Result type alias |
rypipe-python (PyO3 bindings)¶
| Module | Responsibility |
|---|---|
lib.rs |
Extension module, exception types |
plan_kwargs.rs |
Python kwargs to ExecutionPlan conversion |
export.rs |
Arrow to PyArrow via C Data Interface |
State and ownership¶
TableBuilder owns three parallel structures:
- columns: Vec<ColumnBuilder> (dense storage)
- field_index: HashMap<String, usize> (name to Vec index)
- column_order: Vec<String> (output order)
A fourth row_dirty: Vec<u64> bitmask tracks touched columns. All are kept
in sync via ensure_column_idx, take_column, and extend.
Pipeline<S,P> is Clone where S: Splitter + Clone and P: RecordParser + Clone,
so the same pipeline can be reused across files and execution modes.
Testing strategy¶
Every optimization has a test that verifies correctness: - Splitter tests: monotonic points, coverage, comment/CDATA rejection. - Engine tests: extend, last-write-wins, rename, drop, filter, typed columns. - Columnar tests: push/pop, split_off, arrow export, dictionary upgrade. - Integration tests: whole-file parse == N-chunk parse for N in {1, 2, 7, 64}.
Next steps¶
- Engine for the hot path (row handling, dirty tracking, predicate-first)
- Columnar for storage internals (StrColumn, ColumnBuilder, dictionary)
- Execution for scheduling, parallelism, and bounded memory
- Decoder API for the trait boundary (Splitter, RecordParser, ColumnarSink)
- Data flow for diagrams of each execution mode
- Optimizations for every change from the original design
- Storage and export for Arrow type mapping and null handling
- Execution plan for pushdown plans and filter predicates
Key invariants¶
-
Adapter never sees ExecutionPlan. The boundary is a trait. Adapters emit plain names and
Valueevents; the engine resolves names and chooses storage. This keeps adapters tiny and all pushdown logic in one place. -
One hash per field in steady state.
field_index.get(name)inensure_column_idxis the only HashMap probe per field.resolve_fieldis called once per field when usingresolve+put_field_resolved. -
Row_dirty tracks touched columns. The bitmask is cleared in
finish_rowand kept in sync byensure_column_idx,take_column, andextend. It enables null-fill of only missing columns. -
Zero-copy Arrow export.
to_arrow_arrayusesmem::taketo move internal buffers into Arrow arrays. No data copying. -
Predicate-first evaluation. When a filter is active, fields are buffered until the predicate resolves. The engine switches to direct mode on Pass, discards on Fail.
-
Layout prediction. After the first row,
expect_slotenables memcmp-based field resolution, skipping attribute scan + hash lookup.
Design decisions¶
Why not HashMap?¶
The original design used a HashMap for column storage. This required two hash probes per field: one to check if the column exists, one to get a mutable reference. With dense Vec storage, the second probe becomes a bounds-checked array access. This saves ~100 ns per row for 10 fields.
Why a bitmask for null-fill?¶
The naive approach pushes None for every missing column. With 10 columns
where 8 are present, this wastes 80% of pushes. The bitmask tracks which
columns were touched, so finish_row only pushes None for the 2 missing
columns. The bitmask test is a single word load + bit test, cheaper than
a Vec push per column.
Why predicate-first?¶
For selective filters (e.g., 10% pass rate), most rows are rejected early. Without predicate-first, the engine pushes all fields into columns, then pops them on rejection. With predicate-first, the engine buffers fields until the predicate column arrives, evaluates, and either discards the buffer (Fail) or drains it to columns (Pass). This eliminates 90% of push/pop cycles for selective filters.
Why not override find_split_points?¶
The default implementation applies the measured 2 MiB chunk floor that
prevents sub-MB chunk collapse. Two adapters got this wrong: TSV collected
first K newlines (negative scaling), crxml scanned entire file for <!
(25% overhead). The default eliminates this bug class.